diff --git a/.changeset/check-installed-skill.md b/.changeset/check-installed-skill.md new file mode 100644 index 00000000..4418ea71 --- /dev/null +++ b/.changeset/check-installed-skill.md @@ -0,0 +1,5 @@ +--- +"@design-intelligence/ghost": minor +--- + +Add `ghost skill check` to compare installed skill instructions with the bundled files without writing, report missing, changed, and extra reference files, and suggest a reinstall command. diff --git a/README.md b/README.md index 4a424c5c..46acc448 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,11 @@ npm install -D @design-intelligence/ghost npx ghost skill install ``` +After upgrading, run `npx ghost skill check` to compare the installed skill +with this CLI's bundle. Use `--agent` or `--dest` to select an installation; +the command prints the directory it checks and never modifies it. Review any +local edits before reinstalling with `ghost skill install --force`. + ## Use It ghost is **bring-your-own-agent**. Install the skill bundle so Claude Code, @@ -65,6 +70,7 @@ ghost pull # read the cover plus picked nodes' full bodies ghost review # during review: match a diff to guidance and checks ghost stats # while tuning: see what agents reached for ghost skill install # install the unified ghost skill bundle +ghost skill check # compare an installation with the shipped skill ghost manifest # emit a machine-readable index of commands and flags ``` diff --git a/packages/ghost/README.md b/packages/ghost/README.md index 60da361a..53eb7198 100644 --- a/packages/ghost/README.md +++ b/packages/ghost/README.md @@ -18,6 +18,11 @@ npm install -D @design-intelligence/ghost npx ghost skill install ``` +After upgrading, run `npx ghost skill check` to compare the installed skill +with this CLI's bundle. Use `--agent` or `--dest` to select an installation; +the command prints the directory it checks and never modifies it. Review any +local edits before reinstalling with `ghost skill install --force`. + ## Use It ghost is **bring-your-own-agent**. Install the skill bundle so Claude Code, @@ -48,6 +53,7 @@ ghost pull # read the cover plus picked nodes' full bodies ghost review # during review: match a diff to guidance and checks ghost stats # while tuning: see what agents reached for ghost skill install # install the unified ghost skill bundle +ghost skill check # compare an installation with the shipped skill ghost manifest # emit a machine-readable index of commands and flags ``` diff --git a/packages/ghost/src/commands/command-discovery.ts b/packages/ghost/src/commands/command-discovery.ts index 6b7fd318..737903bd 100644 --- a/packages/ghost/src/commands/command-discovery.ts +++ b/packages/ghost/src/commands/command-discovery.ts @@ -162,8 +162,8 @@ const COMMAND_DISCOVERY = [ name: "skill", group: "core", defaultHelp: true, - compactName: "skill install", - summary: "Install the ghost skill bundle.", + compactName: "skill install|check", + summary: "Install or check the ghost skill bundle.", }, { name: "manifest", diff --git a/packages/ghost/src/commands/skill-check.ts b/packages/ghost/src/commands/skill-check.ts new file mode 100644 index 00000000..98525ea7 --- /dev/null +++ b/packages/ghost/src/commands/skill-check.ts @@ -0,0 +1,108 @@ +import { readdir, readFile, stat } from "node:fs/promises"; +import { join } from "node:path"; +import type { SkillBundleFile } from "#ghost-core"; +import { isMissingPathError } from "../internal/fs.js"; + +export type SkillCheckResult = { + targetDir: string; + checked: string[]; + missing: string[]; + changed: string[]; + extra: string[]; + matches: boolean; + reinstallCommand: string; +}; + +export async function checkSkillInstall( + targetDir: string, + bundle: SkillBundleFile[], +): Promise { + const expected = bundle + .map((file) => ({ ...file, path: file.path.replaceAll("\\", "/") })) + .filter( + (file) => file.path === "SKILL.md" || file.path.startsWith("references/"), + ) + .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + const missing: string[] = []; + const changed: string[] = []; + for (const file of expected) { + const absolute = join(targetDir, file.path); + try { + if ( + !(await stat(absolute)).isFile() || + (await readFile(absolute, "utf-8")) !== file.content + ) { + changed.push(file.path); + } + } catch (err) { + if (!isMissingPathError(err)) throw err; + missing.push(file.path); + } + } + + const checked = expected.map((file) => file.path); + const expectedPaths = new Set(checked); + const extra = (await listReferenceFiles(targetDir)) + .filter((path) => !expectedPaths.has(path)) + .sort(); + return { + targetDir, + checked, + missing, + changed, + extra, + matches: missing.length === 0 && changed.length === 0 && extra.length === 0, + reinstallCommand: `ghost skill install --dest '${targetDir.replaceAll("'", "'\\''")}' --force`, + }; +} + +async function listReferenceFiles(targetDir: string): Promise { + const root = join(targetDir, "references"); + try { + if (!(await stat(root)).isDirectory()) return []; + } catch (err) { + if (isMissingPathError(err)) return []; + throw err; + } + const paths: string[] = []; + async function walk(dir: string, prefix: string): Promise { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = `${prefix}/${entry.name}`; + if (entry.isDirectory()) await walk(join(dir, entry.name), path); + // Include symlinks (even dangling ones) without following extra targets. + else paths.push(path); + } + } + await walk(root, "references"); + return paths; +} + +export function formatSkillCheckResult(result: SkillCheckResult): string { + const lines = [ + "ghost skill check", + `Target: ${result.targetDir}`, + result.matches + ? "Result: installed files match the bundled ghost skill instructions." + : "Result: installed files differ from the bundled ghost skill instructions.", + "", + ]; + const sections: [string, string[]][] = result.matches + ? [["Checked files", result.checked]] + : [ + ["Missing files", result.missing], + ["Changed files", result.changed], + ["Extra reference files", result.extra], + ]; + for (const [title, paths] of sections) { + if (paths.length) + lines.push(`${title}:`, ...paths.map((path) => ` ${path}`), ""); + } + if (!result.matches) + lines.push("Reinstall with:", ` ${result.reinstallCommand}`, ""); + lines.push( + "This only compares SKILL.md and references/ to the bundled ghost skill instructions.", + "It does not prove the skill is runtime-active or semantically compatible with any host agent.", + "", + ); + return lines.join("\n"); +} diff --git a/packages/ghost/src/commands/skill-command.ts b/packages/ghost/src/commands/skill-command.ts index d9d7a2c7..220e76f9 100644 --- a/packages/ghost/src/commands/skill-command.ts +++ b/packages/ghost/src/commands/skill-command.ts @@ -4,9 +4,10 @@ import { homedir } from "node:os"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { CAC } from "cac"; -import { loadSkillBundle } from "#ghost-core"; +import { loadSkillBundle, UsageError } from "#ghost-core"; import { exitCli, failFromError } from "./errors.js"; import { parseEnumOption } from "./options.js"; +import { checkSkillInstall, formatSkillCheckResult } from "./skill-check.js"; // The bundle assets are copied to `dist/skill-bundle` (sibling of `commands/`). const SKILL_BUNDLE_ROOT = fileURLToPath( @@ -24,24 +25,43 @@ type SupportedAgent = (typeof SUPPORTED_AGENTS)[number]; export function registerSkillCommand(cli: CAC): void { cli - .command("skill ", "Install the unified ghost skill bundle.") + .command( + "skill ", + "Install or check the unified ghost skill bundle.", + ) .option( "--dest ", - "Install destination (default: detected agent skills directory + /ghost)", + "Install/check destination (default: detected agent skills directory + /ghost)", ) .option( "--agent ", "Agent destination to use when --dest is omitted: claude, cursor, codex, opencode, goose", ) - .option("--force", "Overwrite an existing installed ghost skill") + .option( + "--force", + "Overwrite an existing installed ghost skill (install only)", + ) .action(async (action: string, opts) => { try { - if (action !== "install") { - console.error("Error: ghost skill currently supports only `install`"); - await exitCli(2); - return; + if (action !== "install" && action !== "check") { + throw new UsageError( + "ghost skill supports only `install` and `check`", + ); + } + if (action === "check" && opts.force !== undefined) { + throw new UsageError( + "ghost skill check does not accept --force; omit it to check without writing, or use ghost skill install --force to reinstall.", + ); } + if ( + opts.dest !== undefined && + (typeof opts.dest !== "string" || !opts.dest.trim()) + ) { + throw new UsageError( + "--dest must be a nonempty path; pass --dest or omit it to use the agent destination.", + ); + } const agent = parseAgent(opts.agent); const outDir = resolve( process.cwd(), @@ -49,6 +69,14 @@ export function registerSkillCommand(cli: CAC): void { ? opts.dest : `${agentSkillDir(agent ?? detectAgent())}/ghost`, ); + const bundle = loadSkillBundle(SKILL_BUNDLE_ROOT); + + if (action === "check") { + const result = await checkSkillInstall(outDir, bundle); + process.stdout.write(formatSkillCheckResult(result)); + await exitCli(result.matches ? 0 : 1); + return; + } if (existsSync(resolve(outDir, "SKILL.md")) && !opts.force) { console.error( @@ -66,7 +94,6 @@ export function registerSkillCommand(cli: CAC): void { }); } - const bundle = loadSkillBundle(SKILL_BUNDLE_ROOT); const written: string[] = []; for (const file of bundle) { const outPath = resolve(outDir, file.path); diff --git a/packages/ghost/src/skill-bundle/SKILL.md b/packages/ghost/src/skill-bundle/SKILL.md index 61521189..9ab99770 100644 --- a/packages/ghost/src/skill-bundle/SKILL.md +++ b/packages/ghost/src/skill-bundle/SKILL.md @@ -115,6 +115,11 @@ Copy the `.ghost/` directory, run `ghost validate --package `, then run `ghost skill install` in the receiving workspace. From there, gather and pull against that package with `--package `. +After upgrading the CLI, use `ghost skill check --agent ` or +`ghost skill check --dest ` to compare the intended installation with the +shipped bundle. Review local edits before reinstalling with `--force`. This +check does not establish which skill an active session has loaded. + ghost package authoring is **elicitation, not scanning**. The raw material is what the human brings and points at: words, images, links, products, brand docs, copy they love or hate. Repo code can supply material locators and local diff --git a/packages/ghost/src/skill-bundle/references/schema.md b/packages/ghost/src/skill-bundle/references/schema.md index eda6e21b..ded92b27 100644 --- a/packages/ghost/src/skill-bundle/references/schema.md +++ b/packages/ghost/src/skill-bundle/references/schema.md @@ -134,6 +134,11 @@ it does not grade them. prose for the host agent. Repeated baselines point to prose already included in the packet. - `ghost stats` summarizes local gather and pull events. +- `ghost skill check` compares an installed `SKILL.md` and `references/` with + this CLI's bundle. It uses install's `--agent` and `--dest` resolution, + prints the target, and never writes. Exit 0 means a match, 1 means missing or + differing files, and 2 means invalid arguments. A match does not establish + which instructions an active host session has loaded. ### Loading diagnostics diff --git a/packages/ghost/test/skill-check.test.ts b/packages/ghost/test/skill-check.test.ts new file mode 100644 index 00000000..20c37c26 --- /dev/null +++ b/packages/ghost/test/skill-check.test.ts @@ -0,0 +1,241 @@ +import { + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { loadSkillBundle } from "../src/ghost-core/index.js"; +import { runCli } from "./cli-test-utils.js"; + +vi.mock("node:os", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, homedir: vi.fn() }; +}); +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readFile: vi.fn(actual.readFile), + stat: vi.fn(actual.stat), + readdir: vi.fn(actual.readdir), + }; +}); + +const BUNDLE = loadSkillBundle(resolve("packages/ghost/src/skill-bundle")); + +describe("ghost skill check", () => { + let dir: string; + let home: string; + let dest: string; + + beforeEach(async () => { + dir = await realpath(await mkdtemp(join(tmpdir(), "ghost-skill-check-"))); + home = join(dir, "home"); + dest = join(dir, "skills", "ghost"); + vi.mocked(homedir).mockReturnValue(home); + }); + afterEach(async () => { + vi.restoreAllMocks(); + await rm(dir, { recursive: true, force: true }); + }); + + async function install(): Promise { + expect( + (await runCli(["skill", "install", "--dest", "skills/ghost"], dir)).code, + ).toBe(0); + } + const checkArgs = ["skill", "check", "--dest", "skills/ghost"]; + + it("matches a fresh install, reports the resolved target, and does not write", async () => { + await install(); + const before = await snapshotFiles(dest); + const result = await runCli(checkArgs, dir); + + expect(result.code).toBe(0); + expect(result.stdout).toContain(`Target: ${dest}`); + expect(result.stdout).toContain( + "Result: installed files match the bundled ghost skill instructions.", + ); + for (const file of BUNDLE) + expect(result.stdout).toContain(` ${file.path}`); + expect(result.stdout).toContain( + "does not prove the skill is runtime-active or semantically compatible", + ); + expect(result.stderr).toBe(""); + await expect(snapshotFiles(dest)).resolves.toEqual(before); + }); + + it("flags changed, missing, and nested retired files deterministically without writing", async () => { + await install(); + await writeFile(join(dest, "SKILL.md"), "edited\n"); + await writeFile(join(dest, "references", "schema.md"), "edited\n"); + await rm(join(dest, "references", "making.md")); + await mkdir(join(dest, "references", "old")); + await writeFile(join(dest, "references", "old", "retired.md"), "old\n"); + await writeFile(join(dest, "references", "retired.md"), "old\n"); + const before = await snapshotFiles(dest); + + const result = await runCli(checkArgs, dir); + expect(result.code).toBe(1); + expect(result.stdout).toContain("Missing files:\n references/making.md"); + expect(result.stdout).toContain( + "Changed files:\n SKILL.md\n references/schema.md", + ); + expect(result.stdout).toContain( + "Extra reference files:\n references/old/retired.md\n references/retired.md", + ); + expect(result.stdout).toContain( + ` ghost skill install --dest '${dest}' --force`, + ); + await expect(snapshotFiles(dest)).resolves.toEqual(before); + }); + + it("reports an absent install without creating it", async () => { + const result = await runCli(checkArgs, dir); + expect(result.code).toBe(1); + expect(result.stdout).toContain(`Target: ${dest}`); + expect(result.stdout).toContain("Missing files:\n SKILL.md"); + expect(result.stdout).toContain(" references/authoring.md"); + expect(result.stdout).not.toContain("Changed files:"); + expect(result.stdout).not.toContain("Extra reference files:"); + await expect(readdir(dir)).resolves.toEqual([]); + }); + + it("ignores unrelated root notes and metadata", async () => { + await install(); + await writeFile(join(dest, "notes.md"), "keep\n"); + await writeFile(join(dest, "package.json"), '{"version":"0.0.0"}\n'); + const before = await snapshotFiles(dest); + const result = await runCli(checkArgs, dir); + expect(result.code).toBe(0); + expect(result.stdout).not.toContain("notes.md"); + expect(result.stdout).not.toContain("package.json"); + await expect(snapshotFiles(dest)).resolves.toEqual(before); + }); + + it("flags retired symlinks without following their targets", async () => { + await install(); + await symlink(join(dir, "absent"), join(dest, "references", "dangling.md")); + await symlink(dest, join(dest, "references", "loop"), "dir"); + await symlink( + join(dest, "SKILL.md"), + join(dest, "references", "retired.md"), + ); + const result = await runCli(checkArgs, dir); + expect(result.code).toBe(1); + expect(result.stdout).toContain( + "Extra reference files:\n references/dangling.md\n references/loop\n references/retired.md", + ); + }); + + it("reports a directory replacing an expected file as changed", async () => { + await install(); + await rm(join(dest, "SKILL.md")); + await mkdir(join(dest, "SKILL.md")); + const result = await runCli(checkArgs, dir); + expect(result.code).toBe(1); + expect(result.stdout).toContain("Changed files:\n SKILL.md"); + }); + + it("honors custom destinations over agent selection and safely quotes reinstall commands", async () => { + const custom = "skills/brand's ghost"; + await runCli( + ["skill", "install", "--dest", custom, "--agent", "goose"], + dir, + ); + await writeFile(join(dir, custom, "SKILL.md"), "edited\n"); + const result = await runCli( + ["skill", "check", "--dest", custom, "--agent", "goose"], + dir, + ); + expect(result.code).toBe(1); + expect(result.stdout).toContain(`Target: ${join(dir, custom)}`); + expect(result.stdout).toContain( + `ghost skill install --dest '${join(dir, "skills", "brand'\\''s ghost")}' --force`, + ); + }); + + it.each([ + ["claude", ".claude"], + ["cursor", ".cursor"], + ["codex", ".codex"], + ["opencode", ".opencode"], + ["goose", ".agents"], + ])("shares install's explicit %s destination within a mocked home", async (agent, folder) => { + expect( + (await runCli(["skill", "install", "--agent", agent], dir)).code, + ).toBe(0); + const result = await runCli(["skill", "check", "--agent", agent], dir); + expect(result.code).toBe(0); + expect(result.stdout).toContain( + `Target: ${join(home, folder, "skills", "ghost")}`, + ); + }); + + it.each([ + ".claude", + ".cursor", + ])("shares install's detected/default %s destination", async (folder) => { + // An empty home falls back to Claude; a Cursor directory selects Cursor. + if (folder === ".cursor") + await mkdir(join(home, folder), { recursive: true }); + expect((await runCli(["skill", "install"], dir)).code).toBe(0); + const result = await runCli(["skill", "check"], dir); + expect(result.code).toBe(0); + expect(result.stdout).toContain( + `Target: ${join(home, folder, "skills", "ghost")}`, + ); + }); + + it.each([ + ["--force"], + ["--agent", "nope"], + ["--dest", ""], + ])("rejects invalid arguments %j with exit 2 and no writes", async (...args) => { + await install(); + const before = await snapshotFiles(dest); + const result = await runCli([...checkArgs, ...args], dir); + expect(result.code).toBe(2); + expect(result.stderr).toContain(args[0]); + await expect(snapshotFiles(dest)).resolves.toEqual(before); + }); + + it.each([ + "readFile", + "stat", + "readdir", + ] as const)("surfaces %s permission/I/O errors instead of reporting mismatches", async (operation) => { + await install(); + const denial = Object.assign(new Error("permission denied"), { + code: "EACCES", + }); + const mock = vi.mocked({ readFile, stat, readdir }[operation]); + mock.mockRejectedValueOnce(denial); + const result = await runCli(checkArgs, dir); + expect(result.code).toBe(1); + expect(result.stderr).toContain("permission denied"); + expect(result.stdout).not.toContain("Result:"); + }); +}); + +async function snapshotFiles(root: string): Promise> { + const out: Record = {}; + async function walk(dir: string, prefix: string): Promise { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const absolute = join(dir, entry.name); + const path = `${prefix}${entry.name}`; + if (entry.isDirectory()) await walk(absolute, `${path}/`); + else out[path] = await readFile(absolute, "utf-8"); + } + } + await walk(root, ""); + return out; +}