From f1592e40ebd6793504d86c4383547b153cdcf8d0 Mon Sep 17 00:00:00 2001 From: Harald Kirschner Date: Sun, 15 Feb 2026 14:57:54 -0800 Subject: [PATCH] feat: add Windows Copilot CLI support with cliArgs and .bat/.cmd handling --- src/services/copilot.ts | 128 +++++++++++++++++++++++------------ src/services/evalScaffold.ts | 4 +- src/services/evaluator.ts | 4 +- src/services/instructions.ts | 10 ++- 4 files changed, 91 insertions(+), 55 deletions(-) diff --git a/src/services/copilot.ts b/src/services/copilot.ts index 3ec951e..2816803 100644 --- a/src/services/copilot.ts +++ b/src/services/copilot.ts @@ -7,48 +7,95 @@ import fg from "fast-glob"; const execFileAsync = promisify(execFile); -let cachedCliPath: string | null = null; -let cachedCliPathTimestamp = 0; +export type CopilotCliConfig = { + cliPath: string; + cliArgs?: string[]; +}; + +let cachedCliConfig: CopilotCliConfig | null = null; +let cachedCliConfigTimestamp = 0; const CLI_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes -export async function assertCopilotCliReady(): Promise { - const cliPath = await findCopilotCliPath(); +function cacheConfig(config: CopilotCliConfig): CopilotCliConfig { + cachedCliConfig = config; + cachedCliConfigTimestamp = Date.now(); + return config; +} + +export async function assertCopilotCliReady(): Promise { + const config = await findCopilotCliConfig(); try { - await execFileAsync(cliPath, ["--version"], { timeout: 5000 }); + const [cmd, args] = buildExecArgs(config, ["--version"]); + await execFileAsync(cmd, args, { timeout: 5000 }); } catch { - throw new Error(`Copilot CLI at ${cliPath} is not working.`); + const desc = config.cliArgs ? `${config.cliPath} ${config.cliArgs.join(" ")}` : config.cliPath; + throw new Error(`Copilot CLI at ${desc} is not working.`); } - return cliPath; + return config; } export async function listCopilotModels(): Promise { - const cliPath = await assertCopilotCliReady(); - const { stdout } = await execFileAsync(cliPath, ["--help"], { timeout: 5000 }); + const config = await assertCopilotCliReady(); + const [cmd, args] = buildExecArgs(config, ["--help"]); + const { stdout } = await execFileAsync(cmd, args, { timeout: 5000 }); return extractModelChoices(stdout); } -async function findCopilotCliPath(): Promise { - if (cachedCliPath && Date.now() - cachedCliPathTimestamp < CLI_CACHE_TTL_MS) { - return cachedCliPath; +function buildExecArgs(config: CopilotCliConfig, extraArgs: string[]): [string, string[]] { + if (config.cliArgs && config.cliArgs.length > 0) { + return [config.cliPath, [...config.cliArgs, ...extraArgs]]; + } + if ( + process.platform === "win32" && + (config.cliPath.endsWith(".bat") || config.cliPath.endsWith(".cmd")) + ) { + return ["cmd", ["/c", config.cliPath, ...extraArgs]]; + } + return [config.cliPath, extraArgs]; +} + +async function findCopilotCliConfig(): Promise { + if (cachedCliConfig && Date.now() - cachedCliConfigTimestamp < CLI_CACHE_TTL_MS) { + return cachedCliConfig; + } + + const isWindows = process.platform === "win32"; + const home = process.env.HOME ?? process.env.USERPROFILE ?? ""; + const appData = process.env.APPDATA ?? ""; + + // On Windows, prefer npm-installed binary and use node + cliArgs approach. + // This bypasses .cmd/.bat wrapper issues that prevent direct spawning. + // See: https://github.com/microsoft/vscode/issues/291990 + if (isWindows && appData) { + const npmLoaderPath = path.join( + appData, + "npm", + "node_modules", + "@github", + "copilot", + "npm-loader.js" + ); + try { + await fs.access(npmLoaderPath); + return cacheConfig({ cliPath: process.execPath, cliArgs: [npmLoaderPath] }); + } catch { + // npm binary not found, will try PATH and VS Code locations + } } - // Try PATH lookup first (works on all platforms) - const whichCmd = process.platform === "win32" ? "where" : "which"; + const whichCmd = isWindows ? "where" : "which"; try { const { stdout } = await execFileAsync(whichCmd, ["copilot"], { timeout: 5000 }); const found = stdout.trim().split(/\r?\n/)[0]; if (found) { - cachedCliPath = found; - cachedCliPathTimestamp = Date.now(); - return found; + return cacheConfig({ cliPath: found }); } } catch { - // Ignore - will try VS Code locations + // Not on PATH, will try VS Code locations } - const home = process.env.HOME ?? process.env.USERPROFILE ?? ""; const staticLocations: string[] = []; if (process.platform === "darwin") { @@ -61,53 +108,44 @@ async function findCopilotCliPath(): Promise { `${home}/.config/Code - Insiders/User/globalStorage/github.copilot-chat/copilotCli/copilot`, `${home}/.config/Code/User/globalStorage/github.copilot-chat/copilotCli/copilot` ); - } else if (process.platform === "win32") { - const appData = process.env.APPDATA ?? ""; - if (appData) { - staticLocations.push( - `${appData}\\Code - Insiders\\User\\globalStorage\\github.copilot-chat\\copilotCli\\copilot.exe`, - `${appData}\\Code\\User\\globalStorage\\github.copilot-chat\\copilotCli\\copilot.exe` - ); - } + } else if (isWindows && appData) { + staticLocations.push( + `${appData}\\Code - Insiders\\User\\globalStorage\\github.copilot-chat\\copilotCli\\copilot.bat`, + `${appData}\\Code\\User\\globalStorage\\github.copilot-chat\\copilotCli\\copilot.bat` + ); } for (const location of staticLocations) { try { await fs.access(location); - cachedCliPath = location; - cachedCliPathTimestamp = Date.now(); - return location; + return cacheConfig({ cliPath: location }); } catch { - // Try next location + // Try next } } - const ext = process.platform === "win32" ? ".exe" : ""; + const exts = isWindows ? "{.exe,.bat,.cmd}" : ""; const normalizedHome = home.replace(/\\/g, "/"); const globPatterns = [ - `${normalizedHome}/.vscode-insiders/extensions/github.copilot-chat-*/copilotCli/copilot${ext}`, - `${normalizedHome}/.vscode/extensions/github.copilot-chat-*/copilotCli/copilot${ext}` + `${normalizedHome}/.vscode-insiders/extensions/github.copilot-chat-*/copilotCli/copilot${exts}`, + `${normalizedHome}/.vscode/extensions/github.copilot-chat-*/copilotCli/copilot${exts}` ]; for (const pattern of globPatterns) { const matches = await fg(pattern, { onlyFiles: true }); if (matches.length > 0) { - const normalized = path.normalize(matches[0]); - cachedCliPath = normalized; - cachedCliPathTimestamp = Date.now(); - return normalized; + return cacheConfig({ cliPath: path.normalize(matches[0]) }); } } - const platformHint = - process.platform === "win32" - ? " Searched APPDATA and VS Code extension paths." - : process.platform === "linux" - ? " Searched ~/.config/Code and VS Code extension paths." - : " Searched ~/Library/Application Support/Code and VS Code extension paths."; + const platformHint = isWindows + ? " Searched APPDATA and VS Code extension paths." + : process.platform === "linux" + ? " Searched ~/.config/Code and VS Code extension paths." + : " Searched ~/Library/Application Support/Code and VS Code extension paths."; throw new Error( - `Copilot CLI not found. Install GitHub Copilot Chat extension in VS Code.${platformHint}` + `Copilot CLI not found. Install GitHub Copilot Chat extension in VS Code or run: npm install -g @github/copilot.${platformHint}` ); } diff --git a/src/services/evalScaffold.ts b/src/services/evalScaffold.ts index 8da2e18..2890ba9 100644 --- a/src/services/evalScaffold.ts +++ b/src/services/evalScaffold.ts @@ -39,11 +39,11 @@ export async function generateEvalScaffold(options: EvalScaffoldOptions): Promis return withCwd(repoPath, async () => { progress("Checking Copilot CLI..."); - const cliPath = await assertCopilotCliReady(); + const cliConfig = await assertCopilotCliReady(); progress("Starting Copilot SDK..."); const sdk = await import("@github/copilot-sdk"); - const client = new sdk.CopilotClient({ cliPath }); + const client = new sdk.CopilotClient(cliConfig); try { progress("Creating session..."); diff --git a/src/services/evaluator.ts b/src/services/evaluator.ts index db3f195..5ba873d 100644 --- a/src/services/evaluator.ts +++ b/src/services/evaluator.ts @@ -96,9 +96,9 @@ export async function runEval( const runStartedAt = Date.now(); progress("Starting Copilot SDK..."); - const cliPath = await assertCopilotCliReady(); + const cliConfig = await assertCopilotCliReady(); const sdk = await import("@github/copilot-sdk"); - const client = new sdk.CopilotClient({ cliPath }); + const client = new sdk.CopilotClient(cliConfig); try { const results: EvalResult[] = []; diff --git a/src/services/instructions.ts b/src/services/instructions.ts index 6a4310f..7742ef9 100644 --- a/src/services/instructions.ts +++ b/src/services/instructions.ts @@ -24,13 +24,11 @@ export async function generateCopilotInstructions( return withCwd(repoPath, async () => { progress("Checking Copilot CLI..."); - const cliPath = await assertCopilotCliReady(); + const cliConfig = await assertCopilotCliReady(); progress("Starting Copilot SDK..."); const sdk = await import("@github/copilot-sdk"); - const client = new sdk.CopilotClient({ - cliPath - }); + const client = new sdk.CopilotClient(cliConfig); try { progress("Creating session..."); @@ -113,11 +111,11 @@ export async function generateAreaInstructions( return withCwd(repoPath, async () => { progress(`Checking Copilot CLI for area "${area.name}"...`); - const cliPath = await assertCopilotCliReady(); + const cliConfig = await assertCopilotCliReady(); progress(`Starting Copilot SDK for area "${area.name}"...`); const sdk = await import("@github/copilot-sdk"); - const client = new sdk.CopilotClient({ cliPath }); + const client = new sdk.CopilotClient(cliConfig); try { const applyToPatterns = Array.isArray(area.applyTo) ? area.applyTo : [area.applyTo];