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:
7
.changeset/fn-7419-cursor-cli-binary-path.md
Normal file
7
.changeset/fn-7419-cursor-cli-binary-path.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
34
packages/core/src/__tests__/cursor-cli-settings.test.ts
Normal file
34
packages/core/src/__tests__/cursor-cli-settings.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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: ".",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: "" })
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user