import { spawnSync } from "node:child_process"; import { models as cursorFallbackModels } from "@paperclipai/adapter-cursor-local"; import type { AdapterModel } from "./types.js"; const CURSOR_MODELS_TIMEOUT_MS = 5_000; const CURSOR_MODELS_CACHE_TTL_MS = 60_000; const MAX_BUFFER_BYTES = 512 * 1024; let cached: { expiresAt: number; models: AdapterModel[] } | null = null; type CursorModelsCommandResult = { status: number | null; stdout: string; stderr: string; hasError: boolean; }; function dedupeModels(models: AdapterModel[]): AdapterModel[] { const seen = new Set(); const deduped: AdapterModel[] = []; for (const model of models) { const id = model.id.trim(); if (!id || seen.has(id)) continue; seen.add(id); deduped.push({ id, label: model.label.trim() || id }); } return deduped; } function sanitizeModelId(raw: string): string { return raw .trim() .replace(/^["'`]+|["'`]+$/g, "") .replace(/\(.*\)\s*$/g, "") .trim(); } function isLikelyModelId(raw: string): boolean { const value = sanitizeModelId(raw); if (!value) return false; return /^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(value); } function pushModelId(target: AdapterModel[], raw: string) { const id = sanitizeModelId(raw); if (!isLikelyModelId(id)) return; target.push({ id, label: id }); } function collectFromJsonValue(value: unknown, target: AdapterModel[]) { if (typeof value === "string") { pushModelId(target, value); return; } if (!Array.isArray(value)) return; for (const item of value) { if (typeof item === "string") { pushModelId(target, item); continue; } if (typeof item !== "object" || item === null) continue; const id = (item as { id?: unknown }).id; if (typeof id === "string") { pushModelId(target, id); } } } export function parseCursorModelsOutput(stdout: string, stderr: string): AdapterModel[] { const models: AdapterModel[] = []; const combined = `${stdout}\n${stderr}`; const trimmedStdout = stdout.trim(); if (trimmedStdout.startsWith("{") || trimmedStdout.startsWith("[")) { try { const parsed = JSON.parse(trimmedStdout) as unknown; if (Array.isArray(parsed)) { collectFromJsonValue(parsed, models); } else if (typeof parsed === "object" && parsed !== null) { const rec = parsed as Record; collectFromJsonValue(rec.models, models); collectFromJsonValue(rec.data, models); } } catch { // Ignore malformed JSON and continue parsing plain text formats. } } for (const match of combined.matchAll(/available models?:\s*([^\n]+)/gi)) { const list = match[1] ?? ""; for (const token of list.split(",")) { pushModelId(models, token); } } for (const lineRaw of combined.split(/\r?\n/)) { const line = lineRaw.trim(); if (!line) continue; const bullet = line.replace(/^[-*]\s+/, "").trim(); if (!bullet || bullet.includes(" ")) continue; pushModelId(models, bullet); } return dedupeModels(models); } function mergedWithFallback(models: AdapterModel[]): AdapterModel[] { return dedupeModels([...models, ...cursorFallbackModels]); } function defaultCursorModelsRunner(): CursorModelsCommandResult { const result = spawnSync("agent", ["models"], { encoding: "utf8", timeout: CURSOR_MODELS_TIMEOUT_MS, maxBuffer: MAX_BUFFER_BYTES, }); return { status: result.status, stdout: typeof result.stdout === "string" ? result.stdout : "", stderr: typeof result.stderr === "string" ? result.stderr : "", hasError: Boolean(result.error), }; } let cursorModelsRunner: () => CursorModelsCommandResult = defaultCursorModelsRunner; function fetchCursorModelsFromCli(): AdapterModel[] { const result = cursorModelsRunner(); const { stdout, stderr } = result; if (result.hasError && stdout.trim().length === 0 && stderr.trim().length === 0) { return []; } if ((result.status ?? 1) !== 0 && !/available models?:/i.test(`${stdout}\n${stderr}`)) { return []; } return parseCursorModelsOutput(stdout, stderr); } export async function listCursorModels(): Promise { const now = Date.now(); if (cached && cached.expiresAt > now) { return cached.models; } const discovered = fetchCursorModelsFromCli(); if (discovered.length > 0) { const merged = mergedWithFallback(discovered); cached = { expiresAt: now + CURSOR_MODELS_CACHE_TTL_MS, models: merged, }; return merged; } if (cached && cached.models.length > 0) { return cached.models; } return dedupeModels(cursorFallbackModels); } export function resetCursorModelsCacheForTests() { cached = null; } export function setCursorModelsRunnerForTests(runner: (() => CursorModelsCommandResult) | null) { cursorModelsRunner = runner ?? defaultCursorModelsRunner; }