Skip to content
Draft
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/shared-cli-manifest-builder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@anarchitecture/ghost": minor
---

Add a `ghost manifest --format json` command that emits a self-describing manifest of every command and flag, so a host agent can discover the CLI in one call instead of scraping `--help`. The terminal help, docs-site manifest, and this command all derive from one shared `buildCliManifest()` (also exported from `@anarchitecture/ghost/cli`).
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ of truth; ordinary Git review is the approval boundary for fingerprint edits.
| `ghost review` | Emit an advisory review packet grounded in fingerprint + diff. |
| `ghost skill install` | Install the BYOA skill bundle. |
| `ghost signals` | Emit raw repo signals as authoring evidence _(advanced)_. |
| `ghost manifest` | Emit a self-describing JSON manifest of commands and flags _(advanced)_. |
| `ghost migrate` | Migrate a legacy `.ghost/` package onto the node model _(maintenance)_. |

Run `ghost --help` for the core workflow, `ghost --help --all` for everything,
Expand Down
22 changes: 21 additions & 1 deletion apps/docs/src/generated/cli-manifest.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"generatedAt": "2026-06-29T14:19:25.804Z",
"generatedAt": "2026-06-29T21:44:31.543Z",
"tools": [
{
"tool": "ghost",
Expand Down Expand Up @@ -187,6 +187,26 @@
}
]
},
{
"tool": "ghost",
"name": "manifest",
"rawName": "manifest",
"description": "Emit a self-describing JSON manifest of every command and flag.",
"group": "advanced",
"defaultHelp": false,
"compactName": "manifest",
"summary": "Emit a self-describing JSON manifest of commands and flags.",
"options": [
{
"rawName": "--format <fmt>",
"name": "format",
"description": "Output format: json",
"default": "json",
"takesValue": true,
"negated": false
}
]
},
{
"tool": "ghost",
"name": "migrate",
Expand Down
7 changes: 6 additions & 1 deletion packages/ghost/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { formatGhostHelp } from "./commands/command-discovery.js";
import { failFromError } from "./commands/errors.js";
import { registerFingerprintCommands } from "./commands/fingerprint-commands.js";
import { registerGatherCommand } from "./commands/gather-command.js";
import { registerManifestCommand } from "./commands/manifest-command.js";
import { registerMigrateCommand } from "./commands/migrate-command.js";
import {
buildReviewPacket,
Expand All @@ -24,7 +25,10 @@ import {

const execFileAsync = promisify(execFile);

export { getCommandDiscoveryMetadata } from "./commands/command-discovery.js";
export {
buildCliManifest,
getCommandDiscoveryMetadata,
} from "./commands/command-discovery.js";

export function buildCli(): ReturnType<typeof cac> {
const cli = cac("ghost");
Expand All @@ -33,6 +37,7 @@ export function buildCli(): ReturnType<typeof cac> {

registerGatherCommand(cli);
registerChecksCommand(cli);
registerManifestCommand(cli);
registerMigrateCommand(cli);
registerSkillCommand(cli);

Expand Down
87 changes: 87 additions & 0 deletions packages/ghost/src/commands/command-discovery.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,85 @@
import type { CAC, Command } from "cac";

export type CliManifestOption = {
rawName: string;
name: string;
description: string;
default: unknown;
takesValue: boolean;
negated: boolean;
};

export type CliManifestCommand = {
tool: string;
name: string;
rawName: string;
description: string;
group?: CommandDiscoveryGroup;
defaultHelp?: boolean;
compactName?: string;
summary?: string;
options: CliManifestOption[];
};

export type CliManifestGlobalOption = {
rawName: string;
name: string;
description: string;
default: unknown;
};

export type CliManifestTool = {
tool: string;
commands: CliManifestCommand[];
globalOptions: CliManifestGlobalOption[];
};

/**
* Derive a structured manifest of a built cac CLI: every command, its
* curated discovery metadata, and its flags. The cac registry is the single
* source of truth, so this can never drift from the real command definitions.
*
* Shared by the terminal help formatter's data layer and the docs-site
* manifest dump (`scripts/dump-cli-help.mjs`) so both read one shape.
*/
export function buildCliManifest(cli: CAC, toolName: string): CliManifestTool {
const commands: CliManifestCommand[] = cli.commands.map((cmd) => {
const discovery = metadataFor(cmd);
return {
tool: toolName,
name: cmd.name,
rawName: cmd.rawName,
description: cmd.description,
...(discovery
? {
group: discovery.group,
defaultHelp: discovery.defaultHelp,
compactName: discovery.compactName,
summary: discovery.summary,
}
: {}),
options: cmd.options.map((o) => ({
rawName: o.rawName,
name: o.name,
description: o.description,
default: o.config?.default ?? null,
takesValue: /<[^>]+>/.test(o.rawName),
negated: Boolean(o.negated),
})),
};
});

const globalOptions: CliManifestGlobalOption[] =
cli.globalCommand.options.map((o) => ({
rawName: o.rawName,
name: o.name,
description: o.description,
default: o.config?.default ?? null,
}));

return { tool: toolName, commands, globalOptions };
}

type HelpSection = {
title?: string;
body: string;
Expand Down Expand Up @@ -89,6 +169,13 @@ const COMMAND_DISCOVERY = [
compactName: "signals",
summary: "Emit raw repo signals for fingerprint authoring.",
},
{
name: "manifest",
group: "advanced",
defaultHelp: false,
compactName: "manifest",
summary: "Emit a self-describing JSON manifest of commands and flags.",
},
{
name: "migrate",
group: "maintenance",
Expand Down
36 changes: 36 additions & 0 deletions packages/ghost/src/commands/manifest-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { CAC } from "cac";
import { buildCliManifest } from "./command-discovery.js";
import { failFromError } from "./errors.js";

/**
* Emit a self-describing manifest of the CLI: every command, its curated
* discovery metadata, and its flags. Lets a host agent learn the CLI in one
* call instead of scraping `--help`. Derived from the cac registry, so it can
* never drift from the real command definitions.
*/
export function registerManifestCommand(cli: CAC): void {
cli
.command(
"manifest",
"Emit a self-describing JSON manifest of every command and flag.",
)
.option("--format <fmt>", "Output format: json", { default: "json" })
.action((opts) => {
try {
if (opts.format !== "json") {
console.error("Error: ghost manifest supports only --format json");
process.exit(2);
return;
}
const manifest = {
apiVersion: 1,
type: "manifest" as const,
data: buildCliManifest(cli, cli.name),
};
process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`);
process.exit(0);
} catch (err) {
failFromError(err);
}
});
}
3 changes: 0 additions & 3 deletions packages/ghost/src/scan/node-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@ import { type PlacedNode, parseNode } from "#ghost-core";
import { GHOST_CHECKS_DIRNAME } from "./checks-dir.js";
import { FINGERPRINT_MANIFEST_FILENAME } from "./constants.js";

/** A directory `index.md` denotes the prose for the directory itself. */
const INDEX_FILENAME = "index.md";

/**
* Reserved package-root entries that are never nodes. `checks/` is a reserved
* top-level subtree (the markdown checks that govern surfaces). The manifest is
Expand Down
1 change: 1 addition & 0 deletions packages/ghost/src/skill-bundle/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ ref). Inherited nodes are read-only and flow into gather/validate like local one
|---|---|
| `GHOST_PACKAGE_DIR=<relative-dir> ghost init` / `ghost init --package <dir>` | Create or resolve a custom fingerprint package directory for host wrappers or a monorepo package. |
| `ghost signals [path]` | Emit raw repo signals for fingerprint authoring. |
| `ghost manifest [--format json]` | Emit a self-describing JSON manifest of every command and flag. |
| `ghost migrate [dir]` | Migrate a legacy `.ghost/` package onto the directory-tree node model. |

## Workflows
Expand Down
84 changes: 41 additions & 43 deletions packages/ghost/test/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,11 @@
import { mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { join } from "node:path";
import { Readable } from "node:stream";
import { fileURLToPath } from "node:url";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import { parse as parseYaml } from "yaml";
import { buildCli } from "../src/cli.js";

const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");

const BASE_FINGERPRINT = `---
id: local
source: llm
timestamp: 2026-04-24T00:00:00.000Z
palette:
dominant:
- { role: primary, value: "#111111" }
neutrals: { steps: ["#ffffff", "#111111"], count: 2 }
semantic: []
saturationProfile: muted
contrast: high
spacing: { scale: [4, 8, 16], baseUnit: 4, regularity: 1 }
typography:
families: ["Inter"]
sizeRamp: [12, 16, 24]
weightDistribution: { 400: 1 }
lineHeightPattern: normal
surfaces:
borderRadii: [4, 8]
shadowComplexity: deliberate-none
borderUsage: minimal
---

# Character

Quiet and direct.

# Decisions

### shape-language
Use modest radii.
`;

function fingerprintWithId(id: string): string {
return BASE_FINGERPRINT.replace("id: local", `id: ${id}`);
}

async function runCli(
argv: string[],
cwd: string,
Expand Down Expand Up @@ -202,6 +162,7 @@ describe("ghost CLI", () => {
"signals [path]",
"gather",
"checks",
"manifest",
"migrate",
"skill <action>",
"review",
Expand All @@ -210,6 +171,43 @@ describe("ghost CLI", () => {
}
});

it("emits a self-describing JSON manifest of commands and flags", async () => {
const result = await runCli(["manifest", "--format", "json"], dir);

expect(result.code).toBe(0);
const manifest = JSON.parse(result.stdout);
expect(manifest.apiVersion).toBe(1);
expect(manifest.type).toBe("manifest");
expect(manifest.data.tool).toBe("ghost");

const names = manifest.data.commands.map(
(command: { name: string }) => command.name,
);
expect(names).toContain("gather");
expect(names).toContain("manifest");

const gather = manifest.data.commands.find(
(command: { name: string }) => command.name === "gather",
);
expect(gather.group).toBe("core");
expect(typeof gather.summary).toBe("string");
expect(Array.isArray(gather.options)).toBe(true);

const globalNames = manifest.data.globalOptions.map(
(option: { name: string }) => option.name,
);
expect(globalNames).toContain("help");
});

it("rejects a non-json manifest format with a usage error", async () => {
const result = await runCli(["manifest", "--format", "text"], dir, {
allowNoExit: true,
});

expect(result.code).toBe(2);
expect(result.stderr).toContain("--format json");
});

it("initializes the default fingerprint package without cache", async () => {
const init = await runCli(["init", "--format", "json"], dir);
const scan = await runCli(["scan", "--format", "json"], dir);
Expand Down Expand Up @@ -832,7 +830,7 @@ composition:
);
// Graph slice (Option A, prose nodes): own + cascaded ancestors.
// The root index (`core`) cascades; the marketing index node is own.
expect(byId["core"]).toEqual({ kind: "ancestor", from: "core" });
expect(byId.core).toEqual({ kind: "ancestor", from: "core" });
expect(byId["email/marketing"]).toEqual({ kind: "own" });
// checkout/clarity sits on a sibling surface with no `relates` link in, so
// it is not pulled in.
Expand Down
Loading