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#!/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