arc402-cli 0.5.0 → 0.7.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.
Files changed (52) hide show
  1. package/dist/commands/config.d.ts.map +1 -1
  2. package/dist/commands/config.js +17 -1
  3. package/dist/commands/config.js.map +1 -1
  4. package/dist/commands/doctor.d.ts +3 -0
  5. package/dist/commands/doctor.d.ts.map +1 -0
  6. package/dist/commands/doctor.js +205 -0
  7. package/dist/commands/doctor.js.map +1 -0
  8. package/dist/commands/wallet.d.ts.map +1 -1
  9. package/dist/commands/wallet.js +467 -23
  10. package/dist/commands/wallet.js.map +1 -1
  11. package/dist/config.d.ts +1 -0
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +11 -2
  14. package/dist/config.js.map +1 -1
  15. package/dist/daemon/index.d.ts.map +1 -1
  16. package/dist/daemon/index.js +294 -208
  17. package/dist/daemon/index.js.map +1 -1
  18. package/dist/endpoint-notify.d.ts +7 -0
  19. package/dist/endpoint-notify.d.ts.map +1 -1
  20. package/dist/endpoint-notify.js +104 -0
  21. package/dist/endpoint-notify.js.map +1 -1
  22. package/dist/index.js +15 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/program.d.ts.map +1 -1
  25. package/dist/program.js +2 -0
  26. package/dist/program.js.map +1 -1
  27. package/dist/repl.d.ts.map +1 -1
  28. package/dist/repl.js +565 -162
  29. package/dist/repl.js.map +1 -1
  30. package/dist/ui/banner.d.ts +2 -0
  31. package/dist/ui/banner.d.ts.map +1 -1
  32. package/dist/ui/banner.js +27 -18
  33. package/dist/ui/banner.js.map +1 -1
  34. package/dist/ui/format.d.ts.map +1 -1
  35. package/dist/ui/format.js +2 -0
  36. package/dist/ui/format.js.map +1 -1
  37. package/dist/ui/spinner.d.ts.map +1 -1
  38. package/dist/ui/spinner.js +11 -0
  39. package/dist/ui/spinner.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/commands/config.ts +18 -2
  42. package/src/commands/doctor.ts +172 -0
  43. package/src/commands/wallet.ts +512 -35
  44. package/src/config.ts +10 -1
  45. package/src/daemon/index.ts +234 -140
  46. package/src/endpoint-notify.ts +73 -0
  47. package/src/index.ts +15 -1
  48. package/src/program.ts +2 -0
  49. package/src/repl.ts +673 -197
  50. package/src/ui/banner.ts +26 -19
  51. package/src/ui/format.ts +1 -0
  52. package/src/ui/spinner.ts +10 -0
package/src/repl.ts CHANGED
@@ -1,33 +1,16 @@
1
- import readline from "node:readline";
2
1
  import chalk from "chalk";
3
2
  import fs from "fs";
4
3
  import path from "path";
5
4
  import os from "os";
6
5
  import { createProgram } from "./program";
7
- import { renderBanner, BannerConfig } from "./ui/banner";
6
+ import { getBannerLines, BannerConfig } from "./ui/banner";
8
7
  import { c } from "./ui/colors";
9
8
 
10
- // ─── Prompt ───────────────────────────────────────────────────────────────────
11
-
12
- const PROMPT =
13
- chalk.cyanBright("◈") +
14
- " " +
15
- chalk.dim("arc402") +
16
- " " +
17
- chalk.white(">") +
18
- " ";
19
-
20
- // ─── Sentinel thrown to intercept process.exit() from commands ───────────────
21
-
22
- class REPLExitSignal extends Error {
23
- constructor(public readonly code: number = 0) {
24
- super("repl-exit-signal");
25
- }
26
- }
27
-
28
9
  // ─── Config / banner helpers ──────────────────────────────────────────────────
29
10
 
30
11
  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;
31
14
 
32
15
  async function loadBannerConfig(): Promise<BannerConfig | undefined> {
33
16
  if (!fs.existsSync(CONFIG_PATH)) return undefined;
@@ -66,71 +49,61 @@ async function loadBannerConfig(): Promise<BannerConfig | undefined> {
66
49
  }
67
50
  }
68
51
 
69
- // ─── Status dashboard ─────────────────────────────────────────────────────────
52
+ // ─── ANSI helpers ─────────────────────────────────────────────────────────────
70
53
 
71
- async function showStatus(): Promise<void> {
72
- console.log();
73
- console.log(
74
- " " + chalk.cyanBright("◈") + " " + chalk.dim("─".repeat(45))
75
- );
76
- if (!fs.existsSync(CONFIG_PATH)) {
77
- console.log(
78
- chalk.dim(" No config found. Run 'config init' to get started.")
79
- );
80
- console.log();
81
- return;
82
- }
83
- try {
84
- const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")) as {
85
- network?: string;
86
- walletContractAddress?: string;
87
- rpcUrl?: string;
88
- };
89
- if (raw.network)
90
- console.log(` ${chalk.dim("Network")} ${chalk.white(raw.network)}`);
91
- if (raw.walletContractAddress) {
92
- const w = raw.walletContractAddress;
93
- console.log(
94
- ` ${chalk.dim("Wallet")} ${chalk.white(`${w.slice(0, 6)}...${w.slice(-4)}`)}`
95
- );
96
- }
97
- if (raw.rpcUrl && raw.walletContractAddress) {
98
- try {
99
- // eslint-disable-next-line @typescript-eslint/no-var-requires
100
- const ethersLib = require("ethers") as typeof import("ethers");
101
- const provider = new ethersLib.ethers.JsonRpcProvider(raw.rpcUrl);
102
- const bal = await Promise.race([
103
- provider.getBalance(raw.walletContractAddress),
104
- new Promise<never>((_, r) =>
105
- setTimeout(() => r(new Error("timeout")), 2000)
106
- ),
107
- ]);
108
- console.log(
109
- ` ${chalk.dim("Balance")} ${chalk.white(
110
- `${parseFloat(ethersLib.ethers.formatEther(bal)).toFixed(4)} ETH`
111
- )}`
112
- );
113
- } catch {
114
- /* skip */
115
- }
116
- }
117
- } catch {
118
- /* skip */
119
- }
120
- console.log();
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);
121
70
  }
122
71
 
123
- // ─── Shell-style tokenizer (handles "quoted strings") ────────────────────────
72
+ // ─── Prompt constants ─────────────────────────────────────────────────────────
73
+
74
+ const PROMPT_TEXT =
75
+ chalk.cyanBright("◈") +
76
+ " " +
77
+ chalk.dim("arc402") +
78
+ " " +
79
+ chalk.white(">") +
80
+ " ";
81
+
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
+ // ─── Shell-style tokenizer ────────────────────────────────────────────────────
124
90
 
125
91
  function parseTokens(input: string): string[] {
126
92
  const tokens: string[] = [];
127
93
  let current = "";
128
94
  let inQuote = false;
129
95
  let quoteChar = "";
130
-
96
+ let escape = false;
131
97
  for (const ch of input) {
98
+ if (escape) {
99
+ current += ch;
100
+ escape = false;
101
+ continue;
102
+ }
132
103
  if (inQuote) {
133
- if (ch === quoteChar) {
104
+ if (quoteChar === '"' && ch === "\\") {
105
+ escape = true;
106
+ } else if (ch === quoteChar) {
134
107
  inQuote = false;
135
108
  } else {
136
109
  current += ch;
@@ -151,134 +124,415 @@ function parseTokens(input: string): string[] {
151
124
  return tokens;
152
125
  }
153
126
 
154
- // ─── Tab completer ────────────────────────────────────────────────────────────
127
+ // ─── Tab completion logic ─────────────────────────────────────────────────────
155
128
 
156
- function buildCompleter(
129
+ function getCompletions(
130
+ line: string,
157
131
  topCmds: string[],
158
132
  subCmds: Map<string, string[]>
159
- ): readline.Completer {
160
- const specialCmds = ["help", "exit", "quit", "clear", "status"];
161
- const allTop = [...specialCmds, ...topCmds];
162
-
163
- return function completer(line: string): [string[], string] {
164
- const trimmed = line.trimStart();
165
- const spaceIdx = trimmed.indexOf(" ");
166
-
167
- if (spaceIdx === -1) {
168
- // Completing the first word (top-level command)
169
- const hits = allTop.filter((cmd) => cmd.startsWith(trimmed));
170
- return [hits.length ? hits : allTop, trimmed];
171
- }
172
-
173
- // Completing a subcommand
174
- const parent = trimmed.slice(0, spaceIdx);
175
- const rest = trimmed.slice(spaceIdx + 1);
176
- const subs = subCmds.get(parent) ?? [];
177
- const hits = subs.filter((s) => s.startsWith(rest));
178
- return [
179
- hits.map((s) => `${parent} ${s}`),
180
- trimmed,
181
- ];
182
- };
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}`);
183
144
  }
184
145
 
185
- // ─── REPL entry point ─────────────────────────────────────────────────────────
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;
159
+
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
+ }
186
176
 
187
- export async function startREPL(): Promise<void> {
188
- // Show the banner
189
- const bannerCfg = await loadBannerConfig();
190
- renderBanner(bannerCfg);
191
-
192
- // Build a template program once just to extract command metadata for completions
193
- const template = createProgram();
194
- const topCmds = template.commands.map((cmd) => cmd.name());
195
- const subCmds = new Map<string, string[]>();
196
- for (const cmd of template.commands) {
197
- if (cmd.commands.length > 0) {
198
- subCmds.set(
199
- cmd.name(),
200
- cmd.commands.map((s) => s.name())
201
- );
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");
202
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);
203
253
  }
204
254
 
205
- const rl = readline.createInterface({
206
- input: process.stdin,
207
- output: process.stdout,
208
- prompt: PROMPT,
209
- completer: buildCompleter(topCmds, subCmds),
210
- terminal: true,
211
- historySize: 200,
212
- });
213
-
214
- function goodbye(): void {
215
- console.log(
216
- "\n " + chalk.cyanBright("◈") + chalk.dim(" goodbye")
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]);
261
+ }
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))
217
268
  );
269
+ write(ansi.showCursor);
218
270
  }
219
271
 
220
- rl.on("SIGINT", () => {
221
- goodbye();
222
- rl.close();
223
- process.exit(0);
224
- });
272
+ // ── Input line ───────────────────────────────────────────────────────────────
225
273
 
226
- rl.on("close", () => {
227
- goodbye();
228
- process.exit(0);
229
- });
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
+ }
230
280
 
231
- rl.prompt();
281
+ // ── Key handler ──────────────────────────────────────────────────────────────
232
282
 
233
- // Process lines one at a time
234
- for await (const line of rl) {
235
- const input = line.trim();
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
+ }
411
+ }
412
+
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();
444
+ }
445
+
446
+ // ── Write to output area ─────────────────────────────────────────────────────
447
+
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
+ }
453
+
454
+ // ── Submit line ──────────────────────────────────────────────────────────────
455
+
456
+ private async submit(): Promise<void> {
457
+ const input = this.inputBuffer.trim();
458
+ this.inputBuffer = "";
459
+ this.cursorPos = 0;
460
+ this.historyIdx = -1;
236
461
 
237
462
  if (!input) {
238
- rl.prompt();
239
- continue;
463
+ this.drawInputLine();
464
+ return;
465
+ }
466
+
467
+ // Add to history
468
+ if (input !== this.history[this.history.length - 1]) {
469
+ this.history.push(input);
240
470
  }
241
471
 
242
- // ── Special built-in commands ──────────────────────────────────────────
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 ──────────────────────────────────────────────────────
243
478
 
244
479
  if (input === "exit" || input === "quit") {
245
- goodbye();
246
- rl.close();
247
- process.exit(0);
480
+ this.exitGracefully();
481
+ return;
248
482
  }
249
483
 
250
484
  if (input === "clear") {
251
- console.clear();
252
- const cfg = await loadBannerConfig();
253
- renderBanner(cfg);
254
- rl.prompt();
255
- continue;
485
+ this.bannerCfg = await loadBannerConfig();
486
+ this.setupScreen();
487
+ this.drawInputLine();
488
+ return;
256
489
  }
257
490
 
258
491
  if (input === "status") {
259
- await showStatus();
260
- rl.prompt();
261
- continue;
492
+ await this.runStatus();
493
+ this.afterCommand();
494
+ return;
262
495
  }
263
496
 
264
- if (input === "help" || input === "help ") {
265
- // Show the full commander help via the program
266
- const prog = createProgram();
267
- prog.exitOverride();
268
- prog.configureOutput({
269
- writeOut: (str) => process.stdout.write(str),
270
- writeErr: (str) => process.stderr.write(str),
271
- });
272
- try {
273
- await prog.parseAsync(["node", "arc402", "--help"]);
274
- } catch {
275
- /* commander throws after printing help — ignore */
497
+ if (input === "help" || input === "/help") {
498
+ await this.runHelp();
499
+ this.afterCommand();
500
+ return;
501
+ }
502
+
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;
276
511
  }
277
- rl.prompt();
278
- continue;
512
+ this.afterCommand();
513
+ return;
279
514
  }
280
515
 
281
- // ── Dispatch to commander ──────────────────────────────────────────────
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);
282
536
 
283
537
  const tokens = parseTokens(input);
284
538
  const prog = createProgram();
@@ -288,41 +542,263 @@ export async function startREPL(): Promise<void> {
288
542
  writeErr: (str) => process.stderr.write(str),
289
543
  });
290
544
 
291
- // Intercept process.exit() so a command exiting doesn't kill the REPL
292
- const origExit = process.exit;
293
- (process as NodeJS.Process).exit = ((code?: number) => {
294
- throw new REPLExitSignal(code ?? 0);
295
- }) as typeof process.exit;
296
-
297
545
  try {
298
546
  await prog.parseAsync(["node", "arc402", ...tokens]);
299
547
  } catch (err) {
300
- if (err instanceof REPLExitSignal) {
301
- // Command called process.exit() — normal, just continue the REPL
548
+ const e = err as { code?: string; message?: string };
549
+ if (
550
+ e.code === "commander.helpDisplayed" ||
551
+ e.code === "commander.version" ||
552
+ e.code === "commander.executeSubCommandAsync"
553
+ ) {
554
+ // already written or normal exit
555
+ } else if (e.code === "commander.unknownCommand") {
556
+ process.stdout.write(
557
+ `\n ${c.failure} ${chalk.red(`Unknown command: ${chalk.white(tokens[0])}`)} \n`
558
+ );
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
+ );
302
566
  } else {
303
- const e = err as { code?: string; message?: string };
304
- if (
305
- e.code === "commander.helpDisplayed" ||
306
- e.code === "commander.version"
307
- ) {
308
- // Help / version output was already written — nothing to do
309
- } else if (e.code === "commander.unknownCommand") {
310
- console.log(
311
- `\n ${c.failure} ${chalk.red(`Unknown command: ${chalk.white(tokens[0])}`)}`
312
- );
313
- console.log(chalk.dim(" Type 'help' for available commands\n"));
314
- } else if (e.code?.startsWith("commander.")) {
315
- console.log(`\n ${c.failure} ${chalk.red(e.message ?? String(err))}\n`);
316
- } else {
317
- console.log(
318
- `\n ${c.failure} ${chalk.red(e.message ?? String(err))}\n`
567
+ process.stdout.write(
568
+ `\n ${c.failure} ${chalk.red(e.message ?? String(err))}\n`
569
+ );
570
+ }
571
+ }
572
+
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
+ process.stdout.write("\n");
629
+
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`
319
716
  );
717
+ } catch {
718
+ /* skip */
320
719
  }
321
720
  }
322
- } finally {
323
- (process as NodeJS.Process).exit = origExit;
721
+ process.stdout.write("\n");
722
+ } catch {
723
+ /* skip */
324
724
  }
725
+ }
726
+
727
+ // ── Built-in: help ────────────────────────────────────────────────────────────
325
728
 
326
- rl.prompt();
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
+ );
753
+ process.stdout.write(
754
+ " " +
755
+ chalk.white("/chat <message>") +
756
+ chalk.dim(" Explicitly route to chat\n")
757
+ );
758
+ process.stdout.write(
759
+ chalk.dim(
760
+ " Gateway: http://localhost:19000 (openclaw gateway start)\n"
761
+ )
762
+ );
763
+ process.stdout.write("\n");
327
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
+ 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
+ }
801
+
802
+ const tui = new TUI();
803
+ await tui.start();
328
804
  }