-
Notifications
You must be signed in to change notification settings - Fork 0
Bundle GitHub Copilot CLI for zero-config installation #14
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 7 commits
66a0ca8
ce24f4c
060f493
e19a226
68215bf
2b2f4d5
128db98
eaa681b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises'; | |
| import { join } from 'node:path'; | ||
| import { existsSync } from 'node:fs'; | ||
| import type { ChatMessage } from '../models/plan.js'; | ||
| import { locateCopilotCli, type CliInfo } from '../utils/cli-locator.js'; | ||
|
|
||
| const SETTINGS_PATH = join(process.cwd(), '.planeteer', 'settings.json'); | ||
|
|
||
|
|
@@ -78,14 +79,69 @@ export function getModelLabel(): string { | |
|
|
||
| let client: CopilotClient | null = null; | ||
| let clientPromise: Promise<CopilotClient> | null = null; | ||
| let cliLocation: CliInfo | null = null; | ||
|
|
||
| /** Initialize CLI location info early (doesn't start the client). */ | ||
| export function initCliInfo(): void { | ||
| if (!cliLocation) { | ||
| const location = locateCopilotCli(); | ||
| if (location) { | ||
| cliLocation = location; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** Get information about the CLI being used. */ | ||
| export function getCliInfo(): CliInfo | null { | ||
| // Initialize on first access if not already done | ||
| if (!cliLocation) { | ||
| initCliInfo(); | ||
| } | ||
| return cliLocation; | ||
| } | ||
|
|
||
| export async function getClient(): Promise<CopilotClient> { | ||
| if (client) return client; | ||
| if (clientPromise) return clientPromise; | ||
|
|
||
| clientPromise = (async () => { | ||
| const c = new CopilotClient(); | ||
| await c.start(); | ||
| // Initialize CLI location if not already done | ||
| initCliInfo(); | ||
|
|
||
| // Use the cached location | ||
| if (!cliLocation) { | ||
| throw new Error( | ||
| 'GitHub Copilot CLI not found.\n\n' + | ||
| 'The bundled CLI should be automatically available, but it appears to be missing.\n' + | ||
| 'Please try:\n' + | ||
| ' 1. Reinstalling dependencies: npm install\n' + | ||
| ' 2. Installing the CLI globally: npm install -g @github/copilot\n\n' + | ||
| 'If the problem persists, please report this issue.' | ||
| ); | ||
| } | ||
|
|
||
| // Create client with the located CLI path | ||
| const c = new CopilotClient({ | ||
| cliPath: cliLocation.path, | ||
| }); | ||
|
|
||
| try { | ||
| await c.start(); | ||
| } catch (err) { | ||
| const message = (err as Error).message || 'Unknown error'; | ||
| throw new Error( | ||
| `Failed to start GitHub Copilot CLI.\n\n` + | ||
| `Error: ${message}\n\n` + | ||
| `The CLI was found at: ${cliLocation.path}\n` + | ||
| `Version: ${cliLocation.version}\n` + | ||
| `Source: ${cliLocation.source}\n\n` + | ||
| `Please ensure you have:\n` + | ||
| ` 1. Authenticated with GitHub Copilot (the CLI will prompt you)\n` + | ||
| ` 2. Active GitHub Copilot subscription\n` + | ||
| ` 3. Proper permissions to execute the CLI binary` | ||
| ); | ||
|
Comment on lines
130
to
143
|
||
| } | ||
|
|
||
| client = c; | ||
| return c; | ||
| })(); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import { describe, it, expect } from 'vitest'; | ||
| import { locateCopilotCli } from './cli-locator.js'; | ||
|
|
||
| describe('cli-locator', () => { | ||
| it('should locate a Copilot CLI binary', () => { | ||
| const location = locateCopilotCli(); | ||
|
|
||
| // Should find either bundled or system CLI | ||
| expect(location).toBeTruthy(); | ||
|
|
||
| if (location) { | ||
| expect(location.path).toBeTruthy(); | ||
| expect(['bundled', 'system']).toContain(location.source); | ||
| expect(location.version).toBeTruthy(); | ||
| } | ||
|
||
| }); | ||
|
|
||
| it('should return valid CLI info when found', () => { | ||
| const location = locateCopilotCli(); | ||
|
|
||
| if (location) { | ||
| // Verify the location has valid properties | ||
| expect(location.source).toMatch(/^(bundled|system)$/); | ||
| expect(location.path).toBeTruthy(); | ||
| expect(location.version).toBeTruthy(); | ||
| } | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,155 @@ | ||||||||||||||||||||||||||||||||||||||||
| import { existsSync } from 'node:fs'; | ||||||||||||||||||||||||||||||||||||||||
| import { join, dirname } from 'node:path'; | ||||||||||||||||||||||||||||||||||||||||
| import { fileURLToPath } from 'node:url'; | ||||||||||||||||||||||||||||||||||||||||
| import { execFileSync } from 'node:child_process'; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| export interface CliInfo { | ||||||||||||||||||||||||||||||||||||||||
| path: string; | ||||||||||||||||||||||||||||||||||||||||
| version: string; | ||||||||||||||||||||||||||||||||||||||||
| source: 'bundled' | 'system'; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||
| * Locate the bundled Copilot CLI binary. | ||||||||||||||||||||||||||||||||||||||||
| * Returns the path if found, otherwise null. | ||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||
| function findBundledCli(): string | null { | ||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||
| // Try to resolve the platform-specific package | ||||||||||||||||||||||||||||||||||||||||
| const platform = process.platform; | ||||||||||||||||||||||||||||||||||||||||
| const arch = process.arch; | ||||||||||||||||||||||||||||||||||||||||
| const packageName = `@github/copilot-${platform}-${arch}`; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // Attempt to resolve via import.meta.resolve | ||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||
| const resolved = import.meta.resolve(packageName); | ||||||||||||||||||||||||||||||||||||||||
| const packagePath = fileURLToPath(resolved); | ||||||||||||||||||||||||||||||||||||||||
| const binaryDir = dirname(packagePath); | ||||||||||||||||||||||||||||||||||||||||
| const binaryName = platform === 'win32' ? 'copilot.exe' : 'copilot'; | ||||||||||||||||||||||||||||||||||||||||
| const binaryPath = join(binaryDir, binaryName); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| if (existsSync(binaryPath)) { | ||||||||||||||||||||||||||||||||||||||||
| return binaryPath; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||
| // If import.meta.resolve fails, try manual path construction | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // Fallback: Construct path from this file's location | ||||||||||||||||||||||||||||||||||||||||
| // Assuming this file is in src/utils/ and node_modules is at repo root | ||||||||||||||||||||||||||||||||||||||||
| const currentFile = fileURLToPath(import.meta.url); | ||||||||||||||||||||||||||||||||||||||||
| const repoRoot = join(dirname(currentFile), '..', '..'); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // Try node_modules location | ||||||||||||||||||||||||||||||||||||||||
| const nodeModulesPath = join( | ||||||||||||||||||||||||||||||||||||||||
| repoRoot, | ||||||||||||||||||||||||||||||||||||||||
| 'node_modules', | ||||||||||||||||||||||||||||||||||||||||
| '@github', | ||||||||||||||||||||||||||||||||||||||||
| `copilot-${platform}-${arch}`, | ||||||||||||||||||||||||||||||||||||||||
| platform === 'win32' ? 'copilot.exe' : 'copilot' | ||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| if (existsSync(nodeModulesPath)) { | ||||||||||||||||||||||||||||||||||||||||
| return nodeModulesPath; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // Try prebuilds location (legacy structure) | ||||||||||||||||||||||||||||||||||||||||
| const prebuildsPath = join( | ||||||||||||||||||||||||||||||||||||||||
| repoRoot, | ||||||||||||||||||||||||||||||||||||||||
| 'node_modules', | ||||||||||||||||||||||||||||||||||||||||
| '@github', | ||||||||||||||||||||||||||||||||||||||||
| 'copilot', | ||||||||||||||||||||||||||||||||||||||||
| 'prebuilds', | ||||||||||||||||||||||||||||||||||||||||
| `${platform}-${arch}`, | ||||||||||||||||||||||||||||||||||||||||
| platform === 'win32' ? 'copilot.exe' : 'copilot' | ||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| if (existsSync(prebuildsPath)) { | ||||||||||||||||||||||||||||||||||||||||
| return prebuildsPath; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||
| // Ignore errors | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||
| * Find the system-installed Copilot CLI. | ||||||||||||||||||||||||||||||||||||||||
| * Returns the path if found, otherwise null. | ||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||
| function findSystemCli(): string | null { | ||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||
| const executable = process.platform === 'win32' ? 'where' : 'which'; | ||||||||||||||||||||||||||||||||||||||||
| const result = execFileSync(executable, ['copilot'], { | ||||||||||||||||||||||||||||||||||||||||
| encoding: 'utf-8', | ||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||
| const path = result.trim().split('\n')[0]; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| if (path && existsSync(path)) { | ||||||||||||||||||||||||||||||||||||||||
| return path; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||
| // Ignore errors - CLI not in PATH | ||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
| }); | |
| const path = result.trim().split('\n')[0]; | |
| if (path && existsSync(path)) { | |
| return path; | |
| } | |
| } catch { | |
| // Ignore errors - CLI not in PATH | |
| // Prevent PATH lookups from hanging indefinitely | |
| timeout: 2000, | |
| }); | |
| const path = result.trim().split('\n')[0]; | |
| if (path && existsSync(path)) { | |
| return path; | |
| } | |
| } catch (error) { | |
| // On timeout or lookup errors, treat as "CLI not in PATH" | |
| return null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in eaa681b - added 2-second timeout to prevent indefinite blocking on PATH lookups from network-mounted entries or misconfigured shell hooks.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This comment says "Load CLI info asynchronously", but
getCliInfo()is synchronous and can perform blocking work (process execution) under the hood. Either update the comment to match reality or move the CLI lookup off the render path (e.g., defer viasetTimeout/Promise.resolve().then(...)and thensetCliInfo).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in eaa681b - moved CLI lookup off the render path using
Promise.resolve().then()to defer the blocking work asynchronously.