@tryfridayai/cli 0.2.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.
@@ -0,0 +1,763 @@
1
+ /**
2
+ * friday chat — Interactive conversation with the agent runtime
3
+ *
4
+ * Spawns the runtime's stdio transport (friday-server.js) as a child process
5
+ * and provides a readline-based REPL with clean, user-friendly output.
6
+ *
7
+ * Use --verbose to see raw debug output (session IDs, tool inputs, etc.)
8
+ */
9
+
10
+ import { spawn } from 'child_process';
11
+ import fs from 'fs';
12
+ import os from 'os';
13
+ import path from 'path';
14
+ import readline from 'readline';
15
+ import { fileURLToPath } from 'url';
16
+ import { createRequire } from 'module';
17
+
18
+ // ── Chat modules ─────────────────────────────────────────────────────────
19
+
20
+ import {
21
+ DIM, RESET, BOLD, YELLOW, RED, CYAN, GREEN, ORANGE,
22
+ PURPLE, PROMPT_STRING,
23
+ } from './chat/ui.js';
24
+ import { renderWelcome } from './chat/welcomeScreen.js';
25
+ import {
26
+ routeSlashCommand, handleColonCommand, checkPendingResponse,
27
+ } from './chat/slashCommands.js';
28
+ import { checkPreQueryHint, checkPostResponseHint } from './chat/smartAffordances.js';
29
+
30
+ // ── Resolve runtime ──────────────────────────────────────────────────────
31
+
32
+ const __filename = fileURLToPath(import.meta.url);
33
+ const __dirname = path.dirname(__filename);
34
+
35
+ const require = createRequire(import.meta.url);
36
+ let runtimeDir;
37
+ try {
38
+ const runtimePkg = require.resolve('friday-runtime/package.json');
39
+ runtimeDir = path.dirname(runtimePkg);
40
+ } catch {
41
+ runtimeDir = path.resolve(__dirname, '..', '..', '..', 'runtime');
42
+ }
43
+ const serverScript = path.join(runtimeDir, 'friday-server.js');
44
+
45
+ // ── Tool humanization ────────────────────────────────────────────────────
46
+
47
+ /** Format a tool name like "mcp__filesystem__read_file" into "Reading file..." */
48
+ function humanizeToolUse(toolName, toolInput) {
49
+ const name = toolName || 'tool';
50
+ const parts = name.split('__');
51
+ const action = parts[parts.length - 1];
52
+ const server = parts.length >= 3 ? parts[1] : null;
53
+
54
+ const descriptions = {
55
+ read_file: () => {
56
+ const p = toolInput?.path || toolInput?.file_path;
57
+ return p ? `Reading ${path.basename(p)}` : 'Reading file';
58
+ },
59
+ write_file: () => {
60
+ const p = toolInput?.path || toolInput?.file_path;
61
+ return p ? `Writing ${path.basename(p)}` : 'Writing file';
62
+ },
63
+ edit_file: () => {
64
+ const p = toolInput?.path || toolInput?.file_path;
65
+ return p ? `Editing ${path.basename(p)}` : 'Editing file';
66
+ },
67
+ create_directory: () => {
68
+ const p = toolInput?.path;
69
+ return p ? `Creating directory ${path.basename(p)}` : 'Creating directory';
70
+ },
71
+ list_directory: () => {
72
+ const p = toolInput?.path;
73
+ return p ? `Listing ${path.basename(p)}/` : 'Listing directory';
74
+ },
75
+ search_files: () => 'Searching files',
76
+ move_file: () => 'Moving file',
77
+ execute_command: () => {
78
+ const cmd = toolInput?.command;
79
+ return cmd ? `Running: ${cmd.length > 60 ? cmd.slice(0, 57) + '...' : cmd}` : 'Running command';
80
+ },
81
+ bash: () => {
82
+ const cmd = toolInput?.command;
83
+ return cmd ? `Running: ${cmd.length > 60 ? cmd.slice(0, 57) + '...' : cmd}` : 'Running command';
84
+ },
85
+ search: () => {
86
+ const q = toolInput?.query;
87
+ return q ? `Searching: ${q.length > 50 ? q.slice(0, 47) + '...' : q}` : 'Searching';
88
+ },
89
+ scrape: () => {
90
+ const u = toolInput?.url;
91
+ return u ? `Fetching ${u.length > 50 ? u.slice(0, 47) + '...' : u}` : 'Fetching page';
92
+ },
93
+ list_processes: () => 'Listing processes',
94
+ kill_process: () => 'Killing process',
95
+ };
96
+
97
+ if (descriptions[action]) {
98
+ return descriptions[action]();
99
+ }
100
+
101
+ const humanized = action.replace(/_/g, ' ').replace(/\b\w/g, (c) => c);
102
+ if (server) {
103
+ return `${humanized} (${server})`;
104
+ }
105
+ return humanized;
106
+ }
107
+
108
+ /**
109
+ * Make absolute file paths clickable in terminal output using OSC 8 hyperlinks.
110
+ */
111
+ function linkifyPaths(text) {
112
+ return text.replace(/(\/(?:Users|home|tmp|var|opt|etc)\/\S+)/g, (match) => {
113
+ const trailingMatch = match.match(/([.,;:!?)}\]]+)$/);
114
+ const cleanPath = trailingMatch ? match.slice(0, -trailingMatch[1].length) : match;
115
+ const trailing = trailingMatch ? trailingMatch[1] : '';
116
+ const url = `file://${cleanPath}`;
117
+ return `\x1b]8;;${url}\x07${cleanPath}\x1b]8;;\x07${trailing}`;
118
+ });
119
+ }
120
+
121
+ // ── Spinner ──────────────────────────────────────────────────────────────
122
+
123
+ const SPINNER_FRAMES = ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f'];
124
+
125
+ function createSpinner() {
126
+ let interval = null;
127
+ let frameIndex = 0;
128
+ let currentText = 'Thinking';
129
+ let lineLength = 0;
130
+
131
+ function clear() {
132
+ if (lineLength > 0) {
133
+ process.stdout.write('\r' + ' '.repeat(lineLength) + '\r');
134
+ lineLength = 0;
135
+ }
136
+ }
137
+
138
+ function render() {
139
+ clear();
140
+ const frame = `${DIM}${SPINNER_FRAMES[frameIndex]} ${currentText}...${RESET}`;
141
+ process.stdout.write(frame);
142
+ lineLength = 2 + currentText.length + 3;
143
+ frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length;
144
+ }
145
+
146
+ return {
147
+ start(text = 'Thinking') {
148
+ currentText = text;
149
+ frameIndex = 0;
150
+ if (interval) clearInterval(interval);
151
+ render();
152
+ interval = setInterval(render, 80);
153
+ },
154
+ update(text) {
155
+ currentText = text;
156
+ },
157
+ stop() {
158
+ if (interval) {
159
+ clearInterval(interval);
160
+ interval = null;
161
+ }
162
+ clear();
163
+ },
164
+ get active() {
165
+ return interval !== null;
166
+ },
167
+ };
168
+ }
169
+
170
+ // ── Interactive selector ─────────────────────────────────────────────────
171
+
172
+ /**
173
+ * Show an interactive arrow-key selector in the terminal.
174
+ * Returns the selected option object { label, value }.
175
+ */
176
+ function selectOption(options, { prompt: promptText = '', rl: rlInterface } = {}) {
177
+ return new Promise((resolve) => {
178
+ let selectedIndex = 0;
179
+
180
+ if (rlInterface) rlInterface.pause();
181
+
182
+ const render = () => {
183
+ if (render._rendered) {
184
+ process.stdout.write(`\x1b[${options.length}A`);
185
+ }
186
+ options.forEach((opt, i) => {
187
+ const prefix = i === selectedIndex ? `${CYAN}\u276f${RESET} ${BOLD}` : ' ';
188
+ const suffix = i === selectedIndex ? RESET : '';
189
+ const dimLabel = i === selectedIndex ? opt.label : `${DIM}${opt.label}${RESET}`;
190
+ process.stdout.write(`\r\x1b[K${prefix}${dimLabel}${suffix}\n`);
191
+ });
192
+ render._rendered = true;
193
+ };
194
+
195
+ render();
196
+
197
+ const wasRaw = process.stdin.isRaw;
198
+ if (process.stdin.isTTY) {
199
+ process.stdin.setRawMode(true);
200
+ }
201
+ process.stdin.resume();
202
+
203
+ const onKey = (data) => {
204
+ const key = data.toString();
205
+
206
+ if (key === '\x1b[A') {
207
+ selectedIndex = (selectedIndex - 1 + options.length) % options.length;
208
+ render();
209
+ return;
210
+ }
211
+ if (key === '\x1b[B') {
212
+ selectedIndex = (selectedIndex + 1) % options.length;
213
+ render();
214
+ return;
215
+ }
216
+ if (key === '\r' || key === '\n') {
217
+ cleanup();
218
+ resolve(options[selectedIndex]);
219
+ return;
220
+ }
221
+ const num = parseInt(key);
222
+ if (num >= 1 && num <= options.length) {
223
+ selectedIndex = num - 1;
224
+ render();
225
+ cleanup();
226
+ resolve(options[selectedIndex]);
227
+ return;
228
+ }
229
+ if (key === '\x03') {
230
+ cleanup();
231
+ resolve(options.find((o) => o.value === 'deny') || options[options.length - 1]);
232
+ return;
233
+ }
234
+ };
235
+
236
+ const cleanup = () => {
237
+ process.stdin.removeListener('data', onKey);
238
+ if (process.stdin.isTTY) {
239
+ process.stdin.setRawMode(wasRaw || false);
240
+ }
241
+ if (rlInterface) rlInterface.resume();
242
+ };
243
+
244
+ process.stdin.on('data', onKey);
245
+ });
246
+ }
247
+
248
+ // ── Main ─────────────────────────────────────────────────────────────────
249
+
250
+ export default async function chat(args) {
251
+ let verbose = args.verbose || false;
252
+ const workspacePath = path.resolve(
253
+ args.workspace || process.env.FRIDAY_WORKSPACE || path.join(os.homedir(), 'FridayWorkspace')
254
+ );
255
+ fs.mkdirSync(workspacePath, { recursive: true });
256
+
257
+ const env = { ...process.env, FRIDAY_WORKSPACE: workspacePath };
258
+
259
+ if (verbose) {
260
+ console.log(`Starting Friday with workspace: ${workspacePath}`);
261
+ }
262
+
263
+ const backend = spawn('node', [serverScript], {
264
+ cwd: runtimeDir,
265
+ env,
266
+ stdio: ['pipe', 'pipe', 'pipe'],
267
+ });
268
+
269
+ if (verbose) {
270
+ backend.stderr.on('data', (chunk) => {
271
+ process.stderr.write(chunk);
272
+ });
273
+ } else {
274
+ backend.stderr.resume();
275
+ }
276
+
277
+ backend.on('exit', (code, signal) => {
278
+ if (verbose) {
279
+ console.log(`Backend exited (code=${code ?? 'null'} signal=${signal ?? 'none'})`);
280
+ }
281
+ process.exit(code ?? 0);
282
+ });
283
+
284
+ const rl = readline.createInterface({
285
+ input: process.stdin,
286
+ output: process.stdout,
287
+ prompt: PROMPT_STRING,
288
+ });
289
+
290
+ let sessionId = null;
291
+ let pendingPermission = null;
292
+ let pendingRulePrompt = null;
293
+ const rulePromptQueue = [];
294
+ let isStreaming = false;
295
+ let accumulatedResponse = ''; // Track response text for post-response hints
296
+
297
+ const spinner = createSpinner();
298
+
299
+ function writeMessage(payload) {
300
+ backend.stdin.write(`${JSON.stringify(payload)}\n`);
301
+ }
302
+
303
+ function sendPermissionResponse(approved, { updatedInput, message } = {}) {
304
+ if (!pendingPermission) {
305
+ console.log(`${DIM}No pending permission request.${RESET}`);
306
+ return;
307
+ }
308
+ const response = {
309
+ type: 'permission_response',
310
+ permission_id: pendingPermission.permission_id,
311
+ approved,
312
+ };
313
+ if (updatedInput) response.updated_input = updatedInput;
314
+ if (typeof message === 'string' && message.length > 0) response.message = message;
315
+ writeMessage(response);
316
+ pendingPermission = null;
317
+ if (approved) {
318
+ spinner.start('Working');
319
+ }
320
+ }
321
+
322
+ function showRulePrompt() {
323
+ if (pendingRulePrompt || rulePromptQueue.length === 0) return;
324
+ pendingRulePrompt = rulePromptQueue.shift();
325
+ const prompt = pendingRulePrompt;
326
+ console.log('');
327
+ console.log(`${BOLD}${prompt.title}${RESET}`);
328
+ if (prompt.message) {
329
+ console.log(`${DIM}${prompt.message}${RESET}`);
330
+ }
331
+ prompt.actions.forEach((action, index) => {
332
+ console.log(` ${BOLD}${index + 1}${RESET} ${action.label}`);
333
+ });
334
+ console.log(`${DIM}Type :rule <number> to choose.${RESET}`);
335
+ console.log('');
336
+ }
337
+
338
+ function handleRuleAction(actionIdOrIndex) {
339
+ if (!pendingRulePrompt) {
340
+ console.log(`${DIM}No rule prompt to respond to.${RESET}`);
341
+ return;
342
+ }
343
+ let resolvedActionId = actionIdOrIndex;
344
+ const prompt = pendingRulePrompt;
345
+ if (/^\d+$/.test(actionIdOrIndex)) {
346
+ const idx = Number(actionIdOrIndex) - 1;
347
+ const action = prompt.actions[idx];
348
+ if (!action) {
349
+ console.log(`${RED}No action at index ${actionIdOrIndex}${RESET}`);
350
+ return;
351
+ }
352
+ resolvedActionId = action.id;
353
+ }
354
+ if (!prompt.actions.some((a) => a.id === resolvedActionId)) {
355
+ console.log(`${RED}Unknown action: ${resolvedActionId}${RESET}`);
356
+ return;
357
+ }
358
+ writeMessage({
359
+ type: 'rule_action',
360
+ prompt_id: prompt.prompt_id,
361
+ action_id: resolvedActionId,
362
+ });
363
+ pendingRulePrompt = null;
364
+ showRulePrompt();
365
+ }
366
+
367
+ // ── Slash command context ──────────────────────────────────────────────
368
+
369
+ const slashCtx = {
370
+ get rl() { return rl; },
371
+ get sessionId() { return sessionId; },
372
+ get workspacePath() { return workspacePath; },
373
+ get verbose() { return verbose; },
374
+ get spinner() { return spinner; },
375
+ get backend() { return backend; },
376
+ writeMessage,
377
+ selectOption,
378
+ resetSession() { sessionId = null; },
379
+ toggleVerbose() {
380
+ verbose = !verbose;
381
+ if (verbose) {
382
+ backend.stderr.removeAllListeners('data');
383
+ backend.stderr.on('data', (chunk) => process.stderr.write(chunk));
384
+ console.log(`${GREEN}Verbose mode on${RESET}`);
385
+ } else {
386
+ backend.stderr.removeAllListeners('data');
387
+ backend.stderr.resume();
388
+ console.log(`${DIM}Verbose mode off${RESET}`);
389
+ }
390
+ },
391
+ };
392
+
393
+ // ── Backend message handler ────────────────────────────────────────────
394
+
395
+ function handleBackendLine(line) {
396
+ if (!line.trim()) return;
397
+ try {
398
+ const msg = JSON.parse(line);
399
+
400
+ // Check if a slash command is waiting for this response type
401
+ if (checkPendingResponse(msg)) {
402
+ return;
403
+ }
404
+
405
+ // In verbose mode, show everything raw (original behavior)
406
+ if (verbose) {
407
+ handleBackendLineVerbose(msg);
408
+ return;
409
+ }
410
+
411
+ switch (msg.type) {
412
+ case 'ready':
413
+ console.log(renderWelcome());
414
+ console.log('');
415
+ rl.prompt();
416
+ break;
417
+
418
+ case 'session':
419
+ sessionId = msg.session_id;
420
+ break;
421
+
422
+ case 'thinking':
423
+ if (!spinner.active) {
424
+ spinner.start('Thinking');
425
+ }
426
+ break;
427
+
428
+ case 'info':
429
+ break;
430
+
431
+ case 'chunk':
432
+ if (spinner.active) {
433
+ spinner.stop();
434
+ }
435
+ if (!isStreaming) {
436
+ isStreaming = true;
437
+ }
438
+ {
439
+ const text = msg.text || msg.content || '';
440
+ accumulatedResponse += text;
441
+ process.stdout.write(linkifyPaths(text));
442
+ }
443
+ break;
444
+
445
+ case 'thinking_complete':
446
+ break;
447
+
448
+ case 'tool_use': {
449
+ if (spinner.active) {
450
+ spinner.stop();
451
+ }
452
+ if (isStreaming) {
453
+ process.stdout.write('\n');
454
+ isStreaming = false;
455
+ }
456
+ const desc = humanizeToolUse(msg.tool_name, msg.tool_input);
457
+ spinner.start(desc);
458
+ break;
459
+ }
460
+
461
+ case 'tool_result':
462
+ break;
463
+
464
+ case 'permission_request':
465
+ pendingPermission = msg;
466
+ if (spinner.active) {
467
+ spinner.stop();
468
+ }
469
+ if (isStreaming) {
470
+ process.stdout.write('\n');
471
+ isStreaming = false;
472
+ }
473
+ console.log('');
474
+ console.log(`${YELLOW}${BOLD}Permission needed:${RESET} ${msg.description || msg.tool_name}`);
475
+ if (msg.tool_input) {
476
+ const entries = Object.entries(msg.tool_input);
477
+ const preview = entries.slice(0, 3).map(([k, v]) => {
478
+ const val = typeof v === 'string' ? v : JSON.stringify(v);
479
+ const short = val.length > 70 ? val.slice(0, 67) + '...' : val;
480
+ return ` ${DIM}${k}: ${short}${RESET}`;
481
+ });
482
+ preview.forEach((line) => console.log(line));
483
+ if (entries.length > 3) {
484
+ console.log(` ${DIM}...and ${entries.length - 3} more${RESET}`);
485
+ }
486
+ }
487
+ console.log('');
488
+ selectOption(
489
+ [
490
+ { label: 'Allow', value: 'allow' },
491
+ { label: 'Allow for this session', value: 'session' },
492
+ { label: 'Deny', value: 'deny' },
493
+ ],
494
+ { rl }
495
+ ).then((choice) => {
496
+ if (choice.value === 'deny') {
497
+ sendPermissionResponse(false, {});
498
+ } else {
499
+ const permissionLevel = choice.value === 'session' ? 'session' : 'once';
500
+ const response = {
501
+ type: 'permission_response',
502
+ permission_id: pendingPermission.permission_id,
503
+ approved: true,
504
+ permission_level: permissionLevel,
505
+ };
506
+ writeMessage(response);
507
+ pendingPermission = null;
508
+ spinner.start('Working');
509
+ }
510
+ rl.prompt();
511
+ });
512
+ break;
513
+
514
+ case 'permission_cancelled':
515
+ if (pendingPermission && pendingPermission.permission_id === msg.permission_id) {
516
+ pendingPermission = null;
517
+ }
518
+ break;
519
+
520
+ case 'rule_prompt':
521
+ if (spinner.active) {
522
+ spinner.stop();
523
+ }
524
+ rulePromptQueue.push(msg);
525
+ if (!pendingRulePrompt) showRulePrompt();
526
+ break;
527
+
528
+ case 'error':
529
+ if (spinner.active) {
530
+ spinner.stop();
531
+ }
532
+ console.log(`\n${RED}Error: ${msg.message}${RESET}`);
533
+ rl.prompt();
534
+ break;
535
+
536
+ case 'complete':
537
+ if (spinner.active) {
538
+ spinner.stop();
539
+ }
540
+ if (isStreaming) {
541
+ isStreaming = false;
542
+ }
543
+ // Show cost summary if available
544
+ if (msg.cost && msg.cost.estimated > 0) {
545
+ const cost = msg.cost.estimated;
546
+ const costStr = cost < 0.01 ? `${(cost * 100).toFixed(2)}c` : `$${cost.toFixed(4)}`;
547
+ const tokens = msg.cost.tokens;
548
+ console.log(`\n${DIM}${costStr} \u00b7 ${tokens.input + tokens.output} tokens${RESET}`);
549
+ } else {
550
+ console.log('');
551
+ }
552
+ // Post-response smart affordance hint
553
+ if (accumulatedResponse) {
554
+ const postHint = checkPostResponseHint(accumulatedResponse);
555
+ if (postHint) {
556
+ console.log(postHint);
557
+ }
558
+ accumulatedResponse = '';
559
+ }
560
+ rl.prompt();
561
+ break;
562
+
563
+ default:
564
+ break;
565
+ }
566
+ } catch {
567
+ if (verbose) {
568
+ process.stdout.write(line);
569
+ }
570
+ }
571
+ }
572
+
573
+ // ── Verbose handler (original behavior) ────────────────────────────────
574
+
575
+ function handleBackendLineVerbose(msg) {
576
+ switch (msg.type) {
577
+ case 'ready':
578
+ console.log('Friday is ready. Type your prompt to begin.');
579
+ rl.prompt();
580
+ break;
581
+ case 'session':
582
+ sessionId = msg.session_id;
583
+ console.log(`Session: ${sessionId}`);
584
+ break;
585
+ case 'thinking':
586
+ console.log(`[thinking] ${msg.content}`);
587
+ break;
588
+ case 'info':
589
+ console.log(`[info] ${msg.message}`);
590
+ break;
591
+ case 'chunk':
592
+ process.stdout.write(msg.text || msg.content || '');
593
+ break;
594
+ case 'permission_request':
595
+ pendingPermission = msg;
596
+ console.log('\n=== Permission Request ==========================');
597
+ console.log(`Tool : ${msg.tool_name}`);
598
+ console.log(`Desc : ${msg.description}`);
599
+ if (msg.tool_input) {
600
+ const keys = Object.keys(msg.tool_input);
601
+ console.log('Input:');
602
+ keys.slice(0, 5).forEach((key) => {
603
+ console.log(` ${key}: ${JSON.stringify(msg.tool_input[key])}`);
604
+ });
605
+ if (keys.length > 5) console.log(` ...and ${keys.length - 5} more`);
606
+ }
607
+ console.log('Type :allow or :deny to respond.');
608
+ console.log('===============================================\n');
609
+ rl.prompt();
610
+ break;
611
+ case 'permission_cancelled':
612
+ if (pendingPermission && pendingPermission.permission_id === msg.permission_id) {
613
+ pendingPermission = null;
614
+ }
615
+ console.log(`Permission ${msg.permission_id} cancelled.`);
616
+ break;
617
+ case 'rule_prompt':
618
+ rulePromptQueue.push(msg);
619
+ if (!pendingRulePrompt) showRulePrompt();
620
+ break;
621
+ case 'error':
622
+ console.log(`[error] ${msg.message}`);
623
+ break;
624
+ case 'complete':
625
+ console.log('');
626
+ rl.prompt();
627
+ break;
628
+ default:
629
+ if (msg.type === 'tool_use') {
630
+ console.log(`\n[tool] ${msg.tool_name}: ${JSON.stringify(msg.tool_input || {}).slice(0, 200)}`);
631
+ } else if (msg.type === 'tool_result') {
632
+ const preview =
633
+ typeof msg.content === 'string'
634
+ ? msg.content.slice(0, 200)
635
+ : JSON.stringify(msg.content).slice(0, 200);
636
+ console.log(`[result] ${preview}`);
637
+ }
638
+ }
639
+ }
640
+
641
+ // ── Buffer partial lines from stdout ───────────────────────────────────
642
+
643
+ let buffer = '';
644
+ backend.stdout.on('data', (chunk) => {
645
+ buffer += chunk.toString();
646
+ const lines = buffer.split(/\r?\n/);
647
+ buffer = lines.pop() || '';
648
+ lines.forEach(handleBackendLine);
649
+ });
650
+
651
+ // ── Input handler ──────────────────────────────────────────────────────
652
+
653
+ rl.on('line', async (input) => {
654
+ const line = input.trim();
655
+ if (!line) {
656
+ rl.prompt();
657
+ return;
658
+ }
659
+
660
+ // ── Slash commands (/help, /plugins, etc.) ──────────────────────────
661
+ if (line.startsWith('/')) {
662
+ await routeSlashCommand(line, slashCtx);
663
+ return;
664
+ }
665
+
666
+ // ── Colon commands (backward compatibility) ─────────────────────────
667
+ if (line.startsWith(':')) {
668
+ const [command, ...rest] = line.slice(1).split(' ');
669
+ const argString = rest.join(' ').trim();
670
+
671
+ // Check if this is a slash command alias — show migration hint
672
+ const migrated = handleColonCommand(line, slashCtx);
673
+
674
+ // Handle legacy colon-only commands (:allow, :deny, :rule, :raw)
675
+ switch (command) {
676
+ case 'q':
677
+ case 'quit':
678
+ // Migrate to /quit
679
+ console.log(`${ORANGE}Hint:${RESET} ${DIM}Commands now use / prefix. Try ${BOLD}/quit${RESET}`);
680
+ spinner.stop();
681
+ backend.kill();
682
+ rl.close();
683
+ return;
684
+ case 'new':
685
+ console.log(`${ORANGE}Hint:${RESET} ${DIM}Commands now use / prefix. Try ${BOLD}/new${RESET}`);
686
+ sessionId = null;
687
+ writeMessage({ type: 'new_session' });
688
+ console.log(`${DIM}New session started.${RESET}`);
689
+ break;
690
+ case 'allow': {
691
+ let updatedInput;
692
+ if (argString) {
693
+ try {
694
+ updatedInput = JSON.parse(argString);
695
+ } catch {
696
+ console.log(`${RED}Invalid JSON for updated input.${RESET}`);
697
+ break;
698
+ }
699
+ }
700
+ sendPermissionResponse(true, { updatedInput });
701
+ break;
702
+ }
703
+ case 'deny':
704
+ sendPermissionResponse(false, { message: argString });
705
+ break;
706
+ case 'rule':
707
+ if (!argString) {
708
+ console.log(`${DIM}Usage: :rule <number|actionId>${RESET}`);
709
+ break;
710
+ }
711
+ handleRuleAction(argString);
712
+ break;
713
+ case 'raw':
714
+ if (!argString) {
715
+ console.log(`${DIM}Usage: :raw {"type":"..."} ${RESET}`);
716
+ break;
717
+ }
718
+ try {
719
+ writeMessage(JSON.parse(argString));
720
+ } catch {
721
+ console.log(`${RED}Payload must be valid JSON.${RESET}`);
722
+ }
723
+ break;
724
+ case 'help':
725
+ console.log(`${ORANGE}Hint:${RESET} ${DIM}Commands now use / prefix. Try ${BOLD}/help${RESET}`);
726
+ await routeSlashCommand('/help', slashCtx);
727
+ return;
728
+ case 'verbose':
729
+ console.log(`${ORANGE}Hint:${RESET} ${DIM}Commands now use / prefix. Try ${BOLD}/verbose${RESET}`);
730
+ slashCtx.toggleVerbose();
731
+ break;
732
+ default:
733
+ // Check if it maps to a slash command
734
+ if (!migrated) {
735
+ console.log(`${DIM}Unknown command :${command}. Type /help for commands.${RESET}`);
736
+ } else {
737
+ // Route through slash system
738
+ await routeSlashCommand(`/${command} ${argString}`.trim(), slashCtx);
739
+ return;
740
+ }
741
+ }
742
+ rl.prompt();
743
+ return;
744
+ }
745
+
746
+ // ── Pre-query smart affordance hint ──────────────────────────────────
747
+ const preHint = checkPreQueryHint(line);
748
+ if (preHint) {
749
+ console.log(preHint);
750
+ }
751
+
752
+ // ── Send user query ─────────────────────────────────────────────────
753
+ accumulatedResponse = '';
754
+ spinner.start('Thinking');
755
+ writeMessage({ type: 'query', message: line, session_id: sessionId });
756
+ });
757
+
758
+ rl.on('close', () => {
759
+ spinner.stop();
760
+ backend.kill('SIGINT');
761
+ process.exit(0);
762
+ });
763
+ }