/** * KHAELOR * File: src/config/loader.ts * Description: Config loading and precedence merge (flags > project > user > env > defaults) with secret redaction. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { readFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { KhaelorError, unwrap } from "../shared/index.js"; import { DEFAULT_CONFIG, validatePartialConfig } from "./schema.js"; import type { GateSection, KhaelorConfig, PartialKhaelorConfig, PermissionsSection, ThinkingMode, } from "./schema.js"; export const REDACTED = "[redacted]"; /** Typed CLI flag inputs (parsed upstream; validated here like any other tier). */ export interface CliConfigFlags { model?: string; auxModel?: string; thinking?: string; maxOutputTokens?: number; /** `khaelor --gate strict|auto|off` (v2 §1). */ gate?: string; } export interface LoadConfigOptions { flags?: CliConfigFlags; /** Project root — `.khaelor/config.json` is looked up here. Default: process.cwd(). */ cwd?: string; /** Environment — only ANTHROPIC_API_KEY is read. Default: process.env. */ env?: Record; /** User config dir containing config.json. Default: ~/.khaelor. Injectable for tests. */ userConfigDir?: string; } const INSPECT = Symbol.for("nodejs.util.inspect.custom"); /** * The fully merged configuration. The API key is held privately: it is * excluded from JSON.stringify, String(), and util.inspect output — * secrets never reach logs or the TUI (CLAUDE.md §6). */ export class ResolvedConfig implements KhaelorConfig { readonly model: string; readonly auxModel: string; readonly thinking: ThinkingMode; readonly maxOutputTokens: number; readonly permissions: Readonly; readonly gate: Readonly; /** Source paths that contributed, highest precedence first (for /config display). */ readonly sources: readonly string[]; readonly #apiKey: string | null; constructor(config: KhaelorConfig, apiKey: string | null, sources: readonly string[]) { this.model = config.model; this.auxModel = config.auxModel; this.thinking = config.thinking; this.maxOutputTokens = config.maxOutputTokens; this.permissions = Object.freeze({ ...config.permissions }); this.gate = Object.freeze({ mode: config.gate.mode, autoApprove: Object.freeze({ ...config.gate.autoApprove }), }); this.sources = Object.freeze([...sources]); this.#apiKey = apiKey; } /** The only accessor for the secret — callers must never log or display it. */ get apiKey(): string | null { return this.#apiKey; } get hasApiKey(): boolean { return this.#apiKey !== null && this.#apiKey.length > 0; } /** Redacted view — safe for logs, /config, and debugging. */ toJSON(): Record { return { model: this.model, auxModel: this.auxModel, thinking: this.thinking, maxOutputTokens: this.maxOutputTokens, permissions: this.permissions, gate: this.gate, sources: this.sources, apiKey: this.hasApiKey ? REDACTED : null, }; } toString(): string { return `ResolvedConfig(${JSON.stringify(this.toJSON())})`; } [INSPECT](): Record { return this.toJSON(); } } async function readConfigFile(path: string): Promise { let text: string; try { text = await readFile(path, "utf8"); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code === "ENOENT" || code === "ENOTDIR") return null; throw new KhaelorError("config-io", `Cannot read config file: ${path}`, { cause: String(error), }); } let parsed: unknown; try { parsed = JSON.parse(text); } catch (error) { throw new KhaelorError("config-invalid", `Invalid JSON in config file: ${path}`, { cause: String(error), }); } return unwrap(validatePartialConfig(parsed, path)); } function flagsToPartial(flags: CliConfigFlags): unknown { const raw: Record = {}; if (flags.model !== undefined) raw["model"] = flags.model; if (flags.auxModel !== undefined) raw["auxModel"] = flags.auxModel; if (flags.thinking !== undefined) raw["thinking"] = flags.thinking; if (flags.maxOutputTokens !== undefined) raw["maxOutputTokens"] = flags.maxOutputTokens; if (flags.gate !== undefined) raw["gate"] = { mode: flags.gate }; return raw; } /** * Permission sections merge by key — higher tiers override individual * capabilities — except the explicit `rules` arrays, which concatenate * (lower tier first) so last-match-wins preserves layer precedence * (PERMISSION_MODEL.md §4.2). */ function mergePermissions( base: PermissionsSection, tier: PermissionsSection | undefined, ): PermissionsSection { if (tier === undefined) return { ...base }; const merged: PermissionsSection = { ...base, ...tier }; const baseRules = base["rules"]; const tierRules = tier["rules"]; if (Array.isArray(baseRules) && Array.isArray(tierRules)) { merged["rules"] = [...baseRules, ...tierRules]; } return merged; } function mergeTier(base: KhaelorConfig, tier: PartialKhaelorConfig): KhaelorConfig { return { model: tier.model ?? base.model, auxModel: tier.auxModel ?? base.auxModel, thinking: tier.thinking ?? base.thinking, maxOutputTokens: tier.maxOutputTokens ?? base.maxOutputTokens, permissions: mergePermissions(base.permissions, tier.permissions), gate: tier.gate ?? base.gate, }; } /** * Load and merge configuration with the CLAUDE.md §6 precedence: * * CLI flags → .khaelor/config.json (project) → ~/.khaelor/config.json (user) → env → defaults * * The API key comes exclusively from the ANTHROPIC_API_KEY environment * variable and is redacted from every stringification path. */ export async function loadConfig(options: LoadConfigOptions = {}): Promise { const cwd = options.cwd ?? process.cwd(); const env = options.env ?? process.env; const userConfigDir = options.userConfigDir ?? join(homedir(), ".khaelor"); const userPath = join(userConfigDir, "config.json"); const projectPath = join(cwd, ".khaelor", "config.json"); const userTier = await readConfigFile(userPath); const projectTier = await readConfigFile(projectPath); const flagsTier = unwrap(validatePartialConfig(flagsToPartial(options.flags ?? {}), "cli-flags")); // Lowest precedence first: defaults → (env: key only) → user → project → flags. let merged: KhaelorConfig = { ...DEFAULT_CONFIG, permissions: { ...DEFAULT_CONFIG.permissions }, }; const sources: string[] = []; if (userTier !== null) { merged = mergeTier(merged, userTier); sources.unshift(userPath); } if (projectTier !== null) { merged = mergeTier(merged, projectTier); sources.unshift(projectPath); } merged = mergeTier(merged, flagsTier); if (Object.keys(flagsTier).length > 0) sources.unshift("cli-flags"); const apiKeyRaw = env["ANTHROPIC_API_KEY"]; const apiKey = typeof apiKeyRaw === "string" && apiKeyRaw.length > 0 ? apiKeyRaw : null; return new ResolvedConfig(merged, apiKey, sources); }