UNPKG

akm-cli

Version:

akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur

346 lines (345 loc) • 17.1 kB
// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. /** * Shared CLI utilities extracted from `src/cli.ts` so that individual * command modules can import them without a circular dependency. * * Exported: output, runWithJsonErrors, parseAllFlagValues, emitJsonError */ import { defineCommand } from "citty"; import { stringify as yamlStringify } from "yaml"; import { renderHealthHtml } from "../commands/health/renderers.js"; import { assertNever } from "../core/assert.js"; import { AkmError, UsageError } from "../core/errors.js"; import { getOutputMode } from "../output/context.js"; import { renderGenericHtml, renderGenericMarkdown, renderGenericText } from "../output/generic-render.js"; import { deliverRendered } from "../output/html-render.js"; import { getMdRendererHandler } from "../output/render-registry.js"; import { shapeForCommand } from "../output/shapes.js"; import { formatPlain, outputJsonl } from "../output/text.js"; import { parseAllFlagValues } from "./invocation.js"; import { hasSubcommand } from "./parse-args.js"; export { parseAllFlagValues }; // ── Exit codes ─────────────────────────────────────────────────────────────── /** * Canonical process exit-code table for the akm CLI. Single source of truth — * referenced by `classifyExitCode` here and re-imported by `src/cli.ts` so the * health-warn / general-failure paths stay in sync. * * 0 success * 1 general / not-found * 2 usage error * 4 health warn (health command only) * 70 internal / unclassified (sysexits EX_SOFTWARE — akm threw unexpectedly) * 75 transient (sysexits EX_TEMPFAIL — retry shortly; another akm process * holds a lock or is writing state.db right now, not a bad command line) * 78 config error */ export const EXIT_CODES = { SUCCESS: 0, GENERAL: 1, USAGE: 2, HEALTH_WARN: 4, // sysexits.h EX_SOFTWARE. Distinct from GENERAL(1) so scripts can tell an // expected "not found" outcome from akm itself throwing an unexpected error. INTERNAL: 70, // sysexits.h EX_TEMPFAIL (#948 addendum). Distinct from USAGE(2): a // scheduler or cron wrapper classifies 2 as "fix the command line", but a // TransientError means "try again in a few seconds" — a different retry // contract callers can branch on. TEMPFAIL: 75, CONFIG: 78, }; // ── Helpers ────────────────────────────────────────────────────────────────── /** * Map a thrown value to a process exit code. * * Known, classified errors (instances of `AkmError`) are dispatched through an * exhaustive switch on the `kind` discriminant — `assertNever` makes a missing * case a compile-time error, so adding a new error class can't silently inherit * the wrong code. Anything that is NOT an `AkmError` is treated as a genuinely * unexpected internal failure and maps to INTERNAL(70) rather than GENERAL(1), * so callers can distinguish "akm threw" from a normal not-found outcome. */ function classifyExitCode(error) { if (!(error instanceof AkmError)) return EXIT_CODES.INTERNAL; switch (error.kind) { case "usage": return EXIT_CODES.USAGE; case "config": return EXIT_CODES.CONFIG; case "not-found": return EXIT_CODES.GENERAL; case "transient": return EXIT_CODES.TEMPFAIL; default: return assertNever(error.kind, "classifyExitCode"); } } function extractHint(error) { if (error instanceof Error && "hint" in error && typeof error.hint === "function") { return error.hint(); } return undefined; } /** * Serialize an error to the standard JSON envelope and record the mapped * exit code. Used in both the startup try/catch and `runWithJsonErrors`. * * R-067: this used to call `process.exit(exitCode)` directly, which * terminates the process synchronously and skips every pending `finally` * block up the call stack — including `src/cli.ts`'s own * `disposeDispatchResources()` cleanup and citty's per-command `cleanup` * hooks. `process.exitCode = exitCode; return;` is equivalent for every * caller here: Node/Bun exits with that code once the event loop drains * naturally, but cleanup on the way there still runs. * `src/commands/improve/extract-cli.ts` already uses this exact pattern for * its own non-throw failure signal. * * Because this no longer throws or exits, it no longer terminates control * flow on its own — every call site MUST treat it like a normal return and * stop doing further work itself (an explicit `return;` right after the * call, same as any other caller of a fallible function). `runWithJsonErrors` * below satisfies this for free (this call is its catch block's last * statement); `src/cli.ts`'s three direct call sites add the `return;` * explicitly. */ export function emitJsonError(error) { const message = error instanceof Error ? error.message : String(error); const hint = extractHint(error); const exitCode = classifyExitCode(error); // Classified akm errors carry a stable machine-readable `code`; unexpected // internal errors have none. const code = error instanceof AkmError ? error.code : undefined; console.error(JSON.stringify({ ok: false, error: message, ...(code ? { code } : {}), hint }, null, 2)); process.exitCode = exitCode; } /** * Run an async function and route any thrown error through the standard JSON * error envelope so users never see a raw stack trace. */ export async function runWithJsonErrors(fn) { try { await fn(); } catch (error) { emitJsonError(error); } } /** * The global output flags, redeclared on every leaf command. * * citty parses each command level against only that command's own args, so a * flag declared on the root command is UNKNOWN at the leaf — and an unknown * flag does not consume its space-separated value, which then falls through as * a positional. `akm sync --format json` once synced a bundle named "json", * and `akm env unset env:x KEY --format json` once tried to unset a key named * "json"; both grew bespoke argv-inspection workarounds. Declaring the flags * at the leaf lets the parser consume the value, which is the root-cause fix. * * These declarations exist for PARSING only. The output mode is still read * exactly once, from the invocation singleton at startup — no command body may * read these args (that is the same one-parse rule `cli/invocation.ts` * documents). * * These are also the canonical descriptions for the root command's own args * (`main.args` in src/cli.ts) — the root spreads this same object rather than * redeclaring the text, so root help and leaf help can never drift apart. * `format`/`detail` need per-site `default` overrides (root has one, leaves * don't — see the one-parse rule above), so those two keys get shallow * overrides at the root; the description text itself is never duplicated. */ export const GLOBAL_OUTPUT_ARGS = { format: { type: "string", description: "Output format: json|jsonl|yaml|text|md|html (global flag)" }, detail: { type: "string", description: "Detail level (verbosity): brief|normal|full (global flag).", }, // R-050(c): single-sourced with the root command's own `--shape` help // (`main.args.shape` in src/cli.ts, which spreads this object) so the // caveat is visible from every leaf's own `--help`, not only the top-level // one. `summary` outside `show` falls back to `agent` with a warning // (shapeForCommand in src/output/shapes.ts) — `--shape` is a global flag, // so a script that passes it to a mixed batch of commands still works. shape: { type: "string", description: "Output projection: human|agent|summary (global flag). 'agent' trims to agent-essential fields; " + "'summary' only has a dedicated projection on 'akm show' — elsewhere it falls back to 'agent' with a " + "warning. Default: human.", }, output: { type: "string", description: "Write rendered output to a file instead of stdout (all formats except jsonl) (global flag)", }, // S11: surfaced at every leaf (not just the root) so `akm <command> --help` // documents them too — they already apply globally, parsed from raw argv // by `applyEarlyStderrFlags` in src/cli.ts before citty ever sees them, so // declaring them here is documentation, not new parsing behavior. A // command with its own same-named arg (e.g. `env path --quiet`) wins the // merge in `defineJsonCommand` as usual. quiet: { type: "boolean", alias: "q", description: "Suppress non-essential stderr output (banners, spinners, progress info) (global flag). " + "Safety-critical output is never suppressed: errors and destructive-action confirmation prompts " + "always appear regardless of --quiet.", default: false, }, verbose: { type: "boolean", description: "Print per-spec diagnostics to stderr (global flag; also honours AKM_VERBOSE env var).", default: false, }, }; /** * Define a citty command whose `run` body is automatically wrapped in * `runWithJsonErrors`, so the handler emits a byte-identical JSON error * envelope (stdout/stderr/exit-code) on throw without the boilerplate. A * command without a `run` (a pure subcommand group) is passed through * unchanged. * * Every command defined here also accepts the {@link GLOBAL_OUTPUT_ARGS} so * their values are parsed rather than mis-captured as positionals; a command * declaring its own arg of the same name wins (e.g. `env path` has its own * `quiet`). */ export function defineJsonCommand(def) { const { run, ...rest } = def; const withGlobals = { ...rest, args: { ...GLOBAL_OUTPUT_ARGS, ...rest.args } }; if (!run) return defineCommand(withGlobals); return defineCommand({ ...withGlobals, run: (context) => runWithJsonErrors(() => run(context)), }); } /** * Canonical bare-group behavior (0.9.0 breaking change, owner ruling 12). * * Before 0.9.0 the twelve `akm <group>` command groups did three different * things when invoked with no subcommand: some printed help and exited 1, * some ran an implicit default action and exited 0 (e.g. bare `akm graph` * silently rendering `graph summary`), and one already raised a structured * usage error and exited 2. None of that is discoverable from the exit code * alone, and a script that greps stdout for a specific default action broke * silently the moment someone reordered subcommands. * * The canonical choice, applied uniformly: a bare group invocation is a * USAGE ERROR — exit 2, the same structured JSON envelope every other usage * mistake in this CLI produces (not citty's raw help banner, and not a * silent default action). This matches STABILITY.md's documented exit-code * table (2 = usage) and the exit code the CLI already used for "unknown * command" / "missing required argument" as of the companion 0.9.0 fix. * * `defaultRun` is now OPTIONAL for exactly this reason: omitting it opts a * group into the shared, canonical error below. Passing an explicit * `defaultRun` is a deliberate opt-out and should not be added to new groups * without a documented reason — see CHANGELOG for the migration note. */ function bareGroupUsageError(meta, subcommandSet) { const name = typeof meta === "object" && meta !== null && "name" in meta && typeof meta.name === "string" ? meta.name : undefined; const usage = name ? `\`akm ${name}\`` : "This command"; const subcommands = [...subcommandSet].sort().join(", "); throw new UsageError(`${usage} requires a subcommand. Available: ${subcommands}.`, "MISSING_REQUIRED_ARGUMENT", `Run \`akm ${name ?? "<command>"} --help\` to see usage for each subcommand.`); } /** * Define a citty subcommand-group command (env, secret, proposal, tasks, wiki, * …) that shares one wiring shape: a `subCommands` map, a routing set * DERIVED from that map's keys (so the set can never silently desync from the * registered subcommands), and a default body that fires ONLY for the bare * group invocation — citty still runs the group body after dispatching a * subcommand, so the shared guard short-circuits when `args._[0]` names a * registered subcommand. * * The `defaultRun` body is wrapped in `runWithJsonErrors`, so it emits a * byte-identical JSON error envelope on throw — exactly the per-site * `run() { return runWithJsonErrors(() => { if (hasSubcommand(...)) return; … }); }` * boilerplate this replaces. */ export function defineGroupCommand(def) { const subcommandSet = new Set(Object.keys(def.subCommands)); const defaultRun = def.defaultRun ?? (() => bareGroupUsageError(def.meta, subcommandSet)); return defineCommand({ meta: def.meta, ...(def.args ? { args: def.args } : {}), subCommands: def.subCommands, run: (context) => runWithJsonErrors(() => { if (hasSubcommand(context.args, subcommandSet)) return; return defaultRun(context); }), }); } /** * Render a command result according to the active output mode * (json/jsonl/yaml/text/md/html). When `--output <path>` is set, the rendered * document is written to that file instead of stdout (jsonl excepted — it is * a line-streaming protocol and always goes to stdout). */ /** * Emit a result whose success is graded by an exit code. `ok` on the envelope * and `process.exitCode` are set from the same value here, so the two cannot * disagree (#918); `exitCode` undefined or 0 means success. */ export function outputWithExitCode(command, result, exitCode) { const failed = exitCode !== undefined && exitCode !== EXIT_CODES.SUCCESS; output(command, { ...result, ok: !failed }); if (failed) process.exitCode = exitCode; } export function output(command, result) { const mode = getOutputMode(); const shaped = shapeForCommand(command, result, mode.detail, mode.shape); if (mode.format === "jsonl") { outputJsonl(command, shaped); return; } switch (mode.format) { case "json": deliverRendered(JSON.stringify(shaped, null, 2), mode.outputPath); return; case "yaml": deliverRendered(yamlStringify(shaped), mode.outputPath); return; case "text": { // D7 — registry first, generic rendering of the shaped envelope second. // Mirrors the md/html fallback immediately below: a command with no // registered text formatter used to fall through to // `JSON.stringify(shaped, null, 2)`, i.e. silently hand back JSON while // claiming `--format text` — the same "wrong format wearing the right // flag" bug D7 already closed for md/html. `renderGenericText` (a // DISTINCT function from `renderGenericMarkdown` — see its doc comment // in src/output/generic-render.ts for why reusing the md renderer here // was itself a version of the same bug) renders flat `key=value` text // matching the house style already established by registered text // formatters like `config list`. const plain = formatPlain(command, shaped, mode.detail); deliverRendered(plain ?? renderGenericText(command, shaped), mode.outputPath); return; } case "md": { // D7 — registry first, generic rendering of the shaped envelope second. // No command emits JSON under `--format md` any more: silently handing // back the wrong format was the worst of the three behaviours this // replaced. const rendered = getMdRendererHandler(command)?.(shaped, mode.detail); deliverRendered(rendered ?? renderGenericMarkdown(command, shaped), mode.outputPath); return; } case "html": { // `akm health` is the only command with a bespoke HTML report, so it is // called directly rather than through a registry with one possible // registrant (see `output/render-registry.ts`). const rendered = command === "health" ? renderHealthHtml(shaped) : null; deliverRendered(rendered ?? renderGenericHtml(command, shaped), mode.outputPath); return; } } } // parseAllFlagValues moved to ./invocation (chunk-9 WI-9.9 argv-normalization // fold); re-exported above so every existing importer of this module is // unaffected.