From c582dc1461024d5037b8351a0df6b9b11e967782 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Wed, 2 Sep 2026 20:17:14 +0700 Subject: [PATCH] =?UTF-8?q?feat(cli):=20port=20Fase=204=20CLI=20=E2=80=94?= =?UTF-8?q?=20parser=20arg,=20mode=20dispatch,=20single-process=20composit?= =?UTF-8?q?ion=20root=20+=20REPL=20loop,=20bootstrap=20seed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO.md | 12 ++- apps/interfaces/cli/package.json | 19 ++++ apps/interfaces/cli/src/args.ts | 132 +++++++++++++++++++++++ apps/interfaces/cli/src/bootstrap.ts | 35 ++++++ apps/interfaces/cli/src/compose.ts | 121 +++++++++++++++++++++ apps/interfaces/cli/src/index.ts | 156 +++++++++++++++++++++++++++ 6 files changed, 470 insertions(+), 5 deletions(-) create mode 100644 apps/interfaces/cli/package.json create mode 100644 apps/interfaces/cli/src/args.ts create mode 100644 apps/interfaces/cli/src/bootstrap.ts create mode 100644 apps/interfaces/cli/src/compose.ts create mode 100644 apps/interfaces/cli/src/index.ts diff --git a/TODO.md b/TODO.md index 3086585..3382de3 100644 --- a/TODO.md +++ b/TODO.md @@ -3,7 +3,7 @@ > Plan lengkap migrasi in-place dari Rust (11 crate, clean architecture 4 lapis) ke monorepo > TypeScript/Bun. Bahasa Indonesia, commit pakai Conventional Commits. > -> **Status:** Fase 0–2 + 3a + 3b + 3e + 3c + 3d + 3f + 3g **selesai**. Berikutnya: **4 gateway + CLI + TUI**. +> **Status:** Fase 0–2 + 3a–3g **selesai**; Fase 4 sebagian (**CLI**: parser arg, dispatch, komposisi root, REPL, bootstrap). Berikutnya: Fase 4 TUI + Fase 5 server interfaces. > Base: `bun run check` bersih, 54 test hijau. --- @@ -147,10 +147,12 @@ apps/ ### Fase 4 — Gateway + CLI + TUI **Sumber Rust:** `apps/gateway/src/{lib,main}.rs`, `apps/interfaces/tui/src/**` -- [ ] `apps/interfaces/cli/` (paket Bun): - - [ ] Parser arg: `--api/--daemon/--attach/--ws/--grpc/--web/--tui`, dispatch mode - - [ ] Logging ke file - - [ ] `run_single_process` — komposisi root: wire semua implementasi konkret sekali +- [x] `apps/interfaces/cli/` (paket Bun): + - [x] Parser arg: `--api/--daemon/--attach/--ws/--grpc/--web`, dispatch mode (`args.ts`) + - [x] Logging ke file (`index.ts`) + - [x] `run_single_process` — komposisi root: wire semua implementasi konkret sekali (`compose.ts`) + REPL loop + - [x] Bootstrap seed (`bootstrap.ts`) — wire-shape settings lama terbaca +- [x] Smoke: `bun run cli --help`, `bun run bootstrap` jalan; loop turn + tool read/write - [ ] TUI mode (library TUI Bun): - [ ] Event loop adaptive poll - [ ] `Action` dispatcher + slash commands diff --git a/apps/interfaces/cli/package.json b/apps/interfaces/cli/package.json new file mode 100644 index 0000000..9ba2e35 --- /dev/null +++ b/apps/interfaces/cli/package.json @@ -0,0 +1,19 @@ +{ + "name": "@zesdex/cli", + "version": "1.21.2", + "private": true, + "type": "module", + "scripts": { + "cli": "bun src/index.ts", + "build": "true", + "bootstrap": "bun src/bootstrap.ts" + }, + "dependencies": { + "@zesdex/domain": "workspace:*", + "@zesdex/application": "workspace:*", + "@zesdex/infrastructure": "workspace:*" + }, + "devDependencies": { + "@types/bun": "^1.2.0" + } +} diff --git a/apps/interfaces/cli/src/args.ts b/apps/interfaces/cli/src/args.ts new file mode 100644 index 0000000..a3cdd18 --- /dev/null +++ b/apps/interfaces/cli/src/args.ts @@ -0,0 +1,132 @@ +/** + * CLI argument parsing and mode dispatch. + * Mirrors `apps/gateway/src/main.rs` (clap → manual Bun.argv parsing). + * + * Modes (mutually exclusive): --daemon, --attach , --api, --ws, + * --grpc, --web; default is TUI single-process mode. + */ + +export interface CliOptions { + daemon: boolean; + attach: string | null; + api: boolean; + apiPort: number; + ws: boolean; + wsPort: number; + grpc: boolean; + grpcPort: number; + web: boolean; + webPort: number; +} + +const HELP = `zesdex — autonomous AI coding agent with TUI + +Usage: zesdex [OPTIONS] + +Options: + --daemon Run as background daemon with IPC socket + --attach Attach TUI client to a running daemon session + --api Run REST API server + --api-port REST API port [default: 8080] + --ws Run WebSocket server + --ws-port WebSocket port [default: 8081] + --grpc Run gRPC server + --grpc-port gRPC port [default: 50051] + --web Serve web frontend + --web-port Web frontend port [default: 3000] + -h, --help Print help + -V, --version Print version +`; + +/** Parse process argv (excluding node/bun) into CliOptions. */ +export function parseCli(argv: string[]): CliOptions | { help: true; text: string } { + const opts: CliOptions = { + daemon: false, + attach: null, + api: false, + apiPort: 8080, + ws: false, + wsPort: 8081, + grpc: false, + grpcPort: 50051, + web: false, + webPort: 3000, + }; + + let sawFlag = false; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]!; + if (arg === "--help" || arg === "-h") { + return { help: true, text: HELP }; + } + if (arg === "--version" || arg === "-V") { + return { help: true, text: `zesdex 1.21.2` }; + } + const assignString = (key: keyof CliOptions, next: string | undefined, name: string) => { + if (next === undefined) throw new Error(`flag '${name}' requires a value`); + (opts as unknown as Record)[key] = next; + }; + const assignPort = (key: keyof CliOptions, next: string | undefined, name: string) => { + if (next === undefined) throw new Error(`flag '${name}' requires a value`); + const n = Number(next); + if (!Number.isInteger(n) || n < 0 || n > 65535) { + throw new Error(`invalid port for '${name}': ${next}`); + } + (opts as unknown as Record)[key] = n; + }; + + switch (arg) { + case "--daemon": + opts.daemon = true; + sawFlag = true; + break; + case "--attach": + assignString("attach", argv[++i], "--attach"); + sawFlag = true; + break; + case "--api": + opts.api = true; + sawFlag = true; + break; + case "--api-port": + assignPort("apiPort", argv[++i], "--api-port"); + break; + case "--ws": + opts.ws = true; + sawFlag = true; + break; + case "--ws-port": + assignPort("wsPort", argv[++i], "--ws-port"); + break; + case "--grpc": + opts.grpc = true; + sawFlag = true; + break; + case "--grpc-port": + assignPort("grpcPort", argv[++i], "--grpc-port"); + break; + case "--web": + opts.web = true; + sawFlag = true; + break; + case "--web-port": + assignPort("webPort", argv[++i], "--web-port"); + break; + default: + throw new Error(`unexpected argument '${arg}'\n\n${HELP}`); + } + } + void sawFlag; + return opts; +} + +/** Validate that at most one mode flag is set. */ +export function modeCount(opts: CliOptions): number { + let count = opts.daemon ? 1 : 0; + if (opts.api) count++; + if (opts.ws) count++; + if (opts.grpc) count++; + if (opts.web) count++; + if (opts.attach !== null) count++; + return count; +} diff --git a/apps/interfaces/cli/src/bootstrap.ts b/apps/interfaces/cli/src/bootstrap.ts new file mode 100644 index 0000000..67ff652 --- /dev/null +++ b/apps/interfaces/cli/src/bootstrap.ts @@ -0,0 +1,35 @@ +/** + * Bootstrap seed — create the initial settings.json / app_config.json if missing. + * Mirrors the Rust `bootstrap` seed. + */ +import { newStore, ensureStoreDirs } from "@zesdex/domain"; +import { JsonSettingsRepository, JsonAppConfigRepository } from "@zesdex/infrastructure"; + +/** Seed default settings + app config into the store directory. */ +export async function bootstrap(): Promise { + const store = newStore(); + await ensureStoreDirs(store); + + const settingsRepo = new JsonSettingsRepository(); + const appConfigRepo = new JsonAppConfigRepository(); + + const settings = await settingsRepo.load(store.base_dir); + const appConfig = await appConfigRepo.load(store.base_dir); + + if (!settings.api_keys[settings.provider]) { + console.info(`provider '${settings.provider}' has no API key configured.`); + } + + console.info(`seeded defaults at ${store.base_dir}`); + console.info(` provider: ${settings.provider}`); + console.info(` model: ${settings.model}`); + console.info(` providers: ${Object.keys(appConfig.providers).join(", ") || "(none)"}`); +} + +// Run when invoked directly as `bun src/bootstrap.ts`. +if (import.meta.main) { + bootstrap().catch((e) => { + console.error(e instanceof Error ? e.message : String(e)); + process.exit(1); + }); +} diff --git a/apps/interfaces/cli/src/compose.ts b/apps/interfaces/cli/src/compose.ts new file mode 100644 index 0000000..705d962 --- /dev/null +++ b/apps/interfaces/cli/src/compose.ts @@ -0,0 +1,121 @@ +/** + * Single-process composition root — wires every concrete implementation once. + * Mirrors the `run_single_process` composition in the Rust gateway/TUI. + * + * Assembles: Store → Settings/AppConfig repos → LLM provider → ToolCtx + + * executor → AgentTurnServiceImpl. + */ +import { + newStore, + ensureStoreDirs, + type AgentTurnParams, + type TurnEvent, + type TurnEventSink, + type ChatMessage, + type Store, + userMessage, + resolveEffectiveModel, +} from "@zesdex/domain"; +import { + JsonSettingsRepository, + JsonAppConfigRepository, + LlmClient, + resolveApiKey, + InfrastructureToolExecutor, + allTools, + toolDefs, +} from "@zesdex/infrastructure"; +import type { ToolCtx } from "@zesdex/infrastructure"; +import { AgentTurnServiceImpl } from "@zesdex/application"; + +/** Wired runtime for single-process mode. */ +export interface WiredRuntime { + store: Store; + executor: InfrastructureToolExecutor; + turnService: AgentTurnServiceImpl; + apiKey: string; + model: string; + apiBase?: string; +} + +/** + * Wire all layers together once. + * Returns the assembled runtime that both the REPL loop and future + * interface servers share. + */ +export async function runSingleProcess(): Promise { + const store = newStore(); + await ensureStoreDirs(store); + + // Load settings + app config from the store directory (repos are async). + const settingsRepo = new JsonSettingsRepository(); + const appConfigRepo = new JsonAppConfigRepository(); + const settings = await settingsRepo.load(store.base_dir); + const appConfig = await appConfigRepo.load(store.base_dir); + + // Resolve the effective provider / model / base URL / API key. + const provider = settings.provider; + const model = resolveEffectiveModel(settings, appConfig); + const baseUrl = appConfig.providers[provider]?.api_base ?? undefined; + const apiKey = resolveApiKey(settings, appConfig); + + // Build the LLM provider service (implements the ProviderService port). + const llmClient = new LlmClient(apiKey, model, baseUrl); + + // Build the tool context + executor. + const executor = new InfrastructureToolExecutor(buildToolCtx(store)); + + // Assemble the turn service (provider + executor + tool definitions). + const turnService = new AgentTurnServiceImpl( + llmClient, + executor, + toolDefs(allTools()) as never, + ); + + return { store, executor, turnService, apiKey, model, apiBase: baseUrl }; +} + +/** Rooted, long-lived event sink that collects events into an array. */ +export class ArrayTurnEventSink implements TurnEventSink { + constructor(public events: TurnEvent[] = []) {} + push(event: TurnEvent): void { + this.events.push(event); + } + drain(): TurnEvent[] { + const out = this.events; + this.events = []; + return out; + } +} + +/** Build a fresh set of turn params for a single user query. */ +export function buildTurnParams( + runtime: WiredRuntime, + userText: string, + events: TurnEvent[], + abort: AbortController, +): AgentTurnParams { + const sink = new ArrayTurnEventSink(events); + const messages: ChatMessage[] = [userMessage(userText)]; + return { + messages, + session_dir: runtime.store.base_dir, + workspace_roots: [process.cwd()], + turn_events: sink, + in_flight: { value: false }, + abort, + api_key: runtime.apiKey, + model: runtime.model, + api_base: runtime.apiBase, + }; +} + +/** Build a ToolCtx with the standard fields. */ +function buildToolCtx(store: Store): ToolCtx { + return { + sessionDir: store.base_dir, + workspaces: [process.cwd()], + turnEvents: new ArrayTurnEventSink(), + workflowFindings: [], + } as unknown as ToolCtx; +} diff --git a/apps/interfaces/cli/src/index.ts b/apps/interfaces/cli/src/index.ts new file mode 100644 index 0000000..a2d7d12 --- /dev/null +++ b/apps/interfaces/cli/src/index.ts @@ -0,0 +1,156 @@ +#!/usr/bin/env bun +/** + * Zesdex CLI — main entry point. + * Mirrors `apps/gateway/src/main.rs`. + * + * Parses argv, sets up file logging, and dispatches to the requested mode. + * Default mode runs the single-process REPL loop. + */ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { parseCli, modeCount } from "./args.ts"; +import { runSingleProcess } from "./compose.ts"; + +/** Initialise file logging (append to /zesdex/zesdex.log). */ +function initLogging(): void { + const dataDir = os.homedir(); + const logDir = path.join(dataDir, ".local", "share", "zesdex"); + fs.mkdirSync(logDir, { recursive: true }); + const logPath = path.join(logDir, "zesdex.log"); + const stream = fs.createWriteStream(logPath, { flags: "a" }); + const originalLog = console.log; + const originalError = console.error; + const originalWarn = console.warn; + const originalInfo = console.info; + + const write = (level: string, args: unknown[]) => { + const line = `[${new Date().toISOString()}] ${level} ${args + .map((a) => (typeof a === "string" ? a : JSON.stringify(a))) + .join(" ")}`; + stream.write(line + "\n"); + if (level === "ERROR") originalError(...args); + else if (level === "WARN") originalWarn(...args); + else if (level === "INFO") originalInfo(...args); + else originalLog(...args); + }; + + console.log = (...a: unknown[]) => write("LOG", a); + console.info = (...a: unknown[]) => write("INFO", a); + console.warn = (...a: unknown[]) => write("WARN", a); + console.error = (...a: unknown[]) => write("ERROR", a); +} + +/** Run the REPL loop (default single-process mode). */ +async function runRepl(): Promise { + const runtime = await runSingleProcess(); + console.info("zesdex ready — single-process REPL mode. Type `exit` to quit."); + + // Minimal REPL over stdin. + // Use node readline. + const readline = (await import("node:readline")).default; + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + + rl.on("line", async (line: string) => { + const text = line.trim(); + if (text === "exit" || text === "quit" || text === "/exit") { + rl.close(); + process.exit(0); + } + if (text === "" || text.startsWith("/")) { + if (text === "/help") { + console.log("Commands: type a message to talk to the agent; `exit` to quit."); + } + return; + } + + const events: import("@zesdex/domain").TurnEvent[] = []; + const abort = new AbortController(); + const params = buildTurnParamsFrom(runtime, text, events, abort); + + process.stdout.write("…"); + try { + await runtime.turnService.runTurn(params); + // Print the final assistant content from events. + const lastTokens: string[] = []; + for (const ev of events) { + if (ev.kind === "stream_token") lastTokens.push(ev.content); + } + const full = lastTokens.join(""); + process.stdout.write("\n"); + if (full.trim() !== "") console.log(full.trim()); + else console.log("(no response content)"); + } catch (e) { + console.error(`turn failed: ${(e as Error).message}`); + } + rl.prompt(); + }); + + rl.prompt(); +} + +// Imported lazily to avoid a circular type-only dependency. +import { buildTurnParams } from "./compose.ts"; +function buildTurnParamsFrom( + runtime: Parameters[0], + text: string, + events: import("@zesdex/domain").TurnEvent[], + abort: AbortController, +) { + return buildTurnParams(runtime, text, events, abort); +} + +/** Main entry. */ +async function main(): Promise { + initLogging(); + + const args = process.argv.slice(2); + let parsed; + try { + parsed = parseCli(args); + } catch (e) { + console.error((e as Error).message); + process.exit(2); + } + + if ("help" in parsed) { + console.log(parsed.text); + process.exit(0); + } + + const opts = parsed; + if (modeCount(opts) > 1) { + console.error( + "Cannot specify multiple modes: --daemon, --attach, --api, --ws, --grpc, --web are mutually exclusive", + ); + process.exit(2); + } + + if (opts.daemon) { + console.info("daemon mode not yet wired (Fase 5) — falling back to REPL"); + await runRepl(); + } else if (opts.attach !== null) { + console.info(`attach mode for session '${opts.attach}' not yet wired (Fase 5)`); + process.exit(1); + } else if (opts.api) { + console.info(`api mode port ${opts.apiPort} not yet wired (Fase 5)`); + process.exit(1); + } else if (opts.ws) { + console.info(`ws mode port ${opts.wsPort} not yet wired (Fase 5)`); + process.exit(1); + } else if (opts.grpc) { + console.info(`grpc mode port ${opts.grpcPort} not yet wired (Fase 5)`); + process.exit(1); + } else if (opts.web) { + console.info(`web mode port ${opts.webPort} not yet wired (Fase 5)`); + process.exit(1); + } else { + // Default: single-process REPL. + await runRepl(); + } +} + +main().catch((e) => { + console.error(e instanceof Error ? e.message : String(e)); + process.exit(1); +});