@tryfridayai/cli 0.2.1 → 0.2.2

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.
@@ -8,26 +8,16 @@
8
8
  import fs from 'fs';
9
9
  import path from 'path';
10
10
  import os from 'os';
11
- import readline from 'readline';
12
- import { createRequire } from 'module';
13
11
  import {
14
12
  PURPLE, BLUE, TEAL, ORANGE, PINK, DIM, RESET, BOLD,
15
13
  RED, GREEN, CYAN, YELLOW,
16
14
  sectionHeader, labelValue, statusBadge, hint, success, error as errorMsg,
17
15
  maskSecret, groupBy, drawBox,
18
16
  } from './ui.js';
19
-
20
- const require = createRequire(import.meta.url);
21
-
22
- // ── Resolve runtime directory ────────────────────────────────────────────
23
-
24
- let runtimeDir;
25
- try {
26
- const runtimePkg = require.resolve('friday-runtime/package.json');
27
- runtimeDir = path.dirname(runtimePkg);
28
- } catch {
29
- runtimeDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', '..', '..', 'runtime');
30
- }
17
+ import { runtimeDir } from '../../resolveRuntime.js';
18
+ import {
19
+ setApiKey, getApiKey, getConfiguredKeys, isSecureStorageAvailable
20
+ } from '../../secureKeyStore.js';
31
21
 
32
22
  const CONFIG_DIR = process.env.FRIDAY_CONFIG_DIR || path.join(os.homedir(), '.friday');
33
23
  const ENV_FILE = path.join(CONFIG_DIR, '.env');
@@ -40,7 +30,7 @@ const commands = [
40
30
  { name: 'help', aliases: ['h'], description: 'Show all commands' },
41
31
  { name: 'status', aliases: ['s'], description: 'Session, costs, capabilities' },
42
32
  { name: 'plugins', aliases: ['p'], description: 'Install/uninstall/list plugins' },
43
- { name: 'models', aliases: ['m'], description: 'List available models' },
33
+ { name: 'model', aliases: ['m', 'models'], description: 'View and configure models' },
44
34
  { name: 'keys', aliases: ['k'], description: 'Add/update API keys' },
45
35
  { name: 'config', aliases: [], description: 'Permission profile, workspace' },
46
36
  { name: 'schedule', aliases: [], description: 'Manage scheduled agents' },
@@ -140,66 +130,203 @@ function readEnvKeys() {
140
130
  return keys;
141
131
  }
142
132
 
133
+ /**
134
+ * Ask a question and wait for text input.
135
+ * Works with both readline and the InputLine rlCompat adapter.
136
+ */
143
137
  function askQuestion(rl, question) {
144
138
  return new Promise((resolve) => {
145
- rl.question(question, (answer) => resolve(answer.trim()));
139
+ // If rl has a native .question() (readline interface), use it
140
+ if (typeof rl.question === 'function') {
141
+ rl.question(question, (answer) => resolve(answer.trim()));
142
+ return;
143
+ }
144
+
145
+ // Otherwise use raw stdin (InputLine rlCompat adapter)
146
+ rl.pause();
147
+
148
+ let input = '';
149
+ const wasRaw = process.stdin.isRaw;
150
+
151
+ if (process.stdin.isTTY) {
152
+ process.stdin.setRawMode(true);
153
+ }
154
+ process.stdin.resume();
155
+
156
+ const cleanup = () => {
157
+ process.stdin.removeListener('data', onData);
158
+ if (process.stdin.isTTY) {
159
+ process.stdin.setRawMode(wasRaw || false);
160
+ }
161
+ process.stdout.write('\n');
162
+ rl.resume();
163
+ };
164
+
165
+ const onData = (data) => {
166
+ const str = data.toString();
167
+ for (let i = 0; i < str.length; i++) {
168
+ const ch = str[i];
169
+ const code = ch.charCodeAt(0);
170
+
171
+ if (ch === '\r' || ch === '\n') {
172
+ cleanup();
173
+ resolve(input.trim());
174
+ return;
175
+ }
176
+ if (code === 3) { // Ctrl+C
177
+ cleanup();
178
+ resolve('');
179
+ return;
180
+ }
181
+ if (code === 127 || code === 8) { // Backspace
182
+ if (input.length > 0) {
183
+ input = input.slice(0, -1);
184
+ process.stdout.write('\b \b');
185
+ }
186
+ continue;
187
+ }
188
+ // Skip escape sequences
189
+ if (code === 0x1b) {
190
+ i++;
191
+ if (i < str.length && str[i] === '[') {
192
+ i++;
193
+ while (i < str.length && str.charCodeAt(i) >= 0x20 && str.charCodeAt(i) <= 0x3f) i++;
194
+ // skip final byte
195
+ }
196
+ continue;
197
+ }
198
+ // Skip control chars
199
+ if (code < 0x20) continue;
200
+
201
+ input += ch;
202
+ process.stdout.write(ch);
203
+ }
204
+ };
205
+
206
+ process.stdin.on('data', onData);
207
+
208
+ setImmediate(() => {
209
+ process.stdout.write(question);
210
+ });
146
211
  });
147
212
  }
148
213
 
149
214
  /**
150
215
  * Read input with secret masking (hides characters with *).
216
+ * SECURITY: Input is NEVER echoed - only asterisks are shown.
217
+ * Works with both readline and the InputLine rlCompat adapter.
151
218
  */
152
219
  function askSecret(rl, prompt) {
153
220
  return new Promise((resolve) => {
154
- // Pause readline so we can use raw mode
221
+ // Pause InputLine / readline so it doesn't capture keystrokes
155
222
  rl.pause();
156
223
 
157
- process.stdout.write(prompt);
224
+ // Fully detach readline echo if it has terminal property
225
+ const hadTerminal = rl.terminal;
226
+ if ('terminal' in rl) {
227
+ rl.terminal = false;
228
+ }
229
+
230
+ // Clear any buffered input from readline
231
+ if (rl.line) {
232
+ rl.line = '';
233
+ }
158
234
 
159
235
  let input = '';
160
236
  const wasRaw = process.stdin.isRaw;
237
+
238
+ // CRITICAL: Set raw mode FIRST before anything else to prevent echo
161
239
  if (process.stdin.isTTY) {
162
240
  process.stdin.setRawMode(true);
163
241
  }
242
+
243
+ // Resume stdin while in raw mode
164
244
  process.stdin.resume();
165
245
 
246
+ const cleanup = () => {
247
+ process.stdin.removeListener('data', onData);
248
+ if (process.stdin.isTTY) {
249
+ process.stdin.setRawMode(wasRaw || false);
250
+ }
251
+ process.stdout.write('\n');
252
+
253
+ if ('terminal' in rl) {
254
+ rl.terminal = hadTerminal;
255
+ }
256
+
257
+ // Clear readline buffer before resuming to prevent leakage
258
+ if (rl.line) {
259
+ rl.line = '';
260
+ }
261
+ rl.resume();
262
+ };
263
+
166
264
  const onData = (data) => {
167
- const char = data.toString();
168
- if (char === '\r' || char === '\n') {
169
- // Done
170
- process.stdin.removeListener('data', onData);
171
- if (process.stdin.isTTY) {
172
- process.stdin.setRawMode(wasRaw || false);
265
+ const str = data.toString();
266
+ // Use index-based iteration to properly skip multi-byte escape sequences
267
+ let i = 0;
268
+ while (i < str.length) {
269
+ const char = str[i];
270
+ const code = char.charCodeAt(0);
271
+
272
+ if (char === '\r' || char === '\n') {
273
+ cleanup();
274
+ resolve(input);
275
+ return;
173
276
  }
174
- process.stdout.write('\n');
175
- rl.resume();
176
- resolve(input);
177
- return;
178
- }
179
- if (char === '\x03') {
180
- // Ctrl+C
181
- process.stdin.removeListener('data', onData);
182
- if (process.stdin.isTTY) {
183
- process.stdin.setRawMode(wasRaw || false);
277
+ if (char === '\x03') {
278
+ cleanup();
279
+ resolve('');
280
+ return;
184
281
  }
185
- process.stdout.write('\n');
186
- rl.resume();
187
- resolve('');
188
- return;
189
- }
190
- if (char === '\x7f' || char === '\b') {
191
- // Backspace
192
- if (input.length > 0) {
193
- input = input.slice(0, -1);
194
- process.stdout.write('\b \b');
282
+ if (char === '\x7f' || char === '\b') {
283
+ if (input.length > 0) {
284
+ input = input.slice(0, -1);
285
+ process.stdout.write('\b \b');
286
+ }
287
+ i++;
288
+ continue;
195
289
  }
196
- return;
290
+
291
+ // Skip escape sequences (arrow keys, etc. send \x1b[A, \x1b[B, etc.)
292
+ if (code === 0x1b) {
293
+ i++;
294
+ // CSI sequences: ESC [ <params> <final byte>
295
+ if (i < str.length && str[i] === '[') {
296
+ i++;
297
+ // Skip parameter bytes (0x30-0x3f) and intermediate bytes (0x20-0x2f)
298
+ while (i < str.length && str.charCodeAt(i) >= 0x20 && str.charCodeAt(i) <= 0x3f) {
299
+ i++;
300
+ }
301
+ // Skip final byte (0x40-0x7e)
302
+ if (i < str.length) i++;
303
+ }
304
+ // OSC, SS2, SS3 and other ESC sequences: skip next char
305
+ else if (i < str.length) {
306
+ i++;
307
+ }
308
+ continue;
309
+ }
310
+
311
+ // Skip any other control characters (< 0x20)
312
+ if (code < 0x20) {
313
+ i++;
314
+ continue;
315
+ }
316
+
317
+ // Printable character — accept it
318
+ input += char;
319
+ process.stdout.write('*');
320
+ i++;
197
321
  }
198
- input += char;
199
- process.stdout.write('*');
200
322
  };
201
323
 
202
324
  process.stdin.on('data', onData);
325
+
326
+ // Write prompt AFTER raw mode is set and handler is attached
327
+ setImmediate(() => {
328
+ process.stdout.write(prompt);
329
+ });
203
330
  });
204
331
  }
205
332
 
@@ -231,7 +358,7 @@ export async function routeSlashCommand(input, ctx) {
231
358
  case 'help': cmdHelp(ctx); break;
232
359
  case 'status': await cmdStatus(ctx); break;
233
360
  case 'plugins': await cmdPlugins(ctx, argString); break;
234
- case 'models': cmdModels(ctx); break;
361
+ case 'model': await cmdModel(ctx); break;
235
362
  case 'keys': await cmdKeys(ctx); break;
236
363
  case 'config': await cmdConfig(ctx); break;
237
364
  case 'schedule': await cmdSchedule(ctx); break;
@@ -460,7 +587,11 @@ async function pluginInstallFlow(pm, ctx) {
460
587
  const name = pm.getPluginManifest(pluginId).name;
461
588
  console.log('');
462
589
  console.log(success(`\u2713 ${name} installed successfully!`));
463
- console.log(hint('Start a /new session to activate the plugin.'));
590
+ console.log('');
591
+ console.log(hint('To activate the plugin:'));
592
+ console.log(` ${DIM}1. Press Ctrl+C to exit${RESET}`);
593
+ console.log(` ${DIM}2. Run \`friday chat\` to restart${RESET}`);
594
+ console.log(` ${DIM}Your chat history will auto-resume.${RESET}`);
464
595
  console.log('');
465
596
  } catch (err) {
466
597
  console.log(errorMsg(`Install failed: ${err.message}`));
@@ -491,97 +622,225 @@ async function pluginUninstallFlow(pm, ctx) {
491
622
  pm.uninstall(choice.value);
492
623
  console.log('');
493
624
  console.log(success(`\u2713 ${choice.label} uninstalled.`));
494
- console.log(hint('Start a /new session to apply changes.'));
625
+ console.log('');
626
+ console.log(hint('To apply changes:'));
627
+ console.log(` ${DIM}1. Press Ctrl+C to exit${RESET}`);
628
+ console.log(` ${DIM}2. Run \`friday chat\` to restart${RESET}`);
629
+ console.log(` ${DIM}Your chat history will auto-resume.${RESET}`);
495
630
  console.log('');
496
631
  } catch (err) {
497
632
  console.log(errorMsg(`Uninstall failed: ${err.message}`));
498
633
  }
499
634
  }
500
635
 
501
- function cmdModels() {
502
- console.log('');
503
- console.log(sectionHeader('Available Models'));
504
- console.log('');
636
+ function formatModelPrice(pricing, capability) {
637
+ if (!pricing) return '';
505
638
 
506
- let modelsData;
507
- try {
508
- const modelsPath = path.join(runtimeDir, 'src', 'providers', 'models.json');
509
- modelsData = JSON.parse(fs.readFileSync(modelsPath, 'utf8'));
510
- } catch {
511
- console.log(errorMsg('Could not load models.json'));
512
- return;
639
+ // Video models: per second — show standard and high-res/4K tiers
640
+ if (pricing.per_second != null) {
641
+ let result = `$${pricing.per_second.toFixed(2)}/sec`;
642
+ if (pricing.per_second_high_res) {
643
+ result += ` ($${pricing.per_second_high_res.toFixed(2)} high-res)`;
644
+ }
645
+ if (pricing.per_second_4k) {
646
+ result += ` ($${pricing.per_second_4k.toFixed(2)} 4K)`;
647
+ }
648
+ return result;
513
649
  }
514
650
 
515
- const envKeys = readEnvKeys();
516
-
517
- // Group models by capability
518
- const capModels = {
519
- 'Chat': [],
520
- 'Image Gen': [],
521
- 'TTS (Voice)': [],
522
- 'Video Gen': [],
523
- 'STT (Transcription)': [],
524
- };
525
-
526
- const capMap = {
527
- 'chat': 'Chat',
528
- 'image-gen': 'Image Gen',
529
- 'tts': 'TTS (Voice)',
530
- 'video-gen': 'Video Gen',
531
- 'stt': 'STT (Transcription)',
532
- };
651
+ // Chat models: input/output per 1M tokens — show cached price if available
652
+ if (pricing.input_per_1m_tokens != null) {
653
+ let result = `$${pricing.input_per_1m_tokens.toFixed(2)} in / $${pricing.output_per_1m_tokens.toFixed(2)} out per 1M`;
654
+ if (pricing.notes) result += ` — ${pricing.notes}`;
655
+ return result;
656
+ }
533
657
 
534
- for (const [providerId, provider] of Object.entries(modelsData.providers)) {
535
- const keyAvailable = !!envKeys[provider.envKey];
536
- for (const [modelId, model] of Object.entries(provider.models)) {
537
- for (const cap of model.capabilities) {
538
- const groupName = capMap[cap] || cap;
539
- if (!capModels[groupName]) capModels[groupName] = [];
540
- const isDefault = model.default_for?.includes(cap);
541
- capModels[groupName].push({
542
- name: model.name,
543
- provider: providerId,
544
- description: model.description,
545
- isDefault,
546
- available: keyAvailable,
547
- });
658
+ // Image models: per image show quality range for tiered pricing
659
+ if (pricing.per_image != null) {
660
+ if (typeof pricing.per_image === 'number') {
661
+ return `$${pricing.per_image.toFixed(3)}/image`;
662
+ }
663
+ const sizes = pricing.per_image;
664
+ // Collect all prices across sizes and qualities
665
+ const allPrices = [];
666
+ for (const sizeVal of Object.values(sizes)) {
667
+ if (typeof sizeVal === 'number') {
668
+ allPrices.push(sizeVal);
669
+ } else if (typeof sizeVal === 'object') {
670
+ for (const qualityPrice of Object.values(sizeVal)) {
671
+ if (typeof qualityPrice === 'number') allPrices.push(qualityPrice);
672
+ }
673
+ }
674
+ }
675
+ if (allPrices.length > 0) {
676
+ const min = Math.min(...allPrices);
677
+ const max = Math.max(...allPrices);
678
+ if (min === max) {
679
+ return `$${min.toFixed(3)}/image`;
548
680
  }
681
+ return `$${min.toFixed(3)}-$${max.toFixed(3)}/image`;
549
682
  }
550
683
  }
551
684
 
552
- for (const [capName, models] of Object.entries(capModels)) {
553
- if (models.length === 0) continue;
554
- console.log(` ${PURPLE}${BOLD}${capName}${RESET}`);
555
- for (const m of models) {
556
- const defaultTag = m.isDefault ? ` ${TEAL}(default)${RESET}` : '';
557
- const availTag = m.available ? '' : ` ${DIM}(no key)${RESET}`;
558
- console.log(` ${BOLD}${m.name}${RESET}${defaultTag}${availTag} ${DIM}${m.provider}${RESET}`);
559
- console.log(` ${DIM}${m.description}${RESET}`);
685
+ // TTS/STT: per minute (gpt-4o-mini-tts, whisper)
686
+ if (pricing.per_minute != null) {
687
+ return `$${pricing.per_minute.toFixed(3)}/min`;
688
+ }
689
+
690
+ // TTS: per 1M or 1K characters
691
+ if (pricing.per_1m_characters != null) {
692
+ return `$${pricing.per_1m_characters.toFixed(2)}/1M chars`;
693
+ }
694
+ // Google TTS has multiple tiers — show standard and premium
695
+ if (pricing.standard_per_1m_characters != null) {
696
+ let result = `$${pricing.standard_per_1m_characters.toFixed(2)}/1M chars`;
697
+ if (pricing.wavenet_per_1m_characters) {
698
+ result += ` ($${pricing.wavenet_per_1m_characters.toFixed(2)} WaveNet)`;
560
699
  }
561
- console.log('');
700
+ if (pricing.neural2_per_1m_characters) {
701
+ result += ` ($${pricing.neural2_per_1m_characters.toFixed(2)} Neural2)`;
702
+ }
703
+ return result;
562
704
  }
563
705
 
564
- console.log(` ${DIM}Model selection is automatic based on task. Use /keys to add provider keys.${RESET}`);
565
- console.log('');
706
+ // ElevenLabs credit-based pricing
707
+ if (pricing.credits_per_character != null) {
708
+ return `${pricing.credits_per_character} credit${pricing.credits_per_character === 1 ? '' : 's'}/char`;
709
+ }
710
+
711
+ return '';
712
+ }
713
+
714
+ async function cmdModel(ctx) {
715
+ // Lazy-load ProviderRegistry (same pattern as cmdPlugins imports PluginManager)
716
+ const { default: ProviderRegistry } = await import(path.join(runtimeDir, 'providers', 'ProviderRegistry.js'));
717
+ const registry = new ProviderRegistry();
718
+
719
+ const categories = [
720
+ { label: 'Chat', capability: 'chat' },
721
+ { label: 'Image', capability: 'image-gen' },
722
+ { label: 'Video', capability: 'video-gen' },
723
+ { label: 'Voice', capability: 'tts' },
724
+ { label: 'STT', capability: 'stt' },
725
+ ];
726
+
727
+ // Category selection loop
728
+ while (true) {
729
+ console.log('');
730
+ console.log(sectionHeader('Models'));
731
+ console.log('');
732
+
733
+ // Build category options with enabled counts
734
+ const catOptions = categories.map((cat) => {
735
+ const models = registry.getModelsForCapability(cat.capability);
736
+ const enabled = models.filter((m) => !m.disabled).length;
737
+ return {
738
+ label: `${cat.label} ${DIM}(${enabled}/${models.length} enabled)${RESET}`,
739
+ value: cat.capability,
740
+ };
741
+ });
742
+ catOptions.push({ label: 'Done', value: 'done' });
743
+
744
+ const catChoice = await ctx.selectOption(catOptions, { rl: ctx.rl });
745
+ if (catChoice.value === 'done') {
746
+ console.log('');
747
+ console.log(success('Changes saved.'));
748
+ console.log('');
749
+ break;
750
+ }
751
+
752
+ const capability = catChoice.value;
753
+
754
+ // Model list loop for selected category
755
+ while (true) {
756
+ const models = registry.getModelsForCapability(capability);
757
+ console.log('');
758
+
759
+ // Display current model list
760
+ for (const m of models) {
761
+ const dot = m.disabled ? `${RED}\u25cb${RESET}` : `${TEAL}\u25cf${RESET}`;
762
+ const defaultTag = m.isDefault ? '*' : '';
763
+ const statusTag = m.disabled ? `${RED}disabled${RESET}` : `${TEAL}enabled${RESET}`;
764
+ const keyTag = m.hasKey ? '' : ` ${DIM}(no key)${RESET}`;
765
+ const price = formatModelPrice(m.pricing, capability);
766
+ const priceTag = price ? ` ${DIM}| ${price}${RESET}` : '';
767
+ console.log(` ${dot} ${BOLD}${m.name}${defaultTag}${RESET} ${DIM}${m.providerId}${RESET} ${statusTag}${keyTag}${priceTag}`);
768
+ }
769
+ console.log('');
770
+
771
+ // Build toggle options
772
+ const toggleOptions = models.map((m) => ({
773
+ label: `${m.disabled ? 'Enable' : 'Disable'} ${m.name}`,
774
+ value: m.modelId,
775
+ }));
776
+ toggleOptions.push({ label: 'Back', value: 'back' });
777
+
778
+ const toggleChoice = await ctx.selectOption(toggleOptions, { rl: ctx.rl });
779
+ if (toggleChoice.value === 'back') break;
780
+
781
+ const modelId = toggleChoice.value;
782
+ const nowDisabled = registry.toggleModel(modelId);
783
+ const modelName = models.find((m) => m.modelId === modelId)?.name || modelId;
784
+ console.log(nowDisabled
785
+ ? ` ${RED}${modelName} disabled${RESET}`
786
+ : ` ${TEAL}${modelName} enabled${RESET}`);
787
+ }
788
+ }
566
789
  }
567
790
 
568
791
  async function cmdKeys(ctx) {
792
+ // Check if secure storage is available
793
+ const secureAvailable = await isSecureStorageAvailable();
794
+
795
+ // Get configured keys from secure storage
796
+ const configuredKeys = getConfiguredKeys();
797
+
798
+ // Also check legacy env keys for migration
569
799
  const envKeys = readEnvKeys();
570
800
 
571
801
  const keyInfo = [
572
- { key: 'ANTHROPIC_API_KEY', label: 'Anthropic', unlocks: 'Chat (Claude)', value: envKeys.ANTHROPIC_API_KEY },
573
- { key: 'OPENAI_API_KEY', label: 'OpenAI', unlocks: 'Chat, Images, Voice, Video', value: envKeys.OPENAI_API_KEY },
574
- { key: 'GOOGLE_API_KEY', label: 'Google AI', unlocks: 'Chat, Images, Voice, Video', value: envKeys.GOOGLE_API_KEY },
575
- { key: 'ELEVENLABS_API_KEY', label: 'ElevenLabs', unlocks: 'Premium Voice', value: envKeys.ELEVENLABS_API_KEY },
802
+ {
803
+ key: 'ANTHROPIC_API_KEY',
804
+ label: 'Anthropic',
805
+ unlocks: 'Chat (Claude)',
806
+ configured: configuredKeys.ANTHROPIC_API_KEY?.configured || !!envKeys.ANTHROPIC_API_KEY,
807
+ preview: configuredKeys.ANTHROPIC_API_KEY?.preview || (envKeys.ANTHROPIC_API_KEY ? maskSecret(envKeys.ANTHROPIC_API_KEY) : null),
808
+ },
809
+ {
810
+ key: 'OPENAI_API_KEY',
811
+ label: 'OpenAI',
812
+ unlocks: 'Chat, Images, Voice, Video',
813
+ configured: configuredKeys.OPENAI_API_KEY?.configured || !!envKeys.OPENAI_API_KEY,
814
+ preview: configuredKeys.OPENAI_API_KEY?.preview || (envKeys.OPENAI_API_KEY ? maskSecret(envKeys.OPENAI_API_KEY) : null),
815
+ },
816
+ {
817
+ key: 'GOOGLE_API_KEY',
818
+ label: 'Google AI',
819
+ unlocks: 'Chat, Images, Voice, Video',
820
+ configured: configuredKeys.GOOGLE_API_KEY?.configured || !!envKeys.GOOGLE_API_KEY,
821
+ preview: configuredKeys.GOOGLE_API_KEY?.preview || (envKeys.GOOGLE_API_KEY ? maskSecret(envKeys.GOOGLE_API_KEY) : null),
822
+ },
823
+ {
824
+ key: 'ELEVENLABS_API_KEY',
825
+ label: 'ElevenLabs',
826
+ unlocks: 'Premium Voice',
827
+ configured: configuredKeys.ELEVENLABS_API_KEY?.configured || !!envKeys.ELEVENLABS_API_KEY,
828
+ preview: configuredKeys.ELEVENLABS_API_KEY?.preview || (envKeys.ELEVENLABS_API_KEY ? maskSecret(envKeys.ELEVENLABS_API_KEY) : null),
829
+ },
576
830
  ];
577
831
 
578
832
  console.log('');
579
833
  console.log(sectionHeader('API Keys'));
834
+ if (secureAvailable) {
835
+ console.log(` ${DIM}Keys are stored securely in system keychain${RESET}`);
836
+ } else {
837
+ console.log(` ${ORANGE}Warning: Secure storage unavailable, using fallback${RESET}`);
838
+ }
580
839
  console.log('');
581
840
 
582
841
  for (const k of keyInfo) {
583
- const status = k.value
584
- ? `${TEAL}\u2713 configured${RESET} ${DIM}${maskSecret(k.value)}${RESET}`
842
+ const status = k.configured
843
+ ? `${TEAL}\u2713 configured${RESET} ${DIM}${k.preview || '****'}${RESET}`
585
844
  : `${DIM}\u25cb not set${RESET}`;
586
845
  console.log(` ${BOLD}${k.label}${RESET} ${status}`);
587
846
  console.log(` ${DIM}Unlocks: ${k.unlocks}${RESET}`);
@@ -590,7 +849,7 @@ async function cmdKeys(ctx) {
590
849
 
591
850
  // Offer to add/update
592
851
  const options = keyInfo.map(k => ({
593
- label: `${k.value ? 'Update' : 'Add'} ${k.label} key`,
852
+ label: `${k.configured ? 'Update' : 'Add'} ${k.label} key`,
594
853
  value: k.key,
595
854
  }));
596
855
  options.push({ label: 'Done', value: 'done' });
@@ -600,7 +859,7 @@ async function cmdKeys(ctx) {
600
859
 
601
860
  const selected = keyInfo.find(k => k.key === choice.value);
602
861
  console.log('');
603
- console.log(` ${DIM}Enter your ${selected.label} API key:${RESET}`);
862
+ console.log(` ${DIM}Enter your ${selected.label} API key (input is hidden):${RESET}`);
604
863
  const newValue = await askSecret(ctx.rl, ` ${selected.label} key: `);
605
864
 
606
865
  if (!newValue) {
@@ -608,29 +867,46 @@ async function cmdKeys(ctx) {
608
867
  return;
609
868
  }
610
869
 
611
- // Write to ~/.friday/.env
870
+ // Store securely in system keychain
612
871
  try {
613
- const dir = path.dirname(ENV_FILE);
614
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
872
+ if (secureAvailable) {
873
+ await setApiKey(selected.key, newValue);
874
+ console.log('');
875
+ console.log(success(`\u2713 ${selected.label} key saved securely to system keychain`));
876
+ console.log('');
877
+ console.log(hint('To apply changes:'));
878
+ console.log(` ${DIM}1. Press Ctrl+C to exit${RESET}`);
879
+ console.log(` ${DIM}2. Run \`friday chat\` to restart${RESET}`);
880
+ console.log(` ${DIM}Your chat history will auto-resume.${RESET}`);
881
+ console.log('');
882
+ } else {
883
+ // Fallback to .env file (less secure, but functional)
884
+ const dir = path.dirname(ENV_FILE);
885
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
615
886
 
616
- let content = '';
617
- if (fs.existsSync(ENV_FILE)) {
618
- content = fs.readFileSync(ENV_FILE, 'utf8');
619
- }
887
+ let content = '';
888
+ if (fs.existsSync(ENV_FILE)) {
889
+ content = fs.readFileSync(ENV_FILE, 'utf8');
890
+ }
620
891
 
621
- // Replace or append
622
- const regex = new RegExp(`^${selected.key}=.*$`, 'm');
623
- if (regex.test(content)) {
624
- content = content.replace(regex, `${selected.key}=${newValue}`);
625
- } else {
626
- content += `${content.endsWith('\n') || content === '' ? '' : '\n'}${selected.key}=${newValue}\n`;
627
- }
892
+ const regex = new RegExp(`^${selected.key}=.*$`, 'm');
893
+ if (regex.test(content)) {
894
+ content = content.replace(regex, `${selected.key}=${newValue}`);
895
+ } else {
896
+ content += `${content.endsWith('\n') || content === '' ? '' : '\n'}${selected.key}=${newValue}\n`;
897
+ }
628
898
 
629
- fs.writeFileSync(ENV_FILE, content, 'utf8');
630
- console.log('');
631
- console.log(success(`\u2713 ${selected.label} key saved to ~/.friday/.env`));
632
- console.log(hint('Start a /new session for changes to take effect.'));
633
- console.log('');
899
+ fs.writeFileSync(ENV_FILE, content, 'utf8');
900
+ console.log('');
901
+ console.log(success(`\u2713 ${selected.label} key saved to ~/.friday/.env`));
902
+ console.log(` ${ORANGE}Note: For better security, install keytar for secure keychain storage${RESET}`);
903
+ console.log('');
904
+ console.log(hint('To apply changes:'));
905
+ console.log(` ${DIM}1. Press Ctrl+C to exit${RESET}`);
906
+ console.log(` ${DIM}2. Run \`friday chat\` to restart${RESET}`);
907
+ console.log(` ${DIM}Your chat history will auto-resume.${RESET}`);
908
+ console.log('');
909
+ }
634
910
  } catch (err) {
635
911
  console.log(errorMsg(`Failed to save key: ${err.message}`));
636
912
  }
@@ -677,7 +953,11 @@ async function cmdConfig(ctx) {
677
953
  fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(data, null, 2), 'utf8');
678
954
  console.log('');
679
955
  console.log(success(`\u2713 Profile changed to ${profileChoice.value}`));
680
- console.log(hint('Start a /new session for changes to take effect.'));
956
+ console.log('');
957
+ console.log(hint('To apply changes:'));
958
+ console.log(` ${DIM}1. Press Ctrl+C to exit${RESET}`);
959
+ console.log(` ${DIM}2. Run \`friday chat\` to restart${RESET}`);
960
+ console.log(` ${DIM}Your chat history will auto-resume.${RESET}`);
681
961
  } catch (err) {
682
962
  console.log(errorMsg(`Failed to save profile: ${err.message}`));
683
963
  }
@@ -698,7 +978,11 @@ async function cmdConfig(ctx) {
698
978
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2), 'utf8');
699
979
  console.log('');
700
980
  console.log(success(`\u2713 Workspace set to ${resolved}`));
701
- console.log(hint('Start a /new session for changes to take effect.'));
981
+ console.log('');
982
+ console.log(hint('To apply changes:'));
983
+ console.log(` ${DIM}1. Press Ctrl+C to exit${RESET}`);
984
+ console.log(` ${DIM}2. Run \`friday chat\` to restart${RESET}`);
985
+ console.log(` ${DIM}Your chat history will auto-resume.${RESET}`);
702
986
  } catch (err) {
703
987
  console.log(errorMsg(`Failed to set workspace: ${err.message}`));
704
988
  }