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,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() }));
@@ -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");
});
});

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() }));
@@ -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" });
});
});

View File

@@ -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 });
});
});
}

View File

@@ -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<CursorBinaryStatus> {
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,
};
}