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
8 changes: 8 additions & 0 deletions .changeset/exit-code-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@anarchitecture/ghost": patch
---

Make CLI exit codes consistent so an agent can branch on them: unexpected
errors exit `1`, caller mistakes (bad flags, invalid environment, refused
overwrites) exit `2` via a typed `UsageError`, and a missing package now exits
`2` with a `ghost init` hint instead of leaking a raw filesystem error.
15 changes: 15 additions & 0 deletions apps/docs/src/content/docs/cli-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,21 @@ The canonical fingerprint is a `.ghost/` directory tree of prose nodes:
The command tables below are generated from the CLI source. Run
`pnpm dump:cli-help` after command or flag changes.

#### Exit codes

Every command follows one contract, so an agent can branch on the exit code:

| Code | Meaning |
| --- | --- |
| `0` | Success. |
| `1` | The command ran but the result is unhappy: `validate` found issues, or an unexpected error was thrown. |
| `2` | The command was called wrong: a bad flag or argument, a node or surface that is not in the package (`ERR_UNKNOWN_SURFACE`), or a missing package for a command that needs one to do its work (`gather`, `checks`, `review`). |
| `3` | A command-specific refusal — currently only `skill install` when a skill is already present (pass `--force`). |

Reporting commands treat a missing package as a state to report, not a usage
error: `validate` records it as a finding (exit `1`), and `scan`/`signals`
report what they can (exit `0`).

</DocSection>

<DocSection title="Create And Inspect">
Expand Down
13 changes: 6 additions & 7 deletions packages/ghost/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { cac } from "cac";
import { UsageError } from "#ghost-core";
import { registerChecksCommand } from "./commands/checks-command.js";
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 { registerMigrateCommand } from "./commands/migrate-command.js";
Expand Down Expand Up @@ -99,10 +101,7 @@ export function buildCli(): ReturnType<typeof cac> {
process.exit(2);
return;
}
console.error(
`Error: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(2);
failFromError(err);
}
});

Expand Down Expand Up @@ -135,14 +134,14 @@ function parsePositiveIntegerOption(
): number | undefined {
if (value === undefined) return undefined;
if (typeof value !== "string" && typeof value !== "number") {
throw new Error(`${flagName} must be a positive integer`);
throw new UsageError(`${flagName} must be a positive integer`);
}
if (typeof value === "string" && value.trim() === "") {
throw new Error(`${flagName} must be a positive integer`);
throw new UsageError(`${flagName} must be a positive integer`);
}
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < 1) {
throw new Error(`${flagName} must be a positive integer`);
throw new UsageError(`${flagName} must be a positive integer`);
}
return parsed;
}
Expand Down
6 changes: 2 additions & 4 deletions packages/ghost/src/commands/checks-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { resolveFingerprintPackage } from "../fingerprint.js";
import { loadChecksDir } from "../scan/checks-dir.js";
import { loadFingerprintPackage } from "../scan/fingerprint-package.js";
import { failFromError } from "./errors.js";
import { guardSurfaces } from "./surface-guard.js";

function parseSurfaceIds(value: unknown): string[] {
Expand Down Expand Up @@ -108,10 +109,7 @@ export function registerChecksCommand(cli: CAC): void {
}
process.exit(0);
} catch (err) {
console.error(
`Error: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
failFromError(err);
}
});
}
Expand Down
22 changes: 22 additions & 0 deletions packages/ghost/src/commands/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { EXIT } from "#ghost-core";

/**
* Report a thrown error and exit. A `UsageError` (or anything carrying a numeric
* `exitCode`) exits with that code; everything else is an unexpected crash and
* exits `1`. Pass `stream` to match a command's existing output channel.
*/
export function failFromError(
err: unknown,
stream: "stderr" | "stdout" = "stderr",
): never {
const message = err instanceof Error ? err.message : String(err);
const line = `Error: ${message}\n`;
if (stream === "stdout") process.stdout.write(line);
else process.stderr.write(line);

const exitCode =
typeof (err as { exitCode?: unknown })?.exitCode === "number"
? (err as { exitCode: number }).exitCode
: EXIT.failure;
process.exit(exitCode);
}
16 changes: 4 additions & 12 deletions packages/ghost/src/commands/fingerprint-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "../fingerprint.js";
import { detectFileKind, lintDetectedFileKind } from "../scan/file-kind.js";
import { resolveGhostDirDefault, scanStatus, signals } from "../scan/index.js";
import { failFromError } from "./errors.js";
import { registerInitCommand } from "./init-command.js";

/**
Expand Down Expand Up @@ -50,10 +51,7 @@ export function registerFingerprintCommands(cli: CAC): void {

process.exit(report.errors > 0 ? 1 : 0);
} catch (err) {
console.error(
`Error: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(2);
failFromError(err);
}
});

Expand Down Expand Up @@ -114,10 +112,7 @@ export function registerFingerprintCommands(cli: CAC): void {
}
process.exit(0);
} catch (err) {
console.error(
`Error: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(2);
failFromError(err);
}
});

Expand All @@ -134,10 +129,7 @@ export function registerFingerprintCommands(cli: CAC): void {
process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
process.exit(0);
} catch (err) {
process.stderr.write(
`Error: ${err instanceof Error ? err.message : String(err)}\n`,
);
process.exit(2);
failFromError(err);
}
});
}
Expand Down
6 changes: 2 additions & 4 deletions packages/ghost/src/commands/gather-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "#ghost-core";
import { resolveFingerprintPackage } from "../fingerprint.js";
import { loadFingerprintPackage } from "../scan/fingerprint-package.js";
import { failFromError } from "./errors.js";

export function registerGatherCommand(cli: CAC): void {
cli
Expand Down Expand Up @@ -100,10 +101,7 @@ export function registerGatherCommand(cli: CAC): void {
}
process.exit(0);
} catch (err) {
console.error(
`Error: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
failFromError(err);
}
});
}
Expand Down
6 changes: 2 additions & 4 deletions packages/ghost/src/commands/init-command.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { CAC } from "cac";
import { initFingerprintPackage } from "../fingerprint.js";
import { resolveGhostDirDefault } from "../scan/index.js";
import { failFromError } from "./errors.js";

export function registerInitCommand(cli: CAC): void {
cli
Expand Down Expand Up @@ -53,10 +54,7 @@ export function registerInitCommand(cli: CAC): void {
}
process.exit(0);
} catch (err) {
console.error(
`Error: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(2);
failFromError(err);
}
});
}
Expand Down
9 changes: 4 additions & 5 deletions packages/ghost/src/commands/migrate-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import type { CAC } from "cac";
import { parse as parseYaml } from "yaml";
import { UsageError } from "#ghost-core";
import { resolveFingerprintPackage } from "../fingerprint.js";
import {
looksLegacy,
Expand All @@ -10,6 +11,7 @@ import {
migratedNodeFiles,
migrateLegacyPackage,
} from "../scan/index.js";
import { failFromError } from "./errors.js";

export function registerMigrateCommand(cli: CAC): void {
cli
Expand Down Expand Up @@ -68,10 +70,7 @@ export function registerMigrateCommand(cli: CAC): void {
);
process.exit(0);
} catch (err) {
console.error(
`Error: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
failFromError(err);
}
});
}
Expand Down Expand Up @@ -121,7 +120,7 @@ async function writeMigrated(
flag: force ? "w" : "wx",
}).catch((err: unknown) => {
if (!force && isExisting(err)) {
throw new Error(
throw new UsageError(
`Refusing to overwrite ${path}. Pass --force to rewrite the package in place.`,
);
}
Expand Down
3 changes: 2 additions & 1 deletion packages/ghost/src/commands/review-packet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type RoutedCheck,
resolveGraphSlice,
selectChecksForSurfaces,
UsageError,
} from "#ghost-core";
import { loadChecksDir } from "../scan/checks-dir.js";
import {
Expand Down Expand Up @@ -90,7 +91,7 @@ function budgetDiff(
maxDiffBytes = DEFAULT_REVIEW_MAX_DIFF_BYTES,
): { diff: string; budgets: ReviewPacketBudgets; truncated: boolean } {
if (!Number.isSafeInteger(maxDiffBytes) || maxDiffBytes < 1) {
throw new Error("--max-diff-bytes must be a positive integer");
throw new UsageError("--max-diff-bytes must be a positive integer");
}
const bytes = Buffer.byteLength(diffText, "utf-8");
if (bytes <= maxDiffBytes) {
Expand Down
12 changes: 6 additions & 6 deletions packages/ghost/src/commands/skill-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ 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 { failFromError } from "./errors.js";

// The bundle assets are copied to `dist/skill-bundle` (sibling of `commands/`).
const SKILL_BUNDLE_ROOT = fileURLToPath(
Expand Down Expand Up @@ -65,10 +66,7 @@ export function registerSkillCommand(cli: CAC): void {
for (const file of written) process.stdout.write(` ${file}\n`);
process.exit(0);
} catch (err) {
console.error(
`Error: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(2);
failFromError(err);
}
});
}
Expand All @@ -81,7 +79,9 @@ function parseAgent(raw: unknown): SupportedAgent | undefined {
) {
return raw as SupportedAgent;
}
throw new Error(`--agent must be one of: ${SUPPORTED_AGENTS.join(", ")}`);
throw new UsageError(
`--agent must be one of: ${SUPPORTED_AGENTS.join(", ")}`,
);
}

function detectAgent(): SupportedAgent {
Expand Down
33 changes: 33 additions & 0 deletions packages/ghost/src/ghost-core/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* The CLI exit-code contract, in one place so every command agrees and an agent
* can branch on the code:
*
* - `0` success
* - `1` ran, but unhappy: a lint/validate finding, or an unexpected error
* - `2` called wrong: a bad flag or argument, a missing package, an unknown
* node or surface
* - `3` a command-specific refusal (e.g. `skill install` without `--force`)
*
* Usage errors (`2`) are often surfaced by throwing from deep in a helper
* (path validation, byte budgets, overwrite guards). Throwing `UsageError`
* instead of a plain `Error` lets the shared catch tell those apart from a
* genuine crash, so the contract holds without each call site picking a code.
*/
export const EXIT = {
ok: 0,
failure: 1,
usage: 2,
} as const;

/**
* A caller-facing "you used it wrong" error: bad flag or argument, missing
* package, invalid environment, or a refused overwrite. Reported at exit code
* 2, distinct from an unexpected crash (1).
*/
export class UsageError extends Error {
readonly exitCode = EXIT.usage;
constructor(message: string) {
super(message);
this.name = "UsageError";
}
}
2 changes: 2 additions & 0 deletions packages/ghost/src/ghost-core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export {
type RoutedCheck,
selectChecksForSurfaces,
} from "./check/index.js";
// --- CLI exit-code contract ---
export { EXIT, UsageError } from "./errors.js";
// --- Fingerprint package filenames ---
// --- Graph (in-memory fingerprint node graph) ---
export {
Expand Down
14 changes: 13 additions & 1 deletion packages/ghost/src/scan/fingerprint-package-layers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
GhostFingerprintPackageManifestSchema,
type GhostGraphNode,
lintGraph,
UsageError,
} from "#ghost-core";
import { isMissingPathError } from "../internal/fs.js";
import {
Expand All @@ -22,7 +23,18 @@ const LEGACY_FACET_FILES = ["intent.yml", "inventory.yml", "composition.yml"];
export async function loadFingerprintPackage(
paths: FingerprintPackagePaths,
): Promise<LoadedFingerprintPackage> {
const manifestRaw = await readFile(paths.manifest, "utf-8");
let manifestRaw: string;
try {
manifestRaw = await readFile(paths.manifest, "utf-8");
} catch (err) {
// A missing package is a usage error (run `ghost init`), not a crash.
if (isMissingPathError(err)) {
throw new UsageError(
`No Ghost fingerprint package found at ${paths.packageDir} (expected manifest.yml). Run \`ghost init\` or pass --package <dir>.`,
);
}
throw err;
}
const manifest = parseManifest(manifestRaw, "manifest.yml");

// Legacy facet packages no longer load directly — guide to `ghost migrate`.
Expand Down
7 changes: 4 additions & 3 deletions packages/ghost/src/scan/fingerprint-package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type GhostFingerprintPackageManifest,
type GhostGraph,
lintGraph,
UsageError,
} from "#ghost-core";
import { isExistingPathError, isMissingPathError } from "../internal/fs.js";
import {
Expand Down Expand Up @@ -79,7 +80,7 @@ export async function initFingerprintPackage(
const templateName = options.template ?? DEFAULT_TEMPLATE_NAME;
const template = getInitTemplate(templateName);
if (!template) {
throw new Error(
throw new UsageError(
`Unknown init template '${templateName}'. Available: ${listInitTemplates().join(", ")}.`,
);
}
Expand Down Expand Up @@ -120,7 +121,7 @@ async function writeInitFile(
});
} catch (err) {
if (!force && isExistingPathError(err)) {
throw new Error(
throw new UsageError(
`Refusing to overwrite existing Ghost fingerprint file:\n ${path}\nPass --force to overwrite.`,
);
}
Expand All @@ -141,7 +142,7 @@ async function assertInitDoesNotOverwrite(paths: string[]): Promise<void> {
}
if (existing.length > 0) {
const formatted = existing.map((path) => ` ${path}`).join("\n");
throw new Error(
throw new UsageError(
`Refusing to overwrite existing Ghost fingerprint file(s):\n${formatted}\nPass --force to overwrite.`,
);
}
Expand Down
Loading