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
350 lines (349 loc) • 18.5 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/.
/**
* Zod schema for AkmConfig — the single source of truth for the on-disk shape.
*
* ASSEMBLY BARREL: the individual section schemas live under `./schema/*` (one
* cohesive module per config area — engines, embedding, improve, sources/
* bundles, index, search, output, feedback, workflow, setup, plus shared
* `primitives`). This file assembles them into the top-level {@link AkmConfigShape}
* / {@link AkmConfigSchema}, owns the cross-field {@link AkmConfigSchema.superRefine},
* and RE-EXPORTS every section schema so all existing `./config-schema` imports
* keep working unchanged. The decomposition is a pure structural refactor — the
* generated `schemas/akm-config.json` is byte-identical.
*
* Two responsibilities:
* 1. **Validate + transform** the raw JSON-parsed config object into the runtime
* `AkmConfig` shape consumed by the rest of the codebase. Replaces the
* ~1.4k LOC of legacy per-shape parsers (parseLlmConfig, parseEmbeddingConfig,
* parseIndexConfig, etc.) — see `loadConfig` in `./config.ts`.
* 2. **Reject hard-errored values** (openviking source type, legacy
* `stashes[]` key) at load time via `superRefine`.
*
* Design rules:
* - Top-level uses `.passthrough()` so unknown future keys round-trip intact on
* read; `sanitizeConfigForWrite` decides what to persist.
* - Most nested sub-objects use `.catch(undefined)` so malformed entries are
* silently dropped to keep cold-start working when a user has a field-level
* typo in an optional section.
* - Unsupported top-level source shapes and provider kinds are hard-rejected;
* silently dropping them would mask user data loss.
* - UNKNOWN-KEY POLICY: portable descriptive sections generally use
* passthrough so version skew round-trips newer settings. Small finite
* authority/toggle sections (`scheduler`, `execution`, `experimental`, and
* reranker policy) are strict: a misspelling there must not silently turn a
* safety decision off. The top level remains passthrough and warns on
* unknown keys.
* - `defaultWriteTarget` resolution and similar cross-field invariants are
* enforced at save time via `superRefine` on the top-level schema.
*/
import { z } from "zod";
import { HARNESS_MODEL_WORK_IDS } from "../../integrations/harnesses/ids.js";
import { BUILTIN_IMPROVE_STRATEGY_NAMES, IMPROVE_ENGINE_PROCESSES } from "./engine-semantics.js";
import { EmbeddingConnectionConfigSchema } from "./schema/embedding.js";
import { EnginesSchema } from "./schema/engines.js";
import { ExecutionPolicyConfigSchema } from "./schema/execution.js";
import { ExperimentalConfigSchema } from "./schema/experimental.js";
import { FeedbackConfigSchema } from "./schema/feedback.js";
import { ImproveConfigSchema } from "./schema/improve.js";
import { IndexConfigSchema } from "./schema/index-config.js";
import { OutputConfigSchema } from "./schema/output.js";
import { CURRENT_CONFIG_VERSION, engineName, nonEmptyString, nonNegativeNumber } from "./schema/primitives.js";
import { SchedulerConfigSchema } from "./schema/scheduler.js";
import { SearchConfigSchema } from "./schema/search.js";
import { SetupConfigSchema } from "./schema/setup.js";
import { BundlesConfigSchema, RegistryConfigEntrySchema } from "./schema/sources-bundles.js";
import { WorkflowConfigSchema } from "./schema/workflow.js";
// ── Section re-exports (keep every `./config-schema` import path working) ────
export { EmbeddingConnectionConfigSchema } from "./schema/embedding.js";
export { EngineConfigSchema, EnginesSchema, LlmConnectionConfigSchema, LlmProfileConfigSchema } from "./schema/engines.js";
export { ExperimentalConfigSchema } from "./schema/experimental.js";
export { FEEDBACK_FAILURE_MODES, FeedbackConfigSchema } from "./schema/feedback.js";
export { ImproveConfigSchema } from "./schema/improve.js";
export { ConsolidateProcessConfigSchema, DistillProcessConfigSchema, ExtractProcessConfigSchema, ImproveProcessConfigSchema, ImproveProfileConfigSchema, MemoryInferenceProcessConfigSchema, ProactiveMaintenanceProcessConfigSchema, ReflectProcessConfigSchema, TriageProcessConfigSchema, ValidationProcessConfigSchema, } from "./schema/improve-processes.js";
export { IndexConfigSchema, IndexPassConfigSchema } from "./schema/index-config.js";
export { OutputConfigSchema } from "./schema/output.js";
export { CURRENT_CONFIG_VERSION, LlmInvocationOverridesSchema } from "./schema/primitives.js";
export { SchedulerConfigSchema } from "./schema/scheduler.js";
export { SearchConfigSchema } from "./schema/search.js";
export { SetupConfigSchema } from "./schema/setup.js";
export { BundleConfigEntrySchema, BundlesConfigSchema, RegistryConfigEntrySchema, SourceConfigEntrySchema, } from "./schema/sources-bundles.js";
export { WorkflowConfigSchema } from "./schema/workflow.js";
// ── Defaults ───────────────────────────────────────────────────────────────
export const DefaultsSchema = z
.object({
engine: engineName.optional(),
llmEngine: engineName.optional(),
improveStrategy: engineName.optional(),
})
.passthrough();
// ── Top-level AkmConfig ────────────────────────────────────────────────────
/**
* Base object schema used both as the top-level shape and as the source of
* truth for {@link listTopLevelConfigKeys}. {@link AkmConfigSchema} wraps this
* with cross-field refinements (`.superRefine()`).
*
* All fields validate loudly — typos and shape errors throw at load time. The
* the removed parser's warn-and-drop tolerance was a source of silent
* configuration loss. There is no pre-schema compatibility transform.
*/
export const AkmConfigShape = {
configVersion: z.literal(CURRENT_CONFIG_VERSION),
// #945 — fleet inheritance. A filesystem path (relative to the directory of
// the config file that declares it; `~` expands) or a `bundle//<path>` ref
// naming an already-synced local file to deep-merge underneath this config
// (local keys win) — the part after `//` is a plain file path relative to
// that bundle's content root, not an asset conceptId; it needs no asset
// type and is never indexed. No URL form: config load is synchronous and
// runs on every invocation, and akm deliberately does not fetch network
// resources at load time (see `registries`, never fetched until a
// registry-touching command runs) — a URL-backed shared config should be
// synced via `akm bundle add` (git/website) and referenced as
// `extends: bundle//<path>` once materialized locally. Resolved in
// `resolveExtendsChain` (./config.ts), not validated here (a bad ref
// surfaces as a `ConfigError` at load, naming the ref).
extends: nonEmptyString.optional(),
engines: EnginesSchema.optional(),
defaults: DefaultsSchema.optional(),
semanticSearchMode: z.enum(["off", "auto"]).default("off"),
embedding: EmbeddingConnectionConfigSchema.optional(),
// Host-local execution authority. Inherited layers are stripped.
execution: ExecutionPolicyConfigSchema.optional(),
index: IndexConfigSchema.optional(),
registries: z.array(RegistryConfigEntrySchema).optional(),
// `bundles` + `defaultBundle` are the only source configuration shape. The
// retired `stashDir`/`sources[]`/`installed[]` keys are rejected at load;
// there is no runtime config translator. `defaultBundle` names the primary
// bundle used for short-ref resolution.
bundles: BundlesConfigSchema.optional(),
defaultBundle: nonEmptyString.optional(),
output: OutputConfigSchema.optional(),
defaultWriteTarget: nonEmptyString.optional(),
search: SearchConfigSchema.optional(),
feedback: FeedbackConfigSchema.optional(),
archiveRetentionDays: nonNegativeNumber.optional(),
improve: ImproveConfigSchema.optional(),
workflow: WorkflowConfigSchema.optional(),
// Host-local scheduler grants. Inherited layers are stripped by
// `resolveExtendsChain`; bundle-provided config never activates code.
scheduler: SchedulerConfigSchema.optional(),
setup: SetupConfigSchema.optional(),
// D8 — explicit opt-ins for behaviour outside the stability contract. Every
// key defaults to OFF; see `src/core/config/experimental.ts` for the readers.
experimental: ExperimentalConfigSchema.optional(),
};
export const AkmConfigBaseSchema = z.object(AkmConfigShape).passthrough();
/**
* Per-key overrides for unsupported pre-cutover source shapes.
*/
const RETIRED_SOURCE_SHAPE_KEY_MESSAGES = {
stashDir: "stashDir is not supported; configure `bundles`, or use `akm config path --all` / `akm info` to inspect current paths.",
};
export const AkmConfigSchema = AkmConfigBaseSchema.superRefine((config, ctx) => {
const raw = config;
// Only the current source shape enters the runtime. There is no config
// compatibility path; `bundles` + `defaultBundle` fully supersede these keys.
for (const key of ["stashDir", "sources", "installed"]) {
if (key in raw && raw[key] !== undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [key],
message: RETIRED_SOURCE_SHAPE_KEY_MESSAGES[key] ?? `${key} is not supported; configure the current bundles shape`,
});
}
}
// `defaultBundle`, when present, must name a configured bundle.
if (config.defaultBundle !== undefined) {
if (config.bundles === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["defaultBundle"],
message: "defaultBundle requires a bundles map",
});
}
else if (!(config.defaultBundle in config.bundles)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["defaultBundle"],
message: `defaultBundle "${config.defaultBundle}" does not name a configured bundle`,
});
}
else if (config.bundles[config.defaultBundle]?.enabled === false) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["defaultBundle"],
message: `defaultBundle "${config.defaultBundle}" is disabled`,
});
}
}
if (config.defaultWriteTarget !== undefined) {
const target = config.bundles?.[config.defaultWriteTarget];
if (!target) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["defaultWriteTarget"],
message: `defaultWriteTarget "${config.defaultWriteTarget}" does not name a configured bundle`,
});
}
else if (target.enabled === false) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["defaultWriteTarget"],
message: `defaultWriteTarget "${config.defaultWriteTarget}" is disabled`,
});
}
}
for (const key of ["llm", "agent", "improve"]) {
if (config.defaults && key in config.defaults) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["defaults", key],
message: `defaults.${key} is retired in 0.9`,
});
}
}
for (const [name, engine] of Object.entries(config.engines ?? {})) {
if (engine.kind === "agent" && engine.llmEngine) {
const fallback = config.engines?.[engine.llmEngine];
if (!fallback || fallback.kind !== "llm") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["engines", name, "llmEngine"],
message: "llmEngine must name an LLM engine",
});
}
}
}
const defaultEngine = config.defaults?.engine;
if (defaultEngine && !config.engines?.[defaultEngine]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["defaults", "engine"],
message: "engine does not name a configured engine",
});
}
// One rule for every key unattended model work reads its engine from: the
// engine must confine the model-work tool policy (an LLM, or an agent whose
// harness does), because that work runs over generated content with no one
// watching.
const confining = [...HARNESS_MODEL_WORK_IDS];
const confiningPlatforms = `${confining.slice(0, -1).join(", ")} or ${confining.at(-1)}`;
const modelWorkEngine = (name, path) => {
if (!name)
return;
const engine = config.engines?.[name];
if (!engine) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path, message: "engine does not name a configured engine" });
}
else if (engine.kind !== "llm" && !HARNESS_MODEL_WORK_IDS.has(engine.platform)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path,
message: `engine "${name}" (platform ${engine.platform}) cannot confine the model-work tool policy, which unattended model work requires. Use an LLM engine, or an agent engine on ${confiningPlatforms}.`,
});
}
};
modelWorkEngine(config.defaults?.llmEngine, ["defaults", "llmEngine"]);
for (const [passName, pass] of Object.entries(config.index ?? {})) {
const engine = pass?.engine;
if (typeof engine === "string")
modelWorkEngine(engine, ["index", passName, "engine"]);
}
const workflowJudge = config.workflow?.judgeEngine;
if (workflowJudge && !config.engines?.[workflowJudge]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["workflow", "judgeEngine"],
message: "judgeEngine does not name a configured engine",
});
}
const defaultStrategy = config.defaults?.improveStrategy;
if (defaultStrategy &&
// graph-refresh is exempt from this eager, config-load-time check: unlike
// a plain typo, it is a specific, known retirement, and refusing it here
// would fail every command's config load (including `akm migrate apply`,
// the one that fixes this). `resolveImproveStrategy`
// (src/commands/improve/improve-strategies.ts) refuses it lazily, at
// improve-invocation time, with the same message either way.
defaultStrategy !== "graph-refresh" &&
!BUILTIN_IMPROVE_STRATEGY_NAMES.includes(defaultStrategy) &&
!config.improve?.strategies?.[defaultStrategy]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["defaults", "improveStrategy"],
message: "improveStrategy does not name a built-in or configured strategy",
});
}
for (const [strategyName, strategy] of Object.entries(config.improve?.strategies ?? {})) {
const strategyPath = ["improve", "strategies", strategyName];
modelWorkEngine(strategy.engine, [...strategyPath, "engine"]);
for (const [processName, process] of Object.entries(strategy.processes ?? {})) {
const processConfig = process;
const processPath = [...strategyPath, "processes", processName];
if (processConfig.engine && !IMPROVE_ENGINE_PROCESSES.includes(processName)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [...processPath, "engine"],
message: `${processName} does not dispatch an engine`,
});
}
else {
modelWorkEngine(processConfig.engine, [...processPath, "engine"]);
}
if (processConfig.judgment?.enabled === true) {
modelWorkEngine(processConfig.judgment.engine, [...processPath, "judgment", "engine"]);
}
modelWorkEngine(processConfig.qualityGate?.engine, [...processPath, "qualityGate", "engine"]);
}
}
// #464.a: defaultWriteTarget must name a configured source. 0.9.0 (spec
// §10.1): sources are `bundles` keys, so it must name a bundle. With no
// bundles configured, error out instead of silently accepting (no implicit
// "first writable" fallback — see locked decision 3).
if (config.defaultWriteTarget !== undefined) {
const knownNames = Object.keys(config.bundles ?? {});
if (knownNames.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["defaultWriteTarget"],
message: `defaultWriteTarget "${config.defaultWriteTarget}" cannot be resolved: no bundles configured. ` +
"Add at least one entry to `bundles` first.",
});
}
else if (!knownNames.includes(config.defaultWriteTarget)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["defaultWriteTarget"],
message: `defaultWriteTarget "${config.defaultWriteTarget}" does not match any configured bundle: ${knownNames.map((n) => `"${n}"`).join(", ")}.`,
});
}
}
});
/**
* Validate a raw object against {@link AkmConfigSchema}. Returns a structured
* result so callers can render errors as a list (instead of throwing on the
* first issue).
*/
export function validateConfigShape(raw) {
const result = AkmConfigSchema.safeParse(raw);
if (result.success) {
return { ok: true, value: result.data, errors: [] };
}
return {
ok: false,
errors: result.error.issues.map((issue) => ({
path: issue.path.join("."),
message: issue.message,
})),
};
}
// ── Top-level key listing (for hint messages) ───────────────────────────────
/**
* Return the sorted list of top-level config keys recognized by the schema.
* Used by error hints so the list stays in sync with the schema automatically
* (#460).
*/
export function listTopLevelConfigKeys() {
return Object.keys(AkmConfigShape).sort();
}