Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/check-installed-skill.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -65,6 +70,7 @@ ghost pull <ids> # 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
```

Expand Down
6 changes: 6 additions & 0 deletions packages/ghost/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -48,6 +53,7 @@ ghost pull <ids> # 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
```

Expand Down
4 changes: 2 additions & 2 deletions packages/ghost/src/commands/command-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
108 changes: 108 additions & 0 deletions packages/ghost/src/commands/skill-check.ts
Original file line number Diff line number Diff line change
@@ -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<SkillCheckResult> {
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<string[]> {
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<void> {
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");
}
45 changes: 36 additions & 9 deletions packages/ghost/src/commands/skill-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -24,31 +25,58 @@ type SupportedAgent = (typeof SUPPORTED_AGENTS)[number];

export function registerSkillCommand(cli: CAC): void {
cli
.command("skill <action>", "Install the unified ghost skill bundle.")
.command(
"skill <action>",
"Install or check the unified ghost skill bundle.",
)
.option(
"--dest <path>",
"Install destination (default: detected agent skills directory + /ghost)",
"Install/check destination (default: detected agent skills directory + /ghost)",
)
.option(
"--agent <name>",
"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 <path> or omit it to use the agent destination.",
);
}
const agent = parseAgent(opts.agent);
const outDir = resolve(
process.cwd(),
typeof opts.dest === "string"
? 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(
Expand All @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions packages/ghost/src/skill-bundle/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,11 @@ Copy the `.ghost/` directory, run `ghost validate --package <dir>`, then run
`ghost skill install` in the receiving workspace. From there, gather and pull
against that package with `--package <dir>`.

After upgrading the CLI, use `ghost skill check --agent <name>` or
`ghost skill check --dest <path>` 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
Expand Down
5 changes: 5 additions & 0 deletions packages/ghost/src/skill-bundle/references/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading