FN-7697: fix Cursor CLI auth-status and model-list discovery

Corrects the Cursor plugin's CLI integration to match cursor-agent's real command contract instead of best-effort heuristics.

- Derive authentication from `cursor-agent status --format json` (`isAuthenticated` field), failing closed with an actionable reason on non-zero exit or malformed JSON, instead of treating `--version` success as auth-ready.
- Switch model discovery to `cursor-agent models` plain-text output (`id - Label` lines), filtering header/tip/empty-state lines, since `--json`/`model list` are not supported.
- Update README to document the corrected CLI usage.
- Add regression tests covering probe.ts auth-status parsing and process-manager.ts model discovery.
- Add changeset (patch) documenting the fix.

Files changed:
 .changeset/fn-7697-cursor-cli.md                   |  7 ++
 plugins/fusion-plugin-cursor-runtime/README.md     |  3 +-
 .../src/__tests__/probe.test.ts                    | 94 +++++++++++++++++++---
 .../src/__tests__/process-manager.test.ts          | 89 +++++++++++++-------
 plugins/fusion-plugin-cursor-runtime/src/probe.ts  | 39 +++++++--
 .../src/process-manager.ts                         | 84 ++++++++++++-------
 6 files changed, 239 insertions(+), 77 deletions(-)

Fusion-Task-Id: FN-7697

Fusion-Task-Lineage: 6dd56a7f-da8f-4a73-82ff-5e9baab697c5

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-08 18:07:48 -07:00
parent d585edbc92
commit bccb552211
6 changed files with 241 additions and 79 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix Cursor CLI model discovery and auth to use the real cursor-agent commands.
category: fix
dev: Switches model discovery to `cursor-agent models` (plain text `id - Label`, no `--json`/`model list` support) with header/tip/empty-state filtering, and derives auth from `cursor-agent status --format json` (`isAuthenticated`) instead of a `--version`-success heuristic.

View File

@@ -7,7 +7,8 @@ Cursor CLI-backed provider/runtime plugin for Fusion.
- Provider ID: `cursor-cli`
- Binary probes: `cursor-agent`, then `cursor`
- Expected failure states: missing binary, missing Cursor IDE install, locked macOS keychain, unauthenticated runtime
- Model discovery: dynamic command probing (`models --json`, fallbacks) with dedupe + fallback metadata
- Model discovery: `cursor-agent models` (plain text `id - Label` output; no `--json` support) with header/tip/empty-state filtering, dedupe, and fallback metadata
- Auth status: `cursor-agent status --format json` (`isAuthenticated`), probed against the same candidate binary that succeeded `--version`
## Notes

View File

@@ -5,22 +5,82 @@ vi.mock("../cli-spawn.js", () => ({ runCursorCommand: vi.fn() }));
import { runCursorCommand } from "../cli-spawn.js";
import { probeCursorBinary } from "../probe.js";
const AUTHENTICATED_STATUS = JSON.stringify({ isAuthenticated: true, status: "logged_in", hasAccessToken: true, userInfo: { email: "dev@example.com" } });
const UNAUTHENTICATED_STATUS = JSON.stringify({ isAuthenticated: false, status: "logged_out", hasAccessToken: false });
describe("probeCursorBinary", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("reports available when probe succeeds", async () => {
vi.mocked(runCursorCommand).mockResolvedValue({ code: 0, stdout: "1.2.3", stderr: "" });
it("reports authenticated:true from status --format json isAuthenticated", async () => {
vi.mocked(runCursorCommand)
.mockResolvedValueOnce({ code: 0, stdout: "1.2.3", stderr: "" })
.mockResolvedValueOnce({ code: 0, stdout: AUTHENTICATED_STATUS, stderr: "" });
const result = await probeCursorBinary({ binaryPath: "/usr/local/bin/cursor-agent" });
expect(runCursorCommand).toHaveBeenCalledWith("/usr/local/bin/cursor-agent", ["--version"], 3000);
expect(runCursorCommand).toHaveBeenNthCalledWith(1, "/usr/local/bin/cursor-agent", ["--version"], 3000);
expect(runCursorCommand).toHaveBeenNthCalledWith(2, "/usr/local/bin/cursor-agent", ["status", "--format", "json"], 3000);
expect(result.available).toBe(true);
expect(result.authenticated).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 authenticated:false from status --format json isAuthenticated:false", async () => {
vi.mocked(runCursorCommand)
.mockResolvedValueOnce({ code: 0, stdout: "1.2.3", stderr: "" })
.mockResolvedValueOnce({ code: 0, stdout: UNAUTHENTICATED_STATUS, stderr: "" });
const result = await probeCursorBinary({ binaryPath: "cursor-agent" });
expect(result.available).toBe(true);
expect(result.authenticated).toBe(false);
expect(result.reason).toBe("cursor-agent reports not authenticated");
});
it("fails closed to authenticated:false with an actionable reason on malformed/non-JSON status output", async () => {
vi.mocked(runCursorCommand)
.mockResolvedValueOnce({ code: 0, stdout: "1.2.3", stderr: "" })
.mockResolvedValueOnce({ code: 0, stdout: "not json at all", stderr: "" });
const result = await probeCursorBinary({ binaryPath: "cursor-agent" });
expect(result.available).toBe(true);
expect(result.authenticated).toBe(false);
expect(result.reason).toBe("cursor-agent status --format json returned malformed JSON");
});
it("fails closed to authenticated:false with an actionable reason when status exits non-zero", async () => {
vi.mocked(runCursorCommand)
.mockResolvedValueOnce({ code: 0, stdout: "1.2.3", stderr: "" })
.mockResolvedValueOnce({ code: 1, stdout: "", stderr: "unexpected error" });
const result = await probeCursorBinary({ binaryPath: "cursor-agent" });
expect(result.available).toBe(true);
expect(result.authenticated).toBe(false);
expect(result.reason).toBe("cursor-agent status --format json did not return output");
});
it("probes status against the SAME candidate binary that succeeded --version, never re-probing a different candidate", async () => {
vi.mocked(runCursorCommand)
.mockResolvedValueOnce({ code: 127, stdout: "", stderr: "spawn error: ENOENT: cursor-agent" })
.mockResolvedValueOnce({ code: 0, stdout: "cursor 0.50.0\n", stderr: "" })
.mockResolvedValueOnce({ code: 0, stdout: AUTHENTICATED_STATUS, stderr: "" });
const result = await probeCursorBinary();
expect(runCursorCommand).toHaveBeenNthCalledWith(1, "cursor-agent", ["--version"], 3000);
expect(runCursorCommand).toHaveBeenNthCalledWith(2, "cursor", ["--version"], 3000);
expect(runCursorCommand).toHaveBeenNthCalledWith(3, "cursor", ["status", "--format", "json"], 3000);
expect(result.binaryName).toBe("cursor");
expect(result.authenticated).toBe(true);
});
it("reports keychain lock as auth failure", async () => {
vi.mocked(runCursorCommand).mockResolvedValue({ code: 1, stdout: "", stderr: "Error: Your macOS login keychain is locked." });
const result = await probeCursorBinary({ binaryPath: "cursor-agent" });
@@ -38,12 +98,14 @@ describe("probeCursorBinary", () => {
});
it("probes cursor-agent before cursor and reports the first Windows shim success", async () => {
vi.mocked(runCursorCommand).mockResolvedValueOnce({ code: 0, stdout: "cursor-agent 0.50.0\n", stderr: "" });
vi.mocked(runCursorCommand)
.mockResolvedValueOnce({ code: 0, stdout: "cursor-agent 0.50.0\n", stderr: "" })
.mockResolvedValueOnce({ code: 0, stdout: AUTHENTICATED_STATUS, stderr: "" });
const result = await probeCursorBinary();
expect(runCursorCommand).toHaveBeenCalledWith("cursor-agent", ["--version"], 3000);
expect(runCursorCommand).toHaveBeenCalledTimes(1);
expect(runCursorCommand).toHaveBeenNthCalledWith(1, "cursor-agent", ["--version"], 3000);
expect(runCursorCommand).toHaveBeenCalledTimes(2);
expect(result).toMatchObject({
available: true,
authenticated: true,
@@ -56,7 +118,8 @@ describe("probeCursorBinary", () => {
it("falls back to cursor when cursor-agent fails but cursor succeeds", async () => {
vi.mocked(runCursorCommand)
.mockResolvedValueOnce({ code: 127, stdout: "", stderr: "spawn error: ENOENT: cursor-agent" })
.mockResolvedValueOnce({ code: 0, stdout: "cursor 0.50.0\n", stderr: "" });
.mockResolvedValueOnce({ code: 0, stdout: "cursor 0.50.0\n", stderr: "" })
.mockResolvedValueOnce({ code: 0, stdout: AUTHENTICATED_STATUS, stderr: "" });
const result = await probeCursorBinary();
@@ -80,13 +143,15 @@ describe("probeCursorBinary", () => {
});
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: "" });
vi.mocked(runCursorCommand)
.mockResolvedValueOnce({ code: 0, stdout: "cursor-agent.cmd 0.50.0", stderr: "" })
.mockResolvedValueOnce({ code: 0, stdout: AUTHENTICATED_STATUS, 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(runCursorCommand).toHaveBeenNthCalledWith(1, binaryPath, ["--version"], 3000);
expect(runCursorCommand).toHaveBeenNthCalledWith(2, binaryPath, ["status", "--format", "json"], 3000);
expect(result.binaryPath).toBe(binaryPath);
expect(result.usingConfiguredBinaryPath).toBe(true);
});
@@ -94,12 +159,14 @@ describe("probeCursorBinary", () => {
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: "" });
.mockResolvedValueOnce({ code: 0, stdout: "cursor-agent 0.50.0\n", stderr: "" })
.mockResolvedValueOnce({ code: 0, stdout: AUTHENTICATED_STATUS, 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(runCursorCommand).toHaveBeenNthCalledWith(3, "cursor-agent", ["status", "--format", "json"], 3000);
expect(result.available).toBe(true);
expect(result.binaryPath).toBe("cursor-agent");
expect(result.usingConfiguredBinaryPath).toBe(false);
@@ -124,11 +191,12 @@ describe("probeCursorBinary", () => {
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: "" });
.mockResolvedValueOnce({ code: 0, stdout: "cursor 0.50.0\n", stderr: "" })
.mockResolvedValueOnce({ code: 0, stdout: AUTHENTICATED_STATUS, stderr: "" });
const result = await probeCursorBinary({ binaryPath: " cursor-agent " });
expect(runCursorCommand).toHaveBeenCalledTimes(2);
expect(runCursorCommand).toHaveBeenCalledTimes(3);
expect(runCursorCommand).toHaveBeenNthCalledWith(1, "cursor-agent", ["--version"], 3000);
expect(runCursorCommand).toHaveBeenNthCalledWith(2, "cursor", ["--version"], 3000);
expect(result.binaryPath).toBe("cursor");

View File

@@ -5,53 +5,86 @@ vi.mock("../cli-spawn.js", () => ({ runCursorCommand: vi.fn() }));
import { runCursorCommand } from "../cli-spawn.js";
import { discoverCursorModels } from "../process-manager.js";
const REAL_MODELS_OUTPUT = [
"Available models",
"",
"auto - Auto (default)",
"claude-4.5-sonnet - Sonnet 4.5",
"gpt-5 - GPT-5",
"",
"Tip: use --model <id> (or /model <id> in interactive mode) to switch.",
].join("\n");
describe("discoverCursorModels", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("uses json list when available", async () => {
vi.mocked(runCursorCommand).mockResolvedValueOnce({ code: 0, stdout: '[{"id":"cursor/a"},{"id":"cursor/b"}]', stderr: "" });
it("invokes only `models` (never --json or model list) and never falls back", async () => {
vi.mocked(runCursorCommand).mockResolvedValueOnce({ code: 0, stdout: REAL_MODELS_OUTPUT, stderr: "" });
await discoverCursorModels("cursor-agent");
expect(runCursorCommand).toHaveBeenCalledTimes(1);
expect(runCursorCommand).toHaveBeenCalledWith("cursor-agent", ["models"], 5000);
expect(runCursorCommand).not.toHaveBeenCalledWith("cursor-agent", ["models", "--json"], expect.anything());
expect(runCursorCommand).not.toHaveBeenCalledWith("cursor-agent", ["model", "list", "--json"], expect.anything());
});
it("extracts bare ids from real `id - Label` output, dropping header and tip lines", async () => {
vi.mocked(runCursorCommand).mockResolvedValueOnce({ code: 0, stdout: REAL_MODELS_OUTPUT, stderr: "" });
const result = await discoverCursorModels("cursor-agent");
expect(runCursorCommand).toHaveBeenCalledWith("cursor-agent", ["models", "--json"], 5000);
expect(result.models).toEqual(["cursor/a", "cursor/b"]);
expect(result.models).toEqual(["auto", "claude-4.5-sonnet", "gpt-5"]);
expect(result.source).toBe("models-text");
expect(result.fallbackUsed).toBe(false);
});
it("parses the `auto - Auto (default)` first entry to id `auto`", async () => {
vi.mocked(runCursorCommand).mockResolvedValueOnce({ code: 0, stdout: "Available models\n\nauto - Auto (default)\n\nTip: use --model <id> to switch.", stderr: "" });
const result = await discoverCursorModels("cursor-agent");
expect(result.models[0]).toBe("auto");
});
it("dedupes repeated ids", async () => {
vi.mocked(runCursorCommand).mockResolvedValueOnce({ code: 0, stdout: "auto - Auto (default)\nauto - Auto (default)\ngpt-5 - GPT-5", stderr: "" });
const result = await discoverCursorModels("cursor-agent");
expect(result.models).toEqual(["auto", "gpt-5"]);
});
it("returns an empty list with a clear reason for the empty-account state", async () => {
vi.mocked(runCursorCommand).mockResolvedValueOnce({ code: 0, stdout: "No models available for this account.", stderr: "" });
const result = await discoverCursorModels("cursor-agent");
expect(result).toEqual({ models: [], source: "models-text", fallbackUsed: false, reason: "no models available for this account" });
});
it("tolerates JSON output defensively even though the real CLI never sends it", async () => {
vi.mocked(runCursorCommand).mockResolvedValueOnce({ code: 0, stdout: '[{"id":"cursor/a"},{"id":"cursor/b"}]', stderr: "" });
const result = await discoverCursorModels("cursor-agent");
expect(runCursorCommand).toHaveBeenCalledWith("cursor-agent", ["models"], 5000);
expect(result.models).toEqual(["cursor/a", "cursor/b"]);
expect(result.source).toBe("models-json");
});
it("returns empty discovery when the command fails outright", async () => {
vi.mocked(runCursorCommand).mockResolvedValueOnce({ code: 127, stdout: "", stderr: "spawn error: ENOENT" });
const result = await discoverCursorModels("cursor-agent", 2500);
expect(runCursorCommand).toHaveBeenCalledWith("cursor-agent", ["models"], 2500);
expect(result).toEqual({ models: [], source: "none", fallbackUsed: true, reason: "model discovery command unavailable" });
});
it("passes Windows .bat paths with spaces as one binary string", async () => {
vi.mocked(runCursorCommand).mockResolvedValueOnce({ code: 0, stdout: '["cursor/a"]', stderr: "" });
vi.mocked(runCursorCommand).mockResolvedValueOnce({ code: 0, stdout: "cursor/a - 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(runCursorCommand).toHaveBeenCalledWith(binary, ["models"], 5000);
expect(result.models).toEqual(["cursor/a"]);
});
it("falls back to text parsing", async () => {
vi.mocked(runCursorCommand)
.mockResolvedValueOnce({ code: 1, stdout: "", stderr: "" })
.mockResolvedValueOnce({ code: 1, stdout: "", stderr: "" })
.mockResolvedValueOnce({ code: 0, stdout: "cursor/x\ncursor/y", stderr: "" });
const result = await discoverCursorModels("cursor-agent");
expect(runCursorCommand).toHaveBeenNthCalledWith(1, "cursor-agent", ["models", "--json"], 5000);
expect(runCursorCommand).toHaveBeenNthCalledWith(2, "cursor-agent", ["model", "list", "--json"], 5000);
expect(runCursorCommand).toHaveBeenNthCalledWith(3, "cursor-agent", ["models"], 5000);
expect(result.models).toEqual(["cursor/x", "cursor/y"]);
expect(result.fallbackUsed).toBe(true);
});
it("returns empty discovery when every command fails", async () => {
vi.mocked(runCursorCommand)
.mockResolvedValueOnce({ code: 127, stdout: "", stderr: "spawn error: ENOENT" })
.mockResolvedValueOnce({ code: 1, stdout: "", stderr: "unknown command" })
.mockResolvedValueOnce({ code: 1, stdout: "", stderr: "unknown command" });
const result = await discoverCursorModels("cursor-agent", 2500);
expect(runCursorCommand).toHaveBeenNthCalledWith(1, "cursor-agent", ["models", "--json"], 2500);
expect(runCursorCommand).toHaveBeenNthCalledWith(2, "cursor-agent", ["model", "list", "--json"], 2500);
expect(runCursorCommand).toHaveBeenNthCalledWith(3, "cursor-agent", ["models"], 2500);
expect(result).toEqual({ models: [], source: "none", fallbackUsed: true, reason: "model discovery command unavailable" });
});
});

View File

@@ -21,6 +21,28 @@ function summarizeFailure(binary: string, stdout: string, stderr: string): strin
return `${binary}: ${truncated}`;
}
async function probeCursorAuthStatus(binary: string, timeoutMs: number): Promise<{ authenticated: boolean; reason?: string }> {
const status = await runCursorCommand(binary, ["status", "--format", "json"], timeoutMs);
const output = (status.stdout || "").trim();
if (status.code !== 0 || !output) {
return { authenticated: false, reason: "cursor-agent status --format json did not return output" };
}
try {
const parsed = JSON.parse(output) as { isAuthenticated?: unknown };
if (typeof parsed?.isAuthenticated === "boolean") {
return {
authenticated: parsed.isAuthenticated,
reason: parsed.isAuthenticated ? undefined : "cursor-agent reports not authenticated",
};
}
return { authenticated: false, reason: "cursor-agent status --format json missing isAuthenticated field" };
} catch {
return { authenticated: false, reason: "cursor-agent status --format json returned malformed JSON" };
}
}
export async function probeCursorBinary(options?: { timeoutMs?: number; binaryPath?: string }): Promise<CursorBinaryStatus> {
const startedAt = Date.now();
const timeoutMs = options?.timeoutMs ?? 3000;
@@ -40,15 +62,22 @@ export async function probeCursorBinary(options?: { timeoutMs?: number; binaryPa
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
// a best-effort heuristic; keychain/auth errors are handled by fallback
// probes below when surfaced in stderr/stdout.
/*
FNXC:CursorCli 2026-07-08-00:00:
`cursor-agent status --format json` (alias `whoami`) is the real auth
contract, returning `{ isAuthenticated, status, hasAccessToken, userInfo }`.
We probe it with the SAME candidate binary that just succeeded --version
(never re-probing a different candidate), and fail closed to
`authenticated: false` with an actionable reason on any non-zero exit or
malformed/non-JSON output rather than throwing.
*/
const auth = await probeCursorAuthStatus(binary, timeoutMs);
return {
available: true,
authenticated: true,
authenticated: auth.authenticated,
...common,
version: version.stdout.trim() || undefined,
reason: auth.authenticated ? undefined : auth.reason,
};
}

View File

@@ -1,45 +1,69 @@
import { runCursorCommand } from "./cli-spawn.js";
const EMPTY_ACCOUNT_MESSAGE = "no models available for this account.";
/*
FNXC:CursorCli 2026-07-08-00:00:
`cursor-agent models` is plain text, NOT JSON — the real CLI rejects `--json`
(`error: unknown option '--json'`) and has no `model list` subcommand. Output
shape is: an `Available models` header line, a blank line, then one model per
line as `<id> - <Label>` (e.g. `auto - Auto (default)`), ending with a
`Tip: use --model <id> ...` line. An empty account prints the single line
`No models available for this account.`. We strip the header/tip/empty-state
lines and take the text before the first ` - ` as the bare model id.
*/
function parseModelLines(raw: string): string[] {
return raw
const ids = raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.filter((line) => !line.toLowerCase().startsWith("usage"));
.filter((line) => line.toLowerCase() !== "available models")
.filter((line) => line.toLowerCase() !== EMPTY_ACCOUNT_MESSAGE)
.filter((line) => !/^tip:/i.test(line))
.filter((line) => !line.toLowerCase().startsWith("usage"))
.map((line) => {
const separatorIndex = line.indexOf(" - ");
return separatorIndex === -1 ? line : line.slice(0, separatorIndex).trim();
})
.filter(Boolean);
return Array.from(new Set(ids));
}
export async function discoverCursorModels(binary: string, timeoutMs = 5000): Promise<{ models: string[]; source: string; fallbackUsed: boolean; reason?: string }> {
const attempts: Array<{ args: string[]; source: string; structured: boolean }> = [
{ args: ["models", "--json"], source: "models-json", structured: true },
{ args: ["model", "list", "--json"], source: "model-list-json", structured: true },
{ args: ["models"], source: "models-text", structured: false },
];
const res = await runCursorCommand(binary, ["models"], timeoutMs);
if (res.code !== 0) {
return { models: [], source: "none", fallbackUsed: true, reason: "model discovery command unavailable" };
}
for (const attempt of attempts) {
const res = await runCursorCommand(binary, attempt.args, timeoutMs);
if (res.code !== 0) continue;
const output = (res.stdout || "").trim();
if (!output) {
return { models: [], source: "none", fallbackUsed: true, reason: "model discovery command returned no output" };
}
const output = (res.stdout || "").trim();
if (!output) continue;
if (output.toLowerCase() === EMPTY_ACCOUNT_MESSAGE) {
return { models: [], source: "models-text", fallbackUsed: false, reason: "no models available for this account" };
}
try {
const parsed = JSON.parse(output);
if (Array.isArray(parsed)) {
const ids = parsed
.map((entry) => (typeof entry === "string" ? entry : typeof entry?.id === "string" ? entry.id : undefined))
.filter((id): id is string => Boolean(id));
if (ids.length > 0) {
return { models: Array.from(new Set(ids)), source: attempt.source, fallbackUsed: !attempt.structured };
}
// Defensive fallback: tolerate output that happens to be JSON, even though
// the real CLI does not support --json today.
try {
const parsed = JSON.parse(output);
if (Array.isArray(parsed)) {
const ids = parsed
.map((entry) => (typeof entry === "string" ? entry : typeof entry?.id === "string" ? entry.id : undefined))
.filter((id): id is string => Boolean(id));
if (ids.length > 0) {
return { models: Array.from(new Set(ids)), source: "models-json", fallbackUsed: false };
}
} catch {
// output is not JSON; continue with line-based fallback
}
} catch {
// output is not JSON; fall through to line-based parsing
}
const ids = Array.from(new Set(parseModelLines(output)));
if (ids.length > 0) {
return { models: ids, source: attempt.source, fallbackUsed: !attempt.structured };
}
const ids = parseModelLines(output);
if (ids.length > 0) {
return { models: ids, source: "models-text", fallbackUsed: false };
}
return { models: [], source: "none", fallbackUsed: true, reason: "model discovery command unavailable" };