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

931 lines (930 loc) • 47.1 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/. import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { isDeepStrictEqual } from "node:util"; import { isBundleSlug, parseBundleRef } from "../asset/asset-ref.js"; import { deriveBundleId } from "../bundle-id.js"; import { isRecord, resolveStashDir } from "../common.js"; import { ConfigError } from "../errors.js"; import { liftLegacyEngineExtraParams } from "../extra-params.js"; import { formatRegistryLabel, hasRegistryUrlCredentials } from "../registry-url.js"; import { acquireConfigLock, backupExistingConfig, parseConfigText, readConfigText, withConfigLock, writeConfigAtomic, } from "./config-io.js"; import { AkmConfigSchema, CURRENT_CONFIG_VERSION } from "./config-schema.js"; import { bundleComponentConfig, bundleContentRoot, bundleContentRoots, bundleSourceId, bundlesToSourceEntries, filesystemBundleSourceId, isBundleEnabled, } from "./config-sources.js"; import { resolveSchemaAt } from "./config-walker.js"; import { deepMergeConfig, isPlainObject } from "./deep-merge.js"; import { migrateLegacySourceShape } from "./legacy-source-shape-shim.js"; import { isApiKeyReference, SECRET_STORE_REFERENCE_PATTERN } from "./schema/primitives.js"; export { stripJsonComments } from "./config-io.js"; import { getConfigPath } from "../paths.js"; import { warn, warnOnce } from "../warn.js"; // Canonical harness-id source of truth (#565) — runtime value re-export. export { VALID_HARNESS_IDS } from "./config-types.js"; // ── Feedback failure-mode constants (F-3 / #384) ──────────────────────────── // Canonical taxonomy lives in the schema/validator layer; re-exported here so // existing `../core/config/config` import sites keep working. export { FEEDBACK_FAILURE_MODES } from "./config-schema.js"; // ── Defaults ──────────────────────────────────────────────────────────────── export const DEFAULT_CONFIG = { configVersion: "0.9.0", semanticSearchMode: "off", registries: [ { url: "https://raw.githubusercontent.com/itlackey/akm-registry/main/index.json", name: "akm-registry" }, { url: "https://skills.sh", name: "skills.sh", provider: "skills-sh", enabled: false }, ], output: { format: "json", detail: "brief", }, }; // ── Load / Save / Update ──────────────────────────────────────────────────── let cachedConfig; export function resetConfigCache() { cachedConfig = undefined; } export function loadUserConfig() { const configPath = getConfigPath(); let stat; try { stat = fs.statSync(configPath); } catch (err) { if (err.code !== "ENOENT") { throw new ConfigError(`Unable to read config at ${configPath}: ${err instanceof Error ? err.message : String(err)}`, "INVALID_CONFIG_FILE"); } cachedConfig = undefined; return { ...DEFAULT_CONFIG }; } // Cache key: mtimeMs + size. Tests that write rapidly back-to-back inside // the mtime resolution window MUST call resetConfigCache() between writes — // every public test helper already does. if (cachedConfig && cachedConfig.path === configPath && cachedConfig.mtime === stat.mtimeMs && cachedConfig.size === stat.size) { return cachedConfig.config; } let text; try { text = readConfigText(configPath); } catch (err) { throw new ConfigError(`Unable to read config at ${configPath}: ${err instanceof Error ? err.message : String(err)}`, "INVALID_CONFIG_FILE"); } if (text === undefined) { cachedConfig = undefined; return { ...DEFAULT_CONFIG }; } const finalConfig = parseAndValidateConfigText(text, configPath); // Re-stat after potential write-back so the cache key reflects the new mtime. let finalStat = stat; try { finalStat = fs.statSync(configPath); } catch { // Stat failed — use original stat for cache; no harm done. } cachedConfig = { config: finalConfig, path: configPath, mtime: finalStat.mtimeMs, size: finalStat.size, }; return finalConfig; } /** * Run the per-file config pipeline every raw config object goes through * before it is either validated (the local/top-level file) or merged in as * an `extends` base: JSONC parse already done by the caller, then the * `configVersion` read ({@link readConfigVersion}), then the legacy * `stashDir`/`sources[]`/`installed[]` shim, then the legacy `extraParams` * lift (#852). Unknown keys are not an error: the schema drops them in * memory. Shared by {@link parseAndValidateConfigText} (the local file) and * {@link resolveExtendsChain} (each base in the chain) so a fleet-shared base * config can carry its own `configVersion` / legacy shape independently of * the file that extends it. */ function runConfigFilePipeline(text, sourcePath) { const versioned = readConfigVersion(parseConfigText(text, sourcePath), sourcePath); const parsedRaw = migrateLegacySourceShape(versioned, sourcePath); return liftExtraParamsOrThrow(parsedRaw, sourcePath); } /** * `configVersion` is read, never gated on. `"0.9.0"` is the only value akm * has ever shipped, and a document without the field is that same document. * Any other value is named once and the file is read as the current shape * anyway; every ordinary config write and `akm migrate apply`'s `configFile` * step then persist `"0.9.0"`. */ function readConfigVersion(raw, sourcePath) { const version = raw.configVersion; if (version === CURRENT_CONFIG_VERSION) return raw; if (version !== undefined) { warnOnce(`config:config-version${sourcePath ? `:${sourcePath}` : ""}`, `${sourcePath ?? "config.json"} declares configVersion ${JSON.stringify(version)}; this release reads it as ${CURRENT_CONFIG_VERSION}.`); } return { ...raw, configVersion: CURRENT_CONFIG_VERSION }; } /** * #852 (following #815): a config still using legacy `extraParams` keys — * e.g. `reasoning_effort`, a documented 0.9.1 workaround — needs to be * rewritten onto the first-class engine field they now shadow. This used * to happen silently, in memory, on every load; that ran forever and never * converged. The lift itself is now `akm migrate apply`'s job (see * scripts/akm-migrate/migrate/config-extra-params.ts) and persists to disk, so a * config that has not been migrated yet fails closed here instead of * silently drifting from what's on disk. */ function liftExtraParamsOrThrow(parsedRaw, sourcePath) { const where = sourcePath ? ` at ${sourcePath}` : ""; const { config: liftedConfig, lifted, conflicts } = liftLegacyEngineExtraParams(parsedRaw); if (conflicts.length > 0) { const lines = conflicts .map((c) => ` - engines.${c.engine}.extraParams.${c.key} (${JSON.stringify(c.extraParamsValue)}) conflicts with engines.${c.engine}.${c.field} (${JSON.stringify(c.fieldValue)})`) .join("\n"); throw new ConfigError(`Invalid config${where}: extraParams and the first-class field disagree:\n${lines}\n\nEach extraParams key above has a first-class equivalent and akm will not guess which value you meant — remove the extraParams entry once the field carries the value you want.`, "INVALID_CONFIG_FILE"); } if (lifted.length > 0) { warnOnce(`config:extra-params-lift${sourcePath ? `:${sourcePath}` : ""}`, `Config${where} uses deprecated extraParams keys with first-class equivalents — auto-lifted in memory:\n - ${lifted.join("\n - ")}\n\nRun \`akm migrate apply\` to rewrite the config file and silence this warning.`); } return liftedConfig; } /** * Resolve a local file's `extends` chain and validate the merged result * (deep-merged under `DEFAULT_CONFIG`) via Zod ({@link AkmConfigSchema}). * `liftedLocalRaw` must already be through {@link runConfigFilePipeline}. * * Split out of {@link parseAndValidateConfigText} so `mutateConfig` (#945 * finding: baking extends-inherited fields into the local file on every * write) can build the same effective config from a `localRaw` it also * keeps around, instead of only getting the final merged `AkmConfig` back. */ function buildEffectiveConfig(liftedLocalRaw, sourcePath) { const withExtends = resolveExtendsChain(liftedLocalRaw, sourcePath); const where = sourcePath ? ` at ${sourcePath}` : ""; warnUnknownConfigKeys(liftedLocalRaw, sourcePath); const parsed = AkmConfigSchema.safeParse(withExtends); if (!parsed.success) { const lines = parsed.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n"); throw new ConfigError(`Invalid config${where}:\n${lines}`, "INVALID_CONFIG_FILE"); } const merged = deepMergeConfig(DEFAULT_CONFIG, parsed.data); const finalResult = AkmConfigSchema.safeParse(merged); if (!finalResult.success) { const lines = finalResult.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n"); throw new ConfigError(`Invalid merged config${sourcePath ? ` at ${sourcePath}` : ""}:\n${lines}`, "INVALID_CONFIG_FILE"); } assertUniquePhysicalBundleRoots(finalResult.data, sourcePath); return finalResult.data; } /** * Retired `index.<passName>` keys that still validate against the generic * per-pass catchall schema (`IndexPassConfigSchema`), so `resolveSchemaAt` * below never returns `undefined` for them and the walk would otherwise * treat them as a live, ordinary pass. Named here so the same three * guarantees apply as any other retired config key (AGENTS.md "Reading * persisted data"): a config setting one keeps loading, is named once by * the unknown-key warning, and is dropped only by `akm migrate apply`. * Both were retired in 0.9.17-alpha.9: `index.graph` (the LLM entity-graph * extraction pass) and `index.metadataEnhance` (LLM metadata enrichment). */ const RETIRED_CATCHALL_KEY_PATHS = new Set(["index.graph", "index.metadataEnhance"]); /** * Every dotted key in `raw` the schema does not know, at any depth (arrays * are not descended). Unknown keys are never an error: they are a typo, or a * key another release used. Reads keep them (they round-trip through * ordinary writes, so a newer release's settings survive a downgrade); * `akm migrate apply` drops them. */ export function unknownConfigKeyPaths(root, node = root, prefix = []) { const found = []; for (const key of Object.keys(node).sort()) { const keyPath = [...prefix, key]; if (RETIRED_CATCHALL_KEY_PATHS.has(keyPath.join(".")) || resolveSchemaAt(keyPath, root) === undefined) { found.push(keyPath); continue; } const value = node[key]; if (isPlainConfigObject(value)) found.push(...unknownConfigKeyPaths(root, value, keyPath)); } return found; } function warnUnknownConfigKeys(raw, sourcePath) { for (const keyPath of unknownConfigKeyPaths(raw)) { const dotted = keyPath.join("."); warnOnce(`config:unknown-key:${sourcePath ?? "inline"}:${dotted}`, `Unknown config key ${JSON.stringify(dotted)}${sourcePath ? ` at ${sourcePath}` : ""} has no defined akm behavior and is ignored. Check the spelling; a key retired by this release or added by a newer one is dropped by \`akm migrate apply\`.`); } } function deleteConfigPath(node, keyPath) { let cursor = node; for (const segment of keyPath.slice(0, -1)) { if (!isPlainConfigObject(cursor)) return; cursor = cursor[segment]; } if (isPlainConfigObject(cursor)) delete cursor[keyPath[keyPath.length - 1]]; } function isPlainConfigObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } function assertUniquePhysicalBundleRoots(config, sourcePath) { const owners = new Map(); for (const { id, contentRoot } of bundleContentRoots(config)) { const prior = owners.get(contentRoot); if (prior !== undefined) { throw new ConfigError(`Invalid config${sourcePath ? ` at ${sourcePath}` : ""}: bundles ${JSON.stringify(prior)} and ${JSON.stringify(id)} resolve to the same physical content root ${contentRoot}.`, "INVALID_CONFIG_FILE", "Configure one bundle id per physical source root; symbolic-link aliases are not separate bundles."); } owners.set(contentRoot, id); } } /** * Parse raw config text and validate via Zod. * ({@link AkmConfigSchema}). Returns the merged-with-defaults AkmConfig. * * `configVersion` is read as the current version whatever it says (see * {@link readConfigVersion}). When the config sets `extends` (#945), its * resolved chain is deep-merged underneath before validation — see * {@link resolveExtendsChain}. */ export function parseAndValidateConfigText(text, sourcePath) { const liftedConfig = runConfigFilePipeline(text, sourcePath); return buildEffectiveConfig(liftedConfig, sourcePath); } /** * Walk an `extends` chain starting at `localRaw` (already through * {@link runConfigFilePipeline}), returning every layer from the local file * outward to the chain's root, local first. A chain is allowed (a base may * itself set `extends`); a cycle (a resolved source repeating) throws * {@link ConfigError} instead of recursing forever. */ function collectExtendsLayers(localRaw, configPath) { const layers = [{ ref: undefined, raw: localRaw }]; const visited = new Set(configPath ? [path.resolve(configPath)] : []); let current = localRaw; let currentPath = configPath; let containmentRoot; while (true) { const ref = current.extends; if (ref === undefined) return layers; if (typeof ref !== "string" || !ref.trim()) { throw new ConfigError(`Invalid "extends"${currentPath ? ` at ${currentPath}` : ""}: expected a non-empty string (a file path or bundle//path ref), got ${JSON.stringify(ref)}.`, "INVALID_CONFIG_FILE"); } const resolved = resolveConfigRefSource(ref, current, currentPath, containmentRoot); const { text, resolvedPath } = resolved; if (visited.has(resolvedPath)) { throw new ConfigError(`Config "extends" cycle detected: "${ref}"${currentPath ? ` (from ${currentPath})` : ""} resolves back to an already-visited config at ${resolvedPath}.`, "INVALID_CONFIG_FILE"); } visited.add(resolvedPath); const baseRaw = runConfigFilePipeline(text, resolvedPath); warnUnknownConfigKeys(baseRaw, resolvedPath); layers.push({ ref, raw: baseRaw }); current = baseRaw; currentPath = resolvedPath; containmentRoot = resolved.containmentRoot; } } /** * Deep-merge an `extends` chain into its effective raw shape: each layer's * own fields win over its base's (`deepMergeConfig` "local wins" semantics), * reduced root-to-local so the local file's fields win overall. `DEFAULT_CONFIG` * is NOT applied here — the caller still layers it outermost, unchanged. * * The merged result's `extends` field, if any, is always the LOCAL file's own * literal value (never a base's) — `deepMergeConfig`'s local-wins semantics * already guarantee this at every level, since a layer only has `extends` set * when it itself declares one. Keeping it (rather than stripping it) lets * `akm config set`/`unset` round-trip the directive: `mutateConfig` reads the * effective config as `current` for the mutation, but writes back only the * fields `pruneUnchangedInheritedFields` finds changed-or-already-local (#945 * finding — the effective object used to be baked into the local file * verbatim, duplicating every inherited field into it). That pruning keys off * which fields are present in `current`/`next`, so `extends` has to still be * one of them or it would silently vanish from disk on the very next * `config set`. */ function resolveExtendsChain(localRaw, configPath) { const layers = collectExtendsLayers(localRaw, configPath); let merged = {}; for (let i = layers.length - 1; i >= 0; i--) { const layer = layers[i]; merged = deepMergeConfig(merged, i > 0 ? sanitizeInheritedConfig(layer.raw, layer.ref ?? String(i)) : layer.raw); } return merged; } const HOST_LOCAL_CONFIG_KEYS = new Set([ "bundles", "defaultBundle", "defaultWriteTarget", "embedding", "execution", "experimental", "registries", "scheduler", "setup", ]); /** * Shared config contributes portable behavior only. Source ownership, * credentials, executable paths/arguments, and activation remain in the * host's top-level config even when a bundle supplies the inherited file. * LLM endpoints and model selection remain portable; their credentials never * do. */ function sanitizeInheritedConfig(raw, label) { const inherited = { ...raw }; for (const key of HOST_LOCAL_CONFIG_KEYS) { if (!Object.hasOwn(inherited, key)) continue; delete inherited[key]; warnOnce(`config:inherited-host-local:${label}:${key}`, `Ignoring inherited config key ${JSON.stringify(key)} from ${label}; it is host-local and must be declared in the top-level config file.`); } if (isPlainObject(inherited.engines)) { const engines = {}; let strippedAuthority = false; for (const [name, engine] of Object.entries(inherited.engines)) { if (!isPlainObject(engine)) { engines[name] = engine; continue; } const portable = { ...engine }; for (const key of ["apiKey", "apiKeyFile", "bin", "args", "workspace"]) { if (!Object.hasOwn(portable, key)) continue; delete portable[key]; strippedAuthority = true; } engines[name] = portable; } inherited.engines = engines; if (strippedAuthority) { warnOnce(`config:inherited-host-local:${label}:engines-authority`, `Ignoring inherited engine credentials, executable arguments, or workspace from ${label}; those fields are host-local.`); } } if (isPlainObject(inherited.search) && Object.hasOwn(inherited.search, "curateRerank")) { const { curateRerank: _curateRerank, ...portableSearch } = inherited.search; inherited.search = portableSearch; warnOnce(`config:inherited-host-local:${label}:search.curateRerank`, `Ignoring inherited config key "search.curateRerank" from ${label}; network endpoints and credentials are host-local.`); } if (isPlainObject(inherited.improve) && isPlainObject(inherited.improve.strategies)) { const strategies = {}; let strippedSync = false; for (const [name, profile] of Object.entries(inherited.improve.strategies)) { if (isPlainObject(profile) && Object.hasOwn(profile, "sync")) { const { sync: _sync, ...portableProfile } = profile; strategies[name] = portableProfile; strippedSync = true; } else { strategies[name] = profile; } } inherited.improve = { ...inherited.improve, strategies }; if (strippedSync) { warnOnce(`config:inherited-host-local:${label}:improve.sync`, `Ignoring inherited improve strategy sync policy from ${label}; publication policy is host-local.`); } } return inherited; } /** `~` expands to the home directory, mirroring `apiKeyFile`'s resolution (engine-resolution.ts). */ function expandExtendsHomePath(p) { return p.startsWith("~") ? path.join(os.homedir(), p.slice(1)) : p; } /** True when `ref`'s segment before the first `//` is a legal bundle slug — the `bundle//<path>` shape, not a plain filesystem path. */ function looksLikeBundleAssetRef(ref) { const boundary = ref.indexOf("//"); return boundary > 0 && isBundleSlug(ref.slice(0, boundary)); } /** * Resolve an `extends` value (or a `config diff <ref>` CLI argument — same * grammar) to its config text and the absolute path it was read from. Either * a filesystem path (relative to the directory of `fromConfigPath`; `~` * expands) or a `bundle//<path>` ref, where the part after `//` is a plain * file path *relative to that bundle's content root* — NOT an asset * conceptId. It needs no asset type (`scripts/`, `knowledge/`, …) and is * never indexed; a shared config file just lives wherever the bundle puts * it. No URL form and no fetch/sync — the source must already exist locally; * a missing file/bundle throws {@link ConfigError} naming the ref, as does an * empty, absolute, or content-root-escaping path after `//`. */ function resolveConfigRefSource(ref, context, fromConfigPath, containmentRoot) { return looksLikeBundleAssetRef(ref) ? resolveConfigBundleRefSource(ref, context, containmentRoot) : resolveConfigFileRefSource(ref, fromConfigPath, containmentRoot); } function resolveConfigFileRefSource(ref, fromConfigPath, containmentRoot) { const expanded = expandExtendsHomePath(ref); let resolvedPath; if (path.isAbsolute(expanded)) { resolvedPath = expanded; } else if (fromConfigPath) { resolvedPath = path.resolve(path.dirname(fromConfigPath), expanded); } else { throw new ConfigError(`extends "${ref}" is a relative path, but the current config has no known file location to resolve it against.`, "INVALID_CONFIG_FILE"); } if (containmentRoot !== undefined) assertConfigExtendsPhysicalContainment(containmentRoot, resolvedPath, ref); const text = readConfigText(resolvedPath); if (text === undefined) { throw new ConfigError(`extends "${ref}" resolves to ${resolvedPath}, which does not exist. Create the file first, or point "extends" at an existing config.`, "INVALID_CONFIG_FILE"); } return { text, resolvedPath, ...(containmentRoot ? { containmentRoot } : {}) }; } function resolveConfigBundleRefSource(ref, context, outerContainmentRoot) { // Split by hand rather than through `parseBundleRef`: the part after `//` // is a plain file path here, not an asset conceptId, so it must not be run // through conceptId validation (which, for instance, rejects every `..` // segment outright — stricter than the "must not escape the content root" // rule this function enforces itself below via `path.resolve`). // `looksLikeBundleAssetRef` already confirmed `ref` has this `bundle//` // shape with a legal bundle slug before routing here. const boundary = ref.indexOf("//"); const bundleId = ref.slice(0, boundary); const relativePath = ref.slice(boundary + 2); if (!relativePath) { throw new ConfigError(`extends "${ref}" is missing a path after "${bundleId}//".`, "INVALID_CONFIG_FILE"); } if (path.isAbsolute(relativePath)) { throw new ConfigError(`extends "${ref}" must be a path relative to bundle "${bundleId}"'s content root, not absolute.`, "INVALID_CONFIG_FILE"); } const bundles = isRecord(context.bundles) ? context.bundles : undefined; const bundleEntry = bundles?.[bundleId]; const bundlePath = isRecord(bundleEntry) && typeof bundleEntry.path === "string" && bundleEntry.path.length > 0 ? bundleEntry.path : undefined; if (!bundlePath || !isRecord(bundleEntry)) { throw new ConfigError(`extends "${ref}" names bundle "${bundleId}", which is not a configured filesystem bundle (bundles.${bundleId}.path). Only a filesystem bundle can host an "extends" source.`, "INVALID_CONFIG_FILE"); } let componentRoot; try { componentRoot = bundleComponentConfig(bundleEntry)?.root; } catch (err) { throw new ConfigError(`extends "${ref}" names bundle "${bundleId}": ${err instanceof Error ? err.message : String(err)}`, "INVALID_CONFIG_FILE"); } const bundleRoot = bundleContentRoot(bundlePath, componentRoot); const resolvedPath = path.resolve(bundleRoot, relativePath); const relativeToRoot = path.relative(bundleRoot, resolvedPath); if (relativeToRoot === ".." || relativeToRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeToRoot)) { throw new ConfigError(`extends "${ref}" escapes bundle "${bundleId}"'s content root.`, "INVALID_CONFIG_FILE"); } const containmentRoot = fs.realpathSync.native(bundleRoot); assertConfigExtendsPhysicalContainment(containmentRoot, resolvedPath, ref); if (outerContainmentRoot !== undefined) { assertConfigExtendsPhysicalContainment(outerContainmentRoot, resolvedPath, ref); } const text = readConfigText(resolvedPath); if (text === undefined) { throw new ConfigError(`extends "${ref}" resolves to ${resolvedPath}, which does not exist locally. Sync the bundle first, or point "extends" at an existing file.`, "INVALID_CONFIG_FILE"); } return { text, resolvedPath, containmentRoot: outerContainmentRoot ?? containmentRoot }; } function assertConfigExtendsPhysicalContainment(root, candidate, ref) { let physicalRoot; let physicalCandidate; try { physicalRoot = fs.realpathSync.native(root); physicalCandidate = fs.realpathSync.native(candidate); } catch (cause) { throw new ConfigError(`Unable to verify physical containment for extends ${JSON.stringify(ref)}: ${cause instanceof Error ? cause.message : String(cause)}`, "INVALID_CONFIG_FILE"); } const relative = path.relative(physicalRoot, physicalCandidate); if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { throw new ConfigError(`extends ${JSON.stringify(ref)} resolves through a symbolic link outside its bundle content root.`, "INVALID_CONFIG_FILE"); } } /** * `akm config get --show-source` (#945): which raw layer the dotted path's * value comes from — `"local"` when the local file's own raw JSON sets it, * `"extends:<ref>"` for the nearest chain member that sets it (the ref by * which that member is reached), or `"default"` when no layer sets it * (`DEFAULT_CONFIG` or schema default supplies it). Computed lazily by * re-walking the raw layers on each call — no merge-time bookkeeping. */ export function getConfigValueSource(dotted) { const configPath = getConfigPath(); const text = readConfigText(configPath); if (text === undefined) return "default"; const liftedConfig = runConfigFilePipeline(text, configPath); const segments = dotted.split(".").filter((s) => s.length > 0); for (const layer of collectExtendsLayers(liftedConfig, configPath)) { const effectiveLayer = layer.ref === undefined ? layer.raw : sanitizeInheritedConfig(layer.raw, layer.ref); if (hasRawPath(effectiveLayer, segments)) { return layer.ref === undefined ? "local" : `extends:${layer.ref}`; } } return "default"; } function hasRawPath(raw, segments) { let cursor = raw; for (const segment of segments) { if (!isRecord(cursor) || !(segment in cursor)) return false; cursor = cursor[segment]; } return true; } // Exposed for `akm config diff` (config-cli.ts): the `<path|bundle//ref>` // argument shares the exact same resolution grammar as `extends`. export { resolveConfigRefSource }; /** * The configured stash sources as an ordered {@link SourceConfigEntry} list. * * Every source is a `bundles.<slug>` entry. This derives the provider-ready * source list from `bundles` (defaultBundle first, then map order). Returns * `[]` when no bundles are configured. */ export function getSources(config) { return bundlesToSourceEntries(config) ?? []; } export function getEffectiveRegistries(config) { return config.registries ?? DEFAULT_CONFIG.registries ?? []; } /** * Transitional internal accessor. It deliberately never consults a configured * default strategy; callers must pass their already selected strategy. */ export function getImproveProcessConfig(processName, selected) { return selected?.processes?.[processName]; } export function loadConfig() { return loadUserConfig(); } export function saveConfig(config) { // Every lifecycle write produces the only config version this binary can load. const currentConfig = { ...config, configVersion: CURRENT_CONFIG_VERSION }; saveConfigReal(currentConfig); } function saveConfigReal(config) { cachedConfig = undefined; const configPath = getConfigPath(); const dir = path.dirname(configPath); fs.mkdirSync(dir, { recursive: true }); withConfigLock(() => { const validated = validateCompleteConfig(config); backupExistingConfig(configPath); writeConfigAtomic(configPath, sanitizeConfigForWrite(validated)); }); } export function validateCompleteConfig(config) { const parseResult = AkmConfigSchema.safeParse(config); if (parseResult.success) return parseResult.data; const lines = parseResult.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n"); throw new ConfigError(`Refusing to save invalid config:\n${lines}`, "INVALID_CONFIG_FILE", "Fix the listed fields, or undo the offending `akm config set`. " + "If this looks like an akm bug, re-run with --debug to attach the traceback."); } /** * #945 review finding: `mutateConfig` used to write the entire extends-merged * *effective* config (`next`) back to the local file on every `config * set`/`unset`, duplicating every inherited `engines`/`improve.strategies` * field into the local file on the very first ordinary write after adopting * `extends` — defeating the feature (shared config, ≤20-line local files) * and silently freezing the local copy against future upstream changes. * * Reduces `after` (the mutated effective config) down to only what the * mutation actually changed relative to `before` (the effective config * *before* the mutation), plus whatever `localRaw` — the local file's own * raw content, pre-`extends`-merge — already had explicitly. Everything * else that only came along for the ride from `extends`/`DEFAULT_CONFIG` is * left out, so it keeps being read from the base on the next load instead * of being frozen as a local literal. */ function pruneUnchangedInheritedFields(before, after, localRaw) { if (!isPlainObject(after) || !isPlainObject(before)) return after; const localRecord = isPlainObject(localRaw) ? localRaw : undefined; const result = {}; for (const key of Object.keys(after)) { const afterValue = after[key]; const beforeValue = before[key]; const localHasKey = localRecord ? Object.hasOwn(localRecord, key) : false; if (isPlainObject(afterValue) && isPlainObject(beforeValue)) { const pruned = pruneUnchangedInheritedFields(beforeValue, afterValue, localHasKey ? localRecord?.[key] : undefined); const prunedHasContent = isPlainObject(pruned) ? Object.keys(pruned).length > 0 : pruned !== undefined; if (prunedHasContent || localHasKey) result[key] = pruned; continue; } if (localHasKey || !isDeepStrictEqual(afterValue, beforeValue)) { result[key] = afterValue; } } return result; } /** * What to persist for a `mutateConfig`/`mutateConfigWithPrecommit` write: * only the changed-or-already-local fields. This applies both to inherited * configs and ordinary configs: lifecycle mutations must not serialize every * schema default merely because validation materialized it in memory (#972). */ function configWriteBody(localRaw, current, next, persistTopLevelKeys = []) { // A first write keeps the established full-default scaffold. Besides being // useful to a new user, the add -> rejected-install -> remove lifecycle // relies on that symmetry to return a pristine install to DEFAULT_CONFIG. if (localRaw === undefined) return next; const pruned = pruneUnchangedInheritedFields(current, next, localRaw); // A caller may need to persist an explicit user choice even when it equals // the schema default. Interactive setup uses this for semanticSearchMode: // "off" means the user declined the opt-in, not an incidental default that // should disappear from the saved configuration. for (const key of persistTopLevelKeys) { if (Object.hasOwn(next, key)) pruned[key] = next[key]; } pruned.configVersion = CURRENT_CONFIG_VERSION; // Keep existing top-level keys in their authored order. Lifecycle rollback // writes the same logical object twice (add, then remove); reordering those // surviving keys would violate its byte-parity guarantee even though the // parsed JSON is equivalent. Newly changed keys follow in `next` order, // while nested maps (notably `bundles`) retain mutation-selected ordering. const ordered = {}; for (const key of Object.keys(localRaw)) { if (Object.hasOwn(pruned, key)) ordered[key] = pruned[key]; } for (const [key, value] of Object.entries(pruned)) { if (!Object.hasOwn(ordered, key)) ordered[key] = value; } return ordered; } /** * The migrator's one config step. The reader already tolerates every shape * akm has written (`configVersion` read as current, legacy source layout, * `extraParams` lift, unknown keys dropped); this writes that current shape back to * `config.json` — the same body `mutateConfig` writes — so the tolerance * becomes durable. Reports without writing unless `apply` is set. */ export function normalizeConfigFile(configPath, options) { return withConfigLock(() => { const text = readConfigText(configPath); if (text === undefined) return { keys: [], changed: false, applied: false }; const raw = parseConfigText(text, configPath); const localRaw = runConfigFilePipeline(text, configPath); const current = buildEffectiveConfig(localRaw, configPath); const next = validateCompleteConfig({ ...current, configVersion: CURRENT_CONFIG_VERSION }); const body = withSchedulerOnDisk(configWriteBody(localRaw, current, next), next); for (const keyPath of unknownConfigKeyPaths(body)) deleteConfigPath(body, keyPath); // `improve.strategies["graph-refresh"]` is schema-valid (any name is a // legal custom-strategy key), so it never reaches the unknown-key sweep // above. It can only be a leftover override of the deleted graph-refresh // built-in (0.9.17-alpha.9) — resolveImproveStrategy now refuses that // name unconditionally, so the override can never apply again. Drop it // the same way any other retired key is dropped: only by `akm migrate // apply`, never by an ordinary write. const strategies = body.improve?.strategies; if (strategies && Object.hasOwn(strategies, "graph-refresh")) delete strategies["graph-refresh"]; const keys = [...new Set([...Object.keys(raw), ...Object.keys(body)])] .filter((key) => JSON.stringify(raw[key]) !== JSON.stringify(body[key])) .sort(); if (keys.length === 0 || !options.apply) return { keys, changed: keys.length > 0, applied: false }; const backup = backupExistingConfig(configPath); writeConfigAtomic(configPath, body); cachedConfig = undefined; return { keys, changed: true, applied: true, ...(backup ? { backupPath: backup.timestamped } : {}) }; }); } /** * Mutate config under one fail-closed lock spanning read, merge, validation, * ordinary backup, and atomic write. */ export function mutateConfig(mutate, options) { cachedConfig = undefined; const configPath = getConfigPath(); return withConfigLock(() => { const text = readConfigText(configPath); if (text === undefined && options?.absentNoop) { return { config: { ...DEFAULT_CONFIG }, written: false }; } const localRaw = text === undefined ? undefined : runConfigFilePipeline(text, configPath); const current = localRaw === undefined ? { ...DEFAULT_CONFIG } : buildEffectiveConfig(localRaw, configPath); const mutated = mutate(current); if (mutated === current) return { config: current, written: false }; const next = validateCompleteConfig({ ...mutated, configVersion: CURRENT_CONFIG_VERSION }); if (text !== undefined) backupExistingConfig(configPath); fs.mkdirSync(path.dirname(configPath), { recursive: true }); writeConfigAtomic(configPath, sanitizeConfigForWrite(configWriteBody(localRaw, current, next))); return { config: next, written: true }; }); } /** * Mutate config while holding the write lock across one validated pre-commit * side effect. Setup uses this to reject a three-way conflict before creating * its stash, while preventing another config writer from racing the final save. */ export async function mutateConfigWithPrecommit(mutate, precommit, options) { cachedConfig = undefined; const configPath = getConfigPath(); const release = acquireConfigLock(); try { const text = readConfigText(configPath); const localRaw = text === undefined ? undefined : runConfigFilePipeline(text, configPath); const current = localRaw === undefined ? { ...DEFAULT_CONFIG } : buildEffectiveConfig(localRaw, configPath); const mutated = mutate(current); const next = validateCompleteConfig({ ...mutated, configVersion: CURRENT_CONFIG_VERSION }); if (text !== undefined) backupExistingConfig(configPath); const precommitResult = await precommit(next); if (mutated === current) return { config: current, written: false, precommit: precommitResult }; fs.mkdirSync(path.dirname(configPath), { recursive: true }); writeConfigAtomic(configPath, sanitizeConfigForWrite(configWriteBody(localRaw, current, next, options?.persistTopLevelKeys))); return { config: next, written: true, precommit: precommitResult }; } finally { release(); } } const UNBOUND_SCHEDULER_SOURCE_ID = `sha256:${"0".repeat(64)}`; /** * The source identity 0.9.16 bound a scheduler grant to: the configured * bundle's source id, or the implicit `AKM_BUNDLE_DIR` stash's. `undefined` * when the bundle is not active on this host. */ export function schedulerSourceIdFor(config, bundleId) { if (isBundleEnabled(config, bundleId)) return bundleSourceId(config, bundleId); if (config.bundles?.[bundleId] !== undefined || !process.env.AKM_BUNDLE_DIR?.trim()) return undefined; try { const root = resolveStashDir(); const implicitId = deriveBundleId(undefined, root, new Set(Object.keys(config.bundles ?? {}))); return implicitId === bundleId ? filesystemBundleSourceId(root) : undefined; } catch { return undefined; } } /** * `scheduler.enabled` is a list of refs in memory but is written in the * `{kind, ref, sourceId}` shape 0.9.16 reads, so that release still runs * against a config this one wrote (the upgrade rehearsal's read-back). This * release reads either shape; the object form can go once no supported * release is strict about it. */ function schedulerEnabledOnDisk(config) { const enabled = config.scheduler?.enabled; if (enabled === undefined) return undefined; return enabled.map((ref) => { let bundle; let conceptId = ""; try { const parsed = parseBundleRef(ref); bundle = parsed.bundle; conceptId = parsed.conceptId; } catch { return ref; } return { kind: conceptId.startsWith("workflows/") ? "workflow" : "task", ref, sourceId: (bundle !== undefined ? schedulerSourceIdFor(config, bundle) : undefined) ?? UNBOUND_SCHEDULER_SOURCE_ID, }; }); } function withSchedulerOnDisk(body, config) { const onDisk = schedulerEnabledOnDisk(config); if (onDisk === undefined || !isPlainConfigObject(body.scheduler)) return body; return { ...body, scheduler: { ...body.scheduler, enabled: onDisk } }; } /** * Strip literal apiKey fields before writing config to disk. * API keys are expected to come from environment variables * (AKM_EMBED_API_KEY, AKM_LLM_API_KEY, AKM_ENGINE_<NAME>_API_KEY). * * `${VAR}` / `$VAR` references are preserved — they are not secrets, they * are deferred lookups resolved at consumption by `resolveSecret`. Dropping * them would break the documented config-on-disk pattern. * * When a non-reference literal value is stripped, emit a `warn()` so the * user knows their key was dropped and how to provide it at runtime (#474). * Previously the strip was silent — a user invoking `akm setup --from <file> * --yes` with an `apiKey` field expected persistence and got a wiped config * with no feedback. */ export function sanitizeConfigForWrite(config) { const sanitized = { ...config }; const stripped = []; if (config.embedding?.apiKey !== undefined) { const apiKey = config.embedding.apiKey; if (isApiKeyReference(apiKey)) { // Preserve reference verbatim — not a secret. sanitized.embedding = { ...config.embedding }; } else { const { apiKey: _drop, ...rest } = config.embedding; sanitized.embedding = rest; if (apiKey) stripped.push("embedding.apiKey (set AKM_EMBED_API_KEY to provide at runtime)"); } } else if (config.embedding) { sanitized.embedding = { ...config.embedding }; } if (config.engines) { const engines = {}; for (const [name, engine] of Object.entries(config.engines)) { if (engine.kind !== "llm" || engine.apiKey === undefined || isApiKeyReference(engine.apiKey)) { engines[name] = { ...engine }; continue; } const { apiKey: _drop, ...rest } = engine; engines[name] = rest; if (engine.apiKey) stripped.push(`engines.${name}.apiKey`); } sanitized.engines = engines; } if (stripped.length > 0) { warn(`Config sanitizer dropped API key(s) before writing to disk:\n - ${stripped.join("\n - ")}\n\nakm does not persist API keys to config.json. Set the listed environment variables to provide them at runtime, or use \`\${VAR}\` references in your config to defer lookup. See docs/reference/data-and-telemetry.md.`); } if (config.registries) { const droppedRegistries = []; const registries = config.registries.filter((entry) => { if (!hasRegistryUrlCredentials(entry.url)) return true; droppedRegistries.push(formatRegistryLabel(entry)); return false; }); if (droppedRegistries.length > 0) { sanitized.registries = registries; warn(`Config sanitizer dropped registry entr${droppedRegistries.length === 1 ? "y" : "ies"} with URL credentials before writing to disk:\n - ${droppedRegistries.join("\n - ")}\n\nRegistry URLs must be credential-free; configure a credential-free HTTPS endpoint.`); } } return withSchedulerOnDisk(sanitized, config); } export function updateConfig(partial) { return mutateConfig((current) => deepMergeConfig(current, partial)).config; } /** * Resolve a single secret value: expand `${VAR}` / `$VAR` against * `process.env`, or look up `secret://<name>` via `resolveFromStore`. Use this * at apiKey / authorization-header consumption sites (LLM client, embedder, * agent SDK runner) — NOT on the load path. Non-string inputs pass through * unchanged. * * Returns the input unchanged when no substitution markers are present, so * literal API key strings (already-resolved secrets) are zero-cost. * * Other config string values (URLs, endpoints, model names, prompts) are * preserved verbatim on read — only fields explicitly routed through this * helper are expanded. * * A `secret://<name>` value that fails to resolve throws `ConfigError` * (naming the ref, never the secret) rather than silently sending an unusable * credential. */ export function resolveSecret(value, resolveFromStore) { if (value === undefined) return undefined; if (typeof value !== "string") return value; const storeRef = SECRET_STORE_REFERENCE_PATTERN.exec(value)?.[1]; if (storeRef !== undefined) { const resolved = resolveFromStore?.(storeRef) ?? null; if (resolved === null) { throw new ConfigError(`Secret store reference "${value}" did not resolve to a stored value.`, "SECRET_REFERENCE_UNRESOLVED"); } return resolved; } if (!value.includes("$")) return value; return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_match, braced, bare) => { const name = (braced ?? bare); const resolved = process.env[name]; if (!resolved) { // #953: an unset/empty $VAR used to substitute silently, so a gateway // that enforces auth on inference (e.g. Bifrost) failed every call with // an opaque 401 instead of a diagnosable warning naming the variable. warnOnce(`config:empty-env-var:${name}`, `Environment variable ${name} referenced by a $VAR apiKey is unset or empty; the request will be sent without a valid credential.`); } return resolved ?? ""; }); } // Re-export source runtime helpers — implementation lives in config-sources.ts. export { bundleComponentConfig, bundleContentRoot, bundleContentRoots, bundleEntryToSourceEntry, bundleKeyForContentRoot, bundlePhysicalContentRoot, bundleSourceId, bundlesToSourceEntries, installedSourceDescriptor, isBundleEnabled, parseSourceSpec, primaryBundlePath, resolveActiveConfiguredSources, resolveConfiguredSources, } from "./config-sources.js"; /** * Merge a partial user-config override onto a base config. Used by * {@link loadUserConfig} (DEFAULT_CONFIG + on-disk) and {@link updateConfig} * (current config + partial patch). Sub-objects with named records (profiles, * defaults, etc.) shallow-merge; arrays override wholesale. */