SPB Git

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%
7.1 KB · 215 lines typescript
Raw Blame History
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