@evanovation/open-cursor 2.4.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +28 -0
- package/README.md +270 -0
- package/dist/cli/discover.js +527 -0
- package/dist/cli/mcptool.js +10339 -0
- package/dist/cli/opencode-cursor.js +2989 -0
- package/dist/index.js +20588 -0
- package/dist/plugin-entry.js +19848 -0
- package/package.json +82 -0
- package/scripts/cursor-agent-runner.mjs +272 -0
- package/scripts/sdk-runner.mjs +412 -0
- package/src/acp/metrics.ts +83 -0
- package/src/acp/sessions.ts +107 -0
- package/src/acp/tools.ts +209 -0
- package/src/auth.ts +175 -0
- package/src/cli/discover.ts +53 -0
- package/src/cli/mcptool.ts +133 -0
- package/src/cli/model-discovery.ts +71 -0
- package/src/cli/opencode-cursor.ts +1195 -0
- package/src/client/cursor-agent-child.ts +459 -0
- package/src/client/sdk-child.ts +550 -0
- package/src/client/simple.ts +293 -0
- package/src/commands/status.ts +39 -0
- package/src/index.ts +39 -0
- package/src/mcp/client-manager.ts +166 -0
- package/src/mcp/config.ts +169 -0
- package/src/mcp/tool-bridge.ts +133 -0
- package/src/models/config.ts +64 -0
- package/src/models/discovery.ts +105 -0
- package/src/models/index.ts +3 -0
- package/src/models/pricing.ts +196 -0
- package/src/models/sync.ts +247 -0
- package/src/models/types.ts +11 -0
- package/src/models/variants.ts +446 -0
- package/src/plugin-entry.ts +28 -0
- package/src/plugin-toggle.ts +81 -0
- package/src/plugin.ts +2802 -0
- package/src/provider/backend.ts +71 -0
- package/src/provider/boundary.ts +168 -0
- package/src/provider/passthrough-tracker.ts +38 -0
- package/src/provider/runtime-interception.ts +818 -0
- package/src/provider/tool-loop-guard.ts +644 -0
- package/src/provider/tool-schema-compat.ts +800 -0
- package/src/provider.ts +268 -0
- package/src/proxy/formatter.ts +60 -0
- package/src/proxy/handler.ts +29 -0
- package/src/proxy/incremental-prompt.ts +74 -0
- package/src/proxy/prompt-builder.ts +204 -0
- package/src/proxy/server.ts +207 -0
- package/src/proxy/session-resume.ts +312 -0
- package/src/proxy/tool-loop.ts +359 -0
- package/src/proxy/types.ts +13 -0
- package/src/services/toast-service.ts +81 -0
- package/src/streaming/ai-sdk-parts.ts +109 -0
- package/src/streaming/delta-tracker.ts +89 -0
- package/src/streaming/line-buffer.ts +44 -0
- package/src/streaming/openai-sse.ts +118 -0
- package/src/streaming/parser.ts +22 -0
- package/src/streaming/types.ts +158 -0
- package/src/tools/core/executor.ts +25 -0
- package/src/tools/core/registry.ts +27 -0
- package/src/tools/core/types.ts +31 -0
- package/src/tools/defaults.ts +954 -0
- package/src/tools/discovery.ts +140 -0
- package/src/tools/executors/cli.ts +59 -0
- package/src/tools/executors/local.ts +25 -0
- package/src/tools/executors/mcp.ts +39 -0
- package/src/tools/executors/sdk.ts +39 -0
- package/src/tools/index.ts +8 -0
- package/src/tools/registry.ts +34 -0
- package/src/tools/router.ts +123 -0
- package/src/tools/schema.ts +58 -0
- package/src/tools/skills/loader.ts +61 -0
- package/src/tools/skills/resolver.ts +21 -0
- package/src/tools/types.ts +29 -0
- package/src/types.ts +8 -0
- package/src/usage.ts +112 -0
- package/src/utils/binary.ts +71 -0
- package/src/utils/errors.ts +224 -0
- package/src/utils/logger.ts +191 -0
- package/src/utils/perf.ts +76 -0
|
@@ -0,0 +1,1195 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { execFileSync } from "child_process";
|
|
4
|
+
import {
|
|
5
|
+
copyFileSync,
|
|
6
|
+
existsSync,
|
|
7
|
+
lstatSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
realpathSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
symlinkSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from "fs";
|
|
15
|
+
import { homedir } from "os";
|
|
16
|
+
import { basename, dirname, join, resolve } from "path";
|
|
17
|
+
import { fileURLToPath } from "url";
|
|
18
|
+
import {
|
|
19
|
+
discoverModelsFromCursorAgent,
|
|
20
|
+
fallbackModels,
|
|
21
|
+
} from "./model-discovery.js";
|
|
22
|
+
import { resolveCursorAgentBinary } from "../utils/binary.js";
|
|
23
|
+
import { getPossibleAuthPaths, isUsableSdkApiKey } from "../auth.js";
|
|
24
|
+
import { parseCursorBackendPreference } from "../provider/backend.js";
|
|
25
|
+
import { isAgentPoolEnabled, parseAgentPoolIdleMs } from "../client/cursor-agent-child.js";
|
|
26
|
+
import { groupCursorModels, mergeCursorModelEntries } from "../models/variants.js";
|
|
27
|
+
import { resolveOpenCodeConfigPath } from "../plugin-toggle.js";
|
|
28
|
+
import { isSessionResumeEnabled } from "../proxy/session-resume.js";
|
|
29
|
+
import type { DiscoveredModel } from "./model-discovery.js";
|
|
30
|
+
|
|
31
|
+
const BRANDING_HEADER = `
|
|
32
|
+
▄▄▄ ▄▄▄▄ ▄▄▄▄▄ ▄▄ ▄▄ ▄▄▄ ▄▄ ▄▄ ▄▄▄▄ ▄▄▄▄ ▄▄▄ ▄▄▄▄
|
|
33
|
+
██ ██ ██ ██ ██▄▄ ███▄██ ▄▄▄ ██ ▀▀ ██ ██ ██ ██ ██▄▄▄ ██ ██ ██ ██
|
|
34
|
+
▀█▄█▀ ██▀▀ ██▄▄▄ ██ ▀██ ▀█▄█▀ ▀█▄█▀ ██▀█▄ ▄▄▄█▀ ▀█▄█▀ ██▀█▄
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
export function getBrandingHeader(): string {
|
|
38
|
+
return BRANDING_HEADER.trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type CheckResult = {
|
|
42
|
+
name: string;
|
|
43
|
+
passed: boolean;
|
|
44
|
+
message: string;
|
|
45
|
+
warning?: boolean;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type StatusResult = {
|
|
49
|
+
installMethod: "symlink" | "npm-direct" | "none";
|
|
50
|
+
plugin: {
|
|
51
|
+
path: string;
|
|
52
|
+
type: "symlink" | "file" | "missing";
|
|
53
|
+
target?: string;
|
|
54
|
+
};
|
|
55
|
+
provider: {
|
|
56
|
+
configPath: string;
|
|
57
|
+
name: string;
|
|
58
|
+
enabled: boolean;
|
|
59
|
+
baseUrl: string;
|
|
60
|
+
modelCount: number;
|
|
61
|
+
};
|
|
62
|
+
aiSdk: {
|
|
63
|
+
installed: boolean;
|
|
64
|
+
};
|
|
65
|
+
auth: {
|
|
66
|
+
legacyCursorAuthFile: boolean;
|
|
67
|
+
sdkApiKey: boolean;
|
|
68
|
+
sdkApiKeySource?: "CURSOR_API_KEY" | "provider.options.apiKey";
|
|
69
|
+
};
|
|
70
|
+
runtime: {
|
|
71
|
+
backend: {
|
|
72
|
+
preference: "auto" | "cursor-agent" | "sdk";
|
|
73
|
+
};
|
|
74
|
+
agentPool: {
|
|
75
|
+
enabled: boolean;
|
|
76
|
+
idleMs: number;
|
|
77
|
+
};
|
|
78
|
+
sessionResume: {
|
|
79
|
+
enabled: boolean;
|
|
80
|
+
};
|
|
81
|
+
logging: {
|
|
82
|
+
level: string;
|
|
83
|
+
console: boolean;
|
|
84
|
+
dir: string;
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
type ModelExplanation = {
|
|
90
|
+
modelCount: number;
|
|
91
|
+
groupedCount: number;
|
|
92
|
+
directCount: number;
|
|
93
|
+
groups: Array<{
|
|
94
|
+
id: string;
|
|
95
|
+
name: string;
|
|
96
|
+
defaultCursorModel: string;
|
|
97
|
+
memberCount: number;
|
|
98
|
+
variants: Record<string, string>;
|
|
99
|
+
}>;
|
|
100
|
+
direct: string[];
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export function checkBun(): CheckResult {
|
|
104
|
+
try {
|
|
105
|
+
const version = execFileSync("bun", ["--version"], { encoding: "utf8" }).trim();
|
|
106
|
+
return { name: "bun", passed: true, message: `v${version}` };
|
|
107
|
+
} catch {
|
|
108
|
+
return {
|
|
109
|
+
name: "bun",
|
|
110
|
+
passed: false,
|
|
111
|
+
message: "not found - install with: curl -fsSL https://bun.sh/install | bash",
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function checkCursorAgent(): CheckResult {
|
|
117
|
+
try {
|
|
118
|
+
const output = execFileSync(resolveCursorAgentBinary(), ["--version"], { encoding: "utf8" }).trim();
|
|
119
|
+
const version = output.split("\n")[0] || "installed";
|
|
120
|
+
return { name: "cursor-agent", passed: true, message: version };
|
|
121
|
+
} catch {
|
|
122
|
+
return {
|
|
123
|
+
name: "cursor-agent",
|
|
124
|
+
passed: false,
|
|
125
|
+
message: "not found - install with: curl -fsS https://cursor.com/install | bash",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function checkCursorAgentLogin(): CheckResult {
|
|
131
|
+
try {
|
|
132
|
+
// cursor-agent stores credentials in ~/.cursor-agent or similar
|
|
133
|
+
// Try running a command that requires auth
|
|
134
|
+
execFileSync(resolveCursorAgentBinary(), ["models"], {
|
|
135
|
+
encoding: "utf8",
|
|
136
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
137
|
+
timeout: 3000,
|
|
138
|
+
});
|
|
139
|
+
return { name: "cursor-agent login", passed: true, message: "logged in" };
|
|
140
|
+
} catch {
|
|
141
|
+
return {
|
|
142
|
+
name: "cursor-agent login",
|
|
143
|
+
passed: false,
|
|
144
|
+
message: "not logged in - run: cursor-agent login",
|
|
145
|
+
warning: true,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function getProviderApiKey(config: unknown): string | undefined {
|
|
151
|
+
const provider = (config as any)?.provider?.[PROVIDER_ID];
|
|
152
|
+
const apiKey = provider?.options?.apiKey;
|
|
153
|
+
return typeof apiKey === "string" ? apiKey : undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function resolveCliSdkAuthSource(config: unknown): StatusResult["auth"]["sdkApiKeySource"] | undefined {
|
|
157
|
+
if (isUsableSdkApiKey(process.env.CURSOR_API_KEY)) {
|
|
158
|
+
return "CURSOR_API_KEY";
|
|
159
|
+
}
|
|
160
|
+
if (isUsableSdkApiKey(getProviderApiKey(config))) {
|
|
161
|
+
return "provider.options.apiKey";
|
|
162
|
+
}
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function getRuntimeStatus(): StatusResult["runtime"] {
|
|
167
|
+
return {
|
|
168
|
+
backend: {
|
|
169
|
+
preference: parseCursorBackendPreference(process.env.CURSOR_ACP_BACKEND).preference,
|
|
170
|
+
},
|
|
171
|
+
agentPool: {
|
|
172
|
+
enabled: isAgentPoolEnabled(),
|
|
173
|
+
idleMs: parseAgentPoolIdleMs(),
|
|
174
|
+
},
|
|
175
|
+
sessionResume: {
|
|
176
|
+
enabled: isSessionResumeEnabled(),
|
|
177
|
+
},
|
|
178
|
+
logging: {
|
|
179
|
+
level: process.env.CURSOR_ACP_LOG_LEVEL || "info",
|
|
180
|
+
console: process.env.CURSOR_ACP_LOG_CONSOLE === "1",
|
|
181
|
+
dir: process.env.CURSOR_ACP_LOG_DIR || join(homedir(), ".opencode-cursor"),
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function checkSdkApiKey(config: unknown): CheckResult {
|
|
187
|
+
const source = resolveCliSdkAuthSource(config);
|
|
188
|
+
if (source) {
|
|
189
|
+
return {
|
|
190
|
+
name: "Cursor SDK API key",
|
|
191
|
+
passed: true,
|
|
192
|
+
message: `available via ${source}`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const backend = parseCursorBackendPreference(process.env.CURSOR_ACP_BACKEND).preference;
|
|
197
|
+
return {
|
|
198
|
+
name: "Cursor SDK API key",
|
|
199
|
+
passed: false,
|
|
200
|
+
warning: backend !== "sdk",
|
|
201
|
+
message: backend === "sdk"
|
|
202
|
+
? "not configured - required for CURSOR_ACP_BACKEND=sdk"
|
|
203
|
+
: "not configured - required only for CURSOR_ACP_BACKEND=sdk or when cursor-agent is unavailable",
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function adjustCursorAgentCheckForBackend(
|
|
208
|
+
check: CheckResult,
|
|
209
|
+
config: unknown,
|
|
210
|
+
): CheckResult {
|
|
211
|
+
if (check.passed) {
|
|
212
|
+
return check;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const backend = parseCursorBackendPreference(process.env.CURSOR_ACP_BACKEND).preference;
|
|
216
|
+
const sdkSource = resolveCliSdkAuthSource(config);
|
|
217
|
+
const sdkCanHandleRequest = backend === "sdk" || (backend === "auto" && sdkSource);
|
|
218
|
+
if (!sdkCanHandleRequest) {
|
|
219
|
+
return check;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
...check,
|
|
224
|
+
warning: true,
|
|
225
|
+
message: sdkSource
|
|
226
|
+
? `${check.message}; SDK backend can be used via ${sdkSource}`
|
|
227
|
+
: `${check.message}; SDK backend selected but no SDK API key is configured`,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function checkOpenCode(): CheckResult {
|
|
232
|
+
try {
|
|
233
|
+
const version = execFileSync("opencode", ["--version"], { encoding: "utf8" }).trim();
|
|
234
|
+
return { name: "OpenCode", passed: true, message: version };
|
|
235
|
+
} catch {
|
|
236
|
+
return {
|
|
237
|
+
name: "OpenCode",
|
|
238
|
+
passed: false,
|
|
239
|
+
message: "not found - install with: curl -fsSL https://opencode.ai/install | bash",
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function isNpmDirectInstalled(config: unknown): boolean {
|
|
245
|
+
if (!config || typeof config !== "object") return false;
|
|
246
|
+
const plugins = (config as Record<string, unknown>).plugin;
|
|
247
|
+
if (!Array.isArray(plugins)) return false;
|
|
248
|
+
return plugins.some((p) => typeof p === "string" && p.startsWith(NPM_PACKAGE_PREFIX));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function checkPluginFile(pluginPath: string, config: unknown): CheckResult {
|
|
252
|
+
try {
|
|
253
|
+
if (!existsSync(pluginPath)) {
|
|
254
|
+
if (isNpmDirectInstalled(config)) {
|
|
255
|
+
return {
|
|
256
|
+
name: "Plugin file",
|
|
257
|
+
passed: true,
|
|
258
|
+
message: "Installed via npm package (no symlink needed)",
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
name: "Plugin file",
|
|
263
|
+
passed: false,
|
|
264
|
+
message: "not found - run: open-cursor install",
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
const stat = lstatSync(pluginPath);
|
|
268
|
+
if (stat.isSymbolicLink()) {
|
|
269
|
+
const target = readFileSync(pluginPath, "utf8");
|
|
270
|
+
return { name: "Plugin file", passed: true, message: `symlink → ${target}` };
|
|
271
|
+
}
|
|
272
|
+
return { name: "Plugin file", passed: true, message: "file (copy)" };
|
|
273
|
+
} catch {
|
|
274
|
+
return {
|
|
275
|
+
name: "Plugin file",
|
|
276
|
+
passed: false,
|
|
277
|
+
message: "error reading plugin file",
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function checkProviderConfig(configPath: string): CheckResult {
|
|
283
|
+
try {
|
|
284
|
+
if (!existsSync(configPath)) {
|
|
285
|
+
return {
|
|
286
|
+
name: "Provider config",
|
|
287
|
+
passed: false,
|
|
288
|
+
message: "config not found - run: open-cursor install",
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
const config = readConfig(configPath);
|
|
292
|
+
const provider = config.provider?.["cursor-acp"];
|
|
293
|
+
if (!provider) {
|
|
294
|
+
return {
|
|
295
|
+
name: "Provider config",
|
|
296
|
+
passed: false,
|
|
297
|
+
message: "cursor-acp provider missing - run: open-cursor install",
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
const modelCount = Object.keys(provider.models || {}).length;
|
|
301
|
+
return { name: "Provider config", passed: true, message: `${modelCount} models` };
|
|
302
|
+
} catch {
|
|
303
|
+
return {
|
|
304
|
+
name: "Provider config",
|
|
305
|
+
passed: false,
|
|
306
|
+
message: "error reading config",
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function checkAiSdk(opencodeDir: string): CheckResult {
|
|
312
|
+
try {
|
|
313
|
+
const sdkPath = join(opencodeDir, "node_modules", "@ai-sdk", "openai-compatible");
|
|
314
|
+
if (existsSync(sdkPath)) {
|
|
315
|
+
return { name: "AI SDK", passed: true, message: "@ai-sdk/openai-compatible installed" };
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
name: "AI SDK",
|
|
319
|
+
passed: false,
|
|
320
|
+
message: "not installed - run: open-cursor install",
|
|
321
|
+
};
|
|
322
|
+
} catch {
|
|
323
|
+
return {
|
|
324
|
+
name: "AI SDK",
|
|
325
|
+
passed: false,
|
|
326
|
+
message: "error checking AI SDK",
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function runDoctorChecks(configPath: string, pluginPath: string): CheckResult[] {
|
|
332
|
+
const opencodeDir = dirname(configPath);
|
|
333
|
+
let config: unknown;
|
|
334
|
+
try {
|
|
335
|
+
config = readConfig(configPath);
|
|
336
|
+
} catch {
|
|
337
|
+
config = undefined;
|
|
338
|
+
}
|
|
339
|
+
return [
|
|
340
|
+
checkBun(),
|
|
341
|
+
adjustCursorAgentCheckForBackend(checkCursorAgent(), config),
|
|
342
|
+
checkCursorAgentLogin(),
|
|
343
|
+
checkSdkApiKey(config),
|
|
344
|
+
checkOpenCode(),
|
|
345
|
+
checkPluginFile(pluginPath, config),
|
|
346
|
+
checkProviderConfig(configPath),
|
|
347
|
+
checkAiSdk(opencodeDir),
|
|
348
|
+
];
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
type Command = "install" | "sync-models" | "models" | "uninstall" | "status" | "doctor" | "help";
|
|
352
|
+
|
|
353
|
+
type Options = {
|
|
354
|
+
config?: string;
|
|
355
|
+
pluginDir?: string;
|
|
356
|
+
baseUrl?: string;
|
|
357
|
+
copy?: boolean;
|
|
358
|
+
skipModels?: boolean;
|
|
359
|
+
noBackup?: boolean;
|
|
360
|
+
variants?: boolean;
|
|
361
|
+
compact?: boolean;
|
|
362
|
+
dryRun?: boolean;
|
|
363
|
+
deep?: boolean;
|
|
364
|
+
explain?: boolean;
|
|
365
|
+
json?: boolean;
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
type SyncSummary = {
|
|
369
|
+
added: number;
|
|
370
|
+
updated: number;
|
|
371
|
+
removed: number;
|
|
372
|
+
priced: number;
|
|
373
|
+
skipped: number;
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
type SyncModelsResult = {
|
|
377
|
+
syncedCount: number;
|
|
378
|
+
groupedCount: number;
|
|
379
|
+
removedCount: number;
|
|
380
|
+
summary: SyncSummary;
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
type SyncModelsJsonResult = SyncModelsResult & {
|
|
384
|
+
configPath: string;
|
|
385
|
+
dryRun: boolean;
|
|
386
|
+
variants: boolean;
|
|
387
|
+
compact: boolean;
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const PROVIDER_ID = "cursor-acp";
|
|
391
|
+
const NPM_PACKAGE_PREFIX = "@evanovation/open-cursor";
|
|
392
|
+
const DEFAULT_BASE_URL = "http://127.0.0.1:32124/v1";
|
|
393
|
+
|
|
394
|
+
function printHelp() {
|
|
395
|
+
const binName = basename(process.argv[1] || "open-cursor");
|
|
396
|
+
console.log(getBrandingHeader());
|
|
397
|
+
console.log(`${binName}
|
|
398
|
+
|
|
399
|
+
Commands:
|
|
400
|
+
install Configure OpenCode for Cursor (idempotent, safe to re-run)
|
|
401
|
+
sync-models Refresh model list from cursor-agent
|
|
402
|
+
models Explain discovered Cursor model groups and variants
|
|
403
|
+
status Show current configuration state
|
|
404
|
+
doctor Diagnose common issues
|
|
405
|
+
uninstall Remove cursor-acp from OpenCode config
|
|
406
|
+
help Show this help message
|
|
407
|
+
|
|
408
|
+
Options:
|
|
409
|
+
--config <path> Path to opencode.json (default: OPENCODE_CONFIG or ~/.config/opencode/opencode.json)
|
|
410
|
+
--plugin-dir <path> Path to plugin directory (default: ~/.config/opencode/plugin)
|
|
411
|
+
--base-url <url> Proxy base URL (default: http://127.0.0.1:32124/v1)
|
|
412
|
+
--copy Copy plugin instead of symlink
|
|
413
|
+
--skip-models Skip model sync during install
|
|
414
|
+
--variants Generate compact OpenCode model variants from Cursor models
|
|
415
|
+
--compact With --variants, remove raw grouped Cursor model entries
|
|
416
|
+
--dry-run Preview sync/install config changes without writing files
|
|
417
|
+
--deep Run extra doctor checks for models and variant config
|
|
418
|
+
--explain Show model grouping explanation (models command)
|
|
419
|
+
--no-backup Don't create config backup
|
|
420
|
+
--json Output in JSON format where supported
|
|
421
|
+
`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function parseArgs(argv: string[]): { command: Command; options: Options } {
|
|
425
|
+
const [commandRaw, ...rest] = argv;
|
|
426
|
+
const command = normalizeCommand(commandRaw);
|
|
427
|
+
const options: Options = {};
|
|
428
|
+
|
|
429
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
430
|
+
const arg = rest[i];
|
|
431
|
+
if (arg === "--copy") {
|
|
432
|
+
options.copy = true;
|
|
433
|
+
} else if (arg === "--skip-models") {
|
|
434
|
+
options.skipModels = true;
|
|
435
|
+
} else if (arg === "--variants") {
|
|
436
|
+
options.variants = true;
|
|
437
|
+
} else if (arg === "--compact") {
|
|
438
|
+
options.compact = true;
|
|
439
|
+
} else if (arg === "--dry-run") {
|
|
440
|
+
options.dryRun = true;
|
|
441
|
+
} else if (arg === "--deep") {
|
|
442
|
+
options.deep = true;
|
|
443
|
+
} else if (arg === "--explain") {
|
|
444
|
+
options.explain = true;
|
|
445
|
+
} else if (arg === "--no-backup") {
|
|
446
|
+
options.noBackup = true;
|
|
447
|
+
} else if (arg === "--config" && rest[i + 1]) {
|
|
448
|
+
options.config = rest[i + 1];
|
|
449
|
+
i += 1;
|
|
450
|
+
} else if (arg === "--plugin-dir" && rest[i + 1]) {
|
|
451
|
+
options.pluginDir = rest[i + 1];
|
|
452
|
+
i += 1;
|
|
453
|
+
} else if (arg === "--base-url" && rest[i + 1]) {
|
|
454
|
+
options.baseUrl = rest[i + 1];
|
|
455
|
+
i += 1;
|
|
456
|
+
} else if (arg === "--json") {
|
|
457
|
+
options.json = true;
|
|
458
|
+
} else {
|
|
459
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return { command, options };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function normalizeCommand(value: string | undefined): Command {
|
|
467
|
+
switch ((value || "help").toLowerCase()) {
|
|
468
|
+
case "install":
|
|
469
|
+
case "sync-models":
|
|
470
|
+
case "models":
|
|
471
|
+
case "uninstall":
|
|
472
|
+
case "status":
|
|
473
|
+
case "doctor":
|
|
474
|
+
case "help":
|
|
475
|
+
return value ? (value.toLowerCase() as Command) : "help";
|
|
476
|
+
default:
|
|
477
|
+
throw new Error(`Unknown command: ${value}`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function getConfigHome(): string {
|
|
482
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
483
|
+
if (xdg && xdg.length > 0) return xdg;
|
|
484
|
+
return join(homedir(), ".config");
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export function resolvePaths(options: Options) {
|
|
488
|
+
const opencodeDir = join(getConfigHome(), "opencode");
|
|
489
|
+
const configPath = options.config ? resolve(options.config) : resolveOpenCodeConfigPath();
|
|
490
|
+
const pluginDir = resolve(options.pluginDir || join(opencodeDir, "plugin"));
|
|
491
|
+
const pluginPath = join(pluginDir, `${PROVIDER_ID}.js`);
|
|
492
|
+
return { opencodeDir, configPath, pluginDir, pluginPath };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function resolvePluginSource(): string {
|
|
496
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
497
|
+
const currentDir = dirname(currentFile);
|
|
498
|
+
const candidates = [
|
|
499
|
+
join(currentDir, "plugin-entry.js"),
|
|
500
|
+
join(currentDir, "..", "plugin-entry.js"),
|
|
501
|
+
];
|
|
502
|
+
for (const candidate of candidates) {
|
|
503
|
+
if (existsSync(candidate)) {
|
|
504
|
+
return candidate;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
throw new Error("Unable to locate plugin-entry.js next to CLI distribution files");
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
|
|
511
|
+
return typeof error === "object" && error !== null && "code" in error;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function readConfig(configPath: string): any {
|
|
515
|
+
if (!existsSync(configPath)) {
|
|
516
|
+
return { plugin: [], provider: {} };
|
|
517
|
+
}
|
|
518
|
+
let raw: string;
|
|
519
|
+
try {
|
|
520
|
+
raw = readFileSync(configPath, "utf8");
|
|
521
|
+
} catch (error) {
|
|
522
|
+
if (isErrnoException(error) && error.code === "ENOENT") {
|
|
523
|
+
return { plugin: [], provider: {} };
|
|
524
|
+
}
|
|
525
|
+
throw error;
|
|
526
|
+
}
|
|
527
|
+
try {
|
|
528
|
+
return JSON.parse(raw);
|
|
529
|
+
} catch (error) {
|
|
530
|
+
throw new Error(`Invalid JSON in config: ${configPath} (${String(error)})`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function writeConfig(configPath: string, config: any, noBackup: boolean, silent = false) {
|
|
535
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
536
|
+
if (!noBackup && existsSync(configPath)) {
|
|
537
|
+
const backupPath = `${configPath}.bak.${new Date().toISOString().replace(/[:]/g, "-")}`;
|
|
538
|
+
copyFileSync(configPath, backupPath);
|
|
539
|
+
if (!silent) {
|
|
540
|
+
console.log(`Backup written: ${backupPath}`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function ensureProvider(config: any, baseUrl: string) {
|
|
547
|
+
config.plugin = Array.isArray(config.plugin) ? config.plugin : [];
|
|
548
|
+
if (!config.plugin.includes(PROVIDER_ID)) {
|
|
549
|
+
config.plugin.push(PROVIDER_ID);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
config.provider = config.provider && typeof config.provider === "object" ? config.provider : {};
|
|
553
|
+
const current = config.provider[PROVIDER_ID] && typeof config.provider[PROVIDER_ID] === "object"
|
|
554
|
+
? config.provider[PROVIDER_ID]
|
|
555
|
+
: {};
|
|
556
|
+
const options = current.options && typeof current.options === "object" ? current.options : {};
|
|
557
|
+
const models = current.models && typeof current.models === "object" ? current.models : {};
|
|
558
|
+
|
|
559
|
+
config.provider[PROVIDER_ID] = {
|
|
560
|
+
...current,
|
|
561
|
+
name: "Cursor",
|
|
562
|
+
npm: "@ai-sdk/openai-compatible",
|
|
563
|
+
options: {
|
|
564
|
+
...options,
|
|
565
|
+
baseURL: baseUrl,
|
|
566
|
+
},
|
|
567
|
+
models,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function ensurePluginLink(pluginSource: string, pluginPath: string, copyMode: boolean) {
|
|
572
|
+
mkdirSync(dirname(pluginPath), { recursive: true });
|
|
573
|
+
rmSync(pluginPath, { force: true });
|
|
574
|
+
if (copyMode) {
|
|
575
|
+
copyFileSync(pluginSource, pluginPath);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
symlinkSync(pluginSource, pluginPath);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function discoverModelsSafe() {
|
|
582
|
+
try {
|
|
583
|
+
return discoverModelsFromCursorAgent();
|
|
584
|
+
} catch (error) {
|
|
585
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
586
|
+
console.warn(`Warning: cursor-agent models failed; using fallback models (${message})`);
|
|
587
|
+
return fallbackModels();
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function syncModelsIntoProvider(config: any, options: Options): SyncModelsResult {
|
|
592
|
+
if (options.compact && !options.variants) {
|
|
593
|
+
throw new Error("--compact requires --variants");
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const discoveredModels = discoverModelsSafe();
|
|
597
|
+
const provider = config.provider[PROVIDER_ID];
|
|
598
|
+
const existingModels = provider.models && typeof provider.models === "object"
|
|
599
|
+
? provider.models
|
|
600
|
+
: {};
|
|
601
|
+
const beforeModels = snapshotModels(existingModels);
|
|
602
|
+
const result = mergeCursorModelEntries(existingModels, discoveredModels, {
|
|
603
|
+
variants: options.variants === true,
|
|
604
|
+
compact: options.compact === true,
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
provider.models = result.models;
|
|
608
|
+
return {
|
|
609
|
+
syncedCount: result.syncedCount,
|
|
610
|
+
groupedCount: result.groupedCount,
|
|
611
|
+
removedCount: result.removedCount,
|
|
612
|
+
summary: summarizeModelSync(beforeModels, result.models),
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export function explainCursorModels(models: DiscoveredModel[]): ModelExplanation {
|
|
617
|
+
const grouped = groupCursorModels(models);
|
|
618
|
+
const groupedCount = grouped.groups.reduce((total, group) => total + group.members.length, 0);
|
|
619
|
+
|
|
620
|
+
return {
|
|
621
|
+
modelCount: models.length,
|
|
622
|
+
groupedCount,
|
|
623
|
+
directCount: grouped.direct.length,
|
|
624
|
+
groups: grouped.groups.map(group => ({
|
|
625
|
+
id: group.baseId,
|
|
626
|
+
name: group.name,
|
|
627
|
+
defaultCursorModel: group.defaultCursorModelId,
|
|
628
|
+
memberCount: group.members.length,
|
|
629
|
+
variants: group.variants,
|
|
630
|
+
})),
|
|
631
|
+
direct: grouped.direct.map(model => model.id),
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function createSyncJsonResult(
|
|
636
|
+
result: SyncModelsResult,
|
|
637
|
+
options: Options,
|
|
638
|
+
configPath: string,
|
|
639
|
+
): SyncModelsJsonResult {
|
|
640
|
+
return {
|
|
641
|
+
...result,
|
|
642
|
+
configPath,
|
|
643
|
+
dryRun: options.dryRun === true,
|
|
644
|
+
variants: options.variants === true,
|
|
645
|
+
compact: options.compact === true,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function snapshotModels(models: Record<string, unknown>): Record<string, unknown> {
|
|
650
|
+
return JSON.parse(JSON.stringify(models));
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export function summarizeModelSync(
|
|
654
|
+
beforeModels: Record<string, unknown>,
|
|
655
|
+
afterModels: Record<string, unknown>,
|
|
656
|
+
): SyncSummary {
|
|
657
|
+
let added = 0;
|
|
658
|
+
let updated = 0;
|
|
659
|
+
let removed = 0;
|
|
660
|
+
let skipped = 0;
|
|
661
|
+
|
|
662
|
+
for (const [modelId, afterEntry] of Object.entries(afterModels)) {
|
|
663
|
+
if (!Object.prototype.hasOwnProperty.call(beforeModels, modelId)) {
|
|
664
|
+
added++;
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (JSON.stringify(beforeModels[modelId]) === JSON.stringify(afterEntry)) {
|
|
669
|
+
skipped++;
|
|
670
|
+
} else {
|
|
671
|
+
updated++;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
for (const modelId of Object.keys(beforeModels)) {
|
|
676
|
+
if (!Object.prototype.hasOwnProperty.call(afterModels, modelId)) {
|
|
677
|
+
removed++;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
return {
|
|
682
|
+
added,
|
|
683
|
+
updated,
|
|
684
|
+
removed,
|
|
685
|
+
priced: countPricedModelEntries(afterModels),
|
|
686
|
+
skipped,
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function countPricedModelEntries(models: Record<string, unknown>): number {
|
|
691
|
+
let priced = 0;
|
|
692
|
+
|
|
693
|
+
for (const entry of Object.values(models)) {
|
|
694
|
+
if (!isRecord(entry)) continue;
|
|
695
|
+
if (isRecord(entry.cost)) priced++;
|
|
696
|
+
|
|
697
|
+
if (!isRecord(entry.variants)) continue;
|
|
698
|
+
for (const variantEntry of Object.values(entry.variants)) {
|
|
699
|
+
if (isRecord(variantEntry) && isRecord(variantEntry.cost)) {
|
|
700
|
+
priced++;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
return priced;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
709
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function installAiSdk(opencodeDir: string) {
|
|
713
|
+
try {
|
|
714
|
+
execFileSync("bun", ["install", "@ai-sdk/openai-compatible"], {
|
|
715
|
+
cwd: opencodeDir,
|
|
716
|
+
stdio: "inherit",
|
|
717
|
+
});
|
|
718
|
+
} catch (error) {
|
|
719
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
720
|
+
console.warn(`Warning: failed to install @ai-sdk/openai-compatible via bun (${message})`);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function commandInstall(options: Options) {
|
|
725
|
+
const { opencodeDir, configPath, pluginPath } = resolvePaths(options);
|
|
726
|
+
const baseUrl = options.baseUrl || DEFAULT_BASE_URL;
|
|
727
|
+
const copyMode = options.copy === true;
|
|
728
|
+
const pluginSource = resolvePluginSource();
|
|
729
|
+
|
|
730
|
+
if (!options.dryRun) {
|
|
731
|
+
mkdirSync(opencodeDir, { recursive: true });
|
|
732
|
+
ensurePluginLink(pluginSource, pluginPath, copyMode);
|
|
733
|
+
}
|
|
734
|
+
const config = readConfig(configPath);
|
|
735
|
+
ensureProvider(config, baseUrl);
|
|
736
|
+
|
|
737
|
+
if (!options.skipModels) {
|
|
738
|
+
const result = syncModelsIntoProvider(config, options);
|
|
739
|
+
printSyncResult(result, options);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (options.dryRun) {
|
|
743
|
+
console.log("Dry run: no files changed.");
|
|
744
|
+
} else {
|
|
745
|
+
writeConfig(configPath, config, options.noBackup === true);
|
|
746
|
+
installAiSdk(opencodeDir);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
console.log(`${options.dryRun ? "Would install" : "Installed"} ${PROVIDER_ID}`);
|
|
750
|
+
console.log(`Plugin path: ${pluginPath}${copyMode ? " (copy)" : " (symlink)"}`);
|
|
751
|
+
console.log(`Config path: ${configPath}`);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function commandSyncModels(options: Options) {
|
|
755
|
+
const { configPath } = resolvePaths(options);
|
|
756
|
+
const config = readConfig(configPath);
|
|
757
|
+
ensureProvider(config, options.baseUrl || DEFAULT_BASE_URL);
|
|
758
|
+
|
|
759
|
+
const result = syncModelsIntoProvider(config, options);
|
|
760
|
+
|
|
761
|
+
if (!options.dryRun) {
|
|
762
|
+
writeConfig(configPath, config, options.noBackup === true, options.json === true);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
if (options.json) {
|
|
766
|
+
console.log(JSON.stringify(createSyncJsonResult(result, options, configPath), null, 2));
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
printSyncResult(result, options);
|
|
771
|
+
if (options.dryRun) {
|
|
772
|
+
console.log("Dry run: no changes written.");
|
|
773
|
+
}
|
|
774
|
+
console.log(`Config path: ${configPath}`);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function commandModels(options: Options) {
|
|
778
|
+
const models = discoverModelsSafe();
|
|
779
|
+
const explanation = explainCursorModels(models);
|
|
780
|
+
|
|
781
|
+
if (options.json) {
|
|
782
|
+
console.log(JSON.stringify(explanation, null, 2));
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
console.log(`Cursor models discovered: ${explanation.modelCount}`);
|
|
787
|
+
console.log(`Grouped Cursor models: ${explanation.groupedCount}`);
|
|
788
|
+
console.log(`Direct models: ${explanation.directCount}`);
|
|
789
|
+
|
|
790
|
+
if (!options.explain) {
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
console.log("");
|
|
795
|
+
console.log("Model groups:");
|
|
796
|
+
for (const group of explanation.groups) {
|
|
797
|
+
console.log(` ${group.id}`);
|
|
798
|
+
console.log(` Default: ${group.defaultCursorModel}`);
|
|
799
|
+
const variants = Object.entries(group.variants);
|
|
800
|
+
if (variants.length === 0) {
|
|
801
|
+
console.log(" Variants: none");
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
console.log(" Variants:");
|
|
805
|
+
for (const [variant, cursorModel] of variants) {
|
|
806
|
+
console.log(` ${variant}: ${cursorModel}`);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
console.log("");
|
|
811
|
+
console.log("Direct models:");
|
|
812
|
+
for (const modelId of explanation.direct) {
|
|
813
|
+
console.log(` ${modelId}`);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function printSyncResult(result: SyncModelsResult, options: Options) {
|
|
818
|
+
console.log(`Models synced: ${result.syncedCount}`);
|
|
819
|
+
if (options.variants) {
|
|
820
|
+
console.log(`Grouped Cursor models: ${result.groupedCount}`);
|
|
821
|
+
}
|
|
822
|
+
if (result.removedCount > 0) {
|
|
823
|
+
console.log(`Raw grouped models removed: ${result.removedCount}`);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
console.log("Sync summary:");
|
|
827
|
+
console.log(` Added: ${result.summary.added}`);
|
|
828
|
+
console.log(` Updated: ${result.summary.updated}`);
|
|
829
|
+
console.log(` Removed: ${result.summary.removed}`);
|
|
830
|
+
console.log(` Priced: ${result.summary.priced}`);
|
|
831
|
+
console.log(` Skipped: ${result.summary.skipped}`);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const NPM_PACKAGE = "@evanovation/open-cursor";
|
|
835
|
+
|
|
836
|
+
function commandUninstall(options: Options) {
|
|
837
|
+
const { configPath, pluginPath } = resolvePaths(options);
|
|
838
|
+
rmSync(pluginPath, { force: true });
|
|
839
|
+
|
|
840
|
+
if (existsSync(configPath)) {
|
|
841
|
+
const config = readConfig(configPath);
|
|
842
|
+
if (Array.isArray(config.plugin)) {
|
|
843
|
+
// Remove both cursor-acp (symlink) and @evanovation/open-cursor (npm-direct) entries
|
|
844
|
+
config.plugin = config.plugin.filter((name: string) => {
|
|
845
|
+
if (name === PROVIDER_ID) return false;
|
|
846
|
+
if (typeof name === "string" && name.startsWith(NPM_PACKAGE)) return false;
|
|
847
|
+
return true;
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
if (config.provider && typeof config.provider === "object") {
|
|
851
|
+
delete config.provider[PROVIDER_ID];
|
|
852
|
+
}
|
|
853
|
+
writeConfig(configPath, config, options.noBackup === true);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
console.log(`Removed plugin link: ${pluginPath}`);
|
|
857
|
+
console.log(`Removed provider "${PROVIDER_ID}" from ${configPath}`);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
export function getStatusResult(configPath: string, pluginPath: string): StatusResult {
|
|
861
|
+
// Plugin
|
|
862
|
+
let pluginType: "symlink" | "file" | "missing" = "missing";
|
|
863
|
+
let pluginTarget: string | undefined;
|
|
864
|
+
if (existsSync(pluginPath)) {
|
|
865
|
+
try {
|
|
866
|
+
const stat = lstatSync(pluginPath);
|
|
867
|
+
pluginType = stat.isSymbolicLink() ? "symlink" : "file";
|
|
868
|
+
if (pluginType === "symlink") {
|
|
869
|
+
try {
|
|
870
|
+
pluginTarget = readFileSync(pluginPath, "utf8");
|
|
871
|
+
} catch {
|
|
872
|
+
pluginTarget = undefined;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
} catch (error) {
|
|
876
|
+
if (!isErrnoException(error) || error.code !== "ENOENT") {
|
|
877
|
+
throw error;
|
|
878
|
+
}
|
|
879
|
+
pluginType = "missing";
|
|
880
|
+
pluginTarget = undefined;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Provider
|
|
885
|
+
let config: any;
|
|
886
|
+
let providerEnabled = false;
|
|
887
|
+
let baseUrl = "http://127.0.0.1:32124/v1";
|
|
888
|
+
let modelCount = 0;
|
|
889
|
+
if (existsSync(configPath)) {
|
|
890
|
+
config = readConfig(configPath);
|
|
891
|
+
const provider = config.provider?.["cursor-acp"];
|
|
892
|
+
providerEnabled = !!provider;
|
|
893
|
+
if (provider?.options?.baseURL) {
|
|
894
|
+
baseUrl = provider.options.baseURL;
|
|
895
|
+
}
|
|
896
|
+
modelCount = Object.keys(provider?.models || {}).length;
|
|
897
|
+
} else {
|
|
898
|
+
config = undefined;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// AI SDK
|
|
902
|
+
const opencodeDir = dirname(configPath);
|
|
903
|
+
const sdkPath = join(opencodeDir, "node_modules", "@ai-sdk", "openai-compatible");
|
|
904
|
+
const aiSdkInstalled = existsSync(sdkPath);
|
|
905
|
+
const sdkApiKeySource = resolveCliSdkAuthSource(config);
|
|
906
|
+
const legacyCursorAuthFile = getPossibleAuthPaths().some((authPath) => existsSync(authPath));
|
|
907
|
+
|
|
908
|
+
let installMethod: "symlink" | "npm-direct" | "none" = "none";
|
|
909
|
+
if (pluginType !== "missing") {
|
|
910
|
+
installMethod = "symlink";
|
|
911
|
+
} else if (isNpmDirectInstalled(config)) {
|
|
912
|
+
installMethod = "npm-direct";
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
return {
|
|
916
|
+
installMethod,
|
|
917
|
+
plugin: {
|
|
918
|
+
path: pluginPath,
|
|
919
|
+
type: pluginType,
|
|
920
|
+
target: pluginTarget,
|
|
921
|
+
},
|
|
922
|
+
provider: {
|
|
923
|
+
configPath,
|
|
924
|
+
name: "cursor-acp",
|
|
925
|
+
enabled: providerEnabled,
|
|
926
|
+
baseUrl,
|
|
927
|
+
modelCount,
|
|
928
|
+
},
|
|
929
|
+
aiSdk: {
|
|
930
|
+
installed: aiSdkInstalled,
|
|
931
|
+
},
|
|
932
|
+
auth: {
|
|
933
|
+
legacyCursorAuthFile,
|
|
934
|
+
sdkApiKey: sdkApiKeySource !== undefined,
|
|
935
|
+
sdkApiKeySource,
|
|
936
|
+
},
|
|
937
|
+
runtime: getRuntimeStatus(),
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
export function runDeepDoctorChecks(configPath: string): CheckResult[] {
|
|
942
|
+
const checks: CheckResult[] = [];
|
|
943
|
+
let config: any;
|
|
944
|
+
|
|
945
|
+
try {
|
|
946
|
+
config = readConfig(configPath);
|
|
947
|
+
} catch (error) {
|
|
948
|
+
return [{
|
|
949
|
+
name: "Deep config read",
|
|
950
|
+
passed: false,
|
|
951
|
+
message: error instanceof Error ? error.message : String(error),
|
|
952
|
+
}];
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const provider = config.provider?.[PROVIDER_ID];
|
|
956
|
+
const models = isRecord(provider?.models) ? provider.models : {};
|
|
957
|
+
const baseUrl = typeof provider?.options?.baseURL === "string" ? provider.options.baseURL : "";
|
|
958
|
+
|
|
959
|
+
checks.push({
|
|
960
|
+
name: "Provider base URL",
|
|
961
|
+
passed: baseUrl.startsWith("http://") || baseUrl.startsWith("https://"),
|
|
962
|
+
message: baseUrl || "missing - run: open-cursor install",
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
checks.push({
|
|
966
|
+
name: "Provider models",
|
|
967
|
+
passed: Object.keys(models).length > 0,
|
|
968
|
+
message: `${Object.keys(models).length} configured model(s)`,
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
const variantEntryCount = countVariantModelEntries(models);
|
|
972
|
+
checks.push({
|
|
973
|
+
name: "Compact variants",
|
|
974
|
+
passed: variantEntryCount > 0,
|
|
975
|
+
warning: variantEntryCount === 0,
|
|
976
|
+
message: variantEntryCount > 0
|
|
977
|
+
? `${variantEntryCount} model entr${variantEntryCount === 1 ? "y" : "ies"} with variants`
|
|
978
|
+
: "no compact variants found - run: open-cursor sync-models --variants --compact",
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
let discoveredModels: DiscoveredModel[];
|
|
982
|
+
try {
|
|
983
|
+
discoveredModels = discoverModelsFromCursorAgent();
|
|
984
|
+
checks.push({
|
|
985
|
+
name: "Cursor model discovery",
|
|
986
|
+
passed: true,
|
|
987
|
+
message: `${discoveredModels.length} model(s) from cursor-agent`,
|
|
988
|
+
});
|
|
989
|
+
} catch (error) {
|
|
990
|
+
checks.push({
|
|
991
|
+
name: "Cursor model discovery",
|
|
992
|
+
passed: false,
|
|
993
|
+
message: error instanceof Error ? error.message : String(error),
|
|
994
|
+
warning: true,
|
|
995
|
+
});
|
|
996
|
+
return checks;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const knownModelIds = new Set(discoveredModels.map(model => model.id));
|
|
1000
|
+
const unknownTargets = collectConfiguredCursorModels(models)
|
|
1001
|
+
.filter(modelId => !knownModelIds.has(modelId));
|
|
1002
|
+
checks.push({
|
|
1003
|
+
name: "Configured Cursor model targets",
|
|
1004
|
+
passed: unknownTargets.length === 0,
|
|
1005
|
+
warning: unknownTargets.length > 0,
|
|
1006
|
+
message: unknownTargets.length === 0
|
|
1007
|
+
? "all configured targets exist in cursor-agent models"
|
|
1008
|
+
: `${unknownTargets.length} target(s) not found: ${unknownTargets.slice(0, 5).join(", ")}`,
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
return checks;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function countVariantModelEntries(models: Record<string, unknown>): number {
|
|
1015
|
+
return Object.values(models).filter(entry => {
|
|
1016
|
+
return isRecord(entry) && isRecord(entry.variants) && Object.keys(entry.variants).length > 0;
|
|
1017
|
+
}).length;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function collectConfiguredCursorModels(models: Record<string, unknown>): string[] {
|
|
1021
|
+
const targets: string[] = [];
|
|
1022
|
+
|
|
1023
|
+
for (const [modelId, entry] of Object.entries(models)) {
|
|
1024
|
+
if (!isRecord(entry)) {
|
|
1025
|
+
targets.push(modelId);
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const optionTarget = readCursorModel(entry.options);
|
|
1030
|
+
targets.push(optionTarget || modelId);
|
|
1031
|
+
|
|
1032
|
+
if (!isRecord(entry.variants)) continue;
|
|
1033
|
+
for (const variantEntry of Object.values(entry.variants)) {
|
|
1034
|
+
const variantTarget = readCursorModel(variantEntry);
|
|
1035
|
+
if (variantTarget) targets.push(variantTarget);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
return [...new Set(targets)];
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function readCursorModel(value: unknown): string | undefined {
|
|
1043
|
+
if (!isRecord(value)) return undefined;
|
|
1044
|
+
const cursorModel = value.cursorModel;
|
|
1045
|
+
return typeof cursorModel === "string" && cursorModel.trim().length > 0
|
|
1046
|
+
? cursorModel.trim()
|
|
1047
|
+
: undefined;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function commandStatus(options: Options) {
|
|
1051
|
+
const { configPath, pluginPath } = resolvePaths(options);
|
|
1052
|
+
const result = getStatusResult(configPath, pluginPath);
|
|
1053
|
+
|
|
1054
|
+
if (options.json) {
|
|
1055
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
console.log("");
|
|
1060
|
+
console.log("Plugin");
|
|
1061
|
+
console.log(` Path: ${result.plugin.path}`);
|
|
1062
|
+
if (result.plugin.type === "symlink" && result.plugin.target) {
|
|
1063
|
+
console.log(` Type: symlink → ${result.plugin.target}`);
|
|
1064
|
+
} else if (result.plugin.type === "file") {
|
|
1065
|
+
console.log(` Type: file (copy)`);
|
|
1066
|
+
} else {
|
|
1067
|
+
console.log(` Type: missing`);
|
|
1068
|
+
}
|
|
1069
|
+
console.log(` Install method: ${result.installMethod}`);
|
|
1070
|
+
|
|
1071
|
+
console.log("");
|
|
1072
|
+
console.log("Provider");
|
|
1073
|
+
console.log(` Config: ${result.provider.configPath}`);
|
|
1074
|
+
console.log(` Name: ${result.provider.name}`);
|
|
1075
|
+
console.log(` Enabled: ${result.provider.enabled ? "yes" : "no"}`);
|
|
1076
|
+
console.log(` Base URL: ${result.provider.baseUrl}`);
|
|
1077
|
+
console.log(` Models: ${result.provider.modelCount}`);
|
|
1078
|
+
|
|
1079
|
+
console.log("");
|
|
1080
|
+
console.log("AI SDK");
|
|
1081
|
+
console.log(` @ai-sdk/openai-compatible: ${result.aiSdk.installed ? "installed" : "not installed"}`);
|
|
1082
|
+
|
|
1083
|
+
console.log("");
|
|
1084
|
+
console.log("Authentication");
|
|
1085
|
+
console.log(` Legacy cursor-agent auth file: ${result.auth.legacyCursorAuthFile ? "found" : "not found"}`);
|
|
1086
|
+
console.log(
|
|
1087
|
+
` Cursor SDK API key: ${result.auth.sdkApiKey ? `found via ${result.auth.sdkApiKeySource}` : "not configured"}`,
|
|
1088
|
+
);
|
|
1089
|
+
|
|
1090
|
+
console.log("");
|
|
1091
|
+
console.log("Runtime");
|
|
1092
|
+
console.log(` Backend preference: ${result.runtime.backend.preference}`);
|
|
1093
|
+
console.log(` Agent pool: ${result.runtime.agentPool.enabled ? "enabled" : "disabled"}`);
|
|
1094
|
+
console.log(` Agent pool idle: ${result.runtime.agentPool.idleMs}ms`);
|
|
1095
|
+
console.log(` Session resume: ${result.runtime.sessionResume.enabled ? "enabled" : "disabled"}`);
|
|
1096
|
+
console.log(` Log level: ${result.runtime.logging.level}`);
|
|
1097
|
+
console.log(` Console logging: ${result.runtime.logging.console ? "enabled" : "disabled"}`);
|
|
1098
|
+
console.log(` Log dir: ${result.runtime.logging.dir}`);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function commandDoctor(options: Options) {
|
|
1102
|
+
const { configPath, pluginPath } = resolvePaths(options);
|
|
1103
|
+
const checks = [
|
|
1104
|
+
...runDoctorChecks(configPath, pluginPath),
|
|
1105
|
+
...(options.deep ? runDeepDoctorChecks(configPath) : []),
|
|
1106
|
+
];
|
|
1107
|
+
|
|
1108
|
+
if (options.json) {
|
|
1109
|
+
const failed = checks.filter(c => !c.passed && !c.warning);
|
|
1110
|
+
console.log(JSON.stringify({ deep: options.deep === true, checks, failed: failed.length }, null, 2));
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
console.log("");
|
|
1115
|
+
for (const check of checks) {
|
|
1116
|
+
const symbol = check.passed ? "\u2713" : (check.warning ? "\u26A0" : "\u2717");
|
|
1117
|
+
const color = check.passed ? "\x1b[32m" : (check.warning ? "\x1b[33m" : "\x1b[31m");
|
|
1118
|
+
console.log(` ${color}${symbol}\x1b[0m ${check.name}: ${check.message}`);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
const failed = checks.filter(c => !c.passed && !c.warning);
|
|
1122
|
+
console.log("");
|
|
1123
|
+
if (failed.length === 0) {
|
|
1124
|
+
console.log("All checks passed!");
|
|
1125
|
+
} else {
|
|
1126
|
+
console.log(`${failed.length} check(s) failed. See messages above.`);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function main() {
|
|
1131
|
+
let parsed: { command: Command; options: Options };
|
|
1132
|
+
try {
|
|
1133
|
+
parsed = parseArgs(process.argv.slice(2));
|
|
1134
|
+
} catch (error) {
|
|
1135
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1136
|
+
console.error(message);
|
|
1137
|
+
printHelp();
|
|
1138
|
+
process.exit(1);
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
try {
|
|
1143
|
+
switch (parsed.command) {
|
|
1144
|
+
case "install":
|
|
1145
|
+
commandInstall(parsed.options);
|
|
1146
|
+
return;
|
|
1147
|
+
case "sync-models":
|
|
1148
|
+
commandSyncModels(parsed.options);
|
|
1149
|
+
return;
|
|
1150
|
+
case "models":
|
|
1151
|
+
commandModels(parsed.options);
|
|
1152
|
+
return;
|
|
1153
|
+
case "uninstall":
|
|
1154
|
+
commandUninstall(parsed.options);
|
|
1155
|
+
return;
|
|
1156
|
+
case "status":
|
|
1157
|
+
commandStatus(parsed.options);
|
|
1158
|
+
return;
|
|
1159
|
+
case "doctor":
|
|
1160
|
+
commandDoctor(parsed.options);
|
|
1161
|
+
return;
|
|
1162
|
+
case "help":
|
|
1163
|
+
printHelp();
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
} catch (error) {
|
|
1167
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1168
|
+
console.error(`Error: ${message}`);
|
|
1169
|
+
process.exit(1);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function resolveEntrypointArg(argvPath: string | undefined): string {
|
|
1174
|
+
if (!argvPath) return "";
|
|
1175
|
+
return resolve(argvPath);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function toRealPath(path: string): string {
|
|
1179
|
+
try {
|
|
1180
|
+
return realpathSync(path);
|
|
1181
|
+
} catch {
|
|
1182
|
+
return path;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
export function isCliEntrypoint(metaUrl: string, argvPath: string | undefined): boolean {
|
|
1187
|
+
const currentPath = fileURLToPath(metaUrl);
|
|
1188
|
+
const argvResolved = resolveEntrypointArg(argvPath);
|
|
1189
|
+
if (!argvResolved) return false;
|
|
1190
|
+
return currentPath === argvResolved || toRealPath(currentPath) === toRealPath(argvResolved);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
if (process.env.NODE_ENV !== "test" && isCliEntrypoint(import.meta.url, process.argv[1])) {
|
|
1194
|
+
main();
|
|
1195
|
+
}
|