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
326 lines (325 loc) • 18.7 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/.
/**
* `akm search`, `akm curate`, and `akm show` command family. Extracted verbatim
* from src/cli.ts (WS6) so the God Module shrinks; the `main.subCommands.search`,
* `.curate`, and `.show` keys and every command's args/output shape are
* byte-identical. The three commands form a clean cluster: they share the
* usage-event provenance and the `parseScopeFilterFlags`
* search-source parsers. Handlers whose body is a plain
* `runWithJsonErrors(async () => { … })` are migrated to `defineJsonCommand`,
* which emits the same JSON envelope (stdout/stderr/exit-code) as the inline
* form.
*/
import { getParsedInvocation } from "../../cli/invocation.js";
import { parsePositiveIntFlag } from "../../cli/parse-args.js";
import { defineJsonCommand, output, parseAllFlagValues } from "../../cli/shared.js";
import { parseBundleRef } from "../../core/asset/asset-ref.js";
import { parseMetaRef } from "../../core/asset/stash-meta.js";
import { UsageError } from "../../core/errors.js";
import { resolveUsageEventSource } from "../../indexer/usage/usage-events.js";
import { getOutputMode } from "../../output/context.js";
import { deliverRendered } from "../../output/html-render.js";
import { akmCurate, packCuratedHits } from "./curate.js";
import { akmSearch, parseBeliefFilterMode, parseScopeFilterFlags, parseSearchSource } from "./search.js";
import { akmShowUnified } from "./show.js";
/**
* `--source` was renamed to `--from` on `search`/`curate` in 0.9 (S8). citty
* is non-strict, so the retired spelling is silently absorbed rather than
* rejected — the command then runs against the DEFAULT `--from` value
* (local) instead of the source the caller named, with exit 0 and no error.
* Reject it explicitly instead.
*
* Exported so `akm task list` (`../tasks/tasks-cli.ts`, #951) — a pure
* delegating alias for `akm search --type task` — applies the identical
* guard rather than a second copy of it; a task-list-scoped `--source` must
* fail exactly like `search`'s does, not fall through to citty's silent
* unknown-flag absorption.
*/
export function rejectRetiredSourceFlag() {
if (!getParsedInvocation().hasFlag("--source"))
return;
throw new UsageError("`--source` was renamed to `--from` in 0.9. Use `--from local|registry|all` instead.", "INVALID_FLAG_VALUE");
}
export const searchCommand = defineJsonCommand({
meta: { name: "search", description: "Search the bundle" },
args: {
query: {
type: "positional",
description: 'Search query (omit to list all assets). A conceptId-prefix query — "memories/projecta/", "bundle//", "bundle//skills/" — enumerates that subtree instead of keyword-matching; a trailing "/" is required, and an explicit --type wins over the prefix.',
required: false,
default: "",
},
type: {
type: "string",
description: "Asset type filter — free-form, exact match, unvalidated; an unknown type returns no hits (default: any). Built-ins: skill, command, agent, knowledge, workflow, script, memory, lesson, task, session, fact, env, secret, instruction — plus any adapter-defined type (e.g. website, wiki-source, a wiki pageKind). Use workflow to find step-by-step task assets.",
},
limit: { type: "string", description: "Maximum number of results" },
from: { type: "string", description: "Search source (local|registry|all)", default: "local" },
assets: {
type: "boolean",
description: "Include asset-level search results (only meaningful with --from registry|all)",
default: false,
},
filter: {
type: "string",
description: "Scope filter (repeatable): --filter user=<id> --filter agent=<id> --filter run=<id> --filter channel=<name>. Narrows results without changing ranking.",
},
"include-proposed": {
type: "boolean",
description: 'Include entries with quality:"proposed" in the result set. Excluded by default.',
default: false,
},
belief: {
type: "string",
description: "Memory belief filter: all|current|historical. current keeps active memory beliefs; historical keeps contradicted/superseded/archived memory beliefs.",
default: "all",
},
"include-sessions": {
type: "boolean",
description: "Include session assets (excluded from default search results via config.search.defaultExcludeTypes).",
default: false,
},
},
async run({ args }) {
rejectRetiredSourceFlag();
const query = (args.query ?? "").trim();
const type = args.type;
const limit = parsePositiveIntFlag(args.limit ?? undefined);
const source = parseSearchSource(args.from);
// Repeatable; citty exposes only the last `--filter` value, so read all
// occurrences directly from argv (same pattern as `--tag`).
const filterTokens = parseAllFlagValues("--filter");
const filters = parseScopeFilterFlags(filterTokens, "--filter");
const includeProposed = args["include-proposed"] === true;
const belief = parseBeliefFilterMode(typeof args.belief === "string" ? args.belief : undefined);
const includeSessions = args["include-sessions"];
const assets = args.assets === true;
const outputMode = getOutputMode();
const result = await akmSearch({
query,
type,
limit,
source,
filters,
includeProposed,
belief,
includeSessions,
assets,
eventSource: resolveUsageEventSource(),
attributionProjection: outputMode.shape === "agent" ? "agent" : outputMode.detail,
});
output("search", result);
},
});
export const curateCommand = defineJsonCommand({
meta: {
name: "curate",
description: "Pick the assets worth loading for a task. Unlike `akm search`, this attaches a preview and run details per hit, adds related support refs, and summarizes the set — the usual starting point for an agent.",
},
args: {
// Optional in citty so run() is invoked when omitted; we re-validate
// below to surface a structured UsageError (exit 2) instead of citty's
// default help-banner exit-0.
query: { type: "positional", description: "Task or prompt to curate assets for", required: false },
type: {
type: "string",
description: "Asset type filter — free-form, exact match, unvalidated; an unknown type returns no hits (default: any). Built-ins: skill, command, agent, knowledge, workflow, script, memory, lesson, task, session, fact, env, secret, instruction — plus any adapter-defined type (e.g. website, wiki-source, a wiki pageKind). Use workflow to curate step-by-step task assets.",
},
limit: { type: "string", description: "Maximum number of curated results", default: "4" },
from: { type: "string", description: "Search source (local|registry|all)", default: "local" },
pack: {
type: "string",
description: "Pack the ranked stash hits' full content into a single token-budgeted blob instead of returning refs " +
"to follow up on individually — value is the max token budget, e.g. --pack 4000 (~4 chars/token, same " +
"estimator as embedding). Content is resolved the same way `akm show` resolves it, so a ref#fragment " +
"hit packs just that section. Registry hits (--from registry|all) are never packed. Not to be confused " +
"with a workflow asset's own `budget` field (a run-cost cap) — this is a context-size target for this " +
"one curate call.",
},
},
async run({ args }) {
rejectRetiredSourceFlag();
if (!args.query || !String(args.query).trim()) {
throw new UsageError('A curate query is required. Usage: akm curate "<task or prompt>" [--type <type>] [--limit <n>]', "MISSING_REQUIRED_ARGUMENT", 'Describe the task you want assets for, e.g. `akm curate "deploy to prod"`.');
}
const type = args.type;
const limitParsed = parsePositiveIntFlag(args.limit ?? undefined);
const limit = limitParsed && limitParsed > 0 ? limitParsed : 4;
const source = parseSearchSource(args.from ?? "local");
const outputMode = getOutputMode();
const packBudget = parsePositiveIntFlag(args.pack ?? undefined, "--pack");
const curated = await akmCurate({
query: args.query,
type,
limit,
source,
eventSource: resolveUsageEventSource(),
attributionProjection: outputMode.shape === "agent" ? "agent" : outputMode.detail,
});
if (packBudget !== undefined) {
const packed = await packCuratedHits(curated, packBudget);
deliverRendered(outputMode.format === "text" ? formatCuratePackText(packed) : JSON.stringify(packed.items, null, 2), outputMode.outputPath);
return;
}
output("curate", curated);
},
});
/** Human-readable rendering for `akm curate --pack`: concatenated content per hit under a `## <ref>` header. */
function formatCuratePackText(packed) {
if (packed.items.length === 0) {
return `No packed content for "${packed.query}" (budget ${packed.budget} tokens).`;
}
return packed.items.map((item) => `## ${item.ref}\n\n${item.content}`).join("\n\n");
}
/**
* Reject `--scope` (either spelling) on `akm show` (E-3). `--scope` was
* removed in favor of `--filter` (R-047, guardrail 6 — no alias, must keep
* failing loudly, not silently). It is deliberately NOT a declared flag on
* this command, which means citty's default behavior for an undeclared flag
* kicks in — and that default is silent acceptance:
*
* - `--scope=user=nobody` (equals form): citty consumes it as an unknown
* flag's own inline value. It never reaches `args._`, so nothing downstream
* ever notices — the command runs to completion and exits 0, having
* silently ignored the caller's (unsatisfied) scope request. This is the
* dangerous case: the caller believes a read was scoped when it was not,
* and it directly violates guardrail 6's "removed spelling must fail
* loudly, not silently".
* - `--scope user=nobody` (space form): citty treats `--scope` as boolean
* and pushes `user=nobody` into `args._` as a stray positional, which
* incidentally trips `rejectExtraShowPositionals`'s arity check below —
* but with the wrong diagnosis (it blames the unrelated retired
* `toc|section|lines|frontmatter|full` view-mode grammar).
*
* This check runs FIRST, before the positional check, so both spellings are
* caught by one explicit, correctly-worded error — it does NOT make `--scope`
* work, it only makes the failure loud and the diagnosis accurate.
*
* NOTE (general issue, out of scope here): citty silently accepts ANY
* undeclared flag on ANY command (e.g. `akm info --totallybogus` exits 0) —
* this same silent-ignore failure mode applies repo-wide, not just to
* `--scope` on `show`. Fixing that generally is a separate, wide-blast-radius
* owner decision (same family as E-2, `akm list --type skill`); this function
* only closes the `--scope`/`show` instance of it.
*/
function rejectRemovedScopeFlag(ref) {
const usedScopeFlag = getParsedInvocation().userArgs.some((token) => token === "--scope" || token.startsWith("--scope="));
if (!usedScopeFlag)
return;
throw new UsageError("akm show has no --scope flag — it was removed in 0.9.0. Use --filter instead: " +
"--filter user=<id> --filter agent=<id> --filter run=<id> --filter channel=<name>.", "INVALID_FLAG_VALUE", `\`akm show ${ref} --filter user=<id>\` narrows resolution to assets whose frontmatter scope matches.`);
}
/**
* Reject any positional after the ref. The
* `akm show <ref> toc|section "H"|lines A B|frontmatter|full` view grammar was
* removed in 0.9.0; its keywords used to be rewritten into hidden flags before
* citty saw argv, so without this guard a stale invocation would silently
* render the whole item instead of the view the caller asked for.
*
* `--scope` is handled separately, and earlier, by {@link rejectRemovedScopeFlag}
* — by the time this runs, a `--scope`-caused stray positional has already
* been intercepted with the correct diagnosis, so this function's generic
* message is reached only by genuine leftover view-grammar tokens.
*/
function rejectExtraShowPositionals(positionals, ref) {
const extra = (Array.isArray(positionals) ? positionals.map(String) : []).slice(1);
if (extra.length === 0)
return;
throw new UsageError(`akm show takes a single ref, but got ${extra.map((token) => `"${token}"`).join(" ")} after "${ref}". ` +
"The view-mode grammar (toc|section|lines|frontmatter|full) was removed in 0.9.0 — use " +
`\`akm show ${ref}#<heading-slug>\` to read one section, or \`akm show ${ref}\` for the whole item.`, "INVALID_FLAG_VALUE", "An unmatched #fragment lists the available slugs.");
}
export const showCommand = defineJsonCommand({
meta: {
name: "show",
description: "Show a bundle asset by ref (e.g. akm show knowledge/guide.md, akm show knowledge/guide.md#auth)",
},
args: {
ref: {
type: "positional",
description: "Asset ref ([bundle//]conceptId[#fragment]). On a markdown document `#fragment` selects one section by heading slug, and an unmatched fragment lists the available slugs. Example: `akm show knowledge/guide.md#auth`.",
required: true,
},
filter: {
type: "string",
description: "Scope filter (repeatable): --filter user=<id> --filter agent=<id> --filter run=<id> --filter channel=<name>. Narrows resolution to assets whose frontmatter scope matches. Same axis as `akm search --filter`.",
},
context: {
type: "string",
description: "Fragment presentation: exact (default) returns only the selected section; lead returns bounded indexed-safe document lead plus the explicitly labelled selected match.",
},
"max-tokens": {
type: "string",
description: "Approximate context budget in tokens (four characters per token). Requires --context lead; mutually exclusive with --max-chars.",
},
"max-chars": {
type: "string",
description: "Exact context budget in characters. Requires --context lead; mutually exclusive with --max-tokens.",
},
},
async run({ args }) {
// `[origin//]meta[:name]` targets the stash `.meta/` convention, which is
// not a typed asset ref — skip ref validation and let akmShowUnified
// direct-read it. (the ref parser would reject the non-type `meta`.)
if (!parseMetaRef(args.ref))
parseBundleRef(args.ref);
rejectRemovedScopeFlag(args.ref);
rejectExtraShowPositionals(args._, args.ref);
const invocation = getParsedInvocation();
const cliShape = getOutputMode().shape;
// F6/R-021 — `show` deliberately does NOT inherit `output.detail` from
// config the way `search`/`curate` do via `getOutputMode().detail`
// (which merges an explicit `--detail` flag with the config default,
// "brief" out of the box). A bare `akm show <ref>` must always return
// the FULL asset body regardless of `config.output.detail` — that is
// the point of the command. This is a deliberate, permanent exemption,
// not an oversight, so it is resolved through exactly one path here:
// read the raw `--detail` flag directly (bypassing the config-merged
// output mode on purpose) and only ever narrow the response when the
// caller EXPLICITLY passed `--detail brief` on this invocation.
// `--detail full` (explicit or, since it's also the implicit default,
// omitted) and any other value fall through to the full response.
const explicitDetail = invocation.getFlagValue("--detail");
// `--shape summary` selects the compact metadata projection for show.
// `--detail brief` forces the brief response regardless of shape.
const showDetail = explicitDetail === "brief"
? "brief"
: explicitDetail === "full"
? "full"
: cliShape === "summary"
? "summary"
: undefined;
// `--filter` is repeatable — citty only exposes the last value, so read
// every occurrence directly from argv (same helper as `akm search`; the two
// commands share one spelling for the scope-narrowing axis).
const scopeTokens = parseAllFlagValues("--filter");
const scope = parseScopeFilterFlags(scopeTokens, "--filter");
const contextMode = parseFragmentContextMode(typeof args.context === "string" ? args.context : undefined);
const maxTokens = parsePositiveIntFlag(args["max-tokens"] ?? undefined, "--max-tokens");
const maxChars = parsePositiveIntFlag(args["max-chars"] ?? undefined, "--max-chars");
if (maxTokens !== undefined && maxChars !== undefined) {
throw new UsageError("--max-tokens and --max-chars are mutually exclusive.", "INVALID_FLAG_VALUE");
}
const maxContextChars = maxChars ?? (maxTokens !== undefined ? maxTokens * 4 : undefined);
if (maxContextChars !== undefined && !Number.isSafeInteger(maxContextChars)) {
throw new UsageError("Fragment context budget is too large.", "INVALID_FLAG_VALUE");
}
const result = await akmShowUnified({
ref: args.ref,
detail: showDetail,
contextMode,
maxContextChars,
scope,
eventSource: resolveUsageEventSource(),
});
output("show", result);
},
});
export function parseFragmentContextMode(raw) {
const normalized = raw?.trim().toLowerCase() || "exact";
if (normalized === "exact" || normalized === "lead")
return normalized;
throw new UsageError(`Invalid --context value: "${raw}". Expected exact or lead.`, "INVALID_FLAG_VALUE");
}