Skip to content

Commit 49ed903

Browse files
feat: mirror opencode skills into .cursor/skills/ for Cursor agent discovery (#90)
* feat: mirror opencode skills into .cursor/skills/ for Cursor agent discovery Discover opencode's resolved skills (project + global + config.skills.paths), filter through permission config, and materialise them as a git-ignored mirror in <cwd>/.cursor/skills/ with a 'generated: opencode-cursor' sentinel. An <available_skills> catalogue is appended to the generated system rule so the Cursor agent can discover and load skills on demand. - New: src/plugin/skill-discovery.ts — filesystem walk, frontmatter parsing, permission filtering (map-form + rule-array), extraPaths, path expansion, skillSetHash including all files (mtime + size). - New: src/provider/skill-mirror.ts — materialisation with sentinel, git-ignore, idempotent writes, stale-dir pruning, user-owned protection, per-file 1MB skip, total 10MB cap, removeSkillMirror for dispose, buildSkillsCatalogue. - Updated: src/provider/system-rule.ts — writeSystemRule and resolveSystemDelivery accept optional skillsCatalogue appended to rule body. - Updated: src/plugin/index.ts — forwardSkills option (default true), skills.include/exclude override, materialisation in config hook, live re-sync in chat.params hook (hash-gated), currentSkillsCatalogue always forwarded per turn, removeSkillMirror in dispose. - Updated: src/provider/language-model.ts — dynamic + static catalogue resolution (no self-provisioning — respects forwardSkills:false). - Updated: src/provider/delegate.ts + src/plugin/cursor-tools.ts — settingSources: ['project'] passed to delegate's acquireAgent. - Updated: src/provider/index.ts — skillsCatalogue in provider options. - Tests: 403 passing (25 skill-discovery, 14 skill-mirror, 10 plugin-skill- mirror, 6 catalogue in language-model-system, plus existing). - Docs: README.md (Skills section), CHANGELOG.md ([Unreleased]), SECURITY.md (skills mirror threat model). - .gitignore: exclude .cursor/ (plugin-generated runtime artifacts). * fix(skills): follow symlinked skill dirs and files Dirent.isDirectory() is false for a symlink pointing at a directory, so a skill linked in from a shared checkout was dropped from the mirror with no warning. Symlinked supporting files were lost the same way. Entries are now classified through the link target, with a resolved-path visited set guarding the cycles that following links admits. * docs(skills): correct deny scope, note plugin-skill gap skills.include is checked before permissions resolve, so it re-admits a denied skill by design — SECURITY.md read as though deny were absolute. Skills bundled in opencode plugin packages live in the package cache, which is not a scanned location, and the SDK exposes no skills API to resolve them. Documented so the omission is visible rather than silent. * style(plugin): normalize indentation in skills bridge blocks * refactor(skills): route mirror diagnostics through pluginLog 0.7.0 (#85) moved the plugin's own diagnostics off console.* and onto opencode's structured logging API. The skills bridge predated that and still wrote to the terminal, leaving the only console.* calls in src/. Withheld skills and the mirror-failure path now pass structured extra fields instead of pre-formatted strings, so the reason codes are queryable rather than embedded in a message. Also corrects the plugin-cache path in the README limitation: it is platform-dependent, as PLUGIN_CACHE_PATH shows, and was documented as POSIX-only. --------- Co-authored-by: Wayne Simpson <wayne@nocodecreative.io>
1 parent be7a36e commit 49ed903

17 files changed

Lines changed: 2567 additions & 14 deletions

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ coverage/
1010
# Local editor / agent tooling
1111
.opencode/
1212
.claude/
13+
.cursor/
1314

1415
# Local-only dev config (never commit)
1516
opencode.json

‎CHANGELOG.md‎

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

55
## [Unreleased]
66

7+
- **Skills bridge: opencode skills are now mirrored into `.cursor/skills/` for
8+
the Cursor agent.** Both project-scoped and global skills are discovered
9+
(matching opencode's resolution order: `.opencode/skills/`, `.claude/skills/`,
10+
`.agents/skills/`, walked up to the git worktree root, plus global
11+
`~/.config/opencode/skills/` etc.), filtered through opencode's `permission`
12+
config, and materialised as a git-ignored mirror with a `generated:
13+
opencode-cursor` sentinel. An `<available_skills>` catalogue is appended to
14+
the generated system rule so the Cursor agent can discover and load skills on
15+
demand. Works for the primary agent, Cursor sub-agents, and `cursor_delegate`
16+
(which passes `settingSources: ["project"]`). `ask`-permissioned skills are
17+
withheld (the ask prompt can't cross the Cursor boundary). Opt out with
18+
`forwardSkills: false`; manual override with `skills: { include, exclude }`.
19+
User-owned `.cursor/skills/<id>/` directories are never overwritten.
20+
`config.skills.paths` directories are also scanned (lowest priority,
21+
first-wins on duplicate ids). Symlinked skill directories and symlinked
22+
supporting files are followed (broken links and symlink loops are skipped).
23+
Mirror diagnostics (withheld skills, oversized files, write failures) route
24+
through opencode's structured plugin logging rather than the terminal,
25+
matching 0.7.0's logging change. `config.skills.urls` (HTTP catalogs) and
26+
skills bundled inside opencode plugin packages are not yet supported.
27+
728
## [0.7.0] — 2026-07-30
829

930
Structured logging (#85), the stream-watchdog tool-phase budget (#86), and the

‎README.md‎

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,8 @@ See [SECURITY.md](./SECURITY.md) for the full threat model.
182182
| `session` | `"auto"` | Session reuse strategy — see [Session reuse](#session-reuse-session) |
183183
| `forwardMcp` | `true` | Forward opencode's configured MCP servers to the Cursor agent |
184184
| `mcpServers` | — | Extra MCP servers (Cursor `McpServerConfig` shape); merged with forwarded ones |
185+
| `forwardSkills` | `true` | Mirror opencode's resolved skills into `.cursor/skills/` — see [Skills](#skills) |
186+
| `skills` | — | Manual skill override: `{ include?: string[], exclude?: string[] }` — see [Skills](#skills) |
185187
| `toolDisplay` | `"blocks"` | How Cursor's internal tool activity is shown — see [Tool display](#tool-display) |
186188
| `systemPrompt` | `"rules"` | How opencode's system prompt reaches the agent — see [System prompt](#system-prompt) |
187189
| `transport` | — | Cursor agent transport (`"http1"` \| `"http2-direct"` \| `"sidecar"`) — see [Transport](#transport) |
@@ -280,9 +282,84 @@ Disabled entries (`enabled: false`) are skipped. Remote servers requiring OAuth
280282
shareable `clientId` are also skipped (a one-time toast says which). Disable forwarding with
281283
`forwardMcp: false`.
282284

283-
> **Note:** This forwards MCP **servers**. opencode's own skills and subagents are not exposed to
284-
> the Cursor agent. To load your local Cursor skills/rules, use
285-
> `settingSources: ["project","user"]`.
285+
> **Note:** This forwards MCP **servers**. opencode's skills are mirrored into
286+
> `.cursor/skills/` automatically — see [Skills](#skills).
287+
288+
## Skills
289+
290+
With `forwardSkills: true` (default), the plugin mirrors opencode's resolved
291+
skills into `<cwd>/.cursor/skills/` — a git-ignored directory that Cursor
292+
discovers natively when the `project` settings layer is loaded. This works for
293+
both the primary agent and any Cursor sub-agent, because the mirror is written
294+
at plugin init (before any turn) and `settingSources` lives on the provider
295+
config (which survives the sub-agent model-options drop).
296+
297+
The mirror includes:
298+
299+
- **Project skills** from `.opencode/skills/`, `.opencode/skill/`,
300+
`.claude/skills/`, `.agents/skills/` (walked up to the git worktree root).
301+
- **Global skills** from `~/.config/opencode/skills/` and `~/.config/opencode/skill/`,
302+
`~/.claude/skills/`, `~/.agents/skills/`, `~/.opencode/skills/` and `~/.opencode/skill/`.
303+
- **Configured paths** from `config.skills.paths` in your `opencode.json` — additional
304+
directories scanned at the lowest priority (project and standard global locations
305+
win on duplicate ids). `~/` prefixes are expanded to your home directory; relative
306+
paths are resolved against the project directory.
307+
- **Supporting files** alongside each `SKILL.md` (preserving relative paths).
308+
- An `<available_skills>` catalogue appended to the generated system rule,
309+
listing each skill's id and description so the Cursor agent can load them on
310+
demand.
311+
312+
> **Note:** `config.skills.urls` (HTTP skill catalogs) are not yet supported by
313+
> the mirror. If you rely on URL-sourced skills, they will not appear in
314+
> `.cursor/skills/`.
315+
316+
### Permission filtering
317+
318+
Skills are filtered through opencode's live `permission` config before
319+
mirroring:
320+
321+
- **`allow`** (default): included in the mirror.
322+
- **`deny`**: excluded entirely.
323+
- **`ask`**: excluded — the ask prompt can't be enforced across the Cursor
324+
boundary. The plugin logs which skills were withheld and why.
325+
326+
### Manual override
327+
328+
```json
329+
{
330+
"provider": {
331+
"cursor": {
332+
"options": {
333+
"skills": {
334+
"include": ["my-skill", "other-skill"],
335+
"exclude": ["internal-*"]
336+
}
337+
}
338+
}
339+
}
340+
}
341+
```
342+
343+
`include` keeps only the listed skills (and overrides `deny` permission — the
344+
user explicitly asked for them). `exclude` always drops the listed skills.
345+
346+
### Limitations
347+
348+
- **Skills bundled inside opencode plugins are not mirrored.** Those ship under
349+
`<opencode-package-cache>/<pkg>/node_modules/<pkg>/skills/` (on macOS/Linux
350+
`~/.cache/opencode/packages/`; on Windows `%LocalAppData%\opencode\cache\packages\`),
351+
which is not a scanned location — `@opencode-ai/sdk` exposes no skills API, so
352+
the mirror resolves skills from the filesystem itself. Skills that reach
353+
opencode only through a plugin will be absent from `.cursor/skills/`. To
354+
mirror one, add its directory to `config.skills.paths`.
355+
- A user-owned `.cursor/skills/<id>/SKILL.md` (without the `generated:
356+
opencode-cursor` sentinel) is never overwritten or deleted.
357+
- Individual files larger than 1 MB are skipped (the rest of the skill is still
358+
mirrored). Total mirror size is capped at 10 MB.
359+
- `cursor_delegate` with a `cwd` different from the session directory does not
360+
mirror skills into that cwd (but does pass `settingSources: ["project"]` so
361+
any pre-existing `.cursor/skills/` there still loads).
362+
- `cursor_cloud_agent` targets a remote repo and does not inherit skills.
286363

287364
## Delegation tools
288365

@@ -296,7 +373,9 @@ are gated by opencode's `permission` config:
296373
### `cursor_delegate` (local)
297374

298375
Runs one Cursor turn as a permission-gated tool call. Your primary opencode model hands off a
299-
discrete subtask and gets the result back.
376+
discrete subtask and gets the result back. The delegate passes `settingSources: ["project"]` so
377+
any pre-existing `.cursor/skills/` in the delegate's cwd loads (skills are not mirrored into a
378+
non-session cwd — see [Skills limitations](#limitations)).
300379

301380
| Arg | Required | Meaning |
302381
| --- | --- | --- |

‎SECURITY.md‎

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

39+
### Skills mirror — instructions cross the permission boundary
40+
41+
When `forwardSkills` is enabled (default), opencode's resolved skills are
42+
mirrored into `<cwd>/.cursor/skills/` so the Cursor agent can discover and load
43+
them. This means **skill instructions now reach an agent running outside
44+
opencode's permission system**. Key implications:
45+
46+
- Skill content is copied to disk and loaded by Cursor's own agent runtime —
47+
opencode cannot intervene, revoke, or gate access once the files are written.
48+
- Skills are discovered from all standard locations (project `.opencode/skills/`,
49+
`.claude/skills/`, `.agents/skills/`, global `~/.config/opencode/skills/`,
50+
etc.) **and** from `config.skills.paths` — additional directories configured
51+
in `opencode.json`. If `skills.paths` points to directories outside the
52+
project, skills from those directories will also be mirrored.
53+
- `deny`-permissioned skills are excluded from the mirror before writing, unless
54+
explicitly re-added via a manual `skills.include` list, which takes precedence
55+
over permission config by design.
56+
- `ask`-permissioned skills are also excluded, because the ask prompt cannot be
57+
enforced across the Cursor boundary (there is no way for Cursor to relay the
58+
approval request back to opencode's permission system).
59+
- The mirror is written at plugin init and re-synced each turn, so permission
60+
changes take effect on the next turn (not retroactively on already-loaded
61+
skills within a running Cursor agent).
62+
- A user-owned `.cursor/skills/<id>/SKILL.md` (without the `generated:
63+
opencode-cursor` sentinel) is never overwritten or deleted — the plugin
64+
respects existing user content.
65+
- `config.skills.urls` (HTTP skill catalogs) are not mirrored — only filesystem
66+
paths are scanned.
67+
3968
## Credentials
4069

4170
- Your `CURSOR_API_KEY` is read from opencode auth storage or the environment.

‎scripts/verify-skill-mirror.mjs‎

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// Standalone verification: exercises the full skill discovery + mirror pipeline
2+
// against the real skills on this machine, without needing opencode or a Cursor
3+
// API key. Read-only against real skills; writes only to a temp dir.
4+
//
5+
// Run: npx tsx scripts/verify-skill-mirror.mjs
6+
import { mkdtempSync, rmSync, existsSync, readFileSync, readdirSync } from "node:fs";
7+
import { tmpdir } from "node:os";
8+
import { join } from "node:path";
9+
import { homedir } from "node:os";
10+
11+
const { discoverSkills, resolveSkills, skillSetHash } = await import(
12+
"../src/plugin/skill-discovery.ts"
13+
);
14+
const { writeSkillMirror, removeSkillMirror, buildSkillsCatalogue } = await import(
15+
"../src/provider/skill-mirror.ts"
16+
);
17+
18+
// Read skills.paths from the real opencode config.
19+
const configPath = join(homedir(), ".config", "opencode", "opencode.jsonc");
20+
const configRaw = readFileSync(configPath, "utf8");
21+
// Strip JSONC comments — but only // that aren't inside strings.
22+
// Simple approach: remove // comments and /* */ comments, but preserve
23+
// // inside quoted strings by tracking quote state.
24+
function stripJsonc(text) {
25+
let result = "";
26+
let inString = false;
27+
let i = 0;
28+
while (i < text.length) {
29+
const ch = text[i];
30+
if (inString) {
31+
result += ch;
32+
if (ch === "\\" && i + 1 < text.length) {
33+
result += text[i + 1];
34+
i += 2;
35+
continue;
36+
}
37+
if (ch === '"') inString = false;
38+
i++;
39+
continue;
40+
}
41+
if (ch === '"') {
42+
inString = true;
43+
result += ch;
44+
i++;
45+
continue;
46+
}
47+
if (ch === "/" && text[i + 1] === "/") {
48+
// Line comment — skip to end of line.
49+
while (i < text.length && text[i] !== "\n") i++;
50+
continue;
51+
}
52+
if (ch === "/" && text[i + 1] === "*") {
53+
// Block comment — skip to closing.
54+
i += 2;
55+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++;
56+
i += 2;
57+
continue;
58+
}
59+
result += ch;
60+
i++;
61+
}
62+
return result;
63+
}
64+
const config = JSON.parse(stripJsonc(configRaw));
65+
const extraPaths = config.skills?.paths ?? [];
66+
67+
console.log("=== Skill Mirror Verification ===\n");
68+
console.log(`Config skills.paths: ${extraPaths.length} entries`);
69+
for (const p of extraPaths) console.log(` - ${p}`);
70+
console.log();
71+
72+
// Use the opencode config dir as cwd (where opencode actually runs).
73+
const cwd = join(homedir(), ".config", "opencode");
74+
console.log(`Discovery cwd: ${cwd}`);
75+
76+
// Layer 1: Discover skills (with extraPaths from config).
77+
const discovered = discoverSkills(cwd, extraPaths);
78+
console.log(`\nDiscovered: ${discovered.length} skills`);
79+
for (const s of discovered.sort((a, b) => a.id.localeCompare(b.id))) {
80+
console.log(` - ${s.id}: ${s.description.slice(0, 80)}${s.description.length > 80 ? "…" : ""}`);
81+
}
82+
83+
// Layer 2: Filter through permissions.
84+
const resolved = resolveSkills(cwd, config, undefined);
85+
console.log(`\nAfter permission filtering: ${resolved.skills.length} permitted, ${resolved.withheld.length} withheld`);
86+
for (const w of resolved.withheld) {
87+
console.log(` WITHHELD: ${w.id} — ${w.reason}`);
88+
}
89+
90+
// Layer 3: Materialise to a temp dir.
91+
const tmpDir = mkdtempSync(join(tmpdir(), "skill-mirror-verify-"));
92+
console.log(`\nMirror target: ${tmpDir}`);
93+
const warnings = [];
94+
const writeResult = writeSkillMirror(tmpDir, resolved.skills, (msg) => warnings.push(msg));
95+
console.log(`Write result: ${writeResult}`);
96+
if (warnings.length > 0) {
97+
console.log(`Warnings (${warnings.length}):`);
98+
for (const w of warnings) console.log(` - ${w}`);
99+
}
100+
101+
// Layer 4: Verify the mirror on disk.
102+
const mirrorDir = join(tmpDir, ".cursor", "skills");
103+
const mirrored = existsSync(mirrorDir) ? readdirSync(mirrorDir, { withFileTypes: true })
104+
.filter((e) => e.isDirectory())
105+
.map((e) => e.name) : [];
106+
console.log(`\nMirrored directories: ${mirrored.length}`);
107+
for (const id of mirrored.sort()) {
108+
const skillMd = join(mirrorDir, id, "SKILL.md");
109+
const content = readFileSync(skillMd, "utf8");
110+
const hasSentinel = content.includes("generated: opencode-cursor");
111+
console.log(` ${id}: sentinel=${hasSentinel}`);
112+
}
113+
114+
// Layer 5: Verify .gitignore.
115+
const gitignorePath = join(mirrorDir, ".gitignore");
116+
if (existsSync(gitignorePath)) {
117+
const gi = readFileSync(gitignorePath, "utf8");
118+
console.log(`\n.gitignore entries: ${gi.split(/\r?\n/).filter(Boolean).length}`);
119+
console.log(` Contains .gitignore itself: ${gi.includes(".gitignore")}`);
120+
}
121+
122+
// Layer 6: Verify the catalogue.
123+
const catalogue = buildSkillsCatalogue(resolved.skills);
124+
if (catalogue) {
125+
console.log(`\nCatalogue length: ${catalogue.length} chars`);
126+
console.log(` Contains <available_skills>: ${catalogue.includes("<available_skills>")}`);
127+
console.log(` Contains </available_skills>: ${catalogue.includes("</available_skills>")}`);
128+
const skillIdsInCatalogue = resolved.skills.filter((s) => catalogue.includes(`**${s.id}**`));
129+
console.log(` Skills listed in catalogue: ${skillIdsInCatalogue.length} / ${resolved.skills.length}`);
130+
}
131+
132+
// Layer 7: Verify idempotency.
133+
const hash1 = skillSetHash(resolved.skills);
134+
const hash2 = skillSetHash(resolved.skills);
135+
console.log(`\nIdempotency: hash stable = ${hash1 === hash2}`);
136+
137+
// Layer 8: Verify dispose.
138+
removeSkillMirror(tmpDir);
139+
const afterDispose = existsSync(mirrorDir) ? readdirSync(mirrorDir, { withFileTypes: true })
140+
.filter((e) => e.isDirectory())
141+
.map((e) => e.name) : [];
142+
console.log(`\nAfter removeSkillMirror: ${afterDispose.length} dirs remain`);
143+
144+
// Cleanup.
145+
rmSync(tmpDir, { recursive: true, force: true });
146+
147+
// Summary.
148+
console.log("\n=== Summary ===");
149+
console.log(`Discovered: ${discovered.length}`);
150+
console.log(`Permitted: ${resolved.skills.length}`);
151+
console.log(`Withheld: ${resolved.withheld.length}`);
152+
console.log(`Mirrored: ${mirrored.length}`);
153+
console.log(`Warnings: ${warnings.length}`);
154+
console.log(`Dispose clean: ${afterDispose.length === 0}`);
155+
156+
const allPass = discovered.length > 0
157+
&& resolved.skills.length === discovered.length
158+
&& mirrored.length === resolved.skills.length
159+
&& warnings.length === 0
160+
&& afterDispose.length === 0;
161+
162+
console.log(`\nResult: ${allPass ? "PASS" : "FAIL"}`);
163+
if (!allPass) {
164+
console.log(" Discrepancies:");
165+
if (discovered.length === 0) console.log(" - No skills discovered");
166+
if (resolved.skills.length !== discovered.length) console.log(` - Permission filtering changed count: ${discovered.length} -> ${resolved.skills.length}`);
167+
if (mirrored.length !== resolved.skills.length) console.log(` - Mirror count mismatch: expected ${resolved.skills.length}, got ${mirrored.length}`);
168+
if (warnings.length > 0) console.log(` - ${warnings.length} warnings during mirror`);
169+
if (afterDispose.length > 0) console.log(` - Dispose left ${afterDispose.length} dirs`);
170+
}
171+
process.exit(allPass ? 0 : 1);

‎src/plugin/cursor-tools.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,11 @@ export function buildCursorTools(deps: CursorToolDeps): Record<string, ToolDefin
186186
prompt: args.prompt,
187187
model: args.model,
188188
cwd: args.additionalCwds?.length ? [baseCwd, ...args.additionalCwds] : baseCwd,
189+
// Honour the user's project settings layer so delegated turns pick
190+
// up `.cursor/skills/` from the delegate's cwd. The delegate defaults
191+
// to ["project"] on its own, so this is only needed when the user
192+
// explicitly configured settingSources on the provider.
193+
settingSources: ["project"],
189194
...(args.mode ? { mode: args.mode } : {}),
190195
...(args.thinking ? { thinking: args.thinking } : {}),
191196
...(args.sandbox !== undefined ? { sandbox: args.sandbox } : {}),

0 commit comments

Comments
 (0)