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 KhaelorConfig,17 PartialKhaelorConfig,18 PermissionsSection,19 ThinkingMode,20} from "./schema.js";2122export const REDACTED = "[redacted]";2324/** Typed CLI flag inputs (parsed upstream; validated here like any other tier). */25export interface CliConfigFlags {26 model?: string;27 auxModel?: string;28 thinking?: string;29 maxOutputTokens?: number;30}3132export interface LoadConfigOptions {33 flags?: CliConfigFlags;34 /** Project root — `.khaelor/config.json` is looked up here. Default: process.cwd(). */35 cwd?: string;36 /** Environment — only ANTHROPIC_API_KEY is read. Default: process.env. */37 env?: Record<string, string | undefined>;38 /** User config dir containing config.json. Default: ~/.khaelor. Injectable for tests. */39 userConfigDir?: string;40}4142const INSPECT = Symbol.for("nodejs.util.inspect.custom");4344/**45 * The fully merged configuration. The API key is held privately: it is46 * excluded from JSON.stringify, String(), and util.inspect output —47 * secrets never reach logs or the TUI (CLAUDE.md §6).48 */49export class ResolvedConfig implements KhaelorConfig {50 readonly model: string;51 readonly auxModel: string;52 readonly thinking: ThinkingMode;53 readonly maxOutputTokens: number;54 readonly permissions: Readonly<PermissionsSection>;55 /** Source paths that contributed, highest precedence first (for /config display). */56 readonly sources: readonly string[];5758 readonly #apiKey: string | null;5960 constructor(config: KhaelorConfig, apiKey: string | null, sources: readonly string[]) {61 this.model = config.model;62 this.auxModel = config.auxModel;63 this.thinking = config.thinking;64 this.maxOutputTokens = config.maxOutputTokens;65 this.permissions = Object.freeze({ ...config.permissions });66 this.sources = Object.freeze([...sources]);67 this.#apiKey = apiKey;68 }6970 /** The only accessor for the secret — callers must never log or display it. */71 get apiKey(): string | null {72 return this.#apiKey;73 }7475 get hasApiKey(): boolean {76 return this.#apiKey !== null && this.#apiKey.length > 0;77 }7879 /** Redacted view — safe for logs, /config, and debugging. */80 toJSON(): Record<string, unknown> {81 return {82 model: this.model,83 auxModel: this.auxModel,84 thinking: this.thinking,85 maxOutputTokens: this.maxOutputTokens,86 permissions: this.permissions,87 sources: this.sources,88 apiKey: this.hasApiKey ? REDACTED : null,89 };90 }9192 toString(): string {93 return `ResolvedConfig(${JSON.stringify(this.toJSON())})`;94 }9596 [INSPECT](): Record<string, unknown> {97 return this.toJSON();98 }99}100101async function readConfigFile(path: string): Promise<PartialKhaelorConfig | null> {102 let text: string;103 try {104 text = await readFile(path, "utf8");105 } catch (error) {106 const code = (error as NodeJS.ErrnoException).code;107 if (code === "ENOENT" || code === "ENOTDIR") return null;108 throw new KhaelorError("config-io", `Cannot read config file: ${path}`, {109 cause: String(error),110 });111 }112 let parsed: unknown;113 try {114 parsed = JSON.parse(text);115 } catch (error) {116 throw new KhaelorError("config-invalid", `Invalid JSON in config file: ${path}`, {117 cause: String(error),118 });119 }120 return unwrap(validatePartialConfig(parsed, path));121}122123function flagsToPartial(flags: CliConfigFlags): unknown {124 const raw: Record<string, unknown> = {};125 if (flags.model !== undefined) raw["model"] = flags.model;126 if (flags.auxModel !== undefined) raw["auxModel"] = flags.auxModel;127 if (flags.thinking !== undefined) raw["thinking"] = flags.thinking;128 if (flags.maxOutputTokens !== undefined) raw["maxOutputTokens"] = flags.maxOutputTokens;129 return raw;130}131132/**133 * Permission sections merge by key — higher tiers override individual134 * capabilities — except the explicit `rules` arrays, which concatenate135 * (lower tier first) so last-match-wins preserves layer precedence136 * (PERMISSION_MODEL.md §4.2).137 */138function mergePermissions(139 base: PermissionsSection,140 tier: PermissionsSection | undefined,141): PermissionsSection {142 if (tier === undefined) return { ...base };143 const merged: PermissionsSection = { ...base, ...tier };144 const baseRules = base["rules"];145 const tierRules = tier["rules"];146 if (Array.isArray(baseRules) && Array.isArray(tierRules)) {147 merged["rules"] = [...baseRules, ...tierRules];148 }149 return merged;150}151152function mergeTier(base: KhaelorConfig, tier: PartialKhaelorConfig): KhaelorConfig {153 return {154 model: tier.model ?? base.model,155 auxModel: tier.auxModel ?? base.auxModel,156 thinking: tier.thinking ?? base.thinking,157 maxOutputTokens: tier.maxOutputTokens ?? base.maxOutputTokens,158 permissions: mergePermissions(base.permissions, tier.permissions),159 };160}161162/**163 * Load and merge configuration with the CLAUDE.md §6 precedence:164 *165 * CLI flags → .khaelor/config.json (project) → ~/.khaelor/config.json (user) → env → defaults166 *167 * The API key comes exclusively from the ANTHROPIC_API_KEY environment168 * variable and is redacted from every stringification path.169 */170export async function loadConfig(options: LoadConfigOptions = {}): Promise<ResolvedConfig> {171 const cwd = options.cwd ?? process.cwd();172 const env = options.env ?? process.env;173 const userConfigDir = options.userConfigDir ?? join(homedir(), ".khaelor");174175 const userPath = join(userConfigDir, "config.json");176 const projectPath = join(cwd, ".khaelor", "config.json");177178 const userTier = await readConfigFile(userPath);179 const projectTier = await readConfigFile(projectPath);180 const flagsTier = unwrap(validatePartialConfig(flagsToPartial(options.flags ?? {}), "cli-flags"));181182 // Lowest precedence first: defaults → (env: key only) → user → project → flags.183 let merged: KhaelorConfig = {184 ...DEFAULT_CONFIG,185 permissions: { ...DEFAULT_CONFIG.permissions },186 };187 const sources: string[] = [];188 if (userTier !== null) {189 merged = mergeTier(merged, userTier);190 sources.unshift(userPath);191 }192 if (projectTier !== null) {193 merged = mergeTier(merged, projectTier);194 sources.unshift(projectPath);195 }196 merged = mergeTier(merged, flagsTier);197 if (Object.keys(flagsTier).length > 0) sources.unshift("cli-flags");198199 const apiKeyRaw = env["ANTHROPIC_API_KEY"];200 const apiKey = typeof apiKeyRaw === "string" && apiKeyRaw.length > 0 ? apiKeyRaw : null;201202 return new ResolvedConfig(merged, apiKey, sources);203}204