spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1/**2 * KHAELOR3 * File: src/config/loader.ts4 * Description: Config loading and precedence merge (flags > project > user > env > defaults) with secret redaction.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { readFile } from "node:fs/promises";11import { homedir } from "node:os";12import { join } from "node:path";13import { KhaelorError, unwrap } from "../shared/index.js";14import { DEFAULT_CONFIG, validatePartialConfig } from "./schema.js";15import type {16 GateSection,17 KhaelorConfig,18 PartialKhaelorConfig,19 PermissionsSection,20 ThinkingMode,21} from "./schema.js";2223export const REDACTED = "[redacted]";2425/** Typed CLI flag inputs (parsed upstream; validated here like any other tier). */26export interface CliConfigFlags {27 model?: string;28 auxModel?: string;29 thinking?: string;30 maxOutputTokens?: number;31 /** `khaelor --gate strict|auto|off` (v2 §1). */32 gate?: string;33}3435export interface LoadConfigOptions {36 flags?: CliConfigFlags;37 /** Project root — `.khaelor/config.json` is looked up here. Default: process.cwd(). */38 cwd?: string;39 /** Environment — only ANTHROPIC_API_KEY is read. Default: process.env. */40 env?: Record<string, string | undefined>;41 /** User config dir containing config.json. Default: ~/.khaelor. Injectable for tests. */42 userConfigDir?: string;43}4445const INSPECT = Symbol.for("nodejs.util.inspect.custom");4647/**48 * The fully merged configuration. The API key is held privately: it is49 * excluded from JSON.stringify, String(), and util.inspect output —50 * secrets never reach logs or the TUI (CLAUDE.md §6).51 */52export class ResolvedConfig implements KhaelorConfig {53 readonly model: string;54 readonly auxModel: string;55 readonly thinking: ThinkingMode;56 readonly maxOutputTokens: number;57 readonly permissions: Readonly<PermissionsSection>;58 readonly gate: Readonly<GateSection>;59 /** Source paths that contributed, highest precedence first (for /config display). */60 readonly sources: readonly string[];6162 readonly #apiKey: string | null;6364 constructor(config: KhaelorConfig, apiKey: string | null, sources: readonly string[]) {65 this.model = config.model;66 this.auxModel = config.auxModel;67 this.thinking = config.thinking;68 this.maxOutputTokens = config.maxOutputTokens;69 this.permissions = Object.freeze({ ...config.permissions });70 this.gate = Object.freeze({71 mode: config.gate.mode,72 autoApprove: Object.freeze({ ...config.gate.autoApprove }),73 });74 this.sources = Object.freeze([...sources]);75 this.#apiKey = apiKey;76 }7778 /** The only accessor for the secret — callers must never log or display it. */79 get apiKey(): string | null {80 return this.#apiKey;81 }8283 get hasApiKey(): boolean {84 return this.#apiKey !== null && this.#apiKey.length > 0;85 }8687 /** Redacted view — safe for logs, /config, and debugging. */88 toJSON(): Record<string, unknown> {89 return {90 model: this.model,91 auxModel: this.auxModel,92 thinking: this.thinking,93 maxOutputTokens: this.maxOutputTokens,94 permissions: this.permissions,95 gate: this.gate,96 sources: this.sources,97 apiKey: this.hasApiKey ? REDACTED : null,98 };99 }100101 toString(): string {102 return `ResolvedConfig(${JSON.stringify(this.toJSON())})`;103 }104105 [INSPECT](): Record<string, unknown> {106 return this.toJSON();107 }108}109110async function readConfigFile(path: string): Promise<PartialKhaelorConfig | null> {111 let text: string;112 try {113 text = await readFile(path, "utf8");114 } catch (error) {115 const code = (error as NodeJS.ErrnoException).code;116 if (code === "ENOENT" || code === "ENOTDIR") return null;117 throw new KhaelorError("config-io", `Cannot read config file: ${path}`, {118 cause: String(error),119 });120 }121 let parsed: unknown;122 try {123 parsed = JSON.parse(text);124 } catch (error) {125 throw new KhaelorError("config-invalid", `Invalid JSON in config file: ${path}`, {126 cause: String(error),127 });128 }129 return unwrap(validatePartialConfig(parsed, path));130}131132function flagsToPartial(flags: CliConfigFlags): unknown {133 const raw: Record<string, unknown> = {};134 if (flags.model !== undefined) raw["model"] = flags.model;135 if (flags.auxModel !== undefined) raw["auxModel"] = flags.auxModel;136 if (flags.thinking !== undefined) raw["thinking"] = flags.thinking;137 if (flags.maxOutputTokens !== undefined) raw["maxOutputTokens"] = flags.maxOutputTokens;138 if (flags.gate !== undefined) raw["gate"] = { mode: flags.gate };139 return raw;140}141142/**143 * Permission sections merge by key — higher tiers override individual144 * capabilities — except the explicit `rules` arrays, which concatenate145 * (lower tier first) so last-match-wins preserves layer precedence146 * (PERMISSION_MODEL.md §4.2).147 */148function mergePermissions(149 base: PermissionsSection,150 tier: PermissionsSection | undefined,151): PermissionsSection {152 if (tier === undefined) return { ...base };153 const merged: PermissionsSection = { ...base, ...tier };154 const baseRules = base["rules"];155 const tierRules = tier["rules"];156 if (Array.isArray(baseRules) && Array.isArray(tierRules)) {157 merged["rules"] = [...baseRules, ...tierRules];158 }159 return merged;160}161162function mergeTier(base: KhaelorConfig, tier: PartialKhaelorConfig): KhaelorConfig {163 return {164 model: tier.model ?? base.model,165 auxModel: tier.auxModel ?? base.auxModel,166 thinking: tier.thinking ?? base.thinking,167 maxOutputTokens: tier.maxOutputTokens ?? base.maxOutputTokens,168 permissions: mergePermissions(base.permissions, tier.permissions),169 gate: tier.gate ?? base.gate,170 };171}172173/**174 * Load and merge configuration with the CLAUDE.md §6 precedence:175 *176 * CLI flags → .khaelor/config.json (project) → ~/.khaelor/config.json (user) → env → defaults177 *178 * The API key comes exclusively from the ANTHROPIC_API_KEY environment179 * variable and is redacted from every stringification path.180 */181export async function loadConfig(options: LoadConfigOptions = {}): Promise<ResolvedConfig> {182 const cwd = options.cwd ?? process.cwd();183 const env = options.env ?? process.env;184 const userConfigDir = options.userConfigDir ?? join(homedir(), ".khaelor");185186 const userPath = join(userConfigDir, "config.json");187 const projectPath = join(cwd, ".khaelor", "config.json");188189 const userTier = await readConfigFile(userPath);190 const projectTier = await readConfigFile(projectPath);191 const flagsTier = unwrap(validatePartialConfig(flagsToPartial(options.flags ?? {}), "cli-flags"));192193 // Lowest precedence first: defaults → (env: key only) → user → project → flags.194 let merged: KhaelorConfig = {195 ...DEFAULT_CONFIG,196 permissions: { ...DEFAULT_CONFIG.permissions },197 };198 const sources: string[] = [];199 if (userTier !== null) {200 merged = mergeTier(merged, userTier);201 sources.unshift(userPath);202 }203 if (projectTier !== null) {204 merged = mergeTier(merged, projectTier);205 sources.unshift(projectPath);206 }207 merged = mergeTier(merged, flagsTier);208 if (Object.keys(flagsTier).length > 0) sources.unshift("cli-flags");209210 const apiKeyRaw = env["ANTHROPIC_API_KEY"];211 const apiKey = typeof apiKeyRaw === "string" && apiKeyRaw.length > 0 ? apiKeyRaw : null;212213 return new ResolvedConfig(merged, apiKey, sources);214}215