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%
15.7 KB · 438 lines typescript
Raw Blame History
1#!/usr/bin/env node2/**3 * KHAELOR4 * File: src/cli/main.ts5 * Description: The khaelor bin entry — argv parsing, config resolution, fast-startup interactive wiring, --print mode.6 *7 * Author: Simon-Pierre Boucher8 * Contact: contact@spboucher.ai9 */1011import { realpathSync } from "node:fs";12import { homedir } from "node:os";13import { join } from "node:path";14import { pathToFileURL } from "node:url";15import { loadConfig } from "../config/index.js";16import type { ResolvedConfig } from "../config/index.js";17import { resolveContextWindow } from "../context/index.js";18import type { PermissionAnswer, PermissionAsker, PermissionPrompt } from "../permissions/index.js";19import { RepositoryMap } from "../repository/index.js";20import { defaultSessionsDir } from "../session/index.js";21import { TuiApp, fuzzyFilter } from "../tui/index.js";22import type { PaletteItem, PermissionDecision } from "../tui/index.js";23import {24  KHAELOR_VERSION,25  helpText,26  missingApiKeyMessage,27  parseArgs,28} from "./args.js";29import type { ParsedArgs } from "./args.js";30import { registerCliCommands } from "./commands.js";31import { assembleEngine, openSessionContext } from "./engine.js";32import type { Engine } from "./engine.js";33import {34  QuitGuard,35  installCrashHandlers,36  installSignalShutdown,37  quitConfirmationLines,38} from "./lifecycle.js";39import { FileLogger, defaultLogDir } from "./logger.js";40import { runPrintMode } from "./print.js";41import { renderResumedTranscript } from "./sessions.js";4243// ───────────────────────── permission bridge ─────────────────────────4445/**46 * Bridges PermissionService.ask to the TUI permission panel: the service47 * publishes PermissionRequested (the panel renders from that durable event);48 * the user's key press arrives via TuiAppActions.permission and resolves the49 * pending ask. Silence never resolves to allow.50 */51class TuiPermissionAsker implements PermissionAsker {52  #pending: { resolve: (answer: PermissionAnswer) => void; prompt: PermissionPrompt } | null =53    null;5455  ask(prompt: PermissionPrompt): Promise<PermissionAnswer> {56    return new Promise((resolve) => {57      this.#pending = { resolve, prompt };58    });59  }6061  answer(_requestId: string, decision: PermissionDecision): void {62    const pending = this.#pending;63    if (pending === null) return;64    this.#pending = null;65    if (decision === "deny") {66      pending.resolve({ kind: "deny" });67      return;68    }69    if (decision === "allow-always") {70      const pattern = pending.prompt.requests.flatMap((r) => r.alwaysPatterns)[0];71      pending.resolve(72        pattern !== undefined ? { kind: "allow-always", pattern } : { kind: "allow-once" },73      );74      return;75    }76    pending.resolve({ kind: "allow-once" });77  }78}7980// ───────────────────────── interactive session ─────────────────────────8182type SessionEnd = { kind: "quit" } | { kind: "new" } | { kind: "resume"; sessionId: string };8384interface InteractiveOptions {85  config: ResolvedConfig;86  logger: FileLogger;87  cwd: string;88  sessionsDir: string;89  resumeId?: string;90}9192function abbreviateHome(path: string): string {93  const home = homedir();94  return path.startsWith(home) ? `~${path.slice(home.length)}` : path;95}9697async function listModelsLive(config: ResolvedConfig): Promise<{ id: string; displayName?: string }[]> {98  // Lazy SDK import — never in the boot path (ARCHITECTURE.md §12.1).99  const { default: Anthropic } = await import("@anthropic-ai/sdk");100  const sdk = new Anthropic({ apiKey: config.apiKey ?? "", maxRetries: 0, timeout: 5_000 });101  const models: { id: string; displayName?: string }[] = [];102  for await (const model of sdk.models.list()) {103    models.push({ id: model.id, displayName: model.display_name });104    if (models.length >= 25) break;105  }106  return models;107}108109/** Run one interactive session; resolves with how the next iteration should start. */110async function runInteractiveSession(options: InteractiveOptions): Promise<SessionEnd> {111  const { config, logger, cwd } = options;112113  const context = await openSessionContext({114    cwd,115    sessionsDir: options.sessionsDir,116    ...(options.resumeId !== undefined ? { resumeId: options.resumeId } : {}),117    logger,118  });119120  const asker = new TuiPermissionAsker();121  const quitGuard = new QuitGuard();122123  const engineRef: { current: Engine | null } = { current: null };124  let resolveReady: (engine: Engine) => void = () => {};125  const ready = new Promise<Engine>((resolve) => {126    resolveReady = resolve;127  });128  let endSession: (end: SessionEnd) => void = () => {};129  const ended = new Promise<SessionEnd>((resolve) => {130    endSession = resolve;131  });132133  const driveTurn = (active: Engine): void => {134    void active135      .runTurn()136      .then((outcome) => {137        if (outcome.kind === "failed") {138          logger.error("turn failed", { reason: outcome.reason, detail: outcome.detail });139        }140      })141      .catch((error: unknown) => {142        const message = error instanceof Error ? error.message : String(error);143        logger.error("turn crashed", { error: message });144        app.printBlock(["", ` turn crashed: ${message}`, "   details in ~/.khaelor/logs/"]);145      });146  };147148  const runShell = async (active: Engine, command: string): Promise<void> => {149    app.printBlock(["", ` ! ${command}`]);150    try {151      const result = await active.workspace.exec({ cmd: command, timeoutMs: 30_000 });152      const output = `${result.stdout}${result.stderr}`.split("\n").filter((l) => l !== "");153      const capped = output.slice(0, 40).map((line) => `   ${line}`);154      if (output.length > 40) capped.push(`   … ${output.length - 40} more lines`);155      capped.push(`   exit ${result.exitCode ?? "killed"} · ${result.durationMs}ms`);156      app.printBlock(capped);157    } catch (error) {158      app.printBlock([`   shell failed: ${error instanceof Error ? error.message : String(error)}`]);159    }160  };161162  const handleQuit = async (): Promise<void> => {163    const active = await ready;164    const running = active.processes.list().filter((p) => p.status === "running").length;165    if (quitGuard.request(running) === "confirm") {166      app.printBlock(quitConfirmationLines(running));167      return;168    }169    endSession({ kind: "quit" });170  };171172  // One-shot composer capture — /rename consumes the next submit as its input.173  const captureNextRef: { current: ((text: string) => void) | null } = { current: null };174175  const app = new TuiApp({176    bus: context.bus,177    model: config.model,178    thinking: config.thinking,179    cwdLabel: abbreviateHome(cwd),180    contextWindow: resolveContextWindow(config.model),181    actions: {182      submit: (text, opts) => {183        if (!opts.shell && captureNextRef.current !== null) {184          const consume = captureNextRef.current;185          captureNextRef.current = null;186          consume(text);187          return;188        }189        void ready.then(async (active) => {190          if (opts.shell) {191            await runShell(active, text);192            return;193          }194          if (active.turnActive) {195            active.steering.queue(text);196            return;197          }198          active.session.publishDurable({199            type: "user.message-created",200            payload: { text, mentions: [] },201          });202          driveTurn(active);203        });204      },205      interrupt: () => {206        void ready.then((active) => {207          active.interruption.interrupt();208        });209      },210      permission: (requestId, decision) => {211        asker.answer(requestId, decision);212      },213      quit: () => {214        void handleQuit();215      },216    },217  });218219  // FIRST PAINT — everything below is lazy/background (§12.1 budget).220  await app.start();221  if (context.resumed) {222    app.printBlock(renderResumedTranscript(context.log.replayedEvents));223  }224225  const switchGuard = (next: SessionEnd): void => {226    if (engineRef.current !== null && engineRef.current.turnActive) {227      app.printBlock(["", " a turn is running — Esc to interrupt it first"]);228      return;229    }230    endSession(next);231  };232233  // Background: assemble the engine and wire the real commands.234  void (async () => {235    try {236      const assembled = await assembleEngine({237        config,238        cwd,239        context,240        asker,241        logger,242        // Strict-mode design approval (v2 §1): the panel content prints, the243        // selector decides; Esc rejects — silence is never approval.244        designAsker: {245          askDesign: (_artifactId, artifact) =>246            new Promise((resolve) => {247              app.printBlock([248                "",249                " ◑ DESIGN — approval required",250                `   Goal      ${artifact.goal}`,251                `   Files     ${artifact.filesTouched.join(", ")}`,252                ...artifact.approach.split("\n").slice(0, 6).map((line, i) => `   ${i === 0 ? "Approach " : "         "}${line}`),253                ...(artifact.risks.length > 0 ? [`   Risks     ${artifact.risks.join(" · ")}`] : []),254                `   Verify    ${artifact.verification}`,255                ...(artifact.outOfScope.length > 0 ? [`   Not doing ${artifact.outOfScope.join(" · ")}`] : []),256              ]);257              app.openSelector(258                [259                  { id: "approve", label: "Approve design", detail: "unlock the implement phase" },260                  { id: "reject", label: "Reject design", detail: "send KHAELOR back to the drawing board" },261                ],262                (id) => resolve({ approved: id === "approve" }),263                () => resolve({ approved: false, reason: "dismissed without approval" }),264              );265            }),266        },267      });268      engineRef.current = assembled;269      registerCliCommands(app.commands, {270        ui: app,271        engine: () => assembled,272        config,273        cwd,274        sessionsDir: options.sessionsDir,275        projectHash: context.hash,276        actions: {277          newSession: () => switchGuard({ kind: "new" }),278          resumeSession: (sessionId) => switchGuard({ kind: "resume", sessionId }),279          quit: () => {280            void handleQuit();281          },282          captureNextSubmit: (consume) => {283            captureNextRef.current = consume;284          },285        },286        listModels: () => listModelsLive(config),287      });288      resolveReady(assembled);289290      // Deeper background: baseline, branch, repository index for @mentions.291      void assembled.captureBaseline();292      void assembled.git.currentBranch().then((branch) => {293        app.setGitBranch(branch.kind === "ok" ? branch.value : null);294      });295      void new RepositoryMap(assembled.workspace)296        .fileMap()297        .then((entries) => {298          const paths = entries.map((entry) => entry.path);299          app.setMentionProvider((query): PaletteItem[] => {300            if (query === "") return paths.slice(0, 8).map((p) => ({ id: p, label: p }));301            return fuzzyFilter(query, paths, (p) => p)302              .slice(0, 8)303              .map((ranked) => ({ id: ranked.item, label: ranked.item }));304          });305        })306        .catch((error: unknown) => {307          logger.debug("repository map unavailable", {308            error: error instanceof Error ? error.message : String(error),309          });310        });311    } catch (error) {312      const message = error instanceof Error ? error.message : String(error);313      logger.error("engine assembly failed", { error: message });314      app.printBlock(["", ` startup failed: ${message}`, "   details in ~/.khaelor/logs/"]);315    }316  })();317318  const end = await ended;319  app.stop();320  if (engineRef.current !== null) await engineRef.current.shutdown();321  else await context.log.close();322  return end;323}324325// ───────────────────────── entry ─────────────────────────326327export async function main(argv: readonly string[] = process.argv.slice(2)): Promise<void> {328  const args: ParsedArgs = parseArgs(argv);329  if (args.errors.length > 0) {330    process.stderr.write(`khaelor: ${args.errors.join("; ")}\n\n${helpText()}`);331    process.exitCode = 2;332    return;333  }334  if (args.help) {335    process.stdout.write(helpText());336    return;337  }338  if (args.version) {339    process.stdout.write(`khaelor ${KHAELOR_VERSION}\n`);340    return;341  }342  if (args.doctorTui) {343    const { doctorReport } = await import("../tui/doctor.js");344    const report = doctorReport(345      {346        ...(process.env["TERM"] !== undefined ? { TERM: process.env["TERM"] } : {}),347        ...(process.env["COLORTERM"] !== undefined ? { COLORTERM: process.env["COLORTERM"] } : {}),348        ...(process.env["NO_COLOR"] !== undefined ? { NO_COLOR: process.env["NO_COLOR"] } : {}),349        ...(process.env["LANG"] !== undefined ? { LANG: process.env["LANG"] } : {}),350        ...(process.env["LC_ALL"] !== undefined ? { LC_ALL: process.env["LC_ALL"] } : {}),351        ...(process.env["TERM_PROGRAM"] !== undefined ? { TERM_PROGRAM: process.env["TERM_PROGRAM"] } : {}),352      },353      process.stdout.isTTY === true,354      process.stdout.columns ?? 80,355    );356    process.stdout.write(`${report.join("\n")}\n`);357    return;358  }359  if (args.splash && process.stdout.isTTY === true) {360    // The full opening moment (~600 ms), skippable by any key (TUI v2 §2).361    const { renderSplashFrame, SPLASH_FRAMES } = await import("../tui/splash.js");362    const truecolor = process.env["COLORTERM"] === "truecolor" && process.env["NO_COLOR"] === undefined;363    let skipped = false;364    const onKey = (): void => {365      skipped = true;366    };367    process.stdin.on("data", onKey);368    for (let frame = 0; frame < SPLASH_FRAMES && !skipped; frame += 1) {369      const lines = renderSplashFrame(frame, truecolor);370      if (frame > 0) process.stdout.write(`\x1b[${lines.length}A`);371      process.stdout.write(`${lines.join("\x1b[K\n")}\x1b[K\n`);372      await new Promise((resolve) => setTimeout(resolve, 60));373    }374    process.stdin.off("data", onKey);375  }376377  const logger = new FileLogger(join(defaultLogDir(), "khaelor.log"), { debug: args.debug });378  installCrashHandlers(logger);379380  const cwd = process.cwd();381  const config = await loadConfig({382    cwd,383    flags: {384      ...(args.model !== null ? { model: args.model } : {}),385      ...(args.auxModel !== null ? { auxModel: args.auxModel } : {}),386      ...(args.thinking !== null ? { thinking: args.thinking } : {}),387      ...(args.maxOutputTokens !== null ? { maxOutputTokens: args.maxOutputTokens } : {}),388      ...(args.gate !== null ? { gate: args.gate } : {}),389    },390  });391  if (!config.hasApiKey) {392    process.stderr.write(`${missingApiKeyMessage()}\n`);393    process.exitCode = 1;394    return;395  }396397  if (args.print !== null) {398    let finished = false;399    installSignalShutdown(async () => {400      if (!finished) logger.log("warn", "print mode interrupted by signal");401    });402    const code = await runPrintMode({ config, cwd, prompt: args.print, logger });403    finished = true;404    process.exitCode = code;405    return;406  }407408  // Interactive: sessions can chain (/new, /resume) without restarting the process.409  const sessionsDir = defaultSessionsDir();410  let resumeId: string | undefined = args.resume ?? undefined;411  for (;;) {412    const end = await runInteractiveSession({413      config,414      logger,415      cwd,416      sessionsDir,417      ...(resumeId !== undefined ? { resumeId } : {}),418    });419    if (end.kind === "quit") break;420    resumeId = end.kind === "resume" ? end.sessionId : undefined;421  }422  // Lingering handles (SDK sockets, stdin) must not hold the process open.423  process.exit(0);424}425426const entryPath = process.argv[1];427let isDirectRun = false;428if (entryPath !== undefined) {429  try {430    isDirectRun = pathToFileURL(realpathSync(entryPath)).href === import.meta.url;431  } catch {432    isDirectRun = false;433  }434}435if (isDirectRun) {436  void main();437}438