diff --git a/.changeset/fn-7699-cursor-cli-binary-path-model-picker.md b/.changeset/fn-7699-cursor-cli-binary-path-model-picker.md new file mode 100644 index 0000000000..f299091536 --- /dev/null +++ b/.changeset/fn-7699-cursor-cli-binary-path-model-picker.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: The Cursor CLI binary path override now also applies to the model picker, not just sign-in/status. +category: fix +dev: /api/models reads globalSettings.cursorCliBinaryPath (trim/blank→undefined) and threads it as getCursorPickerModels({ binaryPath }) so model-picker discovery spawns the same machine-local cursor-agent used by auth/probe/status. Blank/undefined preserves PATH auto-detection. Follow-up to FN-7696. diff --git a/packages/dashboard/src/__tests__/register-model-routes-cursor-cli.test.ts b/packages/dashboard/src/__tests__/register-model-routes-cursor-cli.test.ts index 9fef18d084..16343249b2 100644 --- a/packages/dashboard/src/__tests__/register-model-routes-cursor-cli.test.ts +++ b/packages/dashboard/src/__tests__/register-model-routes-cursor-cli.test.ts @@ -25,7 +25,11 @@ import { registerModelRoutes } from "../routes/register-model-routes.js"; const mockedGetCursorPickerModels = vi.mocked(getCursorPickerModels); -function setup(useCursorCli?: boolean, registryModels?: Array<{ provider: string; id: string; name: string; reasoning: boolean; contextWindow: number }>) { +function setup( + useCursorCli?: boolean, + registryModels?: Array<{ provider: string; id: string; name: string; reasoning: boolean; contextWindow: number }>, + cursorCliBinaryPath?: unknown, +) { const getHandlers = new Map void }) => Promise>(); const router = { get: vi.fn((path: string, handler: (req: unknown, res: { json: (body: unknown) => void }) => Promise) => { @@ -35,7 +39,7 @@ function setup(useCursorCli?: boolean, registryModels?: Array<{ provider: string const store = { getGlobalSettingsStore: () => ({ - getSettings: vi.fn().mockResolvedValue({ useCursorCli }), + getSettings: vi.fn().mockResolvedValue({ useCursorCli, cursorCliBinaryPath }), }), getSettingsFast: vi.fn().mockResolvedValue({}), }; @@ -162,3 +166,61 @@ describe("registerModelRoutes cursor-cli merge and filter", () => { expect(new Set(keys).size).toBe(keys.length); }); }); + +/* +FNXC:CursorCli 2026-07-08-00:20: +FN-7699: the machine-local cursorCliBinaryPath operator override (already +honored by the auth/probe/status paths in register-auth-routes.ts) must also +apply to model-picker discovery, so an operator whose cursor-agent is not on +PATH still sees Cursor models in the picker. These tests assert the +normalized override is threaded into getCursorPickerModels({ binaryPath }) +verbatim, that blank/undefined preserves binaryPath: undefined (PATH +auto-detection), and that the toggle-off gate (FN-7696) is not regressed. +*/ +describe("registerModelRoutes cursorCliBinaryPath threading", () => { + it("threads a set cursorCliBinaryPath override into getCursorPickerModels verbatim", async () => { + mockedGetCursorPickerModels.mockResolvedValue([ + { provider: "cursor-cli", id: "cursor/gpt-5", name: "GPT-5", reasoning: false, contextWindow: 0 }, + ]); + const handler = setup(true, undefined, "/opt/Cursor/cursor-agent"); + const response = await invoke(handler); + expect(mockedGetCursorPickerModels).toHaveBeenCalledWith({ binaryPath: "/opt/Cursor/cursor-agent" }); + expect(response.models.some((m) => m.provider === "cursor-cli" && m.id === "cursor/gpt-5")).toBe(true); + }); + + it("threads a Windows-shim-style override path verbatim, with no mangling", async () => { + mockedGetCursorPickerModels.mockResolvedValue([]); + const winPath = "C:\\Users\\A User\\AppData\\Roaming\\npm\\cursor-agent.cmd"; + const handler = setup(true, undefined, winPath); + await invoke(handler); + expect(mockedGetCursorPickerModels).toHaveBeenCalledWith({ binaryPath: winPath }); + }); + + it("passes binaryPath: undefined when cursorCliBinaryPath is absent (PATH auto-detection preserved)", async () => { + mockedGetCursorPickerModels.mockResolvedValue([]); + const handler = setup(true, undefined, undefined); + await invoke(handler); + expect(mockedGetCursorPickerModels).toHaveBeenCalledWith({ binaryPath: undefined }); + }); + + it("passes binaryPath: undefined when cursorCliBinaryPath is blank/whitespace-only", async () => { + mockedGetCursorPickerModels.mockResolvedValue([]); + const handler = setup(true, undefined, " "); + await invoke(handler); + expect(mockedGetCursorPickerModels).toHaveBeenCalledWith({ binaryPath: undefined }); + }); + + it("passes binaryPath: undefined when cursorCliBinaryPath is an empty string", async () => { + mockedGetCursorPickerModels.mockResolvedValue([]); + const handler = setup(true, undefined, ""); + await invoke(handler); + expect(mockedGetCursorPickerModels).toHaveBeenCalledWith({ binaryPath: undefined }); + }); + + it("does not surface cursor-cli rows or call getCursorPickerModels when useCursorCli is false, regardless of cursorCliBinaryPath", async () => { + const handler = setup(false, undefined, "/opt/Cursor/cursor-agent"); + const response = await invoke(handler); + expect(mockedGetCursorPickerModels).not.toHaveBeenCalled(); + expect(response.models.some((m) => m.provider === "cursor-cli")).toBe(false); + }); +}); diff --git a/packages/dashboard/src/routes/register-model-routes.ts b/packages/dashboard/src/routes/register-model-routes.ts index 91ade5e9a9..514479d6bd 100644 --- a/packages/dashboard/src/routes/register-model-routes.ts +++ b/packages/dashboard/src/routes/register-model-routes.ts @@ -162,6 +162,7 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => { let useDroidCli = false; let useLlamaCpp = false; let useCursorCli = false; + let cursorCliBinaryPath: string | undefined; let resolvedPlanningProvider: string | undefined; let resolvedPlanningModelId: string | undefined; let customProviders: CustomProvider[] = []; @@ -177,6 +178,20 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => { useDroidCli = globalSettings.useDroidCli === true; useLlamaCpp = globalSettings.useLlamaCpp === true; useCursorCli = (globalSettings as Record).useCursorCli === true; + /* + FNXC:CursorCli 2026-07-08-00:20: + FN-7699 (follow-up to FN-7696): the machine-local `cursorCliBinaryPath` + operator override must apply to model-picker discovery too, not just + the auth/probe/status paths (register-auth-routes.ts's + normalizeCursorCliBinaryPath). Mirror the same trim/blank->undefined + normalization here so a blank/unset override preserves PATH + auto-detection byte-for-byte, and a set override threads through to + getCursorPickerModels below so discovery spawns the exact same + cursor-agent executable the settings card already validated. + */ + const rawCursorCliBinaryPath = (globalSettings as Record).cursorCliBinaryPath; + cursorCliBinaryPath = + typeof rawCursorCliBinaryPath === "string" ? rawCursorCliBinaryPath.trim() || undefined : undefined; customProviders = globalSettings.customProviders ?? []; const mergedSettings = await store.getSettingsFast(); @@ -299,6 +314,15 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => { the existing seenModelKeys provider/id dedup so an existing row always wins over a colliding Cursor row — purely additive, must never displace, overwrite, or filter out an existing row. + + FNXC:CursorCli 2026-07-08-00:20: + FN-7699: thread the normalized cursorCliBinaryPath operator override + (see the globalSettings read block above) into getCursorPickerModels so + discovery spawns the exact same machine-local cursor-agent executable + already validated by the auth/probe/status paths. Blank/undefined + preserves PATH auto-detection unchanged; the cache is keyed per + resolved binary path so the override participates correctly in + TTL/single-flight caching. */ if (useCursorCli) { // getCursorPickerModels never throws by contract (see @@ -306,7 +330,7 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => { // a Cursor discovery failure can never reject the /models handler or // drop existing rows — degrade to zero Cursor rows instead. try { - const cursorModels = await getCursorPickerModels(); + const cursorModels = await getCursorPickerModels({ binaryPath: cursorCliBinaryPath }); for (const cursorModel of cursorModels) { const key = `${cursorModel.provider}/${cursorModel.id}`; if (seenModelKeys.has(key)) continue;