diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d72ab09..96a3a0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,5 +23,8 @@ jobs: - run: npm run lint - run: npm run build - run: npm test - # Prove the package builds and packs cleanly on every change, without publishing. - - run: npm publish --dry-run + # Prove the package packs cleanly on every change. npm pack rather than + # npm publish --dry-run, since newer npm rejects a publish dry-run whenever + # package.json holds an already released version, which is the normal state + # of every pull request between releases. + - run: npm pack --dry-run diff --git a/README.md b/README.md index fceb633..a1d4dce 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,36 @@ Each is detected by its config directory rather than a binary on the path, since an IDE may put nothing on the path. `--json` prints the same result as structured output for scripts. +### install + +`install` copies the gaffa skills into the tools you pick and writes a receipt +next to them, so a later uninstall knows exactly what it wrote. + +``` +npx @gaffa-dev/cli install +npx @gaffa-dev/cli install --tools=claude-code,codex --scope=personal +``` + +With no flags it asks which tools (defaulting to the ones it detects) and which +scope. Pass `--tools`, `--scope` and `-y` to run it unattended, for example in +CI. `--scope=project` writes into the working directory so you can commit the +skills with the repo, `--scope=personal` writes into your home config. A second +install refreshes to the current skills and drops any it wrote before that no +longer exist. + +Until the skills are published to npm, point install at a local checkout with +`--skills-dir` or `GAFFA_SKILLS_DIR`. + +### uninstall + +`uninstall` removes the skills a previous install wrote, for a scope. A skill you +have edited since is left in place and reported, so your own changes are never +lost. + +``` +npx @gaffa-dev/cli uninstall --scope=project +``` + ## Develop ``` @@ -57,7 +87,7 @@ git push origin v0.0.2 A plain tag (`v0.0.1`) publishes under `latest`. A prerelease tag (`v0.0.1-rc.1`) publishes under `next`, so it is opt-in and a bad one can be dropped without -touching anyone on `latest`. Every pull request runs `npm publish --dry-run` on +touching anyone on `latest`. Every pull request runs `npm pack --dry-run` on Windows, macOS and Linux, so packaging problems show up before a real release. The final package name is not fixed. `gaffa` is taken on npm, so the skeleton diff --git a/src/cli.ts b/src/cli.ts index c4d96f3..8d67ba9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,11 +1,18 @@ #!/usr/bin/env node import { readFileSync } from "node:fs"; +import { sep } from "node:path"; +import { createInterface } from "node:readline/promises"; import { runDoctor, processContext } from "./doctor.js"; +import { inspectTools, TOOLS, type DoctorContext, type Scope } from "./tools.js"; +import { readLocalSource } from "./skills-source.js"; +import { install, uninstall, type InstallResult, type UninstallResult } from "./install.js"; const pkg = JSON.parse( readFileSync(new URL("../package.json", import.meta.url), "utf8"), ) as { version: string }; +const IDS = TOOLS.map((t) => t.id).join(", "); + const HELP = `gaffa - the Gaffa command line tool Usage @@ -14,35 +21,208 @@ Usage Commands doctor Report which AI coding tools are installed and whether the gaffa skills are set up in them. Add --json for machine output. + install Copy the gaffa skills into the tools you pick. + --tools=a,b tool ids, default the installed ones + --scope=project or personal, default project + --skills-dir=PATH where to read the skills from, + or set GAFFA_SKILLS_DIR + -y, --yes take the defaults, do not prompt + uninstall Remove skills a previous install wrote, for a scope. A skill you + edited since is left in place and reported. + --scope=project or personal, default project + -y, --yes take the defaults, do not prompt Options -v, --version Print the version and exit -h, --help Show this help and exit + +Tool ids: ${IDS}. +Until the skills are on npm, point install at a local checkout with --skills-dir +or GAFFA_SKILLS_DIR. `; -function main(argv: string[]): number { +interface Flags { + values: Record; + bools: Set; +} + +function parseFlags(args: string[]): Flags { + const values: Record = {}; + const bools = new Set(); + for (const arg of args) { + if (arg === "-y") { + bools.add("yes"); + } else if (arg.startsWith("--")) { + const body = arg.slice(2); + const eq = body.indexOf("="); + if (eq >= 0) values[body.slice(0, eq)] = body.slice(eq + 1); + else bools.add(body); + } + } + return { values, bools }; +} + +function parseList(value: string | undefined): string[] { + return (value ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +// Shorten a path for display: under the working directory it reads ./x, under +// home it reads ~/x, otherwise it stays absolute. +function shortPath(path: string, ctx: DoctorContext): string { + if (path === ctx.cwd || path.startsWith(ctx.cwd + sep)) { + const rest = path.slice(ctx.cwd.length + 1); + return rest ? "./" + rest : "."; + } + if (ctx.home && (path === ctx.home || path.startsWith(ctx.home + sep))) { + return "~" + path.slice(ctx.home.length); + } + return path; +} + +async function ask(question: string, fallback: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = (await rl.question(question)).trim(); + return answer.length ? answer : fallback; + } finally { + rl.close(); + } +} + +function validScope(scope: string): scope is Scope { + return scope === "project" || scope === "personal"; +} + +function formatInstall(results: InstallResult[], version: string, ctx: DoctorContext): string { + if (results.length === 0) return "Nothing to install: no target directories for those tools.\n"; + const lines = [`Installed the gaffa skills (version ${version}) into ${results.length} place${results.length === 1 ? "" : "s"}:`]; + let anyProject = false; + for (const r of results) { + if (r.scope === "project") anyProject = true; + lines.push(` ${shortPath(r.dir, ctx)} (${r.scope}) ${r.written.join(", ")}`); + if (r.dropped.length) lines.push(` dropped (gone from source): ${r.dropped.join(", ")}`); + if (r.kept.length) lines.push(` left in place, you had edited: ${r.kept.join(", ")}`); + } + if (anyProject) { + lines.push(""); + lines.push("For a project install, commit the skills directory and its .gaffa-skills.json to share it."); + } + return lines.join("\n") + "\n"; +} + +function formatUninstall(results: UninstallResult[], ctx: DoctorContext): string { + const touched = results.filter((r) => r.removed.length || r.kept.length); + if (touched.length === 0) return "Nothing to uninstall: no gaffa receipt found for that scope.\n"; + const lines: string[] = []; + for (const r of touched) { + lines.push(`${shortPath(r.dir, ctx)} (${r.scope}): removed ${r.removed.length} file${r.removed.length === 1 ? "" : "s"}`); + if (r.kept.length) lines.push(` left in place, you had edited: ${r.kept.join(", ")}`); + } + return lines.join("\n") + "\n"; +} + +async function runInstall(flags: Flags): Promise { + const ctx = processContext(); + const interactive = Boolean(process.stdin.isTTY) && !flags.bools.has("yes"); + + const skillsDir = flags.values["skills-dir"] ?? ctx.env["GAFFA_SKILLS_DIR"]; + if (!skillsDir) { + process.stderr.write( + "No skills source. Pass --skills-dir or set GAFFA_SKILLS_DIR.\nFetching them from npm arrives with GAF-705.\n", + ); + return 1; + } + let source; + try { + source = readLocalSource(skillsDir); + } catch (err) { + process.stderr.write(`${(err as Error).message}\n`); + return 1; + } + + let tools = parseList(flags.values["tools"]); + if (tools.length === 0) { + const installed = inspectTools(ctx) + .filter((r) => r.installed) + .map((r) => r.id); + if (interactive) { + tools = parseList( + await ask(`Tools to install into [${installed.join(", ") || "none detected"}]: `, installed.join(",")), + ); + } else { + tools = installed; + } + } + const unknown = tools.filter((t) => !TOOLS.some((x) => x.id === t)); + if (unknown.length) { + process.stderr.write(`Unknown tool id: ${unknown.join(", ")}\nKnown: ${IDS}.\n`); + return 1; + } + if (tools.length === 0) { + process.stderr.write("No tools to install into. Pass --tools, or install a supported tool first.\n"); + return 1; + } + + let scope = flags.values["scope"]; + if (!scope && interactive) scope = await ask("Scope, project or personal [project]: ", "project"); + scope = scope ?? "project"; + if (!validScope(scope)) { + process.stderr.write(`Scope must be project or personal, got ${scope}.\n`); + return 1; + } + + process.stdout.write(formatInstall(install(ctx, { tools, scope, source }), source.version, ctx)); + return 0; +} + +async function runUninstall(flags: Flags): Promise { + const ctx = processContext(); + const interactive = Boolean(process.stdin.isTTY) && !flags.bools.has("yes"); + + let scope = flags.values["scope"]; + if (!scope && interactive) scope = await ask("Scope to uninstall, project or personal [project]: ", "project"); + scope = scope ?? "project"; + if (!validScope(scope)) { + process.stderr.write(`Scope must be project or personal, got ${scope}.\n`); + return 1; + } + + process.stdout.write(formatUninstall(uninstall(ctx, scope), ctx)); + return 0; +} + +async function main(argv: string[]): Promise { const args = argv.slice(2); if (args.includes("-v") || args.includes("--version")) { process.stdout.write(`${pkg.version}\n`); return 0; } - if (args.length === 0 || args.includes("-h") || args.includes("--help")) { process.stdout.write(HELP); return 0; } - if (args[0] === "doctor") { - const json = args.includes("--json"); - process.stdout.write(runDoctor(processContext(), json)); + const [command, ...rest] = args; + const flags = parseFlags(rest); + + if (command === "doctor") { + process.stdout.write(runDoctor(processContext(), rest.includes("--json"))); return 0; } + if (command === "install") return runInstall(flags); + if (command === "uninstall") return runUninstall(flags); - process.stderr.write( - `Unknown command: ${args.join(" ")}\nRun "gaffa --help" for usage.\n`, - ); + process.stderr.write(`Unknown command: ${args.join(" ")}\nRun "gaffa --help" for usage.\n`); return 1; } -process.exit(main(process.argv)); +main(process.argv) + .then((code) => process.exit(code)) + .catch((err) => { + process.stderr.write(`${(err as Error).message}\n`); + process.exit(1); + }); diff --git a/src/install.ts b/src/install.ts new file mode 100644 index 0000000..b0c1e09 --- /dev/null +++ b/src/install.ts @@ -0,0 +1,181 @@ +// gaffa install / uninstall. +// +// install copies the gaffa skills into each selected tool's skills directory and +// writes a receipt beside them. A second install refreshes to the current source +// and drops skills we wrote that the source no longer has. uninstall reverses an +// install for a scope: it removes the files the receipt records, but leaves any +// the user edited since (their hash no longer matches) and reports them. +// +// The logic takes an explicit context, source and scope so tests can drive it +// against temp directories. The CLI builds those from the real process. + +import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"; +import { join, relative, sep } from "node:path"; +import { TOOLS, skillTargets, type DoctorContext, type Scope, type Tool } from "./tools.js"; +import type { SkillSource } from "./skills-source.js"; +import { + RECEIPT_NAME, + fileSha256, + readReceipt, + writeReceipt, + type ReceiptFile, +} from "./receipt.js"; + +// The single directory install writes to for a tool at a scope: the first target +// of that scope, since a tool reads any of the dirs it looks in, so writing one +// is enough and writing all of them would duplicate the skills. +function writeDirFor(tool: Tool, scope: Scope, ctx: DoctorContext): string | undefined { + return skillTargets(tool, ctx).find((t) => t.scope === scope)?.path; +} + +// The distinct directories to write for the selected tools at a scope. Several +// tools can resolve to the same directory (for example .agents/skills), so the +// result is de-duplicated: one physical dir, one receipt. +export function writeDirs(toolIds: string[], scope: Scope, ctx: DoctorContext): string[] { + const dirs = new Set(); + for (const id of toolIds) { + const tool = TOOLS.find((t) => t.id === id); + const dir = tool && writeDirFor(tool, scope, ctx); + if (dir) dirs.add(dir); + } + return [...dirs]; +} + +// Every file under root, as paths relative to base, forward-slashed and sorted. +function walkFiles(root: string, base: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const full = join(root, entry.name); + if (entry.isDirectory()) out.push(...walkFiles(full, base)); + else out.push(relative(base, full).split(sep).join("/")); + } + return out.sort(); +} + +// The skill a receipt-relative path belongs to (its first segment). +function skillOf(path: string): string { + return path.split("/")[0]; +} + +type RemoveOutcome = "removed" | "kept" | "absent"; + +// Remove a recorded file if it still matches its hash. If the user edited it the +// hash differs, so it is left in place and reported as kept. Already gone counts +// as absent, no error. +function tryRemove(dir: string, file: ReceiptFile): RemoveOutcome { + const full = join(dir, file.path); + if (!existsSync(full)) return "absent"; + if (fileSha256(full) === file.sha256) { + rmSync(full); + return "removed"; + } + return "kept"; +} + +// Remove empty directories inside one skill directory, deepest first, and the +// skill directory itself if it ends up empty. Confined to a single gaffa-owned +// skill tree, so it never touches a sibling skill or the shared skills directory. +function pruneSkillDir(skillDir: string): void { + if (!existsSync(skillDir)) return; + for (const entry of readdirSync(skillDir, { withFileTypes: true })) { + if (entry.isDirectory()) pruneSkillDir(join(skillDir, entry.name)); + } + if (readdirSync(skillDir).length === 0) rmSync(skillDir, { recursive: true }); +} + +export interface InstallResult { + dir: string; + scope: Scope; + written: string[]; // skills copied in + dropped: string[]; // retired skills removed on a refresh + kept: string[]; // files left because the user had edited them +} + +export interface InstallOptions { + tools: string[]; + scope: Scope; + source: SkillSource; +} + +export function install(ctx: DoctorContext, opts: InstallOptions): InstallResult[] { + const sourceNames = new Set(opts.source.skills.map((s) => s.name)); + return writeDirs(opts.tools, opts.scope, ctx).map((dir) => { + const kept: string[] = []; + const dropped = new Set(); + mkdirSync(dir, { recursive: true }); + + // Refresh: drop skills we wrote before that the source no longer has, then + // tidy each now-empty retired skill directory. + const prior = readReceipt(dir); + if (prior) { + for (const f of prior.files) { + const skill = skillOf(f.path); + if (sourceNames.has(skill)) continue; + dropped.add(skill); + if (tryRemove(dir, f) === "kept") kept.push(f.path); + } + for (const skill of dropped) pruneSkillDir(join(dir, skill)); + } + + // Copy the current source skills in, overwriting our earlier copies. + for (const skill of opts.source.skills) { + cpSync(skill.dir, join(dir, skill.name), { recursive: true }); + } + + // Record the files the source provided, hashed from what was written. Walking + // the source rather than the destination keeps a file the user dropped inside + // a skill dir out of the receipt, so uninstall never removes it. + const files: ReceiptFile[] = []; + for (const skill of opts.source.skills) { + for (const rel of walkFiles(skill.dir, skill.dir)) { + const path = `${skill.name}/${rel}`; + files.push({ path, sha256: fileSha256(join(dir, path)) }); + } + } + writeReceipt(dir, { version: opts.source.version, scope: opts.scope, files }); + + return { + dir, + scope: opts.scope, + written: opts.source.skills.map((s) => s.name), + dropped: [...dropped].sort(), + kept: kept.sort(), + }; + }); +} + +export interface UninstallResult { + dir: string; + scope: Scope; + removed: string[]; // files removed + kept: string[]; // files left because the user had edited them +} + +// Reverse an install for a scope. For every directory with a receipt, remove the +// files it recorded whose content still matches and leave any the user edited. A +// directory with nothing left loses its receipt. One with edited files keeps a +// receipt pruned to just those, so it stays self-describing. +export function uninstall(ctx: DoctorContext, scope: Scope): UninstallResult[] { + const results: UninstallResult[] = []; + for (const dir of writeDirs(TOOLS.map((t) => t.id), scope, ctx)) { + const receipt = readReceipt(dir); + if (!receipt) continue; + const removed: string[] = []; + const kept: string[] = []; + for (const f of receipt.files) { + const outcome = tryRemove(dir, f); + if (outcome === "removed") removed.push(f.path); + else if (outcome === "kept") kept.push(f.path); + } + for (const skill of new Set(receipt.files.map((f) => skillOf(f.path)))) { + pruneSkillDir(join(dir, skill)); + } + if (kept.length === 0) { + rmSync(join(dir, RECEIPT_NAME), { force: true }); + } else { + writeReceipt(dir, { ...receipt, files: receipt.files.filter((f) => kept.includes(f.path)) }); + } + results.push({ dir, scope, removed: removed.sort(), kept: kept.sort() }); + } + return results; +} diff --git a/src/receipt.ts b/src/receipt.ts new file mode 100644 index 0000000..41ef8e1 --- /dev/null +++ b/src/receipt.ts @@ -0,0 +1,44 @@ +// The install receipt: a per-directory record of the skill files gaffa wrote +// there. One receipt sits in each skills directory we write to. It lets +// uninstall remove exactly what we wrote, and lets uninstall and doctor tell an +// untouched copy from one the user has edited, by comparing the recorded hash to +// what is on disk. Paths and hashes only, so a project-scope receipt is safe to +// commit with the repo. + +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { Scope } from "./tools.js"; + +export const RECEIPT_NAME = ".gaffa-skills.json"; + +export interface ReceiptFile { + // Path relative to the receipt's own directory, always forward-slashed. + path: string; + sha256: string; +} + +export interface Receipt { + version: string; + scope: Scope; + files: ReceiptFile[]; +} + +export function fileSha256(path: string): string { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +export function readReceipt(dir: string): Receipt | null { + try { + const parsed = JSON.parse(readFileSync(join(dir, RECEIPT_NAME), "utf8")) as Receipt; + if (!Array.isArray(parsed.files)) return null; + return parsed; + } catch { + return null; + } +} + +export function writeReceipt(dir: string, receipt: Receipt): void { + const files = [...receipt.files].sort((a, b) => a.path.localeCompare(b.path)); + writeFileSync(join(dir, RECEIPT_NAME), JSON.stringify({ ...receipt, files }, null, 2) + "\n"); +} diff --git a/src/skills-source.ts b/src/skills-source.ts new file mode 100644 index 0000000..ee09c93 --- /dev/null +++ b/src/skills-source.ts @@ -0,0 +1,56 @@ +// Where the skills come from. Today they are read from a local gaffa-for-ai +// checkout, pointed at with --skills-dir or GAFFA_SKILLS_DIR. When the skills +// ship as a versioned npm package (GAF-705) npm resolution slots in here and the +// rest of install does not change. If resolving the source fails it throws, so +// install stops before it touches anything on disk. + +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +const GAFFA_PREFIX = "gaffa-"; + +export interface SourceSkill { + name: string; // the gaffa-* directory name + dir: string; // absolute path to the skill directory in the source +} + +export interface SkillSource { + version: string; + skills: SourceSkill[]; +} + +// Read the gaffa-* skill directories (each holding a SKILL.md) from a local +// source directory. Throws if the directory is missing or holds no skills. +export function readLocalSource(skillsDir: string): SkillSource { + let entries; + try { + entries = readdirSync(skillsDir, { withFileTypes: true }); + } catch { + throw new Error(`skills source not found: ${skillsDir}`); + } + const skills = entries + .filter( + (e) => + e.isDirectory() && + e.name.startsWith(GAFFA_PREFIX) && + existsSync(join(skillsDir, e.name, "SKILL.md")), + ) + .map((e) => ({ name: e.name, dir: join(skillsDir, e.name) })) + .sort((a, b) => a.name.localeCompare(b.name)); + if (skills.length === 0) throw new Error(`no gaffa skills found in ${skillsDir}`); + return { version: readSourceVersion(skillsDir), skills }; +} + +// The source's version, from its package.json if the checkout has one, else a +// local placeholder until GAF-705 gives the skills a published version. +function readSourceVersion(skillsDir: string): string { + try { + const pkg = JSON.parse(readFileSync(join(skillsDir, "package.json"), "utf8")) as { + version?: string; + }; + if (pkg.version) return pkg.version; + } catch { + /* no package.json, fall through to the placeholder */ + } + return "0.0.0-local"; +} diff --git a/src/tools.ts b/src/tools.ts index 20d1fdc..8a2cb07 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -23,7 +23,7 @@ interface SkillDir { segments: string[]; } -interface Tool { +export interface Tool { id: string; label: string; // Environment variable that overrides the config directory, if the tool has one. @@ -149,24 +149,37 @@ function resolveConfigPath(tool: Tool, ctx: DoctorContext): string { return join(ctx.home, ...tool.configSegments); } +export interface SkillTarget { + scope: Scope; + path: string; +} + +// The resolved skill directories a tool reads, in the tool's declared order. The +// first target of a given scope is the one to write to: a tool that reads more +// than one dir reads any of them, so install writes the first and does not +// duplicate into the rest. +export function skillTargets(tool: Tool, ctx: DoctorContext): SkillTarget[] { + const configPath = resolveConfigPath(tool, ctx); + return tool.skillDirs.map((dir) => { + let base: string; + if (dir.scope === "project") base = ctx.cwd; + else if (dir.fromConfig) base = configPath; + else base = ctx.home; + return { scope: dir.scope, path: join(base, ...dir.segments) }; + }); +} + // Inspect every target tool against the given home, working directory and // environment. Reads the filesystem, writes nothing. export function inspectTools(ctx: DoctorContext): ToolReport[] { return TOOLS.map((tool) => { const configPath = resolveConfigPath(tool, ctx); - const skillLocations = tool.skillDirs.map((dir) => { - let base: string; - if (dir.scope === "project") base = ctx.cwd; - else if (dir.fromConfig) base = configPath; - else base = ctx.home; - const path = join(base, ...dir.segments); - return { - scope: dir.scope, - path, - exists: isDirectory(path), - skills: gaffaSkillsIn(path), - }; - }); + const skillLocations = skillTargets(tool, ctx).map((t) => ({ + scope: t.scope, + path: t.path, + exists: isDirectory(t.path), + skills: gaffaSkillsIn(t.path), + })); return { id: tool.id, label: tool.label, diff --git a/test/install.test.js b/test/install.test.js new file mode 100644 index 0000000..f14fd23 --- /dev/null +++ b/test/install.test.js @@ -0,0 +1,276 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { install, uninstall, writeDirs } from "../dist/install.js"; +import { readLocalSource } from "../dist/skills-source.js"; +import { readReceipt, RECEIPT_NAME } from "../dist/receipt.js"; + +const cli = fileURLToPath(new URL("../dist/cli.js", import.meta.url)); + +function tmp() { + return mkdtempSync(join(tmpdir(), "gaffa-install-")); +} + +// A source skill: a gaffa-* directory with a SKILL.md and one nested reference +// file, so copying and the receipt cover more than a single top-level file. +function sourceSkill(srcDir, name, body = "skill\n") { + mkdirSync(join(srcDir, name, "references"), { recursive: true }); + writeFileSync(join(srcDir, name, "SKILL.md"), body); + writeFileSync(join(srcDir, name, "references", "ref.md"), "ref\n"); +} + +function cleanup(...dirs) { + for (const d of dirs) rmSync(d, { recursive: true, force: true }); +} + +test("install copies the skills into a tool's dir and writes a receipt with hashes", () => { + const home = tmp(), cwd = tmp(), src = tmp(); + try { + sourceSkill(src, "gaffa-find"); + sourceSkill(src, "gaffa-debug"); + const source = readLocalSource(src); + + const results = install({ home, cwd, env: {} }, { tools: ["codex"], scope: "project", source }); + + assert.equal(results.length, 1); + const dir = join(cwd, ".agents", "skills"); // codex project target + assert.equal(results[0].dir, dir); + assert.ok(existsSync(join(dir, "gaffa-find", "SKILL.md"))); + assert.ok(existsSync(join(dir, "gaffa-debug", "references", "ref.md"))); + + const receipt = readReceipt(dir); + assert.equal(receipt.scope, "project"); + assert.equal(receipt.version, "0.0.0-local"); + const paths = receipt.files.map((f) => f.path); + assert.ok(paths.includes("gaffa-find/SKILL.md")); + assert.ok(paths.includes("gaffa-debug/references/ref.md")); + for (const f of receipt.files) assert.match(f.sha256, /^[0-9a-f]{64}$/); + } finally { + cleanup(home, cwd, src); + } +}); + +test("tools that share a target directory write once, one receipt", () => { + const home = tmp(), cwd = tmp(), src = tmp(); + try { + sourceSkill(src, "gaffa-find"); + const source = readLocalSource(src); + // codex, copilot and cursor all canonically write project .agents/skills. + const dirs = writeDirs(["codex", "copilot", "cursor"], "project", { home, cwd, env: {} }); + assert.deepEqual(dirs, [join(cwd, ".agents", "skills")]); + + const results = install({ home, cwd, env: {} }, { tools: ["codex", "copilot", "cursor"], scope: "project", source }); + assert.equal(results.length, 1); + } finally { + cleanup(home, cwd, src); + } +}); + +test("claude-code and codex resolve to different project dirs", () => { + const home = tmp(), cwd = tmp(); + try { + const dirs = writeDirs(["claude-code", "codex"], "project", { home, cwd, env: {} }); + assert.deepEqual( + dirs.sort(), + [join(cwd, ".agents", "skills"), join(cwd, ".claude", "skills")].sort(), + ); + } finally { + cleanup(home, cwd); + } +}); + +test("a refresh drops a skill the source no longer has", () => { + const home = tmp(), cwd = tmp(), src = tmp(); + try { + sourceSkill(src, "gaffa-find"); + sourceSkill(src, "gaffa-debug"); + const ctx = { home, cwd, env: {} }; + install(ctx, { tools: ["codex"], scope: "project", source: readLocalSource(src) }); + + rmSync(join(src, "gaffa-debug"), { recursive: true, force: true }); // retire it + const results = install(ctx, { tools: ["codex"], scope: "project", source: readLocalSource(src) }); + + const dir = join(cwd, ".agents", "skills"); + assert.ok(existsSync(join(dir, "gaffa-find", "SKILL.md"))); + assert.ok(!existsSync(join(dir, "gaffa-debug"))); + assert.deepEqual(results[0].dropped, ["gaffa-debug"]); + assert.ok(readReceipt(dir).files.every((f) => f.path.startsWith("gaffa-find/"))); + } finally { + cleanup(home, cwd, src); + } +}); + +test("uninstall removes an untouched install and its receipt", () => { + const home = tmp(), cwd = tmp(), src = tmp(); + try { + sourceSkill(src, "gaffa-find"); + const ctx = { home, cwd, env: {} }; + install(ctx, { tools: ["codex"], scope: "project", source: readLocalSource(src) }); + + const results = uninstall(ctx, "project"); + const dir = join(cwd, ".agents", "skills"); + assert.equal(results.length, 1); + assert.ok(results[0].removed.includes("gaffa-find/SKILL.md")); + assert.ok(!existsSync(join(dir, "gaffa-find"))); + assert.ok(!existsSync(join(dir, RECEIPT_NAME))); + } finally { + cleanup(home, cwd, src); + } +}); + +test("uninstall leaves a file the user edited and prunes the receipt to it", () => { + const home = tmp(), cwd = tmp(), src = tmp(); + try { + sourceSkill(src, "gaffa-find"); + const ctx = { home, cwd, env: {} }; + install(ctx, { tools: ["codex"], scope: "project", source: readLocalSource(src) }); + + const dir = join(cwd, ".agents", "skills"); + writeFileSync(join(dir, "gaffa-find", "SKILL.md"), "I edited this\n"); // changes the hash + + const results = uninstall(ctx, "project"); + assert.deepEqual(results[0].kept, ["gaffa-find/SKILL.md"]); + assert.ok(results[0].removed.includes("gaffa-find/references/ref.md")); + assert.ok(existsSync(join(dir, "gaffa-find", "SKILL.md"))); // left in place + assert.ok(!existsSync(join(dir, "gaffa-find", "references"))); // unedited removed, dir pruned + + const receipt = readReceipt(dir); // pruned to the kept file, still self-describing + assert.deepEqual(receipt.files.map((f) => f.path), ["gaffa-find/SKILL.md"]); + } finally { + cleanup(home, cwd, src); + } +}); + +test("a refresh does not clobber a retired skill the user had edited", () => { + const home = tmp(), cwd = tmp(), src = tmp(); + try { + sourceSkill(src, "gaffa-find"); + sourceSkill(src, "gaffa-debug"); + const ctx = { home, cwd, env: {} }; + install(ctx, { tools: ["codex"], scope: "project", source: readLocalSource(src) }); + + const dir = join(cwd, ".agents", "skills"); + writeFileSync(join(dir, "gaffa-debug", "SKILL.md"), "my notes\n"); // edit it + rmSync(join(src, "gaffa-debug"), { recursive: true, force: true }); // then retire it + + const results = install(ctx, { tools: ["codex"], scope: "project", source: readLocalSource(src) }); + assert.deepEqual(results[0].dropped, ["gaffa-debug"]); + assert.ok(results[0].kept.includes("gaffa-debug/SKILL.md")); + assert.ok(existsSync(join(dir, "gaffa-debug", "SKILL.md"))); // edit preserved + } finally { + cleanup(home, cwd, src); + } +}); + +test("readLocalSource rejects a directory with no skills", () => { + const src = tmp(); + try { + assert.throws(() => readLocalSource(src), /no gaffa skills/); + } finally { + cleanup(src); + } +}); + +test("install and uninstall work at personal scope", () => { + const home = tmp(), cwd = tmp(), src = tmp(); + try { + sourceSkill(src, "gaffa-find"); + const ctx = { home, cwd, env: {} }; + const results = install(ctx, { tools: ["claude-code"], scope: "personal", source: readLocalSource(src) }); + const dir = join(home, ".claude", "skills"); // claude-code personal, from config dir + assert.equal(results[0].dir, dir); + assert.ok(existsSync(join(dir, "gaffa-find", "SKILL.md"))); + + const un = uninstall(ctx, "personal"); + assert.ok(un[0].removed.includes("gaffa-find/SKILL.md")); + assert.ok(!existsSync(join(dir, "gaffa-find"))); + } finally { + cleanup(home, cwd, src); + } +}); + +test("a user's own skill and its empty subdirs are never pruned", () => { + const home = tmp(), cwd = tmp(), src = tmp(); + try { + sourceSkill(src, "gaffa-find"); + const ctx = { home, cwd, env: {} }; + const shared = join(cwd, ".agents", "skills"); + mkdirSync(join(shared, "my-skill", "empty"), { recursive: true }); // user's own, empty inside + + install(ctx, { tools: ["codex"], scope: "project", source: readLocalSource(src) }); + assert.ok(existsSync(join(shared, "my-skill", "empty"))); + uninstall(ctx, "project"); + assert.ok(existsSync(join(shared, "my-skill", "empty"))); // untouched + } finally { + cleanup(home, cwd, src); + } +}); + +test("a file the user adds inside a gaffa skill dir is not adopted and survives uninstall", () => { + const home = tmp(), cwd = tmp(), src = tmp(); + try { + sourceSkill(src, "gaffa-find"); + const ctx = { home, cwd, env: {} }; + install(ctx, { tools: ["codex"], scope: "project", source: readLocalSource(src) }); + + const dir = join(cwd, ".agents", "skills"); + writeFileSync(join(dir, "gaffa-find", "my-notes.md"), "mine\n"); + install(ctx, { tools: ["codex"], scope: "project", source: readLocalSource(src) }); // refresh + + assert.ok(readReceipt(dir).files.every((f) => f.path !== "gaffa-find/my-notes.md")); + uninstall(ctx, "project"); + assert.ok(existsSync(join(dir, "gaffa-find", "my-notes.md"))); // left in place + } finally { + cleanup(home, cwd, src); + } +}); + +// End-to-end through the built binary, driving cwd so a project install lands in +// a temp directory rather than the repo. +function run(args, cwd) { + try { + const stdout = execFileSync(process.execPath, [cli, ...args], { encoding: "utf8", cwd }); + return { stdout, code: 0 }; + } catch (err) { + return { stdout: err.stdout ?? "", stderr: err.stderr ?? "", code: err.status }; + } +} + +test("cli install writes skills for a project scope in the working directory", () => { + const cwd = tmp(), src = tmp(); + try { + sourceSkill(src, "gaffa-find"); + const { stdout, code } = run( + ["install", "--tools=codex", "--scope=project", `--skills-dir=${src}`, "--yes"], + cwd, + ); + assert.equal(code, 0); + assert.match(stdout, /Installed the gaffa skills/); + assert.ok(existsSync(join(cwd, ".agents", "skills", "gaffa-find", "SKILL.md"))); + } finally { + cleanup(cwd, src); + } +}); + +test("cli install with no skills source errors and writes nothing", () => { + const cwd = tmp(); + try { + const { code, stderr } = run(["install", "--tools=codex", "--scope=project", "--yes"], cwd); + assert.equal(code, 1); + assert.match(stderr, /GAFFA_SKILLS_DIR|skills source/); + } finally { + cleanup(cwd); + } +}); + +test("help lists install and uninstall", () => { + const { stdout, code } = run(["--help"]); + assert.equal(code, 0); + assert.match(stdout, /install/); + assert.match(stdout, /uninstall/); +});