Skip to content

Commit 9061271

Browse files
committed
feat: auto-refresh model list at plugin startup (#46)
Adds non-blocking model auto-refresh that runs at startup via discovery subprocess. Only adds new models, never removes user-configured ones. Updates all three hardcoded fallback lists and adds a 5s timeout to the discovery subprocess.
1 parent 2078f37 commit 9061271

File tree

6 files changed

+362
-9
lines changed

6 files changed

+362
-9
lines changed

src/cli/model-discovery.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { execFileSync } from "child_process";
22
import { stripAnsi } from "../utils/errors.js";
33

4+
const MODEL_DISCOVERY_TIMEOUT_MS = 5000;
5+
46
export type DiscoveredModel = {
57
id: string;
68
name: string;
@@ -31,7 +33,9 @@ export function parseCursorModelsOutput(output: string): DiscoveredModel[] {
3133
export function discoverModelsFromCursorAgent(): DiscoveredModel[] {
3234
const raw = execFileSync("cursor-agent", ["models"], {
3335
encoding: "utf8",
36+
killSignal: "SIGTERM",
3437
stdio: ["ignore", "pipe", "pipe"],
38+
timeout: MODEL_DISCOVERY_TIMEOUT_MS,
3539
});
3640
const models = parseCursorModelsOutput(raw);
3741
if (models.length === 0) {
@@ -43,8 +47,24 @@ export function discoverModelsFromCursorAgent(): DiscoveredModel[] {
4347
export function fallbackModels(): DiscoveredModel[] {
4448
return [
4549
{ id: "auto", name: "Auto" },
46-
{ id: "sonnet-4.5", name: "Claude 4.5 Sonnet" },
50+
{ id: "composer-1.5", name: "Composer 1.5" },
51+
{ id: "composer-1", name: "Composer 1" },
52+
{ id: "opus-4.6-thinking", name: "Claude 4.6 Opus (Thinking)" },
4753
{ id: "opus-4.6", name: "Claude 4.6 Opus" },
54+
{ id: "sonnet-4.6", name: "Claude 4.6 Sonnet" },
55+
{ id: "sonnet-4.6-thinking", name: "Claude 4.6 Sonnet (Thinking)" },
56+
{ id: "opus-4.5", name: "Claude 4.5 Opus" },
57+
{ id: "opus-4.5-thinking", name: "Claude 4.5 Opus (Thinking)" },
58+
{ id: "sonnet-4.5", name: "Claude 4.5 Sonnet" },
59+
{ id: "sonnet-4.5-thinking", name: "Claude 4.5 Sonnet (Thinking)" },
60+
{ id: "gpt-5.4-high", name: "GPT-5.4 High" },
61+
{ id: "gpt-5.4-medium", name: "GPT-5.4" },
62+
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
4863
{ id: "gpt-5.2", name: "GPT-5.2" },
64+
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" },
65+
{ id: "gemini-3-pro", name: "Gemini 3 Pro" },
66+
{ id: "gemini-3-flash", name: "Gemini 3 Flash" },
67+
{ id: "grok", name: "Grok" },
68+
{ id: "kimi-k2.5", name: "Kimi K2.5" },
4969
];
5070
}

src/client/simple.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -253,11 +253,24 @@ export class SimpleCursorClient {
253253
async getAvailableModels(): Promise<Array<{ id: string; name: string }>> {
254254
return [
255255
{ id: 'auto', name: 'Cursor Agent Auto' },
256+
{ id: 'composer-1.5', name: 'Composer 1.5' },
257+
{ id: 'opus-4.6-thinking', name: 'Claude 4.6 Opus (Thinking)' },
258+
{ id: 'opus-4.6', name: 'Claude 4.6 Opus' },
259+
{ id: 'sonnet-4.6', name: 'Claude 4.6 Sonnet' },
260+
{ id: 'sonnet-4.6-thinking', name: 'Claude 4.6 Sonnet (Thinking)' },
261+
{ id: 'opus-4.5', name: 'Claude 4.5 Opus' },
262+
{ id: 'opus-4.5-thinking', name: 'Claude 4.5 Opus (Thinking)' },
263+
{ id: 'sonnet-4.5', name: 'Claude 4.5 Sonnet' },
264+
{ id: 'sonnet-4.5-thinking', name: 'Claude 4.5 Sonnet (Thinking)' },
265+
{ id: 'gpt-5.4-high', name: 'GPT-5.4 High' },
266+
{ id: 'gpt-5.4-medium', name: 'GPT-5.4' },
267+
{ id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex' },
256268
{ id: 'gpt-5.2', name: 'GPT-5.2' },
269+
{ id: 'gemini-3.1-pro', name: 'Gemini 3.1 Pro' },
257270
{ id: 'gemini-3-pro', name: 'Gemini 3 Pro' },
258-
{ id: 'opus-4.5-thinking', name: 'Claude 4.5 Opus Thinking' },
259-
{ id: 'sonnet-4.5', name: 'Claude 4.5 Sonnet' },
260-
{ id: 'deepseek-v3.2', name: 'DeepSeek V3.2' }
271+
{ id: 'gemini-3-flash', name: 'Gemini 3 Flash' },
272+
{ id: 'grok', name: 'Grok' },
273+
{ id: 'kimi-k2.5', name: 'Kimi K2.5' },
261274
];
262275
}
263276

src/models/discovery.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,20 @@ export class ModelDiscoveryService {
119119
private getDefaultModels(): ModelInfo[] {
120120
return [
121121
{ id: "auto", name: "Auto", description: "Auto-select best model" },
122+
{ id: "composer-1.5", name: "Composer 1.5" },
123+
{ id: "opus-4.6-thinking", name: "Claude 4.6 Opus (Thinking)" },
124+
{ id: "opus-4.6", name: "Claude 4.6 Opus" },
125+
{ id: "sonnet-4.6", name: "Claude 4.6 Sonnet" },
126+
{ id: "sonnet-4.6-thinking", name: "Claude 4.6 Sonnet (Thinking)" },
127+
{ id: "opus-4.5", name: "Claude 4.5 Opus" },
128+
{ id: "sonnet-4.5", name: "Claude 4.5 Sonnet" },
129+
{ id: "gpt-5.4-high", name: "GPT-5.4 High" },
130+
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
122131
{ id: "gpt-5.2", name: "GPT-5.2" },
123-
{ id: "sonnet-4.5", name: "Sonnet 4.5" },
124-
{ id: "opus-4.5", name: "Opus 4.5" },
125-
{ id: "gemini-3-pro", name: "Gemini 3 Pro" }
132+
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" },
133+
{ id: "gemini-3-pro", name: "Gemini 3 Pro" },
134+
{ id: "grok", name: "Grok" },
135+
{ id: "kimi-k2.5", name: "Kimi K2.5" },
126136
];
127137
}
128138

src/models/sync.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/**
2+
* Non-blocking model auto-refresh for plugin startup.
3+
*
4+
* Discovers currently available models from cursor-agent and merges them
5+
* into the opencode.json config. Only adds new models — never removes
6+
* user-configured ones. Safe to call fire-and-forget; all errors are
7+
* caught and logged silently.
8+
*/
9+
import {
10+
existsSync as nodeExistsSync,
11+
readFileSync as nodeReadFileSync,
12+
writeFileSync as nodeWriteFileSync,
13+
} from "node:fs";
14+
import { discoverModelsFromCursorAgent, type DiscoveredModel } from "../cli/model-discovery.js";
15+
import { resolveOpenCodeConfigPath } from "../plugin-toggle.js";
16+
import { createLogger, type Logger } from "../utils/logger.js";
17+
18+
const log = createLogger("model-sync");
19+
const PROVIDER_ID = "cursor-acp";
20+
21+
type ModelConfigEntry = { name: string };
22+
type ProviderConfig = { models?: Record<string, unknown> } & Record<string, unknown>;
23+
type OpenCodeConfig = {
24+
provider?: Record<string, ProviderConfig | undefined>;
25+
} & Record<string, unknown>;
26+
type AutoRefreshModelsDeps = {
27+
defer: () => Promise<void>;
28+
discoverModels: () => DiscoveredModel[];
29+
env: NodeJS.ProcessEnv;
30+
existsSync: (path: string) => boolean;
31+
log: Logger;
32+
readFileSync: (path: string, encoding: BufferEncoding) => string;
33+
writeFileSync: (path: string, data: string, encoding: BufferEncoding) => void;
34+
};
35+
36+
const defaultDeps: AutoRefreshModelsDeps = {
37+
defer: () => Promise.resolve(),
38+
discoverModels: discoverModelsFromCursorAgent,
39+
env: process.env,
40+
existsSync: nodeExistsSync,
41+
log,
42+
readFileSync: nodeReadFileSync,
43+
writeFileSync: nodeWriteFileSync,
44+
};
45+
46+
function isRecord(value: unknown): value is Record<string, unknown> {
47+
return typeof value === "object" && value !== null && !Array.isArray(value);
48+
}
49+
50+
function parseConfig(raw: string): OpenCodeConfig | null {
51+
try {
52+
const parsed = JSON.parse(raw);
53+
return isRecord(parsed) ? (parsed as OpenCodeConfig) : null;
54+
} catch {
55+
return null;
56+
}
57+
}
58+
59+
function getProviderConfig(config: OpenCodeConfig): ProviderConfig | null {
60+
if (!isRecord(config.provider)) {
61+
return null;
62+
}
63+
64+
const provider = config.provider[PROVIDER_ID];
65+
return isRecord(provider) ? (provider as ProviderConfig) : null;
66+
}
67+
68+
function getExistingModels(provider: ProviderConfig): Record<string, unknown> {
69+
return isRecord(provider.models) ? { ...provider.models } : {};
70+
}
71+
72+
function yieldForFireAndForget(): Promise<void> {
73+
return Promise.resolve();
74+
}
75+
76+
/**
77+
* Auto-refresh models at plugin startup.
78+
*
79+
* - Reads the current opencode.json config
80+
* - Queries cursor-agent for available models
81+
* - Merges discovered models into the provider config (additive only)
82+
* - Writes back if any new models were added
83+
*
84+
* This function never throws. All failures are logged at debug level
85+
* and silently ignored so plugin startup is never blocked.
86+
*/
87+
export async function autoRefreshModels(
88+
deps: Partial<AutoRefreshModelsDeps> = {},
89+
): Promise<void> {
90+
const resolvedDeps: AutoRefreshModelsDeps = {
91+
...defaultDeps,
92+
defer: yieldForFireAndForget,
93+
...deps,
94+
};
95+
96+
await resolvedDeps.defer();
97+
98+
try {
99+
const configPath = resolveOpenCodeConfigPath(resolvedDeps.env);
100+
if (!resolvedDeps.existsSync(configPath)) {
101+
resolvedDeps.log.debug("Config file not found, skipping model auto-refresh", { configPath });
102+
return;
103+
}
104+
105+
const raw = resolvedDeps.readFileSync(configPath, "utf8");
106+
const config = parseConfig(raw);
107+
if (!config) {
108+
resolvedDeps.log.debug("Config file is not valid JSON, skipping model auto-refresh");
109+
return;
110+
}
111+
112+
const provider = getProviderConfig(config);
113+
if (!provider) {
114+
resolvedDeps.log.debug("Provider section not found in config, skipping model auto-refresh");
115+
return;
116+
}
117+
118+
const existingModels = getExistingModels(provider);
119+
let discovered: DiscoveredModel[];
120+
try {
121+
discovered = resolvedDeps.discoverModels();
122+
} catch (err) {
123+
resolvedDeps.log.debug("cursor-agent model discovery failed, skipping auto-refresh", {
124+
error: String(err),
125+
});
126+
return;
127+
}
128+
129+
let addedCount = 0;
130+
for (const model of discovered) {
131+
if (Object.prototype.hasOwnProperty.call(existingModels, model.id)) continue;
132+
existingModels[model.id] = { name: model.name } satisfies ModelConfigEntry;
133+
addedCount++;
134+
}
135+
136+
if (addedCount === 0) {
137+
resolvedDeps.log.debug("Model auto-refresh: no new models found", {
138+
existing: Object.keys(existingModels).length,
139+
discovered: discovered.length,
140+
});
141+
return;
142+
}
143+
144+
provider.models = existingModels;
145+
resolvedDeps.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
146+
resolvedDeps.log.info("Model auto-refresh: added new models", {
147+
added: addedCount,
148+
total: Object.keys(existingModels).length,
149+
});
150+
} catch (err) {
151+
resolvedDeps.log.debug("Model auto-refresh failed", { error: String(err) });
152+
}
153+
}

src/plugin.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { toOpenAiParameters, describeTool } from "./tools/schema.js";
2424
import { ToolRouter } from "./tools/router.js";
2525
import { SkillLoader } from "./tools/skills/loader.js";
2626
import { SkillResolver } from "./tools/skills/resolver.js";
27+
import { autoRefreshModels } from "./models/sync.js";
2728
import { createOpencodeClient } from "@opencode-ai/sdk";
2829
import { ToolRegistry as CoreRegistry } from "./tools/core/registry.js";
2930
import { LocalExecutor } from "./tools/executors/local.js";
@@ -1735,8 +1736,8 @@ export const CursorPlugin: Plugin = async ({ $, directory, worktree, client, ser
17351736
});
17361737
await ensurePluginDirectory();
17371738

1738-
// Initialize toast service for MCP pass-through notifications
1739-
toastService.setClient(client);
1739+
// Auto-refresh model list from cursor-agent (non-blocking, fire-and-forget)
1740+
autoRefreshModels().catch(() => {});
17401741

17411742
// Initialize toast service for MCP pass-through notifications
17421743
toastService.setClient(client);

0 commit comments

Comments
 (0)