@tryfridayai/cli 0.2.1 → 0.2.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.
@@ -2,7 +2,7 @@
2
2
  * friday chat — Interactive conversation with the agent runtime
3
3
  *
4
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.
5
+ * and provides a bottom-pinned input bar with clean, user-friendly output.
6
6
  *
7
7
  * Use --verbose to see raw debug output (session IDs, tool inputs, etc.)
8
8
  */
@@ -11,9 +11,7 @@ import { spawn } from 'child_process';
11
11
  import fs from 'fs';
12
12
  import os from 'os';
13
13
  import path from 'path';
14
- import readline from 'readline';
15
14
  import { fileURLToPath } from 'url';
16
- import { createRequire } from 'module';
17
15
 
18
16
  // ── Chat modules ─────────────────────────────────────────────────────────
19
17
 
@@ -26,20 +24,9 @@ import {
26
24
  routeSlashCommand, handleColonCommand, checkPendingResponse,
27
25
  } from './chat/slashCommands.js';
28
26
  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
- }
27
+ import { runtimeDir } from '../resolveRuntime.js';
28
+ import { loadApiKeysToEnv } from '../secureKeyStore.js';
29
+ import InputLine from './chat/inputLine.js';
43
30
  const serverScript = path.join(runtimeDir, 'friday-server.js');
44
31
 
45
32
  // ── Tool humanization ────────────────────────────────────────────────────
@@ -99,12 +86,59 @@ function humanizeToolUse(toolName, toolInput) {
99
86
  }
100
87
 
101
88
  const humanized = action.replace(/_/g, ' ').replace(/\b\w/g, (c) => c);
102
- if (server) {
103
- return `${humanized} (${server})`;
104
- }
105
89
  return humanized;
106
90
  }
107
91
 
92
+ /** Humanize a tool name for permission prompts — hide MCP internals */
93
+ function humanizePermission(toolName, description) {
94
+ // Map tool actions to friendly descriptions
95
+ const friendlyNames = {
96
+ 'generate_image': 'Generate Image',
97
+ 'generate image': 'Generate Image',
98
+ 'generate_video': 'Generate Video',
99
+ 'generate video': 'Generate Video',
100
+ 'text_to_speech': 'Text to Speech',
101
+ 'text to speech': 'Text to Speech',
102
+ 'speech_to_text': 'Speech to Text',
103
+ 'speech to text': 'Speech to Text',
104
+ 'query_model': 'Query AI Model',
105
+ 'list_voices': 'List Voices',
106
+ 'clone_voice': 'Clone Voice',
107
+ 'execute_command': 'Run Terminal Command',
108
+ 'execute command': 'Run Terminal Command',
109
+ 'start_preview': 'Start Preview Server',
110
+ 'stop_preview': 'Stop Preview Server',
111
+ 'WebSearch': 'Search the Web',
112
+ 'WebFetch': 'Fetch Web Page',
113
+ 'scrape': 'Scrape Web Page',
114
+ 'crawl': 'Crawl Website',
115
+ };
116
+
117
+ if (description) {
118
+ // Strip MCP server prefixes: "mcp__server__" or "mcp server-name "
119
+ let cleaned = description
120
+ .replace(/mcp__[\w-]+__/gi, '') // mcp__server__tool
121
+ .replace(/mcp\s+[\w-]+\s+/gi, '') // mcp server-name tool
122
+ .replace(/_/g, ' ')
123
+ .trim();
124
+
125
+ // Check if cleaned text matches a friendly name
126
+ const lowerCleaned = cleaned.toLowerCase();
127
+ for (const [key, value] of Object.entries(friendlyNames)) {
128
+ if (lowerCleaned === key.toLowerCase() || lowerCleaned.startsWith(key.toLowerCase())) {
129
+ return value;
130
+ }
131
+ }
132
+ return cleaned;
133
+ }
134
+
135
+ if (!toolName) return 'use a tool';
136
+ const parts = toolName.split('__');
137
+ const action = parts[parts.length - 1];
138
+
139
+ return friendlyNames[action] || `Allow Friday to use ${action.replace(/_/g, ' ')}`;
140
+ }
141
+
108
142
  /**
109
143
  * Make absolute file paths clickable in terminal output using OSC 8 hyperlinks.
110
144
  */
@@ -125,7 +159,7 @@ const SPINNER_FRAMES = ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u283
125
159
  function createSpinner() {
126
160
  let interval = null;
127
161
  let frameIndex = 0;
128
- let currentText = 'Thinking';
162
+ let currentText = 'Friday is thinking';
129
163
  let lineLength = 0;
130
164
 
131
165
  function clear() {
@@ -144,7 +178,7 @@ function createSpinner() {
144
178
  }
145
179
 
146
180
  return {
147
- start(text = 'Thinking') {
181
+ start(text = 'Friday is thinking') {
148
182
  currentText = text;
149
183
  frameIndex = 0;
150
184
  if (interval) clearInterval(interval);
@@ -254,6 +288,36 @@ export default async function chat(args) {
254
288
  );
255
289
  fs.mkdirSync(workspacePath, { recursive: true });
256
290
 
291
+ // Load API keys from secure storage (system keychain)
292
+ // Keys are loaded into process.env for the runtime to access,
293
+ // but are filtered out before being passed to the agent (see AgentRuntime.js)
294
+ try {
295
+ await loadApiKeysToEnv();
296
+ } catch (err) {
297
+ if (verbose) {
298
+ console.log(`${DIM}Note: Could not load from secure storage: ${err.message}${RESET}`);
299
+ }
300
+ }
301
+
302
+ // Fallback: Also check ~/.friday/.env for legacy keys (will be migrated to keychain)
303
+ const fridayEnvPath = path.join(os.homedir(), '.friday', '.env');
304
+ try {
305
+ if (fs.existsSync(fridayEnvPath)) {
306
+ const content = fs.readFileSync(fridayEnvPath, 'utf8');
307
+ for (const line of content.split('\n')) {
308
+ const trimmed = line.trim();
309
+ if (trimmed.startsWith('#') || !trimmed.includes('=')) continue;
310
+ const eqIdx = trimmed.indexOf('=');
311
+ const key = trimmed.slice(0, eqIdx).trim();
312
+ const val = trimmed.slice(eqIdx + 1).trim();
313
+ // Only use .env values if not already set from secure storage
314
+ if (key && val && !process.env[key]) {
315
+ process.env[key] = val;
316
+ }
317
+ }
318
+ }
319
+ } catch { /* ignore */ }
320
+
257
321
  const env = { ...process.env, FRIDAY_WORKSPACE: workspacePath };
258
322
 
259
323
  if (verbose) {
@@ -281,11 +345,17 @@ export default async function chat(args) {
281
345
  process.exit(code ?? 0);
282
346
  });
283
347
 
284
- const rl = readline.createInterface({
285
- input: process.stdin,
286
- output: process.stdout,
287
- prompt: PROMPT_STRING,
288
- });
348
+ // ── InputLine (replaces readline) ──────────────────────────────────────
349
+
350
+ const inputLine = new InputLine();
351
+
352
+ // Compatibility adapter for slashCommands.js (expects ctx.rl)
353
+ const rlCompat = {
354
+ pause() { inputLine.pause(); },
355
+ resume() { inputLine.resume(); },
356
+ prompt() { inputLine.prompt(); },
357
+ close() { inputLine.destroy(); },
358
+ };
289
359
 
290
360
  let sessionId = null;
291
361
  let pendingPermission = null;
@@ -294,6 +364,14 @@ export default async function chat(args) {
294
364
  let isStreaming = false;
295
365
  let accumulatedResponse = ''; // Track response text for post-response hints
296
366
 
367
+ // ── Permission queue (fixes issues b & c) ──────────────────────────────
368
+ // Multiple permission_request messages can arrive simultaneously.
369
+ // We queue them and show only one selectOption at a time, preventing
370
+ // stacked prompts and the `tool_use ids must be unique` API error.
371
+
372
+ const permissionQueue = [];
373
+ let showingPermission = false;
374
+
297
375
  const spinner = createSpinner();
298
376
 
299
377
  function writeMessage(payload) {
@@ -319,6 +397,73 @@ export default async function chat(args) {
319
397
  }
320
398
  }
321
399
 
400
+ /**
401
+ * Display the next queued permission prompt. Only one is shown at a time.
402
+ * When the user responds, processNextPermission() is called again to
403
+ * dequeue the next one (if any).
404
+ */
405
+ function processNextPermission() {
406
+ if (showingPermission) return; // one at a time
407
+ if (permissionQueue.length === 0) return;
408
+
409
+ showingPermission = true;
410
+ const msg = permissionQueue.shift();
411
+ pendingPermission = msg;
412
+
413
+ if (spinner.active) {
414
+ spinner.stop();
415
+ }
416
+ if (isStreaming) {
417
+ process.stdout.write('\n');
418
+ isStreaming = false;
419
+ }
420
+
421
+ console.log('');
422
+ console.log(`${YELLOW}${BOLD}Permission needed:${RESET} ${humanizePermission(msg.tool_name, msg.description)}`);
423
+ if (msg.tool_input) {
424
+ const entries = Object.entries(msg.tool_input);
425
+ const preview = entries.slice(0, 3).map(([k, v]) => {
426
+ const val = typeof v === 'string' ? v : JSON.stringify(v);
427
+ const short = val.length > 70 ? val.slice(0, 67) + '...' : val;
428
+ return ` ${DIM}${k}: ${short}${RESET}`;
429
+ });
430
+ preview.forEach((line) => console.log(line));
431
+ if (entries.length > 3) {
432
+ console.log(` ${DIM}...and ${entries.length - 3} more${RESET}`);
433
+ }
434
+ }
435
+ console.log('');
436
+ selectOption(
437
+ [
438
+ { label: 'Allow', value: 'allow' },
439
+ { label: 'Allow for this session', value: 'session' },
440
+ { label: 'Deny', value: 'deny' },
441
+ ],
442
+ { rl: rlCompat }
443
+ ).then((choice) => {
444
+ showingPermission = false;
445
+
446
+ if (!pendingPermission) {
447
+ // Permission was cancelled while user was choosing
448
+ inputLine.prompt();
449
+ processNextPermission();
450
+ return;
451
+ }
452
+ if (choice.value === 'deny') {
453
+ sendPermissionResponse(false, {});
454
+ } else {
455
+ sendPermissionResponse(true, {});
456
+ }
457
+
458
+ // Process next queued permission (if any)
459
+ if (permissionQueue.length > 0) {
460
+ processNextPermission();
461
+ } else {
462
+ inputLine.prompt();
463
+ }
464
+ });
465
+ }
466
+
322
467
  function showRulePrompt() {
323
468
  if (pendingRulePrompt || rulePromptQueue.length === 0) return;
324
469
  pendingRulePrompt = rulePromptQueue.shift();
@@ -367,7 +512,7 @@ export default async function chat(args) {
367
512
  // ── Slash command context ──────────────────────────────────────────────
368
513
 
369
514
  const slashCtx = {
370
- get rl() { return rl; },
515
+ get rl() { return rlCompat; },
371
516
  get sessionId() { return sessionId; },
372
517
  get workspacePath() { return workspacePath; },
373
518
  get verbose() { return verbose; },
@@ -412,7 +557,8 @@ export default async function chat(args) {
412
557
  case 'ready':
413
558
  console.log(renderWelcome());
414
559
  console.log('');
415
- rl.prompt();
560
+ inputLine.init();
561
+ inputLine.prompt();
416
562
  break;
417
563
 
418
564
  case 'session':
@@ -421,7 +567,11 @@ export default async function chat(args) {
421
567
 
422
568
  case 'thinking':
423
569
  if (!spinner.active) {
424
- spinner.start('Thinking');
570
+ if (isStreaming) {
571
+ process.stdout.write('\n');
572
+ isStreaming = false;
573
+ }
574
+ spinner.start('Friday is thinking');
425
575
  }
426
576
  break;
427
577
 
@@ -434,6 +584,8 @@ export default async function chat(args) {
434
584
  }
435
585
  if (!isStreaming) {
436
586
  isStreaming = true;
587
+ // Start agent output on a fresh line
588
+ process.stdout.write('\n');
437
589
  }
438
590
  {
439
591
  const text = msg.text || msg.content || '';
@@ -462,59 +614,20 @@ export default async function chat(args) {
462
614
  break;
463
615
 
464
616
  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
- });
617
+ // Queue the permission and process one at a time (fixes issues b & c)
618
+ permissionQueue.push(msg);
619
+ processNextPermission();
512
620
  break;
513
621
 
514
622
  case 'permission_cancelled':
515
623
  if (pendingPermission && pendingPermission.permission_id === msg.permission_id) {
516
624
  pendingPermission = null;
517
625
  }
626
+ // Also remove from queue if still pending
627
+ const cancelIdx = permissionQueue.findIndex(p => p.permission_id === msg.permission_id);
628
+ if (cancelIdx !== -1) {
629
+ permissionQueue.splice(cancelIdx, 1);
630
+ }
518
631
  break;
519
632
 
520
633
  case 'rule_prompt':
@@ -530,7 +643,7 @@ export default async function chat(args) {
530
643
  spinner.stop();
531
644
  }
532
645
  console.log(`\n${RED}Error: ${msg.message}${RESET}`);
533
- rl.prompt();
646
+ inputLine.prompt();
534
647
  break;
535
648
 
536
649
  case 'complete':
@@ -540,12 +653,15 @@ export default async function chat(args) {
540
653
  if (isStreaming) {
541
654
  isStreaming = false;
542
655
  }
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)}`;
656
+ // Show chat token usage if available
657
+ if (msg.cost && msg.cost.tokens) {
547
658
  const tokens = msg.cost.tokens;
548
- console.log(`\n${DIM}${costStr} \u00b7 ${tokens.input + tokens.output} tokens${RESET}`);
659
+ const total = tokens.input + tokens.output;
660
+ if (total > 0) {
661
+ console.log(`\n${DIM}chat: ${total.toLocaleString()} tokens${RESET}`);
662
+ } else {
663
+ console.log('');
664
+ }
549
665
  } else {
550
666
  console.log('');
551
667
  }
@@ -557,7 +673,7 @@ export default async function chat(args) {
557
673
  }
558
674
  accumulatedResponse = '';
559
675
  }
560
- rl.prompt();
676
+ inputLine.prompt();
561
677
  break;
562
678
 
563
679
  default:
@@ -576,7 +692,8 @@ export default async function chat(args) {
576
692
  switch (msg.type) {
577
693
  case 'ready':
578
694
  console.log('Friday is ready. Type your prompt to begin.');
579
- rl.prompt();
695
+ inputLine.init();
696
+ inputLine.prompt();
580
697
  break;
581
698
  case 'session':
582
699
  sessionId = msg.session_id;
@@ -592,21 +709,9 @@ export default async function chat(args) {
592
709
  process.stdout.write(msg.text || msg.content || '');
593
710
  break;
594
711
  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();
712
+ // Queue the permission in verbose mode too
713
+ permissionQueue.push(msg);
714
+ processNextPermission();
610
715
  break;
611
716
  case 'permission_cancelled':
612
717
  if (pendingPermission && pendingPermission.permission_id === msg.permission_id) {
@@ -623,7 +728,7 @@ export default async function chat(args) {
623
728
  break;
624
729
  case 'complete':
625
730
  console.log('');
626
- rl.prompt();
731
+ inputLine.prompt();
627
732
  break;
628
733
  default:
629
734
  if (msg.type === 'tool_use') {
@@ -650,16 +755,51 @@ export default async function chat(args) {
650
755
 
651
756
  // ── Input handler ──────────────────────────────────────────────────────
652
757
 
653
- rl.on('line', async (input) => {
758
+ // Flag to prevent leaked input from askSecret from being sent as query
759
+ let processingSlashCommand = false;
760
+
761
+ // Safety patterns to detect accidentally leaked API keys
762
+ const API_KEY_PATTERNS = [
763
+ /^sk-[a-zA-Z0-9-_]{20,}$/, // OpenAI/Anthropic style
764
+ /^sk-ant-[a-zA-Z0-9-_]{20,}$/, // Anthropic
765
+ /^sk-proj-[a-zA-Z0-9-_]{20,}$/, // OpenAI project keys
766
+ /^AIza[a-zA-Z0-9-_]{30,}$/, // Google API keys
767
+ /^[a-f0-9]{32}$/, // Generic 32-char hex keys
768
+ ];
769
+
770
+ function looksLikeApiKey(text) {
771
+ return API_KEY_PATTERNS.some(pattern => pattern.test(text));
772
+ }
773
+
774
+ inputLine.onSubmit(async (input) => {
654
775
  const line = input.trim();
655
776
  if (!line) {
656
- rl.prompt();
777
+ inputLine.prompt();
778
+ return;
779
+ }
780
+
781
+ // SECURITY: Silently block any input that looks like an API key
782
+ // This is a fallback in case askSecret leaks input to readline buffer
783
+ if (looksLikeApiKey(line)) {
784
+ // Silently ignore - don't alarm the user, just don't send to agent
785
+ inputLine.prompt();
786
+ return;
787
+ }
788
+
789
+ // Ignore input while processing a slash command (e.g., leaked from askSecret)
790
+ if (processingSlashCommand) {
791
+ inputLine.prompt();
657
792
  return;
658
793
  }
659
794
 
660
795
  // ── Slash commands (/help, /plugins, etc.) ──────────────────────────
661
796
  if (line.startsWith('/')) {
662
- await routeSlashCommand(line, slashCtx);
797
+ processingSlashCommand = true;
798
+ try {
799
+ await routeSlashCommand(line, slashCtx);
800
+ } finally {
801
+ processingSlashCommand = false;
802
+ }
663
803
  return;
664
804
  }
665
805
 
@@ -679,7 +819,7 @@ export default async function chat(args) {
679
819
  console.log(`${ORANGE}Hint:${RESET} ${DIM}Commands now use / prefix. Try ${BOLD}/quit${RESET}`);
680
820
  spinner.stop();
681
821
  backend.kill();
682
- rl.close();
822
+ inputLine.destroy();
683
823
  return;
684
824
  case 'new':
685
825
  console.log(`${ORANGE}Hint:${RESET} ${DIM}Commands now use / prefix. Try ${BOLD}/new${RESET}`);
@@ -739,7 +879,7 @@ export default async function chat(args) {
739
879
  return;
740
880
  }
741
881
  }
742
- rl.prompt();
882
+ inputLine.prompt();
743
883
  return;
744
884
  }
745
885
 
@@ -750,14 +890,10 @@ export default async function chat(args) {
750
890
  }
751
891
 
752
892
  // ── Send user query ─────────────────────────────────────────────────
893
+ // Echo user input so it's visible in the scroll region
894
+ console.log(`\n${PURPLE}▸${RESET} ${BOLD}${line}${RESET}`);
753
895
  accumulatedResponse = '';
754
- spinner.start('Thinking');
896
+ spinner.start('Friday is thinking');
755
897
  writeMessage({ type: 'query', message: line, session_id: sessionId });
756
898
  });
757
-
758
- rl.on('close', () => {
759
- spinner.stop();
760
- backend.kill('SIGINT');
761
- process.exit(0);
762
- });
763
899
  }
@@ -6,17 +6,8 @@
6
6
  */
7
7
 
8
8
  import readline from 'readline';
9
- import { createRequire } from 'module';
10
9
  import path from 'path';
11
-
12
- const require = createRequire(import.meta.url);
13
- let runtimeDir;
14
- try {
15
- const runtimePkg = require.resolve('friday-runtime/package.json');
16
- runtimeDir = path.dirname(runtimePkg);
17
- } catch {
18
- runtimeDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', '..', 'runtime');
19
- }
10
+ import { runtimeDir } from '../resolveRuntime.js';
20
11
 
21
12
  const DIM = '\x1b[2m';
22
13
  const RESET = '\x1b[0m';
@@ -31,6 +22,48 @@ function ask(rl, question) {
31
22
  });
32
23
  }
33
24
 
25
+ function askSecret(rl, prompt) {
26
+ return new Promise((resolve) => {
27
+ rl.pause();
28
+ rl.terminal = false;
29
+ process.stdout.write(prompt);
30
+ let input = '';
31
+ const wasRaw = process.stdin.isRaw;
32
+ if (process.stdin.isTTY) process.stdin.setRawMode(true);
33
+ process.stdin.resume();
34
+ const onData = (data) => {
35
+ const str = data.toString();
36
+ for (const char of str) {
37
+ if (char === '\r' || char === '\n') {
38
+ process.stdin.removeListener('data', onData);
39
+ if (process.stdin.isTTY) process.stdin.setRawMode(wasRaw || false);
40
+ process.stdout.write('\n');
41
+ rl.terminal = true;
42
+ rl.resume();
43
+ resolve(input);
44
+ return;
45
+ }
46
+ if (char === '\x03') {
47
+ process.stdin.removeListener('data', onData);
48
+ if (process.stdin.isTTY) process.stdin.setRawMode(wasRaw || false);
49
+ process.stdout.write('\n');
50
+ rl.terminal = true;
51
+ rl.resume();
52
+ resolve('');
53
+ return;
54
+ }
55
+ if (char === '\x7f' || char === '\b') {
56
+ if (input.length > 0) { input = input.slice(0, -1); process.stdout.write('\b \b'); }
57
+ continue;
58
+ }
59
+ input += char;
60
+ process.stdout.write('*');
61
+ }
62
+ };
63
+ process.stdin.on('data', onData);
64
+ });
65
+ }
66
+
34
67
  function maskValue(value) {
35
68
  if (!value || value.length < 8) return '****';
36
69
  return value.slice(0, 4) + '...' + value.slice(-4);
@@ -127,15 +160,12 @@ export default async function install(args) {
127
160
 
128
161
  const requiredTag = field.required ? '' : ` ${DIM}(optional)${RESET}`;
129
162
  const prompt = ` ${field.label}${requiredTag}: `;
130
- const value = await ask(rl, prompt);
163
+ const value = field.type === 'secret'
164
+ ? await askSecret(rl, prompt)
165
+ : await ask(rl, prompt);
131
166
 
132
167
  if (value) {
133
168
  credentials[field.key] = value;
134
- if (field.type === 'secret') {
135
- // Clear the line and reprint masked
136
- process.stdout.write(`\x1b[1A\x1b[2K`);
137
- console.log(` ${field.label}${requiredTag}: ${DIM}${maskValue(value)}${RESET}`);
138
- }
139
169
  } else if (field.required) {
140
170
  console.log(` ${YELLOW}Skipped (required). Plugin may not work without this.${RESET}`);
141
171
  }
@@ -2,17 +2,8 @@
2
2
  * friday plugins — List installed and available plugins
3
3
  */
4
4
 
5
- import { createRequire } from 'module';
6
5
  import path from 'path';
7
-
8
- const require = createRequire(import.meta.url);
9
- let runtimeDir;
10
- try {
11
- const runtimePkg = require.resolve('friday-runtime/package.json');
12
- runtimeDir = path.dirname(runtimePkg);
13
- } catch {
14
- runtimeDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', '..', 'runtime');
15
- }
6
+ import { runtimeDir } from '../resolveRuntime.js';
16
7
 
17
8
  const DIM = '\x1b[2m';
18
9
  const RESET = '\x1b[0m';
@@ -8,17 +8,8 @@
8
8
  */
9
9
 
10
10
  import readline from 'readline';
11
- import { createRequire } from 'module';
12
11
  import path from 'path';
13
-
14
- const require = createRequire(import.meta.url);
15
- let runtimeDir;
16
- try {
17
- const runtimePkg = require.resolve('friday-runtime/package.json');
18
- runtimeDir = path.dirname(runtimePkg);
19
- } catch {
20
- runtimeDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', '..', 'runtime');
21
- }
12
+ import { runtimeDir } from '../resolveRuntime.js';
22
13
 
23
14
  const DIM = '\x1b[2m';
24
15
  const RESET = '\x1b[0m';