@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.
- package/package.json +3 -2
- package/src/commands/chat/inputLine.js +510 -0
- package/src/commands/chat/slashCommands.js +417 -133
- package/src/commands/chat/smartAffordances.js +5 -0
- package/src/commands/chat/welcomeScreen.js +56 -88
- package/src/commands/chat.js +249 -113
- package/src/commands/install.js +46 -16
- package/src/commands/plugins.js +1 -10
- package/src/commands/schedule.js +1 -10
- package/src/commands/setup.js +49 -5
- package/src/commands/uninstall.js +1 -10
- package/src/resolveRuntime.js +31 -0
- package/src/secureKeyStore.js +220 -0
|
@@ -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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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: '
|
|
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(
|
|
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
|
|
221
|
+
// Pause InputLine / readline so it doesn't capture keystrokes
|
|
155
222
|
rl.pause();
|
|
156
223
|
|
|
157
|
-
|
|
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
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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 '
|
|
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(
|
|
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(
|
|
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
|
|
502
|
-
|
|
503
|
-
console.log(sectionHeader('Available Models'));
|
|
504
|
-
console.log('');
|
|
636
|
+
function formatModelPrice(pricing, capability) {
|
|
637
|
+
if (!pricing) return '';
|
|
505
638
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
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
|
-
|
|
565
|
-
|
|
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
|
-
{
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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.
|
|
584
|
-
? `${TEAL}\u2713 configured${RESET} ${DIM}${
|
|
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.
|
|
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
|
-
//
|
|
870
|
+
// Store securely in system keychain
|
|
612
871
|
try {
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
887
|
+
let content = '';
|
|
888
|
+
if (fs.existsSync(ENV_FILE)) {
|
|
889
|
+
content = fs.readFileSync(ENV_FILE, 'utf8');
|
|
890
|
+
}
|
|
620
891
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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(
|
|
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(
|
|
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
|
}
|