feat: mirror opencode skills into .cursor/skills/ for Cursor agent discovery - #78
feat: mirror opencode skills into .cursor/skills/ for Cursor agent discovery#78WayneSimpson wants to merge 1 commit into
Conversation
…scovery 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).
justin-carper
left a comment
There was a problem hiding this comment.
Nice work on this — the design is well thought through and the safety mechanisms are the right ones. The generated: opencode-cursor sentinel guarding every overwrite and delete is exactly the correct call, the self-ignoring .gitignore inside .cursor/skills/ is a neat touch, and 49 new tests covering permission forms, precedence order, idempotency, pruning, size caps, and read-only directories is genuinely thorough. The docs updates are honest about the tradeoffs, which I appreciate.
I verified locally: 403 tests pass across 32 files. (The two semver typecheck errors I hit are pre-existing and unrelated to this branch.)
Two things worth flagging, one blocking-ish and one not:
Skills registered by opencode plugins aren't discovered (not blocking — happy to merge without a fix)
discoverSkills scans a fixed set of filesystem locations. Skills that ship inside an opencode plugin package land in the plugin package cache (~/.cache/opencode/packages/<pkg>/node_modules/<pkg>/skills/), which isn't in the scan list — so they're invisible to the mirror.
Repro: install any plugin that bundles its own skills, then compare opencode's active skill list against discoverSkills() output. The plugin-shipped ones are absent, with no warning.
To be clear, I don't think this is a flaw in your approach — I checked @opencode-ai/sdk v1.15.13 and it exposes no skills API at all, so reimplementing the resolution was the only option available to you. The improvement here stands on its own and I'd rather ship it than hold it.
The ask is just that the gap be visible rather than silent, so a user doesn't quietly get a subset of their skills and never know. Either a line in the README limitations list, or a one-time console.warn when the plugin package cache exists but wasn't scanned. If you'd like to take a swing at actually resolving them, that'd be welcome, but treat it as optional follow-up.
One nitpick, take it or leave it: the new block at src/plugin/index.ts:156-200 sits one tab level shallower than the surrounding code, the closing braces at 291-294 are de-dented, and cursor-tools.ts:184-199 uses tabs inside an otherwise space-indented function. All cosmetic and it compiles fine — looks like an editor artifact. Only mentioning it because the brace depth at 291-294 made me double-check which block the catch was closing.
Left two inline suggestions below. Thanks for putting this together.
| } | ||
| const found: Array<{ id: string; sourceDir: string }> = []; | ||
| for (const entry of entries) { | ||
| if (!entry.isDirectory()) continue; |
There was a problem hiding this comment.
Symlinked skill directories get silently dropped here. Dirent.isDirectory() returns false for a symlink pointing at a directory (the dirent type is DT_LNK), so the whole skill disappears with no warning.
Repro:
mkdir -p /tmp/t/real && printf -- '---\nname: x\ndescription: d\n---\n' > /tmp/t/real/SKILL.md
mkdir -p /tmp/t/proj/.opencode/skills && ln -s /tmp/t/real /tmp/t/proj/.opencode/skills/linked
# discoverSkills("/tmp/t/proj") does not return "linked"
Symlinking skills in from a shared checkout seems like a reasonably common setup, so this is worth catching.
The nice part is that line 129's existsSync(join(skillDir, "SKILL.md")) already does the validation for you, since existsSync follows symlinks. So admitting symlinks is safe with no extra checks:
| if (!entry.isDirectory()) continue; | |
| if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; |
I verified this admits symlinked skill dirs while still rejecting broken symlinks, symlinks pointing at files, and directories without a SKILL.md.
Same pattern affects collectFiles (line 151) and copyTree in skill-mirror.ts (line 92) for symlinked supporting files — lower impact, since that loses one file rather than a whole skill. Your call whether to fix those too.
| etc.) **and** from `config.skills.paths` — additional directories configured | ||
| in `opencode.json`. If `skills.paths` points to directories outside the | ||
| project, skills from those directories will also be mirrored. | ||
| - `deny`-permissioned skills are excluded from the mirror before writing. |
There was a problem hiding this comment.
Small correction — this is stricter than what the code actually does. filterSkills checks the manual include list at skill-discovery.ts:393, which is before permissions are resolved at line 407. So skills.include re-admits a skill that permission config denies.
That looks intentional (there's a test named "manual include keeps a skill even if permission denies it", and the doc comment says as much), so I'm only suggesting the security doc match the behaviour — this is the file someone reads when deciding whether deny is a hard guarantee.
| - `deny`-permissioned skills are excluded from the mirror before writing. | |
| - `deny`-permissioned skills are excluded from the mirror before writing, unless | |
| explicitly re-added via a manual `skills.include` list, which takes precedence | |
| over permission config by design. |
Summary
This PR adds a skills bridge that mirrors opencode's resolved skills into
<cwd>/.cursor/skills/so the Cursor agent (and Cursor sub-agents) can discover and load them natively via theprojectsettings layer.What it does
.opencode/skills/,.claude/skills/,.agents/skills/(walked up to the git worktree root), global~/.config/opencode/skills/, plusconfig.skills.pathsdirectories (lowest priority, first-wins on duplicate ids).permissionconfig (allow/deny/ask).ask-permissioned skills are withheld (the ask prompt can't cross the Cursor boundary). Manualskills: { include, exclude }override is supported with wildcard patterns.generated: opencode-cursorsentinel in eachSKILL.md's frontmatter. User-owned skill directories (no sentinel) are never overwritten. Supporting files are copied alongside, with per-file 1MB and total 10MB caps.<available_skills>section appended to the generated system rule, so the Cursor agent knows what's available and can load skills on demand.chat.paramshook (hash-gated, includes all file mtimes + sizes). The current catalogue is always forwarded per turn, including an empty string when all skills are removed (clearing the catalogue from the rule).removeSkillMirrorandremoveSystemRuledelete only sentinel-bearing files on plugin dispose.New files
src/plugin/skill-discovery.tsskillSetHashsrc/provider/skill-mirror.tsscripts/verify-skill-mirror.mjstest/skill-discovery.test.tstest/skill-mirror.test.tstest/plugin-skill-mirror.test.tsUpdated files
src/plugin/index.ts—forwardSkillsoption, config-hook materialisation, chat.params live re-sync, dispose cleanupsrc/provider/language-model.ts— dynamic + static catalogue resolution (no self-provisioning — respectsforwardSkills: false)src/provider/system-rule.ts—writeSystemRule/resolveSystemDeliveryaccept optionalskillsCataloguesrc/provider/delegate.ts+src/plugin/cursor-tools.ts—settingSources: ["project"]for delegatesrc/provider/index.ts—skillsCataloguein provider optionsREADME.md— Skills section, options table, limitationsCHANGELOG.md—[Unreleased]entrySECURITY.md— skills mirror threat model.gitignore— exclude.cursor/(plugin-generated runtime artifacts)Test results
Security
No API keys, tokens, or personal identifiers in any committed file. The
CURSOR_API_KEYis only referenced by name (existing pattern). The skills mirror threat model is documented in SECURITY.md: skill instructions cross the permission boundary once written to disk,deny/askskills are excluded, and user-owned files are never overwritten.Limitations
config.skills.urls(HTTP catalogs) are not supported — documented in README and SECURITY.md.cursor_cloud_agenttargets a remote repo and does not inherit skills.cursor_delegatewith a non-session cwd does not mirror skills (but does passsettingSources: ["project"]so pre-existing.cursor/skills/there still loads).Verification
Live tested with
opencode run --model cursor/composer-2.5— the Cursor agent successfully discovered, read, and used the mirroredusing-agent-skillsskill. Standalone verification (node scripts/verify-skill-mirror.mjs) confirms all 46 skills discovered, mirrored, catalogued, and disposed cleanly.