diff --git a/.changeset/fn-7419-cursor-cli-binary-path.md b/.changeset/fn-7419-cursor-cli-binary-path.md new file mode 100644 index 0000000000..837c70e017 --- /dev/null +++ b/.changeset/fn-7419-cursor-cli-binary-path.md @@ -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. diff --git a/docs/cursor-cli-contract.md b/docs/cursor-cli-contract.md index c2e41db121..ad199744a5 100644 --- a/docs/cursor-cli-contract.md +++ b/docs/cursor-cli-contract.md @@ -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//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 + + + +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 diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 0877914045..c398bf4c2f 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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. | diff --git a/packages/core/src/__tests__/cursor-cli-settings.test.ts b/packages/core/src/__tests__/cursor-cli-settings.test.ts new file mode 100644 index 0000000000..9b143fa2f4 --- /dev/null +++ b/packages/core/src/__tests__/cursor-cli-settings.test.ts @@ -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"); + }); +}); diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 0ac270bd1c..cebb36cda4 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -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, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index fda2d306d6..3042314192 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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 diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 8a2b5496d2..257f4a1e54 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -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, diff --git a/packages/dashboard/app/components/CursorCliProviderCard.css b/packages/dashboard/app/components/CursorCliProviderCard.css index 8f693b7f39..cbe7824d06 100644 --- a/packages/dashboard/app/components/CursorCliProviderCard.css +++ b/packages/dashboard/app/components/CursorCliProviderCard.css @@ -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%; + } } diff --git a/packages/dashboard/app/components/CursorCliProviderCard.tsx b/packages/dashboard/app/components/CursorCliProviderCard.tsx index 3a29f669c8..4a13b1a7ad 100644 --- a/packages/dashboard/app/components/CursorCliProviderCard.tsx +++ b/packages/dashboard/app/components/CursorCliProviderCard.tsx @@ -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(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 ? ( +
+ +
+ handleBinaryPathChange(event.target.value)} + placeholder={t("setup.cursorCli.binaryPathPlaceholder", "/usr/local/bin/cursor-agent")} + disabled={busy !== null} + /> + +
+ {t("setup.cursorCli.binaryPathHelp", "Leave blank to use PATH auto-detection (`cursor-agent`, then `cursor`).")} + {pathMessage ? {pathMessage.text} : null} +
+ ) : null; const actions = ( <> @@ -97,6 +168,7 @@ export function CursorCliProviderCard({ authenticated, compact = false, onToggle
{actions}
{statusText} + {binaryPathControl} ); } diff --git a/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx b/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx index 47f9015f1d..f6d9a72c3f 100644 --- a/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx @@ -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 () => { diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx index 3b28b3639e..e5d78cb789 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx @@ -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), }); }); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx index a13481d099..9f6d345c74 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx @@ -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 () => { diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.remote-notifications.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.remote-notifications.test.tsx index 535907e861..77b20b6a0d 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.remote-notifications.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.remote-notifications.test.tsx @@ -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), }); }); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx index 13a2bbc89d..97ed866c14 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx @@ -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), }); }); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.test-harness.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.test-harness.tsx index 8a1bcb852f..607987169a 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.test-harness.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.test-harness.tsx @@ -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: ".", diff --git a/packages/dashboard/src/__tests__/routes-auth.test.ts b/packages/dashboard/src/__tests__/routes-auth.test.ts index b4950cd243..3eee0e51c2 100644 --- a/packages/dashboard/src/__tests__/routes-auth.test.ts +++ b/packages/dashboard/src/__tests__/routes-auth.test.ts @@ -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, diff --git a/packages/dashboard/src/routes/register-auth-routes.ts b/packages/dashboard/src/routes/register-auth-routes.ts index 0ac34a939f..db8a3d66ad 100644 --- a/packages/dashboard/src/routes/register-auth-routes.ts +++ b/packages/dashboard/src/routes/register-auth-routes.ts @@ -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 { + /* + 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).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); + const patch: Record = {}; + if (hasEnabledPatch) { + patch.useCursorCli = enabled; + } + if (hasBinaryPathPatch) { + patch.cursorCliBinaryPath = nextBinaryPath ?? null; + } + const settings = await store.updateGlobalSettings(patch); invalidateAllGlobalSettingsCaches(); - res.json({ enabled: (settings as Record).useCursorCli === true, restartRequired: false }); + res.json({ + enabled: (settings as Record).useCursorCli === true, + binaryPath: normalizeCursorCliBinaryPath((settings as Record).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); diff --git a/plugins/fusion-plugin-cursor-runtime/src/__tests__/probe.test.ts b/plugins/fusion-plugin-cursor-runtime/src/__tests__/probe.test.ts index cf77d4610e..d0d2aa6d2a 100644 --- a/plugins/fusion-plugin-cursor-runtime/src/__tests__/probe.test.ts +++ b/plugins/fusion-plugin-cursor-runtime/src/__tests__/probe.test.ts @@ -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"); + }); }); diff --git a/plugins/fusion-plugin-cursor-runtime/src/__tests__/process-manager.test.ts b/plugins/fusion-plugin-cursor-runtime/src/__tests__/process-manager.test.ts index 7918bf2536..e577dfeed2 100644 --- a/plugins/fusion-plugin-cursor-runtime/src/__tests__/process-manager.test.ts +++ b/plugins/fusion-plugin-cursor-runtime/src/__tests__/process-manager.test.ts @@ -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: "" }) diff --git a/plugins/fusion-plugin-cursor-runtime/src/__tests__/provider.test.ts b/plugins/fusion-plugin-cursor-runtime/src/__tests__/provider.test.ts new file mode 100644 index 0000000000..f2159a58ed --- /dev/null +++ b/plugins/fusion-plugin-cursor-runtime/src/__tests__/provider.test.ts @@ -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", + }); + }); +}); diff --git a/plugins/fusion-plugin-cursor-runtime/src/probe.ts b/plugins/fusion-plugin-cursor-runtime/src/probe.ts index 0dc20673cc..c0ead7aa44 100644 --- a/plugins/fusion-plugin-cursor-runtime/src/probe.ts +++ b/plugins/fusion-plugin-cursor-runtime/src/probe.ts @@ -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 { 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, }; diff --git a/plugins/fusion-plugin-cursor-runtime/src/provider.ts b/plugins/fusion-plugin-cursor-runtime/src/provider.ts index b7475b47da..3b01d201ed 100644 --- a/plugins/fusion-plugin-cursor-runtime/src/provider.ts +++ b/plugins/fusion-plugin-cursor-runtime/src/provider.ts @@ -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; + 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, diff --git a/plugins/fusion-plugin-cursor-runtime/src/types.ts b/plugins/fusion-plugin-cursor-runtime/src/types.ts index daf9dd63d0..89ee2034e2 100644 --- a/plugins/fusion-plugin-cursor-runtime/src/types.ts +++ b/plugins/fusion-plugin-cursor-runtime/src/types.ts @@ -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;