From cee7d16a99ef1c9e44e6b75c0a1b40062f4f8ab3 Mon Sep 17 00:00:00 2001 From: Wayne Simpson Date: Sun, 26 Jul 2026 14:22:15 +0100 Subject: [PATCH] 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(); + }); +});