From 31832152f9b0a89a54a5ea6fea8ca48761e24b6d Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Mon, 24 Aug 2026 19:51:10 -0500 Subject: [PATCH 1/7] feat: let the Cursor agent use opencode plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror plugin-bundled skills into .cursor/skills/ and bridge other plugins' custom tools to the Cursor agent via a local stdio MCP server. Skills (folds into forwardSkills): - Scan the opencode plugin cache (~/.cache/opencode/packages/, Windows fallback) and file-plugin sibling skill dirs as lowest-priority roots after project/global/skills.paths; first-wins on duplicate ids. - Merge opencode's live app.skills inventory per turn at the same lowest priority, covering sources the filesystem scan can't see. Plugin tools (new forwardPluginTools option, default on): - Re-import plugin modules from the package cache and read their tool maps — the same closures opencode executes. - Mirrored tools run behind a loopback, token-authenticated control channel; a dependency-free stdio MCP server (opencode-plugin-tools) is merged into the forwarded mcpServers at startup and re-checked each turn. - Permission gate mirrors opencode semantics: wildcard keys, per-ask pattern evaluation, last match wins, ~/ $HOME pattern expansion, ask/unconfigured fails closed with a clear message. - pluginTools.include/exclude filter mirrored tool ids. --- README.md | 108 ++++- ...2026-09-01-plugin-bundled-skills-mirror.md | 169 +++++++ .../plans/2026-09-01-plugin-tools-findings.md | 81 ++++ src/plugin/index.ts | 319 ++++++++++++- src/plugin/plugin-tool-registry.ts | 441 +++++++++++++++++ src/plugin/plugin-tools-bridge.ts | 228 +++++++++ src/plugin/skill-discovery.ts | 366 ++++++++++++-- src/sidecar/plugin-tools-mcp.mjs | 183 +++++++ test/plugin-tools-bridge.test.ts | 447 ++++++++++++++++++ test/plugin-tools-wiring.test.ts | 263 +++++++++++ test/skill-discovery.test.ts | 432 +++++++++++++++-- tsup.config.ts | 18 +- 12 files changed, 2972 insertions(+), 83 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-01-plugin-bundled-skills-mirror.md create mode 100644 docs/superpowers/plans/2026-09-01-plugin-tools-findings.md create mode 100644 src/plugin/plugin-tool-registry.ts create mode 100644 src/plugin/plugin-tools-bridge.ts create mode 100644 src/sidecar/plugin-tools-mcp.mjs create mode 100644 test/plugin-tools-bridge.test.ts create mode 100644 test/plugin-tools-wiring.test.ts diff --git a/README.md b/README.md index 1786000..d6336e5 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,7 @@ The plugin also registers two **delegation tools**: > `edit: deny`, `bash: ask`) do **not** apply to them. > > Options if you need a permission boundary: +> > - Set `sandbox: true` in `provider.cursor.options` to run Cursor's tools in Cursor's sandbox. > - Use `cursor_delegate` instead of the provider path — it is gated by opencode's `permission` > config. @@ -302,9 +303,21 @@ The mirror includes: - **Global skills** from `~/.config/opencode/skills/` and `~/.config/opencode/skill/`, `~/.claude/skills/`, `~/.agents/skills/`, `~/.opencode/skills/` and `~/.opencode/skill/`. - **Configured paths** from `config.skills.paths` in your `opencode.json` — additional - directories scanned at the lowest priority (project and standard global locations + directories scanned at low priority (project and standard global locations win on duplicate ids). `~/` prefixes are expanded to your home directory; relative paths are resolved against the project directory. +- **Plugin-bundled skills** — skills that ship inside installed opencode plugins, + scanned from the opencode plugin cache (`~/.cache/opencode/packages/` on + macOS/Linux; `%LocalAppData%\opencode\cache\packages\` on Windows) and from + `skills/`/`skill/` dirs alongside file-based plugins + (`~/.config/opencode/plugin/`, `~/.config/opencode/plugins/`, and the project's + `.opencode/plugin/`). Handles npm specs (`pkg@latest`, `@scope/pkg@latest`) and + git specs (`pkg@git+https:...`). Plugin-bundled skills are the **lowest** + priority: a project, global, or `skills.paths` skill with the same id always + wins, so you can shadow a plugin's skill by defining your own. +- **opencode's live skill inventory** — on every turn the mirror also consults + opencode's `app.skills` endpoint (when reachable) and merges any skill it + knows about that the filesystem scan missed, at the same lowest priority. - **Supporting files** alongside each `SKILL.md` (preserving relative paths). - An `` catalogue appended to the generated system rule, listing each skill's id and description so the Cursor agent can load them on @@ -346,13 +359,10 @@ user explicitly asked for them). `exclude` always drops the listed skills. ### Limitations -- **Skills bundled inside opencode plugins are not mirrored.** Those ship under - `//node_modules//skills/` (on macOS/Linux - `~/.cache/opencode/packages/`; on Windows `%LocalAppData%\opencode\cache\packages\`), - which is not a scanned location — `@opencode-ai/sdk` exposes no skills API, so - the mirror resolves skills from the filesystem itself. Skills that reach - opencode only through a plugin will be absent from `.cursor/skills/`. To - mirror one, add its directory to `config.skills.paths`. +- Plugin-bundled skills are resolved from the plugin package cache by + filesystem scan (`@opencode-ai/sdk` exposes no skills API), so they update + only when the cache is refreshed — run `opencode-plugins-refresh` after + installing/updating a plugin that ships skills, then restart opencode. - A user-owned `.cursor/skills//SKILL.md` (without the `generated: opencode-cursor` sentinel) is never overwritten or deleted. - Individual files larger than 1 MB are skipped (the rest of the skill is still @@ -362,6 +372,88 @@ user explicitly asked for them). `exclude` always drops the listed skills. any pre-existing `.cursor/skills/` there still loads). - `cursor_cloud_agent` targets a remote repo and does not inherit skills. +## Plugin tools + +Other opencode plugins can register custom tools (e.g. `opencode-pty`'s +`pty_spawn`, `context-mode`'s `ctx_execute`). With `forwardPluginTools: true` +(default), this plugin **mirrors those tools to the Cursor agent** via a local +stdio MCP server (`opencode-plugin-tools`) that is added to the forwarded +`mcpServers`. When the Cursor agent calls one, the call runs through the +plugin's *real* implementation inside opencode's runtime — same code path +opencode itself uses. + +How it works: + +1. At startup (and re-checked each turn) the plugin reads your `plugin: []` + list, re-imports each plugin from the opencode package cache, and reads its + `tool` map. Plugins that can't be imported are skipped and logged once. +2. A loopback-only HTTP control channel (random port, per-session bearer + token) connects the MCP child process to the host plugin, which owns the + tool closures. Nothing is reachable from outside the machine. +3. The Cursor agent sees the tools through its normal MCP surface and calls + them like any other MCP tool. + +### Permissions + +Mirrored calls are evaluated against your opencode `permission` config, +keyed by the tool id — with the same semantics opencode itself uses: +permission keys are wildcard-matched, and every pattern a tool's `ask` +requests is matched against the rule's pattern; the **last** matching rule +wins. `~`/`$HOME` prefixes in patterns expand against your home directory. + +- **`allow`** → runs without prompting (every requested pattern must allow). +- **`deny`** → rejected. +- **`ask`** (or no rule) → rejected with a clear message. The interactive + prompt is anchored to opencode's session/TUI and can't be surfaced to the + Cursor agent, so ask-permissioned tools are withheld rather than run + unattended. Set the tool to `allow` to use it from Cursor: + +```json +{ "permission": { "pty_spawn": "allow", "ctx_*": "allow" } } +``` + +Pattern-scoped rules work too — e.g. allow spawning ptys only under `/tmp` +(specific patterns must come **after** the wildcard they narrow): + +```json +{ "permission": { "pty_spawn": { "*": "ask", "/tmp/*": "allow" } } } +``` + +If a tool's execution calls `ask` internally and no gate is available, the +call fails closed. + +### Filtering + +```json +{ + "provider": { + "cursor": { + "options": { + "forwardPluginTools": true, + "pluginTools": { + "include": ["pty_*"], + "exclude": ["ctx_execute"] + } + } + } + } +} +``` + +`include` keeps only matching tool ids (wildcards supported); `exclude` always +drops. `forwardPluginTools: false` disables the bridge entirely. + +### Limitations + +- Plugins that fail to re-import under the bridge (e.g. native modules that + only load under Bun, or plugins that throw when initialised twice) are + skipped and reported in the opencode log; their tools stay unavailable to + Cursor. Skills and MCP servers from those plugins are unaffected. +- Tool definitions are snapshotted at startup and re-checked per turn; a + plugin installed mid-session is picked up on the next turn. +- `cursor_cloud_agent` targets a remote repo and does not inherit plugin + tools. + ## Delegation tools Both tools resolve the API key from your `opencode auth login` session (or `CURSOR_API_KEY`) and diff --git a/docs/superpowers/plans/2026-09-01-plugin-bundled-skills-mirror.md b/docs/superpowers/plans/2026-09-01-plugin-bundled-skills-mirror.md new file mode 100644 index 0000000..cf54b2f --- /dev/null +++ b/docs/superpowers/plans/2026-09-01-plugin-bundled-skills-mirror.md @@ -0,0 +1,169 @@ +# Give the Cursor agent access to opencode plugins: bundled skills + custom tools + +## Context + +opencode installs typically have many plugins configured (`plugin: [...]` in +`opencode.jsonc`). Those plugins give the opencode agent two things the Cursor +agent currently cannot use: + +1. **Bundled skills** — plugins can ship `skills/` directories inside their npm + package. opencode loads them natively; this repo's skill mirror deliberately + skips the opencode package cache (documented limitation, README "Skills → + Limitations"). Verified real example on this machine: `context-mode@latest` + ships ~8 skills (`ctx-search`, `ctx-index`, …) under + `~/.cache/opencode/packages/context-mode@latest/node_modules/context-mode/skills/` + — all invisible to the Cursor agent. + +2. **Custom tools** — plugins register tools via the `tool: {}` hook + (e.g. `opencode-pty`'s PTY tools). These are opencode-runtime JS functions; + Cursor has no path to them. + +Outcome: extend the existing skill mirror to cover plugin-bundled skills +(Phase 1), and bridge other plugins' registered tools to the Cursor agent via a +local stdio MCP server that proxies back into opencode's own tool execution +(Phase 2). + +## Decisions (from user) + +- Scope: **both** skills and custom tools. +- Sources: npm package cache **and** file plugins (`~/.config/opencode/plugins/`, + `.opencode/plugin/`). Note: file plugins are single `.ts` files — they can't + bundle a `skills/` dir, but scan their parent dirs anyway for correctness. +- Config: **fold into `forwardSkills`** (default on); existing + `skills.include/exclude` applies identically. Tools get their own option + (`forwardPluginTools`, default on) since the risk profile differs. +- Precedence: plugin-bundled skills **lowest priority** on duplicate ids — + project/global/`skills.paths` always win. + +## Existing code to reuse + +| Piece | Location | Role | +| --- | --- | --- | +| `discoverSkills(cwd, extraPaths)` | [src/plugin/skill-discovery.ts](../../../src/plugin/skill-discovery.ts) | Ordered scan-roots pipeline; new cache scan slots in as lowest-priority roots | +| `scanSkillDir(dir)` / `entryKind` / `loadSkill` | same | Symlink-safe `SKILL.md` dir scan — reused as-is | +| `filterSkills()` | same | Permission `allow/deny/ask` + manual include/exclude — applies unchanged | +| `writeSkillMirror()` | [src/provider/skill-mirror.ts](../../../src/provider/skill-mirror.ts) | Sentinel-guarded `.cursor/skills/` mirror, 1 MB/10 MB caps | +| `buildSkillsCatalogue` / `skillSetHash` / `chat.params` live re-sync | [src/plugin/index.ts](../../../src/plugin/index.ts) | Catalogue + mid-session refresh — new sources ride along | +| `translateMcpServers()` | [src/plugin/mcp-config.ts](../../../src/plugin/mcp-config.ts) | Reference for how forwarded MCP servers reach the Cursor agent (`mcpServers` provider option); Phase 2 adds one more entry | +| `context.ask` approval gate | [src/plugin/cursor-tools.ts](../../../src/plugin/cursor-tools.ts) | Permission-gating pattern for the proxied tool execution | +| `PLUGIN_CACHE_PATH` win32/XDG pattern | [src/version-check.ts](../../../src/version-check.ts:27) | Cache-root resolution pattern (XDG_CACHE_HOME → ~/.cache; %LocalAppData%\opencode\cache on Windows) | +| `scripts/opencode-plugins-refresh` | [scripts/opencode-plugins-refresh](../../../scripts/opencode-plugins-refresh) | Documents real cache layouts: `@latest`, `@scope/name@latest`, `@scope/name`, git specs (`superpowers@git+https:/...`) | + +## Phase 1 — Mirror plugin-bundled skills + +Cache-root layout (verified): `$XDG_CACHE_HOME/opencode/packages/` contains +entries per plugin: `@latest/`, `/`, scoped `@scope/@latest/`, +and git specs. Each entry is a package root whose skills live at +`node_modules//skills//SKILL.md` (unscoped and scoped pkg names). +Note some cache entries are **not** plugins (`bash-language-server`, +`typescript-language-server`, `prettier`, `pyright`, `ls`) — they simply have no +`skills/` dir, so scanning them is a cheap no-op; no need to parse `plugin: []` +from opencode.json to filter. + +### Steps + +- [ ] Add `discoverPluginSkills()` (or extend `discoverSkills` with a new + lowest-priority scan-roots group) in `src/plugin/skill-discovery.ts`: + - Resolve cache root: `$XDG_CACHE_HOME/opencode/packages` (win32: + `%LocalAppData%\opencode\cache\packages`) — factor a small shared helper, + since `version-check.ts` duplicates this logic. + - Enumerate entries: for each cache entry dir `E`, scan + `E/node_modules/**/skills` (bounded: check `E/node_modules//skills` + for each immediate child of `E/node_modules`, including `@scope/` nesting, + plus git-spec layouts). Use `scanSkillDir` for each found `skills/` and + `skills/` sibling `skill/`. + - Also scan file-plugin parents: `~/.config/opencode/plugins/` and + `/.opencode/plugin/` — look for sibling `skills/` dirs. +- [ ] Precedence: append these roots **after** all existing roots (first-wins + dedupe already gives lowest priority). +- [ ] No new config surface (folds into `forwardSkills`; existing + `skills.include/exclude` and permission filtering apply). +- [ ] Tests in `test/skill-discovery.test.ts`: fixture cache roots covering + unscoped `@latest`, scoped, no-suffix, git-spec layouts; non-plugin cache + entries ignored; duplicate-id precedence (project skill beats plugin skill); + permission `deny` drops a plugin-bundled skill; `include` list can select one. +- [ ] README: remove "Skills bundled inside opencode plugins are not mirrored" + limitation; document the new source + precedence. + +## Phase 2 — Bridge plugin custom tools via a proxy MCP server + +Design: the plugin spawns a **local stdio MCP server** (bundled in this package) +that exposes every other plugin's registered custom tools as MCP tools. It is +handed to the Cursor agent through the existing `mcpServers` provider option — +the same channel `translateMcpServers` already feeds. When Cursor calls one, +the server proxies execution back into opencode's runtime (which owns the real +tool implementations, including other plugins' closures) via a local RPC loop +hosted by this plugin. + +Why MCP proxy rather than re-implementing tools: opencode's `@opencode-ai/sdk` +exposes **no API to list or invoke registered tools** (verified: README itself +documents "no skills API"; the SDK surface is session/config/MCP-status only). +Plugin tools are in-process closures; the only in-process participant that can +see them is a plugin. So: a tiny in-process registry + a stdio MCP child is the +minimal bridge. + +### Steps + +- [ ] Registry module (e.g. `src/plugin/plugin-tool-registry.ts`): captures the + `tool: {}` maps of *other* plugins. Mechanism: opencode calls each plugin's + hook and merges the returned tools — investigate whether a later-registered + plugin can observe earlier tools (wrap/intercept via the `config` hook's + merged result, or the `tool.execute.before` event which receives tool names — + see `~/.config/opencode/plugins/rtk.ts:19` for the event shape). Fallback if + enumeration is impossible: forward only tools the user lists explicitly in + `provider.cursor.options.pluginTools: ["pty_*", ...]` with descriptors. + > [!WARNING] + > Enumeration feasibility is the key risk. If opencode does not expose other + > plugins' tool maps to a sibling plugin, Phase 2 becomes: document a + > convention where interested plugin authors register tools with this + > plugin's registry, plus the explicit-list fallback. +- [ ] MCP server entry (e.g. `src/sidecar/plugin-tools-mcp.mjs`): stdio MCP + server using the MCP SDK (add `@modelcontextprotocol/sdk` dependency). + `tools/list` serves the registry snapshot (name, description, JSON-schema + args); `tools/call` forwards to the host plugin over a localhost socket or + the stdin/stdout-adjacent control channel, which executes the real opencode + tool through the same `context.ask` gating pattern as `cursor_delegate` + (`src/plugin/cursor-tools.ts`) so the user's `permission` config applies. +- [ ] Wire-up in `src/plugin/index.ts` config hook: when + `provider.cursor.options.forwardPluginTools !== false` and the registry is + non-empty, add `opencode-plugin-tools` to the forwarded `mcpServers` + (`type: "stdio"`, command = `node `, env carries the RPC + port/auth token). Must NOT spawn when the registry is empty. +- [ ] Permission model: proxied calls gated by a new `permission` key + (`cursor_plugin_tools`: ask default), plus per-tool patterns in metadata — + matching the existing delegation-tool gating. +- [ ] Tests: registry capture, MCP server list/call round-trip against a fake + tool, permission gate deny path, empty-registry no-spawn. +- [ ] README: new "Plugin tools" section: what is forwarded, the permission + knob, the security note (Cursor invoking another plugin's tool runs that + tool's code with the user's opencode permissions). + +## Files to modify + +- `src/plugin/skill-discovery.ts` — Phase 1 cache/file-plugin scan roots +- `src/plugin/index.ts` — wire-up for both phases +- `src/version-check.ts` — extract shared opencode cache-root helper (or new + `src/plugin/opencode-cache.ts`) +- NEW `src/plugin/plugin-tool-registry.ts`, NEW `src/sidecar/plugin-tools-mcp.mjs` — Phase 2 +- `test/skill-discovery.test.ts`, NEW `test/plugin-tools-mcp.test.ts` — coverage +- `README.md` — docs for both phases +- `package.json` — Phase 2 adds `@modelcontextprotocol/sdk` dependency (verify + current version via `npm view` before pinning) + +## Verification (executed) + +- `npm run typecheck && npm test` — 41 files / 606 tests pass. +- `npm run build` — dist/sidecar/plugin-tools-mcp.js emitted; dist MCP round-trip + smoke passes (initialize → tools/list → tools/call). +- Phase 1 smoke: discovery against the real cache finds 22 plugin-bundled + skills (8 context-mode + 14 superpowers git-spec) alongside 22 config skills. +- Phase 2 smoke (Bun, matching opencode's runtime): `mirrorPluginTools` pulls + 16 real tools from `opencode-pty@latest` + `context-mode@latest`, `failed: {}`. +- Full-plugin smoke against `dist/plugin/index.js`: bridge lands in + `mcpServers["opencode-plugin-tools"]`; a tool whose `execute` calls + `ctx.ask` is rejected with a clear "set to allow" message when unconfigured + and runs when `permission: { fake_tool: "allow" }`. +- Wiring test covers exact-id and wildcard (`wire_*`) permission allow paths. +- Remaining live check (needs a running opencode + Cursor session): confirm + `.cursor/skills/` contains `ctx-search` etc. and the Cursor agent lists the + `opencode-plugin-tools` MCP server in a real turn. diff --git a/docs/superpowers/plans/2026-09-01-plugin-tools-findings.md b/docs/superpowers/plans/2026-09-01-plugin-tools-findings.md new file mode 100644 index 0000000..e97056f --- /dev/null +++ b/docs/superpowers/plans/2026-09-01-plugin-tools-findings.md @@ -0,0 +1,81 @@ +# Phase 2 findings: bridging plugin tools to the Cursor agent + +Verified against opencode v1.18.18 source (sparse clone at `/tmp/opencode-src`) +and the installed `@opencode-ai/sdk@1.18.18` typings. + +## What was learned + +1. **Plugin tools are in-process closures.** opencode's tool registry calls each + plugin's `hooks.tool` map and wraps `execute` with an Effect bridge + (`packages/opencode/src/tool/registry.ts:125-198`). There is no public API to + *invoke* another plugin's tool from outside that closure. +2. **But a sibling plugin CAN call them.** Plugin load order is config order + (`plugin/index.ts:297` — `Plugin.list()` returns loaded hooks; the registry + iterates `plugin.list()` at registry state init). A plugin registered AFTER + another one can `import()` that plugin's module, call its `server(input)` + with the same `PluginInput` it received, and read `hooks.tool` — the exact + same functions opencode itself will later execute. Same module instance → + same closures. +3. **opencode resolves plugin specs itself.** `Config.plugin` entries may be + bare names, `@latest` specs, file paths, or git URLs; opencode installs them + into the package cache (`~/.cache/opencode/packages/`). A mirror plugin can + reuse the cache resolution the skill mirror already does + (`opencodePackagesRoot`, cache-entry layouts: `name@latest`, + `@scope/name@latest`, `name@git+https:...`). +4. **Execution with permission gating already exists.** opencode's + `ToolContext.ask` (bridged to Effect at `registry.ts:143-146`) honours the + user's live permission config. Calling the mirror's tool map the same way + opencode does (`fromPlugin` shape) preserves that gate for free. +5. **JSON schema extraction is reliable.** opencode converts plugin Zod args via + `z.toJSONSchema(schema, { io: "input" })` (`registry.ts:370`), with a legacy + fallback that treats non-Zod entries as raw JSON Schema + (`registry.ts:358-367`). The registry does the same. + +## Chosen design (vs the plan's session-loopback alternative) + +The plan's WARNING flagged that enumeration may be impossible. It is possible — +via import + re-invoke of sibling plugin modules. That is strictly better than +session-loopback execution: + +| | import + re-invoke (chosen) | session loopback | +| --- | --- | --- | +| Executes the real tool fn | yes — same closure opencode uses | yes — opencode executes it | +| Permission `ask` gate | yes (host bridges `context.ask`) | yes | +| LLM turn cost | none | one full model turn per call | +| Model dependency | none | session's configured model | +| Failure modes | import failures only | prompt/event races, model errors | + +Session loopback remains the documented fallback for tools whose modules cannot +be re-imported (stateful singletons that break on double-init are the known +risk; `opencode-pty` verified importable and its `tool` map is a plain object +of closures over a module-scoped session manager — re-invoking `server()` +creates a second manager, harmless for read-only bridging but noted in README). + +## Verified real targets on this machine + +- `opencode-pty@latest` → `dist/src/plugin.js` exports `PTYPlugin` (also as + `server`); `hooks.tool` = `pty_spawn`, `pty_write`, `pty_read`, `pty_list`, + `pty_kill`. +- `context-mode@latest` → MCP-backed tools (its skills are handled by Phase 1; + its tools are not re-exported as a `tool` map — excluded from mirror, correct). + +## SDK surface used + +- `client.tool.ids()` → `/experimental/tool/ids` (list registered tool ids) +- `client.session.create/update` with `permission` + `metadata` (not needed in + chosen design; kept as fallback notes) +- `client.session.prompt` with `noReply` + `tools` (fallback only) + +## Follow-ups (from review) + +- **M1**: bridge token/port ride the per-turn `mcpServers` config through + opencode's session plumbing; consider a process-lifetime bridge or a 0600 + temp-file handoff. Bridge restart also changes the transcript fingerprint, + re-creating the pooled Cursor agent (intentional but worth a doc note). +- **M3**: `mirrorPluginTools` re-invokes every plugin's `server()` factory + each turn — memoize on plugin-list + permission-config hash; add an + import/init timeout. +- **M6**: `src/version-check.ts` hardcodes `~/.cache` (pre-existing XDG bug, + now duplicated by `opencodePackagesRoot`) — factor a shared helper. +- **N3**: git-spec cache walk admits symlinked components; a realpath + containment check is cheap hardening (low impact: user-controlled cache). diff --git a/src/plugin/index.ts b/src/plugin/index.ts index c2a5ff4..dd3e701 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -1,7 +1,8 @@ -import type { Config, Plugin } from "@opencode-ai/plugin"; +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"; import { discoverModels, toOpencodeModels } from "../model-discovery.js"; @@ -32,7 +33,9 @@ import { } from "../provider/skill-mirror.js"; import { resolveSkills, + resolvePluginSkillSources, skillSetHash, + type LiveSkill, type SkillFilterOptions, } from "../plugin/skill-discovery.js"; import { @@ -41,6 +44,14 @@ import { subagentCallChildId, stampTaskPartSessionId, } from "../provider/subagent-bridge.js"; +import { + mirrorPluginTools, + type MirroredTool, +} from "./plugin-tool-registry.js"; +import { + startPluginToolsBridge, + type PluginToolsBridge, +} from "./plugin-tools-bridge.js"; function apiKeyFromAuth(auth: Auth | undefined): string | undefined { return auth?.type === "api" ? auth.key : undefined; @@ -135,6 +146,23 @@ export const CursorPlugin: Plugin = async (input) => { // provider options (respecting a user-configured `cwd` option) so write and // cleanup can never diverge. let resolvedCwd = directory ?? process.cwd(); + // Skills bundled inside installed opencode plugins ship under the plugin + // package cache, and optionally alongside file-based plugins — resolve the + // plugin-side sources here so the skill mirror includes them. Set to + // undefined when any source throws so the mirror falls back to its default + // filesystem scan instead of mirroring nothing. + let pluginSkillSources: + | { cacheRoot?: string; filePluginRoots?: string[] } + | undefined; + try { + pluginSkillSources = resolvePluginSkillSources(); + } catch (error) { + pluginLog("warn", "plugin skill source discovery failed", { + error: error instanceof Error ? error.message : String(error), + impact: "plugin-bundled skills unavailable to the Cursor agent", + }); + pluginSkillSources = undefined; + } let forwardMcp = true; let userMcp: Record = {}; // Whether to let opencode drive auto-compaction. Default false: the Cursor @@ -151,6 +179,220 @@ export const CursorPlugin: Plugin = async (input) => { // server rather than on every turn. const warnedOAuth = new Set(); + // Plugin-tool bridge state: mirrored tools from other opencode plugins, + // exposed to the Cursor agent via a local stdio MCP server. Populated by + // the config hook; re-checked in chat.params so plugins added mid-session + // are picked up on the next turn. + let forwardPluginTools = true; + let pluginToolOptions: { include?: string[]; exclude?: string[] } | undefined; + let mirroredTools: MirroredTool[] = []; + let pluginToolsBridge: PluginToolsBridge | undefined; + let lastPermissionKey = ""; + let pluginToolsMcpServer: McpServerConfig | undefined; + let pluginToolsWarned = false; + + /** + * Mirror other plugins' tool maps and (re)start the bridge. Returns the + * MCP server config to merge into the Cursor agent's `mcpServers`, or + * undefined when nothing is mirrored. Never throws. + */ + async function syncPluginTools( + config?: Config, + ): Promise { + if (!forwardPluginTools) return undefined; + try { + const result = await mirrorPluginTools(config, input, pluginToolOptions); + if (Object.keys(result.failed).length > 0 && !pluginToolsWarned) { + pluginToolsWarned = true; + pluginLog("warn", "plugin tool mirror skipped some plugins", result.failed); + } + if (result.tools.length === 0) { + await pluginToolsBridge?.close(); + pluginToolsBridge = undefined; + mirroredTools = []; + return undefined; + } + // Restart the bridge when the tool set OR the permission config + // changed (the ask gate closes over the config snapshot). + const ids = result.tools + .map((t) => t.id) + .sort() + .join("|"); + // Permission objects are plain config JSON; JSON.stringify on the + // raw value is a stable-enough identity for change detection + // (config order is stable within a session). + const permKey = JSON.stringify(config?.permission ?? null); + const currentIds = mirroredTools + .map((t) => t.id) + .sort() + .join("|"); + if ( + ids !== currentIds || + permKey !== lastPermissionKey || + !pluginToolsBridge + ) { + await pluginToolsBridge?.close(); + pluginToolsBridge = await startPluginToolsBridge({ + tools: result.tools, + directory: input?.directory ?? process.cwd(), + askGate: makeAskGate(config?.permission), + }); + mirroredTools = result.tools; + lastPermissionKey = permKey; + } + pluginToolsMcpServer = pluginToolsBridge?.mcpServer as + | McpServerConfig + | undefined; + return pluginToolsMcpServer; + } catch (error) { + pluginLog("warn", "plugin tool mirror failed", { + error: error instanceof Error ? error.message : String(error), + }); + return undefined; + } + } + + /** + * Permission gate for mirrored tool execution, evaluated against the + * user's opencode `permission` config exactly like a native tool's + * `context.ask`: + * + * - `allow` → resolve silently. + * - `deny` → reject (the call fails closed). + * - `ask` → reject. The interactive prompt is anchored to an opencode + * session/TUI; a Cursor-originated call has no way to surface it, so — + * like ask-permissioned skills — it is withheld rather than run + * unattended. Users who want a tool available to Cursor set it to + * `allow` (optionally scoped with a pattern). + */ + function makeAskGate( + permissionConfig: unknown, + ): ToolContext["ask"] | undefined { + return async (req) => { + // Mirror opencode's Permission.ask loop: evaluate every requested + // pattern (default "*"). Any deny → reject immediately; all allow → + // run; anything else is "ask", which can't be prompted from Cursor. + const patterns = + Array.isArray(req.patterns) && req.patterns.length > 0 + ? req.patterns + : ["*"]; + let needsAsk = false; + for (const pattern of patterns) { + const action = evaluatePermissionAction( + permissionConfig, + req.permission, + pattern, + ); + if (action === "deny") { + throw new Error( + `permission denied for "${req.permission}" (pattern "${pattern}")`, + ); + } + if (action !== "allow") needsAsk = true; + } + if (!needsAsk) return; + throw new Error( + `permission for "${req.permission}" is set to "ask", which can't be prompted from the Cursor agent — set it to "allow" to use this tool`, + ); + }; + } + + /** + * Resolve a permission action from the live opencode permission config, + * matching opencode's own `Permission.evaluate` semantics: rules are + * flattened in config order and the LAST rule whose permission wildcard + * matches `permission` AND whose pattern wildcard matches `pattern` wins; + * no match → "ask". Supported shapes: + * + * - rule array (V2 ruleset): `[{ permission, pattern, action }, ...]` + * - map form: `{ "pty_*": "allow" }` or `{ "pty_*": { "*": "allow" } }` + * (the nested map keys are pattern wildcards) + */ + function evaluatePermissionAction( + permissionConfig: unknown, + permission: string, + pattern: string, + ): "allow" | "deny" | "ask" { + const wildcardMatch = (pattern: string, value: string): boolean => { + if (pattern === "*") return true; + if (!pattern.includes("*")) return pattern === value; + const regex = pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*"); + return new RegExp(`^${regex}$`).test(value); + }; + const normalize = (value: unknown): "allow" | "deny" | "ask" | undefined => + value === "allow" || value === "deny" || value === "ask" ? value : undefined; + // Mirror opencode's `expand` for pattern wildcards (permission/index.ts): + // `~`, `~/...`, and `$HOME...` expand against the current user's home. + const home = process.env["HOME"] || homedir(); + const expandPattern = (pattern: string): string => { + if (pattern === "~") return home; + if (pattern.startsWith("~/")) return home + pattern.slice(1); + if (pattern.startsWith("$HOME/")) return home + pattern.slice(5); + if (pattern.startsWith("$HOME")) return home + pattern.slice(5); + return pattern; + }; + + // Flatten the config into (permission-wildcard, pattern-wildcard, action) + // triples in config order, then take the last match — same as opencode. + const rules: Array<{ + permission: string; + pattern: string; + action: "allow" | "deny" | "ask"; + }> = []; + const pushRule = (perm: unknown, pattern: unknown, action: unknown): void => { + const normalized = normalize(action); + if (typeof perm !== "string" || normalized === undefined) return; + rules.push({ + permission: perm, + pattern: typeof pattern === "string" ? expandPattern(pattern) : "*", + action: normalized, + }); + }; + + if (Array.isArray(permissionConfig)) { + for (const rule of permissionConfig) { + if (rule && typeof rule === "object") { + const r = rule as { + permission?: unknown; + pattern?: unknown; + action?: unknown; + }; + pushRule(r.permission, r.pattern, r.action); + } + } + } else if (permissionConfig && typeof permissionConfig === "object") { + for (const [perm, value] of Object.entries( + permissionConfig as Record, + )) { + const direct = normalize(value); + if (direct) { + pushRule(perm, "*", value); + continue; + } + if (value && typeof value === "object" && !Array.isArray(value)) { + for (const [pattern, action] of Object.entries( + value as Record, + )) { + pushRule(perm, pattern, action); + } + } + } + } + + for (let i = rules.length - 1; i >= 0; i--) { + const rule = rules[i]!; + if ( + wildcardMatch(rule.permission, permission) && + wildcardMatch(rule.pattern, pattern) + ) { + return rule.action; + } + } + return "ask"; + } + return { auth: { provider: PROVIDER_ID, @@ -202,10 +444,25 @@ export const CursorPlugin: Plugin = async (input) => { string, McpServerConfig >; - const mcpServers = forwardMcp + const baseMcpServers = forwardMcp ? { ...userMcp, ...translateMcpServers(config.mcp) } : userMcp; + // Bridge other plugins' custom tools to the Cursor agent via a + // local stdio MCP server. Opt out with + // `provider.cursor.options.forwardPluginTools: false`; filter with + // `provider.cursor.options.pluginTools: { include, exclude }`. + forwardPluginTools = existingOptions["forwardPluginTools"] !== false; + pluginToolOptions = existingOptions["pluginTools"] as + | { include?: string[]; exclude?: string[] } + | undefined; + const pluginToolsServer = await syncPluginTools( + config as Config | undefined, + ); + const mcpServers = pluginToolsServer + ? { ...baseMcpServers, "opencode-plugin-tools": pluginToolsServer } + : baseMcpServers; + // opencode forwards a model's own options.params on the normal chat // path, but a subagent inheriting its parent's model reaches the provider // with them dropped — letting Cursor's server-side `fast: true` apply. @@ -242,6 +499,7 @@ export const CursorPlugin: Plugin = async (input) => { resolvedCwd, config as Config | undefined, skillFilterOptions, + pluginSkillSources, ); writeSkillMirror(resolvedCwd, resolved.skills, (msg) => pluginLog("warn", msg), @@ -336,13 +594,23 @@ export const CursorPlugin: Plugin = async (input) => { client.config.get(), client.mcp.status(query), ]); - const liveMcp = (cfgRes?.data as Config | undefined)?.mcp; + const liveConfig = cfgRes?.data as Config | undefined; + const liveMcp = liveConfig?.mcp; const status = statusRes?.data as McpStatusMap | undefined; if (status) { - output.options["mcpServers"] = { + // Re-sync the plugin-tools bridge against the live config too, + // so plugins added mid-session reach Cursor on the next turn. + // (Runs here as well as in the dedicated block below so the + // merged server set always carries the latest bridge config.) + const liveToolsServer = await syncPluginTools(liveConfig); + const liveServers: Record = { ...userMcp, ...translateMcpServers(liveMcp, status), }; + if (liveToolsServer) { + liveServers["opencode-plugin-tools"] = liveToolsServer; + } + output.options["mcpServers"] = liveServers; // Notify (once) about OAuth servers we can't forward: opencode // holds their token and it never reaches config.mcp, so the // Cursor agent can't connect. Only those without a shareable @@ -368,6 +636,29 @@ export const CursorPlugin: Plugin = async (input) => { } catch { // Keep the static snapshot; live forwarding is best-effort. } + } else if (client && forwardPluginTools && mirroredTools.length > 0) { + // `forwardMcp: false` still leaves the plugin-tools bridge live + // (it's independent of opencode MCP forwarding), so re-sync it + // against the live config and keep it in the forwarded set. + // Guard on `mirroredTools` so installs with no plugin tools keep + // `mcpServers` entirely absent from the per-turn output. + try { + const query = directory ? { query: { directory } } : undefined; + const cfgRes = await client.config.get(query); + const liveConfig = cfgRes?.data as Config | undefined; + const liveToolsServer = await syncPluginTools(liveConfig); + const liveServers: Record = { + ...userMcp, + }; + if (liveToolsServer) { + liveServers["opencode-plugin-tools"] = liveToolsServer; + } + if (Object.keys(liveServers).length > 0) { + output.options["mcpServers"] = liveServers; + } + } catch { + // Keep the static snapshot; live re-sync is best-effort. + } } // Re-sync the skill mirror from opencode's *live* state so skills @@ -379,10 +670,28 @@ export const CursorPlugin: Plugin = async (input) => { const query = directory ? { query: { directory } } : undefined; const cfgRes = await client.config.get(query); const liveConfig = cfgRes?.data as Config | undefined; + // Live skill inventory from opencode (`app.skills`) — covers + // plugin-bundled skills and any other source the filesystem + // scan can't see. Merged at lowest priority. + let liveSkills: LiveSkill[] | undefined; + try { + // SAFETY: `app.skills` is the V2 endpoint (/app/skills); the V1 + // client typings this repo imports predate it, so widen here. + // The call is optional-chained and caught, so a host without the + // endpoint degrades to the filesystem scan. + const app = client.app as unknown as { + skills?: (params?: unknown) => Promise<{ data?: unknown } | undefined>; + }; + const skillsRes = await app.skills?.(query); + liveSkills = skillsRes?.data as LiveSkill[] | undefined; + } catch { + // Live inventory is best-effort; the filesystem scan stands. + } const resolved = resolveSkills( resolvedCwd, liveConfig, skillFilterOptions, + { ...pluginSkillSources, liveSkills }, ); const hash = skillSetHash(resolved.skills); if (hash !== lastSkillHash) { @@ -537,6 +846,8 @@ export const CursorPlugin: Plugin = async (input) => { // sentinel-guarded, so user-owned files are never deleted. removeSystemRule(resolvedCwd); removeSkillMirror(resolvedCwd); + await pluginToolsBridge?.close(); + pluginToolsBridge = undefined; clearSubagentBridge(); clearLogBridge(); }, diff --git a/src/plugin/plugin-tool-registry.ts b/src/plugin/plugin-tool-registry.ts new file mode 100644 index 0000000..b2cbfb9 --- /dev/null +++ b/src/plugin/plugin-tool-registry.ts @@ -0,0 +1,441 @@ +/** + * Mirror the `tool` maps of other installed opencode plugins so the Cursor + * agent can call them through a local MCP bridge. + * + * How it works: opencode loads plugins in `config.plugin` order and executes + * their `hooks.tool` definitions in-process (`tool/registry.ts`). A sibling + * plugin registered *after* them can re-`import()` the same module and invoke + * its exported `server`/default function to read the identical `hooks.tool` + * map — the exact closures opencode will execute. The mirror never runs a + * plugin's lifecycle hooks (`config`, `event`, `chat.*`); it only reads the + * `tool` map and forwards `execute` calls, with the host plugin providing a + * real `context.ask` so the user's permission config still gates every call. + * + * What is mirrored: + * - `config.plugin` specs that are bare package names or `name@latest` / + * `@scope/name@latest` (resolved against the opencode package cache). + * - git specs (`name@git+https:...`) when the cache entry resolves. + * - explicit local file paths (`.ts`/`.js`), imported directly. + * Skipped: anything that fails to import/init (logged, never fatal), and the + * `@stablekernel/opencode-cursor` spec itself (never mirror ourselves). + */ +import { existsSync, readdirSync, statSync } from "node:fs"; +import type { Dirent } from "node:fs"; +import { join } from "node:path"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; +import { homedir } from "node:os"; +import { tool } from "@opencode-ai/plugin"; +import type { Config, ToolDefinition } from "@opencode-ai/plugin"; +import { opencodePackagesRoot } from "./skill-discovery.js"; + +/** Our own spec, excluded from mirroring (never mirror ourselves). */ +const SELF_SPECS = new Set([ + "@stablekernel/opencode-cursor", + "@stablekernel/opencode-cursor@latest", +]); + +/** A tool definition mirrored from another plugin, ready to execute. */ +export interface MirroredTool { + id: string; + description: string; + parameters: Record; + execute: ToolDefinition["execute"]; + /** The plugin the tool came from (for logging/permission keys). */ + sourcePlugin: string; +} + +export interface MirrorPluginToolsOptions { + /** Only mirror tools whose id matches one of these patterns. */ + include?: string[]; + /** Never mirror tools whose id matches one of these patterns. */ + exclude?: string[]; + /** Override the package cache root (tests). */ + cacheRoot?: string; +} + +export interface MirrorResult { + tools: MirroredTool[]; + /** Specs that were attempted but failed (id → reason). */ + failed: Record; +} + +/** + * Best-effort parse of a `config.plugin` entry into a resolvable form. + * Entries may be `name`, `name@latest`, `@scope/name[@latest]`, + * `name@git+`, or a filesystem path. Returns undefined for entries we + * don't attempt to mirror (relative paths, URL-only specs). + */ +export function parsePluginSpec( + spec: string, +): + | { kind: "npm"; name: string; version?: string } + | { kind: "git"; name: string; raw: string } + | { kind: "path"; path: string } + | { kind: "unsupported"; raw: string } + | undefined { + const trimmed = spec.trim(); + if (!trimmed) return undefined; + // Filesystem paths: absolute, or explicitly relative. + if ( + trimmed.startsWith("/") || + trimmed.startsWith("./") || + trimmed.startsWith("../") || + trimmed.startsWith("~/") || + /\.[cm]?[jt]sx?$/.test(trimmed) + ) { + const expanded = trimmed.startsWith("~/") + ? join(homedir(), trimmed.slice(2)) + : trimmed; + return { kind: "path", path: expanded }; + } + // npm spec: `name`, `name@latest`, `@scope/name@version`. + const at = trimmed.lastIndexOf("@"); + if (at > 0) { + const name = trimmed.slice(0, at); + const version = trimmed.slice(at + 1); + // Git specs (`git+https:...`) land in the cache under the raw spec + // string. Other URL forms (tarball specs) are valid npm but never + // appear in opencode's cache layout — mark them unsupported instead + // of misclassifying them as git. + if (version.startsWith("git+")) { + return { kind: "git", name, raw: trimmed }; + } + if (version.includes("://")) { + return { kind: "unsupported", raw: trimmed }; + } + return { kind: "npm", name, version }; + } + return { kind: "npm", name: trimmed }; +} + +/** + * Locate a plugin's install directory in the opencode package cache for an + * npm or git spec. Tries the layouts opencode produces: `@latest`, + * `@`, bare ``, and (for git specs) the spec string + * verbatim with any nesting depth. + */ +export function resolveCacheEntry( + cacheRoot: string, + parsed: + | { kind: "npm"; name: string; version?: string } + | { kind: "git"; name: string; raw: string } + | { kind: "unsupported"; raw: string }, +): string | undefined { + if (parsed.kind === "unsupported") return undefined; + if (parsed.kind === "git") { + // Git specs land as the spec string itself, potentially nested + // (superpowers@git+https:/github.com/owner/repo.git). Walk bounded. + const parts = parsed.raw.split("/"); + let current = cacheRoot; + for (const part of parts) { + const candidate = join(current, part); + if (!existsSync(candidate)) return undefined; + current = candidate; + } + return current; + } + const { name, version } = parsed; + const candidates = [ + version ? join(cacheRoot, `${name}@${version}`) : undefined, + join(cacheRoot, `${name}@latest`), + join(cacheRoot, name), + ].filter((c): c is string => Boolean(c)); + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + return undefined; +} + +/** + * Resolve a cache entry's importable module path via its package.json + * (`main` / `exports`), matching how opencode itself loads the plugin. A + * scoped or nested `node_modules` root is used for require-resolution so + * relative `main` paths land inside the package dir. + */ +function resolvePackageMain( + cacheEntry: string, + pkgDir: string, + name: string, +): string | undefined { + const tryRequire = (baseDir: string): string | undefined => { + try { + const req = createRequire(join(baseDir, "noop.js")); + return req.resolve(name); + } catch { + return undefined; + } + }; + // Prefer resolution from the package dir itself (handles `exports`), + // then from the cache entry root (handles bare `main` layouts). + return tryRequire(pkgDir) ?? tryRequire(cacheEntry); +} + +/** The package root inside a cache entry: `node_modules/`. */ +function packageRoot(cacheEntry: string, name: string): string | undefined { + const direct = join(cacheEntry, "node_modules", name); + if (existsSync(direct)) return direct; + // Fallback: first node_modules child (git specs nest the real package). + const nm = join(cacheEntry, "node_modules"); + if (!existsSync(nm)) return undefined; + let entries: Dirent[]; + try { + entries = readdirSync(nm, { withFileTypes: true }); + } catch { + return undefined; + } + for (const ent of entries) { + if (ent.name === ".bin") continue; + const full = join(nm, ent.name); + let isDir = ent.isDirectory(); + if (!isDir && ent.isSymbolicLink()) { + try { + isDir = statSync(full).isDirectory(); + } catch { + isDir = false; + } + } + if (isDir) return full; + } + return undefined; +} + +/** Extract a JSON Schema from a plugin's Zod-or-plain args map. */ +export function argsToJsonSchema(args: unknown): Record { + if (args == null || typeof args !== "object") + return { type: "object", properties: {}, required: [] }; + const entries = Object.entries(args as Record); + const allZod = entries.length > 0 && entries.every(([, v]) => isZodType(v)); + if (allZod) { + try { + // `tool.schema` is the same Zod instance opencode bundles plugins + // against, so `_zod`-shaped args always parse with it. zod v4 + // exposes toJSONSchema; keep the call dynamic so this module also + // typechecks against a zod v3 root (legacy path below covers it). + // SAFETY: `tool.schema` is always a Zod namespace object exposing + // `object()`; `toJSONSchema` is only present on Zod v4 builds, so + // the cast widens to a shape that makes both versions typecheck. + const zodLike = tool.schema as unknown as { + object: (shape: unknown) => unknown; + toJSONSchema?: (schema: unknown, opts?: unknown) => Record; + }; + if (typeof zodLike.toJSONSchema === "function") { + const schema = zodLike.toJSONSchema(zodLike.object(args), { + io: "input", + }); + return normalizeZodSchema(schema); + } + } catch { + // fall through to the legacy path + } + } + // Legacy: treat non-Zod entries as raw JSON Schema properties. + const properties: Record = {}; + for (const [key, value] of entries) { + if ( + typeof value === "boolean" || + (typeof value === "object" && value !== null && !Array.isArray(value)) + ) { + properties[key] = value; + } + } + return { type: "object", properties, required: Object.keys(properties) }; +} + +function isZodType(value: unknown): boolean { + return typeof value === "object" && value !== null && "_zod" in value; +} + +/** + * Zod v4 emits `$schema` and `definitions`/`$defs` blocks; Cursor's MCP + * layer only needs a plain object schema, so strip the meta fields and + * inline nothing (definitions are referenced by name and MCP accepts them). + */ +function normalizeZodSchema( + schema: Record, +): Record { + const out = { ...schema }; + delete out["$schema"]; + return out; +} + +/** Read the `tool` map from a loaded plugin module's hooks. */ +function extractToolMap( + hooks: unknown, +): Record | undefined { + if (!hooks || typeof hooks !== "object") return undefined; + const tool = (hooks as { tool?: unknown }).tool; + if (!tool || typeof tool !== "object") return undefined; + const out: Record = {}; + for (const [id, def] of Object.entries(tool as Record)) { + if (isPluginTool(def)) out[id] = def; + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function isPluginTool(value: unknown): value is ToolDefinition { + return ( + typeof value === "object" && + value !== null && + "args" in value && + "description" in value && + "execute" in value + ); +} + +/** + * Load one plugin module and read its `tool` map. The plugin's `server` + * function is invoked with a minimal input shaped like opencode's + * `PluginInput`; only fields the plugin touches at load time matter, and + * most read nothing until hook execution. Throws on import/init failure — + * callers collect the error. + */ +async function loadToolMap( + modulePath: string, + input: unknown, +): Promise | undefined> { + const mod = (await import(pathToFileURL(modulePath).href)) as Record< + string, + unknown + >; + // Preferred export shapes, in order: `server`, `default`, then any + // function export (some plugins export a single named factory). + const candidates = [mod["server"], mod["default"]]; + for (const value of Object.values(mod)) { + if (typeof value === "function" && !candidates.includes(value)) { + candidates.push(value); + } + } + for (const candidate of candidates) { + if (typeof candidate !== "function") continue; + let hooks: unknown; + try { + hooks = await candidate(input); + } catch { + continue; // try the next candidate shape + } + const tools = extractToolMap(hooks); + if (tools) return tools; + } + return undefined; +} + +/** + * Mirror the tool maps of every other plugin listed in `config.plugin`. + * + * Never throws: every failure is recorded in `failed` and the remaining + * plugins are still processed. The returned `execute` functions are the + * original closures from each plugin module; the caller supplies the + * `ToolContext` (with a working `ask`) at call time. + */ +export async function mirrorPluginTools( + config: Config | undefined, + input: unknown, + options?: MirrorPluginToolsOptions, +): Promise { + const failed: Record = {}; + const tools: MirroredTool[] = []; + const seen = new Set(); + + const specs = (config?.plugin ?? []) + .map((entry) => + typeof entry === "string" + ? entry + : Array.isArray(entry) + ? entry[0] + : undefined, + ) + .filter((s): s is string => typeof s === "string" && s.length > 0); + + const cacheRoot = options?.cacheRoot ?? opencodePackagesRoot(homedir()); + + for (const spec of specs) { + if (SELF_SPECS.has(spec)) continue; + const parsed = parsePluginSpec(spec); + if (!parsed) { + failed[spec] = "unsupported spec format"; + continue; + } + + if (parsed.kind === "unsupported") { + failed[spec] = + "unsupported spec format (URL tarball specs are not mirrored)"; + continue; + } + let modulePath: string | undefined; + if (parsed.kind === "path") { + modulePath = parsed.path.startsWith("/") ? parsed.path : undefined; + if (!modulePath || !existsSync(modulePath)) { + failed[spec] = "plugin file not found"; + continue; + } + } else { + const entry = resolveCacheEntry(cacheRoot, parsed); + if (!entry) { + failed[spec] = "not found in opencode package cache"; + continue; + } + const pkg = packageRoot(entry, parsed.name); + if (!pkg) { + failed[spec] = "package root not found in cache entry"; + continue; + } + // Resolve through the cache entry's package.json (main/exports) + // so bundled plugins load exactly where their manifest says. + const resolved = resolvePackageMain(entry, pkg, parsed.name); + if (!resolved) { + failed[spec] = "package entry point not found"; + continue; + } + modulePath = resolved; + } + + if (!modulePath) { + failed[spec] = "plugin module path not resolved"; + continue; + } + let toolMap: Record | undefined; + try { + toolMap = await loadToolMap(modulePath, input); + } catch (error) { + failed[spec] = error instanceof Error ? error.message : String(error); + continue; + } + if (!toolMap) { + failed[spec] = "no tool map exported"; + continue; + } + + for (const [id, def] of Object.entries(toolMap)) { + if (seen.has(id)) continue; + if (options?.exclude?.some((p) => matchPattern(p, id))) continue; + if ( + options?.include && + options.include.length > 0 && + !options.include.some((p) => matchPattern(p, id)) + ) { + continue; + } + seen.add(id); + tools.push({ + id, + description: def.description, + parameters: argsToJsonSchema(def.args), + execute: def.execute, + sourcePlugin: spec, + }); + } + } + + return { tools, failed }; +} + +/** Wildcard match: `*` = any sequence, otherwise literal. */ +function matchPattern(pattern: string, value: string): boolean { + if (pattern === "*") return true; + if (!pattern.includes("*")) return pattern === value; + const regex = pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*"); + return new RegExp(`^${regex}$`).test(value); +} diff --git a/src/plugin/plugin-tools-bridge.ts b/src/plugin/plugin-tools-bridge.ts new file mode 100644 index 0000000..f106f18 --- /dev/null +++ b/src/plugin/plugin-tools-bridge.ts @@ -0,0 +1,228 @@ +/** + * Localhost control channel + MCP wiring for the plugin-tools bridge. + * + * The host plugin owns the mirrored tool closures (see + * `plugin-tool-registry.ts`). The stdio MCP child + * (`sidecar/plugin-tools-mcp.mjs`) can't hold those closures, so it talks to + * this HTTP server on 127.0.0.1 for `tools/list` and `tools/call`. The server + * binds to loopback only and requires a bearer token (generated per session, + * passed to the child via env) so nothing else on the machine can invoke the + * user's plugin tools through it. + * + * Execution happens through the mirrored `execute` closures with a synthetic + * `ToolContext` whose `ask` delegates to the user's opencode permission gate + * (same `context.ask` pattern the delegation tools use), so a `permission` + * config entry for a tool id applies to Cursor-originated calls too. + */ +import { createServer, type Server } from "node:http"; +import { randomBytes } from "node:crypto"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import type { ToolContext } from "@opencode-ai/plugin"; +import type { MirroredTool } from "./plugin-tool-registry.js"; +import { pluginLog } from "../provider/log-bridge.js"; + +export interface PluginToolsBridge { + /** The MCP server config to hand to the Cursor agent, or undefined. */ + mcpServer?: { + type: "stdio"; + command: string; + args: string[]; + env: Record; + }; + /** Stop the control server (called on dispose). */ + close: () => Promise; +} + +/** + * Build the `ToolContext` handed to a mirrored tool's `execute`. `ask` + * delegates to the supplied gate so the user's opencode permission config + * applies; when no gate exists the call fails closed (matches the + * delegation-tool behaviour — never silently allow a sensitive action). + */ +function buildToolContext( + args: { sessionID: string; agent: string; directory: string }, + askGate?: ToolContext["ask"], +): ToolContext { + const controller = new AbortController(); + return { + sessionID: args.sessionID, + messageID: "cursor-plugin-tools", + agent: args.agent, + directory: args.directory, + worktree: args.directory, + abort: controller.signal, + metadata: () => {}, + ask: async (input) => { + if (!askGate) { + throw new Error( + "permission gate unavailable — refusing to run plugin tool without approval", + ); + } + await askGate(input); + }, + }; +} + +/** + * Locate the stdio MCP server script across dist/dev layouts (same pattern + * as `resolveSidecarScript` in provider/agent-backend.ts). + */ +export function resolvePluginToolsScript(): string | undefined { + const candidates = [ + "./plugin-tools-mcp.js", // importer is a chunk at dist root + "../sidecar/plugin-tools-mcp.js", // importer is dist/plugin/index.js + "../sidecar/plugin-tools-mcp.mjs", // importer is src/plugin/*.ts (dev/tests) + ]; + for (const candidate of candidates) { + const path = fileURLToPath(new URL(candidate, import.meta.url)); + if (existsSync(path)) return path; + } + return undefined; +} + +export interface StartBridgeOptions { + tools: MirroredTool[]; + /** Directory the mirrored tools should see as `context.directory`. */ + directory: string; + /** + * Permission gate for `context.ask`. When omitted, tools whose execution + * calls `ask` fail closed. + */ + askGate?: ToolContext["ask"]; + /** Session id stamped into the synthetic ToolContext. */ + sessionID?: string; + /** Agent name stamped into the synthetic ToolContext. */ + agent?: string; +} + +/** + * Start the localhost control server and build the MCP server config for the + * Cursor agent. Returns `{ close }` with no `mcpServer` when the script or a + * usable Node binary can't be found — the bridge degrades to "not offered" + * rather than failing plugin init. + */ +export async function startPluginToolsBridge( + options: StartBridgeOptions, +): Promise { + const scriptPath = resolvePluginToolsScript(); + if (!scriptPath) { + pluginLog("warn", "plugin-tools MCP script not found; bridge disabled"); + return { close: async () => {} }; + } + + const token = randomBytes(24).toString("hex"); + const toolById = new Map(options.tools.map((t) => [t.id, t])); + + const server: Server = createServer((req, res) => { + const send = (status: number, body: unknown) => { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); + }; + const auth = req.headers["authorization"]; + if (auth !== `Bearer ${token}`) { + send(401, { error: "unauthorized" }); + return; + } + if (req.method === "GET" && req.url === "/tools") { + send(200, { + tools: options.tools.map((t) => ({ + id: t.id, + description: t.description, + parameters: t.parameters, + })), + }); + return; + } + if (req.method === "POST" && req.url === "/call") { + // Cap request bodies: loopback + token limits the blast radius, but + // an unbounded accumulator would still let a caller exhaust memory. + const MAX_BODY = 10 * 1024 * 1024; + let raw = ""; + let size = 0; + req.on("data", (chunk) => { + size += chunk.length; + if (size > MAX_BODY) { + req.destroy(); + return; + } + raw += chunk; + }); + req.on("end", async () => { + if (size > MAX_BODY) return; // destroyed above + let body: { id?: string; args?: Record }; + try { + body = JSON.parse(raw); + } catch { + send(400, { ok: false, error: "invalid JSON body" }); + return; + } + const tool = body.id ? toolById.get(body.id) : undefined; + if (!tool) { + send(404, { ok: false, error: `unknown tool: ${body.id}` }); + return; + } + try { + const ctx = buildToolContext( + { + sessionID: options.sessionID ?? "cursor-plugin-tools", + agent: options.agent ?? "cursor", + directory: options.directory, + }, + options.askGate, + ); + const result = await tool.execute((body.args ?? {}) as never, ctx); + if (typeof result === "string") { + send(200, { ok: true, output: result }); + } else { + send(200, { + ok: true, + title: result.title, + output: result.output, + metadata: result.metadata, + }); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + send(200, { ok: false, error: message }); + } + }); + return; + } + send(404, { error: "not found" }); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : undefined; + if (!port) { + await new Promise((resolve) => server.close(() => resolve())); + pluginLog( + "warn", + "plugin-tools control server failed to bind; bridge disabled", + ); + return { close: async () => {} }; + } + + const nodePath = process.execPath; + return { + mcpServer: { + type: "stdio", + command: nodePath, + args: [scriptPath], + env: { + OPENCODE_PLUGIN_TOOLS_PORT: String(port), + OPENCODE_PLUGIN_TOOLS_TOKEN: token, + }, + }, + close: () => + new Promise((resolve) => { + server.close(() => resolve()); + // Force-close lingering keep-alive sockets so dispose doesn't hang. + server.closeAllConnections?.(); + }), + }; +} diff --git a/src/plugin/skill-discovery.ts b/src/plugin/skill-discovery.ts index c44bf67..97da14c 100644 --- a/src/plugin/skill-discovery.ts +++ b/src/plugin/skill-discovery.ts @@ -6,7 +6,13 @@ import { realpathSync, } from "node:fs"; import type { Dirent } from "node:fs"; -import { join, relative, dirname, resolve as resolvePath, isAbsolute } from "node:path"; +import { + join, + relative, + dirname, + resolve as resolvePath, + isAbsolute, +} from "node:path"; import { homedir } from "node:os"; import { execSync } from "node:child_process"; import type { Config } from "@opencode-ai/plugin"; @@ -42,9 +48,10 @@ export interface SkillFilterOptions { // --- Frontmatter parsing --- /** Parse the small recognised frontmatter field set (name, description). */ -function parseFrontmatter( - content: string, -): { name?: string; description?: string } { +function parseFrontmatter(content: string): { + name?: string; + description?: string; +} { if (!content.startsWith("---")) return {}; const end = content.indexOf("\n---", 3); if (end === -1) return {}; @@ -78,6 +85,172 @@ const SKILL_DIR_NAMES = ["skill", "skills"]; /** External (non-opencode) config roots that contain a `skills/` subdir. */ const EXTERNAL_DIR_NAMES = [".claude", ".agents"]; +/** + * Directory names, inside each opencode config root, that may hold file-based + * plugins (`.ts`). Skills bundled alongside file plugins live in sibling + * skill dirs. + */ +const FILE_PLUGIN_DIR_NAMES = ["plugin", "plugins"]; + +/** + * Directory names under each opencode plugin package root that may contain + * skills. Both spellings are accepted because the repo's general skill scans + * use `skill/` and `skills/` interchangeably. + */ +const PLUGIN_SKILL_DIR_NAMES = ["skills", "skill"]; + +/** + * Resolve the root where opencode caches installed plugin packages: plugins + * listed in `plugin: []` are installed here (npm or git specs). Skills bundled + * inside such a package live under `node_modules//skills/`. + * + * Layout matches opencode's own cache location logic and mirrors the + * existing helper in `version-check.ts` (`PLUGIN_CACHE_PATH`). + */ +export function opencodePackagesRoot(home = homedir()): string { + if (process.platform === "win32") { + return join( + process.env.LocalAppData ?? join(home, "AppData", "Local"), + "opencode", + "cache", + "packages", + ); + } + return join( + process.env.XDG_CACHE_HOME ?? join(home, ".cache"), + "opencode", + "packages", + ); +} + +/** + * Collect the `skills/`-style directories inside a cache entry. Handles the + * layouts observed in real caches: + * + * - flat packages: `/node_modules//skills/` + * - scoped packages: `/node_modules/@scope//skills/` + * - git specs: the spec dir nests (`spec@git+https:/github.com/owner/repo.git`) + * before the `node_modules` install dir; found by a bounded + * downward walk. + * + * Follows symlinks (real caches symlink the installed package into + * node_modules). Never throws. + */ +export function pluginCacheSkillDirs(entry: string): string[] { + const dirs: string[] = []; + const visited = new Set(); + + /** Scan one `node_modules` dir: each child is a package root. */ + function scanNodeModules(nodeModules: string): void { + let entries: Dirent[]; + try { + entries = readdirSync(nodeModules, { withFileTypes: true }); + } catch { + return; + } + for (const ent of entries) { + if (ent.name === ".bin") continue; + const fullPath = join(nodeModules, ent.name); + if (entryKind(ent, fullPath) !== "dir") continue; + if (ent.name.startsWith("@")) { + // Scope dir: its children are package roots. + scanNodeModules(fullPath); + continue; + } + for (const skillName of SKILL_DIR_NAMES) { + const candidate = join(fullPath, skillName); + if (existsSync(candidate)) dirs.push(candidate); + } + } + } + + /** Walk down from the entry dir (bounded) to find `node_modules`. */ + function findNodeModules(dir: string, depth: number): void { + if (depth > 5) return; + let realDir: string; + try { + realDir = realpathSync(dir); + } catch { + return; + } + if (visited.has(realDir)) return; + visited.add(realDir); + const nm = join(dir, "node_modules"); + if (existsSync(nm)) { + scanNodeModules(nm); + return; + } + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const ent of entries) { + const fullPath = join(dir, ent.name); + if (entryKind(ent, fullPath) === "dir") { + findNodeModules(fullPath, depth + 1); + } + } + } + + findNodeModules(entry, 0); + return dirs; +} + +/** + * Enumerate every plugin cache entry that may contain skills. Each entry is a + * directory in the opencode packages root (npm specs like `name@latest` or + * `@scope/name@latest`, git specs like `superpowers@git+https:...`, or bare + * dirs). Top-level `node_modules` and package/lock files are skipped; the + * remaining dirs are scanned for `skills/` regardless — non-plugin cache + * entries (language servers, formatters) simply have none, and the README + * documents that the cache also holds such tooling. + */ +export function pluginCacheEntries(root: string): string[] { + let entries: Dirent[]; + try { + entries = readdirSync(root, { withFileTypes: true }); + } catch { + return []; + } + const skip = new Set([ + "node_modules", + "package.json", + "package-lock.json", + "bun.lock", + "bun.lockb", + ]); + const out: string[] = []; + for (const ent of entries) { + if (skip.has(ent.name)) continue; + const full = join(root, ent.name); + if (entryKind(ent, full) !== "dir") continue; + out.push(full); + } + return out; +} + +/** + * Discover `skills/` dirs that ship inside opencode's plugin cache. Lowest + * priority source: project/global/configured paths always win on duplicate ids + * (first-wins ordering in {@link discoverSkills}). + */ +export function discoverPluginSkillDirs( + cacheRoot?: string, + home?: string, +): string[] { + const root = cacheRoot ?? opencodePackagesRoot(home); + if (!existsSync(root)) return []; + const dirs: string[] = []; + for (const entry of pluginCacheEntries(root)) { + for (const skillDir of pluginCacheSkillDirs(entry)) { + dirs.push(skillDir); + } + } + return dirs; +} + /** * Find the git worktree root by walking up from `cwd`. Falls back to `cwd` * itself when not in a git repo (so a non-git project still discovers skills @@ -98,10 +271,7 @@ function worktreeRoot(cwd: string): string { } /** Walk up from `start` to `stop` (inclusive), yielding each directory. */ -function* walkUp( - start: string, - stop: string, -): Generator { +function* walkUp(start: string, stop: string): Generator { let current = start; while (current) { yield current; @@ -113,9 +283,7 @@ function* walkUp( } /** List immediate subdirectories of `dir` that contain a `SKILL.md`. */ -function scanSkillDir( - dir: string, -): Array<{ id: string; sourceDir: string }> { +function scanSkillDir(dir: string): Array<{ id: string; sourceDir: string }> { if (!existsSync(dir)) return []; let entries: Dirent[]; try { @@ -196,10 +364,7 @@ function collectFiles(sourceDir: string): string[] { } /** Load and parse a single skill from its source directory. */ -function loadSkill( - id: string, - sourceDir: string, -): DiscoveredSkill | undefined { +function loadSkill(id: string, sourceDir: string): DiscoveredSkill | undefined { const skillMdPath = join(sourceDir, "SKILL.md"); let content: string; try { @@ -225,7 +390,11 @@ function loadSkill( * home, relative paths → resolved against the project directory, absolute * paths used as-is. Returns undefined for empty input. */ -function expandSkillPath(raw: string, cwd: string, home: string): string | undefined { +function expandSkillPath( + raw: string, + cwd: string, + home: string, +): string | undefined { const trimmed = raw.trim(); if (!trimmed) return undefined; if (trimmed.startsWith("~/")) return join(home, trimmed.slice(2)); @@ -233,6 +402,64 @@ function expandSkillPath(raw: string, cwd: string, home: string): string | undef return resolvePath(cwd, trimmed); } +/** + * Collect skill directories that ship alongside file-based plugins — single + * `.ts` files under `/plugin/` or `/plugins/` in each opencode + * config root. A file plugin can bundle skills in a sibling `skills/` or + * `skill/` dir (checked directly, not per-file) — see + * {@link PLUGIN_SKILL_DIR_NAMES}. + */ +export function discoverFilePluginSkillDirs(roots: string[]): string[] { + const dirs: string[] = []; + for (const root of roots) { + for (const sub of FILE_PLUGIN_DIR_NAMES) { + const pluginDir = join(root, sub); + if (!existsSync(pluginDir)) continue; + for (const skillName of PLUGIN_SKILL_DIR_NAMES) { + const skillDir = join(pluginDir, skillName); + if (existsSync(skillDir)) dirs.push(skillDir); + } + } + } + return dirs; +} + +/** + * Locate plugin-bundled skill sources for the current install. + * + * Returns discovery options for {@link discoverSkills}: the opencode plugin + * cache root, plus any config roots that actually contain file-plugin sibling + * skill dirs (`skills/` or `skill/`, per {@link PLUGIN_SKILL_DIR_NAMES}). + * File-plugin roots are checked cheaply (just an existence test per candidate) + * so the default scan stays fast even when most users have no file-plugin + * skills. Never throws — fs errors degrade to cache-only discovery. + */ +export function resolvePluginSkillSources(cwd?: string): { + cacheRoot?: string; + filePluginRoots?: string[]; +} { + const home = homedir(); + const cacheRoot = opencodePackagesRoot(home); + let filePluginRoots: string[] = []; + try { + const candidates: string[] = []; + const start = cwd ?? process.cwd(); + const stop = worktreeRoot(start); + for (const ancestor of walkUp(start, stop)) { + // File plugins live in the `.opencode` config root of each project. + candidates.push(join(ancestor, ".opencode")); + } + const xdgConfig = process.env["XDG_CONFIG_HOME"] || join(home, ".config"); + candidates.push(join(xdgConfig, "opencode"), home); + filePluginRoots = discoverFilePluginSkillDirs(candidates) + .map((dir) => dirname(dirname(dir))) + .filter((v, i, arr) => arr.indexOf(v) === i); + } catch { + // Degrade to cache-only discovery. + } + return { cacheRoot, filePluginRoots }; +} + /** * Discover skills from the filesystem, using a deterministic resolution * order that prioritises specificity: project beats global, nearer beats @@ -245,7 +472,10 @@ function expandSkillPath(raw: string, cwd: string, home: string): string | undef * 3. Global `~/.config/opencode/skill/`, `~/.config/opencode/skills/` * 4. Global `~/.claude/skills/`, `~/.agents/skills/` * 5. `~/.opencode/skill/`, `~/.opencode/skills/` (if `~/.opencode` exists) - * 6. Extra paths from `config.skills.paths` (lowest priority, first-wins) + * 6. Extra paths from `config.skills.paths` + * 7. Skills bundled inside installed opencode plugins — the opencode plugin + * cache (`opencodePackagesRoot()`), plus file-plugin sibling dirs + * (`~/.config/opencode/plugins/` etc). Lowest priority. * * This differs from opencode's own resolution, which loads concurrently with * unbounded concurrency (making "last wins" non-deterministic). We use @@ -259,10 +489,10 @@ function expandSkillPath(raw: string, cwd: string, home: string): string | undef export function discoverSkills( cwd: string, extraPaths?: string[], + options?: { cacheRoot?: string; filePluginRoots?: string[] }, ): DiscoveredSkill[] { const home = homedir(); - const xdgConfig = - process.env["XDG_CONFIG_HOME"] || join(home, ".config"); + const xdgConfig = process.env["XDG_CONFIG_HOME"] || join(home, ".config"); const stop = worktreeRoot(cwd); // Build the scan list in specificity order (first wins). @@ -300,7 +530,7 @@ export function discoverSkills( } } - // 6. Extra paths from config.skills.paths (lowest priority) + // 6. Extra paths from config.skills.paths if (extraPaths) { for (const raw of extraPaths) { const expanded = expandSkillPath(raw, cwd, home); @@ -310,6 +540,36 @@ export function discoverSkills( } } + // 7. Plugin-bundled skills (lowest priority): the opencode plugin cache, + // then sibling skill dirs of file-based plugins. File-plugin roots follow + // the same specificity order as other project dirs (walk-up near→far, then + // global), so a project's local file plugins are found before global ones. + for (const dir of discoverPluginSkillDirs(options?.cacheRoot, home)) { + scanRoots.push(dir); + } + const filePluginRoots = options?.filePluginRoots; + if (filePluginRoots) { + for (const dir of discoverFilePluginSkillDirs(filePluginRoots)) { + scanRoots.push(dir); + } + } else { + // Project config roots (near→far), then global: same specificity order + // as the other project skill scans. + for (const ancestor of walkUp(cwd, stop)) { + for (const dir of discoverFilePluginSkillDirs([ + join(ancestor, ".opencode"), + ])) { + scanRoots.push(dir); + } + } + for (const dir of discoverFilePluginSkillDirs([ + join(xdgConfig, "opencode"), + home, + ])) { + scanRoots.push(dir); + } + } + // Scan in order, first wins on duplicate id (skip if already seen). const byId = new Map(); for (const dir of scanRoots) { @@ -331,7 +591,9 @@ function wildcardMatch(pattern: string, value: string): boolean { if (pattern === "*") return true; if (!pattern.includes("*")) return pattern === value; // Convert glob to regex: escape everything except *, replace * with .* - const regex = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); + const regex = pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*"); return new RegExp(`^${regex}$`).test(value); } @@ -406,7 +668,7 @@ export function filterSkills( permission: string; pattern: string; action: string; - }>) + }>) : undefined; // Also check the V2 PermissionRuleset form (config.permission as array). @@ -415,7 +677,7 @@ export function filterSkills( permission: string; pattern: string; action: string; - }>) + }>) : undefined; const permitted: DiscoveredSkill[] = []; @@ -470,6 +732,36 @@ export function filterSkills( return { skills: permitted, withheld }; } +/** + * A skill as reported by opencode's live `app.skills` endpoint. + * `location` is the absolute path of the skill's SKILL.md. + */ +export interface LiveSkill { + name: string; + description?: string; + location: string; +} + +/** + * Convert live `app.skills` entries into {@link DiscoveredSkill}s, pointing + * at each skill's on-disk directory (derived from `location`). Entries whose + * location can't be resolved are skipped — the filesystem scan already + * covers anything reachable. + */ +export function liveSkillsToDiscovered(live: LiveSkill[]): DiscoveredSkill[] { + const out: DiscoveredSkill[] = []; + for (const skill of live) { + if (!skill.location) continue; + const sourceDir = skill.location.endsWith("SKILL.md") + ? dirname(skill.location) + : skill.location; + if (!existsSync(join(sourceDir, "SKILL.md"))) continue; + const loaded = loadSkill(skill.name, sourceDir); + if (loaded) out.push(loaded); + } + return out; +} + /** * Discover and filter skills in one call. This is the main entry point for the * plugin's config and chat.params hooks. Never throws — fs errors degrade to @@ -478,15 +770,27 @@ export function filterSkills( * `config.skills.paths` is extracted and passed to {@link discoverSkills} as * `extraPaths`, so skills configured via the `skills.paths` config option are * included in the mirror (lowest priority, first-wins). + * + * When `liveSkills` is supplied (from opencode's `app.skills` endpoint), those + * skills are merged in at the LOWEST priority — the filesystem scan wins on + * duplicate ids, but anything opencode knows about that the scan missed + * (e.g. skills sourced from locations this mirror doesn't scan) still reaches + * the Cursor agent. */ export function resolveSkills( cwd: string, config?: Config, options?: SkillFilterOptions, + discoveryOptions?: { + cacheRoot?: string; + filePluginRoots?: string[]; + liveSkills?: LiveSkill[]; + }, ): ResolvedSkills { - // Extract skills.paths from the config (untyped — the V1 Config type - // doesn't include the `skills` field, but the live config returned by - // client.config.get() does). + // SAFETY: `config` at runtime is the live opencode config JSON returned by + // client.config.get(); its shape always carries a `skills` object when the + // user configured one. The V1 Config type just omits that field, so we + // widen it here. Access is optional-chain guarded below. const skillsConfig = config as unknown as | { skills?: { paths?: string[] } } | undefined; @@ -494,10 +798,18 @@ export function resolveSkills( let discovered: DiscoveredSkill[]; try { - discovered = discoverSkills(cwd, extraPaths); + discovered = discoverSkills(cwd, extraPaths, discoveryOptions); } catch { discovered = []; } + if (discoveryOptions?.liveSkills?.length) { + const seen = new Set(discovered.map((s) => s.id)); + for (const skill of liveSkillsToDiscovered(discoveryOptions.liveSkills)) { + if (seen.has(skill.id)) continue; + seen.add(skill.id); + discovered.push(skill); + } + } return filterSkills(discovered, config, options); } diff --git a/src/sidecar/plugin-tools-mcp.mjs b/src/sidecar/plugin-tools-mcp.mjs new file mode 100644 index 0000000..78bbeda --- /dev/null +++ b/src/sidecar/plugin-tools-mcp.mjs @@ -0,0 +1,183 @@ +/** + * stdio MCP server that exposes opencode plugins' custom tools to the Cursor + * agent. Spawned by the plugin with `OPENCODE_PLUGIN_TOOLS_TOKEN` in env; it + * talks to the host plugin over a localhost HTTP control channel (also + * token-authenticated) that owns the real tool closures. + * + * Wire protocol (control channel): + * GET /tools → { tools: [{id, description, parameters}] } + * POST /call {id, args} → { ok, title?, output?, error? } + * + * Every `tools/call` from Cursor becomes one POST /call; the host executes + * the mirrored tool with a permission-gated ToolContext and returns the + * result. Plain JSON-lines stdio MCP on the other side (see run()). + * + * Kept as plain .mjs so tests can spawn it pre-build; tsup bundles it to + * dist/sidecar/plugin-tools-mcp.js for production. + */ +import { createInterface } from "node:readline"; + +const CONTROL_PORT = Number(process.env.OPENCODE_PLUGIN_TOOLS_PORT ?? 0); +const TOKEN = process.env.OPENCODE_PLUGIN_TOOLS_TOKEN ?? ""; +const SERVER_NAME = "opencode-plugin-tools"; +const SERVER_VERSION = "1.0.0"; +// The single protocol version this server implements. Never echo the +// client-proposed version — MCP servers must answer with a version they +// actually support. +const PROTOCOL_VERSION = "2025-06-18"; + +function logErr(message, extra) { + try { + process.stderr.write( + `[plugin-tools-mcp] ${message}${extra ? ` ${JSON.stringify(extra)}` : ""}\n`, + ); + } catch { + // never throw from logging + } +} + +async function controlRequest(path, body) { + const res = await fetch(`http://127.0.0.1:${CONTROL_PORT}${path}`, { + method: body ? "POST" : "GET", + headers: { + "content-type": "application/json", + authorization: `Bearer ${TOKEN}`, + }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let json; + try { + json = JSON.parse(text); + } catch { + json = undefined; + } + if (!res.ok) { + const message = json?.error ?? `control channel ${res.status}`; + throw new Error(message); + } + return json; +} + +async function listTools() { + try { + const data = await controlRequest("/tools"); + return data?.tools ?? []; + } catch (err) { + logErr("tools/list failed", { error: String(err) }); + return []; + } +} + +async function callTool(name, args) { + try { + const data = await controlRequest("/call", { id: name, args: args ?? {} }); + if (data?.ok === false) { + return { + isError: true, + content: [{ type: "text", text: data.error ?? "tool call failed" }], + }; + } + const output = data?.output ?? ""; + const title = data?.title ? `${data.title}\n\n` : ""; + return { content: [{ type: "text", text: `${title}${output}` }] }; + } catch (err) { + return { isError: true, content: [{ type: "text", text: String(err) }] }; + } +} + +/** Handle one MCP request and return the response payload (or undefined for notifications). */ +async function handle(msg) { + const { id, method, params } = msg; + const reply = (result) => ({ jsonrpc: "2.0", id, result }); + const error = (code, message) => ({ + jsonrpc: "2.0", + id, + error: { code, message }, + }); + + switch (method) { + case "initialize": + return reply({ + protocolVersion: PROTOCOL_VERSION, + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }, + instructions: + "Tools provided by opencode plugins, bridged into the Cursor agent. " + + "Each call runs the plugin's real implementation inside opencode's runtime.", + }); + case "notifications/initialized": + return undefined; + case "ping": + return reply({}); + case "tools/list": { + const tools = await listTools(); + return reply({ + tools: tools.map((t) => ({ + name: t.id, + description: t.description, + inputSchema: t.parameters ?? { type: "object", properties: {} }, + })), + }); + } + case "tools/call": { + const result = await callTool(params?.name, params?.arguments); + return reply(result); + } + default: + if (id === undefined) return undefined; // unknown notification + return error(-32601, `method not found: ${method}`); + } +} + +function run() { + if (!CONTROL_PORT || !TOKEN) { + logErr("missing OPENCODE_PLUGIN_TOOLS_PORT/TOKEN env; exiting"); + process.exit(1); + } + const rl = createInterface({ input: process.stdin, terminal: false }); + rl.on("line", async (line) => { + const trimmed = line.trim(); + if (!trimmed) return; + let msg; + try { + msg = JSON.parse(trimmed); + } catch { + process.stdout.write( + JSON.stringify({ + jsonrpc: "2.0", + id: null, + error: { code: -32700, message: "parse error" }, + }) + "\n", + ); + return; + } + try { + const response = await handle(msg); + if (response !== undefined) { + process.stdout.write(JSON.stringify(response) + "\n"); + } + } catch (err) { + if (msg.id !== undefined) { + process.stdout.write( + JSON.stringify({ + jsonrpc: "2.0", + id: msg.id, + error: { code: -32603, message: String(err) }, + }) + "\n", + ); + } + } + }); + rl.on("close", () => process.exit(0)); + // Keep the process alive until stdin closes (Cursor owns the lifecycle). + process.stdin.resume(); +} + +// Start the stdio loop only when spawned as the entry script (Cursor owns +// the lifecycle). Tests import the handlers above without triggering it. +import { fileURLToPath } from "node:url"; +const scriptFile = fileURLToPath(import.meta.url); +if (process.argv[1] === scriptFile) run(); + +export { handle, listTools, callTool }; diff --git a/test/plugin-tools-bridge.test.ts b/test/plugin-tools-bridge.test.ts new file mode 100644 index 0000000..e0bd0c2 --- /dev/null +++ b/test/plugin-tools-bridge.test.ts @@ -0,0 +1,447 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { spawn } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + parsePluginSpec, + resolveCacheEntry, + argsToJsonSchema, + mirrorPluginTools, +} from "../src/plugin/plugin-tool-registry.js"; +import { + resolvePluginToolsScript, + startPluginToolsBridge, +} from "../src/plugin/plugin-tools-bridge.js"; +import type { Config } from "@opencode-ai/plugin"; +import type { MirroredTool } from "../src/plugin/plugin-tool-registry.js"; + +// --- spec parsing / cache resolution --- + +describe("parsePluginSpec", () => { + it("parses bare npm names", () => { + expect(parsePluginSpec("opencode-pty")).toEqual({ + kind: "npm", + name: "opencode-pty", + }); + }); + it("parses @latest specs", () => { + expect(parsePluginSpec("opencode-pty@latest")).toEqual({ + kind: "npm", + name: "opencode-pty", + version: "latest", + }); + }); + it("parses scoped specs", () => { + expect(parsePluginSpec("@tarquinen/opencode-dcp@latest")).toEqual({ + kind: "npm", + name: "@tarquinen/opencode-dcp", + version: "latest", + }); + }); + it("parses git specs", () => { + const parsed = parsePluginSpec( + "superpowers@git+https://github.com/obra/superpowers.git", + ); + expect(parsed).toMatchObject({ + kind: "git", + name: "superpowers", + }); + }); + it("parses file paths", () => { + expect(parsePluginSpec("./local-plugin.ts")).toMatchObject({ + kind: "path", + }); + expect(parsePluginSpec("~/global-plugin.js")).toMatchObject({ + kind: "path", + }); + }); + it("marks URL tarball specs unsupported (not git)", () => { + expect(parsePluginSpec("pkg@https://registry.example.com/pkg.tgz")).toEqual({ + kind: "unsupported", + raw: "pkg@https://registry.example.com/pkg.tgz", + }); + }); +}); + +describe("resolveCacheEntry", () => { + const dirs: string[] = []; + function tmp(): string { + const d = mkdtempSync(join(tmpdir(), "pty-cache-")); + dirs.push(d); + return d; + } + afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); + }); + + it("finds name@latest entries", async () => { + const { mkdirSync } = await import("node:fs"); + const root = tmp(); + mkdirSync(join(root, "opencode-pty@latest"), { recursive: true }); + const entry = resolveCacheEntry(root, { + kind: "npm", + name: "opencode-pty", + version: "latest", + }); + expect(entry).toBe(join(root, "opencode-pty@latest")); + }); + + it("falls back to bare-name entries", async () => { + const { mkdirSync } = await import("node:fs"); + const root = tmp(); + mkdirSync(join(root, "opencode-pty"), { recursive: true }); + const entry = resolveCacheEntry(root, { + kind: "npm", + name: "opencode-pty", + }); + expect(entry).toBe(join(root, "opencode-pty")); + }); + + it("returns undefined when the entry is missing", () => { + const root = tmp(); + expect( + resolveCacheEntry(root, { kind: "npm", name: "nope" }), + ).toBeUndefined(); + }); +}); + +// --- schema extraction --- + +describe("argsToJsonSchema", () => { + it("handles null/undefined args", () => { + const schema = argsToJsonSchema(undefined); + expect(schema).toMatchObject({ type: "object", properties: {} }); + }); + it("handles plain JSON-schema args (legacy path)", () => { + const schema = argsToJsonSchema({ + command: { type: "string", description: "the command" }, + }); + expect(schema).toMatchObject({ + type: "object", + properties: { command: { type: "string" } }, + }); + }); + it("converts zod v4 args (bundled with @opencode-ai/plugin)", async () => { + const { tool } = await import("@opencode-ai/plugin"); + const def = tool({ + description: "t", + args: { name: tool.schema.string() }, + execute: async () => "ok", + }); + const schema = argsToJsonSchema(def.args); + expect(schema.type).toBe("object"); + expect((schema.properties as Record)["name"]).toBeDefined(); + }); +}); + +// --- mirrorPluginTools end-to-end against a fake plugin --- + +describe("mirrorPluginTools", () => { + const dirs: string[] = []; + function tmp(): string { + const d = mkdtempSync(join(tmpdir(), "pty-mirror-")); + dirs.push(d); + return d; + } + afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); + }); + + async function writeFakePlugin(cacheRoot: string): Promise { + const { mkdirSync, writeFileSync } = await import("node:fs"); + const pkgDir = join( + cacheRoot, + "fake-plugin@latest", + "node_modules", + "fake-plugin", + ); + mkdirSync(pkgDir, { recursive: true }); + writeFileSync( + join(pkgDir, "index.js"), + ` +const server = async () => ({ + tool: { + fake_echo: { + description: "Echoes input.", + args: { text: { type: "string" } }, + execute: async (args) => ({ title: "echo", output: "echo:" + args.text }), + }, + }, +}); +export default server; +`, + "utf8", + ); + writeFileSync( + join(pkgDir, "package.json"), + JSON.stringify({ name: "fake-plugin", version: "1.0.0", type: "module" }), + "utf8", + ); + } + + it("mirrors a plugin's tool map from the cache", async () => { + const cacheRoot = tmp(); + await writeFakePlugin(cacheRoot); + const config = { plugin: ["fake-plugin@latest"] } as unknown as Config; + const result = await mirrorPluginTools(config, {}, { cacheRoot }); + expect(result.tools.map((t) => t.id)).toEqual(["fake_echo"]); + expect(result.tools[0]!.sourcePlugin).toBe("fake-plugin@latest"); + expect(result.failed).toEqual({}); + }); + + it("skips itself and records failures for missing plugins", async () => { + const cacheRoot = tmp(); + await writeFakePlugin(cacheRoot); + const config = { + plugin: [ + "@stablekernel/opencode-cursor@latest", + "missing-plugin@latest", + "fake-plugin@latest", + ], + } as unknown as Config; + const result = await mirrorPluginTools(config, {}, { cacheRoot }); + expect(result.tools.map((t) => t.id)).toEqual(["fake_echo"]); + expect(result.failed["@stablekernel/opencode-cursor@latest"]).toBeUndefined(); + expect(result.failed["missing-plugin@latest"]).toBeDefined(); + }); + + it("applies include/exclude filters", async () => { + const cacheRoot = tmp(); + await writeFakePlugin(cacheRoot); + const config = { plugin: ["fake-plugin@latest"] } as unknown as Config; + + const excluded = await mirrorPluginTools( + config, + {}, + { + cacheRoot, + exclude: ["fake_*"], + }, + ); + expect(excluded.tools).toHaveLength(0); + + const included = await mirrorPluginTools( + config, + {}, + { + cacheRoot, + include: ["fake_echo"], + }, + ); + expect(included.tools).toHaveLength(1); + }); + + it("executes mirrored tools and propagates results", async () => { + const cacheRoot = tmp(); + await writeFakePlugin(cacheRoot); + const config = { plugin: ["fake-plugin@latest"] } as unknown as Config; + const result = await mirrorPluginTools(config, {}, { cacheRoot }); + const toolDef = result.tools[0]!; + // SAFETY: the fake tool ignores its context; cast avoids constructing + // a full ToolContext for a unit test. + const out = await toolDef.execute({ text: "hi" }, {} as never); + expect(out).toMatchObject({ title: "echo", output: "echo:hi" }); + }); +}); + +// --- bridge: control server + MCP child round-trip --- + +describe("plugin-tools bridge", () => { + const dirs: string[] = []; + function tmp(): string { + const d = mkdtempSync(join(tmpdir(), "pty-bridge-")); + dirs.push(d); + return d; + } + afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); + }); + + function fakeTool(id: string, opts?: { ask?: boolean }): MirroredTool { + return { + id, + description: `${id} description`, + parameters: { type: "object", properties: { text: { type: "string" } } }, + execute: async (args, ctx) => { + if (opts?.ask) { + // SAFETY: the bridge only ever invokes `ask` with the four fields + // opencode's AskInput defines; the cast keeps the test free of a + // full ToolContext without loosening the module under test. + const ask = ctx.ask as (input: { + permission: string; + patterns: string[]; + always: string[]; + metadata: Record; + }) => Promise; + await ask({ + permission: id, + patterns: ["*"], + always: ["*"], + metadata: {}, + }); + } + return { + title: `${id} ran`, + output: `out:${(args as { text?: string }).text ?? ""}`, + }; + }, + sourcePlugin: "fake@latest", + }; + } + + it("resolves the MCP script in the src layout", () => { + expect(resolvePluginToolsScript()).toMatch(/plugin-tools-mcp\.mjs$/); + }); + + it("serves tools/list and tools/call through the MCP child", async () => { + const bridge = await startPluginToolsBridge({ + tools: [fakeTool("fake_echo")], + directory: tmp(), + }); + try { + expect(bridge.mcpServer).toBeDefined(); + const { command, args, env } = bridge.mcpServer!; + const child = spawn(command, args ?? [], { + env: { ...process.env, ...env }, + stdio: ["pipe", "pipe", "pipe"], + }); + + const responses = new Map(); + let buf = ""; + child.stdout!.on("data", (chunk: Buffer) => { + buf += chunk.toString(); + const lines = buf.split("\n"); + buf = lines.pop() ?? ""; + for (const line of lines) { + if (!line.trim()) continue; + const msg = JSON.parse(line) as { id?: number }; + if (msg.id !== undefined) responses.set(msg.id, msg); + } + }); + + const send = (msg: unknown) => + child.stdin!.write(JSON.stringify(msg) + "\n"); + const waitFor = async (id: number) => { + for (let i = 0; i < 100; i++) { + if (responses.has(id)) return responses.get(id); + await new Promise((r) => setTimeout(r, 20)); + } + throw new Error(`no response for id ${id}`); + }; + + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); + const init = (await waitFor(1)) as { + result: { serverInfo: { name: string } }; + }; + expect(init.result.serverInfo.name).toBe("opencode-plugin-tools"); + + send({ jsonrpc: "2.0", id: 2, method: "tools/list" }); + const list = (await waitFor(2)) as { + result: { tools: Array<{ name: string; inputSchema: unknown }> }; + }; + expect(list.result.tools.map((t) => t.name)).toEqual(["fake_echo"]); + expect(list.result.tools[0]!.inputSchema).toMatchObject({ + type: "object", + }); + + send({ + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "fake_echo", arguments: { text: "abc" } }, + }); + const call = (await waitFor(3)) as { + result: { content: Array<{ text: string }>; isError?: boolean }; + }; + expect(call.result.isError).toBeFalsy(); + expect(call.result.content[0]!.text).toContain("out:abc"); + + // Unknown tool → error result, not a crash. + send({ + jsonrpc: "2.0", + id: 4, + method: "tools/call", + params: { name: "nope", arguments: {} }, + }); + const unknown = (await waitFor(4)) as { + result: { isError: boolean; content: Array<{ text: string }> }; + }; + expect(unknown.result.isError).toBe(true); + expect(unknown.result.content[0]!.text).toMatch(/unknown tool/); + + child.kill(); + } finally { + await bridge.close(); + } + }); + + it("rejects control-channel requests without the token", async () => { + const bridge = await startPluginToolsBridge({ + tools: [fakeTool("fake_echo")], + directory: tmp(), + }); + try { + const port = Number(bridge.mcpServer!.env["OPENCODE_PLUGIN_TOOLS_PORT"]); + const res = await fetch(`http://127.0.0.1:${port}/tools`); + expect(res.status).toBe(401); + } finally { + await bridge.close(); + } + }); + + it("propagates permission failures as call errors", async () => { + const bridge = await startPluginToolsBridge({ + tools: [fakeTool("gated", { ask: true })], + directory: tmp(), + // No askGate → fail closed. + }); + try { + const port = Number(bridge.mcpServer!.env["OPENCODE_PLUGIN_TOOLS_PORT"]); + const res = await fetch(`http://127.0.0.1:${port}/call`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${bridge.mcpServer!.env["OPENCODE_PLUGIN_TOOLS_TOKEN"]}`, + }, + body: JSON.stringify({ id: "gated", args: { text: "x" } }), + }); + const body = (await res.json()) as { ok: boolean; error?: string }; + expect(body.ok).toBe(false); + expect(body.error).toMatch(/permission gate unavailable/); + } finally { + await bridge.close(); + } + }); + + it("runs ask-gated tools when the gate approves", async () => { + const asked: Array<{ permission: string }> = []; + const bridge = await startPluginToolsBridge({ + tools: [fakeTool("gated", { ask: true })], + directory: tmp(), + askGate: async (req) => { + asked.push({ permission: req.permission }); + }, + }); + try { + const port = Number(bridge.mcpServer!.env["OPENCODE_PLUGIN_TOOLS_PORT"]); + const res = await fetch(`http://127.0.0.1:${port}/call`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${bridge.mcpServer!.env["OPENCODE_PLUGIN_TOOLS_TOKEN"]}`, + }, + body: JSON.stringify({ id: "gated", args: { text: "ok" } }), + }); + const body = (await res.json()) as { ok: boolean; output?: string }; + expect(body.ok).toBe(true); + expect(body.output).toBe("out:ok"); + expect(asked).toEqual([{ permission: "gated" }]); + } finally { + await bridge.close(); + } + }); +}); + +// --- full plugin wiring: config hook merges the bridge into mcpServers --- diff --git a/test/plugin-tools-wiring.test.ts b/test/plugin-tools-wiring.test.ts new file mode 100644 index 0000000..15c3d7c --- /dev/null +++ b/test/plugin-tools-wiring.test.ts @@ -0,0 +1,263 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync } 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: async () => ({ models: [], source: "fallback" }), + toOpencodeModels: () => ({}), +})); + +describe("CursorPlugin plugin-tools wiring", () => { + const dirs: string[] = []; + function tmp(): string { + const d = mkdtempSync(join(tmpdir(), "pty-wire-")); + dirs.push(d); + return d; + } + afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); + }); + + async function writeToolPlugin(cacheRoot: string): Promise { + const { mkdirSync, writeFileSync } = await import("node:fs"); + const pkgDir = join( + cacheRoot, + "wire-plugin@latest", + "node_modules", + "wire-plugin", + ); + mkdirSync(pkgDir, { recursive: true }); + writeFileSync( + join(pkgDir, "package.json"), + JSON.stringify({ + name: "wire-plugin", + version: "1.0.0", + type: "module", + main: "./index.js", + }), + "utf8", + ); + writeFileSync( + join(pkgDir, "index.js"), + `export default async () => ({ + tool: { + wire_tool: { + description: "Wired tool.", + args: {}, + execute: async (args, ctx) => { + await ctx.ask({ + permission: "wire_tool", + patterns: [args?.file ?? "*"], + always: ["*"], + metadata: {}, + }); + return "wired-ok"; + }, + }, + }, +}); +`, + "utf8", + ); + } + + it("adds opencode-plugin-tools to mcpServers and gates by permission", async () => { + const { default: 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(); + const cacheRoot = join(home, ".cache", "opencode", "packages"); + await writeToolPlugin(cacheRoot); + const prevHome = process.env.HOME; + const prevXdg = process.env.XDG_CONFIG_HOME; + const prevCache = process.env.XDG_CACHE_HOME; + process.env.HOME = home; + process.env.XDG_CONFIG_HOME = join(home, ".config"); + delete process.env.XDG_CACHE_HOME; + try { + const cwd = tmp(); + const hooks = await plugin({ + directory: cwd, + client: undefined, + project: {}, + worktree: cwd, + serverUrl: new URL("http://localhost:4096"), + experimental_workspace: { register() {} }, + } as never); + + // Case A: no permission rule → bridge configured, calls fail closed. + const config = { + plugin: ["wire-plugin@latest"], + provider: {}, + mcp: {}, + } as never; + await hooks.config!(config); + const options = ( + config as { + provider: Record }>; + } + ).provider["cursor"]!.options!; + const servers = options["mcpServers"] as Record< + string, + { command: string; args?: string[]; env: Record } + >; + const bridgeServer = servers["opencode-plugin-tools"]; + expect(bridgeServer).toBeDefined(); + + const port = Number(bridgeServer!.env["OPENCODE_PLUGIN_TOOLS_PORT"]); + const token = bridgeServer!.env["OPENCODE_PLUGIN_TOOLS_TOKEN"]; + const denied = (await fetch(`http://127.0.0.1:${port}/call`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ id: "wire_tool", args: {} }), + }).then((r) => r.json())) as { ok: boolean; error?: string }; + expect(denied.ok).toBe(false); + expect(denied.error).toMatch(/ask/); + + await hooks.dispose!(); + + // Case B: permission allow → the same flow executes the tool. + const hooks2 = await plugin({ + directory: cwd, + client: undefined, + project: {}, + worktree: cwd, + serverUrl: new URL("http://localhost:4096"), + experimental_workspace: { register() {} }, + } as never); + const config2 = { + plugin: ["wire-plugin@latest"], + permission: { wire_tool: "allow" }, + provider: {}, + mcp: {}, + } as never; + await hooks2.config!(config2); + const servers2 = ( + config2 as { + provider: Record }>; + } + ).provider["cursor"]!.options!["mcpServers"] as Record< + string, + { env: Record } + >; + const bridgeServer2 = servers2["opencode-plugin-tools"]; + expect(bridgeServer2).toBeDefined(); + const port2 = Number(bridgeServer2!.env["OPENCODE_PLUGIN_TOOLS_PORT"]); + const token2 = bridgeServer2!.env["OPENCODE_PLUGIN_TOOLS_TOKEN"]; + const okCall = (await fetch(`http://127.0.0.1:${port2}/call`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${token2}`, + }, + body: JSON.stringify({ id: "wire_tool", args: {} }), + }).then((r) => r.json())) as { ok: boolean; output?: string }; + expect(okCall.ok).toBe(true); + expect(okCall.output).toBe("wired-ok"); + await hooks2.dispose!(); + + // Case C: wildcard permission (`wire_*`) also allows the call — + // opencode matches permission keys as wildcards, not exact ids. + const hooks3 = await plugin({ + directory: cwd, + client: undefined, + project: {}, + worktree: cwd, + serverUrl: new URL("http://localhost:4096"), + experimental_workspace: { register() {} }, + } as never); + const config3 = { + plugin: ["wire-plugin@latest"], + permission: { "wire_*": "allow" }, + provider: {}, + mcp: {}, + } as never; + await hooks3.config!(config3); + const servers3 = ( + config3 as { + provider: Record }>; + } + ).provider["cursor"]!.options!["mcpServers"] as Record< + string, + { env: Record } + >; + const bridgeServer3 = servers3["opencode-plugin-tools"]; + expect(bridgeServer3).toBeDefined(); + const port3 = Number(bridgeServer3!.env["OPENCODE_PLUGIN_TOOLS_PORT"]); + const token3 = bridgeServer3!.env["OPENCODE_PLUGIN_TOOLS_TOKEN"]; + const wildcardCall = (await fetch(`http://127.0.0.1:${port3}/call`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${token3}`, + }, + body: JSON.stringify({ id: "wire_tool", args: {} }), + }).then((r) => r.json())) as { ok: boolean; output?: string }; + expect(wildcardCall.ok).toBe(true); + expect(wildcardCall.output).toBe("wired-ok"); + await hooks3.dispose!(); + + // Case D: pattern-scoped rule — allow for /tmp/*, ask otherwise. + // The bridge must evaluate the REQUESTED patterns, not just the + // permission key (opencode's Permission.ask semantics). + const hooks4 = await plugin({ + directory: cwd, + client: undefined, + project: {}, + worktree: cwd, + serverUrl: new URL("http://localhost:4096"), + experimental_workspace: { register() {} }, + } as never); + const config4 = { + plugin: ["wire-plugin@latest"], + permission: { wire_tool: { "*": "ask", "/tmp/*": "allow" } }, + provider: {}, + mcp: {}, + } as never; + await hooks4.config!(config4); + const servers4 = ( + config4 as { + provider: Record }>; + } + ).provider["cursor"]!.options!["mcpServers"] as Record< + string, + { env: Record } + >; + const bridgeServer4 = servers4["opencode-plugin-tools"]; + expect(bridgeServer4).toBeDefined(); + const port4 = Number(bridgeServer4!.env["OPENCODE_PLUGIN_TOOLS_PORT"]); + const token4 = bridgeServer4!.env["OPENCODE_PLUGIN_TOOLS_TOKEN"]; + const call4 = (args: Record) => + fetch(`http://127.0.0.1:${port4}/call`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${token4}`, + }, + body: JSON.stringify({ id: "wire_tool", args }), + }).then((r) => r.json()) as Promise<{ + ok: boolean; + output?: string; + error?: string; + }>; + // Matching pattern → allowed. + const allowedCall = await call4({ file: "/tmp/ok.txt" }); + expect(allowedCall.ok).toBe(true); + expect(allowedCall.output).toBe("wired-ok"); + // Non-matching pattern → ask → fail closed. + const deniedCall = await call4({ file: "/etc/passwd" }); + expect(deniedCall.ok).toBe(false); + expect(deniedCall.error).toMatch(/ask/); + await hooks4.dispose!(); + } finally { + process.env.HOME = prevHome; + process.env.XDG_CONFIG_HOME = prevXdg; + process.env.XDG_CACHE_HOME = prevCache; + } + }); +}); diff --git a/test/skill-discovery.test.ts b/test/skill-discovery.test.ts index 1033fd9..6e0c5f3 100644 --- a/test/skill-discovery.test.ts +++ b/test/skill-discovery.test.ts @@ -19,12 +19,8 @@ vi.mock("node:os", async (importOriginal) => { return { ...actual, homedir: () => fakeHome }; }); -const { - discoverSkills, - filterSkills, - resolveSkills, - skillSetHash, -} = await import("../src/plugin/skill-discovery.js"); +const { discoverSkills, filterSkills, resolveSkills, skillSetHash } = + await import("../src/plugin/skill-discovery.js"); import type { DiscoveredSkill } from "../src/plugin/skill-discovery.js"; import type { Config } from "@opencode-ai/plugin"; @@ -47,6 +43,14 @@ afterEach(() => { if (existsSync(fakeHomeMySkills)) { rmSync(fakeHomeMySkills, { recursive: true, force: true }); } + // Clean up plugin-cache / file-plugin fixture dirs under fakeHome. + for (const sub of [ + join(".cache", "opencode", "packages"), + join(".config", "opencode", "plugins"), + ]) { + const p = join(fakeHome, sub); + if (existsSync(p)) rmSync(p, { recursive: true, force: true }); + } }); /** Write a skill directory with SKILL.md frontmatter. */ @@ -138,10 +142,14 @@ describe("discoverSkills", () => { description: "Project version.", }); // Global skill with the same id (in the mocked fakeHome). - writeSkill(join(fakeHome, ".config", "opencode", "skills"), "global-vs-proj", { - name: "global-vs-proj", - description: "Global version.", - }); + writeSkill( + join(fakeHome, ".config", "opencode", "skills"), + "global-vs-proj", + { + name: "global-vs-proj", + description: "Global version.", + }, + ); const skills = discoverSkills(cwd); const skill = skills.find((s) => s.id === "global-vs-proj"); expect(skill).toBeDefined(); @@ -366,22 +374,16 @@ describe("filterSkills", () => { } it("allows all skills when no permission config is present", () => { - const result = filterSkills( - [makeSkill("a"), makeSkill("b")], - undefined, - ); + const result = filterSkills([makeSkill("a"), makeSkill("b")], undefined); expect(result.skills).toHaveLength(2); expect(result.withheld).toHaveLength(0); }); it("denies a skill via map-form permission", () => { const config = { - permission: { skill: { "a": "deny" as const } }, + permission: { skill: { a: "deny" as const } }, } as unknown as Config; - const result = filterSkills( - [makeSkill("a"), makeSkill("b")], - config, - ); + const result = filterSkills([makeSkill("a"), makeSkill("b")], config); expect(result.skills.map((s) => s.id)).toEqual(["b"]); expect(result.withheld).toHaveLength(1); expect(result.withheld[0]!.reason).toContain("denied"); @@ -389,12 +391,9 @@ describe("filterSkills", () => { it("withholds ask-permissioned skills", () => { const config = { - permission: { skill: { "a": "ask" as const } }, + permission: { skill: { a: "ask" as const } }, } as unknown as Config; - const result = filterSkills( - [makeSkill("a"), makeSkill("b")], - config, - ); + const result = filterSkills([makeSkill("a"), makeSkill("b")], config); expect(result.skills.map((s) => s.id)).toEqual(["b"]); expect(result.withheld).toHaveLength(1); expect(result.withheld[0]!.reason).toContain("ask"); @@ -422,7 +421,7 @@ describe("filterSkills", () => { permission: { skill: { "*": "deny" as const, - "special": "allow" as const, + special: "allow" as const, }, }, } as unknown as Config; @@ -441,27 +440,26 @@ describe("filterSkills", () => { { permission: "skill", pattern: "blocked", action: "deny" }, ], } as unknown as Config; - const result = filterSkills( - [makeSkill("ok"), makeSkill("blocked")], - config, - ); + const result = filterSkills([makeSkill("ok"), makeSkill("blocked")], config); expect(result.skills.map((s) => s.id)).toEqual(["ok"]); expect(result.withheld.map((w) => w.id)).toEqual(["blocked"]); }); it("manual exclude always drops a skill", () => { - const result = filterSkills( - [makeSkill("a"), makeSkill("b")], - undefined, - { exclude: ["a"] }, - ); + const result = filterSkills([makeSkill("a"), makeSkill("b")], undefined, { + exclude: ["a"], + }); expect(result.skills.map((s) => s.id)).toEqual(["b"]); expect(result.withheld[0]!.reason).toContain("excluded"); }); it("supports wildcard patterns in manual include and exclude lists", () => { const result = filterSkills( - [makeSkill("public-one"), makeSkill("public-two"), makeSkill("internal-one")], + [ + makeSkill("public-one"), + makeSkill("public-two"), + makeSkill("internal-one"), + ], undefined, { include: ["*-one"], exclude: ["internal-*"] }, ); @@ -470,13 +468,11 @@ describe("filterSkills", () => { it("manual include keeps a skill even if permission denies it", () => { const config = { - permission: { skill: { "a": "deny" as const } }, + permission: { skill: { a: "deny" as const } }, } as unknown as Config; - const result = filterSkills( - [makeSkill("a"), makeSkill("b")], - config, - { include: ["a"] }, - ); + const result = filterSkills([makeSkill("a"), makeSkill("b")], config, { + include: ["a"], + }); expect(result.skills.map((s) => s.id)).toEqual(["a"]); }); @@ -491,6 +487,278 @@ describe("filterSkills", () => { }); }); +describe("plugin-bundled skills", () => { + /** Lay down a fake cache entry: `//node_modules//skills/`. */ + function writeCacheSkill( + cacheRoot: string, + entry: string, + pkg: string, + id: string, + fm: { name: string; description: string }, + extra?: Record, + ): string { + return writeSkill( + join(cacheRoot, entry, "node_modules", pkg, "skills"), + id, + fm, + "Plugin skill content.", + extra, + ); + } + + it("discovers a skill bundled in an unscoped plugin cache entry", () => { + const cwd = tmp(); + const cacheRoot = tmp(); + writeCacheSkill( + cacheRoot, + "context-mode@latest", + "context-mode", + "ctx-search", + { + name: "ctx-search", + description: "Search the knowledge base.", + }, + ); + const skills = discoverSkills(cwd, undefined, { cacheRoot }); + expect(skills.find((s) => s.id === "ctx-search")).toBeDefined(); + }); + + it("discovers a skill in a scoped cache entry (@scope/name@latest)", () => { + const cwd = tmp(); + const cacheRoot = tmp(); + writeCacheSkill( + cacheRoot, + join("@stablekernel", "opencode-bifrost@latest"), + join("@stablekernel", "opencode-bifrost"), + "bifrost-skill", + { name: "bifrost-skill", description: "Bundled scoped." }, + ); + const skills = discoverSkills(cwd, undefined, { cacheRoot }); + expect(skills.find((s) => s.id === "bifrost-skill")).toBeDefined(); + }); + + it("discovers a skill in a git-spec cache entry (nested spec path)", () => { + const cwd = tmp(); + const cacheRoot = tmp(); + writeCacheSkill( + cacheRoot, + join("superpowers@git+https:", "github.com", "obra", "superpowers.git"), + "superpowers", + "brainstorming", + { name: "brainstorming", description: "Git-spec bundled." }, + ); + const skills = discoverSkills(cwd, undefined, { cacheRoot }); + expect(skills.find((s) => s.id === "brainstorming")).toBeDefined(); + }); + + it("ignores cache entries without skills (language servers, formatters)", () => { + const cwd = tmp(); + const cacheRoot = tmp(); + // A tooling entry with no skills/ dir under node_modules. + mkdirSync( + join( + cacheRoot, + "typescript-language-server@latest", + "node_modules", + "typescript-language-server", + ), + { recursive: true }, + ); + const skills = discoverSkills(cwd, undefined, { cacheRoot }); + expect(skills).toHaveLength(0); + }); + + it("ignores the cache root's own node_modules and lockfiles", () => { + const cwd = tmp(); + const cacheRoot = tmp(); + mkdirSync(join(cacheRoot, "node_modules", "some-dep"), { recursive: true }); + writeFileSync(join(cacheRoot, "package-lock.json"), "{}", "utf8"); + // A skill inside the root-level node_modules must NOT be treated as a + // plugin-bundled skill (that dir holds transitive deps, not plugins). + writeSkill( + join(cacheRoot, "node_modules", "some-dep", "skills"), + "dep-skill", + { + name: "dep-skill", + description: "Transitive dep, not a plugin.", + }, + ); + const skills = discoverSkills(cwd, undefined, { cacheRoot }); + expect(skills.find((s) => s.id === "dep-skill")).toBeUndefined(); + }); + + it("collects supporting files from a plugin-bundled skill", () => { + const cwd = tmp(); + const cacheRoot = tmp(); + writeCacheSkill( + cacheRoot, + "ctx@latest", + "ctx", + "ctx-index", + { name: "ctx-index", description: "Index content." }, + { "reference.md": "ref content" }, + ); + const skills = discoverSkills(cwd, undefined, { cacheRoot }); + const skill = skills.find((s) => s.id === "ctx-index"); + expect(skill).toBeDefined(); + expect(skill!.files).toContain("reference.md"); + }); + + it("project skills beat plugin-bundled skills on duplicate id", () => { + const cwd = tmp(); + const cacheRoot = tmp(); + writeSkill(join(cwd, ".opencode", "skills"), "dup", { + name: "dup", + description: "Project version.", + }); + writeCacheSkill(cacheRoot, "ctx@latest", "ctx", "dup", { + name: "dup", + description: "Plugin version.", + }); + const skills = discoverSkills(cwd, undefined, { cacheRoot }); + const dup = skills.find((s) => s.id === "dup"); + expect(dup).toBeDefined(); + expect(dup!.description).toBe("Project version."); + }); + + it("plugin-bundled skills are lower priority than extraPaths", () => { + const cwd = tmp(); + const cacheRoot = tmp(); + const extraDir = tmp(); + writeSkill(extraDir, "dup", { + name: "dup", + description: "Extra path version.", + }); + writeCacheSkill(cacheRoot, "ctx@latest", "ctx", "dup", { + name: "dup", + description: "Plugin version.", + }); + const skills = discoverSkills(cwd, [extraDir], { cacheRoot }); + const dup = skills.find((s) => s.id === "dup"); + expect(dup!.description).toBe("Extra path version."); + }); + + it("discovers sibling skills of file-based plugins (global plugins dir)", () => { + const cwd = tmp(); + // Global config root with a file plugin and its sibling skills dir. + const globalRoot = join(fakeHome, ".config", "opencode"); + mkdirSync(join(globalRoot, "plugins"), { recursive: true }); + writeFileSync( + join(globalRoot, "plugins", "my-plugin.ts"), + "// plugin\n", + "utf8", + ); + writeSkill(join(globalRoot, "plugins", "skills"), "file-plugin-skill", { + name: "file-plugin-skill", + description: "Bundled alongside a file plugin.", + }); + const cacheRoot = tmp(); // empty cache so the default scan is quiet + const skills = discoverSkills(cwd, undefined, { cacheRoot }); + expect(skills.find((s) => s.id === "file-plugin-skill")).toBeDefined(); + }); + + it("discovers sibling skills of project file plugins (.opencode/plugin)", () => { + const cwd = tmp(); + const cacheRoot = tmp(); + mkdirSync(join(cwd, ".opencode", "plugin"), { recursive: true }); + writeFileSync( + join(cwd, ".opencode", "plugin", "local.ts"), + "// plugin\n", + "utf8", + ); + writeSkill(join(cwd, ".opencode", "plugin", "skills"), "local-plugin-skill", { + name: "local-plugin-skill", + description: "Project file plugin skill.", + }); + const skills = discoverSkills(cwd, undefined, { cacheRoot }); + expect(skills.find((s) => s.id === "local-plugin-skill")).toBeDefined(); + }); + + it("project file-plugin skills beat global file-plugin skills on duplicate id", () => { + const cwd = tmp(); + const cacheRoot = tmp(); + const globalRoot = join(fakeHome, ".config", "opencode"); + mkdirSync(join(globalRoot, "plugins"), { recursive: true }); + writeSkill(join(globalRoot, "plugins", "skills"), "dup-file", { + name: "dup-file", + description: "Global file-plugin version.", + }); + mkdirSync(join(cwd, ".opencode", "plugin"), { recursive: true }); + writeSkill(join(cwd, ".opencode", "plugin", "skills"), "dup-file", { + name: "dup-file", + description: "Project file-plugin version.", + }); + const skills = discoverSkills(cwd, undefined, { cacheRoot }); + const dup = skills.find((s) => s.id === "dup-file"); + expect(dup!.description).toBe("Project file-plugin version."); + }); + + it("applies permission deny to plugin-bundled skills", () => { + const cwd = tmp(); + const cacheRoot = tmp(); + writeCacheSkill(cacheRoot, "ctx@latest", "ctx", "ctx-search", { + name: "ctx-search", + description: "Denied plugin skill.", + }); + const config = { + permission: { skill: { "ctx-*": "deny" } }, + } as unknown as Config; + const result = resolveSkills(cwd, config, undefined, { cacheRoot }); + expect(result.skills.find((s) => s.id === "ctx-search")).toBeUndefined(); + expect(result.withheld.find((w) => w.id === "ctx-search")).toBeDefined(); + }); + + it("manual include selects only listed plugin-bundled skills", () => { + const cwd = tmp(); + const cacheRoot = tmp(); + writeCacheSkill(cacheRoot, "ctx@latest", "ctx", "ctx-search", { + name: "ctx-search", + description: "Included.", + }); + writeCacheSkill(cacheRoot, "ctx@latest", "ctx", "ctx-index", { + name: "ctx-index", + description: "Not included.", + }); + const result = resolveSkills( + cwd, + undefined, + { include: ["ctx-search"] }, + { cacheRoot }, + ); + expect(result.skills.map((s) => s.id)).toEqual(["ctx-search"]); + }); + + it("skips a nonexistent cache root silently", () => { + const cwd = tmp(); + const skills = discoverSkills(cwd, undefined, { + cacheRoot: join(tmp(), "does-not-exist"), + }); + expect(skills).toHaveLength(0); + }); +}); + +describe("resolvePluginSkillSources", () => { + it("returns the cache root and real file-plugin roots", async () => { + const { resolvePluginSkillSources } = await import( + "../src/plugin/skill-discovery.js" + ); + const cacheRoot = join(fakeHome, ".cache", "opencode", "packages"); + const globalRoot = join(fakeHome, ".config", "opencode"); + mkdirSync(join(globalRoot, "plugins"), { recursive: true }); + writeFileSync(join(globalRoot, "plugins", "x.ts"), "// plugin\n", "utf8"); + mkdirSync(join(globalRoot, "plugins", "skills", "a"), { recursive: true }); + writeFileSync( + join(globalRoot, "plugins", "skills", "a", "SKILL.md"), + "---\nname: a\ndescription: d\n---\nbody", + "utf8", + ); + const cwd = tmp(); + const result = resolvePluginSkillSources(cwd); + expect(result.cacheRoot).toBe(cacheRoot); + expect(result.filePluginRoots).toContain(globalRoot); + }); +}); + describe("resolveSkills", () => { it("combines discovery and filtering, never throws", () => { const cwd = tmp(); @@ -518,3 +786,85 @@ describe("resolveSkills", () => { expect(secondHash).not.toBe(firstHash); }); }); + +describe("live skills merge (app.skills)", () => { + it("merges live skills the filesystem scan missed, at lowest priority", () => { + const cwd = tmp(); + const cacheRoot = tmp(); + // A project skill the scan finds. + writeSkill(join(cwd, ".opencode", "skills"), "seen-skill", { + name: "seen-skill", + description: "Found by scan.", + }); + // A live-only skill located in a dir the scan doesn't cover. + const liveDir = tmp(); + writeSkill(liveDir, "live-only", { + name: "live-only", + description: "Only opencode knows about this one.", + }); + // A duplicate: live reports the same id as the project skill. + const dupDir = tmp(); + writeSkill(dupDir, "seen-skill", { + name: "seen-skill", + description: "Live duplicate — must lose.", + }); + const result = resolveSkills(cwd, undefined, undefined, { + cacheRoot, + liveSkills: [ + { + name: "live-only", + description: "Only opencode knows about this one.", + location: join(liveDir, "live-only", "SKILL.md"), + }, + { + name: "seen-skill", + description: "Live duplicate — must lose.", + location: join(dupDir, "seen-skill", "SKILL.md"), + }, + ], + }); + const ids = result.skills.map((s) => s.id).sort(); + expect(ids).toEqual(["live-only", "seen-skill"]); + const dup = result.skills.find((s) => s.id === "seen-skill"); + expect(dup!.description).toBe("Found by scan."); + }); + + it("skips live entries whose location is missing on disk", () => { + const cwd = tmp(); + const result = resolveSkills(cwd, undefined, undefined, { + cacheRoot: tmp(), + liveSkills: [ + { + name: "ghost", + description: "Gone.", + location: "/nonexistent/ghost/SKILL.md", + }, + ], + }); + expect(result.skills).toHaveLength(0); + }); + + it("applies permission filtering to live-sourced skills", () => { + const cwd = tmp(); + const liveDir = tmp(); + writeSkill(liveDir, "live-denied", { + name: "live-denied", + description: "Denied.", + }); + const config = { + permission: { skill: { "live-*": "deny" } }, + } as unknown as Config; + const result = resolveSkills(cwd, config, undefined, { + cacheRoot: tmp(), + liveSkills: [ + { + name: "live-denied", + description: "Denied.", + location: join(liveDir, "live-denied", "SKILL.md"), + }, + ], + }); + expect(result.skills).toHaveLength(0); + expect(result.withheld.find((w) => w.id === "live-denied")).toBeDefined(); + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts index 0256a9e..d3dbb61 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,9 +1,18 @@ import { readFileSync } from "node:fs"; import { defineConfig } from "tsup"; -const pkg = JSON.parse( - readFileSync(new URL("./package.json", import.meta.url), "utf8"), -) as { version: string }; +let pkg: { version: string }; +try { + pkg = JSON.parse( + readFileSync(new URL("./package.json", import.meta.url), "utf8"), + ) as { version: string }; +} catch (error) { + throw new Error( + `tsup: failed to read/parse package.json: ${ + error instanceof Error ? error.message : String(error) + }`, + ); +} export default defineConfig({ // Emit config (src-only rootDir + declaration); the root tsconfig.json is the @@ -15,6 +24,9 @@ export default defineConfig({ // Node sidecar hosting @cursor/sdk traffic when the plugin runs under Bun // (Bun's node:http2 breaks Cursor's streaming RPC). Spawned, not imported. "sidecar/agent-host": "src/sidecar/agent-host.mjs", + // stdio MCP server exposing other plugins' custom tools to the Cursor + // agent. Spawned by Cursor (via mcpServers) when the bridge is active. + "sidecar/plugin-tools-mcp": "src/sidecar/plugin-tools-mcp.mjs", }, format: ["esm"], target: "node22", From f3842b0b1fdb8a821e07b529230f0bff75e399b5 Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Mon, 24 Aug 2026 20:11:21 -0500 Subject: [PATCH 2/7] test: isolate XDG env vars in skill-discovery tests CI's GitHub runners carry real XDG_CONFIG_HOME/XDG_CACHE_HOME; the discovery code prefers those env vars over the mocked homedir, so the worker's real config dir leaked into the fakeHome-based expectations. Point both vars at per-file fake dirs for the worker's lifetime. --- test/skill-discovery.test.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/test/skill-discovery.test.ts b/test/skill-discovery.test.ts index 6e0c5f3..36906c4 100644 --- a/test/skill-discovery.test.ts +++ b/test/skill-discovery.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { mkdtempSync, mkdirSync, @@ -19,6 +19,21 @@ vi.mock("node:os", async (importOriginal) => { return { ...actual, homedir: () => fakeHome }; }); +// SAFETY: XDG env vars leak past the fakeHome mock (the discovery code +// prefers `process.env.XDG_CONFIG_HOME` over the mocked homedir), so any +// real XDG_* on the host/CI would contaminate these tests. Point them at +// per-file fake dirs for the duration of this worker. +const realXdgConfig = process.env["XDG_CONFIG_HOME"]; +const realXdgCache = process.env["XDG_CACHE_HOME"]; +process.env["XDG_CONFIG_HOME"] = join(fakeHome, ".config"); +process.env["XDG_CACHE_HOME"] = join(fakeHome, ".cache"); +afterAll(() => { + if (realXdgConfig === undefined) delete process.env["XDG_CONFIG_HOME"]; + else process.env["XDG_CONFIG_HOME"] = realXdgConfig; + if (realXdgCache === undefined) delete process.env["XDG_CACHE_HOME"]; + else process.env["XDG_CACHE_HOME"] = realXdgCache; +}); + const { discoverSkills, filterSkills, resolveSkills, skillSetHash } = await import("../src/plugin/skill-discovery.js"); import type { DiscoveredSkill } from "../src/plugin/skill-discovery.js"; From 2124b110039a0c0d51538643a6bebf470fe843c7 Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Mon, 24 Aug 2026 21:21:31 -0500 Subject: [PATCH 3/7] chore: drop implementation plans from tracked docs --- ...2026-09-01-plugin-bundled-skills-mirror.md | 169 ------------------ .../plans/2026-09-01-plugin-tools-findings.md | 81 --------- 2 files changed, 250 deletions(-) delete mode 100644 docs/superpowers/plans/2026-09-01-plugin-bundled-skills-mirror.md delete mode 100644 docs/superpowers/plans/2026-09-01-plugin-tools-findings.md diff --git a/docs/superpowers/plans/2026-09-01-plugin-bundled-skills-mirror.md b/docs/superpowers/plans/2026-09-01-plugin-bundled-skills-mirror.md deleted file mode 100644 index cf54b2f..0000000 --- a/docs/superpowers/plans/2026-09-01-plugin-bundled-skills-mirror.md +++ /dev/null @@ -1,169 +0,0 @@ -# Give the Cursor agent access to opencode plugins: bundled skills + custom tools - -## Context - -opencode installs typically have many plugins configured (`plugin: [...]` in -`opencode.jsonc`). Those plugins give the opencode agent two things the Cursor -agent currently cannot use: - -1. **Bundled skills** — plugins can ship `skills/` directories inside their npm - package. opencode loads them natively; this repo's skill mirror deliberately - skips the opencode package cache (documented limitation, README "Skills → - Limitations"). Verified real example on this machine: `context-mode@latest` - ships ~8 skills (`ctx-search`, `ctx-index`, …) under - `~/.cache/opencode/packages/context-mode@latest/node_modules/context-mode/skills/` - — all invisible to the Cursor agent. - -2. **Custom tools** — plugins register tools via the `tool: {}` hook - (e.g. `opencode-pty`'s PTY tools). These are opencode-runtime JS functions; - Cursor has no path to them. - -Outcome: extend the existing skill mirror to cover plugin-bundled skills -(Phase 1), and bridge other plugins' registered tools to the Cursor agent via a -local stdio MCP server that proxies back into opencode's own tool execution -(Phase 2). - -## Decisions (from user) - -- Scope: **both** skills and custom tools. -- Sources: npm package cache **and** file plugins (`~/.config/opencode/plugins/`, - `.opencode/plugin/`). Note: file plugins are single `.ts` files — they can't - bundle a `skills/` dir, but scan their parent dirs anyway for correctness. -- Config: **fold into `forwardSkills`** (default on); existing - `skills.include/exclude` applies identically. Tools get their own option - (`forwardPluginTools`, default on) since the risk profile differs. -- Precedence: plugin-bundled skills **lowest priority** on duplicate ids — - project/global/`skills.paths` always win. - -## Existing code to reuse - -| Piece | Location | Role | -| --- | --- | --- | -| `discoverSkills(cwd, extraPaths)` | [src/plugin/skill-discovery.ts](../../../src/plugin/skill-discovery.ts) | Ordered scan-roots pipeline; new cache scan slots in as lowest-priority roots | -| `scanSkillDir(dir)` / `entryKind` / `loadSkill` | same | Symlink-safe `SKILL.md` dir scan — reused as-is | -| `filterSkills()` | same | Permission `allow/deny/ask` + manual include/exclude — applies unchanged | -| `writeSkillMirror()` | [src/provider/skill-mirror.ts](../../../src/provider/skill-mirror.ts) | Sentinel-guarded `.cursor/skills/` mirror, 1 MB/10 MB caps | -| `buildSkillsCatalogue` / `skillSetHash` / `chat.params` live re-sync | [src/plugin/index.ts](../../../src/plugin/index.ts) | Catalogue + mid-session refresh — new sources ride along | -| `translateMcpServers()` | [src/plugin/mcp-config.ts](../../../src/plugin/mcp-config.ts) | Reference for how forwarded MCP servers reach the Cursor agent (`mcpServers` provider option); Phase 2 adds one more entry | -| `context.ask` approval gate | [src/plugin/cursor-tools.ts](../../../src/plugin/cursor-tools.ts) | Permission-gating pattern for the proxied tool execution | -| `PLUGIN_CACHE_PATH` win32/XDG pattern | [src/version-check.ts](../../../src/version-check.ts:27) | Cache-root resolution pattern (XDG_CACHE_HOME → ~/.cache; %LocalAppData%\opencode\cache on Windows) | -| `scripts/opencode-plugins-refresh` | [scripts/opencode-plugins-refresh](../../../scripts/opencode-plugins-refresh) | Documents real cache layouts: `@latest`, `@scope/name@latest`, `@scope/name`, git specs (`superpowers@git+https:/...`) | - -## Phase 1 — Mirror plugin-bundled skills - -Cache-root layout (verified): `$XDG_CACHE_HOME/opencode/packages/` contains -entries per plugin: `@latest/`, `/`, scoped `@scope/@latest/`, -and git specs. Each entry is a package root whose skills live at -`node_modules//skills//SKILL.md` (unscoped and scoped pkg names). -Note some cache entries are **not** plugins (`bash-language-server`, -`typescript-language-server`, `prettier`, `pyright`, `ls`) — they simply have no -`skills/` dir, so scanning them is a cheap no-op; no need to parse `plugin: []` -from opencode.json to filter. - -### Steps - -- [ ] Add `discoverPluginSkills()` (or extend `discoverSkills` with a new - lowest-priority scan-roots group) in `src/plugin/skill-discovery.ts`: - - Resolve cache root: `$XDG_CACHE_HOME/opencode/packages` (win32: - `%LocalAppData%\opencode\cache\packages`) — factor a small shared helper, - since `version-check.ts` duplicates this logic. - - Enumerate entries: for each cache entry dir `E`, scan - `E/node_modules/**/skills` (bounded: check `E/node_modules//skills` - for each immediate child of `E/node_modules`, including `@scope/` nesting, - plus git-spec layouts). Use `scanSkillDir` for each found `skills/` and - `skills/` sibling `skill/`. - - Also scan file-plugin parents: `~/.config/opencode/plugins/` and - `/.opencode/plugin/` — look for sibling `skills/` dirs. -- [ ] Precedence: append these roots **after** all existing roots (first-wins - dedupe already gives lowest priority). -- [ ] No new config surface (folds into `forwardSkills`; existing - `skills.include/exclude` and permission filtering apply). -- [ ] Tests in `test/skill-discovery.test.ts`: fixture cache roots covering - unscoped `@latest`, scoped, no-suffix, git-spec layouts; non-plugin cache - entries ignored; duplicate-id precedence (project skill beats plugin skill); - permission `deny` drops a plugin-bundled skill; `include` list can select one. -- [ ] README: remove "Skills bundled inside opencode plugins are not mirrored" - limitation; document the new source + precedence. - -## Phase 2 — Bridge plugin custom tools via a proxy MCP server - -Design: the plugin spawns a **local stdio MCP server** (bundled in this package) -that exposes every other plugin's registered custom tools as MCP tools. It is -handed to the Cursor agent through the existing `mcpServers` provider option — -the same channel `translateMcpServers` already feeds. When Cursor calls one, -the server proxies execution back into opencode's runtime (which owns the real -tool implementations, including other plugins' closures) via a local RPC loop -hosted by this plugin. - -Why MCP proxy rather than re-implementing tools: opencode's `@opencode-ai/sdk` -exposes **no API to list or invoke registered tools** (verified: README itself -documents "no skills API"; the SDK surface is session/config/MCP-status only). -Plugin tools are in-process closures; the only in-process participant that can -see them is a plugin. So: a tiny in-process registry + a stdio MCP child is the -minimal bridge. - -### Steps - -- [ ] Registry module (e.g. `src/plugin/plugin-tool-registry.ts`): captures the - `tool: {}` maps of *other* plugins. Mechanism: opencode calls each plugin's - hook and merges the returned tools — investigate whether a later-registered - plugin can observe earlier tools (wrap/intercept via the `config` hook's - merged result, or the `tool.execute.before` event which receives tool names — - see `~/.config/opencode/plugins/rtk.ts:19` for the event shape). Fallback if - enumeration is impossible: forward only tools the user lists explicitly in - `provider.cursor.options.pluginTools: ["pty_*", ...]` with descriptors. - > [!WARNING] - > Enumeration feasibility is the key risk. If opencode does not expose other - > plugins' tool maps to a sibling plugin, Phase 2 becomes: document a - > convention where interested plugin authors register tools with this - > plugin's registry, plus the explicit-list fallback. -- [ ] MCP server entry (e.g. `src/sidecar/plugin-tools-mcp.mjs`): stdio MCP - server using the MCP SDK (add `@modelcontextprotocol/sdk` dependency). - `tools/list` serves the registry snapshot (name, description, JSON-schema - args); `tools/call` forwards to the host plugin over a localhost socket or - the stdin/stdout-adjacent control channel, which executes the real opencode - tool through the same `context.ask` gating pattern as `cursor_delegate` - (`src/plugin/cursor-tools.ts`) so the user's `permission` config applies. -- [ ] Wire-up in `src/plugin/index.ts` config hook: when - `provider.cursor.options.forwardPluginTools !== false` and the registry is - non-empty, add `opencode-plugin-tools` to the forwarded `mcpServers` - (`type: "stdio"`, command = `node `, env carries the RPC - port/auth token). Must NOT spawn when the registry is empty. -- [ ] Permission model: proxied calls gated by a new `permission` key - (`cursor_plugin_tools`: ask default), plus per-tool patterns in metadata — - matching the existing delegation-tool gating. -- [ ] Tests: registry capture, MCP server list/call round-trip against a fake - tool, permission gate deny path, empty-registry no-spawn. -- [ ] README: new "Plugin tools" section: what is forwarded, the permission - knob, the security note (Cursor invoking another plugin's tool runs that - tool's code with the user's opencode permissions). - -## Files to modify - -- `src/plugin/skill-discovery.ts` — Phase 1 cache/file-plugin scan roots -- `src/plugin/index.ts` — wire-up for both phases -- `src/version-check.ts` — extract shared opencode cache-root helper (or new - `src/plugin/opencode-cache.ts`) -- NEW `src/plugin/plugin-tool-registry.ts`, NEW `src/sidecar/plugin-tools-mcp.mjs` — Phase 2 -- `test/skill-discovery.test.ts`, NEW `test/plugin-tools-mcp.test.ts` — coverage -- `README.md` — docs for both phases -- `package.json` — Phase 2 adds `@modelcontextprotocol/sdk` dependency (verify - current version via `npm view` before pinning) - -## Verification (executed) - -- `npm run typecheck && npm test` — 41 files / 606 tests pass. -- `npm run build` — dist/sidecar/plugin-tools-mcp.js emitted; dist MCP round-trip - smoke passes (initialize → tools/list → tools/call). -- Phase 1 smoke: discovery against the real cache finds 22 plugin-bundled - skills (8 context-mode + 14 superpowers git-spec) alongside 22 config skills. -- Phase 2 smoke (Bun, matching opencode's runtime): `mirrorPluginTools` pulls - 16 real tools from `opencode-pty@latest` + `context-mode@latest`, `failed: {}`. -- Full-plugin smoke against `dist/plugin/index.js`: bridge lands in - `mcpServers["opencode-plugin-tools"]`; a tool whose `execute` calls - `ctx.ask` is rejected with a clear "set to allow" message when unconfigured - and runs when `permission: { fake_tool: "allow" }`. -- Wiring test covers exact-id and wildcard (`wire_*`) permission allow paths. -- Remaining live check (needs a running opencode + Cursor session): confirm - `.cursor/skills/` contains `ctx-search` etc. and the Cursor agent lists the - `opencode-plugin-tools` MCP server in a real turn. diff --git a/docs/superpowers/plans/2026-09-01-plugin-tools-findings.md b/docs/superpowers/plans/2026-09-01-plugin-tools-findings.md deleted file mode 100644 index e97056f..0000000 --- a/docs/superpowers/plans/2026-09-01-plugin-tools-findings.md +++ /dev/null @@ -1,81 +0,0 @@ -# Phase 2 findings: bridging plugin tools to the Cursor agent - -Verified against opencode v1.18.18 source (sparse clone at `/tmp/opencode-src`) -and the installed `@opencode-ai/sdk@1.18.18` typings. - -## What was learned - -1. **Plugin tools are in-process closures.** opencode's tool registry calls each - plugin's `hooks.tool` map and wraps `execute` with an Effect bridge - (`packages/opencode/src/tool/registry.ts:125-198`). There is no public API to - *invoke* another plugin's tool from outside that closure. -2. **But a sibling plugin CAN call them.** Plugin load order is config order - (`plugin/index.ts:297` — `Plugin.list()` returns loaded hooks; the registry - iterates `plugin.list()` at registry state init). A plugin registered AFTER - another one can `import()` that plugin's module, call its `server(input)` - with the same `PluginInput` it received, and read `hooks.tool` — the exact - same functions opencode itself will later execute. Same module instance → - same closures. -3. **opencode resolves plugin specs itself.** `Config.plugin` entries may be - bare names, `@latest` specs, file paths, or git URLs; opencode installs them - into the package cache (`~/.cache/opencode/packages/`). A mirror plugin can - reuse the cache resolution the skill mirror already does - (`opencodePackagesRoot`, cache-entry layouts: `name@latest`, - `@scope/name@latest`, `name@git+https:...`). -4. **Execution with permission gating already exists.** opencode's - `ToolContext.ask` (bridged to Effect at `registry.ts:143-146`) honours the - user's live permission config. Calling the mirror's tool map the same way - opencode does (`fromPlugin` shape) preserves that gate for free. -5. **JSON schema extraction is reliable.** opencode converts plugin Zod args via - `z.toJSONSchema(schema, { io: "input" })` (`registry.ts:370`), with a legacy - fallback that treats non-Zod entries as raw JSON Schema - (`registry.ts:358-367`). The registry does the same. - -## Chosen design (vs the plan's session-loopback alternative) - -The plan's WARNING flagged that enumeration may be impossible. It is possible — -via import + re-invoke of sibling plugin modules. That is strictly better than -session-loopback execution: - -| | import + re-invoke (chosen) | session loopback | -| --- | --- | --- | -| Executes the real tool fn | yes — same closure opencode uses | yes — opencode executes it | -| Permission `ask` gate | yes (host bridges `context.ask`) | yes | -| LLM turn cost | none | one full model turn per call | -| Model dependency | none | session's configured model | -| Failure modes | import failures only | prompt/event races, model errors | - -Session loopback remains the documented fallback for tools whose modules cannot -be re-imported (stateful singletons that break on double-init are the known -risk; `opencode-pty` verified importable and its `tool` map is a plain object -of closures over a module-scoped session manager — re-invoking `server()` -creates a second manager, harmless for read-only bridging but noted in README). - -## Verified real targets on this machine - -- `opencode-pty@latest` → `dist/src/plugin.js` exports `PTYPlugin` (also as - `server`); `hooks.tool` = `pty_spawn`, `pty_write`, `pty_read`, `pty_list`, - `pty_kill`. -- `context-mode@latest` → MCP-backed tools (its skills are handled by Phase 1; - its tools are not re-exported as a `tool` map — excluded from mirror, correct). - -## SDK surface used - -- `client.tool.ids()` → `/experimental/tool/ids` (list registered tool ids) -- `client.session.create/update` with `permission` + `metadata` (not needed in - chosen design; kept as fallback notes) -- `client.session.prompt` with `noReply` + `tools` (fallback only) - -## Follow-ups (from review) - -- **M1**: bridge token/port ride the per-turn `mcpServers` config through - opencode's session plumbing; consider a process-lifetime bridge or a 0600 - temp-file handoff. Bridge restart also changes the transcript fingerprint, - re-creating the pooled Cursor agent (intentional but worth a doc note). -- **M3**: `mirrorPluginTools` re-invokes every plugin's `server()` factory - each turn — memoize on plugin-list + permission-config hash; add an - import/init timeout. -- **M6**: `src/version-check.ts` hardcodes `~/.cache` (pre-existing XDG bug, - now duplicated by `opencodePackagesRoot`) — factor a shared helper. -- **N3**: git-spec cache walk admits symlinked components; a realpath - containment check is cheap hardening (low impact: user-controlled cache). From b156e32e1ca4ef21b1953ef28d605a1186a089ce Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Mon, 24 Aug 2026 21:36:40 -0500 Subject: [PATCH 4/7] feat: mirror opencode built-in skills from the live inventory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit opencode registers skills in code (currently `customize-opencode`, location ``) that have no on-disk SKILL.md, so neither the filesystem scan nor the live merge reached them — the live entry was dropped because its location isn't resolvable. Materialise content-only live skills (name + description + content from `app.skills`) into a per-process scratch dir with generated frontmatter, so the mirror stamps and copies them like disk-backed skills. The copy is only rewritten when the content or description changes, keeping per-turn mtimes (and the skill-set hash) stable. Verified against a live `opencode debug skill --pure` dump: 23 skills parsed, `customize-opencode` lands in `.cursor/skills/` with the sentinel and appears in the `` catalogue. --- README.md | 21 ++++--- src/plugin/skill-discovery.ts | 104 +++++++++++++++++++++++++++++++--- test/skill-discovery.test.ts | 94 +++++++++++++++++++++++++++++- 3 files changed, 202 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index d6336e5..698c5f5 100644 --- a/README.md +++ b/README.md @@ -318,14 +318,19 @@ The mirror includes: - **opencode's live skill inventory** — on every turn the mirror also consults opencode's `app.skills` endpoint (when reachable) and merges any skill it knows about that the filesystem scan missed, at the same lowest priority. +- **opencode's built-in skills** — skills opencode registers in code rather + than on disk (currently `customize-opencode`, its own config-authoring + guide). They only exist in the live inventory, so the mirror materialises + them from the endpoint's content into `.cursor/skills/` like any other + skill; the materialised copy updates whenever opencode's version changes. - **Supporting files** alongside each `SKILL.md` (preserving relative paths). - An `` catalogue appended to the generated system rule, listing each skill's id and description so the Cursor agent can load them on demand. -> **Note:** `config.skills.urls` (HTTP skill catalogs) are not yet supported by -> the mirror. If you rely on URL-sourced skills, they will not appear in -> `.cursor/skills/`. +> **Note:** URL-sourced skills (`config.skills.urls`) that are also present in +> opencode's live inventory reach the mirror through that route; the mirror +> does not fetch `skills.urls` catalogs on its own. ### Permission filtering @@ -359,10 +364,12 @@ user explicitly asked for them). `exclude` always drops the listed skills. ### Limitations -- Plugin-bundled skills are resolved from the plugin package cache by - filesystem scan (`@opencode-ai/sdk` exposes no skills API), so they update - only when the cache is refreshed — run `opencode-plugins-refresh` after - installing/updating a plugin that ships skills, then restart opencode. +- Skills served via `skills.urls` that opencode itself hasn't loaded (the + endpoint is reachable but the catalog wasn't pulled this session) won't + appear until opencode sees them. +- Built-in skills require the live `app.skills` endpoint (i.e. a running + opencode server reachable by this plugin); the filesystem scan can't see + them on its own. - A user-owned `.cursor/skills//SKILL.md` (without the `generated: opencode-cursor` sentinel) is never overwritten or deleted. - Individual files larger than 1 MB are skipped (the rest of the skill is still diff --git a/src/plugin/skill-discovery.ts b/src/plugin/skill-discovery.ts index 97da14c..4270bf3 100644 --- a/src/plugin/skill-discovery.ts +++ b/src/plugin/skill-discovery.ts @@ -4,6 +4,10 @@ import { statSync, existsSync, realpathSync, + mkdtempSync, + mkdirSync, + rmSync, + writeFileSync, } from "node:fs"; import type { Dirent } from "node:fs"; import { @@ -13,7 +17,7 @@ import { resolve as resolvePath, isAbsolute, } from "node:path"; -import { homedir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { execSync } from "node:child_process"; import type { Config } from "@opencode-ai/plugin"; @@ -740,28 +744,112 @@ export interface LiveSkill { name: string; description?: string; location: string; + /** + * The skill's full body. Present for content-only skills (e.g. opencode's + * `` skills, which have no on-disk SKILL.md). + */ + content?: string; } /** - * Convert live `app.skills` entries into {@link DiscoveredSkill}s, pointing - * at each skill's on-disk directory (derived from `location`). Entries whose - * location can't be resolved are skipped — the filesystem scan already - * covers anything reachable. + * Scratch root holding materialised copies of skills that only exist in + * opencode's live inventory (no on-disk SKILL.md — e.g. opencode's own + * `` skills, whose content is registered in code). Stable across + * calls so `skillSetHash` mtime checks don't churn per turn; wiped on exit. + */ +let liveScratchRoot: string | undefined; +let liveScratchCleanupRegistered = false; + +/** Test hook: drop the scratch root so tests don't share state. */ +export function resetLiveSkillScratch(): void { + const root = liveScratchRoot; + if (root) { + try { + rmSync(root, { recursive: true, force: true }); + } catch { + // best effort + } + } + liveScratchRoot = undefined; +} + +function liveScratchDir(): string { + if (!liveScratchRoot) { + liveScratchRoot = mkdtempSync(join(tmpdir(), "opencode-cursor-skills-")); + if (!liveScratchCleanupRegistered) { + liveScratchCleanupRegistered = true; + const rootAtExit = liveScratchRoot; + process.once("exit", () => { + rmSync(rootAtExit, { recursive: true, force: true }); + }); + } + } + return liveScratchRoot; +} + +/** + * Convert live `app.skills` entries into {@link DiscoveredSkill}s. + * + * Disk-backed entries point at the skill's on-disk directory (derived from + * `location`); entries whose location isn't resolvable are skipped — the + * filesystem scan already covers anything reachable. + * + * Content-only entries (no on-disk SKILL.md — opencode's `` skills + * and anything else opencode serves from memory) are materialised into a + * scratch dir so the mirror can stamp and copy them like any other skill. + * The rewritten file carries frontmatter (the live `description`) + the + * live `content` body, keeping the mirror in sync with opencode's version. */ export function liveSkillsToDiscovered(live: LiveSkill[]): DiscoveredSkill[] { const out: DiscoveredSkill[] = []; for (const skill of live) { - if (!skill.location) continue; + if (!skill.location || !skill.name) continue; const sourceDir = skill.location.endsWith("SKILL.md") ? dirname(skill.location) : skill.location; - if (!existsSync(join(sourceDir, "SKILL.md"))) continue; - const loaded = loadSkill(skill.name, sourceDir); + if (existsSync(join(sourceDir, "SKILL.md"))) { + const loaded = loadSkill(skill.name, sourceDir); + if (loaded) out.push(loaded); + continue; + } + // Not on disk: materialise if opencode gave us content. + if (!skill.content) continue; + const scratchDir = join(liveScratchDir(), skill.name); + const scratchMd = join(scratchDir, "SKILL.md"); + // Rewrite only when the content or description actually changed, so + // per-turn calls don't touch mtimes and invalidate the skill hash. + let needsWrite = true; + if (existsSync(scratchMd)) { + try { + const existing = readFileSync(scratchMd, "utf8"); + if (existing === renderLiveSkillMd(skill)) needsWrite = false; + } catch { + // unreadable → rewrite + } + } + if (needsWrite) { + try { + mkdirSync(scratchDir, { recursive: true }); + writeFileSync(scratchMd, renderLiveSkillMd(skill), "utf8"); + } catch { + continue; // scratch fs unavailable — skip this skill + } + } + const loaded = loadSkill(skill.name, scratchDir); if (loaded) out.push(loaded); } return out; } +/** Render a live (content-only) skill as a stamped SKILL.md. */ +function renderLiveSkillMd(skill: LiveSkill): string { + // YAML: quote the description to survive colons/quotes inside it. + const escaped = (skill.description ?? "").replace(/"/g, '\\"'); + const body = skill.content ?? ""; + const separator = body.startsWith("\n") ? "" : "\n"; + return `---\nname: ${skill.name}\ndescription: "${escaped}"\n---\n${separator}${body}`; +} + /** * Discover and filter skills in one call. This is the main entry point for the * plugin's config and chat.params hooks. Never throws — fs errors degrade to diff --git a/test/skill-discovery.test.ts b/test/skill-discovery.test.ts index 36906c4..810fc5e 100644 --- a/test/skill-discovery.test.ts +++ b/test/skill-discovery.test.ts @@ -6,6 +6,8 @@ import { rmSync, existsSync, symlinkSync, + readFileSync, + statSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -34,8 +36,13 @@ afterAll(() => { else process.env["XDG_CACHE_HOME"] = realXdgCache; }); -const { discoverSkills, filterSkills, resolveSkills, skillSetHash } = - await import("../src/plugin/skill-discovery.js"); +const { + discoverSkills, + filterSkills, + resolveSkills, + skillSetHash, + resetLiveSkillScratch, +} = await import("../src/plugin/skill-discovery.js"); import type { DiscoveredSkill } from "../src/plugin/skill-discovery.js"; import type { Config } from "@opencode-ai/plugin"; @@ -46,6 +53,8 @@ function tmp(): string { return d; } afterEach(() => { + // Drop the content-only live-skill scratch root between tests. + resetLiveSkillScratch(); for (const d of dirs.splice(0)) { rmSync(d, { recursive: true, force: true }); } @@ -883,3 +892,84 @@ describe("live skills merge (app.skills)", () => { expect(result.withheld.find((w) => w.id === "live-denied")).toBeDefined(); }); }); + +describe("live built-in (content-only) skills", () => { + it("materialises a skill so the mirror can copy it", () => { + const cwd = tmp(); + const cacheRoot = tmp(); + const result = resolveSkills(cwd, undefined, undefined, { + cacheRoot, + liveSkills: [ + { + name: "customize-opencode", + description: "Use ONLY when editing opencode config.", + location: "", + content: "# Customizing opencode\n\nBody here.", + }, + ], + }); + const skill = result.skills.find((s) => s.id === "customize-opencode"); + expect(skill).toBeDefined(); + expect(skill!.description).toBe("Use ONLY when editing opencode config."); + // The materialised dir holds a real SKILL.md with frontmatter + body. + const md = readFileSync(join(skill!.sourceDir, "SKILL.md"), "utf8"); + expect(md).toContain("name: customize-opencode"); + expect(md).toContain("# Customizing opencode"); + // Second resolve with identical input must not rewrite the file + // (mtimes stable → skillSetHash stable → no per-turn mirror churn). + const mtimeBefore = statSync(join(skill!.sourceDir, "SKILL.md")).mtimeMs; + resolveSkills(cwd, undefined, undefined, { + cacheRoot, + liveSkills: [ + { + name: "customize-opencode", + description: "Use ONLY when editing opencode config.", + location: "", + content: "# Customizing opencode\n\nBody here.", + }, + ], + }); + const mtimeAfter = statSync(join(skill!.sourceDir, "SKILL.md")).mtimeMs; + expect(mtimeAfter).toBe(mtimeBefore); + }); + + it("rewrites the materialised copy when opencode's content changes", () => { + const cwd = tmp(); + const cacheRoot = tmp(); + const mk = (content: string) => + resolveSkills(cwd, undefined, undefined, { + cacheRoot, + liveSkills: [ + { + name: "builtin-v2", + description: "Built-in.", + location: "", + content, + }, + ], + }); + const first = mk("# v1"); + expect(first.skills.find((s) => s.id === "builtin-v2")).toBeDefined(); + const dir = first.skills.find((s) => s.id === "builtin-v2")!.sourceDir; + const second = mk("# v2 updated"); + const md = readFileSync(join(dir, "SKILL.md"), "utf8"); + expect(md).toContain("# v2 updated"); + expect(second.skills).toHaveLength(1); + }); + + it("skips content-only entries without content or description", () => { + const cwd = tmp(); + const result = resolveSkills(cwd, undefined, undefined, { + cacheRoot: tmp(), + liveSkills: [ + { name: "no-content", description: "d", location: "" }, + { + name: "no-description", + location: "", + content: "# body", + }, + ], + }); + expect(result.skills).toHaveLength(0); + }); +}); From 8505258f5a21eab593cf49946fe266068737fe9d Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Tue, 25 Aug 2026 14:23:49 -0500 Subject: [PATCH 5/7] fix: fetch live skills via raw GET /skill when SDK lacks app.skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The V1 SDK typings this repo builds against (1.18.18) predate the instance route (OpenApi identifier app.skills, path GET /skill), so client.app.skills was undefined at runtime and the live-skill merge — including opencode's customize-opencode skill — silently never ran. Fall back to the hey-api core client underneath the typed groups (client._client.get({ url: "/skill" })), verified against SDK 1.18.18 and opencode 1.18.21: returns { data: Skill[] } including the built-in skill with its full 16 KB content. Live E2E: temp project, branch build via project plugin entry, real cursor provider (auto-smart) — customize-opencode landed in .cursor/skills/ mid-session with the generated sentinel, 45 skills mirrored total. --- src/plugin/index.ts | 47 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/src/plugin/index.ts b/src/plugin/index.ts index dd3e701..5541c38 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -57,6 +57,43 @@ function apiKeyFromAuth(auth: Auth | undefined): string | undefined { return auth?.type === "api" ? auth.key : undefined; } +/** + * Fetch opencode's live skill inventory. The instance route is `GET /skill` + * (OpenApi identifier `app.skills`); newer SDK clients expose it as + * `client.app.skills(...)`, but the V1 SDK typings this repo builds against + * (1.18.18) predate it, so fall back to a raw `client.get` (hey-api). + * + * SAFETY: both casts widen typed surfaces to probe for methods that may not + * exist at runtime — the probe is optional-chained and the caller catches, so + * a host without the route degrades to the filesystem scan. + */ +async function fetchLiveSkills( + client: unknown, + query?: { query?: { directory?: string } }, +): Promise<{ data?: unknown } | undefined> { + const app = (client as { app?: unknown }).app as + | { skills?: (params?: unknown) => Promise<{ data?: unknown } | undefined> } + | undefined; + if (typeof app?.skills === "function") { + return app.skills(query); + } + // Fallback: the typed group predates the route, so reach the hey-api core + // client underneath (`_client`) and hit the route by URL. Verified against + // SDK 1.18.18: `_client.get({ url: "/skill" })` returns `{ data: Skill[] }`. + const inner = (client as { _client?: unknown })._client as + | { + get?: (opts?: { + url?: string; + query?: unknown; + }) => Promise<{ data?: unknown } | undefined>; + } + | undefined; + return inner?.get?.({ + url: "/skill", + ...(query?.query ? { query: query.query } : {}), + }); +} + /** * opencode plugin that adds a "Cursor" provider backed by the official Cursor * SDK (`@cursor/sdk`). @@ -675,14 +712,8 @@ export const CursorPlugin: Plugin = async (input) => { // scan can't see. Merged at lowest priority. let liveSkills: LiveSkill[] | undefined; try { - // SAFETY: `app.skills` is the V2 endpoint (/app/skills); the V1 - // client typings this repo imports predate it, so widen here. - // The call is optional-chained and caught, so a host without the - // endpoint degrades to the filesystem scan. - const app = client.app as unknown as { - skills?: (params?: unknown) => Promise<{ data?: unknown } | undefined>; - }; - const skillsRes = await app.skills?.(query); + let skillsRes: { data?: unknown } | undefined; + skillsRes = await fetchLiveSkills(client, query); liveSkills = skillsRes?.data as LiveSkill[] | undefined; } catch { // Live inventory is best-effort; the filesystem scan stands. From 862cd7c1657cfd87edc3b1bb43bb8a635d2447e1 Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Wed, 26 Aug 2026 09:54:19 -0500 Subject: [PATCH 6/7] fix: bound sidecar control-channel fetches with timeouts controlRequest had no abort signal: a stale port (something accepting but never responding, e.g. a hung opencode holding the port after the bridge closed) would hang Cursor's MCP discovery on tools/list, and tools/call could block forever. tools/list gets 5s (loopback list is instant; discovery degrades to an empty tool list on timeout) and tools/call gets 5 minutes (plugin tools can legitimately run long). New test spawns the sidecar against a never-responding server and asserts tools/list answers [] fast. --- src/sidecar/plugin-tools-mcp.mjs | 10 ++++-- test/plugin-tools-bridge.test.ts | 52 ++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/sidecar/plugin-tools-mcp.mjs b/src/sidecar/plugin-tools-mcp.mjs index 78bbeda..c798059 100644 --- a/src/sidecar/plugin-tools-mcp.mjs +++ b/src/sidecar/plugin-tools-mcp.mjs @@ -36,7 +36,7 @@ function logErr(message, extra) { } } -async function controlRequest(path, body) { +async function controlRequest(path, body, timeoutMs) { const res = await fetch(`http://127.0.0.1:${CONTROL_PORT}${path}`, { method: body ? "POST" : "GET", headers: { @@ -44,6 +44,10 @@ async function controlRequest(path, body) { authorization: `Bearer ${TOKEN}`, }, body: body ? JSON.stringify(body) : undefined, + // A stale/hung port must not hang Cursor's MCP discovery (tools/list) or + // block a tool call forever. Loopback list is instant; calls get a + // generous ceiling because plugin tools can legitimately run for minutes. + signal: AbortSignal.timeout(timeoutMs), }); const text = await res.text(); let json; @@ -61,7 +65,7 @@ async function controlRequest(path, body) { async function listTools() { try { - const data = await controlRequest("/tools"); + const data = await controlRequest("/tools", undefined, 5_000); return data?.tools ?? []; } catch (err) { logErr("tools/list failed", { error: String(err) }); @@ -71,7 +75,7 @@ async function listTools() { async function callTool(name, args) { try { - const data = await controlRequest("/call", { id: name, args: args ?? {} }); + const data = await controlRequest("/call", { id: name, args: args ?? {} }, 300_000); if (data?.ok === false) { return { isError: true, diff --git a/test/plugin-tools-bridge.test.ts b/test/plugin-tools-bridge.test.ts index e0bd0c2..21f135c 100644 --- a/test/plugin-tools-bridge.test.ts +++ b/test/plugin-tools-bridge.test.ts @@ -442,6 +442,58 @@ describe("plugin-tools bridge", () => { await bridge.close(); } }); + + it("tools/list fails fast against a hung control port", { timeout: 20_000 }, async () => { + // A control port that accepts connections but never responds (stale + // server) must not hang Cursor's MCP discovery — listTools aborts after + // its 5s budget and degrades to an empty tool list. + const { createServer } = await import("node:http"); + const hung = createServer(() => { + // never respond + }); + await new Promise((resolve) => + hung.listen(0, "127.0.0.1", () => resolve()), + ); + const address = hung.address(); + const port = typeof address === "object" && address ? address.port : 0; + try { + const child = spawn( + process.execPath, + ["src/sidecar/plugin-tools-mcp.mjs"], + { + env: { + ...process.env, + OPENCODE_PLUGIN_TOOLS_PORT: String(port), + OPENCODE_PLUGIN_TOOLS_TOKEN: "t", + }, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + const started = Date.now(); + const reply = await new Promise((resolve, reject) => { + let buf = ""; + child.stdout!.on("data", (chunk: Buffer) => { + buf += chunk.toString(); + const line = buf.split("\n").find((l) => l.trim()); + if (line) resolve(line); + }); + child.once("error", reject); + child.once("exit", () => reject(new Error("child exited"))); + child.stdin!.write( + JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }) + "\n", + ); + }); + const elapsed = Date.now() - started; + const parsed = JSON.parse(reply) as { result?: { tools?: unknown[] } }; + expect(parsed.result?.tools).toEqual([]); + // 5s budget + slack — anything under 10s proves we did not hang. + expect(elapsed).toBeLessThan(10_000); + child.kill(); + } finally { + await new Promise((resolve) => hung.close(() => resolve())); + hung.closeAllConnections?.(); + } + }); }); // --- full plugin wiring: config hook merges the bridge into mcpServers --- From 9f4b42a32b2b72c5b2088071d86e21b96ad6c9ba Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Wed, 26 Aug 2026 10:49:19 -0500 Subject: [PATCH 7/7] fix(plugin-tools): keep bridge alive on live config refresh Resolve node/bun for the MCP sidecar when execPath is a compiled host, add hung-control timeouts, and retain the plugin-tools bridge when live config.get omits plugin entries so chat.params does not tear it down. Co-authored-by: Cursor --- src/plugin/index.ts | 5 +- src/plugin/plugin-tools-bridge.ts | 33 ++++++++++++- src/sidecar/plugin-tools-mcp.mjs | 6 ++- test/plugin-tools-bridge.test.ts | 47 ++++++++++++++----- test/plugin-tools-wiring.test.ts | 78 ++++++++++++++++++++++++++++++- 5 files changed, 153 insertions(+), 16 deletions(-) diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 5541c38..141f25c 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -86,7 +86,7 @@ async function fetchLiveSkills( url?: string; query?: unknown; }) => Promise<{ data?: unknown } | undefined>; - } + } | undefined; return inner?.get?.({ url: "/skill", @@ -244,6 +244,9 @@ export const CursorPlugin: Plugin = async (input) => { pluginLog("warn", "plugin tool mirror skipped some plugins", result.failed); } if (result.tools.length === 0) { + if (!Array.isArray(config?.plugin) && pluginToolsMcpServer) { + return pluginToolsMcpServer; + } await pluginToolsBridge?.close(); pluginToolsBridge = undefined; mirroredTools = []; diff --git a/src/plugin/plugin-tools-bridge.ts b/src/plugin/plugin-tools-bridge.ts index f106f18..07db5bd 100644 --- a/src/plugin/plugin-tools-bridge.ts +++ b/src/plugin/plugin-tools-bridge.ts @@ -18,6 +18,7 @@ import { createServer, type Server } from "node:http"; import { randomBytes } from "node:crypto"; import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; +import { execSync } from "node:child_process"; import type { ToolContext } from "@opencode-ai/plugin"; import type { MirroredTool } from "./plugin-tool-registry.js"; import { pluginLog } from "../provider/log-bridge.js"; @@ -81,6 +82,32 @@ export function resolvePluginToolsScript(): string | undefined { return undefined; } +function execBasename(execPath: string): string { + const base = execPath.split(/[/\\]/).pop() ?? ""; + return base.replace(/\.exe$/i, "").toLowerCase(); +} + +export function resolvePluginToolsNodeCommand( + execPath = process.execPath, + lookupNode?: () => string | undefined, +): string | undefined { + const name = execBasename(execPath); + if (name === "node" || name === "bun") return execPath; + if (lookupNode) return lookupNode() || undefined; + try { + const out = execSync( + process.platform === "win32" ? "where node" : "command -v node", + { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }, + ).trim(); + return out.split("\n")[0] || undefined; + } catch { + return undefined; + } +} + export interface StartBridgeOptions { tools: MirroredTool[]; /** Directory the mirrored tools should see as `context.directory`. */ @@ -110,6 +137,11 @@ export async function startPluginToolsBridge( pluginLog("warn", "plugin-tools MCP script not found; bridge disabled"); return { close: async () => {} }; } + const nodePath = resolvePluginToolsNodeCommand(); + if (!nodePath) { + pluginLog("warn", "plugin-tools MCP needs node on PATH; bridge disabled"); + return { close: async () => {} }; + } const token = randomBytes(24).toString("hex"); const toolById = new Map(options.tools.map((t) => [t.id, t])); @@ -207,7 +239,6 @@ export async function startPluginToolsBridge( return { close: async () => {} }; } - const nodePath = process.execPath; return { mcpServer: { type: "stdio", diff --git a/src/sidecar/plugin-tools-mcp.mjs b/src/sidecar/plugin-tools-mcp.mjs index c798059..c733001 100644 --- a/src/sidecar/plugin-tools-mcp.mjs +++ b/src/sidecar/plugin-tools-mcp.mjs @@ -75,7 +75,11 @@ async function listTools() { async function callTool(name, args) { try { - const data = await controlRequest("/call", { id: name, args: args ?? {} }, 300_000); + const data = await controlRequest( + "/call", + { id: name, args: args ?? {} }, + 300_000, + ); if (data?.ok === false) { return { isError: true, diff --git a/test/plugin-tools-bridge.test.ts b/test/plugin-tools-bridge.test.ts index 21f135c..56b045e 100644 --- a/test/plugin-tools-bridge.test.ts +++ b/test/plugin-tools-bridge.test.ts @@ -11,6 +11,7 @@ import { mirrorPluginTools, } from "../src/plugin/plugin-tool-registry.js"; import { + resolvePluginToolsNodeCommand, resolvePluginToolsScript, startPluginToolsBridge, } from "../src/plugin/plugin-tools-bridge.js"; @@ -295,6 +296,30 @@ describe("plugin-tools bridge", () => { expect(resolvePluginToolsScript()).toMatch(/plugin-tools-mcp\.mjs$/); }); + it("uses execPath when it is node or bun", () => { + expect(resolvePluginToolsNodeCommand("/opt/homebrew/bin/node")).toBe( + "/opt/homebrew/bin/node", + ); + expect(resolvePluginToolsNodeCommand("/Users/me/.bun/bin/bun")).toBe( + "/Users/me/.bun/bin/bun", + ); + expect( + resolvePluginToolsNodeCommand("C:\\Program Files\\nodejs\\node.exe"), + ).toBe("C:\\Program Files\\nodejs\\node.exe"); + }); + + it("looks up node when execPath is a compiled host binary", () => { + expect( + resolvePluginToolsNodeCommand( + "/opt/homebrew/bin/opencode", + () => "/usr/local/bin/node", + ), + ).toBe("/usr/local/bin/node"); + expect( + resolvePluginToolsNodeCommand("/opt/homebrew/bin/opencode", () => undefined), + ).toBeUndefined(); + }); + it("serves tools/list and tools/call through the MCP child", async () => { const bridge = await startPluginToolsBridge({ tools: [fakeTool("fake_echo")], @@ -443,7 +468,9 @@ describe("plugin-tools bridge", () => { } }); - it("tools/list fails fast against a hung control port", { timeout: 20_000 }, async () => { + it("tools/list fails fast against a hung control port", { + timeout: 20_000, + }, async () => { // A control port that accepts connections but never responds (stale // server) must not hang Cursor's MCP discovery — listTools aborts after // its 5s budget and degrades to an empty tool list. @@ -457,18 +484,14 @@ describe("plugin-tools bridge", () => { const address = hung.address(); const port = typeof address === "object" && address ? address.port : 0; try { - const child = spawn( - process.execPath, - ["src/sidecar/plugin-tools-mcp.mjs"], - { - env: { - ...process.env, - OPENCODE_PLUGIN_TOOLS_PORT: String(port), - OPENCODE_PLUGIN_TOOLS_TOKEN: "t", - }, - stdio: ["pipe", "pipe", "pipe"], + const child = spawn(process.execPath, ["src/sidecar/plugin-tools-mcp.mjs"], { + env: { + ...process.env, + OPENCODE_PLUGIN_TOOLS_PORT: String(port), + OPENCODE_PLUGIN_TOOLS_TOKEN: "t", }, - ); + stdio: ["pipe", "pipe", "pipe"], + }); const started = Date.now(); const reply = await new Promise((resolve, reject) => { let buf = ""; diff --git a/test/plugin-tools-wiring.test.ts b/test/plugin-tools-wiring.test.ts index 15c3d7c..1530701 100644 --- a/test/plugin-tools-wiring.test.ts +++ b/test/plugin-tools-wiring.test.ts @@ -7,6 +7,7 @@ import { join } from "node:path"; vi.mock("../src/model-discovery.js", () => ({ discoverModels: async () => ({ models: [], source: "fallback" }), toOpencodeModels: () => ({}), + modelSupportsReasoning: () => false, })); describe("CursorPlugin plugin-tools wiring", () => { @@ -75,7 +76,7 @@ describe("CursorPlugin plugin-tools wiring", () => { const prevCache = process.env.XDG_CACHE_HOME; process.env.HOME = home; process.env.XDG_CONFIG_HOME = join(home, ".config"); - delete process.env.XDG_CACHE_HOME; + process.env.XDG_CACHE_HOME = join(home, ".cache"); try { const cwd = tmp(); const hooks = await plugin({ @@ -260,4 +261,79 @@ describe("CursorPlugin plugin-tools wiring", () => { process.env.XDG_CACHE_HOME = prevCache; } }); + + it("keeps the plugin-tools bridge when live config.get omits plugin", async () => { + const { default: plugin } = await import("../src/plugin/index.js"); + const home = tmp(); + const cacheRoot = join(home, ".cache", "opencode", "packages"); + await writeToolPlugin(cacheRoot); + const prevHome = process.env.HOME; + const prevXdg = process.env.XDG_CONFIG_HOME; + const prevCache = process.env.XDG_CACHE_HOME; + process.env.HOME = home; + process.env.XDG_CONFIG_HOME = join(home, ".config"); + process.env.XDG_CACHE_HOME = join(home, ".cache"); + try { + const cwd = tmp(); + const client = { + app: { log: async () => ({}) }, + config: { + get: async () => ({ data: {} }), + }, + mcp: { + status: async () => ({ + data: { "context-mode": { status: "connected" } }, + }), + }, + }; + const hooks = await plugin({ + directory: cwd, + client, + project: {}, + worktree: cwd, + serverUrl: new URL("http://localhost:4096"), + experimental_workspace: { register() {} }, + } as never); + const config = { + plugin: ["wire-plugin@latest"], + provider: {}, + mcp: {}, + } as never; + await hooks.config!(config); + const servers = ( + config as { + provider: Record }>; + } + ).provider["cursor"]!.options!["mcpServers"] as Record< + string, + { env: Record } + >; + const bridgeServer = servers["opencode-plugin-tools"]; + expect(bridgeServer).toBeDefined(); + const port = Number(bridgeServer!.env["OPENCODE_PLUGIN_TOOLS_PORT"]); + const token = bridgeServer!.env["OPENCODE_PLUGIN_TOOLS_TOKEN"]; + const output = { options: {} as Record }; + await hooks["chat.params"]!( + { + model: { providerID: "cursor" }, + sessionID: "s1", + agent: "build", + } as never, + output as never, + ); + const liveServers = output.options["mcpServers"] as + | Record }> + | undefined; + expect(liveServers?.["opencode-plugin-tools"]).toBeDefined(); + const listed = await fetch(`http://127.0.0.1:${port}/tools`, { + headers: { authorization: `Bearer ${token}` }, + }); + expect(listed.ok).toBe(true); + await hooks.dispose!(); + } finally { + process.env.HOME = prevHome; + process.env.XDG_CONFIG_HOME = prevXdg; + process.env.XDG_CACHE_HOME = prevCache; + } + }); });