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%
6.7 KB · 204 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  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