Skip to content

Commit b156e32

Browse files
committed
feat: mirror opencode built-in skills from the live inventory
opencode registers skills in code (currently `customize-opencode`, location `<built-in>`) that have no on-disk SKILL.md, so neither the filesystem scan nor the live merge reached them — the live entry was dropped because its location isn't resolvable. Materialise content-only live skills (name + description + content from `app.skills`) into a per-process scratch dir with generated frontmatter, so the mirror stamps and copies them like disk-backed skills. The copy is only rewritten when the content or description changes, keeping per-turn mtimes (and the skill-set hash) stable. Verified against a live `opencode debug skill --pure` dump: 23 skills parsed, `customize-opencode` lands in `.cursor/skills/` with the sentinel and appears in the `<available_skills>` catalogue.
1 parent 2124b11 commit b156e32

3 files changed

Lines changed: 202 additions & 17 deletions

File tree

‎README.md‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -318,14 +318,19 @@ The mirror includes:
318318
- **opencode's live skill inventory** — on every turn the mirror also consults
319319
opencode's `app.skills` endpoint (when reachable) and merges any skill it
320320
knows about that the filesystem scan missed, at the same lowest priority.
321+
- **opencode's built-in skills** — skills opencode registers in code rather
322+
than on disk (currently `customize-opencode`, its own config-authoring
323+
guide). They only exist in the live inventory, so the mirror materialises
324+
them from the endpoint's content into `.cursor/skills/` like any other
325+
skill; the materialised copy updates whenever opencode's version changes.
321326
- **Supporting files** alongside each `SKILL.md` (preserving relative paths).
322327
- An `<available_skills>` catalogue appended to the generated system rule,
323328
listing each skill's id and description so the Cursor agent can load them on
324329
demand.
325330

326-
> **Note:** `config.skills.urls` (HTTP skill catalogs) are not yet supported by
327-
> the mirror. If you rely on URL-sourced skills, they will not appear in
328-
> `.cursor/skills/`.
331+
> **Note:** URL-sourced skills (`config.skills.urls`) that are also present in
332+
> opencode's live inventory reach the mirror through that route; the mirror
333+
> does not fetch `skills.urls` catalogs on its own.
329334
330335
### Permission filtering
331336

@@ -359,10 +364,12 @@ user explicitly asked for them). `exclude` always drops the listed skills.
359364

360365
### Limitations
361366

362-
- Plugin-bundled skills are resolved from the plugin package cache by
363-
filesystem scan (`@opencode-ai/sdk` exposes no skills API), so they update
364-
only when the cache is refreshed — run `opencode-plugins-refresh` after
365-
installing/updating a plugin that ships skills, then restart opencode.
367+
- Skills served via `skills.urls` that opencode itself hasn't loaded (the
368+
endpoint is reachable but the catalog wasn't pulled this session) won't
369+
appear until opencode sees them.
370+
- Built-in skills require the live `app.skills` endpoint (i.e. a running
371+
opencode server reachable by this plugin); the filesystem scan can't see
372+
them on its own.
366373
- A user-owned `.cursor/skills/<id>/SKILL.md` (without the `generated:
367374
opencode-cursor` sentinel) is never overwritten or deleted.
368375
- Individual files larger than 1 MB are skipped (the rest of the skill is still

‎src/plugin/skill-discovery.ts‎

Lines changed: 96 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import {
44
statSync,
55
existsSync,
66
realpathSync,
7+
mkdtempSync,
8+
mkdirSync,
9+
rmSync,
10+
writeFileSync,
711
} from "node:fs";
812
import type { Dirent } from "node:fs";
913
import {
@@ -13,7 +17,7 @@ import {
1317
resolve as resolvePath,
1418
isAbsolute,
1519
} from "node:path";
16-
import { homedir } from "node:os";
20+
import { homedir, tmpdir } from "node:os";
1721
import { execSync } from "node:child_process";
1822
import type { Config } from "@opencode-ai/plugin";
1923

@@ -740,28 +744,112 @@ export interface LiveSkill {
740744
name: string;
741745
description?: string;
742746
location: string;
747+
/**
748+
* The skill's full body. Present for content-only skills (e.g. opencode's
749+
* `<built-in>` skills, which have no on-disk SKILL.md).
750+
*/
751+
content?: string;
743752
}
744753

745754
/**
746-
* Convert live `app.skills` entries into {@link DiscoveredSkill}s, pointing
747-
* at each skill's on-disk directory (derived from `location`). Entries whose
748-
* location can't be resolved are skipped — the filesystem scan already
749-
* covers anything reachable.
755+
* Scratch root holding materialised copies of skills that only exist in
756+
* opencode's live inventory (no on-disk SKILL.md — e.g. opencode's own
757+
* `<built-in>` skills, whose content is registered in code). Stable across
758+
* calls so `skillSetHash` mtime checks don't churn per turn; wiped on exit.
759+
*/
760+
let liveScratchRoot: string | undefined;
761+
let liveScratchCleanupRegistered = false;
762+
763+
/** Test hook: drop the scratch root so tests don't share state. */
764+
export function resetLiveSkillScratch(): void {
765+
const root = liveScratchRoot;
766+
if (root) {
767+
try {
768+
rmSync(root, { recursive: true, force: true });
769+
} catch {
770+
// best effort
771+
}
772+
}
773+
liveScratchRoot = undefined;
774+
}
775+
776+
function liveScratchDir(): string {
777+
if (!liveScratchRoot) {
778+
liveScratchRoot = mkdtempSync(join(tmpdir(), "opencode-cursor-skills-"));
779+
if (!liveScratchCleanupRegistered) {
780+
liveScratchCleanupRegistered = true;
781+
const rootAtExit = liveScratchRoot;
782+
process.once("exit", () => {
783+
rmSync(rootAtExit, { recursive: true, force: true });
784+
});
785+
}
786+
}
787+
return liveScratchRoot;
788+
}
789+
790+
/**
791+
* Convert live `app.skills` entries into {@link DiscoveredSkill}s.
792+
*
793+
* Disk-backed entries point at the skill's on-disk directory (derived from
794+
* `location`); entries whose location isn't resolvable are skipped — the
795+
* filesystem scan already covers anything reachable.
796+
*
797+
* Content-only entries (no on-disk SKILL.md — opencode's `<built-in>` skills
798+
* and anything else opencode serves from memory) are materialised into a
799+
* scratch dir so the mirror can stamp and copy them like any other skill.
800+
* The rewritten file carries frontmatter (the live `description`) + the
801+
* live `content` body, keeping the mirror in sync with opencode's version.
750802
*/
751803
export function liveSkillsToDiscovered(live: LiveSkill[]): DiscoveredSkill[] {
752804
const out: DiscoveredSkill[] = [];
753805
for (const skill of live) {
754-
if (!skill.location) continue;
806+
if (!skill.location || !skill.name) continue;
755807
const sourceDir = skill.location.endsWith("SKILL.md")
756808
? dirname(skill.location)
757809
: skill.location;
758-
if (!existsSync(join(sourceDir, "SKILL.md"))) continue;
759-
const loaded = loadSkill(skill.name, sourceDir);
810+
if (existsSync(join(sourceDir, "SKILL.md"))) {
811+
const loaded = loadSkill(skill.name, sourceDir);
812+
if (loaded) out.push(loaded);
813+
continue;
814+
}
815+
// Not on disk: materialise if opencode gave us content.
816+
if (!skill.content) continue;
817+
const scratchDir = join(liveScratchDir(), skill.name);
818+
const scratchMd = join(scratchDir, "SKILL.md");
819+
// Rewrite only when the content or description actually changed, so
820+
// per-turn calls don't touch mtimes and invalidate the skill hash.
821+
let needsWrite = true;
822+
if (existsSync(scratchMd)) {
823+
try {
824+
const existing = readFileSync(scratchMd, "utf8");
825+
if (existing === renderLiveSkillMd(skill)) needsWrite = false;
826+
} catch {
827+
// unreadable → rewrite
828+
}
829+
}
830+
if (needsWrite) {
831+
try {
832+
mkdirSync(scratchDir, { recursive: true });
833+
writeFileSync(scratchMd, renderLiveSkillMd(skill), "utf8");
834+
} catch {
835+
continue; // scratch fs unavailable — skip this skill
836+
}
837+
}
838+
const loaded = loadSkill(skill.name, scratchDir);
760839
if (loaded) out.push(loaded);
761840
}
762841
return out;
763842
}
764843

844+
/** Render a live (content-only) skill as a stamped SKILL.md. */
845+
function renderLiveSkillMd(skill: LiveSkill): string {
846+
// YAML: quote the description to survive colons/quotes inside it.
847+
const escaped = (skill.description ?? "").replace(/"/g, '\\"');
848+
const body = skill.content ?? "";
849+
const separator = body.startsWith("\n") ? "" : "\n";
850+
return `---\nname: ${skill.name}\ndescription: "${escaped}"\n---\n${separator}${body}`;
851+
}
852+
765853
/**
766854
* Discover and filter skills in one call. This is the main entry point for the
767855
* plugin's config and chat.params hooks. Never throws — fs errors degrade to

‎test/skill-discovery.test.ts‎

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
rmSync,
77
existsSync,
88
symlinkSync,
9+
readFileSync,
10+
statSync,
911
} from "node:fs";
1012
import { tmpdir } from "node:os";
1113
import { join } from "node:path";
@@ -34,8 +36,13 @@ afterAll(() => {
3436
else process.env["XDG_CACHE_HOME"] = realXdgCache;
3537
});
3638

37-
const { discoverSkills, filterSkills, resolveSkills, skillSetHash } =
38-
await import("../src/plugin/skill-discovery.js");
39+
const {
40+
discoverSkills,
41+
filterSkills,
42+
resolveSkills,
43+
skillSetHash,
44+
resetLiveSkillScratch,
45+
} = await import("../src/plugin/skill-discovery.js");
3946
import type { DiscoveredSkill } from "../src/plugin/skill-discovery.js";
4047
import type { Config } from "@opencode-ai/plugin";
4148

@@ -46,6 +53,8 @@ function tmp(): string {
4653
return d;
4754
}
4855
afterEach(() => {
56+
// Drop the content-only live-skill scratch root between tests.
57+
resetLiveSkillScratch();
4958
for (const d of dirs.splice(0)) {
5059
rmSync(d, { recursive: true, force: true });
5160
}
@@ -883,3 +892,84 @@ describe("live skills merge (app.skills)", () => {
883892
expect(result.withheld.find((w) => w.id === "live-denied")).toBeDefined();
884893
});
885894
});
895+
896+
describe("live built-in (content-only) skills", () => {
897+
it("materialises a <built-in> skill so the mirror can copy it", () => {
898+
const cwd = tmp();
899+
const cacheRoot = tmp();
900+
const result = resolveSkills(cwd, undefined, undefined, {
901+
cacheRoot,
902+
liveSkills: [
903+
{
904+
name: "customize-opencode",
905+
description: "Use ONLY when editing opencode config.",
906+
location: "<built-in>",
907+
content: "# Customizing opencode\n\nBody here.",
908+
},
909+
],
910+
});
911+
const skill = result.skills.find((s) => s.id === "customize-opencode");
912+
expect(skill).toBeDefined();
913+
expect(skill!.description).toBe("Use ONLY when editing opencode config.");
914+
// The materialised dir holds a real SKILL.md with frontmatter + body.
915+
const md = readFileSync(join(skill!.sourceDir, "SKILL.md"), "utf8");
916+
expect(md).toContain("name: customize-opencode");
917+
expect(md).toContain("# Customizing opencode");
918+
// Second resolve with identical input must not rewrite the file
919+
// (mtimes stable → skillSetHash stable → no per-turn mirror churn).
920+
const mtimeBefore = statSync(join(skill!.sourceDir, "SKILL.md")).mtimeMs;
921+
resolveSkills(cwd, undefined, undefined, {
922+
cacheRoot,
923+
liveSkills: [
924+
{
925+
name: "customize-opencode",
926+
description: "Use ONLY when editing opencode config.",
927+
location: "<built-in>",
928+
content: "# Customizing opencode\n\nBody here.",
929+
},
930+
],
931+
});
932+
const mtimeAfter = statSync(join(skill!.sourceDir, "SKILL.md")).mtimeMs;
933+
expect(mtimeAfter).toBe(mtimeBefore);
934+
});
935+
936+
it("rewrites the materialised copy when opencode's content changes", () => {
937+
const cwd = tmp();
938+
const cacheRoot = tmp();
939+
const mk = (content: string) =>
940+
resolveSkills(cwd, undefined, undefined, {
941+
cacheRoot,
942+
liveSkills: [
943+
{
944+
name: "builtin-v2",
945+
description: "Built-in.",
946+
location: "<built-in>",
947+
content,
948+
},
949+
],
950+
});
951+
const first = mk("# v1");
952+
expect(first.skills.find((s) => s.id === "builtin-v2")).toBeDefined();
953+
const dir = first.skills.find((s) => s.id === "builtin-v2")!.sourceDir;
954+
const second = mk("# v2 updated");
955+
const md = readFileSync(join(dir, "SKILL.md"), "utf8");
956+
expect(md).toContain("# v2 updated");
957+
expect(second.skills).toHaveLength(1);
958+
});
959+
960+
it("skips content-only entries without content or description", () => {
961+
const cwd = tmp();
962+
const result = resolveSkills(cwd, undefined, undefined, {
963+
cacheRoot: tmp(),
964+
liveSkills: [
965+
{ name: "no-content", description: "d", location: "<built-in>" },
966+
{
967+
name: "no-description",
968+
location: "<built-in>",
969+
content: "# body",
970+
},
971+
],
972+
});
973+
expect(result.skills).toHaveLength(0);
974+
});
975+
});

0 commit comments

Comments
 (0)