FN-7697: fix Cursor CLI auth-status and model-list discovery
Corrects the Cursor plugin's CLI integration to match cursor-agent's real command contract instead of best-effort heuristics. - Derive authentication from `cursor-agent status --format json` (`isAuthenticated` field), failing closed with an actionable reason on non-zero exit or malformed JSON, instead of treating `--version` success as auth-ready. - Switch model discovery to `cursor-agent models` plain-text output (`id - Label` lines), filtering header/tip/empty-state lines, since `--json`/`model list` are not supported. - Update README to document the corrected CLI usage. - Add regression tests covering probe.ts auth-status parsing and process-manager.ts model discovery. - Add changeset (patch) documenting the fix. Files changed: .changeset/fn-7697-cursor-cli.md | 7 ++ plugins/fusion-plugin-cursor-runtime/README.md | 3 +- .../src/__tests__/probe.test.ts | 94 +++++++++++++++++++--- .../src/__tests__/process-manager.test.ts | 89 +++++++++++++------- plugins/fusion-plugin-cursor-runtime/src/probe.ts | 39 +++++++-- .../src/process-manager.ts | 84 ++++++++++++------- 6 files changed, 239 insertions(+), 77 deletions(-) Fusion-Task-Id: FN-7697 Fusion-Task-Lineage: 6dd56a7f-da8f-4a73-82ff-5e9baab697c5 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7697-cursor-cli.md
Normal file
7
.changeset/fn-7697-cursor-cli.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix Cursor CLI model discovery and auth to use the real cursor-agent commands.
|
||||
category: fix
|
||||
dev: Switches model discovery to `cursor-agent models` (plain text `id - Label`, no `--json`/`model list` support) with header/tip/empty-state filtering, and derives auth from `cursor-agent status --format json` (`isAuthenticated`) instead of a `--version`-success heuristic.
|
||||
@@ -7,7 +7,8 @@ Cursor CLI-backed provider/runtime plugin for Fusion.
|
||||
- Provider ID: `cursor-cli`
|
||||
- Binary probes: `cursor-agent`, then `cursor`
|
||||
- Expected failure states: missing binary, missing Cursor IDE install, locked macOS keychain, unauthenticated runtime
|
||||
- Model discovery: dynamic command probing (`models --json`, fallbacks) with dedupe + fallback metadata
|
||||
- Model discovery: `cursor-agent models` (plain text `id - Label` output; no `--json` support) with header/tip/empty-state filtering, dedupe, and fallback metadata
|
||||
- Auth status: `cursor-agent status --format json` (`isAuthenticated`), probed against the same candidate binary that succeeded `--version`
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@@ -5,22 +5,82 @@ vi.mock("../cli-spawn.js", () => ({ runCursorCommand: vi.fn() }));
|
||||
import { runCursorCommand } from "../cli-spawn.js";
|
||||
import { probeCursorBinary } from "../probe.js";
|
||||
|
||||
const AUTHENTICATED_STATUS = JSON.stringify({ isAuthenticated: true, status: "logged_in", hasAccessToken: true, userInfo: { email: "dev@example.com" } });
|
||||
const UNAUTHENTICATED_STATUS = JSON.stringify({ isAuthenticated: false, status: "logged_out", hasAccessToken: false });
|
||||
|
||||
describe("probeCursorBinary", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("reports available when probe succeeds", async () => {
|
||||
vi.mocked(runCursorCommand).mockResolvedValue({ code: 0, stdout: "1.2.3", stderr: "" });
|
||||
it("reports authenticated:true from status --format json isAuthenticated", async () => {
|
||||
vi.mocked(runCursorCommand)
|
||||
.mockResolvedValueOnce({ code: 0, stdout: "1.2.3", stderr: "" })
|
||||
.mockResolvedValueOnce({ code: 0, stdout: AUTHENTICATED_STATUS, stderr: "" });
|
||||
|
||||
const result = await probeCursorBinary({ binaryPath: "/usr/local/bin/cursor-agent" });
|
||||
expect(runCursorCommand).toHaveBeenCalledWith("/usr/local/bin/cursor-agent", ["--version"], 3000);
|
||||
|
||||
expect(runCursorCommand).toHaveBeenNthCalledWith(1, "/usr/local/bin/cursor-agent", ["--version"], 3000);
|
||||
expect(runCursorCommand).toHaveBeenNthCalledWith(2, "/usr/local/bin/cursor-agent", ["status", "--format", "json"], 3000);
|
||||
expect(result.available).toBe(true);
|
||||
expect(result.authenticated).toBe(true);
|
||||
expect(result.version).toBe("1.2.3");
|
||||
expect(result.binaryPath).toBe("/usr/local/bin/cursor-agent");
|
||||
expect(result.configuredBinaryPath).toBe("/usr/local/bin/cursor-agent");
|
||||
expect(result.usingConfiguredBinaryPath).toBe(true);
|
||||
});
|
||||
|
||||
it("reports authenticated:false from status --format json isAuthenticated:false", async () => {
|
||||
vi.mocked(runCursorCommand)
|
||||
.mockResolvedValueOnce({ code: 0, stdout: "1.2.3", stderr: "" })
|
||||
.mockResolvedValueOnce({ code: 0, stdout: UNAUTHENTICATED_STATUS, stderr: "" });
|
||||
|
||||
const result = await probeCursorBinary({ binaryPath: "cursor-agent" });
|
||||
|
||||
expect(result.available).toBe(true);
|
||||
expect(result.authenticated).toBe(false);
|
||||
expect(result.reason).toBe("cursor-agent reports not authenticated");
|
||||
});
|
||||
|
||||
it("fails closed to authenticated:false with an actionable reason on malformed/non-JSON status output", async () => {
|
||||
vi.mocked(runCursorCommand)
|
||||
.mockResolvedValueOnce({ code: 0, stdout: "1.2.3", stderr: "" })
|
||||
.mockResolvedValueOnce({ code: 0, stdout: "not json at all", stderr: "" });
|
||||
|
||||
const result = await probeCursorBinary({ binaryPath: "cursor-agent" });
|
||||
|
||||
expect(result.available).toBe(true);
|
||||
expect(result.authenticated).toBe(false);
|
||||
expect(result.reason).toBe("cursor-agent status --format json returned malformed JSON");
|
||||
});
|
||||
|
||||
it("fails closed to authenticated:false with an actionable reason when status exits non-zero", async () => {
|
||||
vi.mocked(runCursorCommand)
|
||||
.mockResolvedValueOnce({ code: 0, stdout: "1.2.3", stderr: "" })
|
||||
.mockResolvedValueOnce({ code: 1, stdout: "", stderr: "unexpected error" });
|
||||
|
||||
const result = await probeCursorBinary({ binaryPath: "cursor-agent" });
|
||||
|
||||
expect(result.available).toBe(true);
|
||||
expect(result.authenticated).toBe(false);
|
||||
expect(result.reason).toBe("cursor-agent status --format json did not return output");
|
||||
});
|
||||
|
||||
it("probes status against the SAME candidate binary that succeeded --version, never re-probing a different candidate", async () => {
|
||||
vi.mocked(runCursorCommand)
|
||||
.mockResolvedValueOnce({ code: 127, stdout: "", stderr: "spawn error: ENOENT: cursor-agent" })
|
||||
.mockResolvedValueOnce({ code: 0, stdout: "cursor 0.50.0\n", stderr: "" })
|
||||
.mockResolvedValueOnce({ code: 0, stdout: AUTHENTICATED_STATUS, stderr: "" });
|
||||
|
||||
const result = await probeCursorBinary();
|
||||
|
||||
expect(runCursorCommand).toHaveBeenNthCalledWith(1, "cursor-agent", ["--version"], 3000);
|
||||
expect(runCursorCommand).toHaveBeenNthCalledWith(2, "cursor", ["--version"], 3000);
|
||||
expect(runCursorCommand).toHaveBeenNthCalledWith(3, "cursor", ["status", "--format", "json"], 3000);
|
||||
expect(result.binaryName).toBe("cursor");
|
||||
expect(result.authenticated).toBe(true);
|
||||
});
|
||||
|
||||
it("reports keychain lock as auth failure", async () => {
|
||||
vi.mocked(runCursorCommand).mockResolvedValue({ code: 1, stdout: "", stderr: "Error: Your macOS login keychain is locked." });
|
||||
const result = await probeCursorBinary({ binaryPath: "cursor-agent" });
|
||||
@@ -38,12 +98,14 @@ describe("probeCursorBinary", () => {
|
||||
});
|
||||
|
||||
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: "" });
|
||||
vi.mocked(runCursorCommand)
|
||||
.mockResolvedValueOnce({ code: 0, stdout: "cursor-agent 0.50.0\n", stderr: "" })
|
||||
.mockResolvedValueOnce({ code: 0, stdout: AUTHENTICATED_STATUS, stderr: "" });
|
||||
|
||||
const result = await probeCursorBinary();
|
||||
|
||||
expect(runCursorCommand).toHaveBeenCalledWith("cursor-agent", ["--version"], 3000);
|
||||
expect(runCursorCommand).toHaveBeenCalledTimes(1);
|
||||
expect(runCursorCommand).toHaveBeenNthCalledWith(1, "cursor-agent", ["--version"], 3000);
|
||||
expect(runCursorCommand).toHaveBeenCalledTimes(2);
|
||||
expect(result).toMatchObject({
|
||||
available: true,
|
||||
authenticated: true,
|
||||
@@ -56,7 +118,8 @@ describe("probeCursorBinary", () => {
|
||||
it("falls back to cursor when cursor-agent fails but cursor succeeds", async () => {
|
||||
vi.mocked(runCursorCommand)
|
||||
.mockResolvedValueOnce({ code: 127, stdout: "", stderr: "spawn error: ENOENT: cursor-agent" })
|
||||
.mockResolvedValueOnce({ code: 0, stdout: "cursor 0.50.0\n", stderr: "" });
|
||||
.mockResolvedValueOnce({ code: 0, stdout: "cursor 0.50.0\n", stderr: "" })
|
||||
.mockResolvedValueOnce({ code: 0, stdout: AUTHENTICATED_STATUS, stderr: "" });
|
||||
|
||||
const result = await probeCursorBinary();
|
||||
|
||||
@@ -80,13 +143,15 @@ describe("probeCursorBinary", () => {
|
||||
});
|
||||
|
||||
it("tries a Windows path with spaces and .cmd shim before PATH fallback", async () => {
|
||||
vi.mocked(runCursorCommand).mockResolvedValueOnce({ code: 0, stdout: "cursor-agent.cmd 0.50.0", stderr: "" });
|
||||
vi.mocked(runCursorCommand)
|
||||
.mockResolvedValueOnce({ code: 0, stdout: "cursor-agent.cmd 0.50.0", stderr: "" })
|
||||
.mockResolvedValueOnce({ code: 0, stdout: AUTHENTICATED_STATUS, stderr: "" });
|
||||
|
||||
const binaryPath = "C:\\Users\\A User\\AppData\\Roaming\\npm\\cursor-agent.cmd";
|
||||
const result = await probeCursorBinary({ binaryPath });
|
||||
|
||||
expect(runCursorCommand).toHaveBeenCalledWith(binaryPath, ["--version"], 3000);
|
||||
expect(runCursorCommand).toHaveBeenCalledTimes(1);
|
||||
expect(runCursorCommand).toHaveBeenNthCalledWith(1, binaryPath, ["--version"], 3000);
|
||||
expect(runCursorCommand).toHaveBeenNthCalledWith(2, binaryPath, ["status", "--format", "json"], 3000);
|
||||
expect(result.binaryPath).toBe(binaryPath);
|
||||
expect(result.usingConfiguredBinaryPath).toBe(true);
|
||||
});
|
||||
@@ -94,12 +159,14 @@ describe("probeCursorBinary", () => {
|
||||
it("falls back to PATH candidates when a configured binary fails", async () => {
|
||||
vi.mocked(runCursorCommand)
|
||||
.mockResolvedValueOnce({ code: 127, stdout: "", stderr: "spawn error: ENOENT: /missing/cursor-agent" })
|
||||
.mockResolvedValueOnce({ code: 0, stdout: "cursor-agent 0.50.0\n", stderr: "" });
|
||||
.mockResolvedValueOnce({ code: 0, stdout: "cursor-agent 0.50.0\n", stderr: "" })
|
||||
.mockResolvedValueOnce({ code: 0, stdout: AUTHENTICATED_STATUS, stderr: "" });
|
||||
|
||||
const result = await probeCursorBinary({ binaryPath: "/missing/cursor-agent" });
|
||||
|
||||
expect(runCursorCommand).toHaveBeenNthCalledWith(1, "/missing/cursor-agent", ["--version"], 3000);
|
||||
expect(runCursorCommand).toHaveBeenNthCalledWith(2, "cursor-agent", ["--version"], 3000);
|
||||
expect(runCursorCommand).toHaveBeenNthCalledWith(3, "cursor-agent", ["status", "--format", "json"], 3000);
|
||||
expect(result.available).toBe(true);
|
||||
expect(result.binaryPath).toBe("cursor-agent");
|
||||
expect(result.usingConfiguredBinaryPath).toBe(false);
|
||||
@@ -124,11 +191,12 @@ describe("probeCursorBinary", () => {
|
||||
it("dedupes overrides equal to default PATH candidate names", async () => {
|
||||
vi.mocked(runCursorCommand)
|
||||
.mockResolvedValueOnce({ code: 127, stdout: "", stderr: "spawn error: ENOENT: cursor-agent" })
|
||||
.mockResolvedValueOnce({ code: 0, stdout: "cursor 0.50.0\n", stderr: "" });
|
||||
.mockResolvedValueOnce({ code: 0, stdout: "cursor 0.50.0\n", stderr: "" })
|
||||
.mockResolvedValueOnce({ code: 0, stdout: AUTHENTICATED_STATUS, stderr: "" });
|
||||
|
||||
const result = await probeCursorBinary({ binaryPath: " cursor-agent " });
|
||||
|
||||
expect(runCursorCommand).toHaveBeenCalledTimes(2);
|
||||
expect(runCursorCommand).toHaveBeenCalledTimes(3);
|
||||
expect(runCursorCommand).toHaveBeenNthCalledWith(1, "cursor-agent", ["--version"], 3000);
|
||||
expect(runCursorCommand).toHaveBeenNthCalledWith(2, "cursor", ["--version"], 3000);
|
||||
expect(result.binaryPath).toBe("cursor");
|
||||
|
||||
@@ -5,53 +5,86 @@ vi.mock("../cli-spawn.js", () => ({ runCursorCommand: vi.fn() }));
|
||||
import { runCursorCommand } from "../cli-spawn.js";
|
||||
import { discoverCursorModels } from "../process-manager.js";
|
||||
|
||||
const REAL_MODELS_OUTPUT = [
|
||||
"Available models",
|
||||
"",
|
||||
"auto - Auto (default)",
|
||||
"claude-4.5-sonnet - Sonnet 4.5",
|
||||
"gpt-5 - GPT-5",
|
||||
"",
|
||||
"Tip: use --model <id> (or /model <id> in interactive mode) to switch.",
|
||||
].join("\n");
|
||||
|
||||
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: "" });
|
||||
it("invokes only `models` (never --json or model list) and never falls back", async () => {
|
||||
vi.mocked(runCursorCommand).mockResolvedValueOnce({ code: 0, stdout: REAL_MODELS_OUTPUT, stderr: "" });
|
||||
await discoverCursorModels("cursor-agent");
|
||||
|
||||
expect(runCursorCommand).toHaveBeenCalledTimes(1);
|
||||
expect(runCursorCommand).toHaveBeenCalledWith("cursor-agent", ["models"], 5000);
|
||||
expect(runCursorCommand).not.toHaveBeenCalledWith("cursor-agent", ["models", "--json"], expect.anything());
|
||||
expect(runCursorCommand).not.toHaveBeenCalledWith("cursor-agent", ["model", "list", "--json"], expect.anything());
|
||||
});
|
||||
|
||||
it("extracts bare ids from real `id - Label` output, dropping header and tip lines", async () => {
|
||||
vi.mocked(runCursorCommand).mockResolvedValueOnce({ code: 0, stdout: REAL_MODELS_OUTPUT, 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.models).toEqual(["auto", "claude-4.5-sonnet", "gpt-5"]);
|
||||
expect(result.source).toBe("models-text");
|
||||
expect(result.fallbackUsed).toBe(false);
|
||||
});
|
||||
|
||||
it("parses the `auto - Auto (default)` first entry to id `auto`", async () => {
|
||||
vi.mocked(runCursorCommand).mockResolvedValueOnce({ code: 0, stdout: "Available models\n\nauto - Auto (default)\n\nTip: use --model <id> to switch.", stderr: "" });
|
||||
const result = await discoverCursorModels("cursor-agent");
|
||||
|
||||
expect(result.models[0]).toBe("auto");
|
||||
});
|
||||
|
||||
it("dedupes repeated ids", async () => {
|
||||
vi.mocked(runCursorCommand).mockResolvedValueOnce({ code: 0, stdout: "auto - Auto (default)\nauto - Auto (default)\ngpt-5 - GPT-5", stderr: "" });
|
||||
const result = await discoverCursorModels("cursor-agent");
|
||||
|
||||
expect(result.models).toEqual(["auto", "gpt-5"]);
|
||||
});
|
||||
|
||||
it("returns an empty list with a clear reason for the empty-account state", async () => {
|
||||
vi.mocked(runCursorCommand).mockResolvedValueOnce({ code: 0, stdout: "No models available for this account.", stderr: "" });
|
||||
const result = await discoverCursorModels("cursor-agent");
|
||||
|
||||
expect(result).toEqual({ models: [], source: "models-text", fallbackUsed: false, reason: "no models available for this account" });
|
||||
});
|
||||
|
||||
it("tolerates JSON output defensively even though the real CLI never sends it", 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"], 5000);
|
||||
expect(result.models).toEqual(["cursor/a", "cursor/b"]);
|
||||
expect(result.source).toBe("models-json");
|
||||
});
|
||||
|
||||
it("returns empty discovery when the command fails outright", async () => {
|
||||
vi.mocked(runCursorCommand).mockResolvedValueOnce({ code: 127, stdout: "", stderr: "spawn error: ENOENT" });
|
||||
|
||||
const result = await discoverCursorModels("cursor-agent", 2500);
|
||||
|
||||
expect(runCursorCommand).toHaveBeenCalledWith("cursor-agent", ["models"], 2500);
|
||||
expect(result).toEqual({ models: [], source: "none", fallbackUsed: true, reason: "model discovery command unavailable" });
|
||||
});
|
||||
|
||||
it("passes Windows .bat paths with spaces as one binary string", async () => {
|
||||
vi.mocked(runCursorCommand).mockResolvedValueOnce({ code: 0, stdout: '["cursor/a"]', stderr: "" });
|
||||
vi.mocked(runCursorCommand).mockResolvedValueOnce({ code: 0, stdout: "cursor/a - Cursor A", stderr: "" });
|
||||
const binary = "C:\\Program Files\\Cursor\\cursor-agent.bat";
|
||||
|
||||
const result = await discoverCursorModels(binary);
|
||||
|
||||
expect(runCursorCommand).toHaveBeenCalledWith(binary, ["models", "--json"], 5000);
|
||||
expect(runCursorCommand).toHaveBeenCalledWith(binary, ["models"], 5000);
|
||||
expect(result.models).toEqual(["cursor/a"]);
|
||||
});
|
||||
|
||||
it("falls back to text parsing", async () => {
|
||||
vi.mocked(runCursorCommand)
|
||||
.mockResolvedValueOnce({ code: 1, stdout: "", stderr: "" })
|
||||
.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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,28 @@ function summarizeFailure(binary: string, stdout: string, stderr: string): strin
|
||||
return `${binary}: ${truncated}`;
|
||||
}
|
||||
|
||||
async function probeCursorAuthStatus(binary: string, timeoutMs: number): Promise<{ authenticated: boolean; reason?: string }> {
|
||||
const status = await runCursorCommand(binary, ["status", "--format", "json"], timeoutMs);
|
||||
const output = (status.stdout || "").trim();
|
||||
|
||||
if (status.code !== 0 || !output) {
|
||||
return { authenticated: false, reason: "cursor-agent status --format json did not return output" };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(output) as { isAuthenticated?: unknown };
|
||||
if (typeof parsed?.isAuthenticated === "boolean") {
|
||||
return {
|
||||
authenticated: parsed.isAuthenticated,
|
||||
reason: parsed.isAuthenticated ? undefined : "cursor-agent reports not authenticated",
|
||||
};
|
||||
}
|
||||
return { authenticated: false, reason: "cursor-agent status --format json missing isAuthenticated field" };
|
||||
} catch {
|
||||
return { authenticated: false, reason: "cursor-agent status --format json returned malformed JSON" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function probeCursorBinary(options?: { timeoutMs?: number; binaryPath?: string }): Promise<CursorBinaryStatus> {
|
||||
const startedAt = Date.now();
|
||||
const timeoutMs = options?.timeoutMs ?? 3000;
|
||||
@@ -40,15 +62,22 @@ export async function probeCursorBinary(options?: { timeoutMs?: number; binaryPa
|
||||
probeDurationMs: Date.now() - startedAt,
|
||||
};
|
||||
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
|
||||
// a best-effort heuristic; keychain/auth errors are handled by fallback
|
||||
// probes below when surfaced in stderr/stdout.
|
||||
/*
|
||||
FNXC:CursorCli 2026-07-08-00:00:
|
||||
`cursor-agent status --format json` (alias `whoami`) is the real auth
|
||||
contract, returning `{ isAuthenticated, status, hasAccessToken, userInfo }`.
|
||||
We probe it with the SAME candidate binary that just succeeded --version
|
||||
(never re-probing a different candidate), and fail closed to
|
||||
`authenticated: false` with an actionable reason on any non-zero exit or
|
||||
malformed/non-JSON output rather than throwing.
|
||||
*/
|
||||
const auth = await probeCursorAuthStatus(binary, timeoutMs);
|
||||
return {
|
||||
available: true,
|
||||
authenticated: true,
|
||||
authenticated: auth.authenticated,
|
||||
...common,
|
||||
version: version.stdout.trim() || undefined,
|
||||
reason: auth.authenticated ? undefined : auth.reason,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,45 +1,69 @@
|
||||
import { runCursorCommand } from "./cli-spawn.js";
|
||||
|
||||
const EMPTY_ACCOUNT_MESSAGE = "no models available for this account.";
|
||||
|
||||
/*
|
||||
FNXC:CursorCli 2026-07-08-00:00:
|
||||
`cursor-agent models` is plain text, NOT JSON — the real CLI rejects `--json`
|
||||
(`error: unknown option '--json'`) and has no `model list` subcommand. Output
|
||||
shape is: an `Available models` header line, a blank line, then one model per
|
||||
line as `<id> - <Label>` (e.g. `auto - Auto (default)`), ending with a
|
||||
`Tip: use --model <id> ...` line. An empty account prints the single line
|
||||
`No models available for this account.`. We strip the header/tip/empty-state
|
||||
lines and take the text before the first ` - ` as the bare model id.
|
||||
*/
|
||||
function parseModelLines(raw: string): string[] {
|
||||
return raw
|
||||
const ids = raw
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.filter((line) => !line.toLowerCase().startsWith("usage"));
|
||||
.filter((line) => line.toLowerCase() !== "available models")
|
||||
.filter((line) => line.toLowerCase() !== EMPTY_ACCOUNT_MESSAGE)
|
||||
.filter((line) => !/^tip:/i.test(line))
|
||||
.filter((line) => !line.toLowerCase().startsWith("usage"))
|
||||
.map((line) => {
|
||||
const separatorIndex = line.indexOf(" - ");
|
||||
return separatorIndex === -1 ? line : line.slice(0, separatorIndex).trim();
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return Array.from(new Set(ids));
|
||||
}
|
||||
|
||||
export async function discoverCursorModels(binary: string, timeoutMs = 5000): Promise<{ models: string[]; source: string; fallbackUsed: boolean; reason?: string }> {
|
||||
const attempts: Array<{ args: string[]; source: string; structured: boolean }> = [
|
||||
{ args: ["models", "--json"], source: "models-json", structured: true },
|
||||
{ args: ["model", "list", "--json"], source: "model-list-json", structured: true },
|
||||
{ args: ["models"], source: "models-text", structured: false },
|
||||
];
|
||||
const res = await runCursorCommand(binary, ["models"], timeoutMs);
|
||||
if (res.code !== 0) {
|
||||
return { models: [], source: "none", fallbackUsed: true, reason: "model discovery command unavailable" };
|
||||
}
|
||||
|
||||
for (const attempt of attempts) {
|
||||
const res = await runCursorCommand(binary, attempt.args, timeoutMs);
|
||||
if (res.code !== 0) continue;
|
||||
const output = (res.stdout || "").trim();
|
||||
if (!output) {
|
||||
return { models: [], source: "none", fallbackUsed: true, reason: "model discovery command returned no output" };
|
||||
}
|
||||
|
||||
const output = (res.stdout || "").trim();
|
||||
if (!output) continue;
|
||||
if (output.toLowerCase() === EMPTY_ACCOUNT_MESSAGE) {
|
||||
return { models: [], source: "models-text", fallbackUsed: false, reason: "no models available for this account" };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(output);
|
||||
if (Array.isArray(parsed)) {
|
||||
const ids = parsed
|
||||
.map((entry) => (typeof entry === "string" ? entry : typeof entry?.id === "string" ? entry.id : undefined))
|
||||
.filter((id): id is string => Boolean(id));
|
||||
if (ids.length > 0) {
|
||||
return { models: Array.from(new Set(ids)), source: attempt.source, fallbackUsed: !attempt.structured };
|
||||
}
|
||||
// Defensive fallback: tolerate output that happens to be JSON, even though
|
||||
// the real CLI does not support --json today.
|
||||
try {
|
||||
const parsed = JSON.parse(output);
|
||||
if (Array.isArray(parsed)) {
|
||||
const ids = parsed
|
||||
.map((entry) => (typeof entry === "string" ? entry : typeof entry?.id === "string" ? entry.id : undefined))
|
||||
.filter((id): id is string => Boolean(id));
|
||||
if (ids.length > 0) {
|
||||
return { models: Array.from(new Set(ids)), source: "models-json", fallbackUsed: false };
|
||||
}
|
||||
} catch {
|
||||
// output is not JSON; continue with line-based fallback
|
||||
}
|
||||
} catch {
|
||||
// output is not JSON; fall through to line-based parsing
|
||||
}
|
||||
|
||||
const ids = Array.from(new Set(parseModelLines(output)));
|
||||
if (ids.length > 0) {
|
||||
return { models: ids, source: attempt.source, fallbackUsed: !attempt.structured };
|
||||
}
|
||||
const ids = parseModelLines(output);
|
||||
if (ids.length > 0) {
|
||||
return { models: ids, source: "models-text", fallbackUsed: false };
|
||||
}
|
||||
|
||||
return { models: [], source: "none", fallbackUsed: true, reason: "model discovery command unavailable" };
|
||||
|
||||
Reference in New Issue
Block a user