arc402-cli 0.4.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/repl.ts ADDED
@@ -0,0 +1,793 @@
1
+ import chalk from "chalk";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import os from "os";
5
+ import { createProgram } from "./program";
6
+ import { getBannerLines, BannerConfig } from "./ui/banner";
7
+ import { c } from "./ui/colors";
8
+
9
+ // ─── Sentinel to intercept process.exit() from commands ──────────────────────
10
+
11
+ class REPLExitSignal extends Error {
12
+ constructor(public readonly code: number = 0) {
13
+ super("repl-exit-signal");
14
+ }
15
+ }
16
+
17
+ // ─── Config / banner helpers ──────────────────────────────────────────────────
18
+
19
+ const CONFIG_PATH = path.join(os.homedir(), ".arc402", "config.json");
20
+
21
+ async function loadBannerConfig(): Promise<BannerConfig | undefined> {
22
+ if (!fs.existsSync(CONFIG_PATH)) return undefined;
23
+ try {
24
+ const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")) as {
25
+ network?: string;
26
+ walletContractAddress?: string;
27
+ rpcUrl?: string;
28
+ };
29
+ const cfg: BannerConfig = { network: raw.network };
30
+ if (raw.walletContractAddress) {
31
+ const w = raw.walletContractAddress;
32
+ cfg.wallet = `${w.slice(0, 6)}...${w.slice(-4)}`;
33
+ }
34
+ if (raw.rpcUrl && raw.walletContractAddress) {
35
+ try {
36
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
37
+ const ethersLib = require("ethers") as typeof import("ethers");
38
+ const provider = new ethersLib.ethers.JsonRpcProvider(raw.rpcUrl);
39
+ const bal = await Promise.race([
40
+ provider.getBalance(raw.walletContractAddress),
41
+ new Promise<never>((_, r) =>
42
+ setTimeout(() => r(new Error("timeout")), 2000)
43
+ ),
44
+ ]);
45
+ cfg.balance = `${parseFloat(
46
+ ethersLib.ethers.formatEther(bal)
47
+ ).toFixed(4)} ETH`;
48
+ } catch {
49
+ /* skip balance on timeout */
50
+ }
51
+ }
52
+ return cfg;
53
+ } catch {
54
+ return undefined;
55
+ }
56
+ }
57
+
58
+ // ─── ANSI helpers ─────────────────────────────────────────────────────────────
59
+
60
+ const ESC = "\x1b";
61
+
62
+ const ansi = {
63
+ clearScreen: `${ESC}[2J`,
64
+ home: `${ESC}[H`,
65
+ clearLine: `${ESC}[2K`,
66
+ clearToEol: `${ESC}[K`,
67
+ hideCursor: `${ESC}[?25l`,
68
+ showCursor: `${ESC}[?25h`,
69
+ move: (r: number, col: number) => `${ESC}[${r};${col}H`,
70
+ scrollRegion: (top: number, bot: number) => `${ESC}[${top};${bot}r`,
71
+ resetScroll: `${ESC}[r`,
72
+ };
73
+
74
+ function write(s: string): void {
75
+ process.stdout.write(s);
76
+ }
77
+
78
+ // ─── Prompt constants ─────────────────────────────────────────────────────────
79
+
80
+ const PROMPT_TEXT =
81
+ chalk.cyanBright("◈") +
82
+ " " +
83
+ chalk.dim("arc402") +
84
+ " " +
85
+ chalk.white(">") +
86
+ " ";
87
+
88
+ // Visible character count of "◈ arc402 > "
89
+ const PROMPT_VIS = 11;
90
+
91
+ // ─── Known command detection ──────────────────────────────────────────────────
92
+
93
+ const BUILTIN_CMDS = ["help", "exit", "quit", "clear", "status"];
94
+
95
+ // ─── Shell-style tokenizer ────────────────────────────────────────────────────
96
+
97
+ function parseTokens(input: string): string[] {
98
+ const tokens: string[] = [];
99
+ let current = "";
100
+ let inQuote = false;
101
+ let quoteChar = "";
102
+ for (const ch of input) {
103
+ if (inQuote) {
104
+ if (ch === quoteChar) inQuote = false;
105
+ else current += ch;
106
+ } else if (ch === '"' || ch === "'") {
107
+ inQuote = true;
108
+ quoteChar = ch;
109
+ } else if (ch === " ") {
110
+ if (current) {
111
+ tokens.push(current);
112
+ current = "";
113
+ }
114
+ } else {
115
+ current += ch;
116
+ }
117
+ }
118
+ if (current) tokens.push(current);
119
+ return tokens;
120
+ }
121
+
122
+ // ─── Tab completion logic ─────────────────────────────────────────────────────
123
+
124
+ function getCompletions(
125
+ line: string,
126
+ topCmds: string[],
127
+ subCmds: Map<string, string[]>
128
+ ): string[] {
129
+ const allTop = [...BUILTIN_CMDS, ...topCmds];
130
+ const trimmed = line.trimStart();
131
+ const spaceIdx = trimmed.indexOf(" ");
132
+ if (spaceIdx === -1) {
133
+ return allTop.filter((cmd) => cmd.startsWith(trimmed));
134
+ }
135
+ const parent = trimmed.slice(0, spaceIdx);
136
+ const rest = trimmed.slice(spaceIdx + 1);
137
+ const subs = subCmds.get(parent) ?? [];
138
+ return subs.filter((s) => s.startsWith(rest)).map((s) => `${parent} ${s}`);
139
+ }
140
+
141
+ // ─── TUI class ────────────────────────────────────────────────────────────────
142
+
143
+ class TUI {
144
+ private inputBuffer = "";
145
+ private cursorPos = 0;
146
+ private history: string[] = [];
147
+ private historyIdx = -1;
148
+ private historyTemp = "";
149
+ private bannerLines: string[] = [];
150
+ private topCmds: string[] = [];
151
+ private subCmds = new Map<string, string[]>();
152
+ private bannerCfg?: BannerConfig;
153
+ private commandRunning = false;
154
+
155
+ private get termRows(): number {
156
+ return process.stdout.rows || 24;
157
+ }
158
+ private get termCols(): number {
159
+ return process.stdout.columns || 80;
160
+ }
161
+ private get scrollTop(): number {
162
+ // +1 for separator row after banner
163
+ return this.bannerLines.length + 2;
164
+ }
165
+ private get scrollBot(): number {
166
+ return this.termRows - 1;
167
+ }
168
+ private get inputRow(): number {
169
+ return this.termRows;
170
+ }
171
+
172
+ // ── Lifecycle ───────────────────────────────────────────────────────────────
173
+
174
+ async start(): Promise<void> {
175
+ this.bannerCfg = await loadBannerConfig();
176
+
177
+ // Build command metadata for completion
178
+ const template = createProgram();
179
+ this.topCmds = template.commands.map((cmd) => cmd.name());
180
+ for (const cmd of template.commands) {
181
+ if (cmd.commands.length > 0) {
182
+ this.subCmds.set(
183
+ cmd.name(),
184
+ cmd.commands.map((s) => s.name())
185
+ );
186
+ }
187
+ }
188
+
189
+ // Draw initial screen
190
+ this.setupScreen();
191
+ this.drawInputLine();
192
+
193
+ // Enter raw mode
194
+ if (process.stdin.isTTY) {
195
+ process.stdin.setRawMode(true);
196
+ }
197
+ process.stdin.resume();
198
+ process.stdin.setEncoding("utf8");
199
+ process.stdin.on("data", this.boundKeyHandler);
200
+
201
+ // Resize handler
202
+ process.stdout.on("resize", () => {
203
+ this.setupScreen();
204
+ this.drawInputLine();
205
+ });
206
+
207
+ // SIGINT (shouldn't fire in raw mode, but just in case)
208
+ process.on("SIGINT", () => this.exitGracefully());
209
+
210
+ // Keep alive — process.stdin listener keeps the event loop running
211
+ await new Promise<never>(() => {
212
+ /* never resolves; process.exit() is called on quit */
213
+ });
214
+ }
215
+
216
+ // ── Screen setup ────────────────────────────────────────────────────────────
217
+
218
+ private setupScreen(): void {
219
+ this.bannerLines = getBannerLines(this.bannerCfg);
220
+
221
+ write(ansi.hideCursor);
222
+ write(ansi.clearScreen + ansi.home);
223
+
224
+ // Banner
225
+ for (const line of this.bannerLines) {
226
+ write(line + "\n");
227
+ }
228
+
229
+ // Separator between banner and output area
230
+ write(chalk.dim("─".repeat(this.termCols)) + "\n");
231
+
232
+ // Set scroll region (output area, leaves last row free for input)
233
+ if (this.scrollTop <= this.scrollBot) {
234
+ write(ansi.scrollRegion(this.scrollTop, this.scrollBot));
235
+ }
236
+
237
+ // Position cursor at top of output area
238
+ write(ansi.move(this.scrollTop, 1));
239
+ write(ansi.showCursor);
240
+ }
241
+
242
+ // ── Banner repaint (in-place, preserves output area) ────────────────────────
243
+
244
+ private repaintBanner(): void {
245
+ write(ansi.hideCursor);
246
+ for (let i = 0; i < this.bannerLines.length; i++) {
247
+ write(ansi.move(i + 1, 1) + ansi.clearToEol + this.bannerLines[i]);
248
+ }
249
+ // Separator
250
+ const sepRow = this.bannerLines.length + 1;
251
+ write(
252
+ ansi.move(sepRow, 1) +
253
+ ansi.clearToEol +
254
+ chalk.dim("─".repeat(this.termCols))
255
+ );
256
+ write(ansi.showCursor);
257
+ }
258
+
259
+ // ── Input line ───────────────────────────────────────────────────────────────
260
+
261
+ private drawInputLine(): void {
262
+ write(ansi.move(this.inputRow, 1) + ansi.clearLine);
263
+ write(PROMPT_TEXT + this.inputBuffer);
264
+ // Place cursor at correct position within the input
265
+ write(ansi.move(this.inputRow, PROMPT_VIS + 1 + this.cursorPos));
266
+ }
267
+
268
+ // ── Key handler ──────────────────────────────────────────────────────────────
269
+
270
+ private readonly boundKeyHandler = (key: string): void => {
271
+ if (this.commandRunning) return;
272
+ this.handleKey(key);
273
+ };
274
+
275
+ private handleKey(key: string): void {
276
+ // Ctrl+C
277
+ if (key === "\u0003") {
278
+ this.exitGracefully();
279
+ return;
280
+ }
281
+ // Ctrl+L — refresh
282
+ if (key === "\u000C") {
283
+ this.setupScreen();
284
+ this.drawInputLine();
285
+ return;
286
+ }
287
+ // Enter
288
+ if (key === "\r" || key === "\n") {
289
+ void this.submit();
290
+ return;
291
+ }
292
+ // Backspace
293
+ if (key === "\u007F" || key === "\b") {
294
+ if (this.cursorPos > 0) {
295
+ this.inputBuffer =
296
+ this.inputBuffer.slice(0, this.cursorPos - 1) +
297
+ this.inputBuffer.slice(this.cursorPos);
298
+ this.cursorPos--;
299
+ this.drawInputLine();
300
+ }
301
+ return;
302
+ }
303
+ // Delete (forward)
304
+ if (key === "\x1b[3~") {
305
+ if (this.cursorPos < this.inputBuffer.length) {
306
+ this.inputBuffer =
307
+ this.inputBuffer.slice(0, this.cursorPos) +
308
+ this.inputBuffer.slice(this.cursorPos + 1);
309
+ this.drawInputLine();
310
+ }
311
+ return;
312
+ }
313
+ // Up arrow — history prev
314
+ if (key === "\x1b[A") {
315
+ if (this.historyIdx === -1) {
316
+ this.historyTemp = this.inputBuffer;
317
+ this.historyIdx = this.history.length - 1;
318
+ } else if (this.historyIdx > 0) {
319
+ this.historyIdx--;
320
+ }
321
+ if (this.historyIdx >= 0) {
322
+ this.inputBuffer = this.history[this.historyIdx];
323
+ this.cursorPos = this.inputBuffer.length;
324
+ this.drawInputLine();
325
+ }
326
+ return;
327
+ }
328
+ // Down arrow — history next
329
+ if (key === "\x1b[B") {
330
+ if (this.historyIdx >= 0) {
331
+ this.historyIdx++;
332
+ if (this.historyIdx >= this.history.length) {
333
+ this.historyIdx = -1;
334
+ this.inputBuffer = this.historyTemp;
335
+ } else {
336
+ this.inputBuffer = this.history[this.historyIdx];
337
+ }
338
+ this.cursorPos = this.inputBuffer.length;
339
+ this.drawInputLine();
340
+ }
341
+ return;
342
+ }
343
+ // Right arrow
344
+ if (key === "\x1b[C") {
345
+ if (this.cursorPos < this.inputBuffer.length) {
346
+ this.cursorPos++;
347
+ this.drawInputLine();
348
+ }
349
+ return;
350
+ }
351
+ // Left arrow
352
+ if (key === "\x1b[D") {
353
+ if (this.cursorPos > 0) {
354
+ this.cursorPos--;
355
+ this.drawInputLine();
356
+ }
357
+ return;
358
+ }
359
+ // Home / Ctrl+A
360
+ if (key === "\x1b[H" || key === "\u0001") {
361
+ this.cursorPos = 0;
362
+ this.drawInputLine();
363
+ return;
364
+ }
365
+ // End / Ctrl+E
366
+ if (key === "\x1b[F" || key === "\u0005") {
367
+ this.cursorPos = this.inputBuffer.length;
368
+ this.drawInputLine();
369
+ return;
370
+ }
371
+ // Ctrl+U — clear line
372
+ if (key === "\u0015") {
373
+ this.inputBuffer = "";
374
+ this.cursorPos = 0;
375
+ this.drawInputLine();
376
+ return;
377
+ }
378
+ // Ctrl+K — kill to end
379
+ if (key === "\u000B") {
380
+ this.inputBuffer = this.inputBuffer.slice(0, this.cursorPos);
381
+ this.drawInputLine();
382
+ return;
383
+ }
384
+ // Tab — completion
385
+ if (key === "\t") {
386
+ this.handleTab();
387
+ return;
388
+ }
389
+ // Printable characters
390
+ if (key >= " " && !key.startsWith("\x1b")) {
391
+ this.inputBuffer =
392
+ this.inputBuffer.slice(0, this.cursorPos) +
393
+ key +
394
+ this.inputBuffer.slice(this.cursorPos);
395
+ this.cursorPos += key.length;
396
+ this.drawInputLine();
397
+ }
398
+ }
399
+
400
+ // ── Tab completion ───────────────────────────────────────────────────────────
401
+
402
+ private handleTab(): void {
403
+ const completions = getCompletions(
404
+ this.inputBuffer,
405
+ this.topCmds,
406
+ this.subCmds
407
+ );
408
+ if (completions.length === 0) return;
409
+
410
+ if (completions.length === 1) {
411
+ this.inputBuffer = completions[0] + " ";
412
+ this.cursorPos = this.inputBuffer.length;
413
+ this.drawInputLine();
414
+ return;
415
+ }
416
+
417
+ // Find common prefix
418
+ const common = completions.reduce((a, b) => {
419
+ let i = 0;
420
+ while (i < a.length && i < b.length && a[i] === b[i]) i++;
421
+ return a.slice(0, i);
422
+ });
423
+ if (common.length > this.inputBuffer.trimStart().length) {
424
+ this.inputBuffer = common;
425
+ this.cursorPos = common.length;
426
+ }
427
+
428
+ // Show options in output area
429
+ this.writeOutput("\n" + chalk.dim(completions.join(" ")) + "\n");
430
+ this.drawInputLine();
431
+ }
432
+
433
+ // ── Write to output area ─────────────────────────────────────────────────────
434
+
435
+ private writeOutput(text: string): void {
436
+ // Move cursor to bottom of scroll region to ensure scroll-down works
437
+ write(ansi.move(this.scrollBot, 1));
438
+ write(text);
439
+ }
440
+
441
+ // ── Submit line ──────────────────────────────────────────────────────────────
442
+
443
+ private async submit(): Promise<void> {
444
+ const input = this.inputBuffer.trim();
445
+ this.inputBuffer = "";
446
+ this.cursorPos = 0;
447
+ this.historyIdx = -1;
448
+
449
+ if (!input) {
450
+ this.drawInputLine();
451
+ return;
452
+ }
453
+
454
+ // Add to history
455
+ if (input !== this.history[this.history.length - 1]) {
456
+ this.history.push(input);
457
+ }
458
+
459
+ // Echo the input into the output area
460
+ this.writeOutput(
461
+ "\n" + chalk.dim("◈ ") + chalk.white(input) + "\n"
462
+ );
463
+
464
+ // ── Built-in commands ──────────────────────────────────────────────────────
465
+
466
+ if (input === "exit" || input === "quit") {
467
+ this.exitGracefully();
468
+ return;
469
+ }
470
+
471
+ if (input === "clear") {
472
+ this.bannerCfg = await loadBannerConfig();
473
+ this.setupScreen();
474
+ this.drawInputLine();
475
+ return;
476
+ }
477
+
478
+ if (input === "status") {
479
+ await this.runStatus();
480
+ this.afterCommand();
481
+ return;
482
+ }
483
+
484
+ if (input === "help" || input === "/help") {
485
+ await this.runHelp();
486
+ this.afterCommand();
487
+ return;
488
+ }
489
+
490
+ // ── /chat prefix — explicit chat route ────────────────────────────────────
491
+
492
+ if (input.startsWith("/chat ") || input === "/chat") {
493
+ const msg = input.slice(6).trim();
494
+ if (msg) {
495
+ this.commandRunning = true;
496
+ await this.sendChat(msg);
497
+ this.commandRunning = false;
498
+ }
499
+ this.afterCommand();
500
+ return;
501
+ }
502
+
503
+ // ── Chat mode detection ────────────────────────────────────────────────────
504
+
505
+ const firstWord = input.split(/\s+/)[0];
506
+ const allKnown = [...BUILTIN_CMDS, ...this.topCmds];
507
+ if (!allKnown.includes(firstWord)) {
508
+ this.commandRunning = true;
509
+ await this.sendChat(input);
510
+ this.commandRunning = false;
511
+ this.afterCommand();
512
+ return;
513
+ }
514
+
515
+ // ── Dispatch to commander ──────────────────────────────────────────────────
516
+
517
+ this.commandRunning = true;
518
+ // Move output cursor to bottom of scroll region
519
+ write(ansi.move(this.scrollBot, 1));
520
+
521
+ // Suspend TUI stdin so interactive commands (prompts, readline) work cleanly
522
+ process.stdin.removeListener("data", this.boundKeyHandler);
523
+
524
+ const tokens = parseTokens(input);
525
+ const prog = createProgram();
526
+ prog.exitOverride();
527
+ prog.configureOutput({
528
+ writeOut: (str) => process.stdout.write(str),
529
+ writeErr: (str) => process.stderr.write(str),
530
+ });
531
+
532
+ const origExit = process.exit;
533
+ (process as NodeJS.Process).exit = ((code?: number) => {
534
+ throw new REPLExitSignal(code ?? 0);
535
+ }) as typeof process.exit;
536
+
537
+ try {
538
+ await prog.parseAsync(["node", "arc402", ...tokens]);
539
+ } catch (err) {
540
+ if (err instanceof REPLExitSignal) {
541
+ // Command called process.exit() — normal
542
+ } else {
543
+ const e = err as { code?: string; message?: string };
544
+ if (
545
+ e.code === "commander.helpDisplayed" ||
546
+ e.code === "commander.version"
547
+ ) {
548
+ // already written
549
+ } else if (e.code === "commander.unknownCommand") {
550
+ process.stdout.write(
551
+ `\n ${c.failure} ${chalk.red(`Unknown command: ${chalk.white(tokens[0])}`)} \n`
552
+ );
553
+ process.stdout.write(
554
+ chalk.dim(" Type 'help' for available commands\n")
555
+ );
556
+ } else if (e.code?.startsWith("commander.")) {
557
+ process.stdout.write(
558
+ `\n ${c.failure} ${chalk.red(e.message ?? String(err))}\n`
559
+ );
560
+ } else {
561
+ process.stdout.write(
562
+ `\n ${c.failure} ${chalk.red(e.message ?? String(err))}\n`
563
+ );
564
+ }
565
+ }
566
+ } finally {
567
+ (process as NodeJS.Process).exit = origExit;
568
+ }
569
+
570
+ // Restore raw mode + our listener (interactive commands may have toggled it)
571
+ if (process.stdin.isTTY) {
572
+ process.stdin.setRawMode(true);
573
+ }
574
+ process.stdin.on("data", this.boundKeyHandler);
575
+ this.commandRunning = false;
576
+
577
+ this.afterCommand();
578
+ }
579
+
580
+ // ── OpenClaw chat ─────────────────────────────────────────────────────────────
581
+
582
+ private async sendChat(message: string): Promise<void> {
583
+ write(ansi.move(this.scrollBot, 1));
584
+
585
+ let res: Response;
586
+ try {
587
+ res = await fetch("http://localhost:19000/api/agent", {
588
+ method: "POST",
589
+ headers: { "Content-Type": "application/json" },
590
+ body: JSON.stringify({ message, session: "arc402-repl" }),
591
+ signal: AbortSignal.timeout(30000),
592
+ });
593
+ } catch (err: unknown) {
594
+ const msg = err instanceof Error ? err.message : String(err);
595
+ const isDown =
596
+ msg.includes("ECONNREFUSED") ||
597
+ msg.includes("fetch failed") ||
598
+ msg.includes("ENOTFOUND") ||
599
+ msg.includes("UND_ERR_SOCKET");
600
+ if (isDown) {
601
+ process.stdout.write(
602
+ "\n " +
603
+ chalk.yellow("⚠") +
604
+ " " +
605
+ chalk.dim("OpenClaw gateway not running. Start with: ") +
606
+ chalk.white("openclaw gateway start") +
607
+ "\n"
608
+ );
609
+ } else {
610
+ process.stdout.write(
611
+ "\n " + c.failure + " " + chalk.red(msg) + "\n"
612
+ );
613
+ }
614
+ return;
615
+ }
616
+
617
+ if (!res.body) {
618
+ process.stdout.write(
619
+ "\n" + chalk.dim(" ◈ ") + chalk.white("(empty response)") + "\n"
620
+ );
621
+ return;
622
+ }
623
+
624
+ process.stdout.write("\n");
625
+
626
+ const flushLine = (line: string): void => {
627
+ // Unwrap SSE data lines
628
+ if (line.startsWith("data: ")) {
629
+ line = line.slice(6);
630
+ if (line === "[DONE]") return;
631
+ try {
632
+ const j = JSON.parse(line) as {
633
+ text?: string;
634
+ content?: string;
635
+ delta?: { text?: string };
636
+ };
637
+ line = j.text ?? j.content ?? j.delta?.text ?? line;
638
+ } catch {
639
+ /* use raw */
640
+ }
641
+ }
642
+ if (line.trim()) {
643
+ process.stdout.write(chalk.dim(" ◈ ") + chalk.white(line) + "\n");
644
+ }
645
+ };
646
+
647
+ const reader = res.body.getReader();
648
+ const decoder = new TextDecoder();
649
+ let buffer = "";
650
+
651
+ while (true) {
652
+ const { done, value } = await reader.read();
653
+ if (done) break;
654
+ buffer += decoder.decode(value, { stream: true });
655
+ const lines = buffer.split("\n");
656
+ buffer = lines.pop() ?? "";
657
+ for (const line of lines) flushLine(line);
658
+ }
659
+
660
+ if (buffer.trim()) flushLine(buffer);
661
+ }
662
+
663
+ // ── After each command: repaint banner + input ───────────────────────────────
664
+
665
+ private afterCommand(): void {
666
+ this.repaintBanner();
667
+ this.drawInputLine();
668
+ }
669
+
670
+ // ── Built-in: status ─────────────────────────────────────────────────────────
671
+
672
+ private async runStatus(): Promise<void> {
673
+ write(ansi.move(this.scrollBot, 1));
674
+ if (!fs.existsSync(CONFIG_PATH)) {
675
+ process.stdout.write(
676
+ chalk.dim("\n No config found. Run 'config init' to get started.\n")
677
+ );
678
+ return;
679
+ }
680
+ try {
681
+ const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")) as {
682
+ network?: string;
683
+ walletContractAddress?: string;
684
+ rpcUrl?: string;
685
+ };
686
+ process.stdout.write("\n");
687
+ if (raw.network)
688
+ process.stdout.write(
689
+ ` ${chalk.dim("Network")} ${chalk.white(raw.network)}\n`
690
+ );
691
+ if (raw.walletContractAddress) {
692
+ const w = raw.walletContractAddress;
693
+ process.stdout.write(
694
+ ` ${chalk.dim("Wallet")} ${chalk.white(`${w.slice(0, 6)}...${w.slice(-4)}`)}\n`
695
+ );
696
+ }
697
+ if (raw.rpcUrl && raw.walletContractAddress) {
698
+ try {
699
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
700
+ const ethersLib = require("ethers") as typeof import("ethers");
701
+ const provider = new ethersLib.ethers.JsonRpcProvider(raw.rpcUrl);
702
+ const bal = await Promise.race([
703
+ provider.getBalance(raw.walletContractAddress),
704
+ new Promise<never>((_, r) =>
705
+ setTimeout(() => r(new Error("timeout")), 2000)
706
+ ),
707
+ ]);
708
+ process.stdout.write(
709
+ ` ${chalk.dim("Balance")} ${chalk.white(
710
+ `${parseFloat(ethersLib.ethers.formatEther(bal)).toFixed(4)} ETH`
711
+ )}\n`
712
+ );
713
+ } catch {
714
+ /* skip */
715
+ }
716
+ }
717
+ process.stdout.write("\n");
718
+ } catch {
719
+ /* skip */
720
+ }
721
+ }
722
+
723
+ // ── Built-in: help ────────────────────────────────────────────────────────────
724
+
725
+ private async runHelp(): Promise<void> {
726
+ write(ansi.move(this.scrollBot, 1));
727
+ process.stdin.removeListener("data", this.boundKeyHandler);
728
+ const prog = createProgram();
729
+ prog.exitOverride();
730
+ prog.configureOutput({
731
+ writeOut: (str) => process.stdout.write(str),
732
+ writeErr: (str) => process.stderr.write(str),
733
+ });
734
+ try {
735
+ await prog.parseAsync(["node", "arc402", "--help"]);
736
+ } catch {
737
+ /* commander throws after printing help */
738
+ }
739
+ if (process.stdin.isTTY) process.stdin.setRawMode(true);
740
+ process.stdin.on("data", this.boundKeyHandler);
741
+
742
+ process.stdout.write("\n");
743
+ process.stdout.write(chalk.cyanBright("Chat") + "\n");
744
+ process.stdout.write(
745
+ " " +
746
+ chalk.white("<message>") +
747
+ chalk.dim(" Send message to OpenClaw gateway\n")
748
+ );
749
+ process.stdout.write(
750
+ " " +
751
+ chalk.white("/chat <message>") +
752
+ chalk.dim(" Explicitly route to chat\n")
753
+ );
754
+ process.stdout.write(
755
+ chalk.dim(
756
+ " Gateway: http://localhost:19000 (openclaw gateway start)\n"
757
+ )
758
+ );
759
+ process.stdout.write("\n");
760
+ }
761
+
762
+ // ── Exit ──────────────────────────────────────────────────────────────────────
763
+
764
+ private exitGracefully(): void {
765
+ write(ansi.move(this.inputRow, 1) + ansi.clearLine);
766
+ write(" " + chalk.cyanBright("◈") + chalk.dim(" goodbye") + "\n");
767
+ write(ansi.resetScroll);
768
+ write(ansi.showCursor);
769
+ if (process.stdin.isTTY) {
770
+ process.stdin.setRawMode(false);
771
+ }
772
+ process.exit(0);
773
+ }
774
+ }
775
+
776
+ // ─── REPL entry point ─────────────────────────────────────────────────────────
777
+
778
+ export async function startREPL(): Promise<void> {
779
+ if (!process.stdout.isTTY) {
780
+ // Non-TTY (piped): fall back to minimal line-mode output
781
+ const bannerCfg = await loadBannerConfig();
782
+ for (const line of getBannerLines(bannerCfg)) {
783
+ process.stdout.write(line + "\n");
784
+ }
785
+ process.stdout.write(
786
+ "Interactive TUI requires a TTY. Use arc402 <command> directly.\n"
787
+ );
788
+ return;
789
+ }
790
+
791
+ const tui = new TUI();
792
+ await tui.start();
793
+ }