#!/usr/bin/env node /** * KHAELOR * File: src/cli/main.ts * Description: The khaelor bin entry — argv parsing, config resolution, fast-startup interactive wiring, --print mode. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { realpathSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { loadConfig } from "../config/index.js"; import type { ResolvedConfig } from "../config/index.js"; import { resolveContextWindow } from "../context/index.js"; import type { PermissionAnswer, PermissionAsker, PermissionPrompt } from "../permissions/index.js"; import { RepositoryMap } from "../repository/index.js"; import { defaultSessionsDir } from "../session/index.js"; import { TuiApp, fuzzyFilter } from "../tui/index.js"; import type { PaletteItem, PermissionDecision } from "../tui/index.js"; import { KHAELOR_VERSION, helpText, missingApiKeyMessage, parseArgs, } from "./args.js"; import type { ParsedArgs } from "./args.js"; import { registerCliCommands } from "./commands.js"; import { assembleEngine, openSessionContext } from "./engine.js"; import type { Engine } from "./engine.js"; import { QuitGuard, installCrashHandlers, installSignalShutdown, quitConfirmationLines, } from "./lifecycle.js"; import { FileLogger, defaultLogDir } from "./logger.js"; import { runPrintMode } from "./print.js"; import { renderResumedTranscript } from "./sessions.js"; // ───────────────────────── permission bridge ───────────────────────── /** * Bridges PermissionService.ask to the TUI permission panel: the service * publishes PermissionRequested (the panel renders from that durable event); * the user's key press arrives via TuiAppActions.permission and resolves the * pending ask. Silence never resolves to allow. */ class TuiPermissionAsker implements PermissionAsker { #pending: { resolve: (answer: PermissionAnswer) => void; prompt: PermissionPrompt } | null = null; ask(prompt: PermissionPrompt): Promise { return new Promise((resolve) => { this.#pending = { resolve, prompt }; }); } answer(_requestId: string, decision: PermissionDecision): void { const pending = this.#pending; if (pending === null) return; this.#pending = null; if (decision === "deny") { pending.resolve({ kind: "deny" }); return; } if (decision === "allow-always") { const pattern = pending.prompt.requests.flatMap((r) => r.alwaysPatterns)[0]; pending.resolve( pattern !== undefined ? { kind: "allow-always", pattern } : { kind: "allow-once" }, ); return; } pending.resolve({ kind: "allow-once" }); } } // ───────────────────────── interactive session ───────────────────────── type SessionEnd = { kind: "quit" } | { kind: "new" } | { kind: "resume"; sessionId: string }; interface InteractiveOptions { config: ResolvedConfig; logger: FileLogger; cwd: string; sessionsDir: string; resumeId?: string; } function abbreviateHome(path: string): string { const home = homedir(); return path.startsWith(home) ? `~${path.slice(home.length)}` : path; } async function listModelsLive(config: ResolvedConfig): Promise<{ id: string; displayName?: string }[]> { // Lazy SDK import — never in the boot path (ARCHITECTURE.md §12.1). const { default: Anthropic } = await import("@anthropic-ai/sdk"); const sdk = new Anthropic({ apiKey: config.apiKey ?? "", maxRetries: 0, timeout: 5_000 }); const models: { id: string; displayName?: string }[] = []; for await (const model of sdk.models.list()) { models.push({ id: model.id, displayName: model.display_name }); if (models.length >= 25) break; } return models; } /** Run one interactive session; resolves with how the next iteration should start. */ async function runInteractiveSession(options: InteractiveOptions): Promise { const { config, logger, cwd } = options; const context = await openSessionContext({ cwd, sessionsDir: options.sessionsDir, ...(options.resumeId !== undefined ? { resumeId: options.resumeId } : {}), logger, }); const asker = new TuiPermissionAsker(); const quitGuard = new QuitGuard(); const engineRef: { current: Engine | null } = { current: null }; let resolveReady: (engine: Engine) => void = () => {}; const ready = new Promise((resolve) => { resolveReady = resolve; }); let endSession: (end: SessionEnd) => void = () => {}; const ended = new Promise((resolve) => { endSession = resolve; }); const driveTurn = (active: Engine): void => { void active .runTurn() .then((outcome) => { if (outcome.kind === "failed") { logger.error("turn failed", { reason: outcome.reason, detail: outcome.detail }); } }) .catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); logger.error("turn crashed", { error: message }); app.printBlock(["", ` turn crashed: ${message}`, " details in ~/.khaelor/logs/"]); }); }; const runShell = async (active: Engine, command: string): Promise => { app.printBlock(["", ` ! ${command}`]); try { const result = await active.workspace.exec({ cmd: command, timeoutMs: 30_000 }); const output = `${result.stdout}${result.stderr}`.split("\n").filter((l) => l !== ""); const capped = output.slice(0, 40).map((line) => ` ${line}`); if (output.length > 40) capped.push(` … ${output.length - 40} more lines`); capped.push(` exit ${result.exitCode ?? "killed"} · ${result.durationMs}ms`); app.printBlock(capped); } catch (error) { app.printBlock([` shell failed: ${error instanceof Error ? error.message : String(error)}`]); } }; const handleQuit = async (): Promise => { const active = await ready; const running = active.processes.list().filter((p) => p.status === "running").length; if (quitGuard.request(running) === "confirm") { app.printBlock(quitConfirmationLines(running)); return; } endSession({ kind: "quit" }); }; // One-shot composer capture — /rename consumes the next submit as its input. const captureNextRef: { current: ((text: string) => void) | null } = { current: null }; const app = new TuiApp({ bus: context.bus, model: config.model, thinking: config.thinking, cwdLabel: abbreviateHome(cwd), contextWindow: resolveContextWindow(config.model), actions: { submit: (text, opts) => { if (!opts.shell && captureNextRef.current !== null) { const consume = captureNextRef.current; captureNextRef.current = null; consume(text); return; } void ready.then(async (active) => { if (opts.shell) { await runShell(active, text); return; } if (active.turnActive) { active.steering.queue(text); return; } active.session.publishDurable({ type: "user.message-created", payload: { text, mentions: [] }, }); driveTurn(active); }); }, interrupt: () => { void ready.then((active) => { active.interruption.interrupt(); }); }, permission: (requestId, decision) => { asker.answer(requestId, decision); }, quit: () => { void handleQuit(); }, }, }); // FIRST PAINT — everything below is lazy/background (§12.1 budget). await app.start(); if (context.resumed) { app.printBlock(renderResumedTranscript(context.log.replayedEvents)); } const switchGuard = (next: SessionEnd): void => { if (engineRef.current !== null && engineRef.current.turnActive) { app.printBlock(["", " a turn is running — Esc to interrupt it first"]); return; } endSession(next); }; // Background: assemble the engine and wire the real commands. void (async () => { try { const assembled = await assembleEngine({ config, cwd, context, asker, logger, // Strict-mode design approval (v2 §1): the panel content prints, the // selector decides; Esc rejects — silence is never approval. designAsker: { askDesign: (_artifactId, artifact) => new Promise((resolve) => { app.printBlock([ "", " ◑ DESIGN — approval required", ` Goal ${artifact.goal}`, ` Files ${artifact.filesTouched.join(", ")}`, ...artifact.approach.split("\n").slice(0, 6).map((line, i) => ` ${i === 0 ? "Approach " : " "}${line}`), ...(artifact.risks.length > 0 ? [` Risks ${artifact.risks.join(" · ")}`] : []), ` Verify ${artifact.verification}`, ...(artifact.outOfScope.length > 0 ? [` Not doing ${artifact.outOfScope.join(" · ")}`] : []), ]); app.openSelector( [ { id: "approve", label: "Approve design", detail: "unlock the implement phase" }, { id: "reject", label: "Reject design", detail: "send KHAELOR back to the drawing board" }, ], (id) => resolve({ approved: id === "approve" }), () => resolve({ approved: false, reason: "dismissed without approval" }), ); }), }, }); engineRef.current = assembled; registerCliCommands(app.commands, { ui: app, engine: () => assembled, config, cwd, sessionsDir: options.sessionsDir, projectHash: context.hash, actions: { newSession: () => switchGuard({ kind: "new" }), resumeSession: (sessionId) => switchGuard({ kind: "resume", sessionId }), quit: () => { void handleQuit(); }, captureNextSubmit: (consume) => { captureNextRef.current = consume; }, }, listModels: () => listModelsLive(config), }); resolveReady(assembled); // Deeper background: baseline, branch, repository index for @mentions. void assembled.captureBaseline(); void assembled.git.currentBranch().then((branch) => { app.setGitBranch(branch.kind === "ok" ? branch.value : null); }); void new RepositoryMap(assembled.workspace) .fileMap() .then((entries) => { const paths = entries.map((entry) => entry.path); app.setMentionProvider((query): PaletteItem[] => { if (query === "") return paths.slice(0, 8).map((p) => ({ id: p, label: p })); return fuzzyFilter(query, paths, (p) => p) .slice(0, 8) .map((ranked) => ({ id: ranked.item, label: ranked.item })); }); }) .catch((error: unknown) => { logger.debug("repository map unavailable", { error: error instanceof Error ? error.message : String(error), }); }); } catch (error) { const message = error instanceof Error ? error.message : String(error); logger.error("engine assembly failed", { error: message }); app.printBlock(["", ` startup failed: ${message}`, " details in ~/.khaelor/logs/"]); } })(); const end = await ended; app.stop(); if (engineRef.current !== null) await engineRef.current.shutdown(); else await context.log.close(); return end; } // ───────────────────────── entry ───────────────────────── export async function main(argv: readonly string[] = process.argv.slice(2)): Promise { const args: ParsedArgs = parseArgs(argv); if (args.errors.length > 0) { process.stderr.write(`khaelor: ${args.errors.join("; ")}\n\n${helpText()}`); process.exitCode = 2; return; } if (args.help) { process.stdout.write(helpText()); return; } if (args.version) { process.stdout.write(`khaelor ${KHAELOR_VERSION}\n`); return; } if (args.doctorTui) { const { doctorReport } = await import("../tui/doctor.js"); const report = doctorReport( { ...(process.env["TERM"] !== undefined ? { TERM: process.env["TERM"] } : {}), ...(process.env["COLORTERM"] !== undefined ? { COLORTERM: process.env["COLORTERM"] } : {}), ...(process.env["NO_COLOR"] !== undefined ? { NO_COLOR: process.env["NO_COLOR"] } : {}), ...(process.env["LANG"] !== undefined ? { LANG: process.env["LANG"] } : {}), ...(process.env["LC_ALL"] !== undefined ? { LC_ALL: process.env["LC_ALL"] } : {}), ...(process.env["TERM_PROGRAM"] !== undefined ? { TERM_PROGRAM: process.env["TERM_PROGRAM"] } : {}), }, process.stdout.isTTY === true, process.stdout.columns ?? 80, ); process.stdout.write(`${report.join("\n")}\n`); return; } if (args.splash && process.stdout.isTTY === true) { // The full opening moment (~600 ms), skippable by any key (TUI v2 §2). const { renderSplashFrame, SPLASH_FRAMES } = await import("../tui/splash.js"); const truecolor = process.env["COLORTERM"] === "truecolor" && process.env["NO_COLOR"] === undefined; let skipped = false; const onKey = (): void => { skipped = true; }; process.stdin.on("data", onKey); for (let frame = 0; frame < SPLASH_FRAMES && !skipped; frame += 1) { const lines = renderSplashFrame(frame, truecolor); if (frame > 0) process.stdout.write(`\x1b[${lines.length}A`); process.stdout.write(`${lines.join("\x1b[K\n")}\x1b[K\n`); await new Promise((resolve) => setTimeout(resolve, 60)); } process.stdin.off("data", onKey); } const logger = new FileLogger(join(defaultLogDir(), "khaelor.log"), { debug: args.debug }); installCrashHandlers(logger); const cwd = process.cwd(); const config = await loadConfig({ cwd, flags: { ...(args.model !== null ? { model: args.model } : {}), ...(args.auxModel !== null ? { auxModel: args.auxModel } : {}), ...(args.thinking !== null ? { thinking: args.thinking } : {}), ...(args.maxOutputTokens !== null ? { maxOutputTokens: args.maxOutputTokens } : {}), ...(args.gate !== null ? { gate: args.gate } : {}), }, }); if (!config.hasApiKey) { process.stderr.write(`${missingApiKeyMessage()}\n`); process.exitCode = 1; return; } if (args.print !== null) { let finished = false; installSignalShutdown(async () => { if (!finished) logger.log("warn", "print mode interrupted by signal"); }); const code = await runPrintMode({ config, cwd, prompt: args.print, logger }); finished = true; process.exitCode = code; return; } // Interactive: sessions can chain (/new, /resume) without restarting the process. const sessionsDir = defaultSessionsDir(); let resumeId: string | undefined = args.resume ?? undefined; for (;;) { const end = await runInteractiveSession({ config, logger, cwd, sessionsDir, ...(resumeId !== undefined ? { resumeId } : {}), }); if (end.kind === "quit") break; resumeId = end.kind === "resume" ? end.sessionId : undefined; } // Lingering handles (SDK sockets, stdin) must not hold the process open. process.exit(0); } const entryPath = process.argv[1]; let isDirectRun = false; if (entryPath !== undefined) { try { isDirectRun = pathToFileURL(realpathSync(entryPath)).href === import.meta.url; } catch { isDirectRun = false; } } if (isDirectRun) { void main(); }