|
1 | 1 | import { tool, type ToolContext, type ToolDefinition } from "@opencode-ai/plugin"; |
| 2 | +import { rmSync } from "node:fs"; |
| 3 | +import semver from "semver"; |
2 | 4 | import { runCloudAgent } from "../provider/cloud-agent.js"; |
3 | 5 | import { runDelegate } from "../provider/delegate.js"; |
4 | 6 | import { linkDelegateSession } from "../provider/subagent-bridge.js"; |
| 7 | +import { discoverModels } from "../model-discovery.js"; |
| 8 | +import { |
| 9 | + getLocalVersion, |
| 10 | + getLatestVersion, |
| 11 | + clearVersionCache, |
| 12 | + PLUGIN_CACHE_PATH, |
| 13 | +} from "../version-check.js"; |
5 | 14 |
|
6 | 15 | const s = tool.schema; |
7 | 16 |
|
8 | 17 | export interface CursorToolDeps { |
9 | 18 | /** |
10 | 19 | * Resolve the Cursor API key (from opencode auth, captured by the plugin's |
11 | 20 | * auth loader, or the CURSOR_API_KEY env var). Returns undefined when no key |
12 | | - * is available so the tool can return a clear "needs auth" message. |
| 21 | + * is available so the tool can return a clear "needs auth" message. Async |
| 22 | + * because v2 resolves the stored connection per call. |
13 | 23 | */ |
14 | | - resolveApiKey: () => string | undefined; |
| 24 | + resolveApiKey: () => string | undefined | Promise<string | undefined>; |
15 | 25 | /** Default working directory for local delegation (the session worktree/cwd). */ |
16 | 26 | defaultCwd: () => string; |
17 | 27 | } |
@@ -88,7 +98,7 @@ export function buildCursorTools(deps: CursorToolDeps): Record<string, ToolDefin |
88 | 98 | .describe("Operate on the current branch instead of creating a new one."), |
89 | 99 | }, |
90 | 100 | execute: async (args, context) => { |
91 | | - const apiKey = deps.resolveApiKey(); |
| 101 | + const apiKey = await deps.resolveApiKey(); |
92 | 102 | if (!apiKey) return NEEDS_AUTH; |
93 | 103 |
|
94 | 104 | const approval = await requestApproval( |
@@ -168,7 +178,7 @@ export function buildCursorTools(deps: CursorToolDeps): Record<string, ToolDefin |
168 | 178 | .describe("Resume a specific Cursor agent id instead of starting fresh."), |
169 | 179 | }, |
170 | 180 | execute: async (args, context) => { |
171 | | - const apiKey = deps.resolveApiKey(); |
| 181 | + const apiKey = await deps.resolveApiKey(); |
172 | 182 | if (!apiKey) return NEEDS_AUTH; |
173 | 183 |
|
174 | 184 | const approval = await requestApproval(context, "cursor_delegate", [args.model], { |
@@ -243,3 +253,121 @@ export function buildCursorTools(deps: CursorToolDeps): Record<string, ToolDefin |
243 | 253 | }), |
244 | 254 | }; |
245 | 255 | } |
| 256 | + |
| 257 | +/** |
| 258 | + * Shared base of the `cursor_refresh_models` description. v1 appends a note |
| 259 | + * pointing at `cursor_update_plugin`; v2 (which does not register that tool) |
| 260 | + * appends a note pointing at `opencode plugin update` instead. |
| 261 | + */ |
| 262 | +export const REFRESH_DESCRIPTION_BASE = |
| 263 | + "Refresh the live Cursor model catalog now (bypasses the cache) and report the available models. The catalog also auto-refreshes on every opencode startup; use this to pick up new models mid-session."; |
| 264 | + |
| 265 | +/** |
| 266 | + * Build the maintenance tools shared by v1 and v2: |
| 267 | + * - `cursor_refresh_models`: force-refresh the model catalog. |
| 268 | + * - `cursor_update_plugin`: check for and perform a plugin update. |
| 269 | + * |
| 270 | + * `deps?.resolveApiKey` (v2 passes its stored-connection path) makes the |
| 271 | + * refresh authenticate with the resolved key; without it (v1) the refresh |
| 272 | + * stays keyless and discovery falls back to CURSOR_API_KEY / cached models, |
| 273 | + * as before. The update tool never touches the key or session state. |
| 274 | + */ |
| 275 | +export function buildMaintenanceTools( |
| 276 | + deps?: Pick<CursorToolDeps, "resolveApiKey">, |
| 277 | +): Record<string, ToolDefinition> { |
| 278 | + return { |
| 279 | + cursor_refresh_models: { |
| 280 | + description: `${REFRESH_DESCRIPTION_BASE} Note: to update the plugin itself (not just the model list), use the cursor_update_plugin tool.`, |
| 281 | + args: {}, |
| 282 | + execute: async () => { |
| 283 | + const apiKey = deps ? await deps.resolveApiKey() : undefined; |
| 284 | + const result = await discoverModels({ apiKey, forceRefresh: true }); |
| 285 | + const lines = result.models.map((m) => `- ${m.id} — ${m.displayName}`); |
| 286 | + const header = |
| 287 | + result.source === "live" |
| 288 | + ? `Refreshed ${result.models.length} Cursor models (live):` |
| 289 | + : `Could not fetch live models (${result.source}). ${result.warning ?? ""}`.trim(); |
| 290 | + return { |
| 291 | + title: `Cursor models (${result.source})`, |
| 292 | + output: [header, ...lines].join("\n"), |
| 293 | + metadata: { source: result.source, count: result.models.length }, |
| 294 | + }; |
| 295 | + }, |
| 296 | + }, |
| 297 | + cursor_update_plugin: { |
| 298 | + description: |
| 299 | + "Check if the @stablekernel/opencode-cursor plugin is up to date and update it if not. Call this when the user asks to update, upgrade, or refresh the cursor plugin. Clears the cached install so opencode fetches the latest version on next launch.", |
| 300 | + args: {}, |
| 301 | + execute: async () => { |
| 302 | + if (process.env.CI || process.env.NO_UPDATE_NOTIFIER) { |
| 303 | + return { |
| 304 | + title: "cursor plugin (checks disabled)", |
| 305 | + output: "Update checks are disabled (CI or NO_UPDATE_NOTIFIER is set).", |
| 306 | + metadata: { |
| 307 | + local: undefined, |
| 308 | + latest: undefined, |
| 309 | + status: "disabled" as const, |
| 310 | + }, |
| 311 | + }; |
| 312 | + } |
| 313 | + |
| 314 | + const local = getLocalVersion(); |
| 315 | + if (!local || !semver.valid(local)) { |
| 316 | + return { |
| 317 | + title: "cursor plugin (unknown version)", |
| 318 | + output: "Could not determine the installed plugin version.", |
| 319 | + metadata: { local, latest: undefined, status: "failed" as const }, |
| 320 | + }; |
| 321 | + } |
| 322 | + |
| 323 | + const latest = await getLatestVersion(); |
| 324 | + if (!latest || !semver.valid(latest)) { |
| 325 | + return { |
| 326 | + title: "cursor plugin (registry unavailable)", |
| 327 | + output: |
| 328 | + "Could not fetch the latest version from npm. Check your network connection and try again.", |
| 329 | + metadata: { local, latest, status: "failed" as const }, |
| 330 | + }; |
| 331 | + } |
| 332 | + |
| 333 | + if (!semver.gt(latest, local)) { |
| 334 | + return { |
| 335 | + title: "cursor plugin (up to date)", |
| 336 | + output: `The plugin is up to date (v${local}).`, |
| 337 | + metadata: { local, latest, status: "up-to-date" as const }, |
| 338 | + }; |
| 339 | + } |
| 340 | + |
| 341 | + // Plugin is outdated — clear the opencode plugin cache so it re-fetches on next launch. |
| 342 | + const cachePath = PLUGIN_CACHE_PATH; |
| 343 | + const removeCommand = |
| 344 | + process.platform === "win32" |
| 345 | + ? `rmdir /s /q "${cachePath}"` |
| 346 | + : `rm -rf ${cachePath}`; |
| 347 | + |
| 348 | + try { |
| 349 | + rmSync(cachePath, { recursive: true, force: true }); |
| 350 | + clearVersionCache(); |
| 351 | + return { |
| 352 | + title: "cursor plugin (updated)", |
| 353 | + output: |
| 354 | + `Plugin cache cleared (v${local} → v${latest}).\n` + |
| 355 | + `Restart opencode to complete the upgrade — it will fetch v${latest} on next launch.`, |
| 356 | + metadata: { local, latest, status: "updated" as const }, |
| 357 | + }; |
| 358 | + } catch (err) { |
| 359 | + const message = err instanceof Error ? err.message : String(err); |
| 360 | + return { |
| 361 | + title: "cursor plugin (cache clear failed)", |
| 362 | + output: |
| 363 | + `Failed to clear plugin cache: ${message}\n\n` + |
| 364 | + `To update manually, exit opencode and run:\n\n` + |
| 365 | + ` ${removeCommand}\n\n` + |
| 366 | + `then restart opencode.`, |
| 367 | + metadata: { local, latest, status: "failed" as const }, |
| 368 | + }; |
| 369 | + } |
| 370 | + }, |
| 371 | + }, |
| 372 | + }; |
| 373 | +} |
0 commit comments