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

272 lines (271 loc) • 14.3 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/. /** * Reject flags the resolved command does not declare. * * citty (0.2.x) parses argv with `node:util`'s parseArgs in non-strict mode * (`strict: false`), so an undeclared flag is collected into the parsed object * and silently ignored by the handler. Nothing * downstream noticed, so `akm lint --fail-on-flaged` (one transposed letter in * the flag STABILITY.md documents as a CI contract) parsed fine, exited 0, and * the gate it was meant to enforce never fired. Same for `--limt 3`, `--jsn`, * and every other typo — the command ran with the default instead, and the user * had no signal. * * This walks the same subcommand path citty resolves (via the shared scan * helpers in `./invocation`), unions the arg definitions declared along it, * and fails a flag that matches nothing. The union (rather than the leaf's * args alone) is deliberate: parent-level flags may legally appear before the * subcommand token, and a false positive here would reject a VALID invocation * — much worse than the silence it replaces. In the same spirit, everything * after a literal `--` is passthrough, a declared value flag's value is never * scanned as a flag, `--no-<name>` resolves against `<name>`, and a bare `-` * (stdin convention) and negative numbers are left alone. A subcommand invoked * by a `meta.name` alias rather than its key takes the stand-down path (no * validation) — conservative by design. */ import { UsageError } from "../core/errors.js"; import { warn } from "../core/warn.js"; import { cittyComparableName, findCittyTopLevelCommandIndex, toAliasArray, } from "./invocation.js"; import { retiredFlagHint } from "./retired-commands.js"; /** * Commands that must never refuse on an unrecognized flag — `akm info` warns * and continues instead of exiting 2, the same tolerance a bare `akm help * --whatever` already gets for free from its group-command fallback (no * resolved subcommand, so this gate stands down entirely). `info` is a leaf * command, so it needs an explicit opt-in here instead. */ const UNKNOWN_FLAG_TOLERANT_COMMANDS = new Set(["info"]); /** Flags citty implements itself, which no command declares. */ const IMPLICIT_FLAGS = ["help", "h", "version", "v"]; /** * Retired flags whose commands still diagnose them THEMSELVES, with a message * that names the replacement ("`--scope` was removed, use `--filter`", * "`--source` was renamed to `--generator`"). A generic "unknown flag" would * preempt the better diagnosis, so these are passed through — but ONLY on the * command path that owns the diagnostic, keyed by the resolved path. On every * other command the same spelling is a genuine typo and still fails fast * A retired flag is rejected everywhere else, where silently dropping it could * run a real mutation. * * Shrink-only: when a command drops its bespoke diagnostic, drop the entry and * the generic error takes over. */ const SELF_DIAGNOSED_FLAGS = new Map(Object.entries({ show: ["akmView", "scope"], // removed view grammar; --scope points at --filter index: ["enrich", "re-enrich"], // removed index-time enrichment flags "proposal accept": ["source"], // renamed to --generator "proposal reject": ["source"], // renamed to --generator "proposal drain": ["profile"], // retired, points at --strategy search: ["source"], // renamed to --from curate: ["source"], // renamed to --from "task list": ["source"], // renamed to --from (alias of `search --type task`, #951) remember: ["target"], // renamed to --bundle clone: ["target"], // renamed to --bundle improve: ["auto-accept", "target"], // retired in 0.9.0 / renamed to --bundle "task add": ["target"], // renamed to --bundle "task run": ["target"], // renamed to --bundle "task history": ["target"], // renamed to --bundle "task sync": ["target"], // renamed to --bundle }).map(([path, flags]) => [path, new Set(flags.map(cittyComparableName))])); /** * Union the arg definitions declared along the resolved command path. The * subcommand walk delegates to {@link findCittyTopLevelCommandIndex} — the * same scan `src/cli.ts` resolves commands with — so both agree on which * token is the subcommand name. */ function collectKnownArgs(root, rawArgs) { const names = new Set(IMPLICIT_FLAGS.map(cittyComparableName)); const valueFlags = new Set(); const booleanFlags = new Set(); const displayNames = []; const path = []; let cmd = root; let args = rawArgs; for (;;) { for (const [key, def] of Object.entries(cmd.args ?? {})) { // Positionals are not flags; including them would accept `--<positional>`. if (def.type === "positional") continue; const comparable = cittyComparableName(key); // Shared args (GLOBAL_OUTPUT_ARGS) recur at every level; suggest each once. if (!names.has(comparable)) { names.add(comparable); displayNames.push(`--${key}`); } if (def.type === "string" || def.type === "enum") valueFlags.add(comparable); if (def.type === "boolean") booleanFlags.add(comparable); for (const alias of toAliasArray(def.alias)) { names.add(cittyComparableName(alias)); if (def.type === "string" || def.type === "enum") valueFlags.add(cittyComparableName(alias)); if (def.type === "boolean") booleanFlags.add(cittyComparableName(alias)); } } const subCommands = cmd.subCommands; if (!subCommands || Object.keys(subCommands).length === 0) break; const idx = findCittyTopLevelCommandIndex(args, (cmd.args ?? {})); const token = idx >= 0 ? args[idx] : undefined; // A group with no subcommand token: citty reports "no command specified". if (token === undefined) return { names, valueFlags, booleanFlags, displayNames, path, resolved: false }; const sub = subCommands[token]; // An unrecognized token: citty reports the unknown command, which is the // real problem — its flags are beside the point. if (!sub) return { names, valueFlags, booleanFlags, displayNames, path, resolved: false }; path.push(token); cmd = sub; args = args.slice(idx + 1); } return { names, valueFlags, booleanFlags, displayNames, path, resolved: true }; } /** Single-row-at-a-time edit-distance DP (inputs are short flag/command names). */ function editDistance(a, b) { let previous = Array.from({ length: b.length + 1 }, (_, j) => j); for (let i = 1; i <= a.length; i += 1) { const current = [i]; for (let j = 1; j <= b.length; j += 1) { const cost = a[i - 1] === b[j - 1] ? 0 : 1; current.push(Math.min((previous[j] ?? 0) + 1, (current[j - 1] ?? 0) + 1, (previous[j - 1] ?? 0) + cost)); } previous = current; } return previous[b.length] ?? 0; } /** * Closest candidate within `threshold` edit distance, or undefined when * nothing is close enough to be worth suggesting. Shared by the unknown-flag * and unknown-command (src/cli.ts) did-you-mean paths, which pick different * thresholds. */ export function closestMatch(attempted, candidates, threshold) { let best; let bestDistance = Number.POSITIVE_INFINITY; for (const candidate of candidates) { const distance = editDistance(attempted, candidate); if (distance < bestDistance) { bestDistance = distance; best = candidate; } } return best !== undefined && bestDistance <= threshold ? best : undefined; } /** * The one unknown-flag rejection, shared by the short- and long-flag scans so * the user-facing error contract cannot drift between them. No explicit hint * when there is no suggestion — UNKNOWN_FLAG's canned hint (core/errors.ts) * already says to run the command with --help. * * @param shown The flag as the user typed it (dashes, no `=value`). * @param attempted The spelling to edit-distance against `--long` candidates. */ function throwUnknownFlag(shown, attempted, known) { // Flags the 0.9 overhaul removed outright get their retirement note, not a // did-you-mean — same reasoning as `retiredCommandHint` in src/cli.ts: // edit distance can point at an unrelated survivor rather than the real // replacement procedure (e.g. `index --background` is closer to no other // `index` flag than it is to any useful suggestion). const retired = retiredFlagHint(known.path, attempted); if (retired) { throw new UsageError(`Unknown flag "${shown}".`, "UNKNOWN_FLAG", retired); } const threshold = Math.max(2, Math.ceil(attempted.length / 3)); const suggestion = closestMatch(attempted, known.displayNames, threshold); throw new UsageError(`Unknown flag "${shown}".`, "UNKNOWN_FLAG", suggestion ? `Did you mean \`${suggestion}\`? Run the command with \`--help\` to see its accepted flags.` : undefined); } /** The {@link UNKNOWN_FLAG_TOLERANT_COMMANDS} counterpart to {@link throwUnknownFlag}: report, don't refuse. */ function warnUnknownFlag(shown, known) { warn(`[akm ${known.path.join(" ")}] ignoring unknown flag "${shown}" — run with --help to see its accepted flags.`); } /** * Throw a {@link UsageError} naming the first flag the resolved command does * not declare. Returns silently when every flag is known. */ export function assertKnownFlags(root, rawArgs) { const passthroughAt = rawArgs.indexOf("--"); const ownArgs = passthroughAt === -1 ? rawArgs : rawArgs.slice(0, passthroughAt); const known = collectKnownArgs(root, rawArgs); if (!known.resolved) return; // `workflow run`, `task run`, and `task explain` each own one deliberately // dynamic namespace: long options become exact-name parameter/input flags // and are checked against the frozen plan / task source's own contract // before this gate would otherwise reject them (spec // docs/plans/specs/p2a-task-source-v4.md §5.1 — task run's // UNKNOWN_FLAG/INPUT_BINDING_INVALID must be raised from INSIDE the command // body, where runWithJsonErrors renders the JSON envelope, not from this // generic pre-dispatch gate; docs/plans/specs/p2b-input-bindings.md §4.5, // B-55 — `task explain` reuses the identical `parseTaskInputFlags` // scanner, so it needs the identical exemption). const dynamicNamedFlagCommands = new Set(["workflow run", "task run", "task explain"]); const dynamicWorkflowParams = dynamicNamedFlagCommands.has(known.path.join(" ")); const selfDiagnosed = SELF_DIAGNOSED_FLAGS.get(known.path.join(" ")); const tolerant = UNKNOWN_FLAG_TOLERANT_COMMANDS.has(known.path.join(" ")); for (let i = 0; i < ownArgs.length; i += 1) { const token = ownArgs[i]; // Not a flag: positional, a bare `-` (stdin), or a negative number. if (!token.startsWith("-") || token === "-" || /^-\d/.test(token)) continue; // Node's util.parseArgs, which citty delegates to, treats one-dash tokens // as bundled short flags. Boolean aliases may be combined (`-qy`), while a // string alias consumes the remainder (`-mhello`) or the following token. // It never treats `-auto-fix` as the long `auto-fix` option. if (!token.startsWith("--")) { const shortFlags = token.slice(1); for (let offset = 0; offset < shortFlags.length; offset += 1) { const rawName = shortFlags[offset]; const candidate = cittyComparableName(rawName); if (!known.names.has(candidate)) { if (tolerant) { warnUnknownFlag(token, known); break; } throwUnknownFlag(token, `-${rawName}`, known); } if (known.valueFlags.has(candidate)) { if (offset === shortFlags.length - 1) i += 1; break; } } continue; } const withoutDashes = token.replace(/^-{1,2}/, ""); const [rawName = ""] = withoutDashes.split("=", 1); const hasInlineValue = withoutDashes.includes("="); // `--no-foo` is citty's negation of the BOOLEAN `--foo`. Resolving it // against a value flag would accept `--no-limit`, which citty's own `--no-` // preprocessing force-sets to `limit: false` (no type check, before // parseArgs runs) — a boolean reaching a string parser, i.e. an internal // error (exit 70) instead of the usage error (exit 2) this is here to give. const negated = rawName.startsWith("no-") && known.booleanFlags.has(cittyComparableName(rawName.slice(3))) ? rawName.slice(3) : undefined; const candidates = [cittyComparableName(rawName), ...(negated ? [cittyComparableName(negated)] : [])]; if (selfDiagnosed !== undefined && candidates.some((name) => selfDiagnosed.has(name))) continue; if (!candidates.some((name) => known.names.has(name))) { // `workflow run` owns one deliberately dynamic namespace: long options // become exact-name workflow parameters and are checked against the // frozen plan before a run is inserted. Short flags remain strict. if (dynamicWorkflowParams) continue; if (tolerant) { warnUnknownFlag(token.split("=")[0], known); continue; } throwUnknownFlag(token.split("=")[0], `--${rawName}`, known); } // Skip a declared value flag's value so `--reason "--x"` is not scanned. if (!hasInlineValue && candidates.some((name) => known.valueFlags.has(name))) i += 1; } }