diff --git a/commands/__tests__/skills-surface.test.ts b/commands/__tests__/skills-surface.test.ts new file mode 100644 index 00000000..7571e479 --- /dev/null +++ b/commands/__tests__/skills-surface.test.ts @@ -0,0 +1,438 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { execFileSync } from "child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { dirname, join } from "path"; +import { computeRows, decidePaletteAction, skillsSurface } from "../skills.ts"; + +function writeFile(path: string, content: string): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); +} + +function makePackDir(): string { + return realpathSync(mkdtempSync(join(tmpdir(), "rt-skills-surface-pack-"))); +} + +function writeStubs(packDir: string, verbs: Record): void { + writeFile(join(packDir, "pack", "stubs.jsonc"), JSON.stringify({ verbs })); +} + +/** Trivial no-slot pipeline-step engine + fixture mattstack root, for apply's compile delegation. */ +function makeEngineFixture(): { mattstackDir: string; manifestPath: string } { + const mattstackDir = realpathSync(mkdtempSync(join(tmpdir(), "rt-skills-surface-mattstack-"))); + writeFile( + join(mattstackDir, "plugins", "mattstack", ".claude-plugin", "plugin.json"), + JSON.stringify({ version: "1.0.0" }), + ); + writeFile( + join(mattstackDir, "plugins", "mattstack", "skills", "pipeline", "my-verb", "SKILL.md"), + `---\nname: my-verb\ndescription: "Do the thing"\ntype: pipeline-step\n---\n\nDo the thing.\n`, + ); + + const manifestDir = realpathSync(mkdtempSync(join(tmpdir(), "rt-skills-surface-manifest-"))); + const manifestPath = join(manifestDir, "skills.jsonc"); + writeFile(manifestPath, JSON.stringify({ bindings: {} })); + + return { mattstackDir, manifestPath }; +} + +let logSpy: ReturnType; +let logs: string[]; + +beforeEach(() => { + logs = []; + logSpy = spyOn(console, "log").mockImplementation((...args: unknown[]) => { + logs.push(args.map(String).join(" ")); + }); +}); + +afterEach(() => { + logSpy.mockRestore(); + // Bun ignores process.exitCode = undefined once truthy; 0 is the only value that clears it. + process.exitCode = 0; +}); + +async function runExpectingCleanExit(fn: () => Promise): Promise<{ exitCode: number | undefined; errors: string[] }> { + const errors: string[] = []; + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit sentinel"); + }); + const errorSpy = spyOn(console, "error").mockImplementation((...args: unknown[]) => { + errors.push(args.map(String).join(" ")); + }); + try { + await fn(); + return { exitCode: undefined, errors }; + } catch { + const exitCode = exitSpy.mock.calls.at(-1)?.[0] as number | undefined; + return { exitCode, errors }; + } finally { + exitSpy.mockRestore(); + errorSpy.mockRestore(); + } +} + +describe("skillsSurface list", () => { + test("no surface.jsonc: infers public/internal from current skills/ + attachments/ + stub verbs", async () => { + const packDir = makePackDir(); + writeStubs(packDir, { "my-verb": { engine: "my-verb", description: "Do the thing" } }); + writeFile(join(packDir, "skills", "hand-authored-public", "SKILL.md"), "---\nname: x\n---\nbody\n"); + writeFile(join(packDir, "attachments", "hand-authored-internal", "SKILL.md"), "---\nname: y\n---\nbody\n"); + + await skillsSurface(["list", "--team", "t", "--pack-dir", packDir]); + + const out = logs.join("\n"); + expect(out).toContain("no surface.jsonc"); + expect(out).toMatch(/public.*hand-authored-public/); + expect(out).toMatch(/internal.*hand-authored-internal/); + expect(out).toMatch(/public.*compiled.*my-verb/); + }); + + test("with surface.jsonc: statuses reflect the config, not disk placement", async () => { + const packDir = makePackDir(); + writeStubs(packDir, {}); + writeFile(join(packDir, "skills", "still-under-skills", "SKILL.md"), "---\nname: z\n---\nbody\n"); + writeFile(join(packDir, "pack", "surface.jsonc"), JSON.stringify({ public: [] })); + + await skillsSurface(["list", "--team", "t", "--pack-dir", packDir]); + + const out = logs.join("\n"); + expect(out).toContain("pack/surface.jsonc"); + expect(out).toMatch(/internal.*still-under-skills/); + }); + + test("unrecognized argument: clean one-line error, exit 1", async () => { + const { exitCode, errors } = await runExpectingCleanExit(() => + skillsSurface(["list", "--bogus"]), + ); + expect(exitCode).toBe(1); + expect(errors[0]).toStartWith("rt skills: "); + expect(errors[0]).toContain("--bogus"); + }); +}); + +describe("skillsSurface set", () => { + test("--public on an attachments/ dir: bootstraps surface.jsonc, moves it to skills/ via apply (plain rename, no git repo)", async () => { + const packDir = makePackDir(); + writeStubs(packDir, {}); + writeFile(join(packDir, "attachments", "my-attach", "SKILL.md"), "---\nname: a\n---\nbody\n"); + const { mattstackDir, manifestPath } = makeEngineFixture(); + + await skillsSurface([ + "set", "my-attach", "--public", + "--team", "t", "--pack-dir", packDir, "--mattstack-dir", mattstackDir, "--manifest", manifestPath, + ]); + + const surfacePath = join(packDir, "pack", "surface.jsonc"); + expect(existsSync(surfacePath)).toBe(true); + const surface = JSON.parse(readFileSync(surfacePath, "utf8").replace(/^\/\/.*\n/, "")); + expect(surface.public).toContain("my-attach"); + + expect(existsSync(join(packDir, "skills", "my-attach", "SKILL.md"))).toBe(true); + expect(existsSync(join(packDir, "attachments", "my-attach"))).toBe(false); + + const out = logs.join("\n"); + expect(out).toContain("not a git repo"); + }); + + test("--internal on a skills/ dir: bootstraps surface.jsonc, moves it to attachments/ via apply", async () => { + const packDir = makePackDir(); + writeStubs(packDir, {}); + writeFile(join(packDir, "skills", "my-skill", "SKILL.md"), "---\nname: s\n---\nbody\n"); + const { mattstackDir, manifestPath } = makeEngineFixture(); + + await skillsSurface([ + "set", "my-skill", "--internal", + "--team", "t", "--pack-dir", packDir, "--mattstack-dir", mattstackDir, "--manifest", manifestPath, + ]); + + expect(existsSync(join(packDir, "attachments", "my-skill", "SKILL.md"))).toBe(true); + expect(existsSync(join(packDir, "skills", "my-skill"))).toBe(false); + + const surfacePath = join(packDir, "pack", "surface.jsonc"); + const surface = JSON.parse(readFileSync(surfacePath, "utf8").replace(/^\/\/.*\n/, "")); + expect(surface.public).not.toContain("my-skill"); + }); + + test("--internal on a compiled stub verb: surface.jsonc updated, apply removes the compiled dir (never git-mv'd)", async () => { + const packDir = makePackDir(); + writeStubs(packDir, { "my-verb": { engine: "my-verb", description: "Do the thing" } }); + writeFile(join(packDir, "skills", "my-verb", "SKILL.md"), "---\nname: my-verb\n---\nold compiled content\n"); + const { mattstackDir, manifestPath } = makeEngineFixture(); + + await skillsSurface([ + "set", "my-verb", "--internal", + "--team", "t", "--pack-dir", packDir, "--mattstack-dir", mattstackDir, "--manifest", manifestPath, + ]); + + expect(existsSync(join(packDir, "skills", "my-verb"))).toBe(false); + expect(existsSync(join(packDir, "attachments", "my-verb"))).toBe(false); + + const out = logs.join("\n"); + expect(out).not.toContain("moved my-verb"); + }); + + test("--public on a compiled stub verb (bootstrap default already public): apply compiles it", async () => { + const packDir = makePackDir(); + writeStubs(packDir, { "my-verb": { engine: "my-verb", description: "Do the thing" } }); + const { mattstackDir, manifestPath } = makeEngineFixture(); + + await skillsSurface([ + "set", "my-verb", "--public", + "--team", "t", "--pack-dir", packDir, "--mattstack-dir", mattstackDir, "--manifest", manifestPath, + ]); + + const skillMd = readFileSync(join(packDir, "skills", "my-verb", "SKILL.md"), "utf8"); + expect(skillMd).toContain("compiled by rt skills compile"); + }); + + test("uses a real git mv when the pack dir is a git repo", async () => { + const packDir = makePackDir(); + execFileSync("git", ["init", "-q"], { cwd: packDir }); + execFileSync("git", ["config", "user.email", "t@example.com"], { cwd: packDir }); + execFileSync("git", ["config", "user.name", "t"], { cwd: packDir }); + writeStubs(packDir, {}); + writeFile(join(packDir, "skills", "my-skill", "SKILL.md"), "---\nname: s\n---\nbody\n"); + execFileSync("git", ["add", "-A"], { cwd: packDir }); + const { mattstackDir, manifestPath } = makeEngineFixture(); + + await skillsSurface([ + "set", "my-skill", "--internal", + "--team", "t", "--pack-dir", packDir, "--mattstack-dir", mattstackDir, "--manifest", manifestPath, + ]); + + expect(existsSync(join(packDir, "attachments", "my-skill", "SKILL.md"))).toBe(true); + expect(existsSync(join(packDir, "skills", "my-skill"))).toBe(false); + // git mv on a never-committed add reports as a plain staged add at the new + // path (git has no prior commit to diff a rename against). + const status = execFileSync("git", ["status", "--porcelain"], { cwd: packDir, encoding: "utf8" }); + expect(status).toContain("attachments/my-skill"); + + const out = logs.join("\n"); + expect(out).not.toContain("not a git repo"); + }); + + test("unknown name: clean one-line error, exit 1", async () => { + const packDir = makePackDir(); + writeStubs(packDir, {}); + + const { exitCode, errors } = await runExpectingCleanExit(() => + skillsSurface(["set", "no-such-skill", "--public", "--pack-dir", packDir]), + ); + + expect(exitCode).toBe(1); + expect(errors[0]).toStartWith("rt skills: "); + expect(errors[0]).toContain("no-such-skill"); + }); + + test("missing --public/--internal: clean one-line error, exit 1", async () => { + const packDir = makePackDir(); + writeStubs(packDir, {}); + writeFile(join(packDir, "skills", "my-skill", "SKILL.md"), "---\nname: s\n---\nbody\n"); + + const { exitCode, errors } = await runExpectingCleanExit(() => + skillsSurface(["set", "my-skill", "--pack-dir", packDir]), + ); + + expect(exitCode).toBe(1); + expect(errors[0]).toStartWith("rt skills: "); + }); + + test("missing name: clean one-line error, exit 1", async () => { + const { exitCode, errors } = await runExpectingCleanExit(() => + skillsSurface(["set", "--public"]), + ); + + expect(exitCode).toBe(1); + expect(errors[0]).toStartWith("rt skills: "); + }); +}); + +describe("skillsSurface apply", () => { + test("dry-run prints planned moves and touches nothing", async () => { + const packDir = makePackDir(); + writeStubs(packDir, {}); + writeFile(join(packDir, "attachments", "my-attach", "SKILL.md"), "---\nname: a\n---\nbody\n"); + writeFile(join(packDir, "pack", "surface.jsonc"), JSON.stringify({ public: ["my-attach"] })); + const { mattstackDir, manifestPath } = makeEngineFixture(); + + await skillsSurface([ + "apply", "--dry-run", + "--team", "t", "--pack-dir", packDir, "--mattstack-dir", mattstackDir, "--manifest", manifestPath, + ]); + + expect(existsSync(join(packDir, "attachments", "my-attach"))).toBe(true); + expect(existsSync(join(packDir, "skills", "my-attach"))).toBe(false); + expect(logs.some((l) => l.includes("would move") && l.includes("my-attach"))).toBe(true); + }); + + test("no surface.jsonc: no moves, compiles the roster as usual", async () => { + const packDir = makePackDir(); + writeStubs(packDir, { "my-verb": { engine: "my-verb", description: "Do the thing" } }); + const { mattstackDir, manifestPath } = makeEngineFixture(); + + await skillsSurface([ + "apply", + "--team", "t", "--pack-dir", packDir, "--mattstack-dir", mattstackDir, "--manifest", manifestPath, + ]); + + expect(logs.some((l) => l.includes("no moves needed"))).toBe(true); + expect(existsSync(join(packDir, "skills", "my-verb", "SKILL.md"))).toBe(true); + }); + + test("unrecognized argument: clean one-line error, exit 1", async () => { + const { exitCode, errors } = await runExpectingCleanExit(() => + skillsSurface(["apply", "--bogus"]), + ); + expect(exitCode).toBe(1); + expect(errors[0]).toStartWith("rt skills: "); + }); +}); + +describe("skillsSurface bare invocation (fzf palette)", () => { + test("non-tty: prints the list and the set-command hint, does not crash or write config", async () => { + const packDir = makePackDir(); + writeStubs(packDir, {}); + writeFile(join(packDir, "skills", "my-skill", "SKILL.md"), "---\nname: s\n---\nbody\n"); + + // bun test inherits stdin from the shell; force the non-tty fallback so this + // never spawns real fzf on an interactive run. + const previousIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true }); + try { + await skillsSurface(["--team", "t", "--pack-dir", packDir]); + } finally { + Object.defineProperty(process.stdin, "isTTY", { value: previousIsTTY, configurable: true }); + } + + expect(existsSync(join(packDir, "pack", "surface.jsonc"))).toBe(false); + const out = logs.join("\n"); + expect(out).toContain("my-skill"); + expect(out).toContain("rt skills surface set"); + }); + + test("empty pack: prints a no-skills message instead of crashing", async () => { + const packDir = makePackDir(); + writeStubs(packDir, {}); + + await skillsSurface(["--pack-dir", packDir]); + + expect(logs.some((l) => l.includes("no skills registered"))).toBe(true); + }); + + test("unrecognized subcommand: clean one-line error, exit 1", async () => { + const { exitCode, errors } = await runExpectingCleanExit(() => + skillsSurface(["bogus-mode"]), + ); + expect(exitCode).toBe(1); + expect(errors[0]).toStartWith("rt skills: "); + expect(errors[0]).toContain("bogus-mode"); + }); +}); + +describe("decidePaletteAction", () => { + test("zero-marked cursor-row artifact shows as a +1 delta the user can decline", () => { + // Everything was internal; fzf's default --multi accept on Enter with + // nothing marked emits the cursor row anyway -- that row now reads + // "public" in resultRows even though the user meant to uncheck everything. + const previousPublic = new Set(); + const resultRows = [{ name: "x", status: "public" as const }]; + + const action = decidePaletteAction(previousPublic, resultRows, false); + + expect(action.kind).toBe("declined"); + if (action.kind !== "no-changes") { + expect(action.delta.toPublic).toEqual(["x"]); + expect(action.delta.toInternal).toEqual([]); + } + }); + + test("decline writes nothing: the confirmed=false path never yields a write action", () => { + const previousPublic = new Set(); + const resultRows = [{ name: "x", status: "public" as const }]; + + const action = decidePaletteAction(previousPublic, resultRows, false); + + expect(action.kind).not.toBe("write"); + }); + + test("confirming a real delta yields a write action carrying it", () => { + const previousPublic = new Set(["y"]); + const resultRows = [ + { name: "x", status: "public" as const }, + { name: "y", status: "internal" as const }, + ]; + + const action = decidePaletteAction(previousPublic, resultRows, true); + + expect(action.kind).toBe("write"); + if (action.kind === "write") { + expect(action.delta.toPublic).toEqual(["x"]); + expect(action.delta.toInternal).toEqual(["y"]); + } + }); + + test("no changes short-circuits regardless of the confirm answer", () => { + const previousPublic = new Set(["x"]); + const resultRows = [{ name: "x", status: "public" as const }]; + + expect(decidePaletteAction(previousPublic, resultRows, false).kind).toBe("no-changes"); + expect(decidePaletteAction(previousPublic, resultRows, true).kind).toBe("no-changes"); + }); +}); + +describe("computeRows -- previously-public names absent from skills/, attachments/, stubs.jsonc", () => { + test("surfaces the orphan as a 'missing' row instead of dropping it", () => { + const packDir = makePackDir(); + writeStubs(packDir, {}); + writeFile(join(packDir, "skills", "real-skill", "SKILL.md"), "---\nname: real-skill\n---\nbody\n"); + + const surface = { public: ["ghost", "real-skill"] }; + const { rows } = computeRows(packDir, new Set(), surface); + + const ghostRow = rows.find((r) => r.name === "ghost"); + expect(ghostRow).toEqual({ name: "ghost", kind: "missing", status: "public" }); + const realRow = rows.find((r) => r.name === "real-skill"); + expect(realRow?.status).toBe("public"); + }); + + test("combined with decidePaletteAction: leaving the missing row untouched yields no delta", () => { + const packDir = makePackDir(); + writeStubs(packDir, {}); + + const surface = { public: ["ghost"] }; + const { rows } = computeRows(packDir, new Set(), surface); + const previousPublic = new Set(surface.public); + + // Simulates the palette round trip: the fzf row for "ghost" exists and stays + // preselected because the user never touched it. + const resultRows = rows.map((r) => ({ name: r.name, status: r.status })); + + const action = decidePaletteAction(previousPublic, resultRows, false); + expect(action.kind).toBe("no-changes"); + }); + + test("combined with decidePaletteAction: explicitly demoting the missing row surfaces the removal", () => { + const packDir = makePackDir(); + writeStubs(packDir, {}); + + const surface = { public: ["ghost"] }; + const { rows } = computeRows(packDir, new Set(), surface); + const previousPublic = new Set(surface.public); + + const resultRows = rows.map((r) => ({ name: r.name, status: "internal" as const })); + + const preview = decidePaletteAction(previousPublic, resultRows, false); + expect(preview.kind).toBe("declined"); + if (preview.kind !== "no-changes") { + expect(preview.delta.toInternal).toEqual(["ghost"]); + } + + const decision = decidePaletteAction(previousPublic, resultRows, true); + expect(decision.kind).toBe("write"); + if (decision.kind === "write") { + expect(decision.delta.toInternal).toEqual(["ghost"]); + } + }); +}); diff --git a/commands/__tests__/skills.test.ts b/commands/__tests__/skills.test.ts new file mode 100644 index 00000000..63acf1b1 --- /dev/null +++ b/commands/__tests__/skills.test.ts @@ -0,0 +1,450 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, statSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { dirname, join } from "path"; +import { skillsCheck, skillsCompile } from "../skills.ts"; +import { compileSkill } from "../../lib/skills/compile.ts"; +import { invocableRoster, loadAttachment, loadStepSource } from "../../lib/skills/sources.ts"; +import type { PluginRoots } from "../../lib/skills/sources.ts"; +import type { VerbDef } from "../../lib/skills/types.ts"; + +function writeFile(path: string, content: string): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); +} + +const WATCH_CI_SKILL_MD = `--- +name: watch-ci +description: "Watch CI until it goes green" +type: pipeline-step +allowed-tools: + - "Bash(gh:*)" + - "Read" +slots: + domain: { contract: "watch-ci-domain@1" } + forge: { contract: "ci-forge@1", required: true } +--- + +Poll the pipeline every 30s and report status. See \${CLAUDE_SKILL_DIR}/scripts/ci-watch.sh for the poller. +`; + +const WATCH_CI_SCRIPT = "#!/bin/sh\necho polling\n"; + +const DOMAIN_SKILL_MD = `--- +name: watch-ci-domain +description: "Domain rules for watch-ci" +metadata: + provides: "watch-ci-domain@1" +allowed-tools: + - "Read(\${CLAUDE_SKILL_DIR}/ci-config.json)" +--- + +Domain rules live at \${CLAUDE_SKILL_DIR}/ci-config.json for details. +`; + +const CI_CONFIG_JSON = `{ "noisy": ["flaky-job"] }\n`; + +const FORGE_SKILL_MD = `--- +name: gitlab-forge +description: "Talk to GitLab via glab" +metadata: + provides: "ci-forge@1" +allowed-tools: + - "Bash(glab:*)" +--- + +Talk to GitLab via glab. +`; + +const STUBS_JSONC = `{ + "verbs": { + "watch-ci": { + "engine": "watch-ci", + "description": "Use when watching or triaging CI." + } + } +} +`; + +function manifestJsonc(team: string, withForge: boolean): string { + const bindings = withForge + ? `{ + "domain": "claimview:watch-ci-domain", + "forge": "mattstack:gitlab-forge" + }` + : `{ + "domain": "claimview:watch-ci-domain" + }`; + return `// GENERATED by merge-manifests.sh -- do not hand-edit; +// provenance (binding <- source): +// mattstack:watch-ci domain <- ${team}@${team} +{ + "bindings": { + "mattstack:watch-ci": ${bindings} + } +} +`; +} + +/** + * Fixture "mattstack home" root: plugins// mirrors resolvePluginRoots()'s + * real shape (dir + .claude-plugin/plugin.json) so skillsCompile's test-mode + * root resolution and the golden compile below walk identical trees. + */ +function makeMattstackDir(): string { + const dir = realpathSync(mkdtempSync(join(tmpdir(), "rt-skills-cli-mattstack-"))); + + const mattstackPluginDir = join(dir, "plugins", "mattstack"); + writeFile(join(mattstackPluginDir, ".claude-plugin", "plugin.json"), JSON.stringify({ version: "1.2.0" })); + writeFile(join(mattstackPluginDir, "skills", "pipeline", "watch-ci", "SKILL.md"), WATCH_CI_SKILL_MD); + writeFile(join(mattstackPluginDir, "skills", "pipeline", "watch-ci", "scripts", "ci-watch.sh"), WATCH_CI_SCRIPT); + chmodSync(join(mattstackPluginDir, "skills", "pipeline", "watch-ci", "scripts", "ci-watch.sh"), 0o755); + writeFile(join(mattstackPluginDir, "skills", "gitlab-forge", "SKILL.md"), FORGE_SKILL_MD); + + const claimviewPluginDir = join(dir, "plugins", "claimview"); + writeFile(join(claimviewPluginDir, ".claude-plugin", "plugin.json"), JSON.stringify({ version: "0.3.0" })); + writeFile(join(claimviewPluginDir, "attachments", "watch-ci-domain", "SKILL.md"), DOMAIN_SKILL_MD); + writeFile(join(claimviewPluginDir, "attachments", "watch-ci-domain", "ci-config.json"), CI_CONFIG_JSON); + + return dir; +} + +function makePackDir(): string { + const dir = realpathSync(mkdtempSync(join(tmpdir(), "rt-skills-cli-pack-"))); + writeFile(join(dir, "pack", "stubs.jsonc"), STUBS_JSONC); + return dir; +} + +function makeManifest(team: string, withForge = true): string { + const dir = realpathSync(mkdtempSync(join(tmpdir(), "rt-skills-cli-manifest-"))); + const path = join(dir, "skills.jsonc"); + writeFile(path, manifestJsonc(team, withForge)); + return path; +} + +function computeGolden(mattstackDir: string) { + const roots: PluginRoots = { + byName: { + mattstack: { dir: join(mattstackDir, "plugins", "mattstack"), version: "1.2.0" }, + claimview: { dir: join(mattstackDir, "plugins", "claimview"), version: "0.3.0" }, + }, + }; + const verb: VerbDef = { name: "watch-ci", engine: "watch-ci", description: "Use when watching or triaging CI." }; + const step = loadStepSource("watch-ci", roots); + const domain = loadAttachment("claimview:watch-ci-domain", "domain", roots); + const forge = loadAttachment("mattstack:gitlab-forge", "forge", roots); + const invocable = invocableRoster(roots); + return compileSkill(verb, step, { domain, forge }, invocable); +} + +let logSpy: ReturnType; +let logs: string[]; + +beforeEach(() => { + logs = []; + logSpy = spyOn(console, "log").mockImplementation((...args: unknown[]) => { + logs.push(args.map(String).join(" ")); + }); +}); + +afterEach(() => { + logSpy.mockRestore(); + // skillsCheck sets this on staleness; Bun ignores process.exitCode = undefined once + // truthy, so 0 is the only value that clears it before the suite's own exit status. + process.exitCode = 0; +}); + +/** + * Expected/domain errors print a clean one-liner and process.exit(1) instead + * of throwing a bare stack trace (matches commands/secrets.ts, + * commands/settings-keys.ts). Mock process.exit to throw a sentinel so the + * real test process never dies, and read the spies' recorded calls BEFORE + * mockRestore() -- bun's mockRestore() clears .mock.calls, unlike jest's. + */ +async function runExpectingCleanExit(fn: () => Promise): Promise<{ exitCode: number | undefined; errors: string[] }> { + const errors: string[] = []; + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit sentinel"); + }); + const errorSpy = spyOn(console, "error").mockImplementation((...args: unknown[]) => { + errors.push(args.map(String).join(" ")); + }); + try { + await fn(); + return { exitCode: undefined, errors }; + } catch { + const exitCode = exitSpy.mock.calls.at(-1)?.[0] as number | undefined; + return { exitCode, errors }; + } finally { + exitSpy.mockRestore(); + errorSpy.mockRestore(); + } +} + +describe("skillsCompile", () => { + test("dry-run prints a would-write summary and writes nothing", async () => { + const mattstackDir = makeMattstackDir(); + const packDir = makePackDir(); + const manifestPath = makeManifest("t"); + + await skillsCompile([ + "--team", "t", + "--dry-run", + "--pack-dir", packDir, + "--mattstack-dir", mattstackDir, + "--manifest", manifestPath, + "--verb", "watch-ci", + ]); + + const summary = logs.find((l) => /would write \d+ files/.test(l)); + expect(summary).toBeDefined(); + expect(summary).toContain("watch-ci"); + expect(existsSync(join(packDir, "skills", "watch-ci"))).toBe(false); + }); + + test("real run emits SKILL.md + vendored files matching a golden compile, byte for byte", async () => { + const mattstackDir = makeMattstackDir(); + const packDir = makePackDir(); + const manifestPath = makeManifest("t"); + + await skillsCompile([ + "--team", "t", + "--pack-dir", packDir, + "--mattstack-dir", mattstackDir, + "--manifest", manifestPath, + "--verb", "watch-ci", + ]); + + expect(logs.some((l) => l.includes("compiled watch-ci") && l.includes("3 files"))).toBe(true); + + const golden = computeGolden(mattstackDir); + const outDir = join(packDir, "skills", "watch-ci"); + + for (const file of golden.files) { + const dest = join(outDir, file.path); + expect(existsSync(dest)).toBe(true); + const expected = "content" in file ? file.content : readFileSync(file.copyFrom, "utf8"); + expect(readFileSync(dest, "utf8")).toBe(expected); + } + + const scriptPath = join(outDir, "scripts", "ci-watch.sh"); + expect(statSync(scriptPath).mode & 0o111).not.toBe(0); + }); + + test("missing required binding: clean one-line error naming verb and slot, exit 1, no partial write", async () => { + const mattstackDir = makeMattstackDir(); + const packDir = makePackDir(); + const manifestPath = makeManifest("t", false); + + const { exitCode, errors } = await runExpectingCleanExit(() => + skillsCompile([ + "--team", "t", + "--pack-dir", packDir, + "--mattstack-dir", mattstackDir, + "--manifest", manifestPath, + "--verb", "watch-ci", + ]), + ); + + expect(exitCode).toBe(1); + expect(errors).toHaveLength(1); + expect(errors[0]).toStartWith("rt skills: "); + expect(errors[0]).not.toContain("\n at "); // no stack trace + expect(errors[0]).toContain("watch-ci"); + expect(errors[0]).toContain("forge"); + expect(existsSync(join(packDir, "skills", "watch-ci"))).toBe(false); + }); + + test("unrecognized argument: clean one-line error, exit 1", async () => { + const { exitCode, errors } = await runExpectingCleanExit(() => + skillsCompile(["--bogus-flag"]), + ); + + expect(exitCode).toBe(1); + expect(errors).toHaveLength(1); + expect(errors[0]).toStartWith("rt skills: "); + expect(errors[0]).toContain("--bogus-flag"); + }); + + test("unknown verb: clean one-line error, exit 1", async () => { + const mattstackDir = makeMattstackDir(); + const packDir = makePackDir(); + const manifestPath = makeManifest("t"); + + const { exitCode, errors } = await runExpectingCleanExit(() => + skillsCompile([ + "--team", "t", + "--pack-dir", packDir, + "--mattstack-dir", mattstackDir, + "--manifest", manifestPath, + "--verb", "no-such-verb", + ]), + ); + + expect(exitCode).toBe(1); + expect(errors).toHaveLength(1); + expect(errors[0]).toStartWith("rt skills: "); + expect(errors[0]).toContain("no-such-verb"); + }); + + test("absent manifest (no skills.jsonc names the team): clean one-line error, exit 1", async () => { + const mattstackDir = makeMattstackDir(); + const packDir = makePackDir(); + + const { exitCode, errors } = await runExpectingCleanExit(() => + skillsCompile([ + "--team", "t", + "--pack-dir", packDir, + "--mattstack-dir", mattstackDir, + "--verb", "watch-ci", + ]), + ); + + expect(exitCode).toBe(1); + expect(errors).toHaveLength(1); + expect(errors[0]).toStartWith("rt skills: "); + expect(errors[0]).toContain("skills.jsonc"); + }); + + test("default pack dir formula (--team + --mattstack-dir, no --pack-dir)", async () => { + const mattstackDir = makeMattstackDir(); + const manifestPath = makeManifest("t"); + writeFile(join(mattstackDir, "teams", "t", "mattstack", "packs", "t", "pack", "stubs.jsonc"), STUBS_JSONC); + + await skillsCompile([ + "--team", "t", + "--dry-run", + "--mattstack-dir", mattstackDir, + "--manifest", manifestPath, + ]); + + expect(logs.some((l) => /would write \d+ files/.test(l) && l.includes("watch-ci"))).toBe(true); + }); + + test("default manifest lookup (--mattstack-dir only, no --manifest)", async () => { + const mattstackDir = makeMattstackDir(); + const packDir = makePackDir(); + writeFile(join(mattstackDir, "repos", "some-repo", "skills.jsonc"), manifestJsonc("t", true)); + + await skillsCompile([ + "--team", "t", + "--dry-run", + "--pack-dir", packDir, + "--mattstack-dir", mattstackDir, + "--verb", "watch-ci", + ]); + + expect(logs.some((l) => /would write \d+ files/.test(l))).toBe(true); + }); +}); + +describe("skillsCheck", () => { + test("reports current right after a compile", async () => { + const mattstackDir = makeMattstackDir(); + const packDir = makePackDir(); + const manifestPath = makeManifest("t"); + + await skillsCompile([ + "--team", "t", + "--pack-dir", packDir, + "--mattstack-dir", mattstackDir, + "--manifest", manifestPath, + "--verb", "watch-ci", + ]); + logs = []; + + await skillsCheck([ + "--team", "t", + "--pack-dir", packDir, + "--mattstack-dir", mattstackDir, + "--manifest", manifestPath, + "--verb", "watch-ci", + ]); + + expect(logs.some((l) => l.includes("watch-ci") && l.includes("current"))).toBe(true); + expect(logs.some((l) => l.includes("stale"))).toBe(false); + expect(process.exitCode).not.toBe(1); + }); + + test("hand-edited SKILL.md reports stale and exits 1", async () => { + const mattstackDir = makeMattstackDir(); + const packDir = makePackDir(); + const manifestPath = makeManifest("t"); + + await skillsCompile([ + "--team", "t", + "--pack-dir", packDir, + "--mattstack-dir", mattstackDir, + "--manifest", manifestPath, + "--verb", "watch-ci", + ]); + + const skillMdPath = join(packDir, "skills", "watch-ci", "SKILL.md"); + writeFileSync(skillMdPath, readFileSync(skillMdPath, "utf8") + "\nhand-edited drift\n"); + logs = []; + + await skillsCheck([ + "--team", "t", + "--pack-dir", packDir, + "--mattstack-dir", mattstackDir, + "--manifest", manifestPath, + "--verb", "watch-ci", + ]); + + const staleLine = logs.find((l) => l.includes("stale")); + expect(staleLine).toBeDefined(); + expect(staleLine).toContain("watch-ci"); + expect(staleLine).toContain("SKILL.md"); + expect(process.exitCode).toBe(1); + }); + + test("orphan file left over from a previous compile reports stale and exits 1", async () => { + const mattstackDir = makeMattstackDir(); + const packDir = makePackDir(); + const manifestPath = makeManifest("t"); + + await skillsCompile([ + "--team", "t", + "--pack-dir", packDir, + "--mattstack-dir", mattstackDir, + "--manifest", manifestPath, + "--verb", "watch-ci", + ]); + + writeFileSync(join(packDir, "skills", "watch-ci", "leftover.txt"), "stale content\n"); + logs = []; + + await skillsCheck([ + "--team", "t", + "--pack-dir", packDir, + "--mattstack-dir", mattstackDir, + "--manifest", manifestPath, + "--verb", "watch-ci", + ]); + + const staleLine = logs.find((l) => l.includes("stale")); + expect(staleLine).toBeDefined(); + expect(staleLine).toContain("watch-ci"); + expect(staleLine).toContain("leftover.txt"); + expect(process.exitCode).toBe(1); + }); + + test("never-compiled public verb (missing outDir) reports stale and exits 1", async () => { + const mattstackDir = makeMattstackDir(); + const packDir = makePackDir(); + const manifestPath = makeManifest("t"); + + await skillsCheck([ + "--team", "t", + "--pack-dir", packDir, + "--mattstack-dir", mattstackDir, + "--manifest", manifestPath, + "--verb", "watch-ci", + ]); + + const staleLine = logs.find((l) => l.includes("stale")); + expect(staleLine).toBeDefined(); + expect(staleLine).toContain("watch-ci"); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/commands/skills.ts b/commands/skills.ts new file mode 100644 index 00000000..27dc95d8 --- /dev/null +++ b/commands/skills.ts @@ -0,0 +1,799 @@ +/** + * rt skills -- compile pack verbs from step sources + manifest bindings into + * committed SKILL.md files, and check compiled output against its sources. + * + * rt skills compile [--team ] [--verb ...] [--manifest ] [--dry-run] + * rt skills check [--team ] [--verb ...] [--manifest ] + * + * --pack-dir / --mattstack-dir are test-only escape hatches (hidden from the + * command tree): they let tests point the whole resolution chain at a + * mkdtemp fixture instead of the real ~/.mattstack, without a PATH-shimmed + * `claude` binary -- execSync inside this process ignores runtime PATH + * mutations (resolved at Bun's own startup), so a fake `claude` on PATH is + * not a reliable test seam. --mattstack-dir stands in for both the + * ~/.mattstack root (pack-dir and manifest defaults) and the Claude plugin + * cache (mirrored under /plugins//) that resolvePluginRoots() + * queries for real via `claude plugin list --json`. + */ + +import { execFileSync, spawnSync } from "child_process"; +import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "fs"; +import { createInterface } from "node:readline"; +import { dirname, join } from "path"; +import { resolveFzf } from "../lib/fzf.ts"; +import { mattstackHome } from "../lib/rt-paths.ts"; +import { compileSkill, HEADER_COMMENT } from "../lib/skills/compile.ts"; +import { + invocableRoster, + loadAttachment, + loadStepSource, + readManifestBindings, + readSurface, + readVerbRoster, + resolvePluginRoots, + stripFrontmatter, + type PluginRoots, + type SurfaceConfig, +} from "../lib/skills/sources.ts"; +import type { AttachmentSource, CompileResult, VerbDef } from "../lib/skills/types.ts"; + +/** + * Marks an error as an expected, user-facing condition (bad flags, absent + * binding, unknown verb) rather than a bug in this command -- withCleanErrors + * prints these as a one-line "rt skills: " and exits 1 with no + * stack trace; anything else propagates to the top-level crash handler. + */ +class SkillsUsageError extends Error {} + +async function withCleanErrors(fn: () => Promise): Promise { + try { + await fn(); + } catch (err) { + if (err instanceof SkillsUsageError) { + console.error(`rt skills: ${err.message}`); + process.exit(1); + } + throw err; + } +} + +type Flags = { + team: string; + verbs: string[] | null; + manifest: string | null; + dryRun: boolean; + packDir: string | null; + mattstackDir: string | null; +}; + +function parseFlags(args: string[]): Flags { + const verbs: string[] = []; + let team = "claimview"; + let manifest: string | null = null; + let dryRun = false; + let packDir: string | null = null; + let mattstackDir: string | null = null; + + for (let i = 0; i < args.length; i++) { + const a = args[i]!; + switch (a) { + case "--team": team = args[++i] ?? team; break; + case "--verb": { const v = args[++i]; if (v) verbs.push(v); break; } + case "--manifest": manifest = args[++i] ?? null; break; + case "--dry-run": dryRun = true; break; + case "--pack-dir": packDir = args[++i] ?? null; break; + case "--mattstack-dir": mattstackDir = args[++i] ?? null; break; + default: + throw new SkillsUsageError(`unrecognized argument "${a}"`); + } + } + + return { team, verbs: verbs.length ? verbs : null, manifest, dryRun, packDir, mattstackDir }; +} + +function packRootDir(mattstackRoot: string, team: string): string { + return join(mattstackRoot, "teams", team, "mattstack", "packs", team); +} + +function leadingCommentBlock(raw: string): string { + const lines: string[] = []; + for (const line of raw.split("\n")) { + if (line.trim().startsWith("//")) { + lines.push(line); + continue; + } + break; + } + return lines.join("\n"); +} + +function listSubdirs(dir: string): string[] { + if (!existsSync(dir)) return []; + return readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); +} + +function listFilesRecursive(dir: string, prefix = ""): string[] { + if (!existsSync(dir)) return []; + const files: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const relPath = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + files.push(...listFilesRecursive(join(dir, entry.name), relPath)); + } else { + files.push(relPath); + } + } + return files; +} + +function findDefaultManifest(mattstackRoot: string, team: string): string { + const reposRoot = join(mattstackRoot, "repos"); + const candidates: { path: string; mtimeMs: number }[] = []; + + for (const repoName of listSubdirs(reposRoot)) { + const manifestPath = join(reposRoot, repoName, "skills.jsonc"); + if (!existsSync(manifestPath)) continue; + const header = leadingCommentBlock(readFileSync(manifestPath, "utf8")); + if (!header.includes(team)) continue; + candidates.push({ path: manifestPath, mtimeMs: statSync(manifestPath).mtimeMs }); + } + + if (candidates.length === 0) { + throw new SkillsUsageError( + `no skills.jsonc under ${reposRoot}/*/ names team "${team}" in its provenance header; pass --manifest explicitly`, + ); + } + + candidates.sort((a, b) => b.mtimeMs - a.mtimeMs); + const newest = candidates[0]!; + const tied = candidates.filter((c) => c.mtimeMs === newest.mtimeMs); + if (tied.length > 1) { + throw new SkillsUsageError( + `ambiguous manifest for team "${team}" -- candidates tie for newest:\n${tied.map((c) => c.path).join("\n")}\npass --manifest explicitly`, + ); + } + + return newest.path; +} + +/** Test-mode-only: scans /plugins//.claude-plugin/plugin.json, bypassing the real `claude plugin list --json`. */ +function resolvePluginRootsFromDir(dir: string): PluginRoots { + const pluginsDir = join(dir, "plugins"); + const byName: PluginRoots["byName"] = {}; + + for (const name of listSubdirs(pluginsDir)) { + const pluginDir = join(pluginsDir, name); + let version = "unknown"; + try { + const parsed = JSON.parse(readFileSync(join(pluginDir, ".claude-plugin", "plugin.json"), "utf8")); + if (typeof parsed.version === "string") version = parsed.version; + } catch { + // best-effort: a fixture plugin without a readable manifest still resolves a root + } + byName[name] = { dir: pluginDir, version }; + } + + return { byName }; +} + +function selectVerbs(roster: VerbDef[], names: string[] | null): VerbDef[] { + if (!names) return roster; + const byName = new Map(roster.map((v) => [v.name, v])); + return names.map((name) => { + const verb = byName.get(name); + if (!verb) throw new SkillsUsageError(`verb "${name}" not found in roster`); + return verb; + }); +} + +type Resolved = { + packDir: string; + roster: VerbDef[]; + bindings: Record>; + pluginRoots: PluginRoots; + invocable: Set; + surface: SurfaceConfig | null; + internalRoster: Set; +}; + +/** + * Internal roster tokens mirror invocableRoster's ":" shape so + * they line up with body-prose tokens and fill bindings. A dir under + * /skills/ not (yet) named in surface.jsonc's public list is + * internal by default -- this is what lets a fill inline through the + * transition window before it physically moves. Non-public stub verbs seed + * the roster too: a retired verb's dir is deleted, so the dir scan alone + * would let dangling references to it slip through. attachments/ dirs seed + * it as well: `surface apply` moves a skill there once it goes internal, so + * without this a name that migrated from skills/ to attachments/ falls out + * of the roster and a body token naming it downgrades from a compile error + * to a mere "not invocable" warning. + */ +function computeInternalRoster( + team: string, + packDir: string, + surface: SurfaceConfig | null, + fullRoster: VerbDef[], +): Set { + const internal = new Set(); + if (!surface) return internal; + const publicSet = new Set(surface.public); + for (const name of listSubdirs(join(packDir, "skills"))) { + if (!publicSet.has(name)) internal.add(`${team}:${name}`); + } + for (const name of listSubdirs(join(packDir, "attachments"))) { + if (!publicSet.has(name)) internal.add(`${team}:${name}`); + } + for (const verb of fullRoster) { + if (!publicSet.has(verb.name)) internal.add(`${team}:${verb.name}`); + } + return internal; +} + +function resolve(flags: Flags): Resolved { + const mattstackRoot = flags.mattstackDir ?? mattstackHome(); + const packDir = flags.packDir ?? packRootDir(mattstackRoot, flags.team); + const manifestPath = flags.manifest ?? findDefaultManifest(mattstackRoot, flags.team); + + const fullRoster = readVerbRoster(packDir); + const roster = selectVerbs(fullRoster, flags.verbs); + const bindings = readManifestBindings(manifestPath); + const pluginRoots = flags.mattstackDir ? resolvePluginRootsFromDir(mattstackRoot) : resolvePluginRoots(); + const invocable = invocableRoster(pluginRoots); + const surface = readSurface(packDir); + const internalRoster = computeInternalRoster(flags.team, packDir, surface, fullRoster); + + return { packDir, roster, bindings, pluginRoots, invocable, surface, internalRoster }; +} + +function compileVerb(verb: VerbDef, resolved: Resolved): CompileResult { + let step; + try { + step = loadStepSource(verb.engine, resolved.pluginRoots); + } catch (err) { + throw new SkillsUsageError(`verb "${verb.name}": ${(err as Error).message}`); + } + + const slotBindings = resolved.bindings[`${step.plugin}:${verb.engine}`] ?? {}; + const fills: Record = {}; + for (const slotName of Object.keys(step.slots)) { + const bindingName = slotBindings[slotName]; + if (!bindingName) { + fills[slotName] = null; + continue; + } + try { + fills[slotName] = loadAttachment(bindingName, slotName, resolved.pluginRoots); + } catch (err) { + throw new SkillsUsageError(`verb "${verb.name}": ${(err as Error).message}`); + } + } + + try { + return compileSkill(verb, step, fills, resolved.invocable, { internalRoster: resolved.internalRoster }); + } catch (err) { + // compileSkill's own message already names verb + slot -- pass it through unchanged. + throw new SkillsUsageError((err as Error).message); + } +} + +function writeCompiledVerb(outDir: string, result: CompileResult): void { + rmSync(outDir, { recursive: true, force: true }); + mkdirSync(outDir, { recursive: true }); + + for (const file of result.files) { + const dest = join(outDir, file.path); + mkdirSync(dirname(dest), { recursive: true }); + if ("content" in file) { + writeFileSync(dest, file.content); + } else { + copyFileSync(file.copyFrom, dest); + chmodSync(dest, statSync(file.copyFrom).mode); + } + } +} + +export async function skillsCompile(args: string[]): Promise { + await withCleanErrors(async () => { + const flags = parseFlags(args); + const resolved = resolve(flags); + const publicSet = resolved.surface ? new Set(resolved.surface.public) : null; + + for (const verb of resolved.roster) { + const outDir = join(resolved.packDir, "skills", verb.name); + + if (publicSet && !publicSet.has(verb.name)) { + console.log(`internal: ${verb.name} (not compiled; roster entry retired)`); + if (!flags.dryRun && existsSync(outDir)) { + rmSync(outDir, { recursive: true, force: true }); + } + continue; + } + + const result = compileVerb(verb, resolved); + if (result.errors.length > 0) { + throw new SkillsUsageError(`verb "${verb.name}": ${result.errors.join("; ")}`); + } + + if (flags.dryRun) { + console.log(`would write ${result.files.length} files for ${verb.name}`); + for (const warning of result.warnings) console.log(` ${warning}`); + continue; + } + + writeCompiledVerb(outDir, result); + console.log(`compiled ${verb.name} (${result.files.length} files, ${result.warnings.length} warnings)`); + for (const warning of result.warnings) console.log(` ${warning}`); + } + + if (publicSet) { + for (const name of listSubdirs(join(resolved.packDir, "skills"))) { + if (!publicSet.has(name)) { + console.log(`misplaced: ${name} (run rt skills surface apply, or move it)`); + process.exitCode = 1; + } + } + } + }); +} + +export async function skillsCheck(args: string[]): Promise { + await withCleanErrors(async () => { + const flags = parseFlags(args); + const resolved = resolve(flags); + // No surface.jsonc means the pack has no public/internal split yet -- every roster verb is public. + const publicSet = resolved.surface ? new Set(resolved.surface.public) : null; + + let anyStale = false; + + for (const verb of resolved.roster) { + const outDir = join(resolved.packDir, "skills", verb.name); + const isPublic = !publicSet || publicSet.has(verb.name); + + if (!existsSync(outDir)) { + if (isPublic) { + anyStale = true; + console.log(`${verb.name}: stale (never compiled -- outDir missing; run rt skills compile)`); + } + continue; + } + + const result = compileVerb(verb, resolved); + const staleFiles: string[] = []; + const expectedPaths = new Set(result.files.map((f) => f.path)); + + for (const file of result.files) { + const dest = join(outDir, file.path); + const expected = "content" in file ? Buffer.from(file.content) : readFileSync(file.copyFrom); + if (!existsSync(dest) || !readFileSync(dest).equals(expected)) { + staleFiles.push(file.path); + } + } + + // A file left behind by an earlier compile: writeCompiledVerb would delete it on + // the next real compile, so "current" here would be a false clean bill of health. + for (const onDisk of listFilesRecursive(outDir)) { + if (!expectedPaths.has(onDisk)) staleFiles.push(`${onDisk} (orphan)`); + } + + if (staleFiles.length > 0) { + anyStale = true; + console.log(`${verb.name}: stale (recompile or investigate drift with git diff) -- ${staleFiles.join(", ")}`); + } else { + console.log(`${verb.name}: current`); + } + } + + if (anyStale) process.exitCode = 1; + }); +} + +// ─── rt skills surface -- list / set / apply / fzf palette ──────────────── + +type SurfaceFlags = { + team: string; + dryRun: boolean; + packDir: string | null; + mattstackDir: string | null; + manifest: string | null; +}; + +type SurfaceRow = { name: string; kind: "compiled" | "hand-authored" | "missing"; status: "public" | "internal" }; + +function kindLabel(kind: SurfaceRow["kind"]): string { + return kind === "missing" ? "(no files on disk)" : kind; +} + +function parseSurfaceFlags(args: string[]): { flags: SurfaceFlags; rest: string[] } { + let team = "claimview"; + let dryRun = false; + let packDir: string | null = null; + let mattstackDir: string | null = null; + let manifest: string | null = null; + const rest: string[] = []; + + for (let i = 0; i < args.length; i++) { + const a = args[i]!; + switch (a) { + case "--team": team = args[++i] ?? team; break; + case "--dry-run": dryRun = true; break; + case "--pack-dir": packDir = args[++i] ?? null; break; + case "--mattstack-dir": mattstackDir = args[++i] ?? null; break; + case "--manifest": manifest = args[++i] ?? null; break; + default: rest.push(a); + } + } + + return { flags: { team, dryRun, packDir, mattstackDir, manifest }, rest }; +} + +function resolveSurfacePaths(flags: SurfaceFlags): { packDir: string } { + const mattstackRoot = flags.mattstackDir ?? mattstackHome(); + const packDir = flags.packDir ?? packRootDir(mattstackRoot, flags.team); + return { packDir }; +} + +function isCompiledDir(dir: string): boolean { + const skillMdPath = join(dir, "SKILL.md"); + if (!existsSync(skillMdPath)) return false; + const { body } = stripFrontmatter(readFileSync(skillMdPath, "utf8")); + return body.startsWith(HEADER_COMMENT); +} + +/** Stub verb names are always compile targets; a materialized dir carrying the compiler header is one too, even if its verb was since retired from stubs.jsonc. */ +function classify(name: string, dir: string | null, verbNames: Set): "compiled" | "hand-authored" { + if (verbNames.has(name)) return "compiled"; + if (dir && isCompiledDir(dir)) return "compiled"; + return "hand-authored"; +} + +function collectRegistry(packDir: string, verbNames: Set) { + const skillsNames = new Set(listSubdirs(join(packDir, "skills"))); + const attachmentNames = new Set(listSubdirs(join(packDir, "attachments"))); + const allNames = new Set([...skillsNames, ...attachmentNames, ...verbNames]); + return { skillsNames, attachmentNames, allNames }; +} + +/** The set `set`'s first use bootstraps surface.jsonc from -- so the first edit is a delta from reality, not a cliff. */ +function defaultPublicSet(skillsNames: Set, verbNames: Set): Set { + return new Set([...skillsNames, ...verbNames]); +} + +export function computeRows( + packDir: string, + verbNames: Set, + surface: SurfaceConfig | null, +): { source: string; rows: SurfaceRow[] } { + const { skillsNames, attachmentNames, allNames } = collectRegistry(packDir, verbNames); + const publicSet = surface ? new Set(surface.public) : defaultPublicSet(skillsNames, verbNames); + const source = surface + ? "pack/surface.jsonc" + : "(no surface.jsonc yet -- inferred from current skills/ + stubs.jsonc placement)"; + + // A name in surface.jsonc's public list but absent from skills/, attachments/, and + // stubs.jsonc would otherwise never become a row -- the palette write derives the new + // public list from rows alone, so omitting it here means the write silently drops it. + const names = new Set(allNames); + for (const name of publicSet) names.add(name); + + const rows = [...names].sort().map((name) => { + const dir = skillsNames.has(name) + ? join(packDir, "skills", name) + : attachmentNames.has(name) + ? join(packDir, "attachments", name) + : null; + return { + name, + kind: allNames.has(name) ? classify(name, dir, verbNames) : ("missing" as const), + status: (publicSet.has(name) ? "public" : "internal") as "public" | "internal", + }; + }); + + return { source, rows }; +} + +function writeSurfaceConfig(packDir: string, publicList: string[]): void { + const path = join(packDir, "pack", "surface.jsonc"); + mkdirSync(dirname(path), { recursive: true }); + const json = JSON.stringify({ public: publicList }, null, 2); + writeFileSync(path, `// surface.jsonc -- names this pack's public skills/ directories.\n${json}\n`); +} + +function compileArgs(flags: SurfaceFlags, packDir: string): string[] { + const args = ["--team", flags.team, "--pack-dir", packDir]; + if (flags.mattstackDir) args.push("--mattstack-dir", flags.mattstackDir); + if (flags.manifest) args.push("--manifest", flags.manifest); + if (flags.dryRun) args.push("--dry-run"); + return args; +} + +function isInsideGitWorkTree(dir: string): boolean { + try { + execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: dir, stdio: "pipe" }); + return true; + } catch { + return false; + } +} + +/** git mv keeps rename history for the common case; fixtures (and any non-git pack dir) fall back to a plain rename. */ +function moveHandAuthoredDir(packDir: string, name: string, from: "skills" | "attachments", to: "skills" | "attachments"): string | null { + const fromRel = join(from, name); + const toRel = join(to, name); + mkdirSync(join(packDir, to), { recursive: true }); + + if (isInsideGitWorkTree(packDir)) { + execFileSync("git", ["mv", fromRel, toRel], { cwd: packDir, stdio: "pipe" }); + return null; + } + + renameSync(join(packDir, fromRel), join(packDir, toRel)); + return "plain rename -- pack dir is not a git repo"; +} + +function printSurfaceRows(flags: SurfaceFlags, source: string, rows: SurfaceRow[]): void { + console.log(`rt skills surface -- team ${flags.team}`); + console.log(`source: ${source}`); + for (const row of rows) { + console.log(` ${row.status.padEnd(9)}${kindLabel(row.kind).padEnd(15)}${row.name}`); + } +} + +async function runList(flags: SurfaceFlags): Promise { + const { packDir } = resolveSurfacePaths(flags); + const verbNames = new Set(readVerbRoster(packDir).map((v) => v.name)); + const surface = readSurface(packDir); + const { source, rows } = computeRows(packDir, verbNames, surface); + + printSurfaceRows(flags, source, rows); + if (rows.length === 0) console.log("(no skills registered in this pack)"); +} + +async function runApply(flags: SurfaceFlags): Promise { + const { packDir } = resolveSurfacePaths(flags); + const verbNames = new Set(readVerbRoster(packDir).map((v) => v.name)); + const surface = readSurface(packDir); + const { skillsNames, attachmentNames } = collectRegistry(packDir, verbNames); + const publicSet = surface ? new Set(surface.public) : defaultPublicSet(skillsNames, verbNames); + + const candidates = [...new Set([...skillsNames, ...attachmentNames])].sort(); + let moved = 0; + + for (const name of candidates) { + const currentlyUnderSkills = skillsNames.has(name); + const dir = join(packDir, currentlyUnderSkills ? "skills" : "attachments", name); + if (classify(name, dir, verbNames) === "compiled") continue; // regenerated/removed by the compile step below, never git-mv'd + + const wantPublic = publicSet.has(name); + if (currentlyUnderSkills === wantPublic) continue; + + const from = currentlyUnderSkills ? "skills" : "attachments"; + const to = currentlyUnderSkills ? "attachments" : "skills"; + moved++; + + if (flags.dryRun) { + console.log(`would move ${name}: ${from}/ -> ${to}/`); + continue; + } + + const note = moveHandAuthoredDir(packDir, name, from, to); + console.log(`moved ${name}: ${from}/ -> ${to}/${note ? ` (${note})` : ""}`); + } + + if (moved === 0) console.log("no moves needed"); + + await skillsCompile(compileArgs(flags, packDir)); +} + +async function runSet(name: string, want: "public" | "internal", flags: SurfaceFlags): Promise { + const { packDir } = resolveSurfacePaths(flags); + const verbNames = new Set(readVerbRoster(packDir).map((v) => v.name)); + const { skillsNames, allNames } = collectRegistry(packDir, verbNames); + + if (!allNames.has(name)) { + throw new SkillsUsageError( + `"${name}" is not a known skill or verb in this pack (checked skills/, attachments/, stubs.jsonc)`, + ); + } + + const surface = readSurface(packDir); + const publicSet = surface ? new Set(surface.public) : defaultPublicSet(skillsNames, verbNames); + + if (want === "public") publicSet.add(name); + else publicSet.delete(name); + + writeSurfaceConfig(packDir, [...publicSet].sort()); + console.log(`${name}: ${want}`); + + await runApply(flags); +} + +export type SurfaceDelta = { toPublic: string[]; toInternal: string[] }; + +export type PaletteAction = + | { kind: "no-changes" } + | { kind: "write"; delta: SurfaceDelta } + | { kind: "declined"; delta: SurfaceDelta }; + +/** + * Pure decision seam for the palette's accept path -- fzf's default --multi + * semantics emit the cursor row on Enter even when nothing is marked, so a + * deliberate "uncheck everything" can silently reintroduce one row. Called + * once (confirmed=false) to compute the delta for the pre-write preview, and + * again with the real answer once the user has seen it. + */ +export function decidePaletteAction( + previousPublic: Set, + resultRows: { name: string; status: "public" | "internal" }[], + confirmed: boolean, +): PaletteAction { + const toPublic: string[] = []; + const toInternal: string[] = []; + + for (const row of resultRows) { + const was = previousPublic.has(row.name); + const now = row.status === "public"; + if (was === now) continue; + if (now) toPublic.push(row.name); + else toInternal.push(row.name); + } + toPublic.sort(); + toInternal.sort(); + + if (toPublic.length === 0 && toInternal.length === 0) return { kind: "no-changes" }; + const delta = { toPublic, toInternal }; + return confirmed ? { kind: "write", delta } : { kind: "declined", delta }; +} + +function printDelta(delta: SurfaceDelta): void { + console.log("changes:"); + for (const name of delta.toPublic) console.log(` + public ${name}`); + for (const name of delta.toInternal) console.log(` - public ${name}`); +} + +function confirmYesNo(promptText: string): Promise { + return new Promise((resolve) => { + const rl = createInterface({ input: process.stdin, output: process.stderr }); + rl.question(promptText, (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes"); + }); + }); +} + +async function runPalette(flags: SurfaceFlags): Promise { + const { packDir } = resolveSurfacePaths(flags); + const verbNames = new Set(readVerbRoster(packDir).map((v) => v.name)); + const surface = readSurface(packDir); + const { skillsNames } = collectRegistry(packDir, verbNames); + const previousPublic = surface ? new Set(surface.public) : defaultPublicSet(skillsNames, verbNames); + const { source, rows } = computeRows(packDir, verbNames, surface); + + if (rows.length === 0) { + console.log("(no skills registered in this pack)"); + return; + } + + const fzfPath = resolveFzf(); + if (!fzfPath || !process.stdin.isTTY) { + printSurfaceRows(flags, source, rows); + console.log(""); + console.log("no tty or fzf not found -- edit one at a time: rt skills surface set --public|--internal"); + return; + } + + const preselected = rows + .map((row, i) => (row.status === "public" ? i + 1 : null)) + .filter((i): i is number => i !== null); + const loadBind = preselected.length + ? `load:${preselected.map((pos) => `pos(${pos})+toggle`).join("+")}+pos(1)` + : "load:pos(1)"; + + const input = rows + .map((row) => `${row.name}\t${row.status.padEnd(9)}${kindLabel(row.kind).padEnd(15)}${row.name}`) + .join("\n"); + + const result = spawnSync( + "fzf", + [ + "--multi", + "--with-nth=2..", + "--delimiter=\t", + "--layout=reverse", + "--border=rounded", + "--border-label= rt skills surface ", + "--prompt= filter: ", + "--header=space: toggle public tab: toggle+next enter: review changes esc: cancel", + "--no-mouse", + "--bind=space:toggle,tab:toggle+down", + `--bind=${loadBind}`, + ], + { input, stdio: ["pipe", "pipe", "inherit"], encoding: "utf8" }, + ); + + if (result.status !== 0) { + console.log("cancelled -- no changes made"); + return; + } + + const selectedSet = new Set( + (result.stdout ?? "") + .replace(/\n$/, "") + .split("\n") + .map((line) => line.split("\t")[0]!) + .filter(Boolean), + ); + + const resultRows = rows.map((row) => ({ + name: row.name, + status: (selectedSet.has(row.name) ? "public" : "internal") as "public" | "internal", + })); + + const preview = decidePaletteAction(previousPublic, resultRows, false); + if (preview.kind === "no-changes") { + console.log("no changes -- surface.jsonc left as is"); + return; + } + + printDelta(preview.delta); + const confirmed = await confirmYesNo(" apply these changes? [y/N] "); + const decision = decidePaletteAction(previousPublic, resultRows, confirmed); + + if (decision.kind !== "write") { + console.log("declined -- no changes made"); + return; + } + + writeSurfaceConfig(packDir, [...selectedSet].sort()); + console.log(`surface.jsonc updated: ${selectedSet.size} public`); + + await runApply(flags); +} + +export async function skillsSurface(args: string[]): Promise { + await withCleanErrors(async () => { + const mode = args[0]; + + if (mode === "list") { + const { flags, rest } = parseSurfaceFlags(args.slice(1)); + if (rest.length) throw new SkillsUsageError(`unrecognized argument "${rest[0]}"`); + await runList(flags); + return; + } + + if (mode === "apply") { + const { flags, rest } = parseSurfaceFlags(args.slice(1)); + if (rest.length) throw new SkillsUsageError(`unrecognized argument "${rest[0]}"`); + await runApply(flags); + return; + } + + if (mode === "set") { + const name = args[1]; + if (!name || name.startsWith("--")) { + throw new SkillsUsageError("set requires a skill name: rt skills surface set --public|--internal"); + } + const { flags, rest } = parseSurfaceFlags(args.slice(2)); + let want: "public" | "internal" | null = null; + for (const a of rest) { + if (a === "--public") want = "public"; + else if (a === "--internal") want = "internal"; + else throw new SkillsUsageError(`unrecognized argument "${a}"`); + } + if (!want) throw new SkillsUsageError("set requires --public or --internal"); + await runSet(name, want, flags); + return; + } + + if (mode !== undefined && !mode.startsWith("--")) { + throw new SkillsUsageError(`unrecognized subcommand "${mode}" (expected list, set, or apply)`); + } + + const { flags, rest } = parseSurfaceFlags(args); + if (rest.length) throw new SkillsUsageError(`unrecognized argument "${rest[0]}"`); + await runPalette(flags); + }); +} diff --git a/lib/command-tree-def.ts b/lib/command-tree-def.ts index cdc380fc..a2127bfc 100644 --- a/lib/command-tree-def.ts +++ b/lib/command-tree-def.ts @@ -750,6 +750,43 @@ export const TREE: Record = { }, }, + skills: { + description: "Compile, check, and manage the surface of the pack's committed skills", + subcommands: { + compile: { + description: "Compile pack verbs from step sources + manifest bindings into committed SKILL.md files", + module: "./commands/skills.ts", + fn: "skillsCompile", + args: [ + { name: "Team", flag: "--team", type: "text", placeholder: "claimview", hint: "Pack team; default claimview" }, + { name: "Verb", flag: "--verb", type: "text", placeholder: "watch-ci", hint: "Compile only this verb (repeatable); omit for every verb in the roster" }, + { name: "Manifest", flag: "--manifest", type: "text", placeholder: "/path/to/skills.jsonc", hint: "Manifest path; omit to auto-find the newest ~/.mattstack/repos/*/skills.jsonc naming this team" }, + { name: "Dry run", flag: "--dry-run", type: "boolean", default: false, hint: "Print what would be written without touching disk" }, + ], + }, + check: { + description: "Report compiled skills that no longer match their sources", + module: "./commands/skills.ts", + fn: "skillsCheck", + args: [ + { name: "Team", flag: "--team", type: "text", placeholder: "claimview", hint: "Pack team; default claimview" }, + { name: "Verb", flag: "--verb", type: "text", placeholder: "watch-ci", hint: "Check only this verb (repeatable); omit for every compiled verb" }, + { name: "Manifest", flag: "--manifest", type: "text", placeholder: "/path/to/skills.jsonc", hint: "Manifest path; omit to auto-find the newest ~/.mattstack/repos/*/skills.jsonc naming this team" }, + ], + }, + surface: { + description: "List, set, or apply the pack's public/internal skill surface (bare invocation opens an fzf multi-toggle palette)", + module: "./commands/skills.ts", + fn: "skillsSurface", + args: [ + { name: "Mode", type: "text", placeholder: "list", hint: "list | set --public|--internal | apply; omit for the fzf palette" }, + { name: "Team", flag: "--team", type: "text", placeholder: "claimview", hint: "Pack team; default claimview" }, + { name: "Dry run", flag: "--dry-run", type: "boolean", default: false, hint: "apply only: print planned moves without touching disk" }, + ], + }, + }, + }, + plugin: { description: "Manage user plugins", subcommands: { diff --git a/lib/fzf.ts b/lib/fzf.ts index 7b23ef38..70dbbecb 100644 --- a/lib/fzf.ts +++ b/lib/fzf.ts @@ -4,7 +4,10 @@ * — never installed, or reaped by `brew autoremove`/`brew cleanup` — we want a * single, actionable error rather than silently degrading to a non-fuzzy * picker or crashing with an opaque spawn ENOENT. Every fzf spawn site calls - * ensureFzf() before spawning. + * ensureFzf() before spawning, except `rt skills surface`'s bare palette: + * that one deliberately soft-falls-back to printing the static list instead + * of erroring, since the same config-write path is also reachable via + * `rt skills surface set`. */ import { bold, dim, yellow, reset } from "./tui.ts"; diff --git a/lib/module-registry.ts b/lib/module-registry.ts index f862c755..ba9cf06a 100644 --- a/lib/module-registry.ts +++ b/lib/module-registry.ts @@ -17,6 +17,7 @@ import * as run from "../commands/run.ts"; import * as secrets from "../commands/secrets.ts"; import * as settings from "../commands/settings.ts"; import * as settingsKeys from "../commands/settings-keys.ts"; +import * as skills from "../commands/skills.ts"; import * as sync from "../commands/sync.ts"; import * as rebase from "../commands/git/rebase.ts"; import * as reset from "../commands/git/reset.ts"; @@ -47,6 +48,7 @@ export const MODULE_REGISTRY: Record = { "./commands/secrets.ts": secrets, "./commands/settings.ts": settings, "./commands/settings-keys.ts": settingsKeys, + "./commands/skills.ts": skills, "./commands/sync.ts": sync, "./commands/git/rebase.ts": rebase, "./commands/git/reset.ts": reset, diff --git a/lib/skills/__tests__/compile.test.ts b/lib/skills/__tests__/compile.test.ts new file mode 100644 index 00000000..3142eb91 --- /dev/null +++ b/lib/skills/__tests__/compile.test.ts @@ -0,0 +1,336 @@ +import { describe, expect, test } from "bun:test"; +import { compileSkill } from "../compile.ts"; +import type { AttachmentSource, CompiledFile, StepSource, VerbDef } from "../types.ts"; + +const verb: VerbDef = { + name: "watch-ci", + engine: "watch-ci-engine", + description: "Watch CI until it goes green", +}; + +const step: StepSource = { + name: "watch-ci", + plugin: "mattstack", + version: "1.2.0", + dir: "/plugins/mattstack/skills/pipeline/watch-ci", + body: "Poll the pipeline every 30s and report status. See ${CLAUDE_SKILL_DIR}/scripts/ci-watch.sh for the poller.", + slots: { + domain: { contract: "watch-ci-domain@1", required: true }, + forge: { contract: "ci-forge@1", required: true }, + }, + allowedTools: ["Bash(gh:*)", "Read"], + stepFiles: ["references/polling-notes.md", "scripts/ci-watch.sh"], +}; + +const domainFill: AttachmentSource = { + binding: "claimview:watch-ci-domain", + plugin: "claimview", + version: "0.3.0", + dir: "/plugins/claimview/attachments/watch-ci-domain", + body: "Domain rules live at ${CLAUDE_SKILL_DIR}/ci-config.json for details.", + provides: "watch-ci-domain@1", + allowedTools: ["Read(${CLAUDE_SKILL_DIR}/ci-config.json)"], + extraFiles: ["ci-config.json"], + registered: false, +}; + +const forgeFill: AttachmentSource = { + binding: "mattstack:gitlab-forge", + plugin: "mattstack", + version: "1.2.0", + dir: "/plugins/mattstack/skills/gitlab-forge", + body: "Talk to GitLab via glab.", + provides: "ci-forge@1", + allowedTools: ["Bash(glab:*)", "Read"], + extraFiles: [], + registered: false, +}; + +const registeredForgeFill: AttachmentSource = { + binding: "mattstack:gitlab-forge", + plugin: "mattstack", + version: "1.2.0", + dir: "/plugins/mattstack/skills/gitlab-forge", + body: "Talk to GitLab via glab.", + provides: "ci-forge@1", + allowedTools: ["Bash(glab:*)", "Read(${CLAUDE_SKILL_DIR}/token.txt)"], + extraFiles: ["token.txt"], + registered: true, +}; + +const roster = new Set(["mattstack:watch-ci", "claimview:watch-ci-domain", "mattstack:gitlab-forge"]); + +function skillFileContent(files: CompiledFile[]): string { + const skillFile = files[0]; + if (!skillFile || !("content" in skillFile)) { + throw new Error("expected files[0] to be a content file"); + } + return skillFile.content; +} + +function toolLinesBetween(content: string): string[] { + const match = content.match(/allowed-tools:\n([\s\S]*?)\nmetadata:/); + if (!match) { + throw new Error("allowed-tools block not found"); + } + return (match[1] as string).split("\n").map((line) => line.trim()); +} + +describe("compileSkill", () => { + test("both slots bound: frontmatter, allowed-tools union, seams, body rewrite", () => { + const result = compileSkill(verb, step, { domain: domainFill, forge: forgeFill }, roster); + + expect(result.warnings).toEqual([]); + + const skillFile = result.files[0]; + expect(skillFile?.path).toBe("SKILL.md"); + const content = skillFileContent(result.files); + + expect(content).toContain('name: "watch-ci"'); + expect(content).toContain('description: "Watch CI until it goes green"'); + expect(content).toContain( + 'metadata:\n compiled: "mattstack@1.2.0 + claimview:watch-ci-domain@0.3.0 + mattstack:gitlab-forge@1.2.0"', + ); + + expect(toolLinesBetween(content)).toEqual([ + '- "Bash(gh:*)"', + '- "Read"', + '- "Read(${CLAUDE_SKILL_DIR}/parts/domain/ci-config.json)"', + '- "Bash(glab:*)"', + ]); + + expect(content).toContain( + "", + ); + expect(content).toContain(""); + expect(content).toContain( + "", + ); + expect(content).toContain( + "", + ); + + expect(content).toContain( + "Domain rules live at ${CLAUDE_SKILL_DIR}/parts/domain/ci-config.json for details.", + ); + expect(content).toContain( + "Poll the pipeline every 30s and report status. See ${CLAUDE_SKILL_DIR}/scripts/ci-watch.sh for the poller.", + ); + }); + + test("optional slot unbound: no seam, no part, no warning", () => { + const stepWithOptional: StepSource = { + ...step, + slots: { + ...step.slots, + notify: { contract: "notify@1", required: false }, + }, + }; + + const result = compileSkill( + verb, + stepWithOptional, + { domain: domainFill, forge: forgeFill, notify: null }, + roster, + ); + + expect(result.warnings).toEqual([]); + const content = skillFileContent(result.files); + expect(content).not.toContain("slot:notify"); + expect(result.files.some((f) => f.path.startsWith("parts/notify/"))).toBe(false); + }); + + test("required slot unbound throws naming verb, slot, contract", () => { + let error: Error | undefined; + try { + compileSkill(verb, step, { domain: null, forge: forgeFill }, roster); + } catch (e) { + error = e as Error; + } + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain("watch-ci"); + expect(error?.message).toContain("domain"); + expect(error?.message).toContain("watch-ci-domain@1"); + }); + + test("provides mismatch throws naming expected vs actual", () => { + const badDomainFill: AttachmentSource = { ...domainFill, provides: "watch-ci-domain@2" }; + + let error: Error | undefined; + try { + compileSkill(verb, step, { domain: badDomainFill, forge: forgeFill }, roster); + } catch (e) { + error = e as Error; + } + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain("watch-ci"); + expect(error?.message).toContain("domain"); + expect(error?.message).toContain("watch-ci-domain@1"); + expect(error?.message).toContain("watch-ci-domain@2"); + expect(error?.message).toContain("claimview:watch-ci-domain"); + }); + + test("vendoring: stepFiles and extraFiles map to exact copyFrom paths", () => { + const result = compileSkill(verb, step, { domain: domainFill, forge: forgeFill }, roster); + + expect(result.files).toContainEqual({ + path: "scripts/ci-watch.sh", + copyFrom: "/plugins/mattstack/skills/pipeline/watch-ci/scripts/ci-watch.sh", + }); + expect(result.files).toContainEqual({ + path: "references/polling-notes.md", + copyFrom: "/plugins/mattstack/skills/pipeline/watch-ci/references/polling-notes.md", + }); + expect(result.files).toContainEqual({ + path: "parts/domain/ci-config.json", + copyFrom: "/plugins/claimview/attachments/watch-ci-domain/ci-config.json", + }); + expect(result.files.some((f) => f.path.startsWith("parts/forge/"))).toBe(false); + }); + + test("registered fill: reference line only, no seam/body, but still vendors extraFiles and joins allowed-tools", () => { + const result = compileSkill( + verb, + step, + { domain: domainFill, forge: registeredForgeFill }, + roster, + ); + + expect(result.warnings).toEqual([]); + + const content = skillFileContent(result.files); + + expect(content).toContain( + "Slot forge is bound to `mattstack:gitlab-forge` (mattstack:gitlab-forge@1.2.0) -- invoke that skill when this flow needs it.", + ); + expect(content).not.toContain(""; + +const REGISTERED_NAME_RE = /\b(mattstack|claimview|assured):[a-z][a-z0-9-]*\b/g; +const SKILL_DIR_PATH_RE = /\$\{CLAUDE_SKILL_DIR\}\/[^\s"'`)]+/g; + +type BoundSlot = { slotName: string; fill: AttachmentSource }; + +function rewriteSkillDirRefs(text: string, slotName: string): string { + return text.split(CLAUDE_SKILL_DIR_TOKEN).join(`${CLAUDE_SKILL_DIR_TOKEN}/parts/${slotName}`); +} + +function dedupePreserveOrder(entries: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const entry of entries) { + if (!seen.has(entry)) { + seen.add(entry); + out.push(entry); + } + } + return out; +} + +function yamlQuote(value: string): string { + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +function resolveBoundSlots( + verb: VerbDef, + step: StepSource, + fills: Record, +): BoundSlot[] { + const boundSlots: BoundSlot[] = []; + + for (const slotName of Object.keys(step.slots)) { + const spec = step.slots[slotName]; + if (!spec) continue; + const fill = fills[slotName] ?? null; + + if (fill === null) { + if (spec.required) { + throw new Error( + `verb "${verb.name}": slot "${slotName}" requires contract "${spec.contract}" but is unbound`, + ); + } + continue; + } + + if (fill.provides !== spec.contract) { + throw new Error( + `verb "${verb.name}": slot "${slotName}" requires contract "${spec.contract}" but binding "${fill.binding}" provides "${fill.provides}"`, + ); + } + + boundSlots.push({ slotName, fill }); + } + + return boundSlots; +} + +function buildAllowedTools(step: StepSource, boundSlots: BoundSlot[]): string[] { + const entries = [ + ...step.allowedTools, + ...boundSlots.flatMap(({ slotName, fill }) => + fill.allowedTools.map((tool) => rewriteSkillDirRefs(tool, slotName)), + ), + ]; + return dedupePreserveOrder(entries); +} + +function buildFrontmatter(verb: VerbDef, allowedTools: string[], compiledParts: string[]): string { + const lines: string[] = ["---"]; + lines.push(`name: ${yamlQuote(verb.name)}`); + lines.push(`description: ${yamlQuote(verb.description)}`); + if (allowedTools.length > 0) { + lines.push("allowed-tools:"); + for (const tool of allowedTools) { + lines.push(` - ${yamlQuote(tool)}`); + } + } + lines.push("metadata:"); + lines.push(` compiled: ${yamlQuote(compiledParts.join(" + "))}`); + lines.push("---"); + return lines.join("\n"); +} + +function buildBody( + step: StepSource, + boundSlots: BoundSlot[], + internalRoster: Set, +): { body: string; notes: string[] } { + const sections: string[] = [HEADER_COMMENT]; + const notes: string[] = []; + + sections.push(``); + sections.push(step.body); + + for (const { slotName, fill } of boundSlots) { + const internal = internalRoster.has(fill.binding); + + if (fill.registered && !internal) { + // Registered, surface-public skills stay singly-canonical: reference, never inline. + sections.push( + `Slot ${slotName} is bound to \`${fill.binding}\` (${fill.binding}@${fill.version}) -- invoke that skill when this flow needs it.`, + ); + continue; + } + + if (fill.registered && internal) { + // Transition window: the file already lives under skills/ but surface.jsonc + // has not declared it public yet -- inline so the compiled output stays correct. + notes.push(`note: ${fill.binding} is surface-internal; inlined`); + } + + sections.push(``); + sections.push(rewriteSkillDirRefs(fill.body, slotName)); + } + + return { body: sections.join("\n\n"), notes }; +} + +function buildVendoredFiles(step: StepSource, boundSlots: BoundSlot[]): CompiledFile[] { + const files: CompiledFile[] = []; + + for (const entry of step.stepFiles) { + files.push({ path: entry, copyFrom: `${step.dir}/${entry}` }); + } + + for (const { slotName, fill } of boundSlots) { + for (const entry of fill.extraFiles) { + files.push({ path: `parts/${slotName}/${entry}`, copyFrom: `${fill.dir}/${entry}` }); + } + } + + return files; +} + +function isCompilerCommentLine(line: string): boolean { + const trimmed = line.trim(); + return trimmed === HEADER_COMMENT || trimmed.startsWith("