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
281 lines (280 loc) • 13 kB
JavaScript
// 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/.
/**
* Single-parse argv normalization (plan §10.7, chunk-9 WI-9.9).
*
* Before this module, ~46 `process.argv` read sites were scattered across
* `src/**` (32 outside `src/cli.ts`), each re-scanning the raw argv array for
* repeated flags (`parseAllFlagValues`), `--`-passthrough tails (`env run`,
* `secret run`), or first-occurrence flag values (`parseFlagValue`). This
* module normalizes argv into one typed {@link ParsedInvocation} object,
* minted ONCE by `src/cli.ts` at startup, so every downstream reader shares
* one parse instead of re-scanning the raw array.
*
* Deliberately import-free: this module must never join an import cycle
* (cycle ratchet, plan §10.7 / chunk-9 D.3), so it depends on nothing but
* global `process` — no `output/context`, no `core/errors`. The flag-parsing
* primitives below are intentionally small, self-contained re-implementations
* of the same algorithms `output/context.ts`'s `parseFlagValue`/
* `hasBooleanFlag` and (the now-retired) `cli/shared.ts` `parseAllFlagValues`
* used, kept byte-identical so every converted call site is behavior-
* preserving — with ONE deliberate exception (R-033a): `getAllFlagValuesFrom`
* now stops at a literal `--` separator instead of scanning past it, fixing a
* bug where a repeatable flag's value placed after `--` (meant to end flag
* parsing) was still read as a real flag occurrence. See that function's
* docstring.
*
* Singleton + fallback semantics (the design decision this module encodes):
* - `setParsedInvocation(argv)` is called exactly once, by `src/cli.ts`, in
* its startup block before any command handler can run. It
* snapshots that argv into an immutable {@link ParsedInvocation} that
* every subsequent `getParsedInvocation()` call returns unchanged for the
* rest of the process lifetime — "normalize argv exactly once at entry".
* - `getParsedInvocation()` is the ONLY way leaf command modules read
* invocation state. When the singleton was never set — the common case
* for unit tests that call command logic directly, and for
* `tests/_helpers/cli.ts`'s in-process CLI harness, which drives citty's
* `runCommand` WITHOUT running `src/cli.ts`'s `import.meta.main` startup
* block (see that file's docstring) — it falls back to parsing the
* CURRENT `process.argv` fresh, on every call, uncached. That harness
* mutates `process.argv` itself per invocation and restores it in
* `finally`, and many suites issue several back-to-back in-process runs
* per test; caching the fallback parse would leak one run's flags into
* the next. So "lazily parses process.argv once" means "once PER CALL",
* not "once for the life of the process" — the fallback is intentionally
* request-scoped, mirroring how every pre-WI-9.9 leaf site read
* `process.argv` fresh at call time. Production code never touches the
* fallback path: the real entry point sets the singleton before any
* command handler can run.
* - `_resetParsedInvocationForTests()` clears an explicitly-set singleton
* (symmetry with `resetOutputMode`/`resetConfigCache` in
* `tests/_helpers/cli.ts`); no suite currently needs it because the
* startup block that calls `setParsedInvocation` never runs under
* `bun test`, but it exists so a test that DOES call
* `setParsedInvocation` directly can clean up after itself.
*
* Also folds three argv re-scanners that pre-dated this module (plan §10.7):
* - `parseAllFlagValues` (moved from `cli/shared.ts`) — repeated-flag
* collection (`--tag foo --tag bar`), re-exported from `cli/shared.ts` so
* every existing importer (feedback-cli, observability-cli, remember-cli,
* search-cli, stash-cli, cli.ts) is unaffected.
* - `findCittyTopLevelCommand`/`findCittyTopLevelCommandIndex` (moved from
* `cli/parse-args.ts`, which had zero internal imports for this cluster) —
* re-exported from `cli/parse-args.ts` for `tests/tasks-embedded.test.ts`.
* - `resolveHelpMigrateVersionArg` (moved from `cli.ts`, where it was
* private) — the `akm help migrate <version>` positional/flag
* disambiguation guard.
*/
// ── Repeated-flag / `--`-passthrough primitives (byte-identical to the
// pre-WI-9.9 per-site implementations they replace) ─────────────────────────
function getFlagValueFrom(argv, flag) {
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--")
break;
if (arg === flag)
return argv[i + 1];
if (arg.startsWith(`${flag}=`))
return arg.slice(flag.length + 1);
}
return undefined;
}
function hasFlagIn(argv, flag) {
for (const arg of argv) {
if (arg === "--")
return false;
if (arg === flag || arg === `${flag}=true`)
return true;
}
return false;
}
/**
* Collect every `--flag value` / `--flag=value` occurrence, stopping at a
* literal `--` separator (R-033a). `--` conventionally ends flag parsing —
* everything after it is positional/passthrough, never a flag akm should
* interpret. Before this fix, a token like `--tag leaked:yes` placed AFTER
* `--` (e.g. `akm feedback <ref> --positive -- --tag leaked:yes`) was still
* picked up as a real `--tag` value, letting argv content past the boundary
* leak into structured output. This is a deliberate BEHAVIOR CHANGE from the
* pre-WI-9.9 per-site implementation this module otherwise keeps
* byte-identical (see the module docstring) — every caller of
* `parseAllFlagValues`/`getAllFlagValues` inherits the fix for free.
*/
function getAllFlagValuesFrom(argv, flag) {
const values = [];
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--")
break;
if (arg === flag && i + 1 < argv.length) {
values.push(argv[i + 1]);
// BUG-M4: skip the value index so `--tag --tag` (literal `--tag` value)
// does not double-count the second `--tag` as a separate flag occurrence.
i++;
}
else if (arg.startsWith(`${flag}=`)) {
values.push(arg.slice(flag.length + 1));
}
}
return values;
}
/**
* Tokens after a literal `--` marker in argv, or `[]` when there is no `--`
* OR nothing follows it. Callers that previously threw a "Missing command"
* UsageError on `dashIndex < 0 || dashIndex === argv.length - 1` now check
* `passthroughArgs().length === 0` — the two conditions are equivalent.
*/
function passthroughArgsFrom(argv) {
const dashIndex = argv.indexOf("--");
if (dashIndex < 0 || dashIndex === argv.length - 1)
return [];
return argv.slice(dashIndex + 1);
}
function createParsedInvocation(argv) {
const snapshot = Object.freeze([...argv]);
const userArgs = Object.freeze(snapshot.slice(2));
return {
argv: snapshot,
userArgs,
getFlagValue: (flag) => getFlagValueFrom(snapshot, flag),
hasFlag: (flag) => hasFlagIn(snapshot, flag),
getAllFlagValues: (flag) => getAllFlagValuesFrom(snapshot, flag),
passthroughArgs: () => passthroughArgsFrom(snapshot),
};
}
let _invocation;
/**
* Mint the process-wide {@link ParsedInvocation} singleton. Called exactly
* once, by `src/cli.ts`, at startup — "normalize argv exactly once at entry"
* (plan §10.7).
*/
export function setParsedInvocation(argv) {
_invocation = createParsedInvocation(argv);
return _invocation;
}
/**
* Read the process-wide {@link ParsedInvocation}. Returns the singleton set
* by `setParsedInvocation` when one exists; otherwise falls back to parsing
* the CURRENT `process.argv` fresh (uncached — see the module docstring for
* why caching the fallback would be wrong). The fallback is what every
* leaf-command unit test and `tests/_helpers/cli.ts`'s in-process harness
* exercise; production CLI runs always hit the singleton.
*/
export function getParsedInvocation() {
if (_invocation)
return _invocation;
return createParsedInvocation(process.argv);
}
/** Test-only: clear an explicitly-set singleton. Symmetry with resetOutputMode/resetConfigCache. */
export function _resetParsedInvocationForTests() {
_invocation = undefined;
}
// ── Folded re-scanner #1: parseAllFlagValues (moved from cli/shared.ts) ─────
/**
* Collect all occurrences of a repeatable flag from the current invocation's
* argv. Citty's StringArgDef only exposes the last value when a flag is
* repeated, so for repeatable CLI args (like `--tag foo --tag bar`) callers
* read argv directly via this helper. Supports both `--flag value` and
* `--flag=value` forms.
*/
export function parseAllFlagValues(flag) {
return getParsedInvocation().getAllFlagValues(flag);
}
/** citty compares `foo-bar` and `fooBar` as the same arg name. */
export function cittyComparableName(name) {
return name.replace(/[-_]+([a-zA-Z0-9])/g, (_match, char) => char.toUpperCase());
}
export function toAliasArray(alias) {
if (Array.isArray(alias))
return alias;
return typeof alias === "string" ? [alias] : [];
}
function isCittyValueFlag(flag, argsDef) {
const name = flag.replace(/^-{1,2}/, "");
const normalized = cittyComparableName(name);
for (const [key, def] of Object.entries(argsDef)) {
if (def.type !== "string" && def.type !== "enum")
continue;
if (normalized === cittyComparableName(key))
return true;
if (toAliasArray(def.alias).includes(name))
return true;
}
return false;
}
/**
* Match citty's top-level subcommand scan (`findSubCommandIndex`).
*
* Citty does not assume `rawArgs[0]` is the command: global string flags may
* appear first and consume the following token. The CLI startup guard uses this
* to classify the requested command before any command handler can run.
*/
export function findCittyTopLevelCommandIndex(rawArgs, argsDef) {
for (let i = 0; i < rawArgs.length; i += 1) {
const arg = rawArgs[i];
if (arg === "--")
return -1;
if (arg.startsWith("-")) {
if (!arg.includes("=") && isCittyValueFlag(arg, argsDef))
i += 1;
continue;
}
return i;
}
return -1;
}
export function findCittyTopLevelCommand(rawArgs, argsDef) {
const index = findCittyTopLevelCommandIndex(rawArgs, argsDef);
return index >= 0 ? rawArgs[index] : undefined;
}
// ── Folded re-scanner #3: resolveHelpMigrateVersionArg (moved from cli.ts,
// where it was a private, unexported function) ──────────────────────────────
/**
* Guard against citty consuming a global flag value as the `help migrate`
* version positional (mirrors `resolveRememberContentArg` /
* `wasFormatValueConsumedAsName` in the read/sources leaf modules).
*
* When the user runs `akm help migrate --format json` without a version
* argument, citty may assign `"json"` to the `version` positional. This
* detects that case and returns `undefined` so the caller surfaces the
* "missing version" UsageError instead of treating a flag value as a version.
*/
export function resolveHelpMigrateVersionArg(version) {
if (version === undefined)
return undefined;
const invocation = getParsedInvocation();
const parsedFormat = invocation.getFlagValue("--format");
if (parsedFormat !== undefined &&
version === parsedFormat &&
wasHelpMigrateFlagValueConsumedAsVersion(version, parsedFormat, "--format")) {
return undefined;
}
const parsedDetail = invocation.getFlagValue("--detail");
if (parsedDetail !== undefined &&
version === parsedDetail &&
wasHelpMigrateFlagValueConsumedAsVersion(version, parsedDetail, "--detail")) {
return undefined;
}
return version;
}
function wasHelpMigrateFlagValueConsumedAsVersion(version, flagValue, flagName) {
const argv = getParsedInvocation().userArgs;
const helpIndex = argv.indexOf("help");
const tokens = helpIndex >= 0 ? argv.slice(helpIndex + 1) : argv;
const migrateIndex = tokens.indexOf("migrate");
const relevant = migrateIndex >= 0 ? tokens.slice(migrateIndex + 1) : tokens;
let flagIndex = -1;
for (let i = 0; i < relevant.length; i += 1) {
const token = relevant[i];
if (token === flagName || token === `${flagName}=${flagValue}`) {
flagIndex = i;
break;
}
}
if (flagIndex === -1)
return false;
if (relevant.slice(0, flagIndex).includes(version))
return false;
return relevant[flagIndex] === flagName ? relevant[flagIndex + 1] === version : true;
}