arc402-cli 0.7.2 → 0.7.4

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