Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<cache>/npm/<spec>/` 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
Expand Down
76 changes: 75 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down Expand Up @@ -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 (`<cache>/npm/<spec>/`) 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)).

Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
],
"files": [
"dist",
"server.js",
"README.md",
"CHANGELOG.md"
],
Expand Down
7 changes: 7 additions & 0 deletions server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// opencode v2 loads a `file://` directory plugin by probing literal root
// entrypoints (<dir>/server, <dir>/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";
136 changes: 132 additions & 4 deletions src/plugin/cursor-tools.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
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;

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<string | undefined>;
/** Default working directory for local delegation (the session worktree/cwd). */
defaultCwd: () => string;
}
Expand Down Expand Up @@ -88,7 +98,7 @@ export function buildCursorTools(deps: CursorToolDeps): Record<string, ToolDefin
.describe("Operate on the current branch instead of creating a new one."),
},
execute: async (args, context) => {
const apiKey = deps.resolveApiKey();
const apiKey = await deps.resolveApiKey();
if (!apiKey) return NEEDS_AUTH;

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

const approval = await requestApproval(context, "cursor_delegate", [args.model], {
Expand Down Expand Up @@ -243,3 +253,121 @@ export function buildCursorTools(deps: CursorToolDeps): Record<string, ToolDefin
}),
};
}

/**
* Shared base of the `cursor_refresh_models` description. v1 appends a note
* pointing at `cursor_update_plugin`; v2 (which does not register that tool)
* appends a note pointing at `opencode plugin update` instead.
*/
export const REFRESH_DESCRIPTION_BASE =
"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.";

/**
* Build the maintenance tools shared by v1 and v2:
* - `cursor_refresh_models`: force-refresh the model catalog.
* - `cursor_update_plugin`: check for and perform a plugin update.
*
* `deps?.resolveApiKey` (v2 passes its stored-connection path) makes the
* refresh authenticate with the resolved key; without it (v1) the refresh
* stays keyless and discovery falls back to CURSOR_API_KEY / cached models,
* as before. The update tool never touches the key or session state.
*/
export function buildMaintenanceTools(
deps?: Pick<CursorToolDeps, "resolveApiKey">,
): Record<string, ToolDefinition> {
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 },
};
}
},
},
};
}
Loading
Loading