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
1,187 lines • 59.5 kB
JavaScript
#!/usr/bin/env bun
// 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/.
// Runtime guard: the akm-cli npm package bootstraps with Node.js >= 22
// (#465, #560), then its launcher prefers a working Bun >= 1.0 when available.
// The runtime boundary (src/runtime.ts, src/storage/database.ts) supports both.
// Under Node the CLI must be launched via the
// `dist/cli-node.mjs` wrapper, which registers the text-import loader hook
// before this module graph loads; running `node dist/cli.js` directly still
// works for code paths that touch no embedded text asset, but the wrapper is
// the supported entry. The hard floor is Node 22 (Node 20 support dropped 2026-07; `@clack/core` imports
// `node:util`'s `styleText` (added in Node 20.12) — Node 18 (EOL) throws at import.
{
const isBun = typeof globalThis.Bun !== "undefined";
if (!isBun) {
const [major = 0] = (process.versions.node ?? "0").split(".").map((part) => Number.parseInt(part, 10) || 0);
const nodeOk = major >= 22;
if (!nodeOk) {
console.error("\n ERROR: the akm-cli npm package requires Node.js >= 22.\n" +
` Detected Node.js ${process.versions.node ?? "unknown"}.\n` +
" Bun >= 1.0 is optional for execution; it does not replace the Node.js bootstrap.\n" +
" Upgrade Node.js (https://nodejs.org), or install the runtime-free standalone binary:\n" +
" curl -fsSL https://github.com/itlackey/akm/releases/latest/download/install.sh | bash\n");
process.exit(1);
}
}
}
// Global error handlers (#478) — route any async work outside the
// `runWithJsonErrors` envelope through the same JSON shape so users never see
// a raw stack trace. Background timers, fire-and-forget appendEvent writes,
// and lazy `import()` failures are the typical sources. Registered before
// any other top-level work so the startup IIFE banner and the stale-DB
// cleanup are also covered.
process.on("unhandledRejection", (reason) => {
const err = reason instanceof Error ? reason : new Error(String(reason));
console.error(JSON.stringify({
ok: false,
error: `Unhandled rejection: ${err.message}`,
code: "UNHANDLED_REJECTION",
hint: "Re-run with AKM_DEBUG=1 for a stack trace, or report at https://github.com/itlackey/akm/issues with the failing command.",
}, null, 2));
if (process.env.AKM_DEBUG === "1" && err.stack)
console.error(err.stack);
process.exit(EXIT_CODES.INTERNAL);
});
process.on("uncaughtException", (err) => {
console.error(JSON.stringify({
ok: false,
error: `Uncaught exception: ${err.message}`,
code: "UNCAUGHT_EXCEPTION",
hint: "Re-run with AKM_DEBUG=1 for a stack trace, or report at https://github.com/itlackey/akm/issues with the failing command.",
}, null, 2));
if (process.env.AKM_DEBUG === "1" && err.stack)
console.error(err.stack);
process.exit(EXIT_CODES.INTERNAL);
});
import fs from "node:fs";
import { defineCommand, parseArgs, renderUsage, runCommand, showUsage } from "citty";
import { findCittyTopLevelCommandIndex, getParsedInvocation, parseAllFlagValues, resolveHelpMigrateVersionArg, setParsedInvocation, } from "./cli/invocation.js";
import { retiredCommandHint } from "./cli/retired-commands.js";
import { defineGroupCommand, EXIT_CODES, emitJsonError, GLOBAL_OUTPUT_ARGS, output, runWithJsonErrors, } from "./cli/shared.js";
import { assertKnownFlags, closestMatch } from "./cli/unknown-flags.js";
import { agentCommand, lintCommand } from "./commands/agent/contribute-cli.js";
import { commandCommand } from "./commands/command/command-cli.js";
import { generateBashCompletions, installBashCompletions } from "./commands/completions.js";
import { configCommand } from "./commands/config-cli.js";
import { envCommand } from "./commands/env/env-cli.js";
import { secretCommand } from "./commands/env/secret-cli.js";
import { feedbackCommand } from "./commands/feedback-cli.js";
import { akmHealth } from "./commands/health.js";
import "./commands/health/renderers.js";
import "./commands/metrics/md-report.js";
import { parseWindowSpec } from "./commands/health/windows.js";
import { improveCommand } from "./commands/improve/improve-cli.js";
import { metricsCommand } from "./commands/metrics/metrics-cli.js";
import { migrateCommand } from "./commands/migrate-cli.js";
import { modelsCommand } from "./commands/models-cli.js";
import { logCommand } from "./commands/observability-cli.js";
import { proposalCommand } from "./commands/proposal/proposal-cli.js";
import { rememberCommand } from "./commands/read/remember-cli.js";
import { curateCommand, searchCommand, showCommand } from "./commands/read/search-cli.js";
import { registryCommand } from "./commands/registry-cli.js";
import { bundleCommand } from "./commands/sources/bundle-cli.js";
import { renderMigrationHelp } from "./commands/sources/migration-help.js";
import { cloneCommand, syncCommand, upgradeCommand } from "./commands/sources/sources-cli.js";
import { importKnowledgeCommand, indexCommand, infoCommand } from "./commands/sources/stash-cli.js";
import { taskCommand } from "./commands/tasks/tasks-cli.js";
import { workflowCommand } from "./commands/workflow-cli.js";
import { DEFAULT_CONFIG, loadConfig } from "./core/config/config.js";
import { UsageError } from "./core/errors.js";
import { launcherPidFromEnv } from "./core/file-lock.js";
import { startParentDeathWatchdog } from "./core/parent-watchdog.js";
import { getConfigPath } from "./core/paths.js";
import { DURATION_UNITS, parseDuration } from "./core/time.js";
import { plainize } from "./core/tty.js";
import { info, isQuiet, setQuiet, setVerbose, warn } from "./core/warn.js";
import { disposeDispatchResources } from "./integrations/agent/runner-dispatch.js";
import { installLlmUsagePersistenceIfAbsent } from "./llm/usage-persist.js";
import { EMBEDDED_HINTS, EMBEDDED_HINTS_FULL } from "./output/cli-hints.js";
import { getOutputMode, initOutputMode, parseDetailLevel } from "./output/context.js";
import { isFormatExemptCommand } from "./output/format-exempt.js";
import { consumeSchedulerContextArg } from "./tasks/scheduler-invocation.js";
import { pkgVersion } from "./version.js";
function applyEarlyStderrFlags(argv) {
const separator = argv.indexOf("--");
const ownArgv = separator === -1 ? argv : argv.slice(0, separator);
if (ownArgv.includes("--quiet") || ownArgv.includes("-q")) {
setQuiet(true);
}
if (ownArgv.includes("--verbose")) {
setVerbose(true);
}
}
// resolveHelpMigrateVersionArg moved to ./cli/invocation (chunk-9 WI-9.9
// argv-normalization fold — it re-scanned process.argv, same as
// findCittyTopLevelCommand and parseAllFlagValues below).
/**
* Stderr-only human-friendly hint after a non-interactive `setup` invocation.
* Default --format is `json`, so a CI or piped consumer sees only the JSON on
* stdout. But an interactive user running `akm setup --yes` would otherwise
* see only the JSON blob with no obvious next step. When stderr is a TTY and
* the JSON went to stdout, print a two-line summary to stderr telling the
* user (a) where the stash landed and (b) what to run next.
*
* Silent when: stderr is not a TTY (CI, pipes), --format=text/yaml (the user
* already gets readable output), --quiet, or the result is missing fields.
*/
function printSetupTtyHint(result) {
if (!process.stderr.isTTY)
return;
const mode = getOutputMode();
if (mode.format !== "json" && mode.format !== "jsonl")
return;
if (isQuiet())
return;
if (!result?.bundleDir)
return;
console.error(plainize(`\n✓ Bundle created at ${result.bundleDir}\n` +
' Next: `akm bundle add <source>`, `akm index`, `akm search "<query>"`, `akm help agents`'));
}
/**
* Module Naming:
* - sources/* : Asset operations (search, show, add, clone)
* - sources/providers/* : Runtime data source providers (filesystem, git, website, npm)
* - registry/* : Discovery from remote registries (npm, GitHub)
* - installed-stashes : Unified source operations (list, remove, update)
*/
const setupCommand = defineCommand({
meta: {
name: "setup",
description: "Interactive configuration wizard. Configures embeddings/LLM connections (for indexing/enrichment), agent profiles (CLI agent, embedded SDK, or none), sources, and registries. Shows which features are enabled at the end. Use --config <json> or --yes for non-interactive/scripting mode.",
},
args: {
// R-051/S11: `setup` is a raw `defineCommand` (not `defineJsonCommand`),
// so it does not get `GLOBAL_OUTPUT_ARGS` for free — same gap `health`
// already had. Three of its four run branches call `output()`; spreading
// this in is a `--help` visibility fix, not a behavior change.
...GLOBAL_OUTPUT_ARGS,
config: {
type: "string",
description: 'Config JSON to apply non-interactively, e.g. \'{"engines":{"local":{"kind":"llm","endpoint":"...","model":"..."}},"defaults":{"llmEngine":"local"}}\'',
},
from: {
type: "string",
description: "Path to a config file (JSON or YAML) to bootstrap from. Skips prompts for keys present in the file.",
},
yes: {
type: "boolean",
default: false,
description: "Accept all defaults, skip all prompts. Idempotent — safe to run in CI.",
},
dir: {
type: "string",
description: "Bundle directory path (overrides defaultBundle in config or --config JSON)",
},
// Declared as the POSITIVE name with `default: true` so citty's native
// `--no-<name>` negation (it strips a leading `--no-` from ANY token and
// negates the remainder BEFORE consulting the declared-args table) does
// the work, matching the `sync --push/--no-push` pattern. A flag
// DECLARED as `no-init` can never be negated: `--no-init` parses as
// "negate `init`", a name nothing declared, leaving the real key at its
// default forever.
init: {
type: "boolean",
default: true,
description: "Scaffold the bundle directory. Use --no-init to write configuration without scaffolding it.",
},
probe: {
type: "boolean",
default: false,
description: "Probe LLM/embedding endpoints before writing config to verify connectivity",
},
},
async run({ args }) {
await runWithJsonErrors(async () => {
const noInit = !args.init;
if (args.from && args.config) {
throw new UsageError("Pass either --from <file> or --config <json>, not both.", "INVALID_FLAG_VALUE");
}
if (args.from) {
// File-based bootstrap. `loadSetupConfigFromFile` expands a leading
// `~`, resolves relative paths against cwd, picks the YAML or JSON
// parser based on the file extension, and surfaces any
// read/parse/shape errors as ConfigError("INVALID_CONFIG_FILE").
// `runSetupFromConfig` is fully non-interactive; with `--yes` it also
// fills defaults for keys the file leaves missing.
const { loadSetupConfigFromFile, runSetupFromConfig } = await import("./setup/setup.js");
const loaded = await loadSetupConfigFromFile(args.from);
const result = await runSetupFromConfig({
configJson: loaded.configJson,
dir: args.dir,
noInit,
probe: args.probe,
applyDefaults: args.yes,
});
output("setup", result);
printSetupTtyHint(result);
}
else if (args.config) {
// Non-interactive config mode. With `--yes`, defaults fill any keys
// the JSON blob leaves missing after the deep merge.
const { runSetupFromConfig } = await import("./setup/setup.js");
const result = await runSetupFromConfig({
configJson: args.config,
dir: args.dir,
noInit,
probe: args.probe,
applyDefaults: args.yes,
});
output("setup", result);
printSetupTtyHint(result);
}
else if (args.yes) {
// Defaults mode — no prompts
const { runSetupWithDefaults } = await import("./setup/setup.js");
const result = await runSetupWithDefaults({
dir: args.dir,
noInit,
probe: args.probe,
});
output("setup", result);
printSetupTtyHint(result);
}
else {
// Interactive wizard. Guard the TTY first: the wizard's prompts read
// from stdin, so a piped/redirected/CI invocation would render the
// first prompt and then block forever instead of failing — and `akm
// setup` is the first command users automate.
if (process.stdin.isTTY !== true) {
throw new UsageError("Interactive setup requires a TTY. Pass --yes to accept defaults, or --config <json> / --from <file> to configure non-interactively.", "NON_INTERACTIVE_REQUIRES_YES");
}
const { runSetupWizard } = await import("./setup/setup.js");
await runSetupWizard({ dir: args.dir, noInit });
}
});
},
});
const healthCommand = defineCommand({
meta: {
name: "health",
description: "Check akm runtime health, artifacts, and improve metrics",
},
args: {
// R-051: `health` is a raw `defineCommand` (not `defineJsonCommand`), so
// it does not get `GLOBAL_OUTPUT_ARGS` for free. `--format`/`--detail`/
// `--shape`/`--output` already parsed correctly here (this command has
// no positional for a stray value to fall into), so this is purely a
// `--help` visibility / consistency fix, not a behavior change.
...GLOBAL_OUTPUT_ARGS,
since: {
type: "string",
description: "Rolling window start (ISO timestamp, date, epoch ms, or shorthand like 24h / 7d)",
},
"group-by": {
type: "string",
description: "Group rows by: run (one row per improve_runs entry). Omit for the default summary.",
},
"window-compare": {
type: "string",
description: "Compare current window vs prior window of the same duration (e.g. 24h, 7d, 30m)",
},
windows: {
type: "string",
description: "Explicit comparison window 'name=...,since=ISO,until=ISO' (repeatable, up to 4; mutually exclusive with --window-compare)",
},
report: {
type: "boolean",
description: "Fetch the full report dataset: per-run rows, trend deltas vs the prior window, and the pending proposal queue. Renders as the rich report under --format md/html and as complete data under any other format.",
default: false,
},
// #914: citty strips any `--no-X` argument and treats it as negating `X`
// (see `parseArgs` in citty's dist), regardless of whether an arg literally
// named "no-X" is declared — declaring "no-probe" directly would silently
// never populate `args["no-probe"]` and the flag would do nothing. Declare
// the positive flag instead; `--no-probe` is citty's automatic negation of
// it (rendered in `--help` via `negativeDescription`).
probe: {
type: "boolean",
default: true,
description: "Probe default-llm-engine / configured-engines reachability, check for a newer akm release, and check the scheduler's recorded akm binary version (on by default).",
negativeDescription: "Skip the reachability probes, the update check, and the scheduler-binary version check (for an offline or air-gapped host).",
},
},
async run({ args }) {
let resultStatus;
const exitCodeBeforeRun = process.exitCode;
await runWithJsonErrors(async () => {
// citty only surfaces the last value of a repeated flag, so read --windows
// directly from argv to support multi-window comparison.
const rawWindows = parseAllFlagValues("--windows");
const windows = rawWindows.length > 0 ? rawWindows.map((raw) => parseWindowSpec(raw)) : undefined;
const groupBy = args["group-by"];
const report = args.report === true;
// `--report` is a DATA flag: it selects the richer read (per-run rows +
// window-compare deltas + the proposal queue) and nothing about the read
// depends on --format. The registered md/html renderers are pure
// functions of the result — a report-shaped result renders as the rich
// report, any other shape falls through to the generic rendering.
//
// The compare window defaults to the report's own `--since` window so the
// deltas are like-for-like (e.g. last 7d vs the prior 7d). A fixed 24h
// default made a `--since 7d` report compare its 7-day totals against a
// 24-hour prior window, producing meaningless deltas.
// Comparison-window precedence. An explicit `--window-compare` always
// wins. Otherwise `--report` seeds a like-for-like comparison from
// `--since`, but only when `--since` is a DURATION: `resolveWindowCompare`
// parses durations only, so feeding it an absolute date, ISO timestamp, or
// epoch value throws. And explicit `--windows` gets no implicit value at
// all — the two are mutually exclusive, so synthesizing one turned a valid
// invocation into a usage error.
const explicitWindows = windows !== undefined && windows.length > 0;
const sinceIsDuration = args.since !== undefined && parseDuration(args.since, DURATION_UNITS) !== null;
const implicitCompare = explicitWindows ? undefined : ((sinceIsDuration ? args.since : undefined) ?? "24h");
const windowCompare = report ? (args["window-compare"] ?? implicitCompare) : args["window-compare"];
const base = await akmHealth({
since: args.since,
groupBy: report ? "run" : groupBy,
windowCompare,
windows,
probe: args.probe !== false,
});
const reportCompare = windowCompare ??
(explicitWindows
? [...(base.windows ?? [])]
.sort((a, b) => new Date(a.since).getTime() - new Date(b.since).getTime())
.map((window) => window.name)
.join(" → ")
: undefined) ??
"24h";
resultStatus = base.status;
if (report) {
const { listPendingProposals } = await import("./commands/proposal/proposal.js");
const { computeAcceptRateBySource } = await import("./commands/health/accept-rate.js");
output("health", {
...base,
report: {
window: args.since ?? "24h",
compare: reportCompare,
comparisonMode: explicitWindows ? "custom" : "duration",
pendingProposals: listPendingProposals().map(({ ref, source, createdAt }) => ({ ref, source, createdAt })),
acceptRateBySource: computeAcceptRateBySource(),
},
});
return;
}
output("health", base);
});
// R-067: `emitJsonError` (src/cli/shared.ts) no longer force-exits on the
// error path — it sets `process.exitCode` and returns, so a `--report`
// failure thrown AFTER `resultStatus` was already assigned (e.g. the
// proposal-queue read above) would otherwise leave `resultStatus`
// populated here too. Skip the status-derived exit entirely once
// `runWithJsonErrors` has already recorded a classified failure, so it is
// never clobbered by a mismatched health status.
if (process.exitCode !== exitCodeBeforeRun)
return;
if (resultStatus === "fail") {
process.exitCode = EXIT_GENERAL;
}
if (resultStatus === "warn") {
process.exitCode = EXIT_HEALTH_WARN;
}
},
});
function loadAgentHints(full) {
return full ? EMBEDDED_HINTS_FULL : EMBEDDED_HINTS;
}
const hintsCommand = defineCommand({
meta: {
name: "hints",
description: "Print agent instructions on how to use akm — the complete guide by default; pass --detail brief for the short one",
},
args: {
detail: {
type: "string",
description: "Hints detail level (brief|normal|full). `brief` prints the short guide; `normal`/`full` print the complete guide.",
default: "normal",
},
},
run({ args }) {
return runWithJsonErrors(() => {
const detail = parseDetailLevel(args.detail) ?? "normal";
process.stdout.write(loadAgentHints(detail !== "brief"));
});
},
});
const completionsCommand = defineCommand({
meta: {
name: "completions",
description: "Generate or install shell completion script",
},
args: {
install: {
type: "boolean",
description: "Install completions to the appropriate directory",
default: false,
},
shell: {
type: "string",
description: "Shell type (bash)",
default: "bash",
},
},
run({ args }) {
// R-052(b): this was a bare `run()` throwing directly, so an unsupported
// `--shell` value escaped straight to citty's top-level error handling
// instead of the standard JSON envelope — exit 1 with a raw stack trace
// instead of the classified exit-2 usage error every other command
// produces (`completions` is format-exempt, so it stays a raw
// `defineCommand` rather than `defineJsonCommand`, but still needs the
// same error-classification wrapper other bare `defineCommand`s in this
// file use, e.g. `help migrate` below).
return runWithJsonErrors(() => {
if (args.shell !== "bash") {
throw new UsageError(`Unsupported shell: ${args.shell}. Only bash is supported.`);
}
const script = generateBashCompletions(main);
if (args.install) {
const dest = installBashCompletions(script);
info(`Completions installed to ${dest}`);
info(`Restart your shell or run: source ${dest}`);
}
else {
process.stdout.write(script);
}
});
},
});
const commands = {
setup: setupCommand,
index: indexCommand,
health: healthCommand,
metrics: metricsCommand,
info: infoCommand,
bundle: bundleCommand,
upgrade: upgradeCommand,
search: searchCommand,
curate: curateCommand,
show: showCommand,
workflow: workflowCommand,
remember: rememberCommand,
import: importKnowledgeCommand,
sync: syncCommand,
clone: cloneCommand,
registry: registryCommand,
migrate: migrateCommand,
config: configCommand,
feedback: feedbackCommand,
log: logCommand,
agent: agentCommand,
command: commandCommand,
lint: lintCommand,
improve: improveCommand,
proposal: proposalCommand,
completions: completionsCommand,
env: envCommand,
secret: secretCommand,
task: taskCommand,
models: modelsCommand,
hints: hintsCommand,
};
function commandHelpTopic(name, command) {
return defineCommand({
meta: { name, description: `Print help for akm ${name}` },
async run() {
await showUsage(command, buildUsageParentForPath([name]));
},
});
}
// `migrate` is excluded here even though it's a normal, now-visible SYSTEM
// command (see HELP_SECTIONS below): `help`'s own `migrate` subcommand
// (registered below) intentionally shadows this generic command-usage
// renderer with release-notes lookup instead — `akm help migrate 0.9.0`
// prints migration guidance, not `migrate`'s own `--help` usage block. That
// usage block is still reachable directly via `akm migrate --help`.
const commandHelpTopics = Object.fromEntries(Object.entries(commands)
.filter(([name]) => name !== "migrate")
.map(([name, command]) => [name, commandHelpTopic(name, command)]));
const helpCommand = defineGroupCommand({
meta: {
name: "help",
description: "Print the command overview, detailed help for a command, agent instructions, or a release's migration guidance",
},
subCommands: {
...commandHelpTopics,
agents: defineCommand({
meta: {
name: "agents",
description: "Print agent instructions on how to use akm — the short guide by default; pass --full for the complete guide",
},
args: {
full: {
type: "boolean",
default: false,
description: "Print the complete guide instead of the short one.",
},
},
run({ args }) {
return runWithJsonErrors(() => {
process.stdout.write(loadAgentHints(args.full === true));
});
},
}),
migrate: defineCommand({
meta: {
name: "migrate",
description: "Print release notes and migration guidance for a version. Bundled notes live in docs/migration/release-notes/<version>.md; an unknown version lists what's available.",
},
args: {
version: {
type: "positional",
description: "Version to review (for example 0.6.0, v0.6.0, 0.6.0-rc1, or latest)",
required: false,
},
},
run({ args }) {
return runWithJsonErrors(() => {
const version = resolveHelpMigrateVersionArg(typeof args.version === "string" ? args.version : undefined);
if (!version?.trim()) {
throw new UsageError("Usage: akm help migrate <version>.", "MISSING_REQUIRED_ARGUMENT", "Pass a version like `0.6.0`, `v0.6.0`, `0.6.0-rc1`, or `latest`.");
}
process.stdout.write(renderMigrationHelp(version));
});
},
}),
},
async defaultRun() {
process.stdout.write(`${await renderSectionedRootHelp()}\n`);
},
});
// Root `--help` text for the exit-code table. `EXIT_CODES` (src/cli/shared.ts)
// is the single source of the numbers; this only supplies the prose next to
// each one, so there is no third, independently-drifting copy of the table
// (the other copy is errors.ts's doc comment, which documents by reference
// instead of repeating the numbers).
const EXIT_CODE_DESCRIPTIONS = [
[EXIT_CODES.SUCCESS, "success"],
[EXIT_CODES.GENERAL, "not found / command-reported failure"],
[EXIT_CODES.USAGE, "usage error"],
[EXIT_CODES.HEALTH_WARN, "health warn (akm health only)"],
[EXIT_CODES.INTERNAL, "internal / unclassified error"],
[
EXIT_CODES.TEMPFAIL,
"transient (retry shortly — another akm process holds a lock or is writing state.db or index.db)",
],
[EXIT_CODES.CONFIG, "config error"],
];
function renderExitCodesHelp() {
return EXIT_CODE_DESCRIPTIONS.map(([code, description]) => ` ${String(code).padEnd(4)}${description}`).join("\n");
}
export const main = defineCommand({
meta: {
name: "akm",
version: pkgVersion,
description: "Agent Knowledge Manager — search, show, and manage assets from your bundle.\n\n" +
`Exit codes:\n${renderExitCodesHelp()}`,
},
args: {
// Single-sourced from GLOBAL_OUTPUT_ARGS (src/cli/shared.ts) so root help
// and leaf help state identical text. format/detail get their `default`
// added here only — the description text itself is never redeclared.
...GLOBAL_OUTPUT_ARGS,
format: { ...GLOBAL_OUTPUT_ARGS.format, default: "json" },
detail: { ...GLOBAL_OUTPUT_ARGS.detail, default: "brief" },
},
subCommands: {
...commands,
help: helpCommand,
},
});
const MAIN_TOP_LEVEL_ARGS = main.args;
/**
* F-5 (spec docs/plans/specs/p2a-task-source-v4.md §6): exported so
* `akm task run`'s input-flags coverage can pin that an undeclared,
* per-task dynamic flag (e.g. `--scope all`) does not change this
* classification — it parses only `args.id` off the declared args and
* tolerates extra tokens (citty's non-strict `parseArgs`).
*/
export function isTaskRunWithId(argv) {
const args = argv.slice(2);
const commandIndex = findCittyTopLevelCommandIndex(args, MAIN_TOP_LEVEL_ARGS);
const command = commandIndex >= 0 ? args[commandIndex] : undefined;
if (command !== "task")
return false;
const taskArgs = args.slice(commandIndex + 1);
if (taskArgs[0] !== "run")
return false;
const runCommand = taskCommand.subCommands?.run;
if (!runCommand?.args)
return false;
try {
const parsed = parseArgs(taskArgs.slice(1), runCommand.args);
return typeof parsed.id === "string" && parsed.id.length > 0;
}
catch {
return false;
}
}
/** Recovery/setup surfaces must remain reachable when config.json is invalid. */
export function shouldBypassConfigStartup(argv) {
const userArgs = argv.slice(2);
const separator = userArgs.indexOf("--");
const args = separator === -1 ? userArgs : userArgs.slice(0, separator);
if (args.includes("--help") || args.includes("-h") || args.includes("--version") || args.includes("-v"))
return true;
// Bare `akm` (no subcommand at all) renders the same sectioned root-help
// text as `akm help` — never touches config either.
if (args.length === 0)
return true;
const commandIndex = findCittyTopLevelCommandIndex(args, MAIN_TOP_LEVEL_ARGS);
const command = commandIndex >= 0 ? args[commandIndex] : undefined;
if (command === "setup" || command === "migrate")
return true;
// `help` and `hints` are pure-text surfaces (bundled release notes, static
// agent-guide text, command usage rendered from `meta` alone) — none of
// their run bodies read config. The documented recovery path
// (`akm help migrate 0.9.0`) is only ever run against a config that
// startup's own load would reject, so both must bypass it the same way
// `setup`/`migrate` do, or the recovery instructions are themselves
// unreachable.
if (command === "help" || command === "hints")
return true;
if (isTaskRunWithId(argv))
return true;
if (command !== "config")
return false;
const configIndex = args.indexOf("config");
const subcommand = args.slice(configIndex + 1).find((arg) => !arg.startsWith("-"));
return subcommand === "path";
}
/**
* Whether `argv` resolves to the top-level `info` command — used by
* `runCli` (and mirrored in `tests/_helpers/cli.ts`) to scope the startup
* config read's best-effort fallback to `info` alone. `info` is NOT on
* {@link shouldBypassConfigStartup}'s allowlist: unlike a bare bypass, it
* still reads a valid config's `output.format`/`output.detail` like any
* other command, it just must not be aborted by one it cannot read (see
* `assembleInfo`'s doc comment, src/commands/sources/info.ts, for why).
* Every other command reads config exactly as before — a broken config
* throws here and the command never runs.
*/
export function isInfoCommand(argv) {
const userArgs = argv.slice(2);
const separator = userArgs.indexOf("--");
const args = separator === -1 ? userArgs : userArgs.slice(0, separator);
const commandIndex = findCittyTopLevelCommandIndex(args, MAIN_TOP_LEVEL_ARGS);
return (commandIndex >= 0 ? args[commandIndex] : undefined) === "info";
}
// ── Exit codes ──────────────────────────────────────────────────────────────
// Canonical table lives in `src/cli/shared.ts` (EXIT_CODES). These aliases keep
// the local call sites terse. EXIT_HEALTH_WARN (4) is the `akm health` "warn"
// status — advisories fired but no hard failure; chosen to avoid colliding with
// GENERAL (1) and USAGE (2). CI monitors can map: 0=pass, 4=warn, 1=fail.
const EXIT_GENERAL = EXIT_CODES.GENERAL;
const EXIT_HEALTH_WARN = EXIT_CODES.HEALTH_WARN;
// ── Top-level driver (replaces citty's `runMain`) ───────────────────────────
//
// R-032: citty's own `runMain` catches EVERY error escaping `runCommand` —
// including its unexported `CLIError`, thrown for "Unknown command …", "No
// command specified.", "Missing required argument/positional …", and invalid
// enum values — and unconditionally calls `process.exit(1)`, regardless of
// error kind (node_modules/citty/dist/index.mjs). That collapsed usage
// mistakes (`akm totally-bogus`, `akm wiki list`, bare `akm log`) onto exit
// code 1 instead of the documented usage-error code 2 (STABILITY.md's
// exit-code table), and there is no way to override it from outside
// `runMain`'s own call frame: once it calls `process.exit`, nothing run
// afterward — including a `finally` further up the stack — gets a chance to
// execute. So the CLI drives citty's exported `runCommand` directly instead
// of `runMain`, replicating `runMain`'s `--help` / `--version`
// short-circuits and its CLIError → usage-banner rendering, but classifying
// a CLIError as USAGE (2) instead of GENERAL (1). Every other error escaping
// this boundary keeps the previous GENERAL (1) mapping — this only
// reclassifies the one error family citty itself throws before any of our
// own command bodies (and their `runWithJsonErrors` / `emitJsonError`
// classification) ever run.
const HELP_FLAGS = ["--help", "-h"];
const VERSION_FLAGS = ["--version", "-v"];
/**
* Duck-types citty's internal, unexported `CLIError`
* (node_modules/citty/dist/index.mjs) — the class `runCommand` throws for
* "Unknown command …", "No command specified.", "Missing required
* argument/positional …", and invalid enum values. citty does not export
* this class, so `instanceof` isn't available; `name` is set in its
* constructor (`this.name = "CLIError"`) and is stable across the pinned
* `citty@^0.2.2` dependency.
*/
function isCittyCliError(error) {
return error instanceof Error && error.name === "CLIError";
}
function findCittySubCommandByName(subCommands, name) {
if (name in subCommands)
return subCommands[name];
for (const sub of Object.values(subCommands)) {
const alias = sub.meta?.alias;
const aliases = Array.isArray(alias) ? alias : alias ? [alias] : [];
if (aliases.includes(name))
return sub;
}
return undefined;
}
/**
* Re-implementation of citty's own (unexported) `resolveSubCommand`: walks
* `rawArgs` down the subcommand tree the same way its private
* `findSubCommandIndex` / `_findSubCommand` do, so an explicit `--help`
* renders the deepest command the user was actually invoking, matching what
* citty's own `runMain` would have shown.
*/
function resolveDeepestCittyCommand(cmd, rawArgs) {
const subCommands = cmd.subCommands;
if (subCommands && Object.keys(subCommands).length > 0) {
const idx = findCittyTopLevelCommandIndex(rawArgs, (cmd.args ?? {}));
const name = idx >= 0 ? rawArgs[idx] : undefined;
if (name !== undefined) {
const sub = findCittySubCommandByName(subCommands, name);
if (sub)
return resolveDeepestCittyCommand(sub, rawArgs.slice(idx + 1));
}
}
return cmd;
}
function resolveCittyCommandPath(cmd, rawArgs, path = []) {
const subCommands = cmd.subCommands;
if (!subCommands || Object.keys(subCommands).length === 0)
return [...path];
const index = findCittyTopLevelCommandIndex(rawArgs, (cmd.args ?? {}));
const token = index >= 0 ? rawArgs[index] : undefined;
if (token === undefined)
return [...path];
const sub = findCittySubCommandByName(subCommands, token);
if (!sub)
return [...path];
const name = Object.entries(subCommands).find(([, candidate]) => candidate === sub)?.[0] ?? token;
return resolveCittyCommandPath(sub, rawArgs.slice(index + 1), [...path, name]);
}
/**
* `showUsage`/`renderUsage` (citty) render a subcommand's own USAGE line as
* `${parentMeta.name} ${cmdMeta.name}` using only the DIRECT parent — for a
* two-deep command (`akm task run`) that renders `task run`, silently
* dropping the `akm ` root prefix every top-level command's `--help` already
* shows. Building a synthetic parent whose `meta.name` is the full prefix
* (`akm task`) fixes it for any depth without patching the vendored
* dependency (S11 item 4).
*/
function buildUsageParentForPath(path) {
// `version` carries through too — citty's renderUsage falls back to
// `parentMeta.version` when the resolved command itself declares none
// (true of every subcommand here), and the real parent it substitutes for
// always resolves to `main`, which does declare one.
return {
meta: {
name: ["akm", ...path.slice(0, -1)].join(" "),
version: pkgVersion,
},
};
}
// ── Sectioned root help (S11) ────────────────────────────────────────────────
//
// citty's own generated COMMANDS list is a flat, unordered dump of every
// top-level command — fine for a handful of commands, not for the ~28 this
// CLI has grown to. Groups them instead under fixed sections mirroring
// how they're actually used, reusing citty's own `renderUsage` for the
// banner/USAGE/OPTIONS portion (so it stays byte-identical to every
// subcommand's own `--help`) and replacing only the COMMANDS section.
const HELP_SECTIONS = [
{
title: "AGENT LOOP",
commands: ["curate", "search", "show", "feedback", "remember"],
},
{
title: "ASSETS",
commands: ["import", "clone", "bundle", "env", "secret", "sync", "proposal"],
},
{ title: "AUTOMATION", commands: ["improve", "agent", "command", "workflow", "task"] },
{
title: "SYSTEM",
commands: [
"setup",
"index",
"lint",
"health",
"metrics",
"config",
"models",
"registry",
"info",
"log",
"migrate",
"help",
"hints",
"upgrade",
"completions",
],
},
];
/** Abbreviations whose trailing period does not end a sentence. */
const NON_TERMINAL_ABBREVIATIONS = ["e.g.", "i.e.", "etc.", "vs."];
/**
* First sentence of a description, for the root command list.
*
* Several commands carry multi-paragraph descriptions that are correct on
* `akm <cmd> --help` but turn the root listing back into the undifferentiated
* wall this section replaced (a single command's description ran past 1k
* characters on one row).
*/
function firstSentence(text) {
const line = text.split("\n", 1)[0]?.trim() ?? "";
for (let i = 0; i < line.length; i++) {
if (line[i] !== "." && line[i] !== "!" && line[i] !== "?")
continue;
const candidate = line.slice(0, i + 1);
const next = line[i + 1];
if (next !== undefined && next !== " ")
continue;
if (NON_TERMINAL_ABBREVIATIONS.some((abbr) => candidate.toLowerCase().endsWith(abbr)))
continue;
return candidate;
}
return line;
}
function topLevelCommandDescription(name) {
const sub = main.subCommands?.[name];
return firstSentence(sub?.meta?.description ?? "");
}
function formatCommandRows(names) {
const width = Math.max(...names.map((name) => name.length));
return names.map((name) => ` ${name.padEnd(width)} ${topLevelCommandDescription(name)}`).join("\n");
}
function renderCommandSections() {
return HELP_SECTIONS.map(({ title, commands }) => `${title}\n${formatCommandRows(commands)}`).join("\n\n");
}
/**
* Sectioned replacement for citty's root COMMANDS list (S11 item 1). Reuses
* `renderUsage(main)` for the banner + exit-code table (kept verbatim) +
* USAGE line + global OPTIONS, then cuts the string before citty's own
* "COMMANDS" heading (present because `main` has `subCommands`) and appends
* the grouped sections plus a one-line bundle/ref-grammar tagline and the
* `akm help agents` pointer.
*/
async function renderSectionedRootHelp() {
const base = await renderUsage(main, undefined);
const commandsHeadingIndex = base.indexOf("COMMANDS");
// Cut at the START of the heading's own line, not the word itself — the
// word is preceded by citty's own ANSI bold/underline escape codes, and
// slicing mid-line would leave a dangling open escape sequence.
const cutIndex = commandsHeadingIndex === -1 ? -1 : base.lastIndexOf("\n", commandsHeadingIndex);
const head = (cutIndex === -1 ? base : base.slice(0, cutIndex)).replace(/\n+$/, "");
const epilogue = [
'A "bundle" is your managed directory of assets (skills, agents, memories, workflows, ...). Refs use the ' +
"grammar [bundle//]conceptId[#fragment], where the concept id is the bundle's own path for the asset — " +
"e.g. `akm show skills/deploy`, or `akm show work//skills/deploy` for a named bundle. Copy ids from " +
"`akm search` output rather than synthesizing them.",
"",
"Run `akm help <command>` or `akm <command> --help` for details on any command.",
"Agents: run `akm hints` for the complete guide or `akm help agents` for the short guide.",
].join("\n");
return [head, "", renderCommandSections(), "", epilogue].join("\n");
}
/**
* Walk down the subcommand tree the same way {@link resolveDeepestCittyCommand}
* does, but stop and report the first token that fails to resolve instead of
* silently giving up — the attempted spelling plus its sibling candidate set,
* for the did-you-mean suggestion below. Returns undefined when every token
* resolved (e.g. "no command specified", or a missing positional/flag on an
* otherwise-valid command) — there is no unknown NAME to suggest a fix for.
*/
function findUnknownCommandAttempt(rawArgs) {
let cmd = main;
let args = rawArgs;
const parentPath = [];
for (;;) {
const subCommands = cmd.subCommands;
if (!subCommands || Object.keys(subCommands).length === 0)
return undefined;
const idx = findCittyTopLevelCommandIndex(args, (cmd.args ?? {}));
const token = idx >= 0 ? args[idx] : undefined;
if (token === undefined)
return undefined;
const sub = findCittySubCommandByName(subCommands, token);
if (!sub)
return {
attempted: token,
candidates: Object.keys(subCommands),
parentPath,
};
parentPath.push(token);
cmd = sub;
args = args.slice(idx + 1);
}
}
/** Closest command within a length-scaled distance threshold (shared DP in cli/unknown-flags.ts). */
function closestCommandMatch(attempted, candidates) {
return closestMatch(attempted, candidates, Math.max(2, Math.ceil(attempted.length / 2)));
}
const CLI_HELP_POINTER = "Run `akm --help` for usage.";
/**
* citty's `CLIError` carries its own stable `.code` (not exported in its
* types, but present on every instance — see {@link isCittyCliError}'s doc
* comment): `E_UNKNOWN_COMMAND` for an unrecognized command/subcommand name,
* `EARG`/`E_NO_COMMAND` for a missing required argument/positional or a bare
* group with no `run`. Maps that to one of this CLI's own stable
* `UsageErrorCode`s so the reclassified error (below) carries a real code
* instead of the generic fallback.
*/
function cittyCliErrorUsageCode(error) {
switch (error.code) {
case "E_UNKNOWN_COMMAND":
return "UNKNOWN_COMMAND";
case "EARG":
case "E_NO_COMMAND":
return "MISSING_REQUIRED_ARGUMENT";
default:
return "INVALID_FLAG_VALUE";
}
}
/**
* Route a citty `CLIError` through the standard JSON envelope instead of
* citty's own usage-banner + raw `console.error(message)` (S11 item 3): the
* one-line diagnosis is the error's own message (e.g. "Unknown command
* foo"), and the hint is a short pointer — with a did-you-mean suggestion
* prepended when the unresolved token is close to a real sibling command —
* rather than the full ~46KB usage dump citty would otherwise print.
*/
function toUsageErrorFromCliError(error, rawArgs) {
const code = cittyCliErrorUsageCode(error);
const attempt = code === "UNKNOWN_COMMAND" ? findUnknownCommandAttempt(rawArgs) : undefined;
// Retired spellings from the 0.9 hard break get their replacement, not a
// did-you-mean: edit distance suggests the WRONG command for most of them
// (`init`→`info`, `update`→`upgrade`), and agents follow suggestions.
const retired = attempt ? retiredCommandHint(attempt.parentPath, attempt.attempted) : undefined;
const suggestion = retired === undefined && attempt ? closestCommandMatch(attempt.attempted, attempt.candidates) : undefined;
const hint = retired ?? (suggestion ? `Did you mean \`${suggestion}\`? ${CLI_HELP_POINTER}` : CLI_HELP_POINTER);
// citty colorizes error.message with ANSI escapes even when stdout/stderr
// is not a TTY. Strip them so the JSON envelope's `error` field is plain
// text instead of embedding raw escape sequences.
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — strip ANSI escape codes
const message = error.message.replace(/\x1b\[[0-9;]*m/g, "");
return new UsageError(message, code, hint);
}
/** Normalize citty's private CLIError for the real entrypoint and in-process test harness. */
export function normalizeCittyCliError(error, rawArgs) {
return isCittyCliError(error) ? toUsageErrorFromCliError(error, rawArgs) : error;
}
/**
* The CLI's real startup sequence, extracted into a function so error paths
* can `return` early — top-level `return` is a syntax error in an ES module,
* and this used to rely on `emitJsonError`'s `never` return type (a
* synchronous `process.exit`) to stop execution instead. Now that
* `emitJsonError` (src/cli/shared.ts, R-067) only records `process.exitCode`
* and returns, every direct call site here needs its own explicit `return;`
* to stop the rest of startup from running after a fatal early error.
*/
export async function runCli() {
try {
process.argv = consumeSchedulerContextArg(process.argv);
}
catch (error) {
emitJsonError(error);
return;
}
// Mint the ParsedInvocation singleton from the (normalized) argv — the ONE
// place argv is parsed for the whole process (plan §10.7 / chunk-9 WI-9.9).
// Every out-of-cli.ts command module reads argv state through
// `getParsedInvocation()` from here on instead of re-scanning process.argv.
setParsedInvocation(process.argv);
// Resolve output mode once at startup from the (normalized) argv and persisted
// config. All subsequent output() calls read from this in-memory singleton.
// `initOutputMode` can throw a UsageError when --format/--detail values are
// invalid; surface it through the same JSON-error path the rest of the CLI uses
// rather than letting the raw exception escape with a stack trace.
try {
applyEarlyStderrFlags(process.argv);
const bypassConfig = shouldBypassConfigStartup(process.argv);
// Off the bypass allowlist, every command reads config here exactly as
// it always has: an invalid config.json throws, `emitJsonError` reports
// it, and the command never runs — no side effect of its own body ever
// happens (a lock taken, a network call made, a database opened
// read-write). `akm info` is the ONE exception (see `assembleInfo`'s
// doc comment, src/commands/sources/info.ts): only ITS read is
// best-effort, falling back to `DEFAULT_CONFIG.output` instead of
// throwing. Scoped narrowly on purpose — an earlier version of this fix
// made the read best-effort for every command, which silently changed
// outcomes across the CLI (some commands that should refuse at exit 78
// ran anyway; `health`/`index`/`config set`/`feedback` still failed,
// but only after already taking a lock, opening a database read-write,
// or making a network call).
let outputDefaults = DEFAULT_CONFIG.output ?? {};
if (!bypassConfig) {
if (isInfoCommand(process.argv)) {
try {
outputDefaults = loadConfig().output ?? {};
}
catch {
outputDefaults = DEFAULT_CONFIG.output ?? {};
}
}
else {
outputDefaults = loadConfig().output ?? {};
}
}
initOutputMode(process.argv, outputDefaults);
}
catch (error) {
emitJsonError(error);
return;
}
const commandPath = resolveCittyCommandPath(main, process.argv.slice(2));
// D7 — every command that renders through output() honours all six --format
// values. The declared exempt set (src/output/format-exempt.ts) does not
// render an envelope at all, so warn rather than pretend: silently ignoring
// the flag is what made the old md/html behaviour so hard to discover. A
// warning, not an error, because the flag is harmless here and scripts that
// pass --format globally to a mixed batch of commands should still work.
const invocation = getParsedInvocation();
if ((invocation.hasFlag("--format") || invocation.getFlagValue("--format") !== undefined) &&
isFormatExemptCommand(commandPath)) {
warn(`[output] '--format' has no effect on 'akm ${commandPath.join(" ")}' — its output is not a result envelope.`);
}
// First-time-user breadcrumb: when run with no subcommand AND no config
// exists yet AND stderr is a TTY, print a friendly pointer to `akm setup`
// above citty's auto-generated usage block. Triggers only when stdin/stderr
// are interactive (so JSON-output users / CI consumers see nothing extra)
// and stays silent for any flag-only invocation citty would handle itself
// (--help, --version).
(function maybePrintFirstTimeBanner() {
const argv = process.argv.slice(2);
// Fire only on completely bare `akm` invocation. Any explicit flag or
// subcommand means the user knows what they want.
if (argv.length > 0)
return;
if (!process.stderr.isTTY)
return;
try {
if (fs.existsSync(getConfigPath()))
return;
}
catch {
// If we can't resolve the config path, assume non-fresh and stay silent.
return;
}
console.error(plainize("👋 First time with akm? Run `akm setup` to get started.\n Docs: https://github.com/itlackey/akm#readme\n"));
})();
const rawArgs = process.argv.slice(2);
// Persist LLM usage for every command, not only `improve` and `proposal
// drain`. A per-run sink installed inside a command layers over this one and
// restores it on dispose.
const disposeLlmUsageSink = installLlmUsagePersistenceIfAbsent();
try {
if (rawArgs.length === 0) {
process.stdout.write(`${await renderSectionedRootHelp()}\n`);
return;
}
// Mirrors citty's own builtin-flag short-circuit in `runMain` (main's own
// args never declare `help`/`h`/`version`/`v`, so both stay the fixed
// defaults citty would have computed too).
//
// Scan only akm's OWN arguments: everything after a literal `--` belongs to
// the child process (`akm env run <ref> -- tool --help`, `akm secret run
// <ref> -- tool -h`). Scanning the tail printed akm's usage and returned
// without ever launching the requested command.
const passthroughAt = rawArgs.indexOf("--");
const ownArgs = passthroughAt === -1 ? rawArgs : rawArgs.slice(0, passthroughAt);
if (HELP_FLAGS.some((flag) => ownArgs.includes(flag))) {
const resolved = resolveDeepestCittyCommand(main, rawArgs);
if (resolved === main) {
// Root `--help` (S11 item 1): the sectioned overview, not citty's
// flat COMMANDS dump.
process.stdout.write(`${await renderSectionedRootHelp()}\n`);
}
else {
const path = resolveCittyCommandPath(main, rawArgs);
await showUsage(resolved, buildUsageParentForPath(path));
}
return;
}
if (rawArgs.length === 1 && VERSION_FLAGS.includes(rawArgs[0])) {
console.log(pkgVersion);
return;
}
// citty's parseArgs (strict: false) silently ignores undeclared flags, so
// a typo used to run the command with defaults and exit 0. Checked here, after --help and
// --version, so those keep working on any command.
assertKnownFlags(main, rawArgs);
await runCommand(main, { rawArgs });
}
catch (error) {
if (isCittyCliError(error)) {
// R-032/S11: reclassify citty's own "unknown command" / "no command
// specified" / "missing required argument" family as USAGE (2) and
// route it through the SAME JSON envelope every other command's
// failure uses (`emitJsonError`), rather than citty's own usage-banner
// + raw `console.error(message)` — a one-line diagnosis plus a short
// hint (with a did-you-mean suggestion when applicable), not a ~46KB
// usage dump.
emitJsonError(normalizeCittyCliError(error, rawArgs));
return;
}
// Anything else escaping here is a genuinely unexpected failure outside
// any command's own error handling — every command wraps its body in
// `runWithJsonErrors`, `defineJsonCommand`, or `defineGroupCommand`, all
// three of which route thrown errors through `emitJsonError` before they
// could ever reach this boundary. Route it the same way rather than
// hard-coding GENERAL(1): the CLI contract reserves 1 for general/not-found
// and requires a non-`AkmError` throw to render the JSON failure envelope
// with INTERNAL(70) (AGENTS.md "CLI Contract"), which is what lets
// automation tell an internal defect apart from an ordinary failure.
// `emitJsonError` classifies and sets `process.exitCode` itself.
emitJsonError(error);
}
finally {
disposeLlmUsageSink();
await disposeDispatchResources();
}
}
// Only run the CLI when this module is the direct entry point. When it is
// imported (e.g. by the in-process test harness in tests/_helpers/cli.ts),
// `import.meta.main` is false and we skip all startup side effects (argv
// mutation, output-mode init, index cleanup, banner, command dispatch) so
// importers can drive the `main` command themselves without the process
// exiting.
//
// Node path: this module carries a `#!/usr/bin/env bun` shebang and is launched
// under Node via the `dist/cli-node.mjs` wrapper, which `import()`s this file
// (so `import.meta.main` is false here even though the CLI is the real entry).
// The wrapper sets `AKM_NODE_ENTRY=1` to opt into the startup block. Compiled
// standalone binaries are the same shape: their entry is
// `scripts/akm-standalone.ts` (which also embeds the akm-migrate tool), and it
// sets `AKM_STANDALONE_ENTRY=1` before importing this file. The test harness
// sets neither, so importing cli.ts under Bun stays inert as before.
if (import.meta.main || process.env.AKM_NODE_ENTRY === "1" || process.env.AKM_STANDALONE_ENTRY === "1") {
// #956: a launcher that dies WITHOUT delivering a signal
// (SIGKILL, an OOM kill, a supervisor that force-removes the process)
// still reparents this process to init with nothing to catch — the field
// evidence was 40 orphaned `bun …/dist/cli.js` processes in one day, some
// from a curate hook killing its own launcher on timeout. Active for
// EVERY command (not only `index`), since a hook-invoked `curate`/`search`
// child is orphaned the same way. Inert when there is no launcher
// (AKM_LAUNCHER_PID unset — a direct `bun src/cli.ts` run, or any test
// harness that imports this module without setting it): only a real
// launcher-managed run has a parent worth watching.
const parentWatchdog = launcherPidFromEnv()
? startParentDeathWatchdog({
initialPpid: process.ppid,
onOrphaned: () => {
// Self-delivers the same signal a real SIGTERM would be. `akm
// index`'s own AbortController listens for it
// (commands/sources/stash-cli.ts) and gets a graceful, in-process
// shutdown. Every other command has no SIGTERM listener of its
// own, so this self-signal terminates it directly via the
// runtime's default disposition WITHOUT running `exit` handlers
// (lock release included) — it stops the orphaned process, which
// is the goal here, but any lock it held is left for the next
// acquirer's dead-pid stale-reclaim (file-lock.ts) to clear, not
// released in-process. No second, parallel abort path to keep in
// sync either way.
process.kill(process.pid, "SIGTERM");
},
})
: undefined;
try {
await runCli();
}
finally {
parentWatchdog?.stop();
}
}