Skip to content

Commit 1230be9

Browse files
feat: add opencode v2 support (dual v1/v2 plugin entrypoint) (#127)
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.
1 parent 8dffc9a commit 1230be9

17 files changed

Lines changed: 1246 additions & 116 deletions

‎CHANGELOG.md‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,51 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
- **Add: opencode v2 support (dual v1/v2 plugin entrypoint).** The default
8+
export is now a dual object: the v2 `{ id, setup }` shape plus the v1
9+
`server()` entrypoint. opencode v1 (>= 1.18.29) calls `server()` as before;
10+
opencode v2 (tested against 2.0.19) calls `setup()` and registers the Cursor
11+
provider (with `settings.cwd` pinned to the session directory), the model
12+
catalog seeded from stored auth or the `CURSOR_API_KEY` env with real
13+
per-model cost (same table as v1), the session context mapping, the
14+
dedicated title hook (marks title generation ephemeral, same race fix as
15+
v1's `chat.params`), the `cursor_refresh_models` tool, and the stored-key
16+
auth methods through the v2 transform/hook API. v2 config uses the
17+
`plugins` key (plural) with a plain package string entry; the plugin reads
18+
no plugin-level options on v2 (v2's `providers.cursor.settings` are not
19+
consulted — the plugin registers its own provider record).
20+
The provider package specifier honors `OPENCODE_CURSOR_PROVIDER_NPM` on
21+
v2 as well, so a local `file://` build can be tested without publishing.
22+
A root `server.js` entry file was added for v2's path-plugin loader, and
23+
the `cursor_refresh_models` / `cursor_update_plugin` bodies moved to a
24+
shared builder used by both entrypoints. On v2 the refresh tool points
25+
its description at `opencode plugin update` (not the unregistered
26+
`cursor_update_plugin`) and re-resolves the stored connection key on
27+
every call; on v1 the refresh tool is unchanged (keyless, as before).
28+
The published `.d.ts` files no longer reference the dev-only
29+
`@opencode/plugin` package (v2 structural types are declared locally), so
30+
TypeScript consumers resolve them without extra installs.
31+
32+
Tools intentionally NOT registered on v2 (fail-closed — absent, not
33+
silently ungated):
34+
35+
- `cursor_delegate` / `cursor_cloud_agent` — v1 gates them fail-closed
36+
through the per-call `ask` approval; v2's plugin ToolContext has no
37+
`ask`/`assert` path a plugin can drive and nothing else in 2.0.19
38+
enforces one, so on v2 they would run Cursor agents ungated. They are
39+
unavailable on v2 until a real approval path exists.
40+
- `cursor_update_plugin` — it clears the v1 `packages/` cache layout;
41+
v2 caches plugins under `<cache>/npm/<spec>/` and updates them with
42+
`opencode plugin update`, so a v1 cache-clear would report success
43+
while doing nothing. Use `opencode plugin update` on v2.
44+
45+
Known v2 gaps (also in README): no live MCP forwarding, no skill
46+
mirror/skills catalogue, no plugin-tools bridge, no subagent `task`-part
47+
stamping, no update toast (v2 has no `ctx.tui`), no `app.log` bridge (v2
48+
removed the write endpoint), and the `provider.cursor.options` block is
49+
not read on v2 (models are always listed with the no-auto-compaction
50+
limit).
51+
752
## [0.9.1-next.0] — 2026-08-26 (pre-release)
853

954
Fixes the subagent child-session pane fragmenting one flowing answer

‎README.md‎

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ It uses the [official Cursor SDK](https://cursor.com/docs/sdk/typescript) (`@cur
1414
1515
## Requirements
1616

17-
- **opencode 1.17+**
17+
- **opencode v2 (2.0.19+, tested against 2.0.19)** or **opencode v1 (1.18.29+)** — the
18+
plugin entrypoint is a dual object; v1 needs 1.18.29+ to load an object (with `id` +
19+
`server`) as a path plugin, v2 reads the same object's `setup`.
1820
- **Node.js 22.13+ on your `PATH`** (optional) — opencode runs on [Bun](https://bun.sh) and the
1921
plugin runs the Cursor SDK in-process by default; Node is only needed for the `sidecar`
2022
transport fallback (see [Transport](#transport)).
@@ -71,6 +73,71 @@ Drop `@latest` (`"@stablekernel/opencode-cursor"`) or pin a version
7173
The stale-version check is skipped when the `CI` or `NO_UPDATE_NOTIFIER`
7274
environment variable is set.
7375

76+
### opencode v2
77+
78+
opencode v2 uses a `plugins` key (plural) and loads the plugin's `setup()` entrypoint
79+
(v1 uses `plugin` and calls `server()`):
80+
81+
```json
82+
{
83+
"$schema": "https://opencode.ai/config.json",
84+
"plugins": ["@stablekernel/opencode-cursor@latest"]
85+
}
86+
```
87+
88+
Both generations load the same published package, so one install serves either.
89+
Minimum versions: v1 >= 1.18.29 (object plugin entrypoint), v2 tested against 2.0.19.
90+
91+
On v2 the plugin registers the Cursor provider (pinned to the session directory
92+
via `settings.cwd`, so the agent runs in your project even when the v2 runner
93+
process was started elsewhere), the model catalog with real per-model cost
94+
(from stored auth or `CURSOR_API_KEY`), the session context mapping, the
95+
`cursor_refresh_models` tool, and the stored-key auth methods. The plugin
96+
reads no plugin-level options on v2 (a `plugins` entry with an options object
97+
is accepted by v2's config schema but ignored by this plugin), and it registers
98+
its own provider record — so the v1 `provider.cursor` block does not apply
99+
there. Configure v2 through the environment variables listed below.
100+
101+
**Tools not exposed on v2** — `cursor_delegate`, `cursor_cloud_agent` (delegation)
102+
and `cursor_update_plugin` (self-update) are v1-only, deliberately:
103+
104+
- **Delegation tools are fail-closed on v1** through a per-call approval gate.
105+
v2's plugin API has no approval path a plugin can drive, and nothing else in
106+
2.0.19 enforces one for plugin tools — so on v2 they would run Cursor agents
107+
*ungated*. The plugin leaves them unregistered on v2: unavailable beats
108+
silently ungated. They return when v2 grows a real approval path.
109+
- **`cursor_update_plugin` clears the v1 plugin cache layout**; v2 caches
110+
plugins elsewhere (`<cache>/npm/<spec>/`) and ships its own updater. On v2 run
111+
`opencode plugin update` instead.
112+
113+
**Known v2 gaps** — behaviors the v1 `config`/`chat.params`/`event` hooks provide that
114+
v2 does not:
115+
116+
- **Delegation tools** (`cursor_delegate`, `cursor_cloud_agent`) — unavailable
117+
on v2 (fail-closed; see "Tools not exposed on v2" above). On v1 they are
118+
gated by a per-call approval honored from your `permission` config.
119+
- **`cursor_update_plugin`** — unavailable on v2; run
120+
`opencode plugin update` instead (see above).
121+
- **Live MCP forwarding** — v1 re-reads opencode's MCP server set every turn, so
122+
mid-session enable/disable reaches the Cursor agent; v2 forwards nothing.
123+
- **Skill mirror / skills catalogue** — v1 mirrors opencode skills into
124+
`.cursor/skills/` and injects the catalogue; unavailable on v2.
125+
- **Plugin-tools bridge** — v1 exposes other plugins' custom tools to the Cursor
126+
agent via a local stdio MCP server; unavailable on v2.
127+
- **Subagent `task`-part stamping** — v1 stamps the child session id on the
128+
running `task` part so the TUI card is clickable; unavailable on v2 (delegate
129+
results still surface, without the child-session link).
130+
- **`provider.cursor.options` settings** — the v1 `provider.cursor.options`
131+
block (forwarding, skills, sandbox, `autoCompaction`, ...) is not read on
132+
v2; the plugin registers its own provider record there. `autoCompaction`
133+
is therefore always off on v2 (models are always listed with the
134+
no-auto-compaction limit — see [Compaction](#compaction)).
135+
- **Update toast / stale-version warning UI** — v2 has no `ctx.tui`; use
136+
`cursor_refresh_models` (v2) / the maintenance tools (v1) and
137+
`opencode plugin update`.
138+
- **`app.log` bridge** — v2 removed the client write endpoint, so plugin events
139+
that v1 surfaced through `app.log` are not logged on v2.
140+
74141
To keep the plugin up to date easily, install the `opencode-plugins-refresh` helper (offered by
75142
the one-line installer, or install manually — see [Keeping the plugin up to date](#keeping-the-plugin-up-to-date)).
76143

@@ -470,6 +537,13 @@ are gated by opencode's `permission` config:
470537
{ "permission": { "cursor_delegate": "ask", "cursor_cloud_agent": "ask" } }
471538
```
472539

540+
> [!NOTE]
541+
> **opencode v1 only.** These tools are not exposed on v2 — v2 has no
542+
> plugin-drivable approval path, and the tools are fail-closed by design, so
543+
> on v2 they are simply absent (see [opencode v2](#opencode-v2)). The
544+
> `permission` shape above is the v1 config; for the host's behavior without
545+
> an entry, see opencode's own `permission` docs.
546+
473547
### `cursor_delegate` (local)
474548

475549
Runs one Cursor turn as a permission-gated tool call. Your primary opencode model hands off a

‎SECURITY.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ your opencode `permission` config (`allow` / `ask` / `deny`) controls them. The
3636
gate is **fail-closed**: if no permission mechanism is available, or approval is
3737
rejected, the call is blocked rather than silently allowed.
3838

39+
On **opencode v2** these tools are **not exposed at all**: v2's plugin API has
40+
no approval path a plugin can drive (no `ask`/`assert`), and nothing else in
41+
2.0.19 enforces one for plugin tools. Rather than registering them without a
42+
gate, the plugin leaves them unregistered — the same fail-closed outcome, by
43+
absence.
44+
3945
### Skills mirror — instructions cross the permission boundary
4046

4147
When `forwardSkills` is enabled (default), opencode's resolved skills are

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
],
2727
"files": [
2828
"dist",
29+
"server.js",
2930
"README.md",
3031
"CHANGELOG.md"
3132
],

‎server.js‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// opencode v2 loads a `file://` directory plugin by probing literal root
2+
// entrypoints (<dir>/server, <dir>/index, ...) — it never consults this
3+
// package's `exports` map for path plugins. Re-export the dual plugin entry
4+
// so the worktree/checkout directory is loadable by both v1 (via package.json
5+
// `exports["./server"]`) and v2 (via this root file).
6+
export * from "./dist/plugin/index.js";
7+
export { default } from "./dist/plugin/index.js";

‎src/plugin/cursor-tools.ts‎

Lines changed: 132 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,27 @@
11
import { tool, type ToolContext, type ToolDefinition } from "@opencode-ai/plugin";
2+
import { rmSync } from "node:fs";
3+
import semver from "semver";
24
import { runCloudAgent } from "../provider/cloud-agent.js";
35
import { runDelegate } from "../provider/delegate.js";
46
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";
514

615
const s = tool.schema;
716

817
export interface CursorToolDeps {
918
/**
1019
* Resolve the Cursor API key (from opencode auth, captured by the plugin's
1120
* 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.
1323
*/
14-
resolveApiKey: () => string | undefined;
24+
resolveApiKey: () => string | undefined | Promise<string | undefined>;
1525
/** Default working directory for local delegation (the session worktree/cwd). */
1626
defaultCwd: () => string;
1727
}
@@ -88,7 +98,7 @@ export function buildCursorTools(deps: CursorToolDeps): Record<string, ToolDefin
8898
.describe("Operate on the current branch instead of creating a new one."),
8999
},
90100
execute: async (args, context) => {
91-
const apiKey = deps.resolveApiKey();
101+
const apiKey = await deps.resolveApiKey();
92102
if (!apiKey) return NEEDS_AUTH;
93103

94104
const approval = await requestApproval(
@@ -168,7 +178,7 @@ export function buildCursorTools(deps: CursorToolDeps): Record<string, ToolDefin
168178
.describe("Resume a specific Cursor agent id instead of starting fresh."),
169179
},
170180
execute: async (args, context) => {
171-
const apiKey = deps.resolveApiKey();
181+
const apiKey = await deps.resolveApiKey();
172182
if (!apiKey) return NEEDS_AUTH;
173183

174184
const approval = await requestApproval(context, "cursor_delegate", [args.model], {
@@ -243,3 +253,121 @@ export function buildCursorTools(deps: CursorToolDeps): Record<string, ToolDefin
243253
}),
244254
};
245255
}
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

Comments
 (0)