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
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/.
/**
* 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 });
},
});