From cee7d16a99ef1c9e44e6b75c0a1b40062f4f8ab3 Mon Sep 17 00:00:00 2001 From: Wayne Simpson Date: Sun, 26 Jul 2026 14:22:15 +0100 Subject: [PATCH 1/5] feat: mirror opencode skills into .cursor/skills/ for Cursor agent discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discover opencode's resolved skills (project + global + config.skills.paths), filter through permission config, and materialise them as a git-ignored mirror in /.cursor/skills/ with a 'generated: opencode-cursor' sentinel. An 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). --- .gitignore | 1 + CHANGELOG.md | 17 + README.md | 80 ++++- SECURITY.md | 27 ++ scripts/verify-skill-mirror.mjs | 171 ++++++++++ src/plugin/cursor-tools.ts | 27 +- src/plugin/index.ts | 111 ++++++- src/plugin/skill-discovery.ts | 486 +++++++++++++++++++++++++++++ src/provider/delegate.ts | 12 +- src/provider/index.ts | 10 + src/provider/language-model.ts | 16 + src/provider/skill-mirror.ts | 392 +++++++++++++++++++++++ src/provider/system-rule.ts | 15 +- test/language-model-system.test.ts | 87 ++++++ test/plugin-skill-mirror.test.ts | 280 +++++++++++++++++ test/skill-discovery.test.ts | 457 +++++++++++++++++++++++++++ test/skill-mirror.test.ts | 262 ++++++++++++++++ 17 files changed, 2420 insertions(+), 31 deletions(-) create mode 100644 scripts/verify-skill-mirror.mjs create mode 100644 src/plugin/skill-discovery.ts create mode 100644 src/provider/skill-mirror.ts create mode 100644 test/plugin-skill-mirror.test.ts create mode 100644 test/skill-discovery.test.ts create mode 100644 test/skill-mirror.test.ts diff --git a/.gitignore b/.gitignore index 1e28bd3..69643c0 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ coverage/ # Local editor / agent tooling .opencode/ .claude/ +.cursor/ # Local-only dev config (never commit) opencode.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 36d6d45..d2f5eb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- **Skills bridge: opencode skills are now mirrored into `.cursor/skills/` for + the Cursor agent.** Both project-scoped and global skills are discovered + (matching opencode's resolution order: `.opencode/skills/`, `.claude/skills/`, + `.agents/skills/`, walked up to the git worktree root, plus global + `~/.config/opencode/skills/` etc.), filtered through opencode's `permission` + config, and materialised as a git-ignored mirror with a `generated: + opencode-cursor` sentinel. An `` catalogue is appended to + the generated system rule so the Cursor agent can discover and load skills on + demand. Works for the primary agent, Cursor sub-agents, and `cursor_delegate` + (which passes `settingSources: ["project"]`). `ask`-permissioned skills are + withheld (the ask prompt can't cross the Cursor boundary). Opt out with + `forwardSkills: false`; manual override with `skills: { include, exclude }`. + User-owned `.cursor/skills//` directories are never overwritten. + `config.skills.paths` directories are also scanned (lowest priority, + first-wins on duplicate ids). `config.skills.urls` (HTTP catalogs) are not + yet supported. + ## [0.6.1] — 2026-07-24 - **Fixed: reasoning/thinking variants showed as meaningless numbered entries for diff --git a/README.md b/README.md index 6b31029..4648789 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,8 @@ See [SECURITY.md](./SECURITY.md) for the full threat model. | `session` | `"auto"` | Session reuse strategy — see [Session reuse](#session-reuse-session) | | `forwardMcp` | `true` | Forward opencode's configured MCP servers to the Cursor agent | | `mcpServers` | — | Extra MCP servers (Cursor `McpServerConfig` shape); merged with forwarded ones | +| `forwardSkills` | `true` | Mirror opencode's resolved skills into `.cursor/skills/` — see [Skills](#skills) | +| `skills` | — | Manual skill override: `{ include?: string[], exclude?: string[] }` — see [Skills](#skills) | | `toolDisplay` | `"blocks"` | How Cursor's internal tool activity is shown — see [Tool display](#tool-display) | | `systemPrompt` | `"rules"` | How opencode's system prompt reaches the agent — see [System prompt](#system-prompt) | | `transport` | — | Cursor agent transport (`"http1"` \| `"http2-direct"` \| `"sidecar"`) — see [Transport](#transport) | @@ -241,9 +243,77 @@ Disabled entries (`enabled: false`) are skipped. Remote servers requiring OAuth shareable `clientId` are also skipped (a one-time toast says which). Disable forwarding with `forwardMcp: false`. -> **Note:** This forwards MCP **servers**. opencode's own skills and subagents are not exposed to -> the Cursor agent. To load your local Cursor skills/rules, use -> `settingSources: ["project","user"]`. +> **Note:** This forwards MCP **servers**. opencode's skills are mirrored into +> `.cursor/skills/` automatically — see [Skills](#skills). + +## Skills + +With `forwardSkills: true` (default), the plugin mirrors opencode's resolved +skills into `/.cursor/skills/` — a git-ignored directory that Cursor +discovers natively when the `project` settings layer is loaded. This works for +both the primary agent and any Cursor sub-agent, because the mirror is written +at plugin init (before any turn) and `settingSources` lives on the provider +config (which survives the sub-agent model-options drop). + +The mirror includes: + +- **Project skills** from `.opencode/skills/`, `.opencode/skill/`, + `.claude/skills/`, `.agents/skills/` (walked up to the git worktree root). +- **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 + win on duplicate ids). `~/` prefixes are expanded to your home directory; relative + paths are resolved against the project directory. +- **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/`. + +### Permission filtering + +Skills are filtered through opencode's live `permission` config before +mirroring: + +- **`allow`** (default): included in the mirror. +- **`deny`**: excluded entirely. +- **`ask`**: excluded — the ask prompt can't be enforced across the Cursor + boundary. The plugin logs which skills were withheld and why. + +### Manual override + +```json +{ + "provider": { + "cursor": { + "options": { + "skills": { + "include": ["my-skill", "other-skill"], + "exclude": ["internal-*"] + } + } + } + } +} +``` + +`include` keeps only the listed skills (and overrides `deny` permission — the +user explicitly asked for them). `exclude` always drops the listed skills. + +### Limitations + +- 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 + mirrored). Total mirror size is capped at 10 MB. +- `cursor_delegate` with a `cwd` different from the session directory does not + mirror skills into that cwd (but does pass `settingSources: ["project"]` so + any pre-existing `.cursor/skills/` there still loads). +- `cursor_cloud_agent` targets a remote repo and does not inherit skills. ## Delegation tools @@ -257,7 +327,9 @@ are gated by opencode's `permission` config: ### `cursor_delegate` (local) Runs one Cursor turn as a permission-gated tool call. Your primary opencode model hands off a -discrete subtask and gets the result back. +discrete subtask and gets the result back. The delegate passes `settingSources: ["project"]` so +any pre-existing `.cursor/skills/` in the delegate's cwd loads (skills are not mirrored into a +non-session cwd — see [Skills limitations](#limitations)). | Arg | Required | Meaning | | --- | --- | --- | diff --git a/SECURITY.md b/SECURITY.md index 2c0149e..3ef4901 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -36,6 +36,33 @@ your opencode `permission` config (`allow` / `ask` / `deny`) controls them. The gate is **fail-closed**: if no permission mechanism is available, or approval is rejected, the call is blocked rather than silently allowed. +### Skills mirror — instructions cross the permission boundary + +When `forwardSkills` is enabled (default), opencode's resolved skills are +mirrored into `/.cursor/skills/` so the Cursor agent can discover and load +them. This means **skill instructions now reach an agent running outside +opencode's permission system**. Key implications: + +- Skill content is copied to disk and loaded by Cursor's own agent runtime — + opencode cannot intervene, revoke, or gate access once the files are written. +- Skills are discovered from all standard locations (project `.opencode/skills/`, + `.claude/skills/`, `.agents/skills/`, global `~/.config/opencode/skills/`, + 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. +- `ask`-permissioned skills are also excluded, because the ask prompt cannot be + enforced across the Cursor boundary (there is no way for Cursor to relay the + approval request back to opencode's permission system). +- The mirror is written at plugin init and re-synced each turn, so permission + changes take effect on the next turn (not retroactively on already-loaded + skills within a running Cursor agent). +- A user-owned `.cursor/skills//SKILL.md` (without the `generated: + opencode-cursor` sentinel) is never overwritten or deleted — the plugin + respects existing user content. +- `config.skills.urls` (HTTP skill catalogs) are not mirrored — only filesystem + paths are scanned. + ## Credentials - Your `CURSOR_API_KEY` is read from opencode auth storage or the environment. diff --git a/scripts/verify-skill-mirror.mjs b/scripts/verify-skill-mirror.mjs new file mode 100644 index 0000000..23308ad --- /dev/null +++ b/scripts/verify-skill-mirror.mjs @@ -0,0 +1,171 @@ +// Standalone verification: exercises the full skill discovery + mirror pipeline +// against the real skills on this machine, without needing opencode or a Cursor +// API key. Read-only against real skills; writes only to a temp dir. +// +// Run: npx tsx scripts/verify-skill-mirror.mjs +import { mkdtempSync, rmSync, existsSync, readFileSync, readdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +const { discoverSkills, resolveSkills, skillSetHash } = await import( + "../src/plugin/skill-discovery.ts" +); +const { writeSkillMirror, removeSkillMirror, buildSkillsCatalogue } = await import( + "../src/provider/skill-mirror.ts" +); + +// Read skills.paths from the real opencode config. +const configPath = join(homedir(), ".config", "opencode", "opencode.jsonc"); +const configRaw = readFileSync(configPath, "utf8"); +// Strip JSONC comments — but only // that aren't inside strings. +// Simple approach: remove // comments and /* */ comments, but preserve +// // inside quoted strings by tracking quote state. +function stripJsonc(text) { + let result = ""; + let inString = false; + let i = 0; + while (i < text.length) { + const ch = text[i]; + if (inString) { + result += ch; + if (ch === "\\" && i + 1 < text.length) { + result += text[i + 1]; + i += 2; + continue; + } + if (ch === '"') inString = false; + i++; + continue; + } + if (ch === '"') { + inString = true; + result += ch; + i++; + continue; + } + if (ch === "/" && text[i + 1] === "/") { + // Line comment — skip to end of line. + while (i < text.length && text[i] !== "\n") i++; + continue; + } + if (ch === "/" && text[i + 1] === "*") { + // Block comment — skip to closing. + i += 2; + while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++; + i += 2; + continue; + } + result += ch; + i++; + } + return result; +} +const config = JSON.parse(stripJsonc(configRaw)); +const extraPaths = config.skills?.paths ?? []; + +console.log("=== Skill Mirror Verification ===\n"); +console.log(`Config skills.paths: ${extraPaths.length} entries`); +for (const p of extraPaths) console.log(` - ${p}`); +console.log(); + +// Use the opencode config dir as cwd (where opencode actually runs). +const cwd = join(homedir(), ".config", "opencode"); +console.log(`Discovery cwd: ${cwd}`); + +// Layer 1: Discover skills (with extraPaths from config). +const discovered = discoverSkills(cwd, extraPaths); +console.log(`\nDiscovered: ${discovered.length} skills`); +for (const s of discovered.sort((a, b) => a.id.localeCompare(b.id))) { + console.log(` - ${s.id}: ${s.description.slice(0, 80)}${s.description.length > 80 ? "…" : ""}`); +} + +// Layer 2: Filter through permissions. +const resolved = resolveSkills(cwd, config, undefined); +console.log(`\nAfter permission filtering: ${resolved.skills.length} permitted, ${resolved.withheld.length} withheld`); +for (const w of resolved.withheld) { + console.log(` WITHHELD: ${w.id} — ${w.reason}`); +} + +// Layer 3: Materialise to a temp dir. +const tmpDir = mkdtempSync(join(tmpdir(), "skill-mirror-verify-")); +console.log(`\nMirror target: ${tmpDir}`); +const warnings = []; +const writeResult = writeSkillMirror(tmpDir, resolved.skills, (msg) => warnings.push(msg)); +console.log(`Write result: ${writeResult}`); +if (warnings.length > 0) { + console.log(`Warnings (${warnings.length}):`); + for (const w of warnings) console.log(` - ${w}`); +} + +// Layer 4: Verify the mirror on disk. +const mirrorDir = join(tmpDir, ".cursor", "skills"); +const mirrored = existsSync(mirrorDir) ? readdirSync(mirrorDir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name) : []; +console.log(`\nMirrored directories: ${mirrored.length}`); +for (const id of mirrored.sort()) { + const skillMd = join(mirrorDir, id, "SKILL.md"); + const content = readFileSync(skillMd, "utf8"); + const hasSentinel = content.includes("generated: opencode-cursor"); + console.log(` ${id}: sentinel=${hasSentinel}`); +} + +// Layer 5: Verify .gitignore. +const gitignorePath = join(mirrorDir, ".gitignore"); +if (existsSync(gitignorePath)) { + const gi = readFileSync(gitignorePath, "utf8"); + console.log(`\n.gitignore entries: ${gi.split(/\r?\n/).filter(Boolean).length}`); + console.log(` Contains .gitignore itself: ${gi.includes(".gitignore")}`); +} + +// Layer 6: Verify the catalogue. +const catalogue = buildSkillsCatalogue(resolved.skills); +if (catalogue) { + console.log(`\nCatalogue length: ${catalogue.length} chars`); + console.log(` Contains : ${catalogue.includes("")}`); + console.log(` Contains : ${catalogue.includes("")}`); + const skillIdsInCatalogue = resolved.skills.filter((s) => catalogue.includes(`**${s.id}**`)); + console.log(` Skills listed in catalogue: ${skillIdsInCatalogue.length} / ${resolved.skills.length}`); +} + +// Layer 7: Verify idempotency. +const hash1 = skillSetHash(resolved.skills); +const hash2 = skillSetHash(resolved.skills); +console.log(`\nIdempotency: hash stable = ${hash1 === hash2}`); + +// Layer 8: Verify dispose. +removeSkillMirror(tmpDir); +const afterDispose = existsSync(mirrorDir) ? readdirSync(mirrorDir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name) : []; +console.log(`\nAfter removeSkillMirror: ${afterDispose.length} dirs remain`); + +// Cleanup. +rmSync(tmpDir, { recursive: true, force: true }); + +// Summary. +console.log("\n=== Summary ==="); +console.log(`Discovered: ${discovered.length}`); +console.log(`Permitted: ${resolved.skills.length}`); +console.log(`Withheld: ${resolved.withheld.length}`); +console.log(`Mirrored: ${mirrored.length}`); +console.log(`Warnings: ${warnings.length}`); +console.log(`Dispose clean: ${afterDispose.length === 0}`); + +const allPass = discovered.length > 0 + && resolved.skills.length === discovered.length + && mirrored.length === resolved.skills.length + && warnings.length === 0 + && afterDispose.length === 0; + +console.log(`\nResult: ${allPass ? "PASS" : "FAIL"}`); +if (!allPass) { + console.log(" Discrepancies:"); + if (discovered.length === 0) console.log(" - No skills discovered"); + if (resolved.skills.length !== discovered.length) console.log(` - Permission filtering changed count: ${discovered.length} -> ${resolved.skills.length}`); + if (mirrored.length !== resolved.skills.length) console.log(` - Mirror count mismatch: expected ${resolved.skills.length}, got ${mirrored.length}`); + if (warnings.length > 0) console.log(` - ${warnings.length} warnings during mirror`); + if (afterDispose.length > 0) console.log(` - Dispose left ${afterDispose.length} dirs`); +} +process.exit(allPass ? 0 : 1); diff --git a/src/plugin/cursor-tools.ts b/src/plugin/cursor-tools.ts index c9fbda5..f387801 100644 --- a/src/plugin/cursor-tools.ts +++ b/src/plugin/cursor-tools.ts @@ -181,17 +181,22 @@ export function buildCursorTools(deps: CursorToolDeps): Record { let resolvedCwd = directory ?? process.cwd(); let forwardMcp = true; let userMcp: Record = {}; + // Skill forwarding state, mirroring the MCP forwarding pattern. + let forwardSkills = true; + let skillFilterOptions: SkillFilterOptions | undefined; + let lastSkillHash = ""; + let currentSkillsCatalogue = ""; // OAuth servers we've already warned about, so the toast fires once per // server rather than on every turn. const warnedOAuth = new Set(); @@ -138,13 +153,51 @@ export const CursorPlugin: Plugin = async (input) => { if (Object.keys(params).length > 0) modelParamDefaults[item.id] = params; } - // One canonical cwd for the provider's rule write and our dispose - // cleanup: an explicit user option wins, else the plugin directory. - const optionCwd = existingOptions["cwd"]; - resolvedCwd = - (typeof optionCwd === "string" ? optionCwd : undefined) ?? - directory ?? - process.cwd(); + // One canonical cwd for the provider's rule write and our dispose + // cleanup: an explicit user option wins, else the plugin directory. + const optionCwd = existingOptions["cwd"]; + resolvedCwd = + (typeof optionCwd === "string" ? optionCwd : undefined) ?? + directory ?? + process.cwd(); + + // Forward opencode's resolved skills (both project and global scope) to + // the Cursor agent by mirroring them into `/.cursor/skills/`. Cursor + // discovers these natively when the `project` settings layer is loaded. + // Opt out via `provider.cursor.options.forwardSkills: false`. Manual + // include/exclude override via `provider.cursor.options.skills`. + forwardSkills = existingOptions["forwardSkills"] !== false; + const skillsOpt = existingOptions["skills"] as + | { include?: string[]; exclude?: string[] } + | undefined; + skillFilterOptions = skillsOpt; + + if (forwardSkills) { + try { + const resolved = resolveSkills( + resolvedCwd, + config as Config | undefined, + skillFilterOptions, + ); + writeSkillMirror(resolvedCwd, resolved.skills, (msg) => + console.warn(`[cursor] ${msg}`), + ); + currentSkillsCatalogue = buildSkillsCatalogue(resolved.skills) ?? ""; + lastSkillHash = skillSetHash(resolved.skills); + if (resolved.withheld.length > 0) { + const summary = resolved.withheld + .map((w) => `${w.id} (${w.reason})`) + .join(", "); + console.warn( + `[cursor] Skills withheld from mirror: ${summary}`, + ); + } + } catch (error) { + console.warn( + `[cursor] Skill mirror failed: ${error instanceof Error ? error.message : String(error)}. Skills will not be available to the Cursor agent this session.`, + ); + } + } config.provider[PROVIDER_ID] = { name: "Cursor", @@ -157,6 +210,9 @@ export const CursorPlugin: Plugin = async (input) => { ...(Object.keys(modelParamDefaults).length > 0 ? { modelParamDefaults } : {}), + ...(currentSkillsCatalogue + ? { skillsCatalogue: currentSkillsCatalogue } + : {}), }, models: { ...toOpencodeModels(models), ...(existing.models ?? {}) }, }; @@ -234,7 +290,39 @@ export const CursorPlugin: Plugin = async (input) => { } } catch { // Keep the static snapshot; live forwarding is best-effort. + } + } + + // Re-sync the skill mirror from opencode's *live* state so skills + // added/removed mid-session reach the Cursor agent on the next turn. + // Hash the resolved skill set and skip the write when unchanged. + if (forwardSkills) { + if (client) { + try { + const query = directory ? { query: { directory } } : undefined; + const cfgRes = await client.config.get(query); + const liveConfig = cfgRes?.data as Config | undefined; + const resolved = resolveSkills( + resolvedCwd, + liveConfig, + skillFilterOptions, + ); + const hash = skillSetHash(resolved.skills); + if (hash !== lastSkillHash) { + writeSkillMirror(resolvedCwd, resolved.skills, (msg) => + console.warn(`[cursor] ${msg}`), + ); + currentSkillsCatalogue = + buildSkillsCatalogue(resolved.skills) ?? ""; + lastSkillHash = hash; + } + } catch { + // Keep the existing mirror; live re-sync is best-effort. + } } + // Always override the startup snapshot, including with an empty + // string when all skills were removed or withheld. + output.options["skillsCatalogue"] = currentSkillsCatalogue; } }, @@ -269,11 +357,12 @@ export const CursorPlugin: Plugin = async (input) => { }, dispose: async () => { - // Best-effort: drop the generated system-prompt rule so it doesn't - // linger in the user's workspace / Cursor IDE after the session ends. - // Uses the same canonical cwd the provider wrote to; sentinel-guarded, - // so a user-owned opencode.mdc is never deleted. + // Best-effort: drop the generated system-prompt rule and skill mirror + // so they don't linger in the user's workspace / Cursor IDE after the + // session ends. Uses the same canonical cwd the provider wrote to; + // sentinel-guarded, so user-owned files are never deleted. removeSystemRule(resolvedCwd); + removeSkillMirror(resolvedCwd); clearSubagentBridge(); }, }; diff --git a/src/plugin/skill-discovery.ts b/src/plugin/skill-discovery.ts new file mode 100644 index 0000000..ecd002a --- /dev/null +++ b/src/plugin/skill-discovery.ts @@ -0,0 +1,486 @@ +import { + readdirSync, + readFileSync, + statSync, + existsSync, +} from "node:fs"; +import type { Dirent } from "node:fs"; +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"; + +/** A skill discovered from the filesystem, ready for permission filtering. */ +export interface DiscoveredSkill { + /** Skill id (the `name` field from frontmatter, also the directory name). */ + id: string; + /** Human-readable name from frontmatter. */ + name: string; + /** Description from frontmatter — used by Cursor for on-demand loading. */ + description: string; + /** Absolute path to the skill's source directory (containing SKILL.md). */ + sourceDir: string; + /** Relative paths of supporting files alongside SKILL.md (not SKILL.md itself). */ + files: string[]; +} + +/** Outcome of discovery + permission filtering. */ +export interface ResolvedSkills { + /** Skills permitted to mirror. */ + skills: DiscoveredSkill[]; + /** Skills withheld and why (for logging / user notification). */ + withheld: Array<{ id: string; reason: string }>; +} + +/** Manual include/exclude override from plugin options. */ +export interface SkillFilterOptions { + include?: string[]; + exclude?: string[]; +} + +// --- Frontmatter parsing --- + +/** Parse the small recognised frontmatter field set (name, description). */ +function parseFrontmatter( + content: string, +): { name?: string; description?: string } { + if (!content.startsWith("---")) return {}; + const end = content.indexOf("\n---", 3); + if (end === -1) return {}; + const frontmatter = content.slice(3, end); + const result: { name?: string; description?: string } = {}; + for (const line of frontmatter.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const colon = trimmed.indexOf(":"); + if (colon === -1) continue; + const key = trimmed.slice(0, colon).trim(); + let value = trimmed.slice(colon + 1).trim(); + // Strip surrounding quotes if present. + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + if (key === "name") result.name = value; + else if (key === "description") result.description = value; + } + return result; +} + +// --- Filesystem walk --- + +/** Directory names under each config root that may contain skills. */ +const SKILL_DIR_NAMES = ["skill", "skills"]; + +/** External (non-opencode) config roots that contain a `skills/` subdir. */ +const EXTERNAL_DIR_NAMES = [".claude", ".agents"]; + +/** + * 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 + * in its own `.opencode/skills/`). + */ +function worktreeRoot(cwd: string): string { + try { + const root = execSync("git rev-parse --show-toplevel", { + cwd, + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + timeout: 3000, + }).trim(); + return root || cwd; + } catch { + return cwd; + } +} + +/** Walk up from `start` to `stop` (inclusive), yielding each directory. */ +function* walkUp( + start: string, + stop: string, +): Generator { + let current = start; + while (current) { + yield current; + if (current === stop) break; + const parent = dirname(current); + if (parent === current) break; + current = parent; + } +} + +/** List immediate subdirectories of `dir` that contain a `SKILL.md`. */ +function scanSkillDir( + dir: string, +): Array<{ id: string; sourceDir: string }> { + if (!existsSync(dir)) return []; + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } + const found: Array<{ id: string; sourceDir: string }> = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const skillDir = join(dir, entry.name); + if (!existsSync(join(skillDir, "SKILL.md"))) continue; + found.push({ id: entry.name, sourceDir: skillDir }); + } + return found; +} + +/** Collect supporting files (relative paths) alongside SKILL.md in a skill dir. */ +function collectFiles(sourceDir: string): string[] { + const files: string[] = []; + function walk(dir: string, base: string) { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const fullPath = join(dir, entry.name); + const relPath = relative(base, fullPath); + if (entry.name === "SKILL.md") continue; + if (entry.isDirectory()) { + walk(fullPath, base); + } else if (entry.isFile()) { + files.push(relPath); + } + } + } + walk(sourceDir, sourceDir); + return files; +} + +/** Load and parse a single skill from its source directory. */ +function loadSkill( + id: string, + sourceDir: string, +): DiscoveredSkill | undefined { + const skillMdPath = join(sourceDir, "SKILL.md"); + let content: string; + try { + content = readFileSync(skillMdPath, "utf8"); + } catch { + return undefined; + } + const fm = parseFrontmatter(content); + // Both name and description are required for the mirror — Cursor matches + // skills by description, and the id must match the name for consistency. + if (!fm.name || !fm.description) return undefined; + return { + id, + name: fm.name, + description: fm.description, + sourceDir, + files: collectFiles(sourceDir), + }; +} + +/** + * Expand a path from `skills.paths` the way opencode does: `~/` prefix → + * 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 { + const trimmed = raw.trim(); + if (!trimmed) return undefined; + if (trimmed.startsWith("~/")) return join(home, trimmed.slice(2)); + if (isAbsolute(trimmed)) return trimmed; + return resolvePath(cwd, trimmed); +} + +/** + * Discover skills from the filesystem, using a deterministic resolution + * order that prioritises specificity: project beats global, nearer beats + * farther, `.opencode` beats `.claude`/`.agents`. + * + * Scan order (first wins on duplicate id — a skill already seen is kept, + * later duplicates are skipped): + * 1. Project `.opencode/skill/`, `.opencode/skills/` walk-up (near→far) + * 2. Project `.claude/skills/`, `.agents/skills/` walk-up (near→far) + * 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) + * + * This differs from opencode's own resolution, which loads concurrently with + * unbounded concurrency (making "last wins" non-deterministic). We use + * first-wins for a deterministic, specificity-ordered mirror. + * + * `extraPaths` corresponds to opencode's `config.skills.paths` — additional + * directories to scan for skills. Paths are expanded: `~/` → home, relative + * → resolved against `cwd`, absolute used as-is. Non-existent directories + * are silently skipped (matching opencode's behaviour). + */ +export function discoverSkills( + cwd: string, + extraPaths?: string[], +): DiscoveredSkill[] { + const home = homedir(); + const xdgConfig = + process.env["XDG_CONFIG_HOME"] || join(home, ".config"); + const stop = worktreeRoot(cwd); + + // Build the scan list in specificity order (first wins). + const scanRoots: string[] = []; + + // 1. Project .opencode walk-up (near→far) + for (const ancestor of walkUp(cwd, stop)) { + for (const sub of SKILL_DIR_NAMES) { + scanRoots.push(join(ancestor, ".opencode", sub)); + } + } + + // 2. Project external walk-up (near→far) + for (const ancestor of walkUp(cwd, stop)) { + for (const ext of EXTERNAL_DIR_NAMES) { + scanRoots.push(join(ancestor, ext, "skills")); + } + } + + // 3. Global opencode + for (const sub of SKILL_DIR_NAMES) { + scanRoots.push(join(xdgConfig, "opencode", sub)); + } + + // 4. Global external + for (const ext of EXTERNAL_DIR_NAMES) { + scanRoots.push(join(home, ext, "skills")); + } + + // 5. ~/.opencode (if it exists) + const tildeOpencode = join(home, ".opencode"); + if (existsSync(tildeOpencode)) { + for (const sub of SKILL_DIR_NAMES) { + scanRoots.push(join(tildeOpencode, sub)); + } + } + + // 6. Extra paths from config.skills.paths (lowest priority) + if (extraPaths) { + for (const raw of extraPaths) { + const expanded = expandSkillPath(raw, cwd, home); + if (!expanded) continue; + if (!existsSync(expanded)) continue; + scanRoots.push(expanded); + } + } + + // Scan in order, first wins on duplicate id (skip if already seen). + const byId = new Map(); + for (const dir of scanRoots) { + const found = scanSkillDir(dir); + for (const { id, sourceDir } of found) { + if (byId.has(id)) continue; + const skill = loadSkill(id, sourceDir); + if (skill) byId.set(id, skill); + } + } + + return Array.from(byId.values()); +} + +// --- Permission filtering --- + +/** Wildcard pattern match supporting `*` (any sequence) and literal text. */ +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, ".*"); + return new RegExp(`^${regex}$`).test(value); +} + +/** Action for a skill under the map-form permission config. */ +type SkillAction = "allow" | "deny" | "ask"; + +/** Resolve the action for a skill id from the map-form `skill` permission rule. */ +function resolveMapPermission( + skillPerm: unknown, + skillId: string, +): SkillAction | undefined { + if (typeof skillPerm === "string") { + return skillPerm as SkillAction; + } + if (typeof skillPerm !== "object" || skillPerm === null) return undefined; + const map = skillPerm as Record; + // Last-matching-pattern wins (iterate in insertion order). + let action: SkillAction | undefined; + for (const [pattern, value] of Object.entries(map)) { + if (wildcardMatch(pattern, skillId)) { + action = value as SkillAction; + } + } + return action; +} + +/** Resolve the action for a skill id from the rule-array permission config. */ +function resolveRuleArrayPermission( + rules: Array<{ permission: string; pattern: string; action: string }>, + skillId: string, +): SkillAction | undefined { + // Last-matching-rule wins. + let action: SkillAction | undefined; + for (const rule of rules) { + if (rule.permission !== "skill") continue; + if (wildcardMatch(rule.pattern, skillId)) { + action = rule.action as SkillAction; + } + } + return action; +} + +/** + * Filter discovered skills through opencode's live permission config and the + * plugin's manual include/exclude override. + * + * - `deny` → excluded entirely. + * - `ask` → excluded (the ask prompt can't be enforced across the Cursor + * boundary). Logged as withheld. + * - `allow` → included. + * - No permission config for skills → all included (default allow). + * + * Manual `include`/`exclude` from plugin options takes precedence over + * permission config: `exclude` always drops, `include` always keeps (even if + * permission says deny — the user explicitly asked for it). + */ +export function filterSkills( + skills: DiscoveredSkill[], + config: Config | undefined, + options?: SkillFilterOptions, +): ResolvedSkills { + const include = options?.include ?? []; + const exclude = options?.exclude ?? []; + const matchesAny = (patterns: string[], id: string) => + patterns.some((pattern) => wildcardMatch(pattern, id)); + + // Extract skill permission config from both forms. + const permission = config?.permission as Record | undefined; + const mapSkillPerm = permission?.["skill"]; + const ruleArray = Array.isArray(permission?.["permission"]) + ? (permission!["permission"] as Array<{ + permission: string; + pattern: string; + action: string; + }>) + : undefined; + + // Also check the V2 PermissionRuleset form (config.permission as array). + const v2Ruleset = Array.isArray(config?.permission) + ? (config!.permission as Array<{ + permission: string; + pattern: string; + action: string; + }>) + : undefined; + + const permitted: DiscoveredSkill[] = []; + const withheld: Array<{ id: string; reason: string }> = []; + + for (const skill of skills) { + // Manual exclude always wins. + if (matchesAny(exclude, skill.id)) { + withheld.push({ id: skill.id, reason: "excluded by plugin options" }); + continue; + } + // Manual include always wins. + if (include.length > 0 && matchesAny(include, skill.id)) { + permitted.push(skill); + continue; + } + // If include list is specified and this skill isn't on it, skip. + if (include.length > 0 && !matchesAny(include, skill.id)) { + withheld.push({ + id: skill.id, + reason: "not in plugin include list", + }); + continue; + } + + // Resolve permission action. + let action: SkillAction | undefined; + if (v2Ruleset) { + action = resolveRuleArrayPermission(v2Ruleset, skill.id); + } + if (action === undefined && ruleArray) { + action = resolveRuleArrayPermission(ruleArray, skill.id); + } + if (action === undefined && mapSkillPerm !== undefined) { + action = resolveMapPermission(mapSkillPerm, skill.id); + } + + // Default to allow when no permission config touches this skill. + if (action === undefined || action === "allow") { + permitted.push(skill); + } else if (action === "deny") { + withheld.push({ id: skill.id, reason: "denied by permission config" }); + } else if (action === "ask") { + withheld.push({ + id: skill.id, + reason: + "ask-permissioned skills are withheld (the ask prompt can't cross the Cursor boundary)", + }); + } + } + + return { skills: permitted, withheld }; +} + +/** + * 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 + * an empty skill list. + * + * `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). + */ +export function resolveSkills( + cwd: string, + config?: Config, + options?: SkillFilterOptions, +): 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). + const skillsConfig = config as unknown as + | { skills?: { paths?: string[] } } + | undefined; + const extraPaths = skillsConfig?.skills?.paths; + + let discovered: DiscoveredSkill[]; + try { + discovered = discoverSkills(cwd, extraPaths); + } catch { + discovered = []; + } + return filterSkills(discovered, config, options); +} + +/** + * A stable hash of the resolved skill set, used to skip re-materialisation + * when nothing changed between turns. Based on skill ids + source dirs + file + * mtimes so content changes are detected. + */ +export function skillSetHash(skills: DiscoveredSkill[]): string { + const parts = skills.map((s) => { + const files = ["SKILL.md", ...s.files].map((file) => { + try { + const stat = statSync(join(s.sourceDir, file)); + return `${file}:${stat.mtimeMs}:${stat.size}`; + } catch { + return `${file}:missing`; + } + }); + files.sort(); + return `${s.id}:${s.sourceDir}:${files.join(",")}`; + }); + parts.sort(); + return parts.join("|"); +} diff --git a/src/provider/delegate.ts b/src/provider/delegate.ts index a996829..7236499 100644 --- a/src/provider/delegate.ts +++ b/src/provider/delegate.ts @@ -1,4 +1,4 @@ -import type { AgentModeOption } from "@cursor/sdk"; +import type { AgentModeOption, SettingSource } from "@cursor/sdk"; import type { CursorUsage } from "./agent-events.js"; import { streamAgentTurn } from "./agent-events.js"; import { resolveControls } from "./controls.js"; @@ -25,6 +25,12 @@ export interface DelegateParams { agentId?: string; /** Cancels the run when aborted (wired to the tool's abort signal). */ abortSignal?: AbortSignal; + /** + * Cursor settings layers to load from disk. When omitted, defaults to + * `["project"]` so the delegate picks up `.cursor/skills/` and other + * project-level config from its cwd. Pass an explicit array to override. + */ + settingSources?: SettingSource[]; } export interface DelegateToolActivity { @@ -67,6 +73,10 @@ export async function runDelegate( modelSelection, mode, cwd: params.cwd, + // Default to the "project" settings layer so the delegate picks up + // `.cursor/skills/` and other project-level config from its cwd. An + // explicit `settingSources` from the caller overrides this default. + settingSources: params.settingSources ?? ["project"], ...(params.sandbox !== undefined ? { sandbox: params.sandbox } : {}), ...(params.agentId ? { resumeAgentId: params.agentId } : {}), }); diff --git a/src/provider/index.ts b/src/provider/index.ts index 68a3e9f..b88ab13 100644 --- a/src/provider/index.ts +++ b/src/provider/index.ts @@ -96,6 +96,13 @@ export interface CursorProviderOptions { * Beats OPENCODE_CURSOR_TRANSPORT. Process-global: last provider to set it wins. */ transport?: "http1" | "http2-direct" | "sidecar"; + /** + * `` catalogue text appended to the generated system rule, + * listing mirrored skills so the Cursor agent can discover and load them on + * demand. Seeded by the plugin's `config` hook from the resolved skill set. + * When undefined, no skills section is appended. + */ + skillsCatalogue?: string; } /** @@ -133,6 +140,9 @@ export function createCursor(options: CursorProviderOptions = {}): ProviderV3 { session: options.session ?? "auto", toolDisplay: options.toolDisplay ?? "blocks", systemPrompt: options.systemPrompt ?? "rules", + ...(options.skillsCatalogue + ? { skillsCatalogue: options.skillsCatalogue } + : {}), }; const notImplemented = (kind: string, modelId: string): never => { diff --git a/src/provider/language-model.ts b/src/provider/language-model.ts index f7ac250..3d80464 100644 --- a/src/provider/language-model.ts +++ b/src/provider/language-model.ts @@ -95,6 +95,12 @@ export interface CursorModelConfig { * {@link SystemPromptMode}). Defaults to "rules". */ systemPrompt?: SystemPromptMode; + /** + * `` catalogue appended to the generated system rule, + * listing mirrored skills for the Cursor agent to discover and load on + * demand. Seeded by the plugin from the resolved skill set. + */ + skillsCatalogue?: string; } /** @@ -283,11 +289,21 @@ export class CursorLanguageModel implements LanguageModelV3 { // Degrades to inline "message" delivery when the user explicitly opted // out of the "project" settings layer, when the rule file is user-owned, // or when the write fails (read-only checkout etc.). + // The skills catalogue may be updated per-turn by the plugin's + // chat.params hook (reflecting mid-session skill changes); fall back to + // the static config value when not present in per-request options. + const dynamicSkillsCatalogue = + typeof providerOptions?.["skillsCatalogue"] === "string" + ? (providerOptions["skillsCatalogue"] as string) + : undefined; + const skillsCatalogue = + dynamicSkillsCatalogue ?? this.config.skillsCatalogue; const delivery = resolveSystemDelivery({ mode: this.config.systemPrompt ?? "rules", settingSources: this.config.settingSources, cwd: this.config.cwd, systemText: extractSystemText(options.prompt), + skillsCatalogue, warn: (message) => this.warnOnce(message), }); const systemMode: SystemPromptMode = delivery.mode; diff --git a/src/provider/skill-mirror.ts b/src/provider/skill-mirror.ts new file mode 100644 index 0000000..8bd9d12 --- /dev/null +++ b/src/provider/skill-mirror.ts @@ -0,0 +1,392 @@ +import { + mkdirSync, + writeFileSync, + readFileSync, + existsSync, + rmSync, + readdirSync, + statSync, + copyFileSync, +} from "node:fs"; +import type { Dirent } from "node:fs"; +import { join, relative, dirname } from "node:path"; +import type { DiscoveredSkill } from "../plugin/skill-discovery.js"; + +/** Location of the generated mirror, relative to the agent's cwd. */ +const SKILLS_DIR = join(".cursor", "skills"); +const IGNORE_FILE = ".gitignore"; + +/** + * Frontmatter sentinel marking mirrored skills as generated by this plugin. + * Only directories whose `SKILL.md` carries it are ever overwritten or + * deleted, so a user-owned `.cursor/skills/` is never clobbered. + */ +const SENTINEL = "generated: opencode-cursor"; + +/** Max bytes per individual supporting file (skip oversized files, not the whole skill). */ +const MAX_FILE_BYTES = 1_048_576; // 1 MB + +/** Max total mirrored bytes across all skills. */ +const MAX_TOTAL_BYTES = 10_485_760; // 10 MB + +/** Outcome of a {@link writeSkillMirror} attempt. */ +export type SkillMirrorWrite = + /** One or more skill directories were created or updated. */ + | "written" + /** All skills already mirrored with identical content; no writes. */ + | "unchanged" + /** No skills to mirror. */ + | "empty" + /** A write error occurred; some skills may be unavailable. */ + | "partial"; + +/** True when the file carries the generated-by sentinel in its frontmatter. */ +function isGenerated(content: string): boolean { + if (!content.startsWith("---")) return false; + const end = content.indexOf("\n---", 3); + const frontmatter = end === -1 ? content : content.slice(0, end); + return frontmatter.split(/\r?\n/).includes(SENTINEL); +} + +/** Inject the sentinel into frontmatter, preserving name and description. */ +function stampSentinel(content: string): string { + if (!content.startsWith("---")) { + // No frontmatter at all — shouldn't happen (discovery requires it), + // but handle gracefully by wrapping the whole content. + return `---\n${SENTINEL}\n---\n\n${content}`; + } + const end = content.indexOf("\n---", 3); + if (end === -1) { + return `---\n${SENTINEL}\n${content.slice(3)}`; + } + const frontmatter = content.slice(3, end); + // Already has the sentinel — return as-is. + if (frontmatter.includes(SENTINEL)) return content; + // Insert sentinel before the closing ---. + return `---${frontmatter}\n${SENTINEL}\n${content.slice(end)}`; +} + +/** Recursively copy a file tree, skipping oversized files. Returns bytes copied + skipped files. */ +function copyTree( + srcDir: string, + destDir: string, + skillId: string, + maxBytes: number, + warn: (message: string) => void, +): { bytes: number; skipped: string[] } { + let bytes = 0; + const skipped: string[] = []; + + function walk(src: string, dest: string) { + let entries; + try { + entries = readdirSync(src, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const srcPath = join(src, entry.name); + const destPath = join(dest, entry.name); + if (entry.isDirectory()) { + walk(srcPath, destPath); + } else if (entry.isFile()) { + // Never copy SKILL.md here — it's already written with the + // sentinel stamped by the caller. Copying the original would + // overwrite the stamped version. + if (entry.name === "SKILL.md") continue; + try { + const size = statSync(srcPath).size; + if (size > MAX_FILE_BYTES) { + skipped.push(relative(srcDir, srcPath)); + warn( + `Skill "${skillId}": skipped oversized file "${relative(srcDir, srcPath)}" (${size} bytes > ${MAX_FILE_BYTES} limit). The rest of the skill is still mirrored.`, + ); + continue; + } + if (bytes + size > maxBytes) { + skipped.push(relative(srcDir, srcPath)); + warn( + `Skill "${skillId}": skipped file "${relative(srcDir, srcPath)}" because the mirror's ${MAX_TOTAL_BYTES}-byte total size limit would be exceeded.`, + ); + continue; + } + mkdirSync(dirname(destPath), { recursive: true }); + copyFileSync(srcPath, destPath); + bytes += size; + } catch { + // Best effort — skip unreadable files. + } + } + } + } + + walk(srcDir, destDir); + return { bytes, skipped }; +} + +/** Compare file contents and skip the write when unchanged. */ +function writeIfChanged(path: string, content: string): boolean { + const existing = existsSync(path) ? readFileSync(path, "utf8") : undefined; + if (existing === content) return false; + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content, "utf8"); + return true; +} + +/** + * Keep the generated skills out of git via `.cursor/skills/.gitignore` (which + * also ignores itself so it doesn't pollute `git status`). Lists each mirrored + * skill directory by id plus the `.gitignore` itself. + */ +function ensureGitIgnored(dir: string, skillIds: string[]): void { + const path = join(dir, IGNORE_FILE); + const existing = existsSync(path) ? readFileSync(path, "utf8") : ""; + const lines = existing.split(/\r?\n/); + const missing = [...skillIds, IGNORE_FILE].filter( + (entry) => !lines.includes(entry), + ); + if (missing.length === 0) return; + const prefix = + existing && !existing.endsWith("\n") ? `${existing}\n` : existing; + writeFileSync(path, `${prefix}${missing.join("\n")}\n`, "utf8"); +} + +/** + * Materialise a git-ignored mirror of opencode's resolved skills into + * `/.cursor/skills/`, which Cursor discovers natively when the `project` + * settings layer is loaded. + * + * Each skill is written to `/.cursor/skills//SKILL.md` with a + * `generated: opencode-cursor` sentinel stamped into the frontmatter. + * Supporting files are copied alongside, preserving relative paths. A + * user-owned skill directory (sentinel-less `SKILL.md`) is never overwritten — + * it's skipped with a warning. Oversized individual files are skipped (not the + * whole skill). Writes are idempotent (content compared before writing). + * Sentinel-bearing directories for skills that no longer resolve are pruned. + * + * Never throws. Every failure degrades to "this skill is unavailable this + * turn" plus a warning, mirroring how `resolveSystemDelivery` falls back. + */ +export function writeSkillMirror( + cwd: string, + skills: DiscoveredSkill[], + warn: (message: string) => void, +): SkillMirrorWrite { + if (skills.length === 0) { + // Still prune stale sentinel-bearing dirs. + try { + pruneStale(cwd, new Set(), warn); + } catch { + // best effort + } + return "empty"; + } + + const dir = join(cwd, SKILLS_DIR); + let totalBytes = 0; + let wroteAny = false; + let partial = false; + const mirroredIds: string[] = []; + + for (const skill of skills) { + if (totalBytes >= MAX_TOTAL_BYTES) { + warn( + `Skill mirror: total size limit (${MAX_TOTAL_BYTES} bytes) reached; skipping remaining skills: ${skills + .filter((s) => !mirroredIds.includes(s.id)) + .map((s) => s.id) + .join(", ")}`, + ); + break; + } + + const skillDir = join(dir, skill.id); + const skillMdPath = join(skillDir, "SKILL.md"); + + // Check for user-owned skill (sentinel-less SKILL.md). + if (existsSync(skillMdPath)) { + try { + const existing = readFileSync(skillMdPath, "utf8"); + if (!isGenerated(existing)) { + warn( + `.cursor/skills/${skill.id}/SKILL.md exists but was not generated by opencode-cursor; leaving it untouched and skipping this skill.`, + ); + continue; + } + } catch { + // Can't read — assume it's ours and proceed. + } + } + + try { + // Read and stamp the SKILL.md. + const sourceContent = readFileSync( + join(skill.sourceDir, "SKILL.md"), + "utf8", + ); + const stamped = stampSentinel(sourceContent); + const skillMdBytes = Buffer.byteLength(stamped); + if (totalBytes + skillMdBytes > MAX_TOTAL_BYTES) { + warn( + `Skill mirror: total size limit (${MAX_TOTAL_BYTES} bytes) reached; skipping skill "${skill.id}" and all remaining skills.`, + ); + partial = true; + break; + } + const changed = writeIfChanged(skillMdPath, stamped); + if (changed) wroteAny = true; + totalBytes += skillMdBytes; + + // Copy supporting files. + const { bytes, skipped } = copyTree( + skill.sourceDir, + skillDir, + skill.id, + MAX_TOTAL_BYTES - totalBytes, + warn, + ); + totalBytes += bytes; + if (skipped.length > 0) partial = true; + + mirroredIds.push(skill.id); + } catch (error) { + partial = true; + warn( + `Failed to mirror skill "${skill.id}": ${error instanceof Error ? error.message : String(error)}. This skill will be unavailable this turn.`, + ); + } + } + + // Prune stale sentinel-bearing dirs for skills no longer in the set. + try { + const staleRemoved = pruneStale( + cwd, + new Set(mirroredIds), + warn, + ); + if (staleRemoved) wroteAny = true; + } catch { + // best effort + } + + // Update .gitignore with the current set of mirrored skill ids. + if (mirroredIds.length > 0) { + try { + ensureGitIgnored(dir, mirroredIds); + } catch { + // non-fatal + } + } + + if (partial) return "partial"; + return wroteAny ? "written" : "unchanged"; +} + +/** + * Remove sentinel-bearing skill directories for skills that no longer resolve. + * User-owned directories (no sentinel) are left in place. Returns true if any + * directories were removed. + */ +function pruneStale( + cwd: string, + currentIds: Set, + warn: (message: string) => void, +): boolean { + const dir = join(cwd, SKILLS_DIR); + if (!existsSync(dir)) return false; + let removed = false; + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return false; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name === "." || entry.name === "..") continue; + if (currentIds.has(entry.name)) continue; + const skillMdPath = join(dir, entry.name, "SKILL.md"); + if (!existsSync(skillMdPath)) continue; + try { + const content = readFileSync(skillMdPath, "utf8"); + if (isGenerated(content)) { + rmSync(join(dir, entry.name), { recursive: true, force: true }); + removed = true; + } + } catch { + // Can't read — leave it alone. + } + } + return removed; +} + +/** + * Remove the entire generated skill mirror (best-effort); used on plugin + * dispose. Only deletes directories whose `SKILL.md` carries the sentinel — + * user-owned `.cursor/skills/` directories are left in place. Also + * removes the `.gitignore` if it was generated (contains the sentinel pattern). + */ +export function removeSkillMirror(cwd: string): void { + const dir = join(cwd, SKILLS_DIR); + if (!existsSync(dir)) return; + try { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const skillMdPath = join(dir, entry.name, "SKILL.md"); + if (!existsSync(skillMdPath)) continue; + try { + if (isGenerated(readFileSync(skillMdPath, "utf8"))) { + rmSync(join(dir, entry.name), { recursive: true, force: true }); + } + } catch { + // best effort + } + } + // Remove the .gitignore if all sentinel dirs are gone. + const remaining = readdirSync(dir, { withFileTypes: true }); + const hasSentinelDir = remaining.some((e) => { + if (!e.isDirectory()) return false; + const p = join(dir, e.name, "SKILL.md"); + if (!existsSync(p)) return false; + try { + return isGenerated(readFileSync(p, "utf8")); + } catch { + return false; + } + }); + if (!hasSentinelDir) { + const ignorePath = join(dir, IGNORE_FILE); + if (existsSync(ignorePath)) { + try { + rmSync(ignorePath); + } catch { + // best effort + } + } + } + } catch { + // best effort — already gone or unreadable + } +} + +/** + * Build the `` catalogue text for the system rule. Lists each + * skill's id and description, plus a one-line instruction to load the matching + * skill file before starting relevant work. Returns undefined when no skills + * are mirrored (so the caller can skip appending an empty section). + */ +export function buildSkillsCatalogue( + skills: DiscoveredSkill[], +): string | undefined { + if (skills.length === 0) return undefined; + const lines = skills.map( + (s) => `- **${s.id}**: ${s.description}`, + ); + return [ + "", + "The following skills are available. Load the matching skill from `.cursor/skills//SKILL.md` before starting relevant work:", + "", + ...lines, + "", + ].join("\n"); +} diff --git a/src/provider/system-rule.ts b/src/provider/system-rule.ts index a7241d4..f1fde53 100644 --- a/src/provider/system-rule.ts +++ b/src/provider/system-rule.ts @@ -57,6 +57,10 @@ function isGenerated(content: string): boolean { * instructions reach the agent without being flattened into the untrusted * user-message transcript (which injection-hardened models reject). * + * When `skillsCatalogue` is provided, it is appended to the rule body as an + * `` section so the Cursor agent knows which skills are + * mirrored on disk and can load them on demand. + * * The file carries a generated-by sentinel; a pre-existing sentinel-less file * is treated as user-owned and never overwritten ("blocked"). An existing * generated rule with identical content is left as-is ("unchanged") to keep @@ -66,11 +70,13 @@ function isGenerated(content: string): boolean { export function writeSystemRule( cwd: string, systemText: string, + skillsCatalogue?: string, ): SystemRuleWrite { - if (!systemText) return "empty"; + if (!systemText && !skillsCatalogue) return "empty"; const dir = join(cwd, RULES_DIR); const path = join(dir, RULE_FILE); - const body = `---\nalwaysApply: true\n${SENTINEL}\n---\n\n${systemText}\n`; + const sections = [systemText, skillsCatalogue].filter(Boolean).join("\n\n"); + const body = `---\nalwaysApply: true\n${SENTINEL}\n---\n\n${sections}\n`; const existing = existsSync(path) ? readFileSync(path, "utf8") : undefined; if (existing !== undefined) { if (!isGenerated(existing)) return "blocked"; @@ -135,9 +141,10 @@ export function resolveSystemDelivery(options: { settingSources: SettingSource[] | undefined; cwd: string; systemText: string; + skillsCatalogue?: string; warn: (message: string) => void; }): SystemDelivery { - const { mode, settingSources, cwd, systemText, warn } = options; + const { mode, settingSources, cwd, systemText, skillsCatalogue, warn } = options; if (mode !== "rules") return { mode, settingSources }; if (settingSources && !settingSources.includes("project")) { warn( @@ -147,7 +154,7 @@ export function resolveSystemDelivery(options: { } let result: SystemRuleWrite; try { - result = writeSystemRule(cwd, systemText); + result = writeSystemRule(cwd, systemText, skillsCatalogue); } catch (error) { warn( `failed to write .cursor/rules/${RULE_FILE} (${error instanceof Error ? error.message : String(error)}); delivering the system prompt inline ("message" mode) for this turn.`, diff --git a/test/language-model-system.test.ts b/test/language-model-system.test.ts index 8a79077..099124c 100644 --- a/test/language-model-system.test.ts +++ b/test/language-model-system.test.ts @@ -12,6 +12,13 @@ import { join } from "node:path"; import type { LanguageModelV3CallOptions } from "@ai-sdk/provider"; import type { SDKUserMessage } from "@cursor/sdk"; +// Mock homedir so self-provisioning doesn't pick up real global skills. +const fakeHome = mkdtempSync(join(tmpdir(), "cursor-lm-home-")); +vi.mock("node:os", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, homedir: () => fakeHome }; +}); + const acquireAgent = vi.fn(); const streamAgentTurn = vi.fn(); @@ -175,3 +182,83 @@ describe("CursorLanguageModel system prompt delivery (message/omit modes)", () = expect(existsSync(rulePath(cwd))).toBe(false); }); }); + +describe("CursorLanguageModel skills catalogue in system rule", () => { + it("appends the static catalogue from config to the rule file", async () => { + const cwd = tmp(); + const catalogue = "\n- **test-skill**: A test.\n"; + await model({ cwd, skillsCatalogue: catalogue }).doGenerate(options); + const rule = readFileSync(rulePath(cwd), "utf8"); + expect(rule).toContain("SYS PROMPT"); + expect(rule).toContain(""); + expect(rule).toContain("test-skill"); + }); + + it("appends the dynamic catalogue from providerOptions, overriding the static one", async () => { + const cwd = tmp(); + const staticCat = "\n- **static**: Static.\n"; + const dynamicCat = "\n- **dynamic**: Dynamic.\n"; + const opts = { + ...options, + providerOptions: { cursor: { skillsCatalogue: dynamicCat } }, + } as unknown as LanguageModelV3CallOptions; + await model({ cwd, skillsCatalogue: staticCat }).doGenerate(opts); + const rule = readFileSync(rulePath(cwd), "utf8"); + expect(rule).toContain("dynamic"); + expect(rule).not.toContain("static"); + }); + + it("uses the dynamic catalogue when no static catalogue is configured", async () => { + const cwd = tmp(); + const dynamicCat = "\n- **dyn-only**: Only dynamic.\n"; + const opts = { + ...options, + providerOptions: { cursor: { skillsCatalogue: dynamicCat } }, + } as unknown as LanguageModelV3CallOptions; + await model({ cwd }).doGenerate(opts); + const rule = readFileSync(rulePath(cwd), "utf8"); + expect(rule).toContain("dyn-only"); + }); + + it("writes no catalogue section when neither static nor dynamic is set", async () => { + const cwd = tmp(); + await model({ cwd }).doGenerate(options); + const rule = readFileSync(rulePath(cwd), "utf8"); + expect(rule).not.toContain(""); + expect(rule).toContain("SYS PROMPT"); + }); + + it("updates the rule when the catalogue changes between turns", async () => { + const cwd = tmp(); + const m = model({ cwd }); + await m.doGenerate(options); + const rule1 = readFileSync(rulePath(cwd), "utf8"); + expect(rule1).not.toContain(""); + + const catalogue = "\n- **new-skill**: New.\n"; + const opts = { + ...options, + providerOptions: { cursor: { skillsCatalogue: catalogue } }, + } as unknown as LanguageModelV3CallOptions; + await m.doGenerate(opts); + const rule2 = readFileSync(rulePath(cwd), "utf8"); + expect(rule2).toContain(""); + expect(rule2).toContain("new-skill"); + }); + + it("does not bypass plugin filtering by discovering skills in the provider", async () => { + const cwd = tmp(); + // A filesystem skill alone must not bypass the plugin's catalogue filtering. + const skillDir = join(cwd, ".opencode", "skills", "self-prov-test"); + mkdirSync(skillDir, { recursive: true }); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: self-prov-test\ndescription: Self-provisioned skill.\n---\n\nBody.\n", + "utf8", + ); + await model({ cwd }).doGenerate(options); + const rule = readFileSync(rulePath(cwd), "utf8"); + expect(rule).not.toContain(""); + expect(rule).not.toContain("self-prov-test"); + }); +}); diff --git a/test/plugin-skill-mirror.test.ts b/test/plugin-skill-mirror.test.ts new file mode 100644 index 0000000..1ec864d --- /dev/null +++ b/test/plugin-skill-mirror.test.ts @@ -0,0 +1,280 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + existsSync, + mkdtempSync, + mkdirSync, + writeFileSync, + readFileSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Config } from "@opencode-ai/plugin"; + +// Mock homedir so global skill discovery doesn't pick up real skills. +const fakeHome = mkdtempSync(join(tmpdir(), "cursor-plugin-skill-home-")); +vi.mock("node:os", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, homedir: () => fakeHome }; +}); + +// Keep the config hook offline: no live model discovery. +vi.mock("../src/model-discovery.js", () => ({ + discoverModels: async () => ({ models: [], source: "fallback" }), + toOpencodeModels: () => ({}), +})); + +const { default: plugin } = await import("../src/plugin/index.js"); + +const dirs: string[] = []; +function tmp(): string { + const d = mkdtempSync(join(tmpdir(), "cursor-plugin-skill-")); + dirs.push(d); + return d; +} +afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +function skillMirrorDir(cwd: string): string { + return join(cwd, ".cursor", "skills"); +} + +function writeTestSkill( + base: string, + id: string, + description = `Skill ${id}`, +): void { + const dir = join(base, id); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "SKILL.md"), + `---\nname: ${id}\ndescription: ${description}\n---\n\nContent.\n`, + "utf8", + ); +} + +describe("CursorPlugin skill mirror", () => { + it("materialises the skill mirror in the config hook", async () => { + const dir = tmp(); + // Create a project skill. + writeTestSkill(join(dir, ".opencode", "skills"), "test-skill"); + + const hooks = await plugin({ directory: dir } as never); + const config = { provider: {}, mcp: {} } as never; + await hooks.config!(config); + + const mirrorPath = join(skillMirrorDir(dir), "test-skill", "SKILL.md"); + expect(existsSync(mirrorPath)).toBe(true); + const content = readFileSync(mirrorPath, "utf8"); + expect(content).toContain("generated: opencode-cursor"); + expect(content).toContain("name: test-skill"); + }); + + it("threads skillsCatalogue into provider options", async () => { + const dir = tmp(); + writeTestSkill(join(dir, ".opencode", "skills"), "cat-skill"); + + const hooks = await plugin({ directory: dir } as never); + const config = { provider: {}, mcp: {} } as never; + await hooks.config!(config); + const options = (config as { provider: Record }> }) + .provider["cursor"]!.options!; + const catalogue = options["skillsCatalogue"]; + expect(typeof catalogue).toBe("string"); + expect(catalogue as string).toContain(""); + expect(catalogue as string).toContain("cat-skill"); + }); + + it("removes the mirror on dispose", async () => { + const dir = tmp(); + writeTestSkill(join(dir, ".opencode", "skills"), "dispose-skill"); + + const hooks = await plugin({ directory: dir } as never); + const config = { provider: {}, mcp: {} } as never; + await hooks.config!(config); + expect( + existsSync(join(skillMirrorDir(dir), "dispose-skill", "SKILL.md")), + ).toBe(true); + + await hooks.dispose!(); + expect( + existsSync(join(skillMirrorDir(dir), "dispose-skill")), + ).toBe(false); + }); + + it("respects forwardSkills:false opt-out", async () => { + const dir = tmp(); + writeTestSkill(join(dir, ".opencode", "skills"), "opt-out"); + + const hooks = await plugin({ directory: dir } as never); + const config = { + provider: { cursor: { options: { forwardSkills: false } } }, + mcp: {}, + } as never; + await hooks.config!(config); + expect( + existsSync(join(skillMirrorDir(dir), "opt-out")), + ).toBe(false); + }); + + it("respects skills.exclude to drop a skill", async () => { + const dir = tmp(); + writeTestSkill(join(dir, ".opencode", "skills"), "excluded"); + writeTestSkill(join(dir, ".opencode", "skills"), "included"); + + const hooks = await plugin({ directory: dir } as never); + const config = { + provider: { cursor: { options: { skills: { exclude: ["excluded"] } } } }, + mcp: {}, + } as never; + await hooks.config!(config); + expect( + existsSync(join(skillMirrorDir(dir), "excluded")), + ).toBe(false); + expect( + existsSync(join(skillMirrorDir(dir), "included")), + ).toBe(true); + }); + + it("respects skills.include to keep only listed skills", async () => { + const dir = tmp(); + writeTestSkill(join(dir, ".opencode", "skills"), "keep"); + writeTestSkill(join(dir, ".opencode", "skills"), "drop"); + + const hooks = await plugin({ directory: dir } as never); + const config = { + provider: { cursor: { options: { skills: { include: ["keep"] } } } }, + mcp: {}, + } as never; + await hooks.config!(config); + expect( + existsSync(join(skillMirrorDir(dir), "keep")), + ).toBe(true); + expect( + existsSync(join(skillMirrorDir(dir), "drop")), + ).toBe(false); + }); + + it("does not overwrite a user-owned skill in .cursor/skills/", async () => { + const dir = tmp(); + // Pre-existing user-owned skill. + const userDir = join(skillMirrorDir(dir), "user-skill"); + mkdirSync(userDir, { recursive: true }); + const userBody = "---\nname: user-skill\ndescription: Mine.\n---\n\nMy content.\n"; + writeFileSync(join(userDir, "SKILL.md"), userBody, "utf8"); + + // Same id in opencode skills. + writeTestSkill(join(dir, ".opencode", "skills"), "user-skill", "Theirs."); + + const hooks = await plugin({ directory: dir } as never); + const config = { provider: {}, mcp: {} } as never; + await hooks.config!(config); + // User-owned file is untouched. + expect(readFileSync(join(userDir, "SKILL.md"), "utf8")).toBe(userBody); + }); + + it("uses a user-configured provider cwd for the mirror", async () => { + const dir = tmp(); + const customCwd = tmp(); + writeTestSkill(join(customCwd, ".opencode", "skills"), "custom-cwd"); + + const hooks = await plugin({ directory: dir } as never); + const config = { + provider: { cursor: { options: { cwd: customCwd } } }, + mcp: {}, + } as never; + await hooks.config!(config); + expect( + existsSync(join(skillMirrorDir(customCwd), "custom-cwd", "SKILL.md")), + ).toBe(true); + // Cleanup on dispose should target customCwd. + await hooks.dispose!(); + expect( + existsSync(join(skillMirrorDir(customCwd), "custom-cwd")), + ).toBe(false); + }); + + it("forwards the current catalogue on every turn and clears removed skills", async () => { + const dir = tmp(); + const source = join(dir, ".opencode", "skills"); + writeTestSkill(source, "first"); + const config = { provider: {}, mcp: {} } as Config; + const client = { + config: { get: async () => ({ data: config }) }, + mcp: { status: async () => ({ data: {} }) }, + }; + const hooks = await plugin({ directory: dir, client } as never); + await hooks.config!(config as never); + const input = { + model: { providerID: "cursor" }, + sessionID: "session", + agent: "build", + } as never; + + const firstOutput = { options: {} } as never; + await hooks["chat.params"]!(input, firstOutput); + expect((firstOutput as { options: Record }).options.skillsCatalogue) + .toContain("first"); + + writeTestSkill(source, "second"); + const secondOutput = { options: {} } as never; + await hooks["chat.params"]!(input, secondOutput); + expect((secondOutput as { options: Record }).options.skillsCatalogue) + .toContain("second"); + + const unchangedOutput = { options: {} } as never; + await hooks["chat.params"]!(input, unchangedOutput); + expect((unchangedOutput as { options: Record }).options.skillsCatalogue) + .toContain("second"); + + rmSync(source, { recursive: true, force: true }); + const emptyOutput = { options: {} } as never; + await hooks["chat.params"]!(input, emptyOutput); + expect((emptyOutput as { options: Record }).options.skillsCatalogue) + .toBe(""); + expect(existsSync(join(skillMirrorDir(dir), "first"))).toBe(false); + expect(existsSync(join(skillMirrorDir(dir), "second"))).toBe(false); + }); +}); + +describe("CursorPlugin skill mirror — sub-agent inheritance", () => { + it("mirror is materialised at config time before any Cursor turn", async () => { + const dir = tmp(); + writeTestSkill(join(dir, ".opencode", "skills"), "sub-agent-skill"); + + const hooks = await plugin({ directory: dir } as never); + const config = { provider: {}, mcp: {} } as never; + await hooks.config!(config); + + // The mirror exists on disk immediately after config, before any turn. + expect( + existsSync(join(skillMirrorDir(dir), "sub-agent-skill", "SKILL.md")), + ).toBe(true); + + // The provider options include settingSources that will enable the + // project layer (where Cursor discovers .cursor/skills/). The system + // rule delivery resolves this at call time, but the catalogue is + // already in the options. + const options = (config as { provider: Record }> }) + .provider["cursor"]!.options!; + expect(options["skillsCatalogue"]).toBeDefined(); + }); + + it("settingSources reaches the provider via config (survives model-options drop)", async () => { + const dir = tmp(); + writeTestSkill(join(dir, ".opencode", "skills"), "settings-test"); + + const hooks = await plugin({ directory: dir } as never); + const config = { + provider: { cursor: { options: { settingSources: ["project", "user"] } } }, + mcp: {}, + } as never; + await hooks.config!(config); + const options = (config as { provider: Record }> }) + .provider["cursor"]!.options!; + // settingSources is on the provider config, not per-request options, + // so it survives the sub-agent model-options drop (same as modelParamDefaults). + expect(options["settingSources"]).toEqual(["project", "user"]); + }); +}); diff --git a/test/skill-discovery.test.ts b/test/skill-discovery.test.ts new file mode 100644 index 0000000..0437dbb --- /dev/null +++ b/test/skill-discovery.test.ts @@ -0,0 +1,457 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + rmSync, + existsSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execSync } from "node:child_process"; + +// Mock homedir so global skill discovery doesn't pick up real skills from +// the developer's machine. Use a temp dir that's cleaned up after each test. +const fakeHome = mkdtempSync(join(tmpdir(), "cursor-skill-home-")); +vi.mock("node:os", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, homedir: () => fakeHome }; +}); + +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"; + +const dirs: string[] = []; +function tmp(): string { + const d = mkdtempSync(join(tmpdir(), "cursor-skill-")); + dirs.push(d); + return d; +} +afterEach(() => { + for (const d of dirs.splice(0)) { + rmSync(d, { recursive: true, force: true }); + } + // Also clean up any skills written to the mocked fakeHome. + const fakeHomeSkills = join(fakeHome, ".config", "opencode", "skills"); + if (existsSync(fakeHomeSkills)) { + rmSync(fakeHomeSkills, { recursive: true, force: true }); + } + const fakeHomeMySkills = join(fakeHome, "my-skills"); + if (existsSync(fakeHomeMySkills)) { + rmSync(fakeHomeMySkills, { recursive: true, force: true }); + } +}); + +/** Write a skill directory with SKILL.md frontmatter. */ +function writeSkill( + base: string, + id: string, + fm: { name: string; description: string }, + body = "Skill content.", + extra?: Record, +): string { + const dir = join(base, id); + mkdirSync(dir, { recursive: true }); + const content = `---\nname: ${fm.name}\ndescription: ${fm.description}\n---\n\n${body}\n`; + writeFileSync(join(dir, "SKILL.md"), content, "utf8"); + if (extra) { + for (const [path, content] of Object.entries(extra)) { + const fullPath = join(dir, path); + mkdirSync(join(fullPath, ".."), { recursive: true }); + writeFileSync(fullPath, content, "utf8"); + } + } + return dir; +} + +describe("discoverSkills", () => { + it("discovers a project skill from .opencode/skills/", () => { + const cwd = tmp(); + writeSkill(join(cwd, ".opencode", "skills"), "my-skill", { + name: "my-skill", + description: "Does a thing.", + }); + const skills = discoverSkills(cwd); + expect(skills).toHaveLength(1); + expect(skills[0]!.id).toBe("my-skill"); + expect(skills[0]!.name).toBe("my-skill"); + expect(skills[0]!.description).toBe("Does a thing."); + }); + + it("discovers from singular .opencode/skill/", () => { + const cwd = tmp(); + writeSkill(join(cwd, ".opencode", "skill"), "singular", { + name: "singular", + description: "Singular dir.", + }); + const skills = discoverSkills(cwd); + expect(skills.find((s) => s.id === "singular")).toBeDefined(); + }); + + it("discovers from .claude/skills/ and .agents/skills/", () => { + const cwd = tmp(); + writeSkill(join(cwd, ".claude", "skills"), "claude-skill", { + name: "claude-skill", + description: "Claude skill.", + }); + writeSkill(join(cwd, ".agents", "skills"), "agent-skill", { + name: "agent-skill", + description: "Agent skill.", + }); + const skills = discoverSkills(cwd); + expect(skills.find((s) => s.id === "claude-skill")).toBeDefined(); + expect(skills.find((s) => s.id === "agent-skill")).toBeDefined(); + }); + + it("project .opencode beats global .config/opencode on duplicate id", () => { + const cwd = tmp(); + // We can't easily test global without mocking homedir, so test + // project-level precedence: .opencode beats .claude for the same id. + writeSkill(join(cwd, ".claude", "skills"), "shared", { + name: "shared", + description: "Claude version.", + }); + writeSkill(join(cwd, ".opencode", "skills"), "shared", { + name: "shared", + description: "Opencode version.", + }); + const skills = discoverSkills(cwd); + const shared = skills.find((s) => s.id === "shared"); + expect(shared).toBeDefined(); + expect(shared!.description).toBe("Opencode version."); + }); + + it("project skill beats global skill with the same id (first wins)", () => { + // This test would have caught the original "later wins" bug, where + // global ~/.opencode (scanned last) would override a project skill. + const cwd = tmp(); + // Project skill. + writeSkill(join(cwd, ".opencode", "skills"), "global-vs-proj", { + name: "global-vs-proj", + 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.", + }); + const skills = discoverSkills(cwd); + const skill = skills.find((s) => s.id === "global-vs-proj"); + expect(skill).toBeDefined(); + expect(skill!.description).toBe("Project version."); + }); + + it("nearer up the tree beats farther (first wins, specificity-ordered)", () => { + const root = tmp(); + const sub = join(root, "subdir"); + mkdirSync(sub, { recursive: true }); + // Near skill scanned first → near wins (more specific location). + writeSkill(join(sub, ".opencode", "skills"), "dup", { + name: "dup", + description: "Near version.", + }); + writeSkill(join(root, ".opencode", "skills"), "dup", { + name: "dup", + description: "Far version.", + }); + // Need git init so worktreeRoot detects the root. + execSync("git init", { cwd: root }); + const skills = discoverSkills(sub); + const dup = skills.find((s) => s.id === "dup"); + expect(dup).toBeDefined(); + expect(dup!.description).toBe("Near version."); + }); + + it("skips skills missing name or description", () => { + const cwd = tmp(); + const dir = join(cwd, ".opencode", "skills", "no-name"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "SKILL.md"), + "---\ndescription: Has desc but no name.\n---\n\nBody.\n", + "utf8", + ); + const dir2 = join(cwd, ".opencode", "skills", "no-desc"); + mkdirSync(dir2, { recursive: true }); + writeFileSync( + join(dir2, "SKILL.md"), + "---\nname: no-desc\n---\n\nBody.\n", + "utf8", + ); + const dir3 = join(cwd, ".opencode", "skills", "valid"); + mkdirSync(dir3, { recursive: true }); + writeFileSync( + join(dir3, "SKILL.md"), + "---\nname: valid\ndescription: Valid.\n---\n\nBody.\n", + "utf8", + ); + const skills = discoverSkills(cwd); + expect(skills.map((s) => s.id)).toEqual(["valid"]); + }); + + it("handles malformed frontmatter gracefully", () => { + const cwd = tmp(); + const dir = join(cwd, ".opencode", "skills", "broken"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "SKILL.md"), "No frontmatter at all.\n", "utf8"); + const skills = discoverSkills(cwd); + expect(skills).toHaveLength(0); + }); + + it("collects supporting files alongside SKILL.md", () => { + const cwd = tmp(); + writeSkill( + join(cwd, ".opencode", "skills"), + "with-files", + { name: "with-files", description: "Has files." }, + "Body.", + { + "reference.md": "# Reference", + "templates/tmpl.txt": "Template content", + }, + ); + const skills = discoverSkills(cwd); + const skill = skills[0]!; + expect(skill.files).toContain("reference.md"); + expect(skill.files).toContain("templates/tmpl.txt"); + expect(skill.files).not.toContain("SKILL.md"); + }); + + it("handles a non-git directory (worktree root = cwd)", () => { + const cwd = tmp(); + writeSkill(join(cwd, ".opencode", "skills"), "no-git", { + name: "no-git", + description: "No git.", + }); + const skills = discoverSkills(cwd); + expect(skills.find((s) => s.id === "no-git")).toBeDefined(); + }); + + it("discovers skills from extraPaths (config.skills.paths)", () => { + const cwd = tmp(); + const extraDir = tmp(); + writeSkill(extraDir, "extra-skill", { + name: "extra-skill", + description: "From extra paths.", + }); + const skills = discoverSkills(cwd, [extraDir]); + expect(skills.find((s) => s.id === "extra-skill")).toBeDefined(); + }); + + it("extraPaths skills are lowest priority (project wins on duplicate)", () => { + const cwd = tmp(); + const extraDir = tmp(); + writeSkill(join(cwd, ".opencode", "skills"), "dup", { + name: "dup", + description: "Project version.", + }); + writeSkill(extraDir, "dup", { + name: "dup", + description: "Extra path version.", + }); + const skills = discoverSkills(cwd, [extraDir]); + const dup = skills.find((s) => s.id === "dup"); + expect(dup).toBeDefined(); + expect(dup!.description).toBe("Project version."); + }); + + it("expands ~/ prefix in extraPaths using homedir", () => { + const cwd = tmp(); + // Write a skill into the mocked fakeHome. + const extraDir = join(fakeHome, "my-skills"); + writeSkill(extraDir, "tilde-skill", { + name: "tilde-skill", + description: "Tilde expanded.", + }); + const skills = discoverSkills(cwd, ["~/my-skills"]); + expect(skills.find((s) => s.id === "tilde-skill")).toBeDefined(); + }); + + it("resolves relative extraPaths against cwd", () => { + const cwd = tmp(); + // Create a subdir with a skill, reference it relative to cwd. + writeSkill(join(cwd, "custom-skills"), "rel-skill", { + name: "rel-skill", + description: "Relative path.", + }); + const skills = discoverSkills(cwd, ["./custom-skills"]); + expect(skills.find((s) => s.id === "rel-skill")).toBeDefined(); + }); + + it("silently skips non-existent extraPaths", () => { + const cwd = tmp(); + const skills = discoverSkills(cwd, ["/nonexistent/path/12345"]); + expect(skills).toHaveLength(0); + }); +}); + +describe("filterSkills", () => { + function makeSkill(id: string): DiscoveredSkill { + return { + id, + name: id, + description: `Skill ${id}`, + sourceDir: `/fake/${id}`, + files: [], + }; + } + + it("allows all skills when no permission config is present", () => { + 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 } }, + } as unknown as 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"); + }); + + it("withholds ask-permissioned skills", () => { + const config = { + permission: { skill: { "a": "ask" as const } }, + } as unknown as 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"); + }); + + it("supports wildcard patterns in map-form permission", () => { + const config = { + permission: { + skill: { + "*": "allow" as const, + "internal-*": "deny" as const, + }, + }, + } as unknown as Config; + const result = filterSkills( + [makeSkill("public"), makeSkill("internal-secret")], + config, + ); + expect(result.skills.map((s) => s.id)).toEqual(["public"]); + expect(result.withheld.map((w) => w.id)).toEqual(["internal-secret"]); + }); + + it("last-matching pattern wins in map-form", () => { + const config = { + permission: { + skill: { + "*": "deny" as const, + "special": "allow" as const, + }, + }, + } as unknown as Config; + const result = filterSkills( + [makeSkill("special"), makeSkill("other")], + config, + ); + expect(result.skills.map((s) => s.id)).toEqual(["special"]); + expect(result.withheld.map((w) => w.id)).toEqual(["other"]); + }); + + it("supports rule-array form (last-matching-rule wins)", () => { + const config = { + permission: [ + { permission: "skill", pattern: "*", action: "allow" }, + { permission: "skill", pattern: "blocked", action: "deny" }, + ], + } as unknown as 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"] }, + ); + 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")], + undefined, + { include: ["*-one"], exclude: ["internal-*"] }, + ); + expect(result.skills.map((s) => s.id)).toEqual(["public-one"]); + }); + + it("manual include keeps a skill even if permission denies it", () => { + const config = { + permission: { skill: { "a": "deny" as const } }, + } as unknown as Config; + const result = filterSkills( + [makeSkill("a"), makeSkill("b")], + config, + { include: ["a"] }, + ); + expect(result.skills.map((s) => s.id)).toEqual(["a"]); + }); + + it("manual include list filters out non-listed skills", () => { + const result = filterSkills( + [makeSkill("a"), makeSkill("b"), makeSkill("c")], + undefined, + { include: ["a", "c"] }, + ); + expect(result.skills.map((s) => s.id)).toEqual(["a", "c"]); + expect(result.withheld.find((w) => w.id === "b")).toBeDefined(); + }); +}); + +describe("resolveSkills", () => { + it("combines discovery and filtering, never throws", () => { + const cwd = tmp(); + writeSkill(join(cwd, ".opencode", "skills"), "test", { + name: "test", + description: "Test skill.", + }); + const result = resolveSkills(cwd); + expect(result.skills).toHaveLength(1); + expect(result.skills[0]!.id).toBe("test"); + }); + + it("changes the skill-set hash when a supporting file changes", () => { + const cwd = tmp(); + const skillDir = writeSkill(join(cwd, ".opencode", "skills"), "test", { + name: "test", + description: "Test skill.", + }); + writeFileSync(join(skillDir, "reference.md"), "one", "utf8"); + const first = resolveSkills(cwd).skills; + const firstHash = skillSetHash(first); + + writeFileSync(join(skillDir, "reference.md"), "longer content", "utf8"); + const secondHash = skillSetHash(resolveSkills(cwd).skills); + expect(secondHash).not.toBe(firstHash); + }); +}); diff --git a/test/skill-mirror.test.ts b/test/skill-mirror.test.ts new file mode 100644 index 0000000..a411f44 --- /dev/null +++ b/test/skill-mirror.test.ts @@ -0,0 +1,262 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + readFileSync, + existsSync, + rmSync, + chmodSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + writeSkillMirror, + removeSkillMirror, + buildSkillsCatalogue, +} from "../src/provider/skill-mirror.js"; +import type { DiscoveredSkill } from "../src/plugin/skill-discovery.js"; + +const dirs: string[] = []; +function tmp(): string { + const d = mkdtempSync(join(tmpdir(), "cursor-mirror-")); + dirs.push(d); + return d; +} +afterEach(() => { + for (const d of dirs.splice(0)) { + // Restore permissions so cleanup can delete the tree. + try { + const skillsDir = join(d, ".cursor", "skills"); + if (existsSync(skillsDir)) chmodSync(skillsDir, 0o755); + } catch {} + try { + chmodSync(d, 0o755); + } catch {} + rmSync(d, { recursive: true, force: true }); + } +}); + +function makeSkill( + id: string, + sourceDir: string, + description = `Skill ${id}`, +): DiscoveredSkill { + mkdirSync(sourceDir, { recursive: true }); + writeFileSync( + join(sourceDir, "SKILL.md"), + `---\nname: ${id}\ndescription: ${description}\n---\n\nContent for ${id}.\n`, + "utf8", + ); + return { + id, + name: id, + description, + sourceDir, + files: [], + }; +} + +function skillDir(cwd: string, id: string): string { + return join(cwd, ".cursor", "skills", id); +} + +function readSkillMd(cwd: string, id: string): string { + return readFileSync(join(skillDir(cwd, id), "SKILL.md"), "utf8"); +} + +describe("writeSkillMirror", () => { + it("writes a skill with the sentinel stamped into frontmatter", () => { + const cwd = tmp(); + const src = join(cwd, "src-skills", "my-skill"); + const skill = makeSkill("my-skill", src); + const warnings: string[] = []; + const result = writeSkillMirror(cwd, [skill], (m) => warnings.push(m)); + expect(result).toBe("written"); + const content = readSkillMd(cwd, "my-skill"); + expect(content).toContain("generated: opencode-cursor"); + expect(content).toContain("name: my-skill"); + expect(content).toContain("description: Skill my-skill"); + expect(warnings).toHaveLength(0); + }); + + it("git-ignores the mirrored skill directories", () => { + const cwd = tmp(); + const skill = makeSkill("a", join(cwd, "src", "a")); + writeSkillMirror(cwd, [skill], () => {}); + const ignore = readFileSync( + join(cwd, ".cursor", "skills", ".gitignore"), + "utf8", + ); + expect(ignore.split(/\r?\n/)).toContain("a"); + expect(ignore.split(/\r?\n/)).toContain(".gitignore"); + }); + + it("is idempotent — second write with same skills performs no writes", () => { + const cwd = tmp(); + const skill = makeSkill("idem", join(cwd, "src", "idem")); + writeSkillMirror(cwd, [skill], () => {}); + // Make the skill dir read-only: an actual rewrite would throw EACCES, + // so a clean "unchanged" return proves the write was skipped. + const skillPath = skillDir(cwd, "idem"); + chmodSync(skillPath, 0o555); + const result = writeSkillMirror(cwd, [skill], () => {}); + expect(result).toBe("unchanged"); + // Restore for cleanup. + chmodSync(skillPath, 0o755); + }); + + it("never overwrites a user-owned skill (no sentinel)", () => { + const cwd = tmp(); + // Pre-existing user-owned skill. + const userDir = skillDir(cwd, "user-owned"); + mkdirSync(userDir, { recursive: true }); + const userBody = "---\nname: user-owned\ndescription: Mine.\n---\n\nMy content.\n"; + writeFileSync(join(userDir, "SKILL.md"), userBody, "utf8"); + + const skill = makeSkill("user-owned", join(cwd, "src", "user-owned")); + const warnings: string[] = []; + writeSkillMirror(cwd, [skill], (m) => warnings.push(m)); + expect(readSkillMd(cwd, "user-owned")).toBe(userBody); + expect(warnings.some((w) => w.includes("user-owned"))).toBe(true); + }); + + it("copies supporting files preserving relative paths", () => { + const cwd = tmp(); + const srcDir = join(cwd, "src", "with-files"); + mkdirSync(srcDir, { recursive: true }); + writeFileSync( + join(srcDir, "SKILL.md"), + "---\nname: with-files\ndescription: Has files.\n---\n\nBody.\n", + "utf8", + ); + mkdirSync(join(srcDir, "templates")); + writeFileSync(join(srcDir, "templates", "tmpl.txt"), "Template", "utf8"); + writeFileSync(join(srcDir, "ref.md"), "# Ref", "utf8"); + + const skill: DiscoveredSkill = { + id: "with-files", + name: "with-files", + description: "Has files.", + sourceDir: srcDir, + files: ["templates/tmpl.txt", "ref.md"], + }; + writeSkillMirror(cwd, [skill], () => {}); + expect(existsSync(join(skillDir(cwd, "with-files"), "templates", "tmpl.txt"))).toBe(true); + expect(existsSync(join(skillDir(cwd, "with-files"), "ref.md"))).toBe(true); + }); + + it("skips an oversized file without dropping its siblings", () => { + const cwd = tmp(); + const srcDir = join(cwd, "src", "oversized"); + const skill = makeSkill("oversized", srcDir); + writeFileSync(join(srcDir, "big.bin"), "x".repeat(1_048_577), "utf8"); + writeFileSync(join(srcDir, "keep.txt"), "keep", "utf8"); + skill.files = ["big.bin", "keep.txt"]; + const warnings: string[] = []; + + const result = writeSkillMirror(cwd, [skill], (message) => warnings.push(message)); + + expect(result).toBe("partial"); + expect(existsSync(join(skillDir(cwd, "oversized"), "big.bin"))).toBe(false); + expect(existsSync(join(skillDir(cwd, "oversized"), "keep.txt"))).toBe(true); + expect(warnings.some((warning) => warning.includes("oversized"))).toBe(true); + }); + + it("prunes sentinel-bearing dirs for skills no longer resolved", () => { + const cwd = tmp(); + const skillA = makeSkill("a", join(cwd, "src", "a")); + writeSkillMirror(cwd, [skillA], () => {}); + expect(existsSync(skillDir(cwd, "a"))).toBe(true); + + // Now mirror with no skills — "a" should be pruned. + writeSkillMirror(cwd, [], () => {}); + expect(existsSync(skillDir(cwd, "a"))).toBe(false); + }); + + it("does not prune user-owned dirs during pruning", () => { + const cwd = tmp(); + // First, mirror a generated skill. + const skillA = makeSkill("a", join(cwd, "src", "a")); + writeSkillMirror(cwd, [skillA], () => {}); + // Add a user-owned skill dir (no sentinel). + const userDir = skillDir(cwd, "user"); + mkdirSync(userDir, { recursive: true }); + writeFileSync( + join(userDir, "SKILL.md"), + "---\nname: user\ndescription: Mine.\n---\n\nMine.\n", + "utf8", + ); + // Re-mirror with no skills — generated "a" pruned, user "user" stays. + writeSkillMirror(cwd, [], () => {}); + expect(existsSync(skillDir(cwd, "a"))).toBe(false); + expect(existsSync(skillDir(cwd, "user"))).toBe(true); + }); + + it("returns empty when no skills are provided", () => { + const cwd = tmp(); + const result = writeSkillMirror(cwd, [], () => {}); + expect(result).toBe("empty"); + }); + + it("degrades gracefully on read-only cwd (warns, does not throw)", () => { + const cwd = tmp(); + const skill = makeSkill("ro", join(cwd, "src", "ro")); + // Make the .cursor/skills dir read-only after first mirror. + writeSkillMirror(cwd, [skill], () => {}); + chmodSync(join(cwd, ".cursor", "skills"), 0o555); + // Changing the skill content should trigger a write attempt that fails. + const newSrc = join(cwd, "src2", "ro"); + const newSkill = makeSkill("ro", newSrc, "Updated description"); + const warnings: string[] = []; + expect(() => + writeSkillMirror(cwd, [newSkill], (m) => warnings.push(m)), + ).not.toThrow(); + // Restore for cleanup. + chmodSync(join(cwd, ".cursor", "skills"), 0o755); + }); +}); + +describe("removeSkillMirror", () => { + it("removes generated skill dirs and the .gitignore", () => { + const cwd = tmp(); + const skill = makeSkill("rm", join(cwd, "src", "rm")); + writeSkillMirror(cwd, [skill], () => {}); + expect(existsSync(skillDir(cwd, "rm"))).toBe(true); + removeSkillMirror(cwd); + expect(existsSync(skillDir(cwd, "rm"))).toBe(false); + }); + + it("leaves user-owned skill dirs in place", () => { + const cwd = tmp(); + const userDir = skillDir(cwd, "user"); + mkdirSync(userDir, { recursive: true }); + const userBody = "---\nname: user\ndescription: Mine.\n---\n\nMine.\n"; + writeFileSync(join(userDir, "SKILL.md"), userBody, "utf8"); + removeSkillMirror(cwd); + expect(readSkillMd(cwd, "user")).toBe(userBody); + }); + + it("tolerates a missing mirror", () => { + const cwd = tmp(); + expect(() => removeSkillMirror(cwd)).not.toThrow(); + }); +}); + +describe("buildSkillsCatalogue", () => { + it("builds a catalogue listing each skill id and description", () => { + const skills: DiscoveredSkill[] = [ + { id: "a", name: "a", description: "Does A.", sourceDir: "/x", files: [] }, + { id: "b", name: "b", description: "Does B.", sourceDir: "/y", files: [] }, + ]; + const cat = buildSkillsCatalogue(skills); + expect(cat).toContain(""); + expect(cat).toContain("**a**: Does A."); + expect(cat).toContain("**b**: Does B."); + expect(cat).toContain(".cursor/skills//SKILL.md"); + }); + + it("returns undefined for no skills", () => { + expect(buildSkillsCatalogue([])).toBeUndefined(); + }); +}); From de890368e1826027b99ba4931e63984c91057199 Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Mon, 3 Aug 2026 11:03:39 -0500 Subject: [PATCH 2/5] 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. --- src/plugin/skill-discovery.ts | 44 ++++++++++++++++++++++-- src/provider/skill-mirror.ts | 17 ++++++++-- test/skill-discovery.test.ts | 63 +++++++++++++++++++++++++++++++++++ test/skill-mirror.test.ts | 36 ++++++++++++++++++++ 4 files changed, 155 insertions(+), 5 deletions(-) diff --git a/src/plugin/skill-discovery.ts b/src/plugin/skill-discovery.ts index ecd002a..c44bf67 100644 --- a/src/plugin/skill-discovery.ts +++ b/src/plugin/skill-discovery.ts @@ -3,6 +3,7 @@ import { readFileSync, statSync, existsSync, + realpathSync, } from "node:fs"; import type { Dirent } from "node:fs"; import { join, relative, dirname, resolve as resolvePath, isAbsolute } from "node:path"; @@ -124,7 +125,11 @@ function scanSkillDir( } const found: Array<{ id: string; sourceDir: string }> = []; for (const entry of entries) { - if (!entry.isDirectory()) continue; + // Symlinks are admitted here rather than filtered: `Dirent.isDirectory()` + // is false for a symlink pointing at a directory, which would silently + // drop skills linked in from a shared checkout. The `SKILL.md` check + // below follows symlinks, so it rejects broken links and links to files. + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; const skillDir = join(dir, entry.name); if (!existsSync(join(skillDir, "SKILL.md"))) continue; found.push({ id: entry.name, sourceDir: skillDir }); @@ -132,10 +137,42 @@ function scanSkillDir( return found; } +/** + * Classify a directory entry, following symlinks. `Dirent` reports a symlink + * as neither file nor directory, so symlinked supporting files would be lost + * without this. Broken symlinks and non-regular targets resolve to "other". + */ +export function entryKind( + entry: Dirent, + fullPath: string, +): "dir" | "file" | "other" { + if (entry.isDirectory()) return "dir"; + if (entry.isFile()) return "file"; + if (!entry.isSymbolicLink()) return "other"; + try { + const target = statSync(fullPath); + if (target.isDirectory()) return "dir"; + if (target.isFile()) return "file"; + } catch { + // Broken symlink. + } + return "other"; +} + /** Collect supporting files (relative paths) alongside SKILL.md in a skill dir. */ function collectFiles(sourceDir: string): string[] { const files: string[] = []; + // Following symlinked directories admits cycles; track resolved paths. + const visited = new Set(); function walk(dir: string, base: string) { + let realDir: string; + try { + realDir = realpathSync(dir); + } catch { + return; + } + if (visited.has(realDir)) return; + visited.add(realDir); let entries; try { entries = readdirSync(dir, { withFileTypes: true }); @@ -146,9 +183,10 @@ function collectFiles(sourceDir: string): string[] { const fullPath = join(dir, entry.name); const relPath = relative(base, fullPath); if (entry.name === "SKILL.md") continue; - if (entry.isDirectory()) { + const kind = entryKind(entry, fullPath); + if (kind === "dir") { walk(fullPath, base); - } else if (entry.isFile()) { + } else if (kind === "file") { files.push(relPath); } } diff --git a/src/provider/skill-mirror.ts b/src/provider/skill-mirror.ts index 8bd9d12..a344da3 100644 --- a/src/provider/skill-mirror.ts +++ b/src/provider/skill-mirror.ts @@ -7,9 +7,11 @@ import { readdirSync, statSync, copyFileSync, + realpathSync, } from "node:fs"; import type { Dirent } from "node:fs"; import { join, relative, dirname } from "node:path"; +import { entryKind } from "../plugin/skill-discovery.js"; import type { DiscoveredSkill } from "../plugin/skill-discovery.js"; /** Location of the generated mirror, relative to the agent's cwd. */ @@ -76,8 +78,18 @@ function copyTree( ): { bytes: number; skipped: string[] } { let bytes = 0; const skipped: string[] = []; + // Following symlinked directories admits cycles; track resolved paths. + const visited = new Set(); function walk(src: string, dest: string) { + let realSrc: string; + try { + realSrc = realpathSync(src); + } catch { + return; + } + if (visited.has(realSrc)) return; + visited.add(realSrc); let entries; try { entries = readdirSync(src, { withFileTypes: true }); @@ -87,9 +99,10 @@ function copyTree( for (const entry of entries) { const srcPath = join(src, entry.name); const destPath = join(dest, entry.name); - if (entry.isDirectory()) { + const kind = entryKind(entry, srcPath); + if (kind === "dir") { walk(srcPath, destPath); - } else if (entry.isFile()) { + } else if (kind === "file") { // Never copy SKILL.md here — it's already written with the // sentinel stamped by the caller. Copying the original would // overwrite the stamped version. diff --git a/test/skill-discovery.test.ts b/test/skill-discovery.test.ts index 0437dbb..1033fd9 100644 --- a/test/skill-discovery.test.ts +++ b/test/skill-discovery.test.ts @@ -5,6 +5,7 @@ import { writeFileSync, rmSync, existsSync, + symlinkSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -289,6 +290,68 @@ describe("discoverSkills", () => { const skills = discoverSkills(cwd, ["/nonexistent/path/12345"]); expect(skills).toHaveLength(0); }); + + it("discovers a skill through a symlinked skill directory", () => { + const cwd = tmp(); + const real = tmp(); + const target = writeSkill(real, "linked-real", { + name: "linked", + description: "Reached through a symlink.", + }); + const skillsRoot = join(cwd, ".opencode", "skills"); + mkdirSync(skillsRoot, { recursive: true }); + symlinkSync(target, join(skillsRoot, "linked")); + const skills = discoverSkills(cwd); + const linked = skills.find((s) => s.id === "linked"); + expect(linked).toBeDefined(); + expect(linked!.description).toBe("Reached through a symlink."); + }); + + it("skips a broken symlink and a symlink pointing at a file", () => { + const cwd = tmp(); + const skillsRoot = join(cwd, ".opencode", "skills"); + mkdirSync(skillsRoot, { recursive: true }); + symlinkSync(join(cwd, "does-not-exist"), join(skillsRoot, "broken")); + const loose = join(cwd, "loose.md"); + writeFileSync(loose, "not a skill dir", "utf8"); + symlinkSync(loose, join(skillsRoot, "file-link")); + expect(discoverSkills(cwd)).toHaveLength(0); + }); + + it("collects supporting files reached through symlinks", () => { + const cwd = tmp(); + const real = tmp(); + const refTarget = join(real, "reference.md"); + writeFileSync(refTarget, "reference body", "utf8"); + const assetsTarget = join(real, "assets"); + mkdirSync(assetsTarget, { recursive: true }); + writeFileSync(join(assetsTarget, "note.txt"), "note", "utf8"); + + const dir = writeSkill(join(cwd, ".opencode", "skills"), "with-links", { + name: "with-links", + description: "Has symlinked supporting files.", + }); + symlinkSync(refTarget, join(dir, "reference.md")); + symlinkSync(assetsTarget, join(dir, "assets")); + + const skill = discoverSkills(cwd).find((s) => s.id === "with-links"); + expect(skill).toBeDefined(); + expect(skill!.files).toContain("reference.md"); + expect(skill!.files).toContain(join("assets", "note.txt")); + }); + + it("does not loop forever on a self-referential symlinked subdirectory", () => { + const cwd = tmp(); + const dir = writeSkill(join(cwd, ".opencode", "skills"), "cyclic", { + name: "cyclic", + description: "Contains a symlink loop.", + }); + writeFileSync(join(dir, "real.txt"), "real", "utf8"); + symlinkSync(dir, join(dir, "loop")); + const skill = discoverSkills(cwd).find((s) => s.id === "cyclic"); + expect(skill).toBeDefined(); + expect(skill!.files).toContain("real.txt"); + }); }); describe("filterSkills", () => { diff --git a/test/skill-mirror.test.ts b/test/skill-mirror.test.ts index a411f44..8fb15cd 100644 --- a/test/skill-mirror.test.ts +++ b/test/skill-mirror.test.ts @@ -7,6 +7,7 @@ import { existsSync, rmSync, chmodSync, + symlinkSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -215,6 +216,41 @@ describe("writeSkillMirror", () => { // Restore for cleanup. chmodSync(join(cwd, ".cursor", "skills"), 0o755); }); + + it("copies supporting files reached through symlinks", () => { + const cwd = tmp(); + const src = join(cwd, "src", "linked-files"); + const skill = makeSkill("linked-files", src); + const external = tmp(); + const refTarget = join(external, "reference.md"); + writeFileSync(refTarget, "reference body", "utf8"); + const assetsTarget = join(external, "assets"); + mkdirSync(assetsTarget, { recursive: true }); + writeFileSync(join(assetsTarget, "note.txt"), "note", "utf8"); + symlinkSync(refTarget, join(src, "reference.md")); + symlinkSync(assetsTarget, join(src, "assets")); + + const warnings: string[] = []; + writeSkillMirror(cwd, [skill], (m) => warnings.push(m)); + const dest = skillDir(cwd, "linked-files"); + expect(readFileSync(join(dest, "reference.md"), "utf8")).toBe( + "reference body", + ); + expect(readFileSync(join(dest, "assets", "note.txt"), "utf8")).toBe("note"); + expect(warnings).toHaveLength(0); + }); + + it("does not loop forever on a self-referential symlinked subdirectory", () => { + const cwd = tmp(); + const src = join(cwd, "src", "cyclic"); + const skill = makeSkill("cyclic", src); + writeFileSync(join(src, "real.txt"), "real", "utf8"); + symlinkSync(src, join(src, "loop")); + expect(() => writeSkillMirror(cwd, [skill], () => {})).not.toThrow(); + expect( + readFileSync(join(skillDir(cwd, "cyclic"), "real.txt"), "utf8"), + ).toBe("real"); + }); }); describe("removeSkillMirror", () => { From 053e4eba84633d4b45fcd3feab81d8126e202ea1 Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Mon, 3 Aug 2026 11:03:48 -0500 Subject: [PATCH 3/5] docs(skills): correct deny scope, note plugin-skill gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 6 ++++-- README.md | 6 ++++++ SECURITY.md | 4 +++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2f5eb9..f284f41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,10 @@ All notable changes to this project will be documented in this file. `forwardSkills: false`; manual override with `skills: { include, exclude }`. User-owned `.cursor/skills//` directories are never overwritten. `config.skills.paths` directories are also scanned (lowest priority, - first-wins on duplicate ids). `config.skills.urls` (HTTP catalogs) are not - yet supported. + first-wins on duplicate ids). Symlinked skill directories and symlinked + supporting files are followed (broken links and symlink loops are skipped). + `config.skills.urls` (HTTP catalogs) and skills bundled inside opencode + plugin packages are not yet supported. ## [0.6.1] — 2026-07-24 diff --git a/README.md b/README.md index 4648789..277e4a2 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,12 @@ user explicitly asked for them). `exclude` always drops the listed skills. ### Limitations +- **Skills bundled inside opencode plugins are not mirrored.** Those ship in the + plugin package cache (`~/.cache/opencode/packages//node_modules//skills/`), + 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`. - 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/SECURITY.md b/SECURITY.md index 3ef4901..6d33d93 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -50,7 +50,9 @@ opencode's permission system**. Key implications: 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. +- `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. - `ask`-permissioned skills are also excluded, because the ask prompt cannot be enforced across the Cursor boundary (there is no way for Cursor to relay the approval request back to opencode's permission system). From fb44fb64a46418c7291b11efdb423b4c5c8c8934 Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Mon, 3 Aug 2026 11:03:48 -0500 Subject: [PATCH 4/5] style(plugin): normalize indentation in skills bridge blocks --- src/plugin/cursor-tools.ts | 32 +++++++-------- src/plugin/index.ts | 80 +++++++++++++++++++------------------- 2 files changed, 56 insertions(+), 56 deletions(-) diff --git a/src/plugin/cursor-tools.ts b/src/plugin/cursor-tools.ts index f387801..f073631 100644 --- a/src/plugin/cursor-tools.ts +++ b/src/plugin/cursor-tools.ts @@ -181,22 +181,22 @@ export function buildCursorTools(deps: CursorToolDeps): Record { if (Object.keys(params).length > 0) modelParamDefaults[item.id] = params; } - // One canonical cwd for the provider's rule write and our dispose - // cleanup: an explicit user option wins, else the plugin directory. - const optionCwd = existingOptions["cwd"]; - resolvedCwd = - (typeof optionCwd === "string" ? optionCwd : undefined) ?? - directory ?? - process.cwd(); + // One canonical cwd for the provider's rule write and our dispose + // cleanup: an explicit user option wins, else the plugin directory. + const optionCwd = existingOptions["cwd"]; + resolvedCwd = + (typeof optionCwd === "string" ? optionCwd : undefined) ?? + directory ?? + process.cwd(); - // Forward opencode's resolved skills (both project and global scope) to - // the Cursor agent by mirroring them into `/.cursor/skills/`. Cursor - // discovers these natively when the `project` settings layer is loaded. - // Opt out via `provider.cursor.options.forwardSkills: false`. Manual - // include/exclude override via `provider.cursor.options.skills`. - forwardSkills = existingOptions["forwardSkills"] !== false; - const skillsOpt = existingOptions["skills"] as - | { include?: string[]; exclude?: string[] } - | undefined; - skillFilterOptions = skillsOpt; + // Forward opencode's resolved skills (both project and global scope) to + // the Cursor agent by mirroring them into `/.cursor/skills/`. Cursor + // discovers these natively when the `project` settings layer is loaded. + // Opt out via `provider.cursor.options.forwardSkills: false`. Manual + // include/exclude override via `provider.cursor.options.skills`. + forwardSkills = existingOptions["forwardSkills"] !== false; + const skillsOpt = existingOptions["skills"] as + | { include?: string[]; exclude?: string[] } + | undefined; + skillFilterOptions = skillsOpt; - if (forwardSkills) { - try { - const resolved = resolveSkills( - resolvedCwd, - config as Config | undefined, - skillFilterOptions, - ); - writeSkillMirror(resolvedCwd, resolved.skills, (msg) => - console.warn(`[cursor] ${msg}`), - ); - currentSkillsCatalogue = buildSkillsCatalogue(resolved.skills) ?? ""; - lastSkillHash = skillSetHash(resolved.skills); - if (resolved.withheld.length > 0) { - const summary = resolved.withheld - .map((w) => `${w.id} (${w.reason})`) - .join(", "); + if (forwardSkills) { + try { + const resolved = resolveSkills( + resolvedCwd, + config as Config | undefined, + skillFilterOptions, + ); + writeSkillMirror(resolvedCwd, resolved.skills, (msg) => + console.warn(`[cursor] ${msg}`), + ); + currentSkillsCatalogue = buildSkillsCatalogue(resolved.skills) ?? ""; + lastSkillHash = skillSetHash(resolved.skills); + if (resolved.withheld.length > 0) { + const summary = resolved.withheld + .map((w) => `${w.id} (${w.reason})`) + .join(", "); + console.warn( + `[cursor] Skills withheld from mirror: ${summary}`, + ); + } + } catch (error) { console.warn( - `[cursor] Skills withheld from mirror: ${summary}`, + `[cursor] Skill mirror failed: ${error instanceof Error ? error.message : String(error)}. Skills will not be available to the Cursor agent this session.`, ); } - } catch (error) { - console.warn( - `[cursor] Skill mirror failed: ${error instanceof Error ? error.message : String(error)}. Skills will not be available to the Cursor agent this session.`, - ); } - } config.provider[PROVIDER_ID] = { name: "Cursor", @@ -290,8 +290,8 @@ export const CursorPlugin: Plugin = async (input) => { } } catch { // Keep the static snapshot; live forwarding is best-effort. + } } - } // Re-sync the skill mirror from opencode's *live* state so skills // added/removed mid-session reach the Cursor agent on the next turn. From 99488b3f3ea300d9b7c8b956f2f1d2a6805829ab Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Mon, 3 Aug 2026 11:25:03 -0500 Subject: [PATCH 5/5] 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. --- CHANGELOG.md | 6 ++++-- README.md | 5 +++-- src/plugin/index.ts | 25 +++++++++++++------------ 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5dc5f5..b43e6f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,10 @@ All notable changes to this project will be documented in this file. `config.skills.paths` directories are also scanned (lowest priority, first-wins on duplicate ids). Symlinked skill directories and symlinked supporting files are followed (broken links and symlink loops are skipped). - `config.skills.urls` (HTTP catalogs) and skills bundled inside opencode - plugin packages are not yet supported. + Mirror diagnostics (withheld skills, oversized files, write failures) route + through opencode's structured plugin logging rather than the terminal, + matching 0.7.0's logging change. `config.skills.urls` (HTTP catalogs) and + skills bundled inside opencode plugin packages are not yet supported. ## [0.7.0] — 2026-07-30 diff --git a/README.md b/README.md index b809933..c5c2626 100644 --- a/README.md +++ b/README.md @@ -345,8 +345,9 @@ user explicitly asked for them). `exclude` always drops the listed skills. ### Limitations -- **Skills bundled inside opencode plugins are not mirrored.** Those ship in the - plugin package cache (`~/.cache/opencode/packages//node_modules//skills/`), +- **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 diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 78d6183..1c02976 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -15,7 +15,7 @@ import { import { buildCursorTools } from "./cursor-tools.js"; import { getLocalVersion, getLatestVersion, clearVersionCache, PLUGIN_CACHE_PATH } from "../version-check.js"; import { removeSystemRule } from "../provider/system-rule.js"; -import { clearLogBridge, setLogBridge } from "../provider/log-bridge.js"; +import { clearLogBridge, pluginLog, setLogBridge } from "../provider/log-bridge.js"; import { writeSkillMirror, removeSkillMirror, @@ -230,22 +230,23 @@ export const CursorPlugin: Plugin = async (input) => { skillFilterOptions, ); writeSkillMirror(resolvedCwd, resolved.skills, (msg) => - console.warn(`[cursor] ${msg}`), + pluginLog("warn", msg), ); currentSkillsCatalogue = buildSkillsCatalogue(resolved.skills) ?? ""; lastSkillHash = skillSetHash(resolved.skills); if (resolved.withheld.length > 0) { - const summary = resolved.withheld - .map((w) => `${w.id} (${w.reason})`) - .join(", "); - console.warn( - `[cursor] Skills withheld from mirror: ${summary}`, - ); + pluginLog("warn", "skills withheld from mirror", { + withheld: resolved.withheld.map((w) => ({ + id: w.id, + reason: w.reason, + })), + }); } } catch (error) { - console.warn( - `[cursor] Skill mirror failed: ${error instanceof Error ? error.message : String(error)}. Skills will not be available to the Cursor agent this session.`, - ); + pluginLog("warn", "skill mirror failed", { + error: error instanceof Error ? error.message : String(error), + impact: "skills unavailable to the Cursor agent this session", + }); } } @@ -370,7 +371,7 @@ export const CursorPlugin: Plugin = async (input) => { const hash = skillSetHash(resolved.skills); if (hash !== lastSkillHash) { writeSkillMirror(resolvedCwd, resolved.skills, (msg) => - console.warn(`[cursor] ${msg}`), + pluginLog("warn", msg), ); currentSkillsCatalogue = buildSkillsCatalogue(resolved.skills) ?? "";