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

476 lines (475 loc) • 23.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/. import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { writeFileAtomic } from "../../core/common.js"; import { ENGINE_NAME_PATTERN_SOURCE } from "../../core/config/engine-semantics.js"; import { ConfigError, UsageError } from "../../core/errors.js"; import { getConfigDir } from "../../core/paths.js"; import { warnOnce } from "../../core/warn.js"; import { cloneExecutionJsonObject } from "../../execution/json.js"; /** * Installed and operator-owned model intent aliases (#802). `resolveExecution` * expands a recognized alias once, into an exact model and inference defaults * at the layer that chose it; harness builders never reinterpret it. */ export const MODEL_MAP_VERSION = 1; let standaloneInstalledModelMapText; const ENGINE_KEY_PATTERN = new RegExp(ENGINE_NAME_PATTERN_SOURCE); const ALIAS_KEY_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/; const RESERVED_MAP_KEYS = new Set(["__proto__", "constructor", "prototype", "tostring"]); /** * A profile's own field names (#946). When one of these appears directly * under an alias — e.g. `"fast": { "engine": "local-fast" }` — it is not a * per-platform column named `model`/`inference`/`engine`; it is a wildcard * default profile applied to every platform column of that alias, exactly * the flat shorthand the original proposal asked for. A nested per-platform * entry in the same alias (`"fast": { "claude": { ... } }`) still overrides * the wildcard for that one column, so both forms compose. */ const PROFILE_FIELD_KEYS = new Set(["model", "inference", "engine"]); /** Internal key the wildcard default profile is stored under (never user-writable directly). */ export const WILDCARD_ENGINE_KEY = "*"; function assertSafeMapKey(key, source, jsonPath) { if (RESERVED_MAP_KEYS.has(key.toLowerCase())) { invalid(source, jsonPath, "reserved prototype-like key is not allowed"); } } function invalid(source, jsonPath, detail) { throw new ConfigError(`Invalid ${source} at ${jsonPath}: ${detail}`, "INVALID_CONFIG_FILE", `Fix ${source}, or remove the optional user models.json to use AKM's installed defaults.`); } function requireRecord(value, source, jsonPath) { if (value === null || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) { invalid(source, jsonPath, "expected a JSON object"); } return value; } function assertOnlyKeys(value, allowed, source, jsonPath) { const allowedSet = new Set(allowed); for (const key of Object.keys(value)) { if (!allowedSet.has(key)) invalid(source, `${jsonPath}.${key}`, "unknown field"); } } function requireNonemptyString(value, source, jsonPath) { if (typeof value !== "string" || value.length === 0 || value.trim() !== value) { invalid(source, jsonPath, "expected a nonempty string without surrounding whitespace"); } return value; } function freezeRecord(entries) { const record = Object.create(null); for (const [key, value] of entries) record[key] = value; return Object.freeze(record); } function ownValue(record, key) { return record !== undefined && Object.hasOwn(record, key) ? record[key] : undefined; } function parseProfileLayer(value, source, jsonPath) { if (typeof value === "string") return requireNonemptyString(value, source, jsonPath); const record = requireRecord(value, source, jsonPath); assertOnlyKeys(record, ["model", "inference", "engine"], source, jsonPath); if (!Object.hasOwn(record, "model") && !Object.hasOwn(record, "inference") && !Object.hasOwn(record, "engine")) { invalid(source, jsonPath, "structured profile must contain model, inference, and/or engine"); } if (Object.hasOwn(record, "model") && Object.hasOwn(record, "engine")) { invalid(source, jsonPath, "model and engine cannot both be set; engine is an indirection for the model value"); } const out = {}; if (Object.hasOwn(record, "model")) out.model = requireNonemptyString(record.model, source, `${jsonPath}.model`); if (Object.hasOwn(record, "engine")) { const rawEngine = requireNonemptyString(record.engine, source, `${jsonPath}.engine`); const engine = rawEngine.toLowerCase(); assertSafeMapKey(engine, source, `${jsonPath}.engine`); if (!ENGINE_KEY_PATTERN.test(engine)) { invalid(source, `${jsonPath}.engine`, "engine reference must be lowercase kebab-case"); } out.engine = engine; } if (Object.hasOwn(record, "inference")) { if (record.inference === null) out.inference = null; else { try { out.inference = cloneExecutionJsonObject(record.inference, `${source} ${jsonPath}.inference`); } catch (error) { invalid(source, `${jsonPath}.inference`, error instanceof Error ? error.message : String(error)); } } } return Object.freeze(out); } /** Parse one installed or user layer. Partial structured profiles are valid at this stage. */ export function parseModelMapLayer(text, source) { let parsed; try { parsed = JSON.parse(text); } catch { invalid(source, "$", "invalid JSON syntax"); } const root = requireRecord(parsed, source, "$"); assertOnlyKeys(root, ["version", "aliases"], source, "$"); if (root.version !== MODEL_MAP_VERSION) { invalid(source, "$.version", `expected version ${MODEL_MAP_VERSION}`); } if (!Object.hasOwn(root, "aliases")) invalid(source, "$.aliases", "required field is missing"); const rawAliases = requireRecord(root.aliases, source, "$.aliases"); const aliases = []; const aliasesSeen = new Map(); for (const [rawAlias, rawEngines] of Object.entries(rawAliases)) { const alias = rawAlias.toLowerCase(); assertSafeMapKey(alias, source, `$.aliases.${rawAlias}`); if (!ALIAS_KEY_PATTERN.test(alias)) invalid(source, `$.aliases.${rawAlias}`, "alias must be lowercase kebab-case"); const previous = aliasesSeen.get(alias); if (previous !== undefined) { invalid(source, `$.aliases.${rawAlias}`, `alias collides case-insensitively with ${previous}`); } aliasesSeen.set(alias, rawAlias); const engineRecord = requireRecord(rawEngines, source, `$.aliases.${rawAlias}`); if (Object.keys(engineRecord).length === 0) { invalid(source, `$.aliases.${rawAlias}`, "alias must contain at least one engine mapping"); } const engines = []; const enginesSeen = new Map(); const wildcardFields = {}; const platformEntries = []; for (const [rawKey, rawValue] of Object.entries(engineRecord)) { if (PROFILE_FIELD_KEYS.has(rawKey)) wildcardFields[rawKey] = rawValue; else platformEntries.push([rawKey, rawValue]); } if (Object.keys(wildcardFields).length > 0) { engines.push([WILDCARD_ENGINE_KEY, parseProfileLayer(wildcardFields, source, `$.aliases.${rawAlias}`)]); } for (const [rawEngine, rawProfile] of platformEntries) { const engine = rawEngine.toLowerCase(); assertSafeMapKey(engine, source, `$.aliases.${rawAlias}.${rawEngine}`); if (!ENGINE_KEY_PATTERN.test(engine)) { invalid(source, `$.aliases.${rawAlias}.${rawEngine}`, "engine key must be lowercase kebab-case"); } const previousEngine = enginesSeen.get(engine); if (previousEngine !== undefined) { invalid(source, `$.aliases.${rawAlias}.${rawEngine}`, `engine key collides case-insensitively with ${previousEngine}`); } enginesSeen.set(engine, rawEngine); engines.push([engine, parseProfileLayer(rawProfile, source, `$.aliases.${rawAlias}.${rawEngine}`)]); } aliases.push([alias, freezeRecord(engines)]); } return Object.freeze({ version: MODEL_MAP_VERSION, aliases: freezeRecord(aliases) }); } function isJsonObject(value) { return value !== null && value !== undefined && typeof value === "object" && !Array.isArray(value); } function mergeJsonValue(base, overlay) { if (!isJsonObject(base) || !isJsonObject(overlay)) return overlay; const entries = new Map(Object.entries(base)); for (const [key, value] of Object.entries(overlay)) entries.set(key, mergeJsonValue(entries.get(key), value)); return Object.freeze(Object.fromEntries(entries)); } function normalizeLayerEntry(entry) { return typeof entry === "string" ? Object.freeze({ model: entry }) : entry; } function mergeProfiles(base, overlay) { const next = normalizeLayerEntry(overlay); const out = {}; if (base?.model !== undefined) out.model = base.model; if (base?.engine !== undefined) out.engine = base.engine; if (base && Object.hasOwn(base, "inference")) out.inference = base.inference; // A literal `model` and an `engine` indirection are mutually exclusive within // one layer (enforced at parse time); across layers, whichever the nearer // layer sets replaces the other so the merged profile keeps that invariant. if (next.model !== undefined) { out.model = next.model; delete out.engine; } if (next.engine !== undefined) { out.engine = next.engine; delete out.model; } if (Object.hasOwn(next, "inference")) { out.inference = next.inference !== null && next.inference !== undefined && isJsonObject(base?.inference) ? mergeJsonValue(base.inference, next.inference) : next.inference; } return Object.freeze(out); } /** * Derive the `{ model, inference }` a model-map profile borrows from a * configured engine (#946). `resolveExecution` derives an engine's own * defaults with the same function, so the two cannot diverge. `model` is * copied verbatim from the engine's own config value; it must already be * meaningful for the model-map column's platform (akm does not translate * between an engine's connection and an agent platform's own provider * registry). Only `kind: "llm"` engines contribute inference defaults — an * agent-kind engine's schema carries no temperature/thinking fields. */ export function engineModelAndInference(engine) { const out = {}; if (Object.hasOwn(engine, "model") && engine.model !== undefined) out.model = engine.model; if (engine.kind === "llm") { const inference = {}; if (Object.hasOwn(engine, "temperature")) inference.temperature = engine.temperature; if (Object.hasOwn(engine, "maxTokens")) inference.maxTokens = engine.maxTokens; if (Object.hasOwn(engine, "supportsJsonSchema")) inference.supportsJsonSchema = engine.supportsJsonSchema; if (Object.hasOwn(engine, "extraParams")) inference.extraParams = engine.extraParams; if (Object.hasOwn(engine, "contextLength")) inference.contextLength = engine.contextLength; if (Object.hasOwn(engine, "enableThinking")) inference.enableThinking = engine.enableThinking; if (Object.hasOwn(engine, "reasoningEffort")) inference.reasoningEffort = engine.reasoningEffort; if (Object.keys(inference).length > 0) out.inference = inference; } return Object.freeze(out); } /** Overlay user fields over installed fields, per (alias, column), without resolving `engine` indirection. */ function mergeRawProfileLayers(installed, user) { const aliases = new Map(); const apply = (layer) => { for (const [alias, layerEngines] of Object.entries(layer.aliases)) { const mergedEngines = aliases.get(alias) ?? new Map(); const wildcard = ownValue(layerEngines, WILDCARD_ENGINE_KEY); if (wildcard !== undefined) { // This layer's wildcard default (#946) overlays every platform column // already accumulated from earlier layers, before this layer's own // explicit per-platform entries (applied below) get a chance to // override it for their one column. for (const [engineKey, profile] of mergedEngines) { if (engineKey === WILDCARD_ENGINE_KEY) continue; mergedEngines.set(engineKey, mergeProfiles(profile, wildcard)); } } for (const [engineKey, profile] of Object.entries(layerEngines)) { mergedEngines.set(engineKey, mergeProfiles(mergedEngines.get(engineKey), profile)); } aliases.set(alias, mergedEngines); } }; apply(installed); if (user) apply(user); return aliases; } /** * The overlaid-but-unresolved profile per (alias, column), before any * `engine` indirection is expanded. `akm models list` (#946) uses this to * report whether a column resolves through a literal `model` or an `engine` * reference — information {@link mergeModelMapLayers} collapses away once it * performs the final resolution. */ export function mergedModelMapProfiles(installed, user) { const aliases = mergeRawProfileLayers(installed, user); const out = []; for (const [alias, engineProfiles] of aliases) out.push([alias, freezeRecord(engineProfiles)]); return freezeRecord(out); } /** * Overlay user fields over installed fields, resolve any `engine` indirection * against configured engines, then enforce usable merged profiles. */ export function mergeModelMapLayers(installed, user, engines) { const aliases = mergeRawProfileLayers(installed, user); const resolvedAliases = []; for (const [alias, engineProfiles] of aliases) { const resolvedEngines = []; for (const [engineKey, profile] of engineProfiles) { let model = profile.model; let inference = Object.hasOwn(profile, "inference") ? profile.inference : undefined; if (model === undefined && profile.engine !== undefined) { const target = ownValue(engines, profile.engine); if (target === undefined) { invalid("merged models.json", `$.aliases.${alias}.${engineKey}.engine`, `references unknown engine ${JSON.stringify(profile.engine)}; a usable model is required after overlay`); } const expansion = engineModelAndInference(target); if (expansion.model === undefined) { invalid("merged models.json", `$.aliases.${alias}.${engineKey}.engine`, `engine ${JSON.stringify(profile.engine)} has no usable model; a usable model is required after overlay`); } model = expansion.model; inference = inference === undefined ? expansion.inference : inference !== null && isJsonObject(expansion.inference) ? mergeJsonValue(expansion.inference, inference) : inference; } if (model === undefined) { invalid("merged models.json", `$.aliases.${alias}.${engineKey}.model`, "a usable model is required after overlay"); } resolvedEngines.push([engineKey, Object.freeze(inference !== undefined ? { model, inference } : { model })]); } resolvedAliases.push([alias, freezeRecord(resolvedEngines)]); } return Object.freeze({ version: MODEL_MAP_VERSION, aliases: freezeRecord(resolvedAliases) }); } function selectionFromProfile(input, profile) { if (!profile.model) throw new ConfigError(`Model alias ${JSON.stringify(input)} does not resolve to a usable model.`); return Object.freeze({ input, interpretation: "alias", model: profile.model, ...(Object.hasOwn(profile, "inference") ? { inference: profile.inference } : {}), }); } /** Resolve one alias through the merged installed/user registry. */ export function resolveModelMapAlias(input, engine, map) { const alias = input.toLowerCase(); const selectedEngine = engine.toLowerCase(); const tier = ownValue(map.aliases, alias); // A specific platform column always wins; the alias-level wildcard default // (#946) is only a fallback for a column no layer ever set explicitly. const profile = ownValue(tier, selectedEngine) ?? ownValue(tier, WILDCARD_ENGINE_KEY); if (profile !== undefined) return selectionFromProfile(input, profile); const knownAliasUnmappedForEngine = tier !== undefined; if (knownAliasUnmappedForEngine) { warnOnce(`model-map-alias-no-engine-mapping:${alias}:${selectedEngine}`, `[akm] Model alias ${JSON.stringify(input)} has no mapping for engine ${JSON.stringify(engine)}; using ${JSON.stringify(input)} as the literal model name. Add $.aliases.${alias}.${engine} to models.json to map it.`); } return Object.freeze({ input, interpretation: "exact", model: input, ...(knownAliasUnmappedForEngine ? { unmappedForEngine: true } : {}), }); } export function userModelMapPath(env = process.env) { return path.join(getConfigDir(env), "models.json"); } function modelMapFileError(label, filePath, action) { return new ConfigError(`${label} could not be ${action}: ${filePath}.`, "INVALID_CONFIG_FILE", `Ensure ${filePath} is a readable regular file owned by the expected user.`); } /** * Read one models.json. `optional` treats absence (including a dangling * symlink) as "no file"; anything that is not a regular file is refused * rather than read, so a FIFO cannot hang the CLI. */ function readModelMapFile(filePath, label, optional) { let stat; try { stat = fs.statSync(filePath); } catch (error) { if (optional && error?.code === "ENOENT") return undefined; throw modelMapFileError(label, filePath, optional ? "inspected" : "found"); } if (!stat.isFile()) { throw new ConfigError(`Unable to read ${label.toLowerCase()} because it is not a readable regular file: ${filePath}.`, "INVALID_CONFIG_FILE", "Replace it with a readable regular models.json file, or a symlink to one."); } try { return fs.readFileSync(filePath, "utf8"); } catch { throw modelMapFileError(label, filePath, "read"); } } /** Resolve the one authoritative external-asset location for this runtime. */ export function installedModelMapPaths(moduleUrl = import.meta.url) { const modulePath = fileURLToPath(moduleUrl); const moduleDirectory = path.dirname(modulePath); const unbundledModule = path.basename(moduleDirectory) === "agent" && path.basename(path.dirname(moduleDirectory)) === "integrations"; const assetPath = path.resolve(moduleDirectory, unbundledModule ? "../../assets/models.json" : "assets/models.json"); return Object.freeze([assetPath]); } /** * Standalone bootstrap only. `scripts/akm-standalone.ts` derives these bytes * from the authoritative JSON import that Bun embeds into the executable. */ export function registerStandaloneModelMapFallback(text) { standaloneInstalledModelMapText = text; } /** Load installed bytes lazily so health can report a missing/bad package asset. */ export function readInstalledModelMapText(options = {}) { if (options.installedText !== undefined) return options.installedText; // Registration occurs only in the compiled standalone bootstrap. Once set, // the embedded authority must win over any mutable file beside the binary. if (standaloneInstalledModelMapText !== undefined) return standaloneInstalledModelMapText; const candidates = installedModelMapPaths(); for (const candidate of candidates) { const external = readModelMapFile(candidate, "Installed models.json", true); if (external !== undefined) return external; } throw new ConfigError(`Installed models.json is missing from this AKM installation (checked: ${candidates.join(", ")}).`, "INVALID_CONFIG_FILE", "Reinstall AKM so its dist/assets/models.json package asset is restored."); } /** Read and parse the installed authority plus the optional operator overlay, without resolving them. */ export function loadModelMapLayers(options = {}) { const installed = parseModelMapLayer(readInstalledModelMapText(options), "installed models.json"); const userPath = userModelMapPath(options.env); const userText = readModelMapFile(userPath, "User models.json", true); const user = userText !== undefined ? parseModelMapLayer(userText, `user models.json (${userPath})`) : undefined; return Object.freeze({ installed, ...(user ? { user } : {}), userPath, userStatus: user ? "loaded" : "absent" }); } /** Load the installed authority plus the optional operator overlay. */ export function loadModelMap(options = {}) { const { installed, user, userPath, userStatus } = loadModelMapLayers(options); return Object.freeze({ map: mergeModelMapLayers(installed, user, options.engines), userPath, userStatus }); } function targetExistsError(target, detail) { return new UsageError(`${detail}: ${target}`, "RESOURCE_ALREADY_EXISTS", "Move the existing target aside, then retry. Use --overwrite only for a regular file you intend to replace."); } function copyIoError(target, action) { return new ConfigError(`Unable to ${action} user models.json at ${target}.`, "INVALID_CONFIG_FILE", "Check the configuration directory ownership and permissions, then retry."); } /** `akm models copy-defaults`: copy the validated installed map into the config directory. */ export function copyDefaultModelMap(options = {}) { const text = readInstalledModelMapText(options); mergeModelMapLayers(parseModelMapLayer(text, "installed models.json")); const target = userModelMapPath(options.env); let existing; try { existing = fs.lstatSync(target); } catch (error) { if (error?.code !== "ENOENT") throw copyIoError(target, "inspect"); } if (existing && !existing.isFile()) { throw targetExistsError(target, "Refusing to replace a non-regular models.json target"); } if (existing && options.overwrite !== true) throw targetExistsError(target, "User models.json already exists"); try { fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 }); } catch { throw copyIoError(target, "create the configuration directory for"); } try { writeFileAtomic(target, text, 0o600); } catch { throw copyIoError(target, "write"); } return { path: target, copied: true, overwritten: existing !== undefined }; }