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

313 lines (312 loc) • 17.2 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/. /** * Stash-lifecycle command cluster — the index/ingest/inspect verbs for * the working stash and its index database: `akm index` (build/refresh the * search index), `akm import` (ingest a knowledge doc/URL), and `akm info` * (system capabilities + index stats). * Extracted verbatim from src/cli.ts (WS6) so the God Module shrinks; the * `main.subCommands.{index,import,info}` keys and every subcommand's * args/output shape stay byte-identical. * * 0.9 CLI overhaul (S7): `init` moved out of this cluster into the new * `akm bundle create` (src/commands/sources/bundle-cli.ts) — no top-level * `init` remains. * * These share no private helper with any command still inline in cli.ts — every * dependency is already exported from a shared module (core/paths, core/warn, * core/errors, core/events, output/context, cli/shared, cli/parse-args, plus the * per-command implementations in ./init, ./indexer, ./info, ./knowledge, * ./core/asset-create, ./core/common), so the cluster moves with zero hoisting. * * The leaf handlers whose body is a plain `runWithJsonErrors(...) + output(...)` * (`import`, `info`) are migrated onto * `defineJsonCommand`, which emits the same JSON envelope (stdout/stderr/ * exit-code) as the inline form. `index` keeps a plain `defineCommand` wrapping * `runWithJsonErrors` because its body owns a spinner, an AbortController, and * SIGINT/SIGTERM handlers in a try/finally — left byte-for-byte untouched. */ import path from "node:path"; import { defineCommand } from "citty"; import * as p from "../../cli/clack.js"; import { getParsedInvocation } from "../../cli/invocation.js"; import { defineJsonCommand, GLOBAL_OUTPUT_ARGS, output, parseAllFlagValues, runWithJsonErrors } from "../../cli/shared.js"; import { assertFlatAssetName } from "../../core/asset/asset-create.js"; import { placementTypes } from "../../core/asset/asset-placement.js"; import { parseFrontmatter } from "../../core/asset/frontmatter.js"; import { isHttpUrl, resolveStashDir } from "../../core/common.js"; import { loadConfig } from "../../core/config/config.js"; import { UsageError } from "../../core/errors.js"; import { appendEvent } from "../../core/events.js"; import { resolveBundleWriteTarget } from "../../core/mutation-target.js"; import { getCacheDir } from "../../core/paths.js"; import { clearLogFile, info, isVerbose, setLogFile } from "../../core/warn.js"; import { resolveWriteTarget } from "../../core/write-source.js"; import { releaseIndexRebuildLock, tryAcquireIndexRebuildLock } from "../../indexer/index-rebuild-lock.js"; import { akmIndex } from "../../indexer/indexer.js"; import { getHyphenatedBoolean, getOutputMode } from "../../output/context.js"; import { pkgVersion } from "../../version.js"; import { inferAssetName, mergeXrefsIntoContent, readKnowledgeInput, resolveSupersedesForWrite, resolveSupersedesWriteTarget, resolveXrefsForWrite, writeMarkdownAsset, } from "../read/knowledge.js"; import { assembleInfo } from "./info.js"; /** Matches the high-frequency per-committed-batch progress line (#954), excluded from non-verbose JSON-mode stderr. */ const EMBEDDED_BATCH_PROGRESS_PATTERN = /^Embedded \d+\/\d+ entries\.$/; export const indexCommand = defineCommand({ meta: { name: "index", description: "Build search index (incremental by default; --full forces full reindex)" }, args: { // R-051: `index` 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 // extra positional for a stray value to fall into), so this is purely a // `--help` visibility / consistency fix, not a behavior change. ...GLOBAL_OUTPUT_ARGS, full: { type: "boolean", description: "Force full reindex", default: false }, clean: { type: "boolean", description: "After indexing, remove any entries whose source file no longer exists on disk.", default: false, }, "dry-run": { type: "boolean", description: "When combined with --clean, report stale entries without deleting them.", default: false, }, reembed: { type: "boolean", description: "Discard every stored vector and re-embed all entries under the configured model.", default: false, }, "skip-if-locked": { type: "boolean", description: "If another `akm index` run already holds the rebuild lock, skip gracefully (exit 0) instead of contending with it. Use for scheduled/opportunistic index runs so they don't pile up against a longer run in progress.", default: false, }, }, async run({ args }) { await runWithJsonErrors(async () => { if (getHyphenatedBoolean(args, "enrich") || getParsedInvocation().getFlagValue("--enrich") !== undefined) { throw new UsageError("`akm index --enrich` has been removed. Metadata enrichment during indexing is retired; it never runs."); } if (getHyphenatedBoolean(args, "re-enrich") || getParsedInvocation().getFlagValue("--re-enrich") !== undefined) { throw new UsageError("`akm index --re-enrich` has been removed. Metadata enrichment during indexing is retired; there is nothing to re-enrich."); } // #956: opt-in, non-blocking rebuild lock — never gates a human-typed // `akm index` (it only warns and contends), but a scheduled/opportunistic // caller can pass --skip-if-locked to step aside instead of piling up // behind a run already in progress. Acquired before any other side // effect (log file, spinner) so a skip does neither. const lockAcquisition = tryAcquireIndexRebuildLock(args["skip-if-locked"]); if (lockAcquisition.state === "skipped") { output("index", { ok: true, skipped: { reason: "lock-held", pid: lockAcquisition.holder.pid, // #956: the launcher pid (when known) alongside the pid that // actually holds the lock — every process listing and task log // shows the launcher pid, not the bun/node child's. launcherPid: lockAcquisition.holder.launcherPid, startedAt: lockAcquisition.holder.startedAt, }, }); return; } const outputMode = getOutputMode(); const controller = new AbortController(); const abort = () => controller.abort(new Error("index interrupted")); process.once("SIGINT", abort); process.once("SIGTERM", abort); const indexLogFile = path.join(getCacheDir(), "logs", "index", `${new Date().toISOString().replace(/[:.]/g, "-")}.log`); setLogFile(indexLogFile); const verbose = isVerbose(); const spin = !verbose && outputMode.format === "text" ? p.spinner() : null; if (spin) { spin.start(`Building search index${args.full ? " (full rebuild)" : ""}...`); } let latestMessage = ""; // Resolve the stash dir once at the `akm index` command boundary and // thread it into the indexer (WI-9.10 CLI-wide sweep) — the indexer leaf // no longer reads the ambient `resolveStashDir()`. const stashDir = resolveStashDir(); try { const result = await akmIndex({ stashDir, full: args.full, clean: args.clean, dryRun: args["dry-run"], reembed: args.reembed, onProgress: ({ phase, message, processed, total }) => { latestMessage = message; const progressPrefix = processed !== undefined && total !== undefined ? `[${processed}/${total}] ` : ""; if (verbose) { info(`[index:${phase}] ${progressPrefix}${message}`); } else if (spin) { spin.stop(`${progressPrefix}${message}`); spin.start(`${progressPrefix}${message}`); } else if (!EMBEDDED_BATCH_PROGRESS_PATTERN.test(message)) { // Non-verbose, non-text (JSON/yaml/etc) mode: silence used to be // total until the run finished (#954) — a stalled // run looked identical to "nothing written". Phase-start // messages and the embedding heartbeat now reach stderr here // too; the high-frequency per-batch `Embedded N/M entries.` // line (emitted after every committed batch) // is deliberately excluded — that would be spam, not a // heartbeat. info(`[index:${phase}] ${progressPrefix}${message}`); } }, signal: controller.signal, }); if (spin) { spin.stop(`Indexed ${result.totalEntries} assets.`); } output("index", result); } catch (error) { if (spin) { spin.stop(latestMessage ? `Indexing failed after: ${latestMessage}` : "Indexing failed."); } throw error; } finally { clearLogFile(); process.off("SIGINT", abort); process.off("SIGTERM", abort); if (lockAcquisition.state === "acquired") releaseIndexRebuildLock(lockAcquisition.ownership); } }); }, }); export const infoCommand = defineJsonCommand({ meta: { name: "info", description: "Show system capabilities, configuration, and index stats" }, run() { // `akm info` must behave like a help command: always exit 0 with a // report. `assembleInfo()` already degrades what it reasonably can // per-section; this is the final backstop for whatever it can't (e.g. // every path resolver needs an environment variable, such as `HOME`, // that this process genuinely does not have) — still a report, with // whatever is cheap and safe enough to never itself throw. let result; try { result = assembleInfo(); } catch (err) { result = { schemaVersion: 1, version: pkgVersion, error: err instanceof Error ? err.message : String(err), assetTypes: [...placementTypes()], searchModes: ["fts"], }; } output("info", result); }, }); export const importKnowledgeCommand = defineJsonCommand({ meta: { name: "import", description: "Import a knowledge document or URL into the default bundle", }, args: { source: { type: "positional", description: 'Source file path, URL, or "-" to read from stdin', required: true, }, name: { type: "string", description: "Knowledge name (flat, no '/'; defaults to the source filename or content slug). Use --path for a subdirectory.", }, path: { type: "string", description: "Relative subdirectory under knowledge/ to place the document in (e.g. 'projects/example'). The filename still comes from --name or the source slug.", }, force: { type: "boolean", description: "Overwrite an existing knowledge document with the same name", default: false, }, target: { type: "string", description: "Override the write destination. Accepts a source name from your config; falls back to defaultWriteTarget then the working bundle.", }, xref: { type: "string", description: "Cross-reference ref merged into the document's `xrefs:` frontmatter (repeatable: --xref knowledge/auth-flow). Existing frontmatter is preserved (dedupe-append, never a nested block); a document whose frontmatter is not parseable YAML aborts the import rather than being rewritten lossily. Each ref must resolve in the write target or a configured source; an unresolvable ref aborts the import.", }, supersedes: { type: "string", description: "Ref of an existing asset this document corrects (repeatable: --supersedes knowledge/legacy-guide). Imports the correction with an xref to the old asset AND demotes the old asset (`beliefState: superseded` + `supersededBy`, a metadata-only edit) so ranking prefers the correction and `--belief current` hides the stale version. An unresolvable or self-referencing ref aborts the import; a ref outside the write target and working bundle still imports the correction but skips the demotion (reported as applied: false).", }, }, async run({ args }) { // `--name` is a flat name; subdirectory placement is `--path`'s job. assertFlatAssetName(args.name); // Collect and validate --xref occurrences (repeatable; citty only exposes // the last value, so read argv directly). Validation happens BEFORE any // read/write so an unresolvable ref (UsageError → exit 2) leaves the // stash untouched. const rawSupersedes = parseAllFlagValues("--supersedes"); const writeTarget = resolveSupersedesWriteTarget(rawSupersedes, args.target); const xrefs = resolveXrefsForWrite(parseAllFlagValues("--xref"), writeTarget); // Collect and validate --supersedes occurrences (repeatable). Same // before-any-read/write contract: an unresolvable ref exits 2 with nothing // imported AND nothing demoted. The superseded refs fold into the imported // doc's xrefs automatically (correction provenance); the demotion runs // inside writeMarkdownAsset, ordered before the git boundary commit. const supersedes = resolveSupersedesForWrite(rawSupersedes, writeTarget); for (const s of supersedes) { if (!xrefs.includes(s.ref)) xrefs.push(s.ref); } const config = loadConfig(); const stashDir = (() => { try { return resolveWriteTarget(config, writeTarget).source.path; } catch (error) { if (!writeTarget) throw error; try { return resolveBundleWriteTarget(config, writeTarget).source.path; } catch { throw error; } } })(); const { content, preferredName } = await readKnowledgeInput(args.source, { stashDir }); // Imported docs may carry their own frontmatter: merge (dedupe-append) // BEFORE the write so write-path indexing sees the final content and no // second frontmatter block is ever nested. // The slug must come from the document BODY: a merged (or self-carried) // frontmatter block puts the `---` fence on the first line, which // inferAssetName would slugify to "" and fall back to a random // knowledge-<epoch>-<rand> name. A stdin import (no filename-derived // preferredName) therefore pre-infers the name from the pre-merge // content's PARSED body — not the raw text, whose first line is the fence // whenever the piped doc carries its own frontmatter — so --xref/ // --supersedes never change the slug and a frontmattered doc gets its // heading-derived slug on every path. const result = await writeMarkdownAsset({ type: "knowledge", content: mergeXrefsIntoContent(content, xrefs), name: args.name ?? (isHttpUrl(args.source) ? preferredName : undefined), fallbackPrefix: "knowledge", preferredName: preferredName ?? inferAssetName(parseFrontmatter(content).content, "knowledge"), force: args.force, target: writeTarget, path: args.path, supersedes, }); appendEvent({ eventType: "import", ref: result.ref, metadata: { source: args.source, path: result.path, force: args.force === true }, }); output("import", { ok: true, source: args.source, ...result }); }, });