FN-7419: add Cursor CLI binary path override

Add a global Cursor CLI binary path override that is validated before enabling or probing Cursor routing.

- Add global settings schema/types and API support for storing a trimmed Cursor CLI binary path override.
- Surface a Settings Authentication control to save, clear, and test the Cursor CLI binary path.
- Probe configured Cursor binaries before PATH fallbacks and expose diagnostics for status/routes.
- Cover settings, route, dashboard, and cursor runtime behavior with focused tests and documentation.

Files changed:
 .changeset/fn-7419-cursor-cli-binary-path.md       |   7 ++
 docs/cursor-cli-contract.md                        |  33 ++++--
 docs/settings-reference.md                         |   2 +
 .../core/src/__tests__/cursor-cli-settings.test.ts |  34 ++++++
 packages/core/src/settings-schema.ts               |   6 +
 packages/core/src/types.ts                         |   8 ++
 packages/dashboard/app/api/legacy.ts               |  17 ++-
 .../app/components/CursorCliProviderCard.css       |  37 +++++-
 .../app/components/CursorCliProviderCard.tsx       |  78 ++++++++++++-
 .../__tests__/ModelOnboardingModal.test.tsx        |   4 +
 .../__tests__/SettingsModal.general.test.tsx       |   2 +
 .../__tests__/SettingsModal.models-auth.test.tsx   |  75 ++++++++++++
 .../SettingsModal.remote-notifications.test.tsx    |   2 +
 .../SettingsModal.scheduling-merge.test.tsx        |   2 +
 .../__tests__/SettingsModal.test-harness.tsx       |   3 +
 .../dashboard/src/__tests__/routes-auth.test.ts    | 130 ++++++++++++++++++++-
 .../dashboard/src/routes/register-auth-routes.ts   |  69 +++++++++--
 .../src/__tests__/probe.test.ts                    |  61 +++++++++-
 .../src/__tests__/process-manager.test.ts          |  10 ++
 .../src/__tests__/provider.test.ts                 |  57 +++++++++
 plugins/fusion-plugin-cursor-runtime/src/probe.ts  |  39 +++++--
 .../fusion-plugin-cursor-runtime/src/provider.ts   |  15 ++-
 plugins/fusion-plugin-cursor-runtime/src/types.ts  |   3 +
 23 files changed, 655 insertions(+), 39 deletions(-)

Fusion-Task-Id: FN-7419
Fusion-Task-Lineage: 2c192a37-a1db-41a9-99f5-a480ed311d9c
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-02 08:34:41 -07:00
parent e4349eed70
commit abb1917999
23 changed files with 655 additions and 39 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Add a Settings override for the local Cursor CLI binary path.
category: feature
dev: Adds global `cursorCliBinaryPath` and threads it through Cursor CLI probes, auth status, enable validation, and model discovery.

View File

@@ -19,12 +19,27 @@ Date: 2026-05-07
- `cursor-agent` is the direct CLI runtime entrypoint and is symlinked to a versioned install under:
- `~/.local/share/cursor-agent/versions/<version>/cursor-agent`
### Detection strategy to implement
### Detection strategy
1. Probe `cursor-agent` first.
2. Probe `cursor` second.
3. Persist the resolved path and executable name in probe results.
4. Report explicit failure reason when neither exists.
1. If the global `cursorCliBinaryPath` setting is a non-empty string, probe that configured binary first.
2. Probe `cursor-agent` from PATH.
3. Probe `cursor` from PATH.
4. Deduplicate candidates when the configured value is exactly `cursor-agent` or `cursor`.
5. Persist the resolved path and executable name in probe results.
6. Report explicit failure reason when neither exists.
### Manual binary path override
<!--
FNXC:CursorCli 2026-07-02-00:00:
Operators can set a global Cursor CLI binary path when PATH discovery resolves the wrong shim. The override is optional and must never remove the cursor-agent/cursor fallback probes.
-->
Settings → Authentication → Cursor CLI exposes an optional binary path field. Leave it blank to use PATH auto-detection. When populated, Fusion validates the configured path by running the same `--version` probe used for status/enable, saves it only if that configured candidate itself succeeds, and then uses it for status, enable validation, and Cursor model discovery before falling back to PATH candidates.
If the configured path fails during ordinary status/model-discovery probes but a PATH candidate succeeds, Fusion remains usable and reports the PATH candidate as the effective `binaryPath`; bounded diagnostics include the configured-path failure. If saving a new non-empty override fails or only succeeds via PATH fallback, the Settings save returns a 400 diagnostic and does not persist the path.
Windows paths with spaces, for example `C:\Users\A User\AppData\Roaming\npm\cursor-agent.cmd`, are treated as one operator-provided string. Users should not quote or split the path in the UI.
### Windows PATH shim invocation
@@ -34,14 +49,14 @@ Windows Cursor installs may publish `cursor-agent.cmd`, `cursor.cmd`, or equival
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"`.
On Windows, `cursor-agent`, `cursor`, and manual override paths 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`.
- Configured binary / `cursor-agent --version` / `cursor --version` probe attempts.
- Model discovery attempts against the effective probe-selected binary: `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.
Non-Windows probes and discovery continue to use direct spawn. Spawn errors such as `ENOENT` or `EACCES` are included in the unavailable probe reason in bounded diagnostic form so a working terminal command is distinguishable from known Cursor runtime/auth states; Fusion does not dump PATH, environment variables, or unbounded stdout/stderr.
## Confirmed error/auth/runtime signals

View File

@@ -93,6 +93,8 @@ Fusion automatically falls back to ntfy's JSON publish format when a notificatio
| `githubTrackingDefaultRepo` | `string` | `undefined` | Global fallback issue-tracking repo (`owner/repo`) used when task-level tracking is enabled and no project/task override is set. In Settings UI this is a detected-remote dropdown with a Custom fallback for manual entry. This key is dual-scope: global saves go through `PUT /api/settings/global` (Settings → Global General). |
| `autoReloadOnVersionChange` | `boolean` | `true` | When enabled (default), the dashboard automatically reloads when a new build version is detected via `/version.json` polling or service worker activation. Set to `false` to suppress automatic reloads — the user must manually refresh to pick up updates. |
| `modelOnboardingComplete` | `boolean` | `undefined` | Whether AI onboarding has been completed or dismissed. |
| `useCursorCli` | `boolean` | `undefined` | Enables the `cursor-cli` provider in model pickers after Cursor CLI status validation. Toggle from Settings → Authentication. |
| `cursorCliBinaryPath` | `string` | `undefined` | Optional global, machine-local Cursor CLI executable override used by Settings → Authentication, status/enable validation, probes, and model discovery. Leave unset/blank to auto-detect `cursor-agent` then `cursor` on PATH. Use this when PATH points at the wrong Cursor install or Windows exposes a specific `.cmd`/`.bat` shim; invalid non-empty saves are rejected with bounded diagnostics. |
| `executionGlobalProvider` | `string` | `undefined` | Global baseline provider for task execution. Project `executionProvider` overrides this. |
| `executionGlobalModelId` | `string` | `undefined` | Global baseline model ID for task execution. |
| `planningGlobalProvider` | `string` | `undefined` | Global baseline provider for planning. Project `planningProvider` overrides this. |

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import type { GlobalSettings } from "../types.js";
import {
DEFAULT_GLOBAL_SETTINGS,
GLOBAL_SETTINGS_KEYS,
isGlobalSettingsKey,
} from "../settings-schema.js";
describe("Cursor CLI global settings", () => {
it("includes the enable toggle and binary path in GLOBAL_SETTINGS_KEYS", () => {
expect(GLOBAL_SETTINGS_KEYS).toContain("useCursorCli");
expect(GLOBAL_SETTINGS_KEYS).toContain("cursorCliBinaryPath");
});
it("defaults both Cursor CLI settings to undefined", () => {
expect(DEFAULT_GLOBAL_SETTINGS.useCursorCli).toBeUndefined();
expect(DEFAULT_GLOBAL_SETTINGS.cursorCliBinaryPath).toBeUndefined();
});
it("recognizes cursorCliBinaryPath as a global settings key", () => {
expect(isGlobalSettingsKey("cursorCliBinaryPath")).toBe(true);
expect(isGlobalSettingsKey("useCursorCli")).toBe(true);
});
it("accepts a string binary override distinct from the enable toggle", () => {
const configured: GlobalSettings = {
useCursorCli: false,
cursorCliBinaryPath: "C:\\Users\\A User\\AppData\\Roaming\\npm\\cursor-agent.cmd",
};
expect(configured.useCursorCli).toBe(false);
expect(configured.cursorCliBinaryPath).toBe("C:\\Users\\A User\\AppData\\Roaming\\npm\\cursor-agent.cmd");
});
});

View File

@@ -140,6 +140,12 @@ export const DEFAULT_GLOBAL_SETTINGS = {
useClaudeCli: undefined,
useDroidCli: undefined,
useLlamaCpp: undefined,
useCursorCli: undefined,
/*
FNXC:CursorCli 2026-07-02-00:00:
Cursor CLI binary overrides are global operator settings because executable locations are machine-local. Blank/undefined preserves PATH auto-detection through cursor-agent and cursor.
*/
cursorCliBinaryPath: undefined,
// Global baseline lanes for per-role model selection
executionGlobalProvider: undefined,
executionGlobalModelId: undefined,

View File

@@ -3197,6 +3197,14 @@ export interface GlobalSettings {
* by the dashboard auth toggle. Setting this field explicitly (true/false)
* always wins. */
useLlamaCpp?: boolean;
/** When true, enable Cursor CLI model-provider support (provider ID: `cursor-cli`)
* through an operator-local Cursor CLI installation. */
useCursorCli?: boolean;
/**
* FNXC:CursorCli 2026-07-02-00:00:
* Operators need a global machine-local Cursor CLI executable override when PATH discovery resolves the wrong `cursor-agent`, `cursor`, `.cmd`, or `.bat` shim. Blank/undefined means Fusion must keep auto-detecting through PATH candidates.
*/
cursorCliBinaryPath?: string;
/** Global baseline AI model provider for task execution (executor agent).
* This is the global lane that project-level `executionProvider` can override.
* Must be set together with `executionGlobalModelId`. Falls back to

View File

@@ -1804,10 +1804,14 @@ export interface CursorCliStatus {
available: boolean;
version?: string;
binaryPath?: string;
configuredBinaryPath?: string;
usingConfiguredBinaryPath?: boolean;
diagnostics?: string[];
reason?: string;
probeDurationMs: number;
};
enabled: boolean;
binaryPath?: string;
extension: null;
ready: boolean;
}
@@ -2150,13 +2154,22 @@ export function setDroidCliEnabled(
export function setCursorCliEnabled(
enabled: boolean,
): Promise<{ enabled: boolean; restartRequired: boolean }> {
return api<{ enabled: boolean; restartRequired: boolean }>("/auth/cursor-cli", {
): Promise<{ enabled: boolean; binaryPath?: string; restartRequired: boolean }> {
return api<{ enabled: boolean; binaryPath?: string; restartRequired: boolean }>("/auth/cursor-cli", {
method: "POST",
body: JSON.stringify({ enabled }),
});
}
export function setCursorCliBinaryPath(
binaryPath: string | null,
): Promise<{ enabled: boolean; binaryPath?: string; restartRequired: boolean }> {
return api<{ enabled: boolean; binaryPath?: string; restartRequired: boolean }>("/auth/cursor-cli", {
method: "POST",
body: JSON.stringify({ binaryPath }),
});
}
/** Enable or disable the llama.cpp provider. */
export function setLlamaCppEnabled(
enabled: boolean,

View File

@@ -5,9 +5,44 @@
align-items: center;
}
.cursor-cli-binary-path-control {
display: grid;
gap: var(--space-xs);
margin-top: var(--space-sm);
}
.cursor-cli-binary-path-label {
color: var(--text-secondary);
font-size: 0.78rem;
font-weight: 600;
}
.cursor-cli-binary-path-row {
display: flex;
gap: var(--space-sm);
align-items: center;
}
.cursor-cli-binary-path-input {
min-width: 0;
flex: 1 1 18rem;
height: 2rem;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--surface-elevated);
color: var(--text-primary);
padding: 0 var(--space-sm);
}
@media (max-width: 768px) {
.cursor-cli-provider-card .auth-provider-cli-actions,
.cursor-cli-provider-card .onboarding-provider-card__actions {
.cursor-cli-provider-card .onboarding-provider-card__actions,
.cursor-cli-binary-path-row {
flex-wrap: wrap;
}
.cursor-cli-binary-path-row .btn,
.cursor-cli-binary-path-input {
width: 100%;
}
}

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
import { fetchCursorCliStatus, setCursorCliEnabled, type CursorCliStatus } from "../api";
import { fetchCursorCliStatus, setCursorCliBinaryPath, setCursorCliEnabled, type CursorCliStatus } from "../api";
import { ProviderIcon } from "./ProviderIcon";
import "./CursorCliProviderCard.css";
@@ -14,7 +14,10 @@ interface CursorCliProviderCardProps {
export function CursorCliProviderCard({ authenticated, compact = false, onToggled }: CursorCliProviderCardProps) {
const { t } = useTranslation("app");
const [status, setStatus] = useState<CursorCliStatus | null>(null);
const [busy, setBusy] = useState<"enabling" | "disabling" | "testing" | null>(null);
const [busy, setBusy] = useState<"enabling" | "disabling" | "testing" | "saving-path" | null>(null);
const [binaryPathInput, setBinaryPathInput] = useState("");
const [pathMessage, setPathMessage] = useState<{ tone: "success" | "error"; text: string } | null>(null);
const pathDirtyRef = useRef(false);
const mountedRef = useRef(true);
useEffect(() => {
@@ -27,7 +30,10 @@ export function CursorCliProviderCard({ authenticated, compact = false, onToggle
const refresh = useCallback(async () => {
try {
const next = await fetchCursorCliStatus();
if (mountedRef.current) setStatus(next);
if (mountedRef.current) {
setStatus(next);
setBinaryPathInput((current) => (pathDirtyRef.current ? current : (next.binaryPath ?? "")));
}
return next;
} catch {
return null;
@@ -54,6 +60,71 @@ export function CursorCliProviderCard({ authenticated, compact = false, onToggle
const currentlyEnabled = status?.enabled ?? authenticated;
const binaryAvailable = status?.binary.available ?? false;
const trimmedBinaryPath = binaryPathInput.trim();
const savedBinaryPath = status?.binaryPath ?? "";
const binaryPathChanged = trimmedBinaryPath !== savedBinaryPath;
const handleBinaryPathChange = useCallback((value: string) => {
setBinaryPathInput(value);
pathDirtyRef.current = true;
setPathMessage(null);
}, []);
const handleSaveBinaryPath = useCallback(async () => {
setBusy("saving-path");
setPathMessage(null);
try {
await setCursorCliBinaryPath(trimmedBinaryPath || null);
if (!mountedRef.current) return;
pathDirtyRef.current = false;
const refreshed = await fetchCursorCliStatus();
if (mountedRef.current) {
setStatus(refreshed);
setBinaryPathInput(refreshed.binaryPath ?? "");
setPathMessage({
tone: "success",
text: trimmedBinaryPath
? t("setup.cursorCli.pathSaved", "Binary path saved and tested.")
: t("setup.cursorCli.pathCleared", "Binary path cleared; PATH auto-detection is active."),
});
}
} catch (error) {
if (mountedRef.current) {
const message = error instanceof Error ? error.message : String(error);
setPathMessage({ tone: "error", text: message });
}
} finally {
if (mountedRef.current) setBusy(null);
}
}, [t, trimmedBinaryPath]);
/*
FNXC:CursorCli 2026-07-02-00:00:
Settings Authentication owns the manual binary override because onboarding should stay a compact enable/test surface. Send the trimmed value as one string so Windows paths with spaces and .cmd/.bat shims are not quoted or split in the browser.
*/
const binaryPathControl = compact ? (
<div className="cursor-cli-binary-path-control">
<label className="cursor-cli-binary-path-label" htmlFor="cursor-cli-binary-path">
{t("setup.cursorCli.binaryPathLabel", "Cursor CLI binary path")}
</label>
<div className="cursor-cli-binary-path-row">
<input
id="cursor-cli-binary-path"
className="cursor-cli-binary-path-input"
type="text"
value={binaryPathInput}
onChange={(event) => handleBinaryPathChange(event.target.value)}
placeholder={t("setup.cursorCli.binaryPathPlaceholder", "/usr/local/bin/cursor-agent")}
disabled={busy !== null}
/>
<button type="button" className="btn btn-sm" onClick={() => void handleSaveBinaryPath()} disabled={busy !== null || !binaryPathChanged}>
{busy === "saving-path" ? t("setup.cursorCli.savingPath", "Saving…") : t("setup.cursorCli.saveAndTestPath", "Save & Test")}
</button>
</div>
<small className="settings-muted">{t("setup.cursorCli.binaryPathHelp", "Leave blank to use PATH auto-detection (`cursor-agent`, then `cursor`).")}</small>
{pathMessage ? <small className={pathMessage.tone === "error" ? "form-error" : "text-muted"}>{pathMessage.text}</small> : null}
</div>
) : null;
const actions = (
<>
@@ -97,6 +168,7 @@ export function CursorCliProviderCard({ authenticated, compact = false, onToggle
<div className="auth-provider-cli-actions">{actions}</div>
</div>
<small className="settings-muted">{statusText}</small>
{binaryPathControl}
</div>
);
}

View File

@@ -22,6 +22,7 @@ const mockFetchCustomProviders = vi.fn();
const mockCreateCustomProvider = vi.fn();
const mockFetchCursorCliStatus = vi.fn();
const mockSetCursorCliEnabled = vi.fn();
const mockSetCursorCliBinaryPath = vi.fn();
const mockUseShellConnection = vi.fn();
const mockConfirm = vi.fn();
@@ -42,6 +43,7 @@ vi.mock("../../api", () => ({
createCustomProvider: (...args: unknown[]) => mockCreateCustomProvider(...args),
fetchCursorCliStatus: (...args: unknown[]) => mockFetchCursorCliStatus(...args),
setCursorCliEnabled: (...args: unknown[]) => mockSetCursorCliEnabled(...args),
setCursorCliBinaryPath: (...args: unknown[]) => mockSetCursorCliBinaryPath(...args),
}));
// Mock CustomModelDropdown since it has complex portal behavior
@@ -1239,6 +1241,8 @@ describe("ModelOnboardingModal", () => {
expect(await screen.findByTestId("cursor-cli-provider-card")).toBeInTheDocument();
expect(screen.getByText("Cursor — via Cursor CLI")).toBeInTheDocument();
expect(screen.queryByLabelText("Cursor CLI binary path")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Save & Test" })).not.toBeInTheDocument();
});
it("renders stable onboarding-provider-icon wrappers for provider cards", async () => {

View File

@@ -59,6 +59,7 @@ import {
mockSetDroidCliEnabled,
mockFetchCursorCliStatus,
mockSetCursorCliEnabled,
mockSetCursorCliBinaryPath,
mockUseWorkspaceFileBrowser,
mockConfirm,
mockUseWorktrunkInstallStatus,
@@ -133,6 +134,7 @@ vi.mock("../../api", async (importOriginal) => {
setDroidCliEnabled: (...args: unknown[]) => mockSetDroidCliEnabled(...args),
fetchCursorCliStatus: (...args: unknown[]) => mockFetchCursorCliStatus(...args),
setCursorCliEnabled: (...args: unknown[]) => mockSetCursorCliEnabled(...args),
setCursorCliBinaryPath: (...args: unknown[]) => mockSetCursorCliBinaryPath(...args),
});
});

View File

@@ -63,6 +63,7 @@ import {
mockSetDroidCliEnabled,
mockFetchCursorCliStatus,
mockSetCursorCliEnabled,
mockSetCursorCliBinaryPath,
mockUseWorkspaceFileBrowser,
mockConfirm,
mockUseWorktrunkInstallStatus,
@@ -138,6 +139,7 @@ vi.mock("../../api", async (importOriginal) => {
setDroidCliEnabled: (...args: unknown[]) => mockSetDroidCliEnabled(...args),
fetchCursorCliStatus: (...args: unknown[]) => mockFetchCursorCliStatus(...args),
setCursorCliEnabled: (...args: unknown[]) => mockSetCursorCliEnabled(...args),
setCursorCliBinaryPath: (...args: unknown[]) => mockSetCursorCliBinaryPath(...args),
});
});
@@ -1552,6 +1554,79 @@ describe("SettingsModal", () => {
expect(await screen.findByTestId("cursor-cli-provider-card")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Enable" })).toBeInTheDocument();
expect(screen.getByLabelText("Cursor CLI binary path")).toBeInTheDocument();
expect(screen.getByText("Leave blank to use PATH auto-detection (`cursor-agent`, then `cursor`).")).toBeInTheDocument();
});
it("saves and tests a populated cursor cli binary override", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [{ id: "cursor-cli", name: "Cursor — via Cursor CLI", authenticated: false, type: "cli" }],
});
mockFetchCursorCliStatus
.mockResolvedValueOnce({
binary: { available: true, version: "0.1.0", binaryPath: "cursor-agent", probeDurationMs: 8 },
enabled: false,
extension: null,
ready: false,
})
.mockResolvedValueOnce({
binary: { available: true, version: "0.1.0", binaryPath: "C:\\Users\\A User\\AppData\\Roaming\\npm\\cursor-agent.cmd", configuredBinaryPath: "C:\\Users\\A User\\AppData\\Roaming\\npm\\cursor-agent.cmd", usingConfiguredBinaryPath: true, probeDurationMs: 8 },
enabled: false,
binaryPath: "C:\\Users\\A User\\AppData\\Roaming\\npm\\cursor-agent.cmd",
extension: null,
ready: false,
});
mockSetCursorCliBinaryPath.mockResolvedValueOnce({ enabled: false, binaryPath: "C:\\Users\\A User\\AppData\\Roaming\\npm\\cursor-agent.cmd", restartRequired: false });
renderModal();
await waitForSettingsModalReady();
const input = await screen.findByLabelText("Cursor CLI binary path");
fireEvent.change(input, { target: { value: " C:\\Users\\A User\\AppData\\Roaming\\npm\\cursor-agent.cmd " } });
await waitFor(() => expect(screen.getByRole("button", { name: "Save & Test" })).not.toBeDisabled());
fireEvent.click(screen.getByRole("button", { name: "Save & Test" }));
await waitFor(() => expect(mockSetCursorCliBinaryPath).toHaveBeenCalledWith("C:\\Users\\A User\\AppData\\Roaming\\npm\\cursor-agent.cmd"));
expect(await screen.findByText("Binary path saved and tested.")).toBeInTheDocument();
});
it("shows cursor cli override diagnostics and can clear the override", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [{ id: "cursor-cli", name: "Cursor — via Cursor CLI", authenticated: false, type: "cli" }],
});
mockFetchCursorCliStatus
.mockResolvedValueOnce({
binary: { available: false, reason: "Configured Cursor CLI binary '/bad-old' failed", binaryPath: "cursor-agent", probeDurationMs: 8 },
enabled: false,
binaryPath: "/bad-old",
extension: null,
ready: false,
})
.mockResolvedValueOnce({
binary: { available: true, binaryPath: "cursor-agent", probeDurationMs: 8 },
enabled: false,
extension: null,
ready: false,
});
mockSetCursorCliBinaryPath
.mockRejectedValueOnce(new Error("Cannot save Cursor CLI binary path: Configured Cursor CLI binary '/missing/cursor-agent' failed"))
.mockResolvedValueOnce({ enabled: false, restartRequired: false });
renderModal();
await waitForSettingsModalReady();
const input = await screen.findByLabelText("Cursor CLI binary path");
fireEvent.change(input, { target: { value: "/missing/cursor-agent" } });
await waitFor(() => expect(screen.getByRole("button", { name: "Save & Test" })).not.toBeDisabled());
fireEvent.click(screen.getByRole("button", { name: "Save & Test" }));
expect(await screen.findByText("Cannot save Cursor CLI binary path: Configured Cursor CLI binary '/missing/cursor-agent' failed")).toBeInTheDocument();
fireEvent.change(input, { target: { value: "" } });
await waitFor(() => expect(screen.getByRole("button", { name: "Save & Test" })).not.toBeDisabled());
fireEvent.click(screen.getByRole("button", { name: "Save & Test" }));
await waitFor(() => expect(mockSetCursorCliBinaryPath).toHaveBeenLastCalledWith(null));
expect(await screen.findByText("Binary path cleared; PATH auto-detection is active.")).toBeInTheDocument();
});
it("disables cursor enable action when binary is unavailable", async () => {

View File

@@ -58,6 +58,7 @@ import {
mockSetDroidCliEnabled,
mockFetchCursorCliStatus,
mockSetCursorCliEnabled,
mockSetCursorCliBinaryPath,
mockUseWorkspaceFileBrowser,
mockConfirm,
mockUseWorktrunkInstallStatus,
@@ -130,6 +131,7 @@ vi.mock("../../api", async (importOriginal) => {
setDroidCliEnabled: (...args: unknown[]) => mockSetDroidCliEnabled(...args),
fetchCursorCliStatus: (...args: unknown[]) => mockFetchCursorCliStatus(...args),
setCursorCliEnabled: (...args: unknown[]) => mockSetCursorCliEnabled(...args),
setCursorCliBinaryPath: (...args: unknown[]) => mockSetCursorCliBinaryPath(...args),
});
});

View File

@@ -60,6 +60,7 @@ import {
mockSetDroidCliEnabled,
mockFetchCursorCliStatus,
mockSetCursorCliEnabled,
mockSetCursorCliBinaryPath,
mockUseWorkspaceFileBrowser,
mockConfirm,
mockUseWorktrunkInstallStatus,
@@ -131,6 +132,7 @@ vi.mock("../../api", async (importOriginal) => {
setDroidCliEnabled: (...args: unknown[]) => mockSetDroidCliEnabled(...args),
fetchCursorCliStatus: (...args: unknown[]) => mockFetchCursorCliStatus(...args),
setCursorCliEnabled: (...args: unknown[]) => mockSetCursorCliEnabled(...args),
setCursorCliBinaryPath: (...args: unknown[]) => mockSetCursorCliBinaryPath(...args),
});
});

View File

@@ -80,6 +80,7 @@ export const mockFetchDroidCliStatus = vi.fn();
export const mockSetDroidCliEnabled = vi.fn();
export const mockFetchCursorCliStatus = vi.fn();
export const mockSetCursorCliEnabled = vi.fn();
export const mockSetCursorCliBinaryPath = vi.fn();
export const mockUseWorkspaceFileBrowser = vi.fn();
export const mockConfirm = vi.fn();
export const mockUseWorktrunkInstallStatus = vi.fn();
@@ -429,10 +430,12 @@ export function installSettingsModalEnv() {
mockFetchCursorCliStatus.mockResolvedValue({
binary: { available: true, version: "0.1.0", binaryPath: "/usr/local/bin/cursor-agent", probeDurationMs: 8 },
enabled: false,
binaryPath: undefined,
extension: null,
ready: false,
});
mockSetCursorCliEnabled.mockResolvedValue({ enabled: true, restartRequired: false });
mockSetCursorCliBinaryPath.mockResolvedValue({ enabled: false, restartRequired: false });
mockUseWorkspaceFileBrowser.mockReturnValue({
entries: [],
currentPath: ".",

View File

@@ -1659,6 +1659,107 @@ describe("Droid CLI auth routes", () => {
expect(store.updateGlobalSettings).toHaveBeenCalledWith({ useCursorCli: true });
});
it("POST /auth/cursor-cli saves a validated binary path without toggling", async () => {
vi.spyOn(runtimeProviderProbesModule, "probeCursorCliProvider").mockResolvedValue({
available: true,
version: "cursor-agent 1.0.0",
binaryPath: "/opt/Cursor/cursor-agent",
configuredBinaryPath: "/opt/Cursor/cursor-agent",
usingConfiguredBinaryPath: true,
probeDurationMs: 8,
});
store.getGlobalSettingsStore = vi.fn().mockReturnValue({
...createMockGlobalSettingsStore(),
getSettings: vi.fn().mockResolvedValue({ useCursorCli: false }),
});
store.updateGlobalSettings = vi.fn().mockResolvedValue({ useCursorCli: false, cursorCliBinaryPath: "/opt/Cursor/cursor-agent" });
const res = await REQUEST(buildApp(), "POST", "/api/auth/cursor-cli", JSON.stringify({ binaryPath: " /opt/Cursor/cursor-agent " }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body).toEqual({ enabled: false, binaryPath: "/opt/Cursor/cursor-agent", restartRequired: false });
expect(runtimeProviderProbesModule.probeCursorCliProvider).toHaveBeenCalledWith({ binaryPath: "/opt/Cursor/cursor-agent" });
expect(store.updateGlobalSettings).toHaveBeenCalledWith({ cursorCliBinaryPath: "/opt/Cursor/cursor-agent" });
});
it("POST /auth/cursor-cli rejects invalid binaryPath values", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/auth/cursor-cli", JSON.stringify({ enabled: false, binaryPath: 123 }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("binaryPath must be a string or null");
});
it("POST /auth/cursor-cli rejects configured paths that only succeed via PATH fallback", async () => {
vi.spyOn(runtimeProviderProbesModule, "probeCursorCliProvider").mockResolvedValue({
available: true,
version: "cursor-agent 1.0.0",
binaryPath: "cursor-agent",
configuredBinaryPath: "/missing/cursor-agent",
usingConfiguredBinaryPath: false,
reason: "Configured Cursor CLI binary '/missing/cursor-agent' failed; PATH fallback succeeded",
probeDurationMs: 8,
});
const res = await REQUEST(buildApp(), "POST", "/api/auth/cursor-cli", JSON.stringify({ binaryPath: "/missing/cursor-agent" }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("Cannot save Cursor CLI binary path");
expect(store.updateGlobalSettings).not.toHaveBeenCalled();
});
it("POST /auth/cursor-cli clears the binary path and restores PATH auto-detection", async () => {
vi.spyOn(runtimeProviderProbesModule, "probeCursorCliProvider").mockResolvedValue({
available: true,
version: "cursor-agent 1.0.0",
binaryPath: "cursor-agent",
probeDurationMs: 8,
});
store.getGlobalSettingsStore = vi.fn().mockReturnValue({
...createMockGlobalSettingsStore(),
getSettings: vi.fn().mockResolvedValue({ useCursorCli: true, cursorCliBinaryPath: "/opt/Cursor/cursor-agent" }),
});
store.updateGlobalSettings = vi.fn().mockResolvedValue({ useCursorCli: true });
const res = await REQUEST(buildApp(), "POST", "/api/auth/cursor-cli", JSON.stringify({ binaryPath: " " }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body).toEqual({ enabled: true, restartRequired: false });
expect(runtimeProviderProbesModule.probeCursorCliProvider).toHaveBeenCalledWith({ binaryPath: undefined });
expect(store.updateGlobalSettings).toHaveBeenCalledWith({ cursorCliBinaryPath: null });
});
it("POST /auth/cursor-cli enables using the stored binary override", async () => {
vi.spyOn(runtimeProviderProbesModule, "probeCursorCliProvider").mockResolvedValue({
available: true,
version: "cursor-agent 1.0.0",
binaryPath: "/opt/Cursor/cursor-agent",
configuredBinaryPath: "/opt/Cursor/cursor-agent",
usingConfiguredBinaryPath: true,
probeDurationMs: 8,
});
store.getGlobalSettingsStore = vi.fn().mockReturnValue({
...createMockGlobalSettingsStore(),
getSettings: vi.fn().mockResolvedValue({ cursorCliBinaryPath: "/opt/Cursor/cursor-agent" }),
});
store.updateGlobalSettings = vi.fn().mockResolvedValue({ useCursorCli: true, cursorCliBinaryPath: "/opt/Cursor/cursor-agent" });
const res = await REQUEST(buildApp(), "POST", "/api/auth/cursor-cli", JSON.stringify({ enabled: true }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(runtimeProviderProbesModule.probeCursorCliProvider).toHaveBeenCalledWith({ binaryPath: "/opt/Cursor/cursor-agent" });
expect(store.updateGlobalSettings).toHaveBeenCalledWith({ useCursorCli: true });
});
it("POST /auth/cursor-cli returns 400 when enabling without binary", async () => {
vi.spyOn(runtimeProviderProbesModule, "probeCursorCliProvider").mockResolvedValue({
available: false,
@@ -1696,16 +1797,43 @@ describe("Droid CLI auth routes", () => {
});
store.getGlobalSettingsStore = vi.fn().mockReturnValue({
...createMockGlobalSettingsStore(),
getSettings: vi.fn().mockResolvedValue({ useCursorCli: true }),
getSettings: vi.fn().mockResolvedValue({ useCursorCli: true, cursorCliBinaryPath: "/opt/Cursor/cursor-agent" }),
});
const res = await GET(buildApp(), "/api/providers/cursor-cli/status");
expect(res.status).toBe(200);
expect(runtimeProviderProbesModule.probeCursorCliProvider).toHaveBeenCalledWith({ binaryPath: "/opt/Cursor/cursor-agent" });
expect(res.body.ready).toBe(true);
expect(res.body.enabled).toBe(true);
expect(res.body.binaryPath).toBe("/opt/Cursor/cursor-agent");
expect(res.body.binary.available).toBe(true);
});
it("GET /auth/status probes Cursor CLI with the stored override", async () => {
vi.spyOn(runtimeProviderProbesModule, "probeCursorCliProvider").mockResolvedValue({
available: true,
version: "cursor-agent 1.0.0",
binaryPath: "C:\\Users\\A User\\AppData\\Roaming\\npm\\cursor-agent.cmd",
configuredBinaryPath: "C:\\Users\\A User\\AppData\\Roaming\\npm\\cursor-agent.cmd",
usingConfiguredBinaryPath: true,
probeDurationMs: 8,
});
store.getGlobalSettingsStore = vi.fn().mockReturnValue({
...createMockGlobalSettingsStore(),
getSettings: vi.fn().mockResolvedValue({ useCursorCli: true, cursorCliBinaryPath: "C:\\Users\\A User\\AppData\\Roaming\\npm\\cursor-agent.cmd" }),
});
const res = await GET(buildApp(), "/api/auth/status");
expect(res.status).toBe(200);
expect(runtimeProviderProbesModule.probeCursorCliProvider).toHaveBeenCalledWith({ binaryPath: "C:\\Users\\A User\\AppData\\Roaming\\npm\\cursor-agent.cmd" });
expect(res.body.providers).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "cursor-cli", authenticated: true }),
]),
);
});
it("GET /providers/cursor-cli/status returns ready false when binary unavailable", async () => {
vi.spyOn(runtimeProviderProbesModule, "probeCursorCliProvider").mockResolvedValue({
available: false,

View File

@@ -36,6 +36,24 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
return authStorage;
}
function normalizeCursorCliBinaryPath(value: unknown): string | undefined {
return typeof value === "string" ? value.trim() || undefined : undefined;
}
async function readCursorCliBinaryPath(): Promise<string | undefined> {
/*
FNXC:CursorCli 2026-07-02-00:00:
Auth provider list, status, enable, and path-save validation must all probe the same trimmed global Cursor CLI binary override before falling back to PATH candidates.
*/
if (!store) return undefined;
const globalSettings = await store.getGlobalSettingsStore().getSettings();
return normalizeCursorCliBinaryPath(globalSettings.cursorCliBinaryPath);
}
async function probeCursorCliWithStoredBinary() {
return probeCursorCliProvider({ binaryPath: await readCursorCliBinaryPath() });
}
/**
* Mask an API key for safe display.
* - If key length <= 8: return 8 bullets (never reveal short keys)
@@ -420,7 +438,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
} catch {
// best effort
}
const cursorBinary = await probeCursorCliProvider();
const cursorBinary = await probeCursorCliWithStoredBinary();
providers.push({
id: "cursor-cli",
name: "Cursor — via Cursor CLI",
@@ -728,21 +746,55 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
if (!store) {
throw new ApiError(500, "Settings store unavailable");
}
const enabled = req.body?.enabled;
if (typeof enabled !== "boolean") {
const requestedEnabled = req.body?.enabled;
const hasEnabledPatch = Object.prototype.hasOwnProperty.call(req.body ?? {}, "enabled");
const requestedBinaryPath = req.body?.binaryPath;
const hasBinaryPathPatch = Object.prototype.hasOwnProperty.call(req.body ?? {}, "binaryPath");
if (!hasEnabledPatch && !hasBinaryPathPatch) {
throw badRequest("enabled or binaryPath is required");
}
if (hasEnabledPatch && typeof requestedEnabled !== "boolean") {
throw badRequest("enabled must be a boolean");
}
if (hasBinaryPathPatch && requestedBinaryPath !== null && typeof requestedBinaryPath !== "string") {
throw badRequest("binaryPath must be a string or null");
}
const currentSettings = await store.getGlobalSettingsStore().getSettings();
const enabled = hasEnabledPatch ? requestedEnabled : (currentSettings as Record<string, unknown>).useCursorCli === true;
const currentBinaryPath = normalizeCursorCliBinaryPath(currentSettings.cursorCliBinaryPath);
const nextBinaryPath = hasBinaryPathPatch
? normalizeCursorCliBinaryPath(requestedBinaryPath)
: currentBinaryPath;
if (hasBinaryPathPatch && nextBinaryPath) {
const binary = await probeCursorCliProvider({ binaryPath: nextBinaryPath });
if (!binary.available || !binary.usingConfiguredBinaryPath) {
throw new ApiError(400, `Cannot save Cursor CLI binary path: ${binary.reason ?? "configured binary not available"}`);
}
}
if (enabled) {
const binary = await probeCursorCliProvider();
const binary = await probeCursorCliProvider({ binaryPath: nextBinaryPath });
if (!binary.available) {
throw new ApiError(400, `Cannot enable Cursor CLI routing: ${binary.reason ?? "cursor binary not available"}`);
}
}
const settings = await store.updateGlobalSettings({ useCursorCli: enabled } as Record<string, unknown>);
const patch: Record<string, unknown> = {};
if (hasEnabledPatch) {
patch.useCursorCli = enabled;
}
if (hasBinaryPathPatch) {
patch.cursorCliBinaryPath = nextBinaryPath ?? null;
}
const settings = await store.updateGlobalSettings(patch);
invalidateAllGlobalSettingsCaches();
res.json({ enabled: (settings as Record<string, unknown>).useCursorCli === true, restartRequired: false });
res.json({
enabled: (settings as Record<string, unknown>).useCursorCli === true,
binaryPath: normalizeCursorCliBinaryPath((settings as Record<string, unknown>).cursorCliBinaryPath),
restartRequired: false,
});
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
@@ -751,7 +803,8 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
router.get("/providers/cursor-cli/status", async (_req, res) => {
try {
const binary = await probeCursorCliProvider();
const binaryPath = await readCursorCliBinaryPath();
const binary = await probeCursorCliProvider({ binaryPath });
let enabled = false;
if (store) {
try {
@@ -761,7 +814,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
// best effort
}
}
res.json({ binary, enabled, extension: null, ready: enabled && binary.available });
res.json({ binary, enabled, binaryPath, extension: null, ready: enabled && binary.available });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);

View File

@@ -12,9 +12,13 @@ describe("probeCursorBinary", () => {
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" });
const result = await probeCursorBinary({ binaryPath: "/usr/local/bin/cursor-agent" });
expect(runCursorCommand).toHaveBeenCalledWith("/usr/local/bin/cursor-agent", ["--version"], 3000);
expect(result.available).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 keychain lock as auth failure", async () => {
@@ -74,4 +78,59 @@ describe("probeCursorBinary", () => {
expect(result.reason).toContain("cursor-agent: spawn error: ENOENT");
expect(result.reason).toContain("cursor: spawn error: ENOENT");
});
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: "" });
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(result.binaryPath).toBe(binaryPath);
expect(result.usingConfiguredBinaryPath).toBe(true);
});
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: "" });
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(result.available).toBe(true);
expect(result.binaryPath).toBe("cursor-agent");
expect(result.usingConfiguredBinaryPath).toBe(false);
expect(result.diagnostics?.[0]).toContain("/missing/cursor-agent: spawn error: ENOENT");
});
it("reports configured-path and fallback diagnostics when every candidate fails", async () => {
vi.mocked(runCursorCommand)
.mockResolvedValueOnce({ code: 126, stdout: "", stderr: "spawn error: EACCES: /opt/Cursor/cursor-agent" })
.mockResolvedValueOnce({ code: 127, stdout: "", stderr: "spawn error: ENOENT: cursor-agent" })
.mockResolvedValueOnce({ code: 127, stdout: "", stderr: "spawn error: ENOENT: cursor" });
const result = await probeCursorBinary({ binaryPath: "/opt/Cursor/cursor-agent" });
expect(result.available).toBe(false);
expect(result.reason).toContain("Configured Cursor CLI binary '/opt/Cursor/cursor-agent' failed");
expect(result.reason).toContain("/opt/Cursor/cursor-agent: spawn error: EACCES");
expect(result.reason).toContain("cursor-agent: spawn error: ENOENT");
expect(result.reason).toContain("cursor: spawn error: ENOENT");
});
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: "" });
const result = await probeCursorBinary({ binaryPath: " cursor-agent " });
expect(runCursorCommand).toHaveBeenCalledTimes(2);
expect(runCursorCommand).toHaveBeenNthCalledWith(1, "cursor-agent", ["--version"], 3000);
expect(runCursorCommand).toHaveBeenNthCalledWith(2, "cursor", ["--version"], 3000);
expect(result.binaryPath).toBe("cursor");
});
});

View File

@@ -18,6 +18,16 @@ describe("discoverCursorModels", () => {
expect(result.fallbackUsed).toBe(false);
});
it("passes Windows .bat paths with spaces as one binary string", async () => {
vi.mocked(runCursorCommand).mockResolvedValueOnce({ code: 0, stdout: '["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(result.models).toEqual(["cursor/a"]);
});
it("falls back to text parsing", async () => {
vi.mocked(runCursorCommand)
.mockResolvedValueOnce({ code: 1, stdout: "", stderr: "" })

View File

@@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../probe.js", () => ({ probeCursorBinary: vi.fn() }));
vi.mock("../process-manager.js", () => ({ discoverCursorModels: vi.fn() }));
import { discoverCursorModels } from "../process-manager.js";
import { probeCursorBinary } from "../probe.js";
import { discoverCursorProviderModels } from "../provider.js";
describe("discoverCursorProviderModels", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("uses the override-aware probe binary for model discovery", async () => {
vi.mocked(probeCursorBinary).mockResolvedValue({
available: true,
authenticated: true,
binaryName: "C:\\Users\\A User\\AppData\\Roaming\\npm\\cursor-agent.cmd",
binaryPath: "C:\\Users\\A User\\AppData\\Roaming\\npm\\cursor-agent.cmd",
configuredBinaryPath: "C:\\Users\\A User\\AppData\\Roaming\\npm\\cursor-agent.cmd",
usingConfiguredBinaryPath: true,
probeDurationMs: 12,
});
vi.mocked(discoverCursorModels).mockResolvedValue({
models: ["cursor/a"],
source: "models-json",
fallbackUsed: false,
});
const result = await discoverCursorProviderModels({ binaryPath: "C:\\Users\\A User\\AppData\\Roaming\\npm\\cursor-agent.cmd" });
expect(probeCursorBinary).toHaveBeenCalledWith({ binaryPath: "C:\\Users\\A User\\AppData\\Roaming\\npm\\cursor-agent.cmd" });
expect(discoverCursorModels).toHaveBeenCalledWith("C:\\Users\\A User\\AppData\\Roaming\\npm\\cursor-agent.cmd");
expect(result.models).toEqual([{ id: "cursor/a", label: "cursor/a" }]);
});
it("returns probe diagnostics when no effective binary is available", async () => {
vi.mocked(probeCursorBinary).mockResolvedValue({
available: false,
authenticated: false,
configuredBinaryPath: "/missing/cursor-agent",
reason: "Configured Cursor CLI binary '/missing/cursor-agent' failed; PATH fallback cursor-agent/cursor also failed",
probeDurationMs: 10,
});
const result = await discoverCursorProviderModels({ binaryPath: "/missing/cursor-agent" });
expect(discoverCursorModels).not.toHaveBeenCalled();
expect(result).toEqual({
models: [],
source: "probe",
fallbackUsed: true,
reason: "Configured Cursor CLI binary '/missing/cursor-agent' failed; PATH fallback cursor-agent/cursor also failed",
});
});
});

View File

@@ -4,6 +4,16 @@ 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;
@@ -14,13 +24,21 @@ function summarizeFailure(binary: string, stdout: string, stderr: string): strin
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 { 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) {
// NOTE: Cursor CLI currently lacks a stable auth-status contract we can
// invoke without side effects. Treating successful --version as ready is
@@ -29,10 +47,8 @@ export async function probeCursorBinary(options?: { timeoutMs?: number; binaryPa
return {
available: true,
authenticated: true,
binaryName: binary,
binaryPath: binary,
...common,
version: version.stdout.trim() || undefined,
probeDurationMs: Date.now() - startedAt,
};
}
@@ -41,10 +57,8 @@ export async function probeCursorBinary(options?: { timeoutMs?: number; binaryPa
return {
available: true,
authenticated: false,
binaryName: binary,
binaryPath: binary,
...common,
reason: "macOS login keychain is locked",
probeDurationMs: Date.now() - startedAt,
};
}
@@ -52,18 +66,21 @@ export async function probeCursorBinary(options?: { timeoutMs?: number; binaryPa
return {
available: true,
authenticated: false,
binaryName: binary,
binaryPath: binary,
...common,
reason: "Cursor IDE installation not found",
probeDurationMs: Date.now() - startedAt,
};
}
}
const baseReason = options?.binaryPath ? `${options.binaryPath} not found on PATH` : "cursor-agent/cursor not found on PATH";
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,
};

View File

@@ -1,12 +1,21 @@
import { discoverCursorModels } from "./process-manager.js";
import { probeCursorBinary } from "./probe.js";
export async function discoverCursorProviderModels() {
const probe = await probeCursorBinary();
function normalizeDiscoveryOptions(options?: unknown): { binaryPath?: string; timeoutMs?: number } {
if (!options || typeof options !== "object") return {};
const record = options as Record<string, unknown>;
return {
binaryPath: typeof record.binaryPath === "string" ? record.binaryPath : undefined,
timeoutMs: typeof record.timeoutMs === "number" ? record.timeoutMs : undefined,
};
}
export async function discoverCursorProviderModels(options?: unknown) {
const probe = await probeCursorBinary(normalizeDiscoveryOptions(options));
if (!probe.available || !probe.binaryName) {
return { models: [], source: "probe", fallbackUsed: true, reason: probe.reason ?? "binary unavailable" };
}
const result = await discoverCursorModels(probe.binaryName);
const result = await discoverCursorModels(probe.binaryPath ?? probe.binaryName);
return {
models: result.models.map((id) => ({ id, label: id })),
source: result.source,

View File

@@ -3,6 +3,9 @@ export interface CursorBinaryStatus {
authenticated?: boolean;
binaryPath?: string;
binaryName?: string;
configuredBinaryPath?: string;
usingConfiguredBinaryPath?: boolean;
diagnostics?: string[];
version?: string;
reason?: string;
probeDurationMs: number;