diff --git a/.changeset/fn-7418-cursor-cli-windows-cmd.md b/.changeset/fn-7418-cursor-cli-windows-cmd.md new file mode 100644 index 0000000000..b6b8ea53ab --- /dev/null +++ b/.changeset/fn-7418-cursor-cli-windows-cmd.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Detect Cursor CLI installations that expose Windows cmd or bat shims. +category: fix +dev: Cursor runtime probes and model discovery now shell-spawn only on Windows and preserve spawn diagnostics. diff --git a/docs/cursor-cli-contract.md b/docs/cursor-cli-contract.md index 89676465ea..c2e41db121 100644 --- a/docs/cursor-cli-contract.md +++ b/docs/cursor-cli-contract.md @@ -26,6 +26,23 @@ Date: 2026-05-07 3. Persist the resolved path and executable name in probe results. 4. Report explicit failure reason when neither exists. +### Windows PATH shim invocation + + + +On Windows, `cursor-agent` and `cursor` can resolve to `.cmd` / `.bat` wrappers rather than native executables. Node.js direct `spawn(binary, args)` does not execute those wrappers reliably; Fusion's Cursor command runner therefore sets shell execution only when `process.platform === "win32"`. + +The Windows shell-backed path applies to every Cursor CLI command Fusion currently runs through the shared runner: + +- `cursor-agent --version` / `cursor --version` probe attempts. +- Model discovery attempts: `models --json`, `model list --json`, and `models`. + +Non-Windows probes and discovery continue to use direct spawn. Spawn errors such as `ENOENT` are included in the unavailable probe reason in bounded diagnostic form so a working terminal command is distinguishable from known Cursor runtime/auth states. + ## Confirmed error/auth/runtime signals Observed command behavior in this environment: diff --git a/plugins/fusion-plugin-cursor-runtime/src/__tests__/cli-spawn.test.ts b/plugins/fusion-plugin-cursor-runtime/src/__tests__/cli-spawn.test.ts new file mode 100644 index 0000000000..d668205c85 --- /dev/null +++ b/plugins/fusion-plugin-cursor-runtime/src/__tests__/cli-spawn.test.ts @@ -0,0 +1,103 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("node:child_process", () => ({ spawn: vi.fn() })); + +import { spawn } from "node:child_process"; +import { runCursorCommand } from "../cli-spawn.js"; + +function mockPlatform(platform: NodeJS.Platform) { + return vi.spyOn(process, "platform", "get").mockReturnValue(platform); +} + +function createMockChild() { + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough; + stderr: PassThrough; + kill: ReturnType; + }; + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = vi.fn(); + vi.mocked(spawn).mockReturnValue(child as never); + return child; +} + +describe("runCursorCommand", () => { + beforeEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("uses the Windows shell so PATH .cmd and .bat Cursor shims can run", async () => { + mockPlatform("win32"); + const child = createMockChild(); + + const resultPromise = runCursorCommand("cursor-agent", ["--version"], 1000); + + expect(spawn).toHaveBeenCalledWith("cursor-agent", ["--version"], { + stdio: ["ignore", "pipe", "pipe"], + shell: true, + }); + + child.stdout.write("cursor-agent 1.2.3\n"); + child.stderr.write("diagnostic\n"); + child.emit("close", 0); + + await expect(resultPromise).resolves.toEqual({ + code: 0, + stdout: "cursor-agent 1.2.3\n", + stderr: "diagnostic\n", + }); + }); + + it("keeps non-Windows Cursor invocations on direct spawn", async () => { + mockPlatform("darwin"); + const child = createMockChild(); + + const resultPromise = runCursorCommand("cursor-agent", ["--version"], 1000); + + expect(spawn).toHaveBeenCalledWith("cursor-agent", ["--version"], { + stdio: ["ignore", "pipe", "pipe"], + shell: false, + }); + + child.emit("close", 0); + await expect(resultPromise).resolves.toMatchObject({ code: 0 }); + }); + + it("returns spawn errors with diagnostics instead of empty stderr", async () => { + mockPlatform("win32"); + const child = createMockChild(); + + const resultPromise = runCursorCommand("cursor-agent", ["--version"], 1000); + child.emit("error", Object.assign(new Error("spawn cursor-agent ENOENT"), { code: "ENOENT" })); + + const result = await resultPromise; + expect(result.code).toBe(127); + expect(result.stderr).toContain("spawn error: ENOENT: spawn cursor-agent ENOENT"); + }); + + it("kills timed-out Cursor commands best-effort and resolves once", async () => { + vi.useFakeTimers(); + mockPlatform("linux"); + const child = createMockChild(); + + const resultPromise = runCursorCommand("cursor-agent", ["models", "--json"], 25); + child.stdout.write("partial"); + + await vi.advanceTimersByTimeAsync(25); + + await expect(resultPromise).resolves.toEqual({ code: 124, stdout: "partial", stderr: "" }); + expect(child.kill).toHaveBeenCalledWith("SIGKILL"); + + child.emit("close", 0); + await expect(resultPromise).resolves.toMatchObject({ code: 124 }); + }); +}); diff --git a/plugins/fusion-plugin-cursor-runtime/src/__tests__/probe.test.ts b/plugins/fusion-plugin-cursor-runtime/src/__tests__/probe.test.ts index 539b9139b0..cf77d4610e 100644 --- a/plugins/fusion-plugin-cursor-runtime/src/__tests__/probe.test.ts +++ b/plugins/fusion-plugin-cursor-runtime/src/__tests__/probe.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../cli-spawn.js", () => ({ runCursorCommand: vi.fn() })); @@ -6,6 +6,10 @@ import { runCursorCommand } from "../cli-spawn.js"; import { probeCursorBinary } from "../probe.js"; describe("probeCursorBinary", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("reports available when probe succeeds", async () => { vi.mocked(runCursorCommand).mockResolvedValue({ code: 0, stdout: "1.2.3", stderr: "" }); const result = await probeCursorBinary({ binaryPath: "cursor-agent" }); @@ -29,13 +33,45 @@ describe("probeCursorBinary", () => { expect(result.reason).toContain("installation not found"); }); - it("reports binary unavailable when all candidates fail", async () => { + it("probes cursor-agent before cursor and reports the first Windows shim success", async () => { + vi.mocked(runCursorCommand).mockResolvedValueOnce({ code: 0, stdout: "cursor-agent 0.50.0\n", stderr: "" }); + + const result = await probeCursorBinary(); + + expect(runCursorCommand).toHaveBeenCalledWith("cursor-agent", ["--version"], 3000); + expect(runCursorCommand).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ + available: true, + authenticated: true, + binaryName: "cursor-agent", + binaryPath: "cursor-agent", + version: "cursor-agent 0.50.0", + }); + }); + + it("falls back to cursor when cursor-agent fails but cursor succeeds", async () => { vi.mocked(runCursorCommand) - .mockResolvedValueOnce({ code: 127, stdout: "", stderr: "" }) - .mockResolvedValueOnce({ code: 127, stdout: "", stderr: "" }); + .mockResolvedValueOnce({ code: 127, stdout: "", stderr: "spawn error: ENOENT: cursor-agent" }) + .mockResolvedValueOnce({ code: 0, stdout: "cursor 0.50.0\n", stderr: "" }); + + const result = await probeCursorBinary(); + + expect(runCursorCommand).toHaveBeenNthCalledWith(1, "cursor-agent", ["--version"], 3000); + expect(runCursorCommand).toHaveBeenNthCalledWith(2, "cursor", ["--version"], 3000); + expect(result.available).toBe(true); + expect(result.binaryName).toBe("cursor"); + expect(result.version).toBe("cursor 0.50.0"); + }); + + it("reports binary unavailable with actionable diagnostics when all candidates fail", async () => { + vi.mocked(runCursorCommand) + .mockResolvedValueOnce({ code: 127, stdout: "", stderr: "spawn error: ENOENT: cursor-agent.cmd" }) + .mockResolvedValueOnce({ code: 127, stdout: "", stderr: "spawn error: ENOENT: cursor.cmd" }); const result = await probeCursorBinary(); expect(result.available).toBe(false); expect(result.reason).toContain("not found"); + expect(result.reason).toContain("cursor-agent: spawn error: ENOENT"); + expect(result.reason).toContain("cursor: spawn error: ENOENT"); }); }); diff --git a/plugins/fusion-plugin-cursor-runtime/src/__tests__/process-manager.test.ts b/plugins/fusion-plugin-cursor-runtime/src/__tests__/process-manager.test.ts index 672ea09b14..7918bf2536 100644 --- a/plugins/fusion-plugin-cursor-runtime/src/__tests__/process-manager.test.ts +++ b/plugins/fusion-plugin-cursor-runtime/src/__tests__/process-manager.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../cli-spawn.js", () => ({ runCursorCommand: vi.fn() })); @@ -6,9 +6,14 @@ import { runCursorCommand } from "../cli-spawn.js"; import { discoverCursorModels } from "../process-manager.js"; describe("discoverCursorModels", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("uses json list when available", async () => { vi.mocked(runCursorCommand).mockResolvedValueOnce({ code: 0, stdout: '[{"id":"cursor/a"},{"id":"cursor/b"}]', stderr: "" }); const result = await discoverCursorModels("cursor-agent"); + expect(runCursorCommand).toHaveBeenCalledWith("cursor-agent", ["models", "--json"], 5000); expect(result.models).toEqual(["cursor/a", "cursor/b"]); expect(result.fallbackUsed).toBe(false); }); @@ -19,7 +24,24 @@ describe("discoverCursorModels", () => { .mockResolvedValueOnce({ code: 1, stdout: "", stderr: "" }) .mockResolvedValueOnce({ code: 0, stdout: "cursor/x\ncursor/y", stderr: "" }); const result = await discoverCursorModels("cursor-agent"); + expect(runCursorCommand).toHaveBeenNthCalledWith(1, "cursor-agent", ["models", "--json"], 5000); + expect(runCursorCommand).toHaveBeenNthCalledWith(2, "cursor-agent", ["model", "list", "--json"], 5000); + expect(runCursorCommand).toHaveBeenNthCalledWith(3, "cursor-agent", ["models"], 5000); expect(result.models).toEqual(["cursor/x", "cursor/y"]); expect(result.fallbackUsed).toBe(true); }); + + it("returns empty discovery when every command fails", async () => { + vi.mocked(runCursorCommand) + .mockResolvedValueOnce({ code: 127, stdout: "", stderr: "spawn error: ENOENT" }) + .mockResolvedValueOnce({ code: 1, stdout: "", stderr: "unknown command" }) + .mockResolvedValueOnce({ code: 1, stdout: "", stderr: "unknown command" }); + + const result = await discoverCursorModels("cursor-agent", 2500); + + expect(runCursorCommand).toHaveBeenNthCalledWith(1, "cursor-agent", ["models", "--json"], 2500); + expect(runCursorCommand).toHaveBeenNthCalledWith(2, "cursor-agent", ["model", "list", "--json"], 2500); + expect(runCursorCommand).toHaveBeenNthCalledWith(3, "cursor-agent", ["models"], 2500); + expect(result).toEqual({ models: [], source: "none", fallbackUsed: true, reason: "model discovery command unavailable" }); + }); }); diff --git a/plugins/fusion-plugin-cursor-runtime/src/cli-spawn.ts b/plugins/fusion-plugin-cursor-runtime/src/cli-spawn.ts index 5616926d9a..1079209738 100644 --- a/plugins/fusion-plugin-cursor-runtime/src/cli-spawn.ts +++ b/plugins/fusion-plugin-cursor-runtime/src/cli-spawn.ts @@ -1,25 +1,50 @@ import { spawn } from "node:child_process"; +function formatSpawnError(error: Error & { code?: unknown }): string { + const code = typeof error.code === "string" ? `${error.code}: ` : ""; + return `spawn error: ${code}${error.message}`.trim(); +} + export async function runCursorCommand(binary: string, args: string[], timeoutMs: number): Promise<{ code: number | null; stdout: string; stderr: string }> { return new Promise((resolve) => { - const child = spawn(binary, args, { stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; - const timer = setTimeout(() => { + let settled = false; + let timer: NodeJS.Timeout | undefined; + + const finish = (result: { code: number | null; stdout: string; stderr: string }) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + resolve(result); + }; + + /* + FNXC:CursorCli 2026-07-02-00:00: + Windows Cursor installers and npm-style shims can expose `cursor-agent.cmd` or `cursor.cmd` on PATH, and Node cannot direct-spawn those batch wrappers without the command shell. + Keep Unix/macOS on direct spawn so only the known Cursor CLI probe/discovery seam uses shell resolution where Windows requires it. + */ + const child = spawn(binary, args, { + stdio: ["ignore", "pipe", "pipe"], + shell: process.platform === "win32", + }); + + timer = setTimeout(() => { try { child.kill("SIGKILL"); } catch { // best effort } - resolve({ code: 124, stdout, stderr }); + finish({ code: 124, stdout, stderr }); }, timeoutMs); + child.stdout?.on("data", (c: Buffer) => { stdout += c.toString("utf-8"); }); child.stderr?.on("data", (c: Buffer) => { stderr += c.toString("utf-8"); }); - child.once("error", () => { - clearTimeout(timer); - resolve({ code: 127, stdout, stderr }); + child.once("error", (error: Error & { code?: unknown }) => { + const diagnostic = formatSpawnError(error); + stderr = stderr ? `${stderr}\n${diagnostic}` : diagnostic; + finish({ code: 127, stdout, stderr }); }); child.once("close", (code) => { - clearTimeout(timer); - resolve({ code, stdout, stderr }); + finish({ code, stdout, stderr }); }); }); } diff --git a/plugins/fusion-plugin-cursor-runtime/src/probe.ts b/plugins/fusion-plugin-cursor-runtime/src/probe.ts index c6b4565036..0dc20673cc 100644 --- a/plugins/fusion-plugin-cursor-runtime/src/probe.ts +++ b/plugins/fusion-plugin-cursor-runtime/src/probe.ts @@ -2,14 +2,25 @@ import { runCursorCommand } from "./cli-spawn.js"; import type { CursorBinaryStatus } from "./types.js"; const CANDIDATES = ["cursor-agent", "cursor"] as const; +const MAX_FAILURE_DETAIL_LENGTH = 180; + +function summarizeFailure(binary: string, stdout: string, stderr: string): string | undefined { + const detail = `${stderr || stdout}`.replace(/\s+/g, " ").trim(); + if (!detail) return undefined; + const truncated = detail.length > MAX_FAILURE_DETAIL_LENGTH ? `${detail.slice(0, MAX_FAILURE_DETAIL_LENGTH - 1)}…` : detail; + return `${binary}: ${truncated}`; +} export async function probeCursorBinary(options?: { timeoutMs?: number; binaryPath?: string }): Promise { const startedAt = Date.now(); const timeoutMs = options?.timeoutMs ?? 3000; const candidates = options?.binaryPath ? [options.binaryPath] : [...CANDIDATES]; + const failureDetails: string[] = []; for (const binary of candidates) { const version = await runCursorCommand(binary, ["--version"], timeoutMs); + const failureDetail = summarizeFailure(binary, version.stdout, version.stderr); + if (failureDetail) failureDetails.push(failureDetail); if (version.code === 0) { // NOTE: Cursor CLI currently lacks a stable auth-status contract we can // invoke without side effects. Treating successful --version as ready is @@ -49,10 +60,11 @@ export async function probeCursorBinary(options?: { timeoutMs?: number; binaryPa } } + const baseReason = options?.binaryPath ? `${options.binaryPath} not found on PATH` : "cursor-agent/cursor not found on PATH"; return { available: false, authenticated: false, - reason: "cursor-agent/cursor not found on PATH", + reason: failureDetails.length > 0 ? `${baseReason} (${failureDetails.join("; ")})` : baseReason, probeDurationMs: Date.now() - startedAt, }; }