Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 83 additions & 45 deletions src/services/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
const cliPath = await findCopilotCliPath();
function cacheConfig(config: CopilotCliConfig): CopilotCliConfig {
cachedCliConfig = config;
cachedCliConfigTimestamp = Date.now();
return config;
}

export async function assertCopilotCliReady(): Promise<CopilotCliConfig> {
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<string[]> {
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<string> {
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<CopilotCliConfig> {
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") {
Expand All @@ -61,53 +108,44 @@ async function findCopilotCliPath(): Promise<string> {
`${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}`
);
}

Expand Down
4 changes: 2 additions & 2 deletions src/services/evalScaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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...");
Expand Down
4 changes: 2 additions & 2 deletions src/services/evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down
10 changes: 4 additions & 6 deletions src/services/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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...");
Expand Down Expand Up @@ -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];
Expand Down
Loading