FN-7418: fix Cursor CLI Windows shim spawning

Fix Cursor runtime probes to execute Windows cmd and bat shims reliably while preserving diagnostics.

- Run shared Cursor CLI probe and discovery spawns through the shell only on Windows.
- Surface bounded spawn error details in unavailable Cursor probe reasons.
- Cover Windows shell spawning, failure diagnostics, probe, and process-manager behavior with tests.
- Document the Windows PATH shim invocation contract and add a patch changeset.

Files changed:
 .changeset/fn-7418-cursor-cli-windows-cmd.md       |   7 ++
 docs/cursor-cli-contract.md                        |  17 ++++
 .../src/__tests__/cli-spawn.test.ts                | 103 +++++++++++++++++++++
 .../src/__tests__/probe.test.ts                    |  44 ++++++++-
 .../src/__tests__/process-manager.test.ts          |  24 ++++-
 .../fusion-plugin-cursor-runtime/src/cli-spawn.ts  |  41 ++++++--
 plugins/fusion-plugin-cursor-runtime/src/probe.ts  |  14 ++-
 7 files changed, 236 insertions(+), 14 deletions(-)

Fusion-Task-Id: FN-7418
Fusion-Task-Lineage: d689bea7-4676-4190-a60a-f85efab90aba
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-02 07:42:04 -07:00
parent 4178e7d325
commit 777f64750c
7 changed files with 236 additions and 14 deletions

View File

@@ -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.

View File

@@ -26,6 +26,23 @@ Date: 2026-05-07
3. Persist the resolved path and executable name in probe results. 3. Persist the resolved path and executable name in probe results.
4. Report explicit failure reason when neither exists. 4. Report explicit failure reason when neither exists.
### Windows PATH shim invocation
<!--
FNXC:CursorCli 2026-07-02-00:00:
Windows Cursor installs may publish `cursor-agent.cmd`, `cursor.cmd`, or equivalent `.bat` shims on PATH; Fusion must invoke Cursor probe and discovery commands through the Windows shell so Node can execute those wrappers.
Unix and macOS stay direct-spawned to avoid broadening shell semantics beyond the platform that requires it.
-->
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 ## Confirmed error/auth/runtime signals
Observed command behavior in this environment: Observed command behavior in this environment:

View File

@@ -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<typeof vi.fn>;
};
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 });
});
});

View File

@@ -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() })); vi.mock("../cli-spawn.js", () => ({ runCursorCommand: vi.fn() }));
@@ -6,6 +6,10 @@ import { runCursorCommand } from "../cli-spawn.js";
import { probeCursorBinary } from "../probe.js"; import { probeCursorBinary } from "../probe.js";
describe("probeCursorBinary", () => { describe("probeCursorBinary", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("reports available when probe succeeds", async () => { it("reports available when probe succeeds", async () => {
vi.mocked(runCursorCommand).mockResolvedValue({ code: 0, stdout: "1.2.3", stderr: "" }); vi.mocked(runCursorCommand).mockResolvedValue({ code: 0, stdout: "1.2.3", stderr: "" });
const result = await probeCursorBinary({ binaryPath: "cursor-agent" }); const result = await probeCursorBinary({ binaryPath: "cursor-agent" });
@@ -29,13 +33,45 @@ describe("probeCursorBinary", () => {
expect(result.reason).toContain("installation not found"); 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) vi.mocked(runCursorCommand)
.mockResolvedValueOnce({ code: 127, stdout: "", stderr: "" }) .mockResolvedValueOnce({ code: 127, stdout: "", stderr: "spawn error: ENOENT: cursor-agent" })
.mockResolvedValueOnce({ code: 127, stdout: "", stderr: "" }); .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(); const result = await probeCursorBinary();
expect(result.available).toBe(false); expect(result.available).toBe(false);
expect(result.reason).toContain("not found"); expect(result.reason).toContain("not found");
expect(result.reason).toContain("cursor-agent: spawn error: ENOENT");
expect(result.reason).toContain("cursor: spawn error: ENOENT");
}); });
}); });

View File

@@ -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() })); vi.mock("../cli-spawn.js", () => ({ runCursorCommand: vi.fn() }));
@@ -6,9 +6,14 @@ import { runCursorCommand } from "../cli-spawn.js";
import { discoverCursorModels } from "../process-manager.js"; import { discoverCursorModels } from "../process-manager.js";
describe("discoverCursorModels", () => { describe("discoverCursorModels", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("uses json list when available", async () => { it("uses json list when available", async () => {
vi.mocked(runCursorCommand).mockResolvedValueOnce({ code: 0, stdout: '[{"id":"cursor/a"},{"id":"cursor/b"}]', stderr: "" }); vi.mocked(runCursorCommand).mockResolvedValueOnce({ code: 0, stdout: '[{"id":"cursor/a"},{"id":"cursor/b"}]', stderr: "" });
const result = await discoverCursorModels("cursor-agent"); const result = await discoverCursorModels("cursor-agent");
expect(runCursorCommand).toHaveBeenCalledWith("cursor-agent", ["models", "--json"], 5000);
expect(result.models).toEqual(["cursor/a", "cursor/b"]); expect(result.models).toEqual(["cursor/a", "cursor/b"]);
expect(result.fallbackUsed).toBe(false); expect(result.fallbackUsed).toBe(false);
}); });
@@ -19,7 +24,24 @@ describe("discoverCursorModels", () => {
.mockResolvedValueOnce({ code: 1, stdout: "", stderr: "" }) .mockResolvedValueOnce({ code: 1, stdout: "", stderr: "" })
.mockResolvedValueOnce({ code: 0, stdout: "cursor/x\ncursor/y", stderr: "" }); .mockResolvedValueOnce({ code: 0, stdout: "cursor/x\ncursor/y", stderr: "" });
const result = await discoverCursorModels("cursor-agent"); 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.models).toEqual(["cursor/x", "cursor/y"]);
expect(result.fallbackUsed).toBe(true); 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" });
});
}); });

View File

@@ -1,25 +1,50 @@
import { spawn } from "node:child_process"; 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 }> { export async function runCursorCommand(binary: string, args: string[], timeoutMs: number): Promise<{ code: number | null; stdout: string; stderr: string }> {
return new Promise((resolve) => { return new Promise((resolve) => {
const child = spawn(binary, args, { stdio: ["ignore", "pipe", "pipe"] });
let stdout = ""; let stdout = "";
let stderr = ""; 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 { try { child.kill("SIGKILL"); } catch {
// best effort // best effort
} }
resolve({ code: 124, stdout, stderr }); finish({ code: 124, stdout, stderr });
}, timeoutMs); }, timeoutMs);
child.stdout?.on("data", (c: Buffer) => { stdout += c.toString("utf-8"); }); child.stdout?.on("data", (c: Buffer) => { stdout += c.toString("utf-8"); });
child.stderr?.on("data", (c: Buffer) => { stderr += c.toString("utf-8"); }); child.stderr?.on("data", (c: Buffer) => { stderr += c.toString("utf-8"); });
child.once("error", () => { child.once("error", (error: Error & { code?: unknown }) => {
clearTimeout(timer); const diagnostic = formatSpawnError(error);
resolve({ code: 127, stdout, stderr }); stderr = stderr ? `${stderr}\n${diagnostic}` : diagnostic;
finish({ code: 127, stdout, stderr });
}); });
child.once("close", (code) => { child.once("close", (code) => {
clearTimeout(timer); finish({ code, stdout, stderr });
resolve({ code, stdout, stderr });
}); });
}); });
} }

View File

@@ -2,14 +2,25 @@ import { runCursorCommand } from "./cli-spawn.js";
import type { CursorBinaryStatus } from "./types.js"; import type { CursorBinaryStatus } from "./types.js";
const CANDIDATES = ["cursor-agent", "cursor"] as const; 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<CursorBinaryStatus> { export async function probeCursorBinary(options?: { timeoutMs?: number; binaryPath?: string }): Promise<CursorBinaryStatus> {
const startedAt = Date.now(); const startedAt = Date.now();
const timeoutMs = options?.timeoutMs ?? 3000; const timeoutMs = options?.timeoutMs ?? 3000;
const candidates = options?.binaryPath ? [options.binaryPath] : [...CANDIDATES]; const candidates = options?.binaryPath ? [options.binaryPath] : [...CANDIDATES];
const failureDetails: string[] = [];
for (const binary of candidates) { for (const binary of candidates) {
const version = await runCursorCommand(binary, ["--version"], timeoutMs); const version = await runCursorCommand(binary, ["--version"], timeoutMs);
const failureDetail = summarizeFailure(binary, version.stdout, version.stderr);
if (failureDetail) failureDetails.push(failureDetail);
if (version.code === 0) { if (version.code === 0) {
// NOTE: Cursor CLI currently lacks a stable auth-status contract we can // NOTE: Cursor CLI currently lacks a stable auth-status contract we can
// invoke without side effects. Treating successful --version as ready is // 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 { return {
available: false, available: false,
authenticated: false, authenticated: false,
reason: "cursor-agent/cursor not found on PATH", reason: failureDetails.length > 0 ? `${baseReason} (${failureDetails.join("; ")})` : baseReason,
probeDurationMs: Date.now() - startedAt, probeDurationMs: Date.now() - startedAt,
}; };
} }