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 });243 engineRef.current = assembled;244 registerCliCommands(app.commands, {245 ui: app,246 engine: () => assembled,247 config,248 cwd,249 sessionsDir: options.sessionsDir,250 projectHash: context.hash,251 actions: {252 newSession: () => switchGuard({ kind: "new" }),253 resumeSession: (sessionId) => switchGuard({ kind: "resume", sessionId }),254 quit: () => {255 void handleQuit();256 },257 captureNextSubmit: (consume) => {258 captureNextRef.current = consume;259 },260 },261 listModels: () => listModelsLive(config),262 });263 resolveReady(assembled);264265 // Deeper background: baseline, branch, repository index for @mentions.266 void assembled.captureBaseline();267 void assembled.git.currentBranch().then((branch) => {268 app.setGitBranch(branch.kind === "ok" ? branch.value : null);269 });270 void new RepositoryMap(assembled.workspace)271 .fileMap()272 .then((entries) => {273 const paths = entries.map((entry) => entry.path);274 app.setMentionProvider((query): PaletteItem[] => {275 if (query === "") return paths.slice(0, 8).map((p) => ({ id: p, label: p }));276 return fuzzyFilter(query, paths, (p) => p)277 .slice(0, 8)278 .map((ranked) => ({ id: ranked.item, label: ranked.item }));279 });280 })281 .catch((error: unknown) => {282 logger.debug("repository map unavailable", {283 error: error instanceof Error ? error.message : String(error),284 });285 });286 } catch (error) {287 const message = error instanceof Error ? error.message : String(error);288 logger.error("engine assembly failed", { error: message });289 app.printBlock(["", ` startup failed: ${message}`, " details in ~/.khaelor/logs/"]);290 }291 })();292293 const end = await ended;294 app.stop();295 if (engineRef.current !== null) await engineRef.current.shutdown();296 else await context.log.close();297 return end;298}299300// ───────────────────────── entry ─────────────────────────301302export async function main(argv: readonly string[] = process.argv.slice(2)): Promise<void> {303 const args: ParsedArgs = parseArgs(argv);304 if (args.errors.length > 0) {305 process.stderr.write(`khaelor: ${args.errors.join("; ")}\n\n${helpText()}`);306 process.exitCode = 2;307 return;308 }309 if (args.help) {310 process.stdout.write(helpText());311 return;312 }313 if (args.version) {314 process.stdout.write(`khaelor ${KHAELOR_VERSION}\n`);315 return;316 }317318 const logger = new FileLogger(join(defaultLogDir(), "khaelor.log"), { debug: args.debug });319 installCrashHandlers(logger);320321 const cwd = process.cwd();322 const config = await loadConfig({323 cwd,324 flags: {325 ...(args.model !== null ? { model: args.model } : {}),326 ...(args.auxModel !== null ? { auxModel: args.auxModel } : {}),327 ...(args.thinking !== null ? { thinking: args.thinking } : {}),328 ...(args.maxOutputTokens !== null ? { maxOutputTokens: args.maxOutputTokens } : {}),329 },330 });331 if (!config.hasApiKey) {332 process.stderr.write(`${missingApiKeyMessage()}\n`);333 process.exitCode = 1;334 return;335 }336337 if (args.print !== null) {338 let finished = false;339 installSignalShutdown(async () => {340 if (!finished) logger.log("warn", "print mode interrupted by signal");341 });342 const code = await runPrintMode({ config, cwd, prompt: args.print, logger });343 finished = true;344 process.exitCode = code;345 return;346 }347348 // Interactive: sessions can chain (/new, /resume) without restarting the process.349 const sessionsDir = defaultSessionsDir();350 let resumeId: string | undefined = args.resume ?? undefined;351 for (;;) {352 const end = await runInteractiveSession({353 config,354 logger,355 cwd,356 sessionsDir,357 ...(resumeId !== undefined ? { resumeId } : {}),358 });359 if (end.kind === "quit") break;360 resumeId = end.kind === "resume" ? end.sessionId : undefined;361 }362 // Lingering handles (SDK sockets, stdin) must not hold the process open.363 process.exit(0);364}365366const entryPath = process.argv[1];367let isDirectRun = false;368if (entryPath !== undefined) {369 try {370 isDirectRun = pathToFileURL(realpathSync(entryPath)).href === import.meta.url;371 } catch {372 isDirectRun = false;373 }374}375if (isDirectRun) {376 void main();377}378