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
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/.
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.
*/