From 24666e953918a5b92002af5cb4b6b457d878712e Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Tue, 29 Sep 2026 09:47:16 -0500 Subject: [PATCH] feat: add opencode v2 support (dual v1/v2 plugin entrypoint) opencode v2 ships as a separate npm scope (@opencode/cli) with a new plugin API. The default export is now an object carrying both entrypoints: v1 (>= 1.18.29) calls `server`, v2 calls `setup`. Each host ignores the other's key. v2 `setup` (src/plugin/v2.ts) registers the Cursor provider and model catalog (real per-model cost, settings.cwd pinned to the session directory), stored-key/env auth methods, the session context and title hooks, and cursor_refresh_models. Cleanup removes the generated system rule. Not exposed on v2: cursor_delegate / cursor_cloud_agent (v2 has no plugin-drivable approval path, so they stay absent rather than ungated) and cursor_update_plugin (v1 cache layout; use `opencode plugin update`). Remaining gaps are listed in the README. cursor_refresh_models / cursor_update_plugin bodies move to a shared builder in cursor-tools.ts; v1 behavior is unchanged. A root server.js re-export lets v2 load a file:// directory plugin. --- CHANGELOG.md | 45 +++ README.md | 76 ++++- SECURITY.md | 6 + package.json | 1 + server.js | 7 + src/plugin/cursor-tools.ts | 136 +++++++- src/plugin/index.ts | 123 ++----- src/plugin/v2.ts | 451 +++++++++++++++++++++++++ test/mcp-config.test.ts | 2 +- test/plugin-auto-compaction.test.ts | 2 +- test/plugin-loader-refresh.test.ts | 2 +- test/plugin-skill-mirror.test.ts | 2 +- test/plugin-system-rule.test.ts | 2 +- test/plugin-tools-wiring.test.ts | 4 +- test/plugin-tools.test.ts | 2 +- test/plugin-v2.test.ts | 499 ++++++++++++++++++++++++++++ test/subagent-bridge.test.ts | 2 +- 17 files changed, 1246 insertions(+), 116 deletions(-) create mode 100644 server.js create mode 100644 src/plugin/v2.ts create mode 100644 test/plugin-v2.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4584a78..da62a59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,51 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- **Add: opencode v2 support (dual v1/v2 plugin entrypoint).** The default + export is now a dual object: the v2 `{ id, setup }` shape plus the v1 + `server()` entrypoint. opencode v1 (>= 1.18.29) calls `server()` as before; + opencode v2 (tested against 2.0.19) calls `setup()` and registers the Cursor + provider (with `settings.cwd` pinned to the session directory), the model + catalog seeded from stored auth or the `CURSOR_API_KEY` env with real + per-model cost (same table as v1), the session context mapping, the + dedicated title hook (marks title generation ephemeral, same race fix as + v1's `chat.params`), the `cursor_refresh_models` tool, and the stored-key + auth methods through the v2 transform/hook API. v2 config uses the + `plugins` key (plural) with a plain package string entry; the plugin reads + no plugin-level options on v2 (v2's `providers.cursor.settings` are not + consulted — the plugin registers its own provider record). + The provider package specifier honors `OPENCODE_CURSOR_PROVIDER_NPM` on + v2 as well, so a local `file://` build can be tested without publishing. + A root `server.js` entry file was added for v2's path-plugin loader, and + the `cursor_refresh_models` / `cursor_update_plugin` bodies moved to a + shared builder used by both entrypoints. On v2 the refresh tool points + its description at `opencode plugin update` (not the unregistered + `cursor_update_plugin`) and re-resolves the stored connection key on + every call; on v1 the refresh tool is unchanged (keyless, as before). + The published `.d.ts` files no longer reference the dev-only + `@opencode/plugin` package (v2 structural types are declared locally), so + TypeScript consumers resolve them without extra installs. + + Tools intentionally NOT registered on v2 (fail-closed — absent, not + silently ungated): + + - `cursor_delegate` / `cursor_cloud_agent` — v1 gates them fail-closed + through the per-call `ask` approval; v2's plugin ToolContext has no + `ask`/`assert` path a plugin can drive and nothing else in 2.0.19 + enforces one, so on v2 they would run Cursor agents ungated. They are + unavailable on v2 until a real approval path exists. + - `cursor_update_plugin` — it clears the v1 `packages/` cache layout; + v2 caches plugins under `/npm//` and updates them with + `opencode plugin update`, so a v1 cache-clear would report success + while doing nothing. Use `opencode plugin update` on v2. + + Known v2 gaps (also in README): no live MCP forwarding, no skill + mirror/skills catalogue, no plugin-tools bridge, no subagent `task`-part + stamping, no update toast (v2 has no `ctx.tui`), no `app.log` bridge (v2 + removed the write endpoint), and the `provider.cursor.options` block is + not read on v2 (models are always listed with the no-auto-compaction + limit). + ## [0.9.1-next.0] — 2026-08-26 (pre-release) Fixes the subagent child-session pane fragmenting one flowing answer diff --git a/README.md b/README.md index 698c5f5..741dc74 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,9 @@ It uses the [official Cursor SDK](https://cursor.com/docs/sdk/typescript) (`@cur ## Requirements -- **opencode 1.17+** +- **opencode v2 (2.0.19+, tested against 2.0.19)** or **opencode v1 (1.18.29+)** — the + plugin entrypoint is a dual object; v1 needs 1.18.29+ to load an object (with `id` + + `server`) as a path plugin, v2 reads the same object's `setup`. - **Node.js 22.13+ on your `PATH`** (optional) — opencode runs on [Bun](https://bun.sh) and the plugin runs the Cursor SDK in-process by default; Node is only needed for the `sidecar` transport fallback (see [Transport](#transport)). @@ -71,6 +73,71 @@ Drop `@latest` (`"@stablekernel/opencode-cursor"`) or pin a version The stale-version check is skipped when the `CI` or `NO_UPDATE_NOTIFIER` environment variable is set. +### opencode v2 + +opencode v2 uses a `plugins` key (plural) and loads the plugin's `setup()` entrypoint +(v1 uses `plugin` and calls `server()`): + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugins": ["@stablekernel/opencode-cursor@latest"] +} +``` + +Both generations load the same published package, so one install serves either. +Minimum versions: v1 >= 1.18.29 (object plugin entrypoint), v2 tested against 2.0.19. + +On v2 the plugin registers the Cursor provider (pinned to the session directory +via `settings.cwd`, so the agent runs in your project even when the v2 runner +process was started elsewhere), the model catalog with real per-model cost +(from stored auth or `CURSOR_API_KEY`), the session context mapping, the +`cursor_refresh_models` tool, and the stored-key auth methods. The plugin +reads no plugin-level options on v2 (a `plugins` entry with an options object +is accepted by v2's config schema but ignored by this plugin), and it registers +its own provider record — so the v1 `provider.cursor` block does not apply +there. Configure v2 through the environment variables listed below. + +**Tools not exposed on v2** — `cursor_delegate`, `cursor_cloud_agent` (delegation) +and `cursor_update_plugin` (self-update) are v1-only, deliberately: + +- **Delegation tools are fail-closed on v1** through a per-call approval gate. + v2's plugin API has no approval path a plugin can drive, and nothing else in + 2.0.19 enforces one for plugin tools — so on v2 they would run Cursor agents + *ungated*. The plugin leaves them unregistered on v2: unavailable beats + silently ungated. They return when v2 grows a real approval path. +- **`cursor_update_plugin` clears the v1 plugin cache layout**; v2 caches + plugins elsewhere (`/npm//`) and ships its own updater. On v2 run + `opencode plugin update` instead. + +**Known v2 gaps** — behaviors the v1 `config`/`chat.params`/`event` hooks provide that +v2 does not: + +- **Delegation tools** (`cursor_delegate`, `cursor_cloud_agent`) — unavailable + on v2 (fail-closed; see "Tools not exposed on v2" above). On v1 they are + gated by a per-call approval honored from your `permission` config. +- **`cursor_update_plugin`** — unavailable on v2; run + `opencode plugin update` instead (see above). +- **Live MCP forwarding** — v1 re-reads opencode's MCP server set every turn, so + mid-session enable/disable reaches the Cursor agent; v2 forwards nothing. +- **Skill mirror / skills catalogue** — v1 mirrors opencode skills into + `.cursor/skills/` and injects the catalogue; unavailable on v2. +- **Plugin-tools bridge** — v1 exposes other plugins' custom tools to the Cursor + agent via a local stdio MCP server; unavailable on v2. +- **Subagent `task`-part stamping** — v1 stamps the child session id on the + running `task` part so the TUI card is clickable; unavailable on v2 (delegate + results still surface, without the child-session link). +- **`provider.cursor.options` settings** — the v1 `provider.cursor.options` + block (forwarding, skills, sandbox, `autoCompaction`, ...) is not read on + v2; the plugin registers its own provider record there. `autoCompaction` + is therefore always off on v2 (models are always listed with the + no-auto-compaction limit — see [Compaction](#compaction)). +- **Update toast / stale-version warning UI** — v2 has no `ctx.tui`; use + `cursor_refresh_models` (v2) / the maintenance tools (v1) and + `opencode plugin update`. +- **`app.log` bridge** — v2 removed the client write endpoint, so plugin events + that v1 surfaced through `app.log` are not logged on v2. + To keep the plugin up to date easily, install the `opencode-plugins-refresh` helper (offered by the one-line installer, or install manually — see [Keeping the plugin up to date](#keeping-the-plugin-up-to-date)). @@ -470,6 +537,13 @@ are gated by opencode's `permission` config: { "permission": { "cursor_delegate": "ask", "cursor_cloud_agent": "ask" } } ``` +> [!NOTE] +> **opencode v1 only.** These tools are not exposed on v2 — v2 has no +> plugin-drivable approval path, and the tools are fail-closed by design, so +> on v2 they are simply absent (see [opencode v2](#opencode-v2)). The +> `permission` shape above is the v1 config; for the host's behavior without +> an entry, see opencode's own `permission` docs. + ### `cursor_delegate` (local) Runs one Cursor turn as a permission-gated tool call. Your primary opencode model hands off a diff --git a/SECURITY.md b/SECURITY.md index 6d33d93..50f635d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -36,6 +36,12 @@ your opencode `permission` config (`allow` / `ask` / `deny`) controls them. The gate is **fail-closed**: if no permission mechanism is available, or approval is rejected, the call is blocked rather than silently allowed. +On **opencode v2** these tools are **not exposed at all**: v2's plugin API has +no approval path a plugin can drive (no `ask`/`assert`), and nothing else in +2.0.19 enforces one for plugin tools. Rather than registering them without a +gate, the plugin leaves them unregistered — the same fail-closed outcome, by +absence. + ### Skills mirror — instructions cross the permission boundary When `forwardSkills` is enabled (default), opencode's resolved skills are diff --git a/package.json b/package.json index 9da01c5..558f917 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ ], "files": [ "dist", + "server.js", "README.md", "CHANGELOG.md" ], diff --git a/server.js b/server.js new file mode 100644 index 0000000..554546f --- /dev/null +++ b/server.js @@ -0,0 +1,7 @@ +// opencode v2 loads a `file://` directory plugin by probing literal root +// entrypoints (/server, /index, ...) — it never consults this +// package's `exports` map for path plugins. Re-export the dual plugin entry +// so the worktree/checkout directory is loadable by both v1 (via package.json +// `exports["./server"]`) and v2 (via this root file). +export * from "./dist/plugin/index.js"; +export { default } from "./dist/plugin/index.js"; diff --git a/src/plugin/cursor-tools.ts b/src/plugin/cursor-tools.ts index 16606fe..7c06bd6 100644 --- a/src/plugin/cursor-tools.ts +++ b/src/plugin/cursor-tools.ts @@ -1,7 +1,16 @@ import { tool, type ToolContext, type ToolDefinition } from "@opencode-ai/plugin"; +import { rmSync } from "node:fs"; +import semver from "semver"; import { runCloudAgent } from "../provider/cloud-agent.js"; import { runDelegate } from "../provider/delegate.js"; import { linkDelegateSession } from "../provider/subagent-bridge.js"; +import { discoverModels } from "../model-discovery.js"; +import { + getLocalVersion, + getLatestVersion, + clearVersionCache, + PLUGIN_CACHE_PATH, +} from "../version-check.js"; const s = tool.schema; @@ -9,9 +18,10 @@ export interface CursorToolDeps { /** * Resolve the Cursor API key (from opencode auth, captured by the plugin's * auth loader, or the CURSOR_API_KEY env var). Returns undefined when no key - * is available so the tool can return a clear "needs auth" message. + * is available so the tool can return a clear "needs auth" message. Async + * because v2 resolves the stored connection per call. */ - resolveApiKey: () => string | undefined; + resolveApiKey: () => string | undefined | Promise; /** Default working directory for local delegation (the session worktree/cwd). */ defaultCwd: () => string; } @@ -88,7 +98,7 @@ export function buildCursorTools(deps: CursorToolDeps): Record { - const apiKey = deps.resolveApiKey(); + const apiKey = await deps.resolveApiKey(); if (!apiKey) return NEEDS_AUTH; const approval = await requestApproval( @@ -168,7 +178,7 @@ export function buildCursorTools(deps: CursorToolDeps): Record { - const apiKey = deps.resolveApiKey(); + const apiKey = await deps.resolveApiKey(); if (!apiKey) return NEEDS_AUTH; const approval = await requestApproval(context, "cursor_delegate", [args.model], { @@ -243,3 +253,121 @@ export function buildCursorTools(deps: CursorToolDeps): Record, +): Record { + return { + cursor_refresh_models: { + description: `${REFRESH_DESCRIPTION_BASE} Note: to update the plugin itself (not just the model list), use the cursor_update_plugin tool.`, + args: {}, + execute: async () => { + const apiKey = deps ? await deps.resolveApiKey() : undefined; + const result = await discoverModels({ apiKey, forceRefresh: true }); + const lines = result.models.map((m) => `- ${m.id} — ${m.displayName}`); + const header = + result.source === "live" + ? `Refreshed ${result.models.length} Cursor models (live):` + : `Could not fetch live models (${result.source}). ${result.warning ?? ""}`.trim(); + return { + title: `Cursor models (${result.source})`, + output: [header, ...lines].join("\n"), + metadata: { source: result.source, count: result.models.length }, + }; + }, + }, + cursor_update_plugin: { + description: + "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.", + args: {}, + execute: async () => { + if (process.env.CI || process.env.NO_UPDATE_NOTIFIER) { + return { + title: "cursor plugin (checks disabled)", + output: "Update checks are disabled (CI or NO_UPDATE_NOTIFIER is set).", + metadata: { + local: undefined, + latest: undefined, + status: "disabled" as const, + }, + }; + } + + const local = getLocalVersion(); + if (!local || !semver.valid(local)) { + return { + title: "cursor plugin (unknown version)", + output: "Could not determine the installed plugin version.", + metadata: { local, latest: undefined, status: "failed" as const }, + }; + } + + const latest = await getLatestVersion(); + if (!latest || !semver.valid(latest)) { + return { + title: "cursor plugin (registry unavailable)", + output: + "Could not fetch the latest version from npm. Check your network connection and try again.", + metadata: { local, latest, status: "failed" as const }, + }; + } + + if (!semver.gt(latest, local)) { + return { + title: "cursor plugin (up to date)", + output: `The plugin is up to date (v${local}).`, + metadata: { local, latest, status: "up-to-date" as const }, + }; + } + + // Plugin is outdated — clear the opencode plugin cache so it re-fetches on next launch. + const cachePath = PLUGIN_CACHE_PATH; + const removeCommand = + process.platform === "win32" + ? `rmdir /s /q "${cachePath}"` + : `rm -rf ${cachePath}`; + + try { + rmSync(cachePath, { recursive: true, force: true }); + clearVersionCache(); + return { + title: "cursor plugin (updated)", + output: + `Plugin cache cleared (v${local} → v${latest}).\n` + + `Restart opencode to complete the upgrade — it will fetch v${latest} on next launch.`, + metadata: { local, latest, status: "updated" as const }, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + title: "cursor plugin (cache clear failed)", + output: + `Failed to clear plugin cache: ${message}\n\n` + + `To update manually, exit opencode and run:\n\n` + + ` ${removeCommand}\n\n` + + `then restart opencode.`, + metadata: { local, latest, status: "failed" as const }, + }; + } + }, + }, + }; +} diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 141f25c..fd89358 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -1,7 +1,6 @@ import type { Config, Plugin, ToolContext } from "@opencode-ai/plugin"; import type { Auth } from "@opencode-ai/sdk/v2"; import type { McpServerConfig } from "@cursor/sdk"; -import { rmSync } from "node:fs"; import { homedir } from "node:os"; import semver from "semver"; import { resolveCursorApiKey } from "../api-key.js"; @@ -13,13 +12,8 @@ import { type McpStatusMap, translateMcpServers, } from "./mcp-config.js"; -import { buildCursorTools } from "./cursor-tools.js"; -import { - getLocalVersion, - getLatestVersion, - clearVersionCache, - PLUGIN_CACHE_PATH, -} from "../version-check.js"; +import { buildCursorTools, buildMaintenanceTools } from "./cursor-tools.js"; +import { getLocalVersion, getLatestVersion } from "../version-check.js"; import { removeSystemRule } from "../provider/system-rule.js"; import { clearLogBridge, @@ -52,6 +46,7 @@ import { startPluginToolsBridge, type PluginToolsBridge, } from "./plugin-tools-bridge.js"; +import { cursorV2Setup } from "./v2.js"; function apiKeyFromAuth(auth: Auth | undefined): string | undefined { return auth?.type === "api" ? auth.key : undefined; @@ -771,99 +766,9 @@ export const CursorPlugin: Plugin = async (input) => { }, tool: { - cursor_update_plugin: { - description: - "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.", - args: {}, - execute: async () => { - if (process.env.CI || process.env.NO_UPDATE_NOTIFIER) { - return { - title: "cursor plugin (checks disabled)", - output: "Update checks are disabled (CI or NO_UPDATE_NOTIFIER is set).", - metadata: { - local: undefined, - latest: undefined, - status: "disabled" as const, - }, - }; - } - - const local = getLocalVersion(); - if (!local || !semver.valid(local)) { - return { - title: "cursor plugin (unknown version)", - output: "Could not determine the installed plugin version.", - metadata: { local, latest: undefined, status: "failed" as const }, - }; - } - - const latest = await getLatestVersion(); - if (!latest || !semver.valid(latest)) { - return { - title: "cursor plugin (registry unavailable)", - output: - "Could not fetch the latest version from npm. Check your network connection and try again.", - metadata: { local, latest, status: "failed" as const }, - }; - } - - if (!semver.gt(latest, local)) { - return { - title: "cursor plugin (up to date)", - output: `The plugin is up to date (v${local}).`, - metadata: { local, latest, status: "up-to-date" as const }, - }; - } - - // Plugin is outdated — clear the opencode plugin cache so it re-fetches on next launch. - const cachePath = PLUGIN_CACHE_PATH; - const removeCommand = - process.platform === "win32" - ? `rmdir /s /q "${cachePath}"` - : `rm -rf ${cachePath}`; - - try { - rmSync(cachePath, { recursive: true, force: true }); - clearVersionCache(); - return { - title: "cursor plugin (updated)", - output: - `Plugin cache cleared (v${local} → v${latest}).\n` + - `Restart opencode to complete the upgrade — it will fetch v${latest} on next launch.`, - metadata: { local, latest, status: "updated" as const }, - }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { - title: "cursor plugin (cache clear failed)", - output: - `Failed to clear plugin cache: ${message}\n\n` + - `To update manually, exit opencode and run:\n\n` + - ` ${removeCommand}\n\n` + - `then restart opencode.`, - metadata: { local, latest, status: "failed" as const }, - }; - } - }, - }, - cursor_refresh_models: { - description: - "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. Note: to update the plugin itself (not just the model list), use the cursor_update_plugin tool.", - args: {}, - execute: async () => { - const result = await discoverModels({ forceRefresh: true }); - const lines = result.models.map((m) => `- ${m.id} — ${m.displayName}`); - const header = - result.source === "live" - ? `Refreshed ${result.models.length} Cursor models (live):` - : `Could not fetch live models (${result.source}). ${result.warning ?? ""}`.trim(); - return { - title: `Cursor models (${result.source})`, - output: [header, ...lines].join("\n"), - metadata: { source: result.source, count: result.models.length }, - }; - }, - }, + // Maintenance tools (refresh catalog / update plugin) are shared + // with the v2 entrypoint via buildMaintenanceTools. + ...buildMaintenanceTools(), // Delegation tools that complement the provider: a cloud/background agent // and a permission-gated local delegate. They resolve the Cursor key from // the auth loader (captured above) or CURSOR_API_KEY. @@ -888,4 +793,18 @@ export const CursorPlugin: Plugin = async (input) => { }; }; -export default CursorPlugin; +/** + * Dual entrypoint: opencode v1 (>= 1.18.29) calls the `server()` hook; v2 + * (>= 2.0.19) calls `setup()`. Each host ignores the other's key. `id` is + * required by both for `file://` path plugins. The named `CursorPlugin` + * export stays for direct imports. + * `cursorV2Setup` uses only locally-declared structural types, so the + * published `.d.ts` is self-contained. + */ +const dualExport = { + id: "opencode-cursor", + setup: cursorV2Setup, + server: CursorPlugin, +}; + +export default dualExport; diff --git a/src/plugin/v2.ts b/src/plugin/v2.ts new file mode 100644 index 0000000..45e3614 --- /dev/null +++ b/src/plugin/v2.ts @@ -0,0 +1,451 @@ +/** + * opencode v2 plugin setup for the Cursor provider. + * + * v2 (>= 2.0.19) loads plugins via a default export with `{ id, setup }` and + * never calls the v1 `server()` entrypoint, so the core behavior is + * re-registered through the v2 transform/hook API: + * + * - auth: declares the Cursor API-key method plus the CURSOR_API_KEY env + * fallback; the key is resolved per call from the stored + * integration connection or env (same resolution order as v1's + * auth.loader → src/api-key.ts). + * - provider: registers the `cursor` provider backed by THIS package via the + * `aisdk:` package prefix — the v2 runner imports the package, + * calls its `create*` export (createCursor) with the merged + * provider/model settings as options, and asks it for a language + * model per request. Discovered models seed the catalog with real + * per-model cost (resolveCost) and defaultModelParams pinned as + * settings.params. `settings.cwd` pins the agent to + * ctx.location.directory, so the provider runs from the session + * directory even when the v2 runner process has another cwd. The + * package specifier honors OPENCODE_CURSOR_PROVIDER_NPM (same + * override as v1) so local checkouts can point v2 at a `file://` + * build instead of the published npm copy. + * - session: the "context" hook forwards the session id and plan mapping + * (the v1 `chat.params` equivalent); the "title" hook marks + * title generation ephemeral (v2 dispatches titles through their + * own hook, not the context hook). + * - tool: `cursor_refresh_models` only, with the same execute body as v1, + * adapted to the v2 tool shape. The other v1 tools are NOT + * registered on v2: + * - `cursor_update_plugin` clears the v1 `packages/` cache + * layout; v2 caches plugins under `/npm//` and + * updates them with `opencode plugin update`, so a v1 + * cache-clear would report success while doing nothing. + * - `cursor_delegate` / `cursor_cloud_agent` are fail-closed on + * v1 via the per-call `ask` approval gate; v2's plugin + * ToolContext has no `ask`/`assert` path a plugin can drive, + * and nothing else in 2.0.19 enforces one, so registering them + * would run Cursor agents ungated. Absent = fail-closed. + * + * Known gaps vs v1 (documented in README): delegation tools and the update + * tool are unavailable on v2 (see above), no live MCP forwarding, no skill + * mirror/skills catalogue, no plugin-tools bridge, no subagent task-part + * stamping, no update toast (v2 has no ctx.tui), no app.log bridge (v2 + * removed the write endpoint), and the `autoCompaction` provider option is + * not honored (models are always listed with the no-auto-compaction limit). + */ +import { tool } from "@opencode-ai/plugin"; +import { discoverModels } from "../model-discovery.js"; +import { defaultModelParams } from "../model-variants.js"; +import { PROVIDER_ID, providerNpm } from "./model-v2.js"; +import { + resolveContextLimit, + resolveOutputLimit, + resolveCost, + NO_AUTO_COMPACTION_INPUT_LIMIT, +} from "../model-limits.js"; +import { buildModelVariants } from "../model-variants.js"; +import { buildMaintenanceTools, REFRESH_DESCRIPTION_BASE } from "./cursor-tools.js"; +import { removeSystemRule } from "../provider/system-rule.js"; + +/** + * The structural contract of a v2 `Provider.Info`. Only the fields this + * setup sets are declared; structural rest fields keep the literal + * assignable to the real (effect-branded) schema type without importing it. + * Settings (not `options`, which v2 does not define) is the channel the v2 + * runner forwards to the package's `create*` export as options — minus the + * runner's reserved keys (timeout/chunkTimeout/compaction/transport/fetch). + */ +export type ProviderInfoV2 = { + readonly id: string; + readonly name: string; + readonly activation: "auto" | "enabled" | "disabled"; + readonly package: string; + readonly settings?: Record; + readonly [key: string]: unknown; +}; + +/** + * The structural contract of a v2 `Model.Info` (subset used by this setup). + * `settings` seeds the request overrides the runner merges per request; + * `variants` are option sets the picker merges into the provider options. + * `limit.input` is optional: v1 uses the `NO_AUTO_COMPACTION_INPUT_LIMIT` + * sentinel to suppress auto-compaction; v2 just omits the field. + */ +export type ModelInfoV2 = { + readonly id: string; + readonly modelID?: string; + readonly providerID: string; + readonly name: string; + readonly capabilities: { + readonly tools: boolean; + readonly input: readonly string[]; + readonly output: readonly string[]; + }; + readonly limit: { + readonly context: number; + readonly input?: number; + readonly output: number; + }; + readonly cost: ReadonlyArray<{ + readonly input: number; + readonly output: number; + readonly cache: { readonly read: number; readonly write: number }; + }>; + readonly status: "alpha" | "beta" | "deprecated" | "active"; + readonly enabled: boolean; + readonly variants?: ReadonlyArray<{ + readonly id: string; + readonly settings?: Record; + }>; + readonly time: { readonly released: number }; + readonly settings?: Record; + readonly package?: string; + readonly [key: string]: unknown; +}; + +/** + * The structural contract of a v2 `Tool.Metadata` result: `Readonly>`. + */ +export type ToolMetadataV2 = Record; + +/** + * The structural contract of a v2 `Tool.Result` for a tool with NO output + * schema (all cursor_* tools): `{ content, metadata }`. `output` is + * `never`-typed in this case — the v2 runner rejects a result carrying an + * `output` key with "Tool result declared output without an output schema". + */ +export interface ToolResultV2 { + readonly content: string; + readonly metadata?: ToolMetadataV2; +} + +/** + * v2 ToolContext surface the shared tool bodies need. The real context is + * `{ sessionID, agent, messageID, id, signal, progress }` — there is no + * `ask`/`abort`/`directory`, hence the delegation tools stay off v2. + */ +export interface ToolContextV2 { + readonly sessionID?: string; + readonly agent?: string; + readonly messageID?: string; + readonly id?: string; + readonly signal?: AbortSignal; + [key: string]: unknown; +} + +/** + * The slice of the v2 plugin Context this setup uses. Kept narrow so tests + * can pass object-literal fakes; the real ctx satisfies it structurally. + * Keeping the used members local also keeps the published `.d.ts` free of + * v2 SDK imports (the v2 SDK is a devDependency; consumers do not have + * it installed, so its types must not leak into the public surface). + */ +export interface PluginContextV2 { + readonly location: { readonly directory: string }; + /** Plugin options from the `plugins: [pkg, options]` config entry. */ + readonly options?: Record; + readonly integration: { + readonly transform: ( + callback: (editor: { + readonly method: { + readonly update: (input: { + readonly integrationID: string; + readonly method: + | { readonly type: "key"; readonly label?: string } + | { readonly type: "env"; readonly names: readonly string[] }; + }) => void; + }; + }) => void, + ) => Promise<{ dispose: () => Promise }>; + readonly connection: { + readonly active: ( + integrationID: string, + ) => Promise; + readonly resolve: ( + connection: ConnectionInfoV2, + ) => Promise; + }; + }; + readonly provider: { + readonly transform: ( + callback: (editor: { + readonly add: (input: { + readonly info: ProviderInfoV2; + readonly models: readonly ModelInfoV2[]; + }) => void; + }) => void, + ) => Promise<{ dispose: () => Promise }>; + }; + readonly session: { + readonly hook: ( + name: "context" | "title", + callback: (event: { + readonly sessionID: string; + readonly agent?: string; + options: Record; + }) => Promise | void, + options: { readonly providerID: string }, + ) => Promise<{ dispose: () => Promise }>; + }; + readonly tool: { + readonly transform: ( + callback: (editor: { + readonly add: (tool: { + readonly name: string; + readonly description: string; + readonly input: unknown; + readonly execute: ( + input: unknown, + context: ToolContextV2, + ) => Promise; + }) => void; + }) => void, + ) => Promise<{ dispose: () => Promise }>; + }; +} + +/** + * Structural stand-in for `@opencode/client`'s generated `ConnectionInfo` + * (the union of ConnectionCredentialInfo and ConnectionEnvInfo) — kept local + * so the public types do not import the dev-only v2 SDK chain. + */ +export type ConnectionInfoV2 = + | { readonly type: "credential"; readonly id: string; readonly label?: string; readonly method?: "key" | "oauth" } + | { readonly type: "env"; readonly name: string }; + +/** + * Structural stand-in for `@opencode/client`'s `Credential.Value` union + * (Key | OAuth). Only the Key branch carries the usable key. + */ +export type CredentialValueV2 = + | { readonly type: "key"; readonly key: string } + | { + readonly type: "oauth"; + readonly methodID: string; + readonly refresh: string; + readonly access: string; + readonly expires: number; + }; + +type Registration = { dispose: () => Promise }; + +/** + * Shape one discovered Cursor model as a v2 `Model.Info`. Carries the v1 + * variant/param channels on the settings rest so the picker and per-request + * defaults keep working: variants are the option sets the picker merges into + * the provider options, and settings.params pins the non-reasoning boolean + * floors (e.g. `fast: "false"`). Cost uses the same resolveCost table as v1 + * (README promises per-model cost reporting on both hosts). + */ +function toModelInfo( + id: string, + name: string, + item: Parameters[0], +): ModelInfoV2 { + const variants = buildModelVariants(item); + const variantList = Object.entries(variants).map(([variantId, variant]) => ({ + id: variantId, + settings: variant as Record, + })); + // Same defaults v1 pins into each model's options.params: the non-reasoning + // boolean floors (e.g. `fast: "false"`), NOT a reasoning variant's params. + const params = defaultModelParams(item); + const c = resolveCost(id); + return { + id, + modelID: id, + providerID: PROVIDER_ID, + name, + capabilities: { + tools: true, + input: ["text", "image"], + output: ["text"], + }, + limit: { + context: resolveContextLimit(id), + // v1 emits the NO_AUTO_COMPACTION_INPUT_LIMIT sentinel to suppress + // auto-compaction; v2's limit.input is optional, so omitting it has + // the same effect. (Honoring `autoCompaction: true` on v2 is a + // documented gap — see README "Known v2 gaps".) + input: NO_AUTO_COMPACTION_INPUT_LIMIT, + output: resolveOutputLimit(id), + }, + cost: [{ input: c.input, output: c.output, cache: { read: c.cacheRead, write: c.cacheWrite } }], + status: "active", + enabled: true, + variants: variantList, + time: { released: 0 }, + ...(Object.keys(params).length > 0 ? { settings: { params } } : {}), + package: `aisdk:${providerNpm()}`, + }; +} + +export async function cursorV2Setup( + context: PluginContextV2, +): Promise<() => Promise> { + const ctx = context; + const registrations: Registration[] = []; + + // --- auth: declare the key method + env fallback for integration "cursor". + registrations.push( + await ctx.integration.transform((editor) => { + editor.method.update({ + integrationID: PROVIDER_ID, + method: { type: "key", label: "Cursor API Key" }, + }); + editor.method.update({ + integrationID: PROVIDER_ID, + method: { type: "env", names: ["CURSOR_API_KEY"] }, + }); + }), + ); + + // --- resolve the stored Cursor key once, before discovery and tools. + // v2 stores auth per integration (`opencode auth login` → connection); + // `connection.active` returns the active connection, `resolve` its + // credential value. Re-resolved before each tool call, so a key saved + // mid-session is picked up without a restart. + const storedKey = async (): Promise => { + const conn = await ctx.integration.connection.active(PROVIDER_ID).catch(() => undefined); + if (!conn) return undefined; + const cred = await ctx.integration.connection.resolve(conn).catch(() => undefined); + return cred?.type === "key" ? cred.key : undefined; + }; + + // --- provider + models: one catalog built from the shared discovery helper. + const { models } = await discoverModels({ apiKey: await storedKey() }); + const modelInfos = models.map((item) => + toModelInfo(item.id, item.displayName || item.id, item), + ); + + // Same canonical cwd the v1 `config` hook resolves: the plugin's location + // directory (the session/project cwd), NOT the runner process's cwd — the + // v2 runner is often a long-lived background service started elsewhere. + // Handed to the provider through `settings`, the channel the v2 runner + // spreads into the package's `create*` options (minus its reserved keys). + const resolvedCwd = ctx.location.directory; + + registrations.push( + await ctx.provider.transform((editor) => { + editor.add({ + info: { + id: PROVIDER_ID, + name: "Cursor", + activation: "auto", + package: `aisdk:${providerNpm()}`, + settings: { cwd: resolvedCwd }, + }, + models: modelInfos, + }); + }), + ); + + // --- per-turn options: the v1 chat.params equivalent, scoped to cursor. + registrations.push( + await ctx.session.hook( + "context", + async (event) => { + event.options.sessionID = event.sessionID; + if (event.agent === "plan" && event.options["mode"] === undefined) { + event.options["mode"] = "plan"; + } + }, + { providerID: PROVIDER_ID }, + ), + ); + + // --- title generation runs through v2's own `title` hook (SessionTitle + // extends SessionRequest and has no `agent` field, so the context hook + // never sees it). Mark it ephemeral for the same reason as the v1 + // chat.params hook: it shares the sessionID with the session's real first + // turn, and an un-marked side-call can win the pool-record race and + // permanently overwrite the session's agent (language-model.ts). + registrations.push( + await ctx.session.hook( + "title", + (event) => { + event.options["ephemeral"] = true; + event.options.sessionID = event.sessionID; + }, + { providerID: PROVIDER_ID }, + ), + ); + + // --- tools: v2 exposes only `cursor_refresh_models`. Same execute body as + // v1, adapted to the v2 tool shape: inputs as StandardSchemaV1 (a zod v4 + // object carries `~standard`), and results as `{ content, metadata }` — + // the runner rejects `output` for tools without an output schema. + // `cursor_update_plugin`, `cursor_delegate` and `cursor_cloud_agent` are + // NOT registered on v2 (see the file header for why) — buildMaintenanceTools + // returns both, so only refresh survives the filter. + const refreshTool = buildMaintenanceTools({ resolveApiKey: storedKey })[ + "cursor_refresh_models" + ]; + if (!refreshTool) throw new Error("cursor_refresh_models tool is missing"); + + registrations.push( + await ctx.tool.transform((editor) => { + editor.add({ + name: "cursor_refresh_models", + // Same description body as v1, but the trailing note points at + // v2's own updater — `cursor_update_plugin` is not registered + // here (see the file header for why), so referencing it would + // send the model (and the user) to a tool that does not exist. + description: `${REFRESH_DESCRIPTION_BASE} Note: to update the plugin itself (not just the model list), run opencode plugin update.`, + // v2's Tool.ValueSchema accepts StandardSchemaV1 (a zod v4 + // object carries `~standard`), not the raw zod *shape* the v1 + // `tool()` helper stores in `args`. + input: tool.schema.object(refreshTool.args), + execute: async (input, context) => { + // The v2 ToolContext has {sessionID, agent, messageID, id, + // signal, progress} — no ask/abort/directory. The refresh + // body touches none of them. `refreshTool.execute` resolves + // the stored key itself (v2 deps above). + const result = await refreshTool.execute(input as never, context as never); + return adaptToolResult(result); + }, + }); + }), + ); + + return async () => { + for (const registration of registrations.splice(0)) { + try { + await registration.dispose(); + } catch { + // dispose is best-effort cleanup; a failing registration must not + // block the others. + } + } + // Same best-effort cleanup as v1's dispose: drop the generated + // system-prompt rule so it does not linger in the repo / Cursor IDE + // after the session. Sentinel-guarded — a user-owned file is kept. + removeSystemRule(resolvedCwd); + }; +} + +/** + * Adapt a v1 tool result (`string | { title?, output, metadata? }`) to the + * v2 `Tool.Result` for a tool without an output schema: `{ content, metadata }`. + * `output` must never be returned — the v2 runner rejects it with + * "Tool result declared output without an output schema". + */ +export function adaptToolResult(result: string | { output: string; metadata?: Record }): ToolResultV2 { + if (typeof result === "string") return { content: result }; + return { + content: result.output, + ...(result.metadata !== undefined ? { metadata: result.metadata } : {}), + }; +} diff --git a/test/mcp-config.test.ts b/test/mcp-config.test.ts index bac3c98..da790e4 100644 --- a/test/mcp-config.test.ts +++ b/test/mcp-config.test.ts @@ -4,7 +4,7 @@ import { findUnshareableOAuthServers, translateMcpServers, } from "../src/plugin/mcp-config.js"; -import plugin from "../src/plugin/index.js"; +import { CursorPlugin as plugin } from "../src/plugin/index.js"; describe("translateMcpServers", () => { it("maps a local (stdio) server, splitting command/args and keeping env", () => { diff --git a/test/plugin-auto-compaction.test.ts b/test/plugin-auto-compaction.test.ts index c1a7e0e..9721143 100644 --- a/test/plugin-auto-compaction.test.ts +++ b/test/plugin-auto-compaction.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { Config } from "@opencode-ai/plugin"; -import plugin from "../src/plugin/index.js"; +import { CursorPlugin as plugin } from "../src/plugin/index.js"; import { NO_AUTO_COMPACTION_INPUT_LIMIT } from "../src/model-limits.js"; /** diff --git a/test/plugin-loader-refresh.test.ts b/test/plugin-loader-refresh.test.ts index 84010a7..4a6cf39 100644 --- a/test/plugin-loader-refresh.test.ts +++ b/test/plugin-loader-refresh.test.ts @@ -15,7 +15,7 @@ vi.mock("../src/model-discovery.js", () => ({ toOpencodeModels: () => ({}), })); -const { default: plugin } = await import("../src/plugin/index.js"); +const { CursorPlugin: plugin } = await import("../src/plugin/index.js"); let savedEnvKey: string | undefined; diff --git a/test/plugin-skill-mirror.test.ts b/test/plugin-skill-mirror.test.ts index 1ec864d..7ac8a59 100644 --- a/test/plugin-skill-mirror.test.ts +++ b/test/plugin-skill-mirror.test.ts @@ -24,7 +24,7 @@ vi.mock("../src/model-discovery.js", () => ({ toOpencodeModels: () => ({}), })); -const { default: plugin } = await import("../src/plugin/index.js"); +const { CursorPlugin: plugin } = await import("../src/plugin/index.js"); const dirs: string[] = []; function tmp(): string { diff --git a/test/plugin-system-rule.test.ts b/test/plugin-system-rule.test.ts index 15e1a19..4a90ae5 100644 --- a/test/plugin-system-rule.test.ts +++ b/test/plugin-system-rule.test.ts @@ -10,7 +10,7 @@ vi.mock("../src/model-discovery.js", () => ({ toOpencodeModels: () => ({}), })); -const { default: plugin } = await import("../src/plugin/index.js"); +const { CursorPlugin: plugin } = await import("../src/plugin/index.js"); const dirs: string[] = []; function tmp(): string { diff --git a/test/plugin-tools-wiring.test.ts b/test/plugin-tools-wiring.test.ts index 1530701..c920d83 100644 --- a/test/plugin-tools-wiring.test.ts +++ b/test/plugin-tools-wiring.test.ts @@ -65,7 +65,7 @@ describe("CursorPlugin plugin-tools wiring", () => { } it("adds opencode-plugin-tools to mcpServers and gates by permission", async () => { - const { default: plugin } = await import("../src/plugin/index.js"); + const { CursorPlugin: plugin } = await import("../src/plugin/index.js"); // Lay the fake cache out exactly like opencodePackagesRoot expects: // $XDG_CACHE_HOME/opencode/packages//node_modules//. const home = tmp(); @@ -263,7 +263,7 @@ describe("CursorPlugin plugin-tools wiring", () => { }); it("keeps the plugin-tools bridge when live config.get omits plugin", async () => { - const { default: plugin } = await import("../src/plugin/index.js"); + const { CursorPlugin: plugin } = await import("../src/plugin/index.js"); const home = tmp(); const cacheRoot = join(home, ".cache", "opencode", "packages"); await writeToolPlugin(cacheRoot); diff --git a/test/plugin-tools.test.ts b/test/plugin-tools.test.ts index 81ce297..748c02d 100644 --- a/test/plugin-tools.test.ts +++ b/test/plugin-tools.test.ts @@ -7,7 +7,7 @@ const runDelegate = vi.fn(); vi.mock("../src/provider/cloud-agent.js", () => ({ runCloudAgent })); vi.mock("../src/provider/delegate.js", () => ({ runDelegate })); -const { default: plugin } = await import("../src/plugin/index.js"); +const { CursorPlugin: plugin } = await import("../src/plugin/index.js"); function ctx(ask: ReturnType) { return { diff --git a/test/plugin-v2.test.ts b/test/plugin-v2.test.ts new file mode 100644 index 0000000..a1eb9fd --- /dev/null +++ b/test/plugin-v2.test.ts @@ -0,0 +1,499 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest"; +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Keep live model discovery offline for the whole file. +vi.mock("../src/model-discovery.js", () => ({ + discoverModels: vi.fn(async () => ({ + models: [ + { + id: "test-model", + displayName: "Test Model", + parameters: [{ id: "fast", values: [{ value: "false" }, { value: "true" }] }], + }, + { + id: "claude-haiku-4-5", + displayName: "Claude Haiku 4.5", + parameters: [], + }, + ], + source: "fallback", + })), + toOpencodeModels: () => ({}), + modelSupportsReasoning: () => false, +})); + +import { defaultModelParams } from "../src/model-variants.js"; +import { resolveCost } from "../src/model-limits.js"; +import { discoverModels } from "../src/model-discovery.js"; +import type { + PluginContextV2, + ToolContextV2, + ToolResultV2, +} from "../src/plugin/v2.js"; + +// Dynamic import: vi.mock must hoist above these modules before +// src/plugin/v2.js evaluates, and vitest forbids static imports here. +const { cursorV2Setup, adaptToolResult } = await import("../src/plugin/v2.js"); +const pluginModule = await import("../src/plugin/index.js"); + +type Registration = { dispose: Mock }; + +/** A mocked v2 registration-capturing function (transform/hook fake). */ +type RegFn = (cb: unknown) => Registration; +type HookFn = (name: string, cb: unknown, options?: unknown) => Registration; + + +/** + * Record every v2 ctx registration so tests can drive the captured + * callbacks. The object-literal fake is cast `as never` like the v1 tests: + * the real ctx is a large Effect service record no test can construct. + */ +function makeCtx(directory = "/work") { + const registrations: Registration[] = []; + const reg = (): Registration => { + const r = { dispose: vi.fn() }; + registrations.push(r); + return r; + }; + const ctx = { + registrations, + location: { directory }, + options: {}, + integration: { + transform: vi.fn((_cb: unknown) => reg()), + connection: { + active: vi.fn(async () => undefined), + resolve: vi.fn(async () => undefined), + }, + }, + provider: { transform: vi.fn((_cb: unknown) => reg()) }, + aisdk: { hook: vi.fn((_cb: unknown) => reg()) }, + session: { hook: vi.fn((_cb: unknown) => reg()) }, + tool: { transform: vi.fn((_cb: unknown) => reg()) }, + mcp: { transform: vi.fn((_cb: unknown) => reg()) }, + } as unknown as PluginContextV2 & { + registrations: Registration[]; + integration: { + transform: RegFn & Mock; + connection: { active: Mock; resolve: Mock }; + }; + provider: { transform: RegFn & Mock }; + aisdk: { hook: HookFn & Mock }; + session: { hook: HookFn & Mock }; + tool: { transform: RegFn & Mock }; + mcp: { transform: RegFn & Mock }; + }; + return ctx; +} + +let savedEnvKey: string | undefined; + +beforeEach(() => { + savedEnvKey = process.env.CURSOR_API_KEY; + delete process.env.CURSOR_API_KEY; +}); + +afterEach(() => { + if (savedEnvKey === undefined) delete process.env.CURSOR_API_KEY; + else process.env.CURSOR_API_KEY = savedEnvKey; +}); + +describe("dual default export", () => { + it("exposes id, the v2 setup, and the v1 server entrypoint by identity", () => { + const def = pluginModule.default as Record; + expect(def["id"]).toBe("opencode-cursor"); + expect(def["setup"]).toBe(cursorV2Setup); + expect(def["server"]).toBe(pluginModule.CursorPlugin); + }); + + it("server still returns the full v1 hook set", async () => { + const hooks = await pluginModule.CursorPlugin({ + directory: "/work", + } as never); + for (const key of [ + "auth", + "config", + "provider", + "chat.params", + "event", + "tool", + "dispose", + ]) { + expect(hooks).toHaveProperty(key); + } + // v1 keeps all four tools. + const tool = hooks.tool as Record; + expect(tool).toHaveProperty("cursor_refresh_models"); + expect(tool).toHaveProperty("cursor_update_plugin"); + expect(tool).toHaveProperty("cursor_delegate"); + expect(tool).toHaveProperty("cursor_cloud_agent"); + }); +}); + +describe("cursorV2Setup", () => { + it("registers integration auth methods (key + env fallback)", async () => { + const ctx = makeCtx(); + await cursorV2Setup(ctx as never); + const cb = ctx.integration.transform.mock.calls[0]![0] as (editor: { + method: { update: (input: unknown) => void }; + }) => void; + const methodUpdate = vi.fn(); + cb({ method: { update: methodUpdate } }); + expect(methodUpdate).toHaveBeenCalledTimes(2); + const calls = methodUpdate.mock.calls.map((c) => c[0]); + expect(calls).toEqual([ + { + integrationID: "cursor", + method: { type: "key", label: "Cursor API Key" }, + }, + { + integrationID: "cursor", + method: { type: "env", names: ["CURSOR_API_KEY"] }, + }, + ]); + }); + + it("registers the cursor provider with aisdk-prefixed package, discovered models, and the session cwd as settings", async () => { + const ctx = makeCtx(); + await cursorV2Setup(ctx as never); + const cb = ctx.provider.transform.mock.calls[0]![0] as (editor: { + add: (input: unknown) => void; + }) => void; + const add = vi.fn(); + cb({ add }); + expect(add).toHaveBeenCalledTimes(1); + const { info, models } = add.mock.calls[0]![0] as { + info: Record & { + settings?: Record; + }; + models: Array & { id: string; providerID: string }>; + }; + expect(info["id"]).toBe("cursor"); + expect(info["name"]).toBe("Cursor"); + expect(String(info["package"])).toMatch(/^aisdk:/); + expect(info["activation"]).toBe("auto"); + // The provider settings carry the session directory so the v2 runner + // hands createCursor the right cwd (it may run from anywhere). + expect(info.settings).toEqual({ cwd: "/work" }); + const model = models.find((m) => m.id === "test-model"); + expect(model).toBeDefined(); + expect(model?.providerID).toBe("cursor"); + }); + + it("emits defaultModelParams as the model's settings.params", async () => { + const ctx = makeCtx(); + await cursorV2Setup(ctx as never); + const cb = ctx.provider.transform.mock.calls[0]![0] as (editor: { + add: (input: unknown) => void; + }) => void; + const add = vi.fn(); + cb({ add }); + const { models } = add.mock.calls[0]![0] as { + models: Array & { id: string }>; + }; + const model = models.find((m) => m.id === "test-model"); + expect(model).toBeDefined(); + // `fast` is a non-reasoning boolean param → pinned "false" by default. + const settings = model?.["settings"] as { params?: Record }; + expect(settings.params).toEqual( + defaultModelParams({ + id: "test-model", + displayName: "Test Model", + parameters: [{ id: "fast", values: [{ value: "false" }, { value: "true" }] }], + }), + ); + expect(settings.params).toEqual({ fast: "false" }); + }); + + it("resolves real per-model cost through resolveCost (no $0 stub)", async () => { + const ctx = makeCtx(); + await cursorV2Setup(ctx as never); + const cb = ctx.provider.transform.mock.calls[0]![0] as (editor: { + add: (input: unknown) => void; + }) => void; + const add = vi.fn(); + cb({ add }); + const { models } = add.mock.calls[0]![0] as { + models: Array & { id: string; cost: unknown }>; + }; + const haiku = models.find((m) => m.id === "claude-haiku-4-5"); + expect(haiku).toBeDefined(); + const c = resolveCost("claude-haiku-4-5"); + expect(haiku?.cost).toEqual([ + { input: c.input, output: c.output, cache: { read: c.cacheRead, write: c.cacheWrite } }, + ]); + // A priced prefix must not report $0 (the old bug). + expect((haiku?.cost as Array<{ input: number }>)[0]!.input).not.toBe(0); + }); + + it("seeds the catalog from the stored connection key, not env alone", async () => { + const ctx = makeCtx(); + ctx.integration.connection.active.mockResolvedValue({ + type: "credential", + id: "c1", + }); + ctx.integration.connection.resolve.mockResolvedValue({ + type: "key", + key: "stored-key", + }); + await cursorV2Setup(ctx as never); + expect(ctx.integration.connection.active).toHaveBeenCalledWith("cursor"); + expect(discoverModels).toHaveBeenCalledWith({ apiKey: "stored-key" }); + }); + + it("falls back to env-only discovery when no stored connection resolves", async () => { + const ctx = makeCtx(); + ctx.integration.connection.active.mockResolvedValue(undefined); + await cursorV2Setup(ctx as never); + expect(discoverModels).toHaveBeenCalledWith({ apiKey: undefined }); + }); + + it("scopes the session context hook to providerID cursor and forwards sessionID/mode", async () => { + const ctx = makeCtx(); + await cursorV2Setup(ctx as never); + const session = ctx.session.hook; + const contextCall = session.mock.calls.find((c) => c[0] === "context"); + expect(contextCall).toBeDefined(); + expect(contextCall![2]).toEqual({ providerID: "cursor" }); + const hook = contextCall![1] as (event: { + sessionID: string; + agent: string; + options: Record; + }) => Promise; + + const base = { sessionID: "s1", agent: "build", options: {} as Record }; + await hook(base); + expect(base.options.sessionID).toBe("s1"); + + const plan = { sessionID: "s2", agent: "plan", options: {} as Record }; + await hook(plan); + expect(plan.options["mode"]).toBe("plan"); + + const planPreset = { + sessionID: "s3", + agent: "plan", + options: { mode: "agent" }, + }; + await hook(planPreset); + expect(planPreset.options["mode"]).toBe("agent"); + }); + + it("marks title requests ephemeral via the dedicated v2 title hook", async () => { + const ctx = makeCtx(); + await cursorV2Setup(ctx as never); + const session = ctx.session.hook; + // v2 dispatches title generation through its own `title` hook + // (SessionTitle extends SessionRequest and has no `agent` field), so + // the context hook would never see a title request. + const titleCall = session.mock.calls.find((c) => c[0] === "title"); + expect(titleCall).toBeDefined(); + expect(titleCall![2]).toEqual({ providerID: "cursor" }); + const titleHook = titleCall![1] as (event: { + sessionID: string; + options: Record; + }) => Promise; + const title = { sessionID: "s4", options: {} as Record }; + await titleHook(title); + expect(title.options["ephemeral"]).toBe(true); + expect(title.options.sessionID).toBe("s4"); + }); + + it("registers ONLY cursor_refresh_models (no update tool, no delegation tools)", async () => { + const ctx = makeCtx(); + await cursorV2Setup(ctx as never); + const cb = ctx.tool.transform.mock.calls[0]![0] as (editor: { + add: (input: unknown) => void; + }) => void; + const add = vi.fn(); + cb({ add }); + const registered = add.mock.calls.map((c) => c[0]) as Array<{ + name: string; + description: unknown; + input: unknown; + options?: { permission?: string }; + execute: unknown; + }>; + const names = registered.map((t) => t.name); + expect(names).toEqual(["cursor_refresh_models"]); + // Fail-closed delegation: absent on v2, not registered-and-ungated. + expect(names).not.toContain("cursor_delegate"); + expect(names).not.toContain("cursor_cloud_agent"); + // v2 has `opencode plugin update`; the v1 cache-clearing tool is absent. + expect(names).not.toContain("cursor_update_plugin"); + for (const t of registered) { + expect(t.description).toBeTypeOf("string"); + // Tool.ValueSchema accepts StandardSchemaV1 (a zod v4 object carries + // `~standard`); a raw zod *shape* (plain object) is not a schema. + expect(t.input && typeof t.input === "object" && "~standard" in t.input).toBe(true); + expect(typeof t.execute).toBe("function"); + // No fake permission gating on v2 (options.permission is not an + // approval path in 2.0.19). + expect(t.options).toBeUndefined(); + } + }); + + it("refresh tool description points at opencode plugin update, not the unregistered cursor_update_plugin", async () => { + const ctx = makeCtx(); + await cursorV2Setup(ctx as never); + const cb = ctx.tool.transform.mock.calls[0]![0] as (editor: { + add: (input: unknown) => void; + }) => void; + const add = vi.fn(); + cb({ add }); + const { description } = add.mock.calls[0]![0] as { + description: string; + }; + // `cursor_update_plugin` is NOT registered on v2, so its description + // must not send the model (or the user) there. + expect(description).not.toContain("cursor_update_plugin"); + expect(description).toContain("opencode plugin update"); + // Same shared body as v1 otherwise. + expect(description).toContain("Refresh the live Cursor model catalog"); + }); + + it("refresh tool re-resolves the stored connection key on every call", async () => { + const ctx = makeCtx(); + ctx.integration.connection.active.mockResolvedValue({ + type: "credential", + id: "c1", + }); + ctx.integration.connection.resolve.mockResolvedValue({ + type: "key", + key: "stored-key", + }); + await cursorV2Setup(ctx as never); + const cb = ctx.tool.transform.mock.calls[0]![0] as (editor: { + add: (input: unknown) => void; + }) => void; + const add = vi.fn(); + cb({ add }); + const refresh = add.mock.calls[0]![0] as { + execute: (input: unknown, context: ToolContextV2) => Promise; + }; + + await refresh.execute({}, { sessionID: "s1" }); + // The refresh ran discovery with the STORED key (the same path setup + // uses), not keyless. + expect(discoverModels).toHaveBeenCalledWith({ + apiKey: "stored-key", + forceRefresh: true, + }); + + // A key saved mid-session is picked up without a restart: the + // connection is re-read on every call. + ctx.integration.connection.resolve.mockResolvedValue({ + type: "key", + key: "rotated-key", + }); + await refresh.execute({}, { sessionID: "s1" }); + expect(discoverModels).toHaveBeenLastCalledWith({ + apiKey: "rotated-key", + forceRefresh: true, + }); + }); + + it("refresh tool executes from a v2 ToolContext and returns { content, metadata } (no output)", async () => { + const ctx = makeCtx(); + await cursorV2Setup(ctx as never); + const cb = ctx.tool.transform.mock.calls[0]![0] as (editor: { + add: (input: unknown) => void; + }) => void; + const add = vi.fn(); + cb({ add }); + const refresh = add.mock.calls[0]![0] as { + execute: (input: unknown, context: ToolContextV2) => Promise; + }; + // Exactly the v2 ToolContext surface: no ask, no abort/directory. + const v2Context = { + sessionID: "s1", + signal: new AbortController().signal, + }; + const result = await refresh.execute({}, v2Context); + // The v2 Tool.Result for a tool without an output schema must use + // `content`; `output` is rejected by the 2.0.19 runner. + expect(result).toHaveProperty("content"); + expect(typeof result.content).toBe("string"); + expect(result.content).toContain("test-model — Test Model"); + expect(result.metadata).toEqual({ source: "fallback", count: 2 }); + expect("output" in (result as unknown as Record)).toBe(false); + // The refresh body ran the shared discovery helper (forceRefresh); + // with no stored connection the key is undefined (keyless refresh). + expect(discoverModels).toHaveBeenCalledWith({ + apiKey: undefined, + forceRefresh: true, + }); + }); + + it("returned cleanup disposes every registration and removes the generated system rule", async () => { + // Point the session directory at a temp dir with a generated rule, so + // the cleanup's removeSystemRule call is observable. + const dir = mkdtempSync(join(tmpdir(), "oc-v2-cleanup-")); + const rulesDir = join(dir, ".cursor", "rules"); + const rulePath = join(rulesDir, "opencode.mdc"); + mkdirRecursive(rulesDir); + writeGeneratedRule(rulePath); + const ctx = makeCtx(dir); + const cleanup = await cursorV2Setup(ctx as never); + expect(typeof cleanup).toBe("function"); + // integration + provider + 2 session hooks (context, title) + tool. + expect(ctx.registrations).toHaveLength(5); + expect(existsSync(rulePath)).toBe(true); + await cleanup?.(); + for (const r of ctx.registrations) { + expect(r.dispose).toHaveBeenCalled(); + } + expect(existsSync(rulePath)).toBe(false); + rmSync(dir, { recursive: true, force: true }); + }); + + it("leaves a user-owned opencode.mdc in place on cleanup", async () => { + const dir = mkdtempSync(join(tmpdir(), "oc-v2-keep-")); + const rulesDir = join(dir, ".cursor", "rules"); + const rulePath = join(rulesDir, "opencode.mdc"); + mkdirRecursive(rulesDir); + writeFileSync(rulePath, "# my own rule\n", "utf8"); + const ctx = makeCtx(dir); + const cleanup = await cursorV2Setup(ctx as never); + await cleanup?.(); + expect(existsSync(rulePath)).toBe(true); + rmSync(dir, { recursive: true, force: true }); + }); +}); + +describe("adaptToolResult", () => { + it("maps a plain string to content only", () => { + expect(adaptToolResult("plain")).toEqual({ content: "plain" }); + }); + + it("maps a v1 { output, metadata } object to { content, metadata }", () => { + expect( + adaptToolResult({ output: "done", metadata: { count: 1 } }), + ).toEqual({ content: "done", metadata: { count: 1 } }); + }); + + it("drops the metadata key when absent", () => { + expect(adaptToolResult({ output: "x" })).toEqual({ content: "x" }); + expect("metadata" in adaptToolResult({ output: "x" })).toBe(false); + }); +}); + +function mkdirRecursive(dir: string) { + mkdirSync(dir, { recursive: true }); +} + +function writeGeneratedRule(path: string) { + // Sentinel matching src/provider/system-rule.ts GENERATED_SENTINEL. + writeFileSync( + path, + "---\ngenerated: opencode-cursor\ndescription: opencode session system prompt\nalwaysApply: true\n---\n\nprompt\n", + "utf8", + ); +} diff --git a/test/subagent-bridge.test.ts b/test/subagent-bridge.test.ts index 6b062ae..43fdd31 100644 --- a/test/subagent-bridge.test.ts +++ b/test/subagent-bridge.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import plugin from "../src/plugin/index.js"; +import { CursorPlugin as plugin } from "../src/plugin/index.js"; import { clearSubagentBridge, linkSubagentSessionLive,