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

491 lines (490 loc) • 21.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/. /** * Dotted-path getter/setter/unsetter for AKM config objects, driven by the * Zod schema in `./config-schema.ts`. * * Replaces the per-key switch statement that used to live in * `src/commands/config-cli.ts`. Adding a new config field is now zero lines * of CLI code — the schema describes how to get/set/unset, and this walker * does the rest. * * Coercion rules for `configSet`: * - z.string() → use raw value * - z.number() → Number(value), must be finite * - z.boolean() → "true" | "false" (case-sensitive) * - z.enum([...]) → must match one of the literal values * - z.array(...) → JSON-parse value, expect array, validate items * - z.object({...}) → JSON-parse value, expect object, schema-validate * - z.union([a,b,...]) → try each branch in order * - z.record(...) → JSON-parse value, validate */ import { z } from "zod"; import { isRecord } from "../common.js"; import { UsageError } from "../errors.js"; import { hasRegistryUrlCredentials, REGISTRY_CREDENTIALS_UNSUPPORTED } from "../registry-url.js"; import { warnOnce } from "../warn.js"; import { AkmConfigBaseSchema, EngineConfigSchema, listTopLevelConfigKeys } from "./config-schema.js"; import { deepMergeConfig } from "./deep-merge.js"; import { isApiKeyReference } from "./schema/primitives.js"; /** * Parse a dotted path into segments. Empty segments are rejected. Bracket * notation (e.g. `sources[0]`) is NOT supported — arrays are set as JSON. */ function parsePath(dotted) { if (!dotted) { throw new UsageError("Config key is required.", "INVALID_FLAG_VALUE", unknownKeyHint("")); } const segments = dotted.split("."); if (segments.some((s) => !s)) { throw new UsageError(`Invalid config key "${dotted}": empty segment between dots.`, "INVALID_FLAG_VALUE", unknownKeyHint(dotted)); } return segments; } /** Strip Zod wrappers down to the inner schema. */ function unwrap(schema) { let current = schema; while (true) { if (current instanceof z.ZodOptional) { current = current._def.innerType; } else if (current instanceof z.ZodDefault) { current = current._def.innerType; } else if (current instanceof z.ZodNullable) { current = current._def.innerType; } else if (current instanceof z.ZodCatch) { // `.catch(...)` wraps an inner schema with a fallback value. current = current._def.innerType; } else if (current instanceof z.ZodEffects) { // `.refine()` / `.superRefine()` / `.transform()` / `.preprocess()` — // descend into the inner schema (`schema` for refine/transform, `out` // for preprocess — both are exposed at `_def.schema`). current = current._def.schema; } else if (current instanceof z.ZodReadonly) { current = current._def.innerType; } else { return current; } } } /** * Resolve the Zod schema for a given dotted path, walking from the top-level * AkmConfig schema. Returns `undefined` if any path segment doesn't match a * known schema field. */ export function resolveSchemaAt(path, config, raw) { let schema = AkmConfigBaseSchema; let existing = config; for (const [index, segment] of path.entries()) { schema = unwrap(schema); if (schema instanceof z.ZodObject) { const next = schema.shape[segment]; if (!next) { // Catchall (e.g. index.<passName>) — descend into the catchall schema. const catchall = schema._def.catchall; if (catchall && !(catchall instanceof z.ZodNever)) { schema = catchall; } else { return undefined; } } else { schema = next; } } else if (schema instanceof z.ZodRecord) { // Named records (engines, improve.strategies, sources, etc.) // accept any string key — descend into the value schema. schema = schema._def.valueType; } else if (schema instanceof z.ZodUnion) { const option = selectUnionObjectOption(schema._def.options, segment, existing, index === path.length - 1 ? raw : undefined); if (!option) return undefined; schema = option.shape[segment]; } else { // Cannot descend into a non-object schema. return undefined; } existing = isRecord(existing) ? existing[segment] : undefined; } // Preserve leaf refinements/transforms for validation. Traversal unwraps at // the start of each loop iteration, while coercion unwraps independently. return schema; } function selectUnionObjectOption(candidates, segment, existing, raw) { const options = candidates .map((candidate) => unwrap(candidate)) .filter((candidate) => candidate instanceof z.ZodObject); if (raw !== undefined) { const requested = options.find((option) => { const leaf = option.shape[segment]; if (!leaf || !(unwrap(leaf) instanceof z.ZodLiteral)) return false; return leaf.safeParse(coerceForSchema(leaf, raw, segment)).success; }); if (requested) return requested; } if (isRecord(existing)) { const selected = options.find((option) => Object.entries(option.shape).some(([key, field]) => key in existing && unwrap(field) instanceof z.ZodLiteral && field.safeParse(existing[key]).success)); if (selected) return segment in selected.shape ? selected : undefined; } return options.find((option) => segment in option.shape); } /** * Get the value at the dotted path from a config-shaped object. Returns * `undefined` when the path is unset, `null` for paths that are explicitly * set to `null`. */ export function configGet(config, dotted) { const path = parsePath(dotted); const schema = resolveSchemaAt(path, config); if (!schema) { throw new UsageError(`Unknown config key: ${dotted}`, "INVALID_FLAG_VALUE", unknownKeyHint(dotted)); } let cursor = config; for (const segment of path) { if (cursor === null || cursor === undefined) return null; if (typeof cursor !== "object") return null; cursor = cursor[segment]; } return cursor ?? null; } /** * Set the value at the dotted path. Coerces `raw` according to the schema at * that path. Returns a new config object — the input is not mutated. * * Throws {@link UsageError} on: * - unknown path * - invalid apiKey paths * - coercion failure */ export function configSet(config, dotted, raw) { const path = parsePath(dotted); if (path.length === 1 && path[0] === "configVersion") { throw new UsageError("configVersion cannot be changed through config set.", "INVALID_FLAG_VALUE"); } // #454: apiKey paths are not persistable. Throw at set time. rejectApiKeyPath(path, dotted); rejectLiteralApiKeyInWholeObjectSet(path, raw, dotted); const schema = resolveSchemaAt(path, config, raw); const symbolicApiKey = (path[0] === "engines" && path[2] === "apiKey") || (path[0] === "embedding" && path.length === 2 && path[1] === "apiKey"); const isUnknownKey = !schema && !symbolicApiKey; if (isUnknownKey) { warnOnce(`config-set:unknown-key:${dotted}`, `"${dotted}" is not a known config key; storing it anyway. Run \`akm config get ${dotted}\` to confirm it round-trips as expected.`); } const judgmentObjectPath = isTriageJudgmentPath(path); const value = judgmentObjectPath && raw === "null" ? undefined : path[0] === "engines" && path.length === 2 ? parseObjectPatch(raw, dotted) : symbolicApiKey ? raw : isUnknownKey ? bestEffortJsonValue(raw) : coerceForSchema(schema, raw, dotted); if (path[0] === "registries") rejectRegistryCredentialValue(value, dotted); const existing = path.reduce((value, key) => { if (value && typeof value === "object") return value[key]; return undefined; }, config); // The judgment leaf supplies `enabled: true` when the field is omitted. // Merge a JSON object patch with the existing value before that current // schema transform, // otherwise the synthesized default can overwrite an explicit false. const candidate = judgmentObjectPath && isRecord(value) ? deepMergeConfig(isRecord(existing) ? existing : {}, value) : value; // Validate the coerced value against the leaf schema. This catches enum // mismatches, out-of-range numbers, schema-level shape errors (writable // npm/website sources via .superRefine, strict-mode unknown keys in nested // objects, etc.) BEFORE we apply the patch. const parsed = path[0] === "engines" && path.length === 2 ? EngineConfigSchema.safeParse(value) : symbolicApiKey ? isApiKeyReference(raw) ? { success: true, data: value } : { success: false, error: { issues: [{ path: [], message: "apiKey must be $VAR, ${VAR}, or secret://<name>" }] }, } : isUnknownKey ? { success: true, data: value } : schema.safeParse(candidate); if (!parsed.success) { const lines = parsed.error.issues .map((i) => { const path = i.path.length > 0 ? `${dotted}.${i.path.join(".")}` : dotted; return ` - ${path}: ${i.message}`; }) .join("\n"); throw new UsageError(`Invalid value for ${dotted}:\n${lines}`, "INVALID_FLAG_VALUE"); } const next = setPath(config, path, isRecord(existing) && isRecord(parsed.data) ? deepMergeConfig(existing, parsed.data) : parsed.data); // Targeted invariant: defaultWriteTarget must point at a configured bundle // (#464.a). Whole-config validation happens at save time; this check fires // at set time so the user sees the typo immediately. if (dotted === "defaultWriteTarget" && typeof value === "string") { const bundles = next.bundles && typeof next.bundles === "object" ? next.bundles : {}; const knownNames = Object.keys(bundles); if (!knownNames.includes(value)) { throw new UsageError(`Unknown bundle "${value}" for defaultWriteTarget; configured bundles: ${knownNames.map((n) => `"${n}"`).join(", ") || "none"}.`, "INVALID_FLAG_VALUE"); } } return next; } function isTriageJudgmentPath(path) { return (path.length === 6 && path[0] === "improve" && path[1] === "strategies" && path[3] === "processes" && path[4] === "triage" && path[5] === "judgment"); } /** * Unset the value at the dotted path. Removes the leaf key (and prunes empty * parent objects if they become empty). Returns a new config object. */ export function configUnset(config, dotted) { const path = parsePath(dotted); if (path.length === 1 && path[0] === "configVersion") { throw new UsageError("configVersion cannot be removed through config unset.", "INVALID_FLAG_VALUE"); } // Validate the path resolves to a real schema field (so typos don't no-op). const schema = resolveSchemaAt(path, config); if (!schema) { throw new UsageError(`Unknown config key: ${dotted}`, "INVALID_FLAG_VALUE", unknownKeyHint(dotted)); } return unsetPath(config, path); } // ── apiKey rejection (#454) ───────────────────────────────────────────────── /** * Embedding credentials are never persisted. Engine credentials are symbolic * configuration and are validated by the engine schema below. */ function rejectApiKeyPath(path, dotted) { if (path[0] === "engines" && path[2] === "apiKey") return; if (path[0] === "embedding" && path.length === 2 && path[1] === "apiKey") return; const last = path[path.length - 1]; if (last !== "apiKey") return; const recipe = recipeForApiKey(path, dotted); throw new UsageError(`apiKey cannot be persisted in config; export ${recipe} instead. (key: ${dotted})`, "INVALID_FLAG_VALUE", "Storing API keys in config.json leaks them through backups, logs, and version control. " + "Use the corresponding environment variable. AKM reads it at request time."); } function rejectLiteralApiKeyInWholeObjectSet(path, raw, dotted) { const isWholeEngineSet = path[0] === "engines" && path.length === 2; const isWholeEmbeddingSet = path.length === 1 && path[0] === "embedding"; if (!isWholeEngineSet && !isWholeEmbeddingSet) return; let parsed; try { parsed = JSON.parse(raw); } catch { return; // Malformed JSON — the caller's own parse/coercion reports this. } if (!isRecord(parsed) || typeof parsed.apiKey !== "string") return; if (isApiKeyReference(parsed.apiKey)) return; throw new UsageError(`apiKey cannot be persisted in config; export ${recipeForApiKey([...path, "apiKey"], `${dotted}.apiKey`)} instead. (key: ${dotted}.apiKey)`, "INVALID_FLAG_VALUE", "Storing API keys in config.json leaks them through backups, logs, and version control. " + "Use the corresponding environment variable. AKM reads it at request time."); } function rejectRegistryCredentialValue(value, dotted) { const entries = Array.isArray(value) ? value : [value]; for (const entry of entries) { const url = isRecord(entry) && typeof entry.url === "string" ? entry.url : undefined; if (url && hasRegistryUrlCredentials(url)) { throw new UsageError(`${REGISTRY_CREDENTIALS_UNSUPPORTED} (key: ${dotted})`, "INVALID_FLAG_VALUE", REGISTRY_CREDENTIALS_UNSUPPORTED); } } } function bestEffortJsonValue(raw) { try { return JSON.parse(raw); } catch { return raw; } } function parseObjectPatch(raw, key) { try { const value = JSON.parse(raw); if (!isRecord(value)) throw new Error("expected an object"); return value; } catch (err) { throw new UsageError(`Invalid JSON object for ${key}: ${err instanceof Error ? err.message : String(err)}`, "INVALID_JSON_CONFIG_VALUE"); } } function recipeForApiKey(path, _dotted) { if (path[0] === "embedding") return "AKM_EMBED_API_KEY"; if (path[0] === "engines" && typeof path[1] === "string") { return `AKM_ENGINE_${path[1].toUpperCase().replaceAll("-", "_")}_API_KEY`; } return "AKM_LLM_API_KEY / AKM_EMBED_API_KEY"; } // ── Coercion ──────────────────────────────────────────────────────────────── /** * Coerce a CLI string into the value expected by the given schema. JSON-object * and JSON-array schemas accept either a JSON literal or the string `null`/ * empty (which clears the value, equivalent to unset). */ function coerceForSchema(schema, raw, key) { const target = unwrap(schema); if (target instanceof z.ZodString) { return raw; } if (target instanceof z.ZodNumber) { const n = Number(raw); if (!Number.isFinite(n)) { throw new UsageError(`Invalid value for ${key}: expected a number, got "${raw}".`); } return n; } if (target instanceof z.ZodBoolean) { if (raw === "true") return true; if (raw === "false") return false; throw new UsageError(`Invalid value for ${key}: expected true or false, got "${raw}".`); } if (target instanceof z.ZodEnum) { return raw; } if (target instanceof z.ZodLiteral) { return typeof target._def.value === "string" ? raw : target._def.value; } if (target instanceof z.ZodArray || target instanceof z.ZodObject || target instanceof z.ZodRecord) { if (raw === "" || raw === "null") return undefined; try { return JSON.parse(raw); } catch (err) { const detail = err instanceof Error ? err.message : String(err); throw new UsageError(`Invalid JSON for ${key}: ${detail}`, "INVALID_JSON_CONFIG_VALUE"); } } if (target instanceof z.ZodUnion) { // Try each option in order. Use the first that the schema accepts after // coercion. Falls through to JSON parsing if all fail. const options = target._def.options; let lastErr; for (const opt of options) { try { const coerced = coerceForSchema(opt, raw, key); const parsed = opt.safeParse(coerced); if (parsed.success) return parsed.data; lastErr = parsed.error; } catch (err) { lastErr = err; } } if (lastErr instanceof Error) throw new UsageError(`Invalid value for ${key}: ${lastErr.message}`); throw new UsageError(`Invalid value for ${key}: did not match any expected type.`); } if (target instanceof z.ZodNull) { if (raw === "" || raw === "null") return null; throw new UsageError(`Invalid value for ${key}: expected null.`); } // Fallback: try JSON parse, then raw string. if (raw === "" || raw === "null") return undefined; try { return JSON.parse(raw); } catch { return raw; } } // ── Path mutators ─────────────────────────────────────────────────────────── function setPath(config, path, value) { if (path.length === 0) return config; const head = path[0]; // non-empty: guarded by the `path.length === 0` check above const rest = path.slice(1); const next = { ...config }; if (rest.length === 0) { if (value === undefined) { delete next[head]; } else { next[head] = value; } return next; } const existing = next[head]; const child = typeof existing === "object" && existing !== null && !Array.isArray(existing) ? existing : {}; next[head] = setPath(child, rest, value); return next; } function unsetPath(config, path) { if (path.length === 0) return config; const head = path[0]; // non-empty: guarded by the `path.length === 0` check above const rest = path.slice(1); const next = { ...config }; if (rest.length === 0) { delete next[head]; return next; } const existing = next[head]; if (typeof existing !== "object" || existing === null || Array.isArray(existing)) { return next; // nothing to unset } const updated = unsetPath(existing, rest); if (Object.keys(updated).length === 0) { delete next[head]; } else { next[head] = updated; } return next; } // ── Hint generation (#460) ────────────────────────────────────────────────── /** * Per-key "use X instead" guidance for config keys retired before the 0.9 * cutover, keyed on the first dotted-path segment. Mitigation item 3: the * generic "valid top-level keys" hint doesn't tell a pre-0.9 user what * replaced the key they typed — this does. */ const RETIRED_KEY_HINTS = { stashDir: "stashDir was removed in 0.9; get the stash path from `akm config path --all` or `akm info`.", sources: "sources is not supported; configure `bundles` instead.", installed: "installed is not supported; configure `bundles` instead.", wikiName: "the wiki subsystem was removed in 0.9; wikis are ordinary knowledge assets now — see `akm import`.", wiki: "the wiki subsystem was removed in 0.9; wikis are ordinary knowledge assets now — see `akm import`.", llm: "llm was replaced by named engines configuration in 0.9; configure `engines` instead.", }; export function unknownKeyHint(attempted) { const keys = listTopLevelConfigKeys(); const generic = `Valid top-level keys: ${keys.join(", ")}. Use dotted paths for nested values (e.g. embedding.endpoint, engines.<name>.model).`; const firstSegment = attempted.split(".")[0] ?? ""; const retiredHint = RETIRED_KEY_HINTS[firstSegment]; return retiredHint ? `${retiredHint} ${generic}` : generic; }