diff --git a/.changeset/shared-cli-manifest-builder.md b/.changeset/shared-cli-manifest-builder.md new file mode 100644 index 00000000..6afdd6d4 --- /dev/null +++ b/.changeset/shared-cli-manifest-builder.md @@ -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`). diff --git a/README.md b/README.md index 04bcca46..65324216 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/apps/docs/src/generated/cli-manifest.json b/apps/docs/src/generated/cli-manifest.json index 1b607ccb..2b20a39d 100644 --- a/apps/docs/src/generated/cli-manifest.json +++ b/apps/docs/src/generated/cli-manifest.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-06-29T14:19:25.804Z", + "generatedAt": "2026-06-29T21:44:31.543Z", "tools": [ { "tool": "ghost", @@ -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 ", + "name": "format", + "description": "Output format: json", + "default": "json", + "takesValue": true, + "negated": false + } + ] + }, { "tool": "ghost", "name": "migrate", diff --git a/packages/ghost/src/cli.ts b/packages/ghost/src/cli.ts index 87df9e08..57517396 100644 --- a/packages/ghost/src/cli.ts +++ b/packages/ghost/src/cli.ts @@ -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, @@ -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 { const cli = cac("ghost"); @@ -33,6 +37,7 @@ export function buildCli(): ReturnType { registerGatherCommand(cli); registerChecksCommand(cli); + registerManifestCommand(cli); registerMigrateCommand(cli); registerSkillCommand(cli); diff --git a/packages/ghost/src/commands/command-discovery.ts b/packages/ghost/src/commands/command-discovery.ts index d07b7222..d33f5190 100644 --- a/packages/ghost/src/commands/command-discovery.ts +++ b/packages/ghost/src/commands/command-discovery.ts @@ -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; @@ -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", diff --git a/packages/ghost/src/commands/manifest-command.ts b/packages/ghost/src/commands/manifest-command.ts new file mode 100644 index 00000000..33825d82 --- /dev/null +++ b/packages/ghost/src/commands/manifest-command.ts @@ -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 ", "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); + } + }); +} diff --git a/packages/ghost/src/scan/node-tree.ts b/packages/ghost/src/scan/node-tree.ts index ab991b92..6d5066b1 100644 --- a/packages/ghost/src/scan/node-tree.ts +++ b/packages/ghost/src/scan/node-tree.ts @@ -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 diff --git a/packages/ghost/src/skill-bundle/SKILL.md b/packages/ghost/src/skill-bundle/SKILL.md index 6111775a..de571994 100644 --- a/packages/ghost/src/skill-bundle/SKILL.md +++ b/packages/ghost/src/skill-bundle/SKILL.md @@ -113,6 +113,7 @@ ref). Inherited nodes are read-only and flow into gather/validate like local one |---|---| | `GHOST_PACKAGE_DIR= ghost init` / `ghost init --package ` | 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 diff --git a/packages/ghost/test/cli.test.ts b/packages/ghost/test/cli.test.ts index 1f85e061..aff7907b 100644 --- a/packages/ghost/test/cli.test.ts +++ b/packages/ghost/test/cli.test.ts @@ -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, @@ -202,6 +162,7 @@ describe("ghost CLI", () => { "signals [path]", "gather", "checks", + "manifest", "migrate", "skill ", "review", @@ -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); @@ -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. diff --git a/scripts/dump-cli-help.mjs b/scripts/dump-cli-help.mjs index 5218ea4a..02a689f5 100644 --- a/scripts/dump-cli-help.mjs +++ b/scripts/dump-cli-help.mjs @@ -31,52 +31,12 @@ for (const tool of TOOLS) { process.exit(1); } const cliModule = await import(pathToFileURL(cliDist).href); - const { buildCli } = cliModule; + const { buildCli, buildCliManifest } = cliModule; const cli = buildCli(); - const discoveryMetadata = - tool.name === "ghost" && - typeof cliModule.getCommandDiscoveryMetadata === "function" - ? new Map( - cliModule - .getCommandDiscoveryMetadata() - .map((entry) => [entry.name, entry]), - ) - : new Map(); - const commands = cli.commands.map((cmd) => { - const discovery = discoveryMetadata.get(cmd.name); - return { - tool: tool.name, - 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 = cli.globalCommand.options.map((o) => ({ - rawName: o.rawName, - name: o.name, - description: o.description, - default: o.config?.default ?? null, - })); - - tools.push({ tool: tool.name, commands, globalOptions }); + // The package exports the manifest builder so the docs snapshot and the + // live terminal help read one shape and can't drift. + tools.push(buildCliManifest(cli, tool.name)); } // Intentionally omits `version`: each CLI reads its version from