From 3067d39f12b4c03f640ee596827537aa8b944540 Mon Sep 17 00:00:00 2001 From: Cyctes-X <100024333+Cyctes-X@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:40:00 +0800 Subject: [PATCH] fix: spawn cursor-agent with shell mode on Windows On Windows, cursor-agent is a .cmd shim. Node's execFileSync cannot execute a bare .cmd file and fails with EINVAL, so model discovery silently fell back to a static 20-model list (missing newly released Cursor models) and 'open-cursor doctor' misreported cursor-agent as not installed. - discoverModelsFromCursorAgent(): use shell mode on win32 via an injectable deps object (platform/execFileSync/resolveBinary), matching the existing binary.ts contract that Windows callers pair the resolver with shell mode - checkCursorAgent()/checkCursorAgentLogin(): same win32 shell mode so doctor checks correctly resolve cursor-agent - add unit tests asserting shell mode is used on win32 and not used elsewhere --- src/cli/model-discovery.ts | 19 +++++++++-- src/cli/opencode-cursor.ts | 6 +++- tests/unit/cli/model-discovery.test.ts | 44 +++++++++++++++++++++++++- 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/cli/model-discovery.ts b/src/cli/model-discovery.ts index f5ced4e..62decef 100644 --- a/src/cli/model-discovery.ts +++ b/src/cli/model-discovery.ts @@ -31,10 +31,23 @@ export function parseCursorModelsOutput(output: string): DiscoveredModel[] { return models; } -export function discoverModelsFromCursorAgent(): DiscoveredModel[] { - const raw = execFileSync(resolveCursorAgentBinary(), ["models"], { +export type DiscoverDeps = { + platform?: NodeJS.Platform; + execFileSync?: typeof execFileSync; + resolveBinary?: () => string; +}; + +export function discoverModelsFromCursorAgent(deps: DiscoverDeps = {}): DiscoveredModel[] { + const platform = deps.platform ?? process.platform; + const exec = deps.execFileSync ?? execFileSync; + const resolveBinary = deps.resolveBinary ?? resolveCursorAgentBinary; + + // On Windows cursor-agent is a .cmd shim, which requires shell mode when + // spawned from Node (execFileSync of a bare .cmd fails with EINVAL). + const raw = exec(resolveBinary(), ["models"], { encoding: "utf8", - ...(process.platform !== "win32" && { killSignal: "SIGTERM" as const }), + shell: platform === "win32", + ...(platform !== "win32" && { killSignal: "SIGTERM" as const }), stdio: ["ignore", "pipe", "pipe"], timeout: MODEL_DISCOVERY_TIMEOUT_MS, }); diff --git a/src/cli/opencode-cursor.ts b/src/cli/opencode-cursor.ts index 3dbca9d..f721055 100644 --- a/src/cli/opencode-cursor.ts +++ b/src/cli/opencode-cursor.ts @@ -116,7 +116,10 @@ export function checkBun(): CheckResult { export function checkCursorAgent(): CheckResult { try { - const output = execFileSync(resolveCursorAgentBinary(), ["--version"], { encoding: "utf8" }).trim(); + const output = execFileSync(resolveCursorAgentBinary(), ["--version"], { + encoding: "utf8", + shell: process.platform === "win32", + }).trim(); const version = output.split("\n")[0] || "installed"; return { name: "cursor-agent", passed: true, message: version }; } catch { @@ -134,6 +137,7 @@ export function checkCursorAgentLogin(): CheckResult { // Try running a command that requires auth execFileSync(resolveCursorAgentBinary(), ["models"], { encoding: "utf8", + shell: process.platform === "win32", stdio: ["ignore", "pipe", "pipe"], timeout: 3000, }); diff --git a/tests/unit/cli/model-discovery.test.ts b/tests/unit/cli/model-discovery.test.ts index 5b2cae5..5b3701a 100644 --- a/tests/unit/cli/model-discovery.test.ts +++ b/tests/unit/cli/model-discovery.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "bun:test"; -import { parseCursorModelsOutput } from "../../../src/cli/model-discovery.js"; +import { + discoverModelsFromCursorAgent, + parseCursorModelsOutput, +} from "../../../src/cli/model-discovery.js"; describe("cli/model-discovery", () => { it("parses model ids and names from cursor-agent output", () => { @@ -28,3 +31,42 @@ auto - Auto expect(models).toEqual([{ id: "auto", name: "Auto" }]); }); }); + +describe("cli/model-discovery discoverModelsFromCursorAgent", () => { + it("runs cursor-agent with shell mode on Windows", () => { + const calls: Array<{ cmd: string; args: string[]; opts: any }> = []; + const exec = (cmd: string, args: string[], opts: any) => { + calls.push({ cmd, args, opts }); + return "auto - Auto\n"; + }; + + const models = discoverModelsFromCursorAgent({ + platform: "win32", + execFileSync: exec as any, + resolveBinary: () => "C:\\cursor-agent\\cursor-agent.cmd", + }); + + expect(models).toEqual([{ id: "auto", name: "Auto" }]); + expect(calls).toHaveLength(1); + expect(calls[0].cmd).toBe("C:\\cursor-agent\\cursor-agent.cmd"); + expect(calls[0].args).toEqual(["models"]); + expect(calls[0].opts.shell).toBe(true); + }); + + it("does not use shell mode on non-Windows platforms", () => { + const calls: Array<{ cmd: string; args: string[]; opts: any }> = []; + const exec = (cmd: string, args: string[], opts: any) => { + calls.push({ cmd, args, opts }); + return "auto - Auto\n"; + }; + + discoverModelsFromCursorAgent({ + platform: "linux", + execFileSync: exec as any, + resolveBinary: () => "/usr/local/bin/cursor-agent", + }); + + expect(calls[0].opts.shell).toBe(false); + expect(calls[0].opts.killSignal).toBe("SIGTERM"); + }); +});