Skip to content

feat: mirror opencode skills into .cursor/skills/ for Cursor agent discovery - #78

Open
WayneSimpson wants to merge 1 commit into
stablekernel:mainfrom
WayneSimpson:feat/skills-bridge
Open

feat: mirror opencode skills into .cursor/skills/ for Cursor agent discovery#78
WayneSimpson wants to merge 1 commit into
stablekernel:mainfrom
WayneSimpson:feat/skills-bridge

Conversation

@WayneSimpson

Copy link
Copy Markdown

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 the project settings layer.

What it does

  • Discovers skills from all standard opencode locations: project .opencode/skills/, .claude/skills/, .agents/skills/ (walked up to the git worktree root), global ~/.config/opencode/skills/, plus config.skills.paths directories (lowest priority, first-wins on duplicate ids).
  • Filters through opencode's live permission config (allow/deny/ask). ask-permissioned skills are withheld (the ask prompt can't cross the Cursor boundary). Manual skills: { include, exclude } override is supported with wildcard patterns.
  • Materialises as a git-ignored mirror with a generated: opencode-cursor sentinel in each SKILL.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.
  • Catalogues skills in an <available_skills> section appended to the generated system rule, so the Cursor agent knows what's available and can load skills on demand.
  • Re-syncs each turn via the chat.params hook (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).
  • Disposes cleanly — removeSkillMirror and removeSystemRule delete only sentinel-bearing files on plugin dispose.

New files

File Purpose
src/plugin/skill-discovery.ts Filesystem walk, frontmatter parsing, permission filtering, path expansion, skillSetHash
src/provider/skill-mirror.ts Materialisation, sentinel stamping, git-ignore, stale-dir pruning, size caps, dispose, catalogue builder
scripts/verify-skill-mirror.mjs Standalone end-to-end verification script
test/skill-discovery.test.ts 25 tests — discovery, filtering, precedence, wildcards, hash
test/skill-mirror.test.ts 14 tests — materialisation, sentinel, idempotency, pruning, size limits, dispose
test/plugin-skill-mirror.test.ts 10 tests — config hook integration, chat.params re-sync, dispose, forwardSkills opt-out

Updated files

  • src/plugin/index.tsforwardSkills option, config-hook materialisation, chat.params live re-sync, dispose cleanup
  • src/provider/language-model.ts — dynamic + static catalogue resolution (no self-provisioning — respects forwardSkills: false)
  • src/provider/system-rule.tswriteSystemRule/resolveSystemDelivery accept optional skillsCatalogue
  • src/provider/delegate.ts + src/plugin/cursor-tools.tssettingSources: ["project"] for delegate
  • src/provider/index.tsskillsCatalogue in provider options
  • README.md — Skills section, options table, limitations
  • CHANGELOG.md[Unreleased] entry
  • SECURITY.md — skills mirror threat model
  • .gitignore — exclude .cursor/ (plugin-generated runtime artifacts)

Test results

403 tests passed
typecheck: clean
build: clean

Security

No API keys, tokens, or personal identifiers in any committed file. The CURSOR_API_KEY is 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/ask skills 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_agent targets a remote repo and does not inherit skills.
  • cursor_delegate with a non-session cwd does not mirror skills (but does pass settingSources: ["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 mirrored using-agent-skills skill. Standalone verification (node scripts/verify-skill-mirror.mjs) confirms all 46 skills discovered, mirrored, catalogued, and disposed cleanly.

…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 justin-carper left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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.

Comment thread SECURITY.md
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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
- `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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants