diff --git a/src/lib/providerModels/cursorAgent.ts b/src/lib/providerModels/cursorAgent.ts index b30fb690f8..49749dcb22 100644 --- a/src/lib/providerModels/cursorAgent.ts +++ b/src/lib/providerModels/cursorAgent.ts @@ -107,11 +107,36 @@ export function humanizeCursorModelId(id: string): string { } export function parseCursorAgentModels(text: string): string[] { - const match = text.match(/Available models:\s*([^\n]+)/); - if (!match) return []; + // Older Cursor Agent releases only exposed the catalog as part of the + // invalid-model error produced by `--model --help`. + const legacyMatch = text.match(/Available models:\s*([^\n]+)/); + if (legacyMatch) { + return deduplicateCursorModelIds(legacyMatch[1].split(",")); + } + + // Current releases expose an official `models` command whose output is: + // + // Available models + // + // auto - Auto (default) + // gpt-5.3-codex - Codex 5.3 + const headerMatch = /(?:^|\n)Available models\s*(?:\n|$)/.exec(text); + if (!headerMatch) return []; + const lines = text.slice(headerMatch.index + headerMatch[0].length).split("\n"); + const ids: string[] = []; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith("Tip:")) break; + const separator = trimmed.indexOf(" - "); + if (separator > 0) ids.push(trimmed.slice(0, separator)); + } + return deduplicateCursorModelIds(ids); +} + +function deduplicateCursorModelIds(ids: string[]): string[] { const seen = new Set(); const out: string[] = []; - for (const raw of match[1].split(",")) { + for (const raw of ids) { const id = raw.trim(); if (!id || seen.has(id)) continue; seen.add(id); @@ -138,12 +163,13 @@ export async function fetchCursorAgentModels( ); } - // cursor-agent prints "Available models: ..." to stderr and exits non-zero - // when given an unknown model id, so we intentionally pass `--help` as the - // model value to coerce it into listing. + const startedAt = Date.now(); let result: { stdout: string; stderr: string }; try { - result = await runCursorAgent(binary, ["--model", "--help"], timeoutMs); + // Modern Cursor Agent releases provide a dedicated catalog flag. Prefer the + // flag over the equivalent `models` subcommand because older releases can + // interpret an unknown positional subcommand as an agent prompt. + result = await runCursorAgent(binary, ["--list-models"], timeoutMs); } catch (err: unknown) { const e = err as NodeJS.ErrnoException; if (e?.code === "ENOENT") { @@ -151,11 +177,24 @@ export async function fetchCursorAgentModels( } throw err; } - const combined = `${result.stdout}\n${result.stderr}`; + let combined = `${result.stdout}\n${result.stderr}`; + let ids = parseCursorAgentModels(combined); + + // Backward compatibility for releases from before the dedicated catalog interface. + if (ids.length === 0 && !/Authentication required|Not logged in/i.test(combined)) { + const remainingTimeoutMs = timeoutMs - (Date.now() - startedAt); + if (remainingTimeoutMs > 0) { + result = await runCursorAgent(binary, ["--model", "--help"], remainingTimeoutMs); + combined = `${result.stdout}\n${result.stderr}`; + ids = parseCursorAgentModels(combined); + } + } - const ids = parseCursorAgentModels(combined); if (ids.length === 0) { - throw new Error("cursor-agent did not return an 'Available models:' line"); + if (/Authentication required|Not logged in/i.test(combined)) { + throw new Error("cursor-agent is not authenticated; run 'agent login' on the OmniRoute host"); + } + throw new Error("cursor-agent did not return a model catalog from 'agent --list-models'"); } return ids.map((id) => ({ diff --git a/tests/unit/cursor-agent-models.test.ts b/tests/unit/cursor-agent-models.test.ts index a59076f3ff..a75e658974 100644 --- a/tests/unit/cursor-agent-models.test.ts +++ b/tests/unit/cursor-agent-models.test.ts @@ -22,6 +22,21 @@ test("parseCursorAgentModels deduplicates and trims", () => { assert.deepEqual(parseCursorAgentModels("Available models: a, a , b"), ["a", "b"]); }); +test("parseCursorAgentModels parses the multiline output from the models command", () => { + const text = `Available models + +auto - Auto (default) +gpt-5.3-codex - Codex 5.3 +claude-opus-4-8-thinking-high-fast - Opus 4.8 1M Thinking Fast + +Tip: use --model to switch.`; + assert.deepEqual(parseCursorAgentModels(text), [ + "auto", + "gpt-5.3-codex", + "claude-opus-4-8-thinking-high-fast", + ]); +}); + test("parseCursorAgentModels returns [] when the marker is missing", () => { assert.deepEqual(parseCursorAgentModels("nothing here"), []); }); @@ -40,7 +55,10 @@ test("humanizeCursorModelId pretty-prints common patterns", () => { humanizeCursorModelId("claude-opus-4-8-thinking-high-fast"), "Claude Opus 4.8 Thinking High Fast" ); - assert.equal(humanizeCursorModelId("claude-fable-5-thinking-xhigh"), "Claude Fable 5 Thinking XHigh"); + assert.equal( + humanizeCursorModelId("claude-fable-5-thinking-xhigh"), + "Claude Fable 5 Thinking XHigh" + ); assert.equal(humanizeCursorModelId("claude-sonnet-5-max"), "Claude Sonnet 5 Max"); assert.equal(humanizeCursorModelId("kimi-k2.5"), "Kimi K2.5"); assert.equal(humanizeCursorModelId("gemini-3.1-pro"), "Gemini 3.1 Pro");