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>
117 lines
4.8 KiB
TypeScript
117 lines
4.8 KiB
TypeScript
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 buildCandidates(binaryPath?: string): { candidates: string[]; configuredBinaryPath?: string } {
|
|
/*
|
|
FNXC:CursorCli 2026-07-02-00:00:
|
|
Manual operator paths must be tried before PATH candidates without deleting the fallback order. Deduping keeps `cursor-agent`/`cursor` overrides from probing the same shim twice while still preserving auto-detection.
|
|
*/
|
|
const configuredBinaryPath = binaryPath?.trim() || undefined;
|
|
const ordered = configuredBinaryPath ? [configuredBinaryPath, ...CANDIDATES] : [...CANDIDATES];
|
|
return { candidates: Array.from(new Set(ordered)), configuredBinaryPath };
|
|
}
|
|
|
|
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}`;
|
|
}
|
|
|
|
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;
|
|
const { candidates, configuredBinaryPath } = buildCandidates(options?.binaryPath);
|
|
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);
|
|
const common = {
|
|
binaryName: binary,
|
|
binaryPath: binary,
|
|
configuredBinaryPath,
|
|
usingConfiguredBinaryPath: configuredBinaryPath === binary,
|
|
diagnostics: failureDetails.length > 0 ? [...failureDetails] : undefined,
|
|
probeDurationMs: Date.now() - startedAt,
|
|
};
|
|
if (version.code === 0) {
|
|
/*
|
|
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: auth.authenticated,
|
|
...common,
|
|
version: version.stdout.trim() || undefined,
|
|
reason: auth.authenticated ? undefined : auth.reason,
|
|
};
|
|
}
|
|
|
|
const combined = `${version.stdout}\n${version.stderr}`.toLowerCase();
|
|
if (combined.includes("keychain is locked")) {
|
|
return {
|
|
available: true,
|
|
authenticated: false,
|
|
...common,
|
|
reason: "macOS login keychain is locked",
|
|
};
|
|
}
|
|
|
|
if (combined.includes("no cursor ide installation found")) {
|
|
return {
|
|
available: true,
|
|
authenticated: false,
|
|
...common,
|
|
reason: "Cursor IDE installation not found",
|
|
};
|
|
}
|
|
}
|
|
|
|
const baseReason = configuredBinaryPath
|
|
? `Configured Cursor CLI binary '${configuredBinaryPath}' failed; PATH fallback cursor-agent/cursor also failed`
|
|
: "cursor-agent/cursor not found on PATH";
|
|
return {
|
|
available: false,
|
|
authenticated: false,
|
|
configuredBinaryPath,
|
|
usingConfiguredBinaryPath: false,
|
|
diagnostics: failureDetails.length > 0 ? failureDetails : undefined,
|
|
reason: failureDetails.length > 0 ? `${baseReason} (${failureDetails.join("; ")})` : baseReason,
|
|
probeDurationMs: Date.now() - startedAt,
|
|
};
|
|
}
|