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

253 lines (252 loc) • 12.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/. /** * Config CLI commands — `akm config get/set/unset/list`. * * Thin wrappers around the schema walker in `core/config-walker.ts`. Adding a * new config field is one line of Zod schema in `core/config-schema.ts` and * zero lines here — the walker handles get/set/unset/coercion uniformly. * * `configVersion` is controlled by the config lifecycle. All execution * settings use their canonical engine/strategy paths; retired aliases are not * rewritten at this boundary. */ import { isDeepStrictEqual } from "node:util"; import { defineGroupCommand, defineJsonCommand, output } from "../cli/shared.js"; import { isRecord, resolveStashDir } from "../core/common.js"; import { DEFAULT_CONFIG, getConfigValueSource, loadConfig, mutateConfig, parseAndValidateConfigText, resolveConfigRefSource, } from "../core/config/config.js"; import { configGet, configSet, configUnset, unknownKeyHint } from "../core/config/config-walker.js"; import { UsageError } from "../core/errors.js"; import { getCacheDir, getConfigPath, getDbPath, getDefaultStashDir } from "../core/paths.js"; import { formatRegistryUrl } from "../core/registry-url.js"; // ── Public API ────────────────────────────────────────────────────────────── export function getConfigValue(config, key) { return redactConfigValue(configGet(config, key), key.split(".")); } export function setConfigValue(config, key, rawValue) { return configSet(config, key, rawValue); } export function unsetConfigValue(config, key) { return configUnset(config, key); } export function listConfig(config) { // 0.9.0 (spec §10.1): sources live in `bundles` (spread from config); the // retired top-level `sources[]` array is no longer surfaced. return redactConfigValue({ ...DEFAULT_CONFIG, ...config }); } function redactConfigValue(value, path = []) { if (typeof value === "string" && isRegistryUrlPath(path)) return formatRegistryUrl(value); if (Array.isArray(value)) return value.map((child, index) => redactConfigValue(child, [...path, String(index)])); if (!value || typeof value !== "object") return value; const result = {}; for (const [key, child] of Object.entries(value)) { if (key !== "apiKey" || (typeof child === "string" && /^\$(?:[A-Za-z_][A-Za-z0-9_]*|\{[A-Za-z_][A-Za-z0-9_]*\})$/.test(child))) { result[key] = redactConfigValue(child, [...path, key]); } } return result; } function isRegistryUrlPath(path) { return path[0] === "registries" && path.at(-1) === "url"; } export { unknownKeyHint }; /** * Flatten a (redacted) config object down to `{dottedPath: leafValue}` * entries. A plain object descends; an array or scalar is a leaf — matching * `deepMergeConfig`'s own "objects merge, arrays replace wholesale" rule, so * an array difference reports as one row rather than per-index noise. */ function flattenConfigLeaves(value, prefix, out) { if (isRecord(value) && Object.keys(value).length > 0) { for (const [key, child] of Object.entries(value)) flattenConfigLeaves(child, [...prefix, key], out); return; } out.set(prefix.join("."), value); } /** * Diff two (already redacted) effective config objects into sorted * `{path, local, other}` rows for every leaf that differs — including a leaf * present on only one side (the other's value reads `undefined`). */ function diffConfigs(local, other) { const localLeaves = new Map(); const otherLeaves = new Map(); flattenConfigLeaves(local, [], localLeaves); flattenConfigLeaves(other, [], otherLeaves); const paths = new Set([...localLeaves.keys(), ...otherLeaves.keys()]); const rows = []; for (const path of paths) { const localValue = localLeaves.get(path); const otherValue = otherLeaves.get(path); if (!isDeepStrictEqual(localValue, otherValue)) rows.push({ path, local: localValue, other: otherValue }); } rows.sort((a, b) => a.path.localeCompare(b.path)); return rows; } /** * `akm config diff <path|bundle//path>`: the current effective config * (this instance's `extends` already applied) against another config file or * bundle-relative file (loaded through the same loader, its own `extends` * honoured), secrets redacted on both sides before comparison so a differing * secret never round-trips into the diff. */ export function akmConfigDiff(ref) { const current = loadConfig(); const { text, resolvedPath } = resolveConfigRefSource(ref, current, getConfigPath()); const other = parseAndValidateConfigText(text, resolvedPath); const rows = diffConfigs(redactConfigValue(current), redactConfigValue(other)); return { rows }; } // ── `akm config` command surface ──────────────────────────────────────────── // Extracted verbatim from src/cli.ts (WS6). The `main.subCommands.config` key // and every config subcommand's args/output shape are byte-identical. Leaf // handlers whose body is a plain `runWithJsonErrors(() => { … })` are // migrated to `defineJsonCommand`, which emits the same JSON envelope // (stdout/stderr/exit-code) as the inline form. // // `akm config enable|disable` (a hardcoded alias for toggling the skills.sh // registry) was removed in 0.9.0 (C4). Use `akm registry add|remove`, the // general mechanism, instead. export const configCommand = defineGroupCommand({ meta: { name: "config", description: "Show and manage configuration" }, subCommands: { path: defineJsonCommand({ meta: { name: "path", description: "Show paths to config, bundle, cache, and index" }, args: { all: { type: "boolean", description: "Show all paths (config, bundle, cache, index)", default: false }, }, run({ args }) { const configPath = getConfigPath(); if (args.all) { let stashDir; try { stashDir = resolveStashDir(); } catch { stashDir = `${getDefaultStashDir()} (not initialized)`; } const cacheDir = getCacheDir(); const result = { config: configPath, bundle: stashDir, cache: cacheDir, index: getDbPath(), }; output("config", result); } else { console.log(configPath); } }, }), list: defineJsonCommand({ meta: { name: "list", description: "List current configuration" }, run() { output("config", listConfig(loadConfig())); }, }), get: defineJsonCommand({ meta: { name: "get", description: "Get a configuration value by key" }, args: { key: { type: "positional", required: true, description: "Config key (for example: embedding, defaultBundle)" }, "show-source": { type: "boolean", description: 'Wrap the value as { value, source }, where source is "local", "extends:<ref>" (the nearest ' + 'extends chain member that sets it), or "default".', default: false, }, }, run({ args }) { const value = getConfigValue(loadConfig(), args.key); output("config", args["show-source"] ? { value, source: getConfigValueSource(args.key) } : value); }, }), set: defineJsonCommand({ meta: { name: "set", description: "Set a configuration value by key; prints the resulting config with ok: true", }, args: { key: { type: "positional", required: true, description: "Config key (for example: embedding, engines.default)", }, value: { type: "positional", required: true, description: "Config value" }, // #463: stable machine-friendly entry point for plugins / hooks. // `--silent` suppresses the config dump on stdout so hook-driven // writes don't pollute their host's output stream. silent: { type: "boolean", description: "Suppress the post-write config dump on stdout entirely (prints nothing; exit code is the status). Use from hooks and CI scripts; the write still happens and errors still print.", default: false, }, }, run({ args }) { const updated = mutateConfig((current) => setConfigValue(current, args.key, args.value)).config; if (!args.silent) { output("config", listConfig(updated)); } }, }), unset: defineJsonCommand({ meta: { name: "unset", description: "Unset an optional configuration key or whole embedding/engine section; prints the resulting config with ok: true", }, args: { key: { type: "positional", required: true, description: "Config key to unset" }, silent: { type: "boolean", description: "Suppress the post-write config dump on stdout entirely (prints nothing; exit code is the status).", default: false, }, }, run({ args }) { // A key only an `extends` base supplies has nothing local to remove: // `unsetConfigValue` would silently no-op and the value comes right // back on the next load. Refuse before mutating rather than pretend // it worked. const source = getConfigValueSource(args.key); if (source.startsWith("extends:")) { throw new UsageError(`Config key "${args.key}" is inherited from ${source} and cannot be unset here.`, "TARGET_NOT_UPDATABLE", `Override it locally with "akm config set ${args.key} <value>", or edit the base file directly.`); } const result = mutateConfig((current) => unsetConfigValue(current, args.key), { absentNoop: true }); const updated = result.config; if (!args.silent) { output("config", listConfig(updated)); } }, }), diff: defineJsonCommand({ meta: { name: "diff", description: "Show effective-config differences against another config file or bundle-relative file, secrets redacted", }, args: { ref: { type: "positional", required: true, description: "Other config: a file path or bundle//path (relative to the bundle's content root)", }, }, run({ args }) { output("config-diff", akmConfigDiff(args.ref)); }, }), }, // The bare `akm config` invocation (and `akm config --list`) dumps the // current config. defineGroupCommand short-circuits this body when a // registered subcommand ran, so the routing set stays derived from the // subCommands map and can never desync. // No `defaultRun`: bare `akm config` is a usage error (exit 2), the canonical // bare-group behavior — owner ruling 12. Run `akm config list` for what the // bare form used to print. });