@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,976 @@
1
+ /**
2
+ * chat/slashCommands.js — Slash command system for Friday CLI chat
3
+ *
4
+ * Command registry, router, waitForResponse infrastructure,
5
+ * and all /command handler implementations.
6
+ */
7
+
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import os from 'os';
11
+ import readline from 'readline';
12
+ import { createRequire } from 'module';
13
+ import {
14
+ PURPLE, BLUE, TEAL, ORANGE, PINK, DIM, RESET, BOLD,
15
+ RED, GREEN, CYAN, YELLOW,
16
+ sectionHeader, labelValue, statusBadge, hint, success, error as errorMsg,
17
+ maskSecret, groupBy, drawBox,
18
+ } 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
+ }
31
+
32
+ const CONFIG_DIR = process.env.FRIDAY_CONFIG_DIR || path.join(os.homedir(), '.friday');
33
+ const ENV_FILE = path.join(CONFIG_DIR, '.env');
34
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
35
+ const PERMISSIONS_FILE = path.join(CONFIG_DIR, 'permissions.json');
36
+
37
+ // ── Command Registry ─────────────────────────────────────────────────────
38
+
39
+ const commands = [
40
+ { name: 'help', aliases: ['h'], description: 'Show all commands' },
41
+ { name: 'status', aliases: ['s'], description: 'Session, costs, capabilities' },
42
+ { name: 'plugins', aliases: ['p'], description: 'Install/uninstall/list plugins' },
43
+ { name: 'models', aliases: ['m'], description: 'List available models' },
44
+ { name: 'keys', aliases: ['k'], description: 'Add/update API keys' },
45
+ { name: 'config', aliases: [], description: 'Permission profile, workspace' },
46
+ { name: 'schedule', aliases: [], description: 'Manage scheduled agents' },
47
+ { name: 'new', aliases: ['n'], description: 'New session' },
48
+ { name: 'quit', aliases: ['q'], description: 'Exit' },
49
+ { name: 'image', aliases: ['img'], description: 'Quick image generation' },
50
+ { name: 'voice', aliases: ['v'], description: 'Quick text-to-speech' },
51
+ { name: 'clear', aliases: [], description: 'Clear screen' },
52
+ { name: 'verbose', aliases: [], description: 'Toggle debug output' },
53
+ ];
54
+
55
+ // Build lookup maps
56
+ const commandMap = new Map();
57
+ for (const cmd of commands) {
58
+ commandMap.set(cmd.name, cmd);
59
+ for (const alias of cmd.aliases) {
60
+ commandMap.set(alias, cmd);
61
+ }
62
+ }
63
+
64
+ // ── waitForResponse infrastructure ───────────────────────────────────────
65
+
66
+ const pendingResponses = new Map();
67
+
68
+ /**
69
+ * Register a pending response listener for a server message type.
70
+ * Returns a promise that resolves when a matching message arrives.
71
+ */
72
+ export function waitForResponse(type, timeoutMs = 5000) {
73
+ return new Promise((resolve, reject) => {
74
+ const timer = setTimeout(() => {
75
+ pendingResponses.delete(type);
76
+ reject(new Error(`Timeout waiting for ${type}`));
77
+ }, timeoutMs);
78
+
79
+ pendingResponses.set(type, (msg) => {
80
+ clearTimeout(timer);
81
+ pendingResponses.delete(type);
82
+ resolve(msg);
83
+ });
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Check if there's a pending response handler for this message type.
89
+ * If so, call it and return true. Otherwise return false.
90
+ */
91
+ export function checkPendingResponse(msg) {
92
+ const handler = pendingResponses.get(msg.type);
93
+ if (handler) {
94
+ handler(msg);
95
+ return true;
96
+ }
97
+ return false;
98
+ }
99
+
100
+ // ── Helpers ──────────────────────────────────────────────────────────────
101
+
102
+ function readJsonSafe(filePath) {
103
+ try {
104
+ if (fs.existsSync(filePath)) {
105
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
106
+ }
107
+ } catch { /* ignore */ }
108
+ return null;
109
+ }
110
+
111
+ function readEnvKeys() {
112
+ const keys = {
113
+ ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || '',
114
+ OPENAI_API_KEY: process.env.OPENAI_API_KEY || '',
115
+ GOOGLE_API_KEY: process.env.GOOGLE_API_KEY || '',
116
+ ELEVENLABS_API_KEY: process.env.ELEVENLABS_API_KEY || '',
117
+ };
118
+
119
+ // Also check the env files
120
+ const envFiles = [
121
+ ENV_FILE,
122
+ path.join(runtimeDir, '..', '.env'),
123
+ ];
124
+ for (const envFile of envFiles) {
125
+ try {
126
+ if (!fs.existsSync(envFile)) continue;
127
+ const content = fs.readFileSync(envFile, 'utf8');
128
+ for (const line of content.split('\n')) {
129
+ const trimmed = line.trim();
130
+ if (trimmed.startsWith('#') || !trimmed.includes('=')) continue;
131
+ const [key, ...rest] = trimmed.split('=');
132
+ const k = key.trim();
133
+ const v = rest.join('=').trim();
134
+ if (keys.hasOwnProperty(k) && !keys[k] && v) {
135
+ keys[k] = v;
136
+ }
137
+ }
138
+ } catch { /* ignore */ }
139
+ }
140
+ return keys;
141
+ }
142
+
143
+ function askQuestion(rl, question) {
144
+ return new Promise((resolve) => {
145
+ rl.question(question, (answer) => resolve(answer.trim()));
146
+ });
147
+ }
148
+
149
+ /**
150
+ * Read input with secret masking (hides characters with *).
151
+ */
152
+ function askSecret(rl, prompt) {
153
+ return new Promise((resolve) => {
154
+ // Pause readline so we can use raw mode
155
+ rl.pause();
156
+
157
+ process.stdout.write(prompt);
158
+
159
+ let input = '';
160
+ const wasRaw = process.stdin.isRaw;
161
+ if (process.stdin.isTTY) {
162
+ process.stdin.setRawMode(true);
163
+ }
164
+ process.stdin.resume();
165
+
166
+ 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);
173
+ }
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);
184
+ }
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');
195
+ }
196
+ return;
197
+ }
198
+ input += char;
199
+ process.stdout.write('*');
200
+ };
201
+
202
+ process.stdin.on('data', onData);
203
+ });
204
+ }
205
+
206
+ // ── Route ────────────────────────────────────────────────────────────────
207
+
208
+ /**
209
+ * Route a slash command input.
210
+ * Returns true if handled, false if not a slash command.
211
+ *
212
+ * @param {string} input - The full input line (e.g., "/help" or "/plugins install")
213
+ * @param {Object} ctx - Context: { rl, writeMessage, sessionId, spinner, verbose,
214
+ * setVerbose, backend, selectOption, workspacePath }
215
+ */
216
+ export async function routeSlashCommand(input, ctx) {
217
+ // Parse command and args
218
+ const [rawCmd, ...rest] = input.slice(1).split(' ');
219
+ const cmdName = rawCmd.toLowerCase();
220
+ const argString = rest.join(' ').trim();
221
+
222
+ const cmd = commandMap.get(cmdName);
223
+ if (!cmd) {
224
+ console.log(`${DIM}Unknown command /${cmdName}. Type /help for commands.${RESET}`);
225
+ ctx.rl.prompt();
226
+ return true;
227
+ }
228
+
229
+ // Dispatch
230
+ switch (cmd.name) {
231
+ case 'help': cmdHelp(ctx); break;
232
+ case 'status': await cmdStatus(ctx); break;
233
+ case 'plugins': await cmdPlugins(ctx, argString); break;
234
+ case 'models': cmdModels(ctx); break;
235
+ case 'keys': await cmdKeys(ctx); break;
236
+ case 'config': await cmdConfig(ctx); break;
237
+ case 'schedule': await cmdSchedule(ctx); break;
238
+ case 'new': cmdNew(ctx); break;
239
+ case 'quit': cmdQuit(ctx); break;
240
+ case 'image': cmdImage(ctx, argString); return true; // don't re-prompt, spinner is active
241
+ case 'voice': cmdVoice(ctx, argString); return true;
242
+ case 'clear': cmdClear(ctx); break;
243
+ case 'verbose': cmdVerbose(ctx); break;
244
+ default: break;
245
+ }
246
+
247
+ // Commands that don't send queries should re-prompt
248
+ if (cmd.name !== 'image' && cmd.name !== 'voice' && cmd.name !== 'quit') {
249
+ ctx.rl.prompt();
250
+ }
251
+ return true;
252
+ }
253
+
254
+ /**
255
+ * Handle backward-compatible `:command` input.
256
+ * Returns true if it was a colon command (handled or migrated), false otherwise.
257
+ */
258
+ export function handleColonCommand(input, ctx) {
259
+ const [rawCmd] = input.slice(1).split(' ');
260
+ const cmdName = rawCmd.toLowerCase();
261
+
262
+ // Check if this matches a slash command
263
+ if (commandMap.has(cmdName)) {
264
+ console.log(`${ORANGE}Hint:${RESET} ${DIM}Commands now use / prefix. Try ${BOLD}/${cmdName}${RESET}${DIM} instead.${RESET}`);
265
+ // Fall through to route it anyway
266
+ return false; // let the caller re-route via /
267
+ }
268
+ return false;
269
+ }
270
+
271
+ // ── Command Implementations ──────────────────────────────────────────────
272
+
273
+ function cmdHelp() {
274
+ console.log('');
275
+ console.log(sectionHeader('Commands'));
276
+ console.log('');
277
+
278
+ const maxLen = Math.max(...commands.map(c => {
279
+ const aliasStr = c.aliases.length ? `, /${c.aliases.join(', /')}` : '';
280
+ return `/${c.name}${aliasStr}`.length;
281
+ }));
282
+
283
+ for (const cmd of commands) {
284
+ const aliasStr = cmd.aliases.length ? `${DIM}, /${cmd.aliases.join(', /')}${RESET}` : '';
285
+ const nameStr = `${BOLD}/${cmd.name}${RESET}${aliasStr}`;
286
+ const visibleLen = `/${cmd.name}${cmd.aliases.length ? `, /${cmd.aliases.join(', /')}` : ''}`.length;
287
+ const padding = ' '.repeat(Math.max(1, maxLen - visibleLen + 2));
288
+ console.log(` ${nameStr}${padding}${DIM}${cmd.description}${RESET}`);
289
+ }
290
+
291
+ console.log('');
292
+ console.log(` ${DIM}Permissions use arrow-key selection by default.${RESET}`);
293
+ console.log(` ${DIM}Use :allow, :deny, :rule for permission shortcuts.${RESET}`);
294
+ console.log('');
295
+ }
296
+
297
+ async function cmdStatus(ctx) {
298
+ console.log('');
299
+ console.log(sectionHeader('Status'));
300
+ console.log('');
301
+
302
+ // Session info
303
+ console.log(labelValue('Session', ctx.sessionId || `${DIM}(new)${RESET}`));
304
+ console.log(labelValue('Workspace', ctx.workspacePath));
305
+
306
+ // Permission profile
307
+ const permsData = readJsonSafe(PERMISSIONS_FILE);
308
+ const profile = permsData?.activeProfile || 'developer';
309
+ console.log(labelValue('Profile', profile));
310
+
311
+ // Verbose
312
+ console.log(labelValue('Verbose', ctx.verbose ? `${TEAL}on${RESET}` : `${DIM}off${RESET}`));
313
+
314
+ // Capabilities
315
+ const envKeys = readEnvKeys();
316
+ const hasOpenAI = !!envKeys.OPENAI_API_KEY;
317
+ const hasGoogle = !!envKeys.GOOGLE_API_KEY;
318
+ const hasElevenLabs = !!envKeys.ELEVENLABS_API_KEY;
319
+ const hasAnthropic = !!envKeys.ANTHROPIC_API_KEY;
320
+
321
+ console.log('');
322
+ console.log(` ${statusBadge(hasAnthropic || hasOpenAI || hasGoogle, 'Chat')} ${statusBadge(hasOpenAI || hasGoogle, 'Images')} ${statusBadge(hasOpenAI || hasElevenLabs || hasGoogle, 'Voice')} ${statusBadge(hasOpenAI || hasGoogle, 'Video')}`);
323
+
324
+ // Installed plugins
325
+ const pluginsData = readJsonSafe(path.join(CONFIG_DIR, 'plugins.json'));
326
+ const installedCount = pluginsData?.plugins ? Object.keys(pluginsData.plugins).length : 0;
327
+ console.log('');
328
+ console.log(labelValue('Plugins', `${installedCount} installed`));
329
+
330
+ // Scheduled agents — try to get count via server
331
+ try {
332
+ ctx.writeMessage({ type: 'scheduled_agent:list', userId: 'default' });
333
+ const resp = await waitForResponse('scheduled_agent:list', 3000);
334
+ const count = resp.agents?.length || 0;
335
+ console.log(labelValue('Agents', `${count} scheduled`));
336
+ } catch {
337
+ console.log(labelValue('Agents', `${DIM}(unavailable)${RESET}`));
338
+ }
339
+
340
+ console.log('');
341
+ }
342
+
343
+ async function cmdPlugins(ctx, argString) {
344
+ const { PluginManager } = await import(path.join(runtimeDir, 'src', 'plugins', 'PluginManager.js'));
345
+ const pm = new PluginManager();
346
+
347
+ // Sub-action parsing
348
+ const subAction = argString.split(' ')[0]?.toLowerCase();
349
+
350
+ if (subAction === 'install') {
351
+ await pluginInstallFlow(pm, ctx);
352
+ return;
353
+ }
354
+ if (subAction === 'uninstall' || subAction === 'remove') {
355
+ await pluginUninstallFlow(pm, ctx);
356
+ return;
357
+ }
358
+
359
+ // Default: show menu
360
+ console.log('');
361
+ console.log(sectionHeader('Plugins'));
362
+ console.log('');
363
+
364
+ const choice = await ctx.selectOption([
365
+ { label: 'View installed', value: 'view' },
366
+ { label: 'Install a plugin', value: 'install' },
367
+ { label: 'Uninstall a plugin', value: 'uninstall' },
368
+ { label: 'Cancel', value: 'cancel' },
369
+ ], { rl: ctx.rl });
370
+
371
+ if (choice.value === 'cancel') return;
372
+
373
+ if (choice.value === 'view') {
374
+ await pluginView(pm);
375
+ return;
376
+ }
377
+ if (choice.value === 'install') {
378
+ await pluginInstallFlow(pm, ctx);
379
+ return;
380
+ }
381
+ if (choice.value === 'uninstall') {
382
+ await pluginUninstallFlow(pm, ctx);
383
+ return;
384
+ }
385
+ }
386
+
387
+ async function pluginView(pm) {
388
+ const available = pm.listAvailable();
389
+ const grouped = groupBy(available, p => p.category);
390
+
391
+ console.log('');
392
+ for (const [category, plugins] of Object.entries(grouped)) {
393
+ console.log(` ${PURPLE}${BOLD}${category.charAt(0).toUpperCase() + category.slice(1)}${RESET}`);
394
+ for (const p of plugins) {
395
+ const status = p.installed
396
+ ? `${TEAL}\u2713 installed${RESET}`
397
+ : `${DIM}not installed${RESET}`;
398
+ console.log(` ${BOLD}${p.name}${RESET} ${status}`);
399
+ console.log(` ${DIM}${p.description}${RESET}`);
400
+ }
401
+ console.log('');
402
+ }
403
+ }
404
+
405
+ async function pluginInstallFlow(pm, ctx) {
406
+ const available = pm.listAvailable().filter(p => !p.installed);
407
+ if (available.length === 0) {
408
+ console.log(success('All plugins are already installed!'));
409
+ return;
410
+ }
411
+
412
+ console.log('');
413
+ console.log(` ${BOLD}Select a plugin to install:${RESET}`);
414
+ console.log('');
415
+
416
+ const options = available.map(p => ({
417
+ label: `${p.name} — ${p.description}`,
418
+ value: p.id,
419
+ }));
420
+ options.push({ label: 'Cancel', value: 'cancel' });
421
+
422
+ const choice = await ctx.selectOption(options, { rl: ctx.rl });
423
+ if (choice.value === 'cancel') return;
424
+
425
+ const pluginId = choice.value;
426
+ const fields = pm.getCredentialFields(pluginId);
427
+
428
+ // Collect credentials
429
+ const credentials = {};
430
+ if (fields.length > 0) {
431
+ console.log('');
432
+ console.log(` ${DIM}Enter credentials for ${pm.getPluginManifest(pluginId).name}:${RESET}`);
433
+ if (pm.getPluginManifest(pluginId).setup?.note) {
434
+ console.log(` ${DIM}${pm.getPluginManifest(pluginId).setup.note}${RESET}`);
435
+ }
436
+ console.log('');
437
+
438
+ for (const field of fields) {
439
+ const label = ` ${field.label}${field.required ? '' : ` ${DIM}(optional)${RESET}`}: `;
440
+ if (field.instructions) {
441
+ console.log(` ${DIM}${field.instructions}${RESET}`);
442
+ }
443
+ let value;
444
+ if (field.type === 'secret') {
445
+ value = await askSecret(ctx.rl, label);
446
+ } else {
447
+ value = await askQuestion(ctx.rl, label);
448
+ }
449
+ if (value) {
450
+ credentials[field.key] = value;
451
+ } else if (field.required) {
452
+ console.log(errorMsg(`${field.label} is required. Aborting install.`));
453
+ return;
454
+ }
455
+ }
456
+ }
457
+
458
+ try {
459
+ pm.install(pluginId, credentials);
460
+ const name = pm.getPluginManifest(pluginId).name;
461
+ console.log('');
462
+ console.log(success(`\u2713 ${name} installed successfully!`));
463
+ console.log(hint('Start a /new session to activate the plugin.'));
464
+ console.log('');
465
+ } catch (err) {
466
+ console.log(errorMsg(`Install failed: ${err.message}`));
467
+ }
468
+ }
469
+
470
+ async function pluginUninstallFlow(pm, ctx) {
471
+ const installed = pm.listInstalled();
472
+ if (installed.length === 0) {
473
+ console.log(` ${DIM}No plugins installed.${RESET}`);
474
+ return;
475
+ }
476
+
477
+ console.log('');
478
+ console.log(` ${BOLD}Select a plugin to uninstall:${RESET}`);
479
+ console.log('');
480
+
481
+ const options = installed.map(p => ({
482
+ label: `${p.name}`,
483
+ value: p.id,
484
+ }));
485
+ options.push({ label: 'Cancel', value: 'cancel' });
486
+
487
+ const choice = await ctx.selectOption(options, { rl: ctx.rl });
488
+ if (choice.value === 'cancel') return;
489
+
490
+ try {
491
+ pm.uninstall(choice.value);
492
+ console.log('');
493
+ console.log(success(`\u2713 ${choice.label} uninstalled.`));
494
+ console.log(hint('Start a /new session to apply changes.'));
495
+ console.log('');
496
+ } catch (err) {
497
+ console.log(errorMsg(`Uninstall failed: ${err.message}`));
498
+ }
499
+ }
500
+
501
+ function cmdModels() {
502
+ console.log('');
503
+ console.log(sectionHeader('Available Models'));
504
+ console.log('');
505
+
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;
513
+ }
514
+
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
+ };
533
+
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
+ });
548
+ }
549
+ }
550
+ }
551
+
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}`);
560
+ }
561
+ console.log('');
562
+ }
563
+
564
+ console.log(` ${DIM}Model selection is automatic based on task. Use /keys to add provider keys.${RESET}`);
565
+ console.log('');
566
+ }
567
+
568
+ async function cmdKeys(ctx) {
569
+ const envKeys = readEnvKeys();
570
+
571
+ 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 },
576
+ ];
577
+
578
+ console.log('');
579
+ console.log(sectionHeader('API Keys'));
580
+ console.log('');
581
+
582
+ for (const k of keyInfo) {
583
+ const status = k.value
584
+ ? `${TEAL}\u2713 configured${RESET} ${DIM}${maskSecret(k.value)}${RESET}`
585
+ : `${DIM}\u25cb not set${RESET}`;
586
+ console.log(` ${BOLD}${k.label}${RESET} ${status}`);
587
+ console.log(` ${DIM}Unlocks: ${k.unlocks}${RESET}`);
588
+ console.log('');
589
+ }
590
+
591
+ // Offer to add/update
592
+ const options = keyInfo.map(k => ({
593
+ label: `${k.value ? 'Update' : 'Add'} ${k.label} key`,
594
+ value: k.key,
595
+ }));
596
+ options.push({ label: 'Done', value: 'done' });
597
+
598
+ const choice = await ctx.selectOption(options, { rl: ctx.rl });
599
+ if (choice.value === 'done') return;
600
+
601
+ const selected = keyInfo.find(k => k.key === choice.value);
602
+ console.log('');
603
+ console.log(` ${DIM}Enter your ${selected.label} API key:${RESET}`);
604
+ const newValue = await askSecret(ctx.rl, ` ${selected.label} key: `);
605
+
606
+ if (!newValue) {
607
+ console.log(` ${DIM}No value entered, skipping.${RESET}`);
608
+ return;
609
+ }
610
+
611
+ // Write to ~/.friday/.env
612
+ try {
613
+ const dir = path.dirname(ENV_FILE);
614
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
615
+
616
+ let content = '';
617
+ if (fs.existsSync(ENV_FILE)) {
618
+ content = fs.readFileSync(ENV_FILE, 'utf8');
619
+ }
620
+
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
+ }
628
+
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('');
634
+ } catch (err) {
635
+ console.log(errorMsg(`Failed to save key: ${err.message}`));
636
+ }
637
+ }
638
+
639
+ async function cmdConfig(ctx) {
640
+ console.log('');
641
+ console.log(sectionHeader('Configuration'));
642
+ console.log('');
643
+
644
+ // Read current config
645
+ const permsData = readJsonSafe(PERMISSIONS_FILE);
646
+ const profile = permsData?.activeProfile || 'developer';
647
+ const configData = readJsonSafe(CONFIG_FILE) || {};
648
+ const workspace = ctx.workspacePath;
649
+
650
+ console.log(labelValue('Profile', `${BOLD}${profile}${RESET}`));
651
+ console.log(labelValue('Workspace', workspace));
652
+ console.log('');
653
+
654
+ const choice = await ctx.selectOption([
655
+ { label: 'Change permission profile', value: 'profile' },
656
+ { label: 'Change workspace path', value: 'workspace' },
657
+ { label: 'Done', value: 'done' },
658
+ ], { rl: ctx.rl });
659
+
660
+ if (choice.value === 'done') return;
661
+
662
+ if (choice.value === 'profile') {
663
+ console.log('');
664
+ const profileChoice = await ctx.selectOption([
665
+ { label: 'developer — Auto-approves file ops in workspace', value: 'developer' },
666
+ { label: 'safe — Read-only by default, asks before writing', value: 'safe' },
667
+ { label: 'locked — Asks permission for everything', value: 'locked' },
668
+ { label: 'Cancel', value: 'cancel' },
669
+ ], { rl: ctx.rl });
670
+
671
+ if (profileChoice.value !== 'cancel') {
672
+ try {
673
+ const data = readJsonSafe(PERMISSIONS_FILE) || {};
674
+ data.activeProfile = profileChoice.value;
675
+ const dir = path.dirname(PERMISSIONS_FILE);
676
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
677
+ fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(data, null, 2), 'utf8');
678
+ console.log('');
679
+ console.log(success(`\u2713 Profile changed to ${profileChoice.value}`));
680
+ console.log(hint('Start a /new session for changes to take effect.'));
681
+ } catch (err) {
682
+ console.log(errorMsg(`Failed to save profile: ${err.message}`));
683
+ }
684
+ }
685
+ return;
686
+ }
687
+
688
+ if (choice.value === 'workspace') {
689
+ const newPath = await askQuestion(ctx.rl, ` New workspace path: `);
690
+ if (newPath) {
691
+ const resolved = path.resolve(newPath);
692
+ try {
693
+ fs.mkdirSync(resolved, { recursive: true });
694
+ const data = readJsonSafe(CONFIG_FILE) || {};
695
+ data.workspace = resolved;
696
+ const dir = path.dirname(CONFIG_FILE);
697
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
698
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2), 'utf8');
699
+ console.log('');
700
+ console.log(success(`\u2713 Workspace set to ${resolved}`));
701
+ console.log(hint('Start a /new session for changes to take effect.'));
702
+ } catch (err) {
703
+ console.log(errorMsg(`Failed to set workspace: ${err.message}`));
704
+ }
705
+ }
706
+ return;
707
+ }
708
+ }
709
+
710
+ async function cmdSchedule(ctx) {
711
+ console.log('');
712
+ console.log(sectionHeader('Scheduled Agents'));
713
+ console.log('');
714
+
715
+ const choice = await ctx.selectOption([
716
+ { label: 'View scheduled agents', value: 'view' },
717
+ { label: 'Create a new agent', value: 'create' },
718
+ { label: 'Trigger an agent now', value: 'trigger' },
719
+ { label: 'Delete an agent', value: 'delete' },
720
+ { label: 'Cancel', value: 'cancel' },
721
+ ], { rl: ctx.rl });
722
+
723
+ if (choice.value === 'cancel') return;
724
+
725
+ if (choice.value === 'view') {
726
+ try {
727
+ ctx.writeMessage({ type: 'scheduled_agent:list', userId: 'default' });
728
+ const resp = await waitForResponse('scheduled_agent:list', 5000);
729
+ const agents = resp.agents || [];
730
+ if (agents.length === 0) {
731
+ console.log(` ${DIM}No scheduled agents.${RESET}`);
732
+ } else {
733
+ for (const agent of agents) {
734
+ const status = agent.status === 'active'
735
+ ? `${TEAL}active${RESET}`
736
+ : `${DIM}${agent.status}${RESET}`;
737
+ const schedule = agent.schedule?.humanReadable || agent.schedule?.cron || 'unknown';
738
+ console.log(` ${BOLD}${agent.name}${RESET} ${DIM}(${agent.id})${RESET} ${status}`);
739
+ console.log(` ${DIM}${schedule}${RESET}`);
740
+ if (agent.description) console.log(` ${DIM}${agent.description}${RESET}`);
741
+ console.log('');
742
+ }
743
+ }
744
+ } catch {
745
+ console.log(errorMsg('Could not fetch scheduled agents.'));
746
+ }
747
+ return;
748
+ }
749
+
750
+ if (choice.value === 'create') {
751
+ console.log(` ${DIM}Describe what you want in natural language:${RESET}`);
752
+ console.log(` ${DIM}Examples: "check my emails every morning at 9am"${RESET}`);
753
+ console.log('');
754
+ const description = await askQuestion(ctx.rl, ` > `);
755
+ if (!description) return;
756
+
757
+ // Parse schedule using the same pattern as schedule.js
758
+ const scheduleData = parseNaturalSchedule(description);
759
+ if (!scheduleData) {
760
+ console.log(` ${DIM}Couldn't detect a schedule. When should this run?${RESET}`);
761
+ const schedInput = await askQuestion(ctx.rl, ` Schedule: `);
762
+ const parsed = parseNaturalSchedule(schedInput);
763
+ if (!parsed) {
764
+ console.log(errorMsg(`Could not parse schedule: "${schedInput}"`));
765
+ return;
766
+ }
767
+ Object.assign(scheduleData || {}, parsed);
768
+ }
769
+
770
+ if (!scheduleData) return;
771
+
772
+ // Extract name
773
+ const name = description
774
+ .replace(/every\s+(day|morning|evening|hour|monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b.*$/i, '')
775
+ .replace(/\b(at\s+\d{1,2}(:\d{2})?\s*(am|pm)?)\b/gi, '')
776
+ .replace(/\b(daily|hourly|weekdays?)\b/gi, '')
777
+ .replace(/\bevery\s+\d+\s+(minutes?|hours?)\b/gi, '')
778
+ .replace(/\busing\s+\w+\b/gi, '')
779
+ .trim() || description.slice(0, 50);
780
+
781
+ const agentName = name.charAt(0).toUpperCase() + name.slice(1);
782
+
783
+ try {
784
+ ctx.writeMessage({
785
+ type: 'scheduled_agent:create',
786
+ userId: 'default',
787
+ agentData: {
788
+ name: agentName,
789
+ description: description.slice(0, 100),
790
+ instructions: description,
791
+ schedule: {
792
+ cron: scheduleData.cron,
793
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
794
+ humanReadable: scheduleData.humanReadable,
795
+ },
796
+ mcpServers: ['terminal'],
797
+ permissions: { preAuthorized: true, tools: [] },
798
+ },
799
+ });
800
+ const resp = await waitForResponse('scheduled_agent:created', 5000);
801
+ console.log('');
802
+ console.log(success(`\u2713 Agent created: ${resp.agent?.name || agentName}`));
803
+ console.log(` ${DIM}${scheduleData.humanReadable}${RESET}`);
804
+ console.log('');
805
+ } catch {
806
+ console.log(errorMsg('Failed to create scheduled agent.'));
807
+ }
808
+ return;
809
+ }
810
+
811
+ if (choice.value === 'trigger') {
812
+ try {
813
+ ctx.writeMessage({ type: 'scheduled_agent:list', userId: 'default' });
814
+ const resp = await waitForResponse('scheduled_agent:list', 5000);
815
+ const agents = resp.agents || [];
816
+ if (agents.length === 0) {
817
+ console.log(` ${DIM}No agents to trigger.${RESET}`);
818
+ return;
819
+ }
820
+ const options = agents.map(a => ({
821
+ label: `${a.name} (${a.schedule?.humanReadable || a.schedule?.cron || ''})`,
822
+ value: a.id,
823
+ }));
824
+ options.push({ label: 'Cancel', value: 'cancel' });
825
+
826
+ const triggerChoice = await ctx.selectOption(options, { rl: ctx.rl });
827
+ if (triggerChoice.value === 'cancel') return;
828
+
829
+ ctx.writeMessage({ type: 'scheduled_agent:trigger', agentId: triggerChoice.value });
830
+ console.log(success('\u2713 Agent triggered.'));
831
+ } catch {
832
+ console.log(errorMsg('Failed to trigger agent.'));
833
+ }
834
+ return;
835
+ }
836
+
837
+ if (choice.value === 'delete') {
838
+ try {
839
+ ctx.writeMessage({ type: 'scheduled_agent:list', userId: 'default' });
840
+ const resp = await waitForResponse('scheduled_agent:list', 5000);
841
+ const agents = resp.agents || [];
842
+ if (agents.length === 0) {
843
+ console.log(` ${DIM}No agents to delete.${RESET}`);
844
+ return;
845
+ }
846
+ const options = agents.map(a => ({ label: a.name, value: a.id }));
847
+ options.push({ label: 'Cancel', value: 'cancel' });
848
+
849
+ const delChoice = await ctx.selectOption(options, { rl: ctx.rl });
850
+ if (delChoice.value === 'cancel') return;
851
+
852
+ ctx.writeMessage({
853
+ type: 'scheduled_agent:delete',
854
+ userId: 'default',
855
+ agentId: delChoice.value,
856
+ });
857
+ const delResp = await waitForResponse('scheduled_agent:deleted', 5000);
858
+ console.log(success('\u2713 Agent deleted.'));
859
+ } catch {
860
+ console.log(errorMsg('Failed to delete agent.'));
861
+ }
862
+ return;
863
+ }
864
+ }
865
+
866
+ function cmdNew(ctx) {
867
+ ctx.resetSession();
868
+ ctx.writeMessage({ type: 'new_session' });
869
+ console.log(`${DIM}New session started.${RESET}`);
870
+ }
871
+
872
+ function cmdQuit(ctx) {
873
+ ctx.spinner.stop();
874
+ ctx.backend.kill();
875
+ ctx.rl.close();
876
+ }
877
+
878
+ function cmdImage(ctx, argString) {
879
+ if (!argString) {
880
+ console.log(` ${DIM}Usage: /image <prompt>${RESET}`);
881
+ console.log(` ${DIM}Example: /image a sunset over mountains${RESET}`);
882
+ ctx.rl.prompt();
883
+ return;
884
+ }
885
+ ctx.spinner.start('Thinking');
886
+ ctx.writeMessage({
887
+ type: 'query',
888
+ message: `Generate an image: ${argString}`,
889
+ session_id: ctx.sessionId,
890
+ });
891
+ }
892
+
893
+ function cmdVoice(ctx, argString) {
894
+ if (!argString) {
895
+ console.log(` ${DIM}Usage: /voice <text>${RESET}`);
896
+ console.log(` ${DIM}Example: /voice Hello, welcome to Friday${RESET}`);
897
+ ctx.rl.prompt();
898
+ return;
899
+ }
900
+ ctx.spinner.start('Thinking');
901
+ ctx.writeMessage({
902
+ type: 'query',
903
+ message: `Convert this text to speech: ${argString}`,
904
+ session_id: ctx.sessionId,
905
+ });
906
+ }
907
+
908
+ function cmdClear() {
909
+ process.stdout.write('\x1b[2J\x1b[H');
910
+ }
911
+
912
+ function cmdVerbose(ctx) {
913
+ ctx.toggleVerbose();
914
+ }
915
+
916
+ // ── Natural schedule parser (reused from schedule.js) ────────────────────
917
+
918
+ function parseNaturalSchedule(input) {
919
+ const lower = input.toLowerCase().trim();
920
+
921
+ const hoursMatch = lower.match(/every\s+(\d+)\s+hours?/);
922
+ if (hoursMatch) {
923
+ const hours = parseInt(hoursMatch[1]);
924
+ return { cron: `0 */${hours} * * *`, humanReadable: `Every ${hours} hours` };
925
+ }
926
+
927
+ const minutesMatch = lower.match(/every\s+(\d+)\s+minutes?/);
928
+ if (minutesMatch) {
929
+ const mins = parseInt(minutesMatch[1]);
930
+ return { cron: `*/${mins} * * * *`, humanReadable: `Every ${mins} minutes` };
931
+ }
932
+
933
+ const dailyMatch = lower.match(/(?:every\s+day|daily)\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/i);
934
+ if (dailyMatch) {
935
+ let hour = parseInt(dailyMatch[1]);
936
+ const minute = parseInt(dailyMatch[2] || '0');
937
+ const ampm = dailyMatch[3];
938
+ if (ampm === 'pm' && hour < 12) hour += 12;
939
+ if (ampm === 'am' && hour === 12) hour = 0;
940
+ return { cron: `${minute} ${hour} * * *`, humanReadable: `Every day at ${hour}:${minute.toString().padStart(2, '0')}` };
941
+ }
942
+
943
+ if (lower.includes('every morning')) {
944
+ return { cron: '0 9 * * *', humanReadable: 'Every morning at 9:00' };
945
+ }
946
+ if (lower.includes('every evening')) {
947
+ return { cron: '0 18 * * *', humanReadable: 'Every evening at 18:00' };
948
+ }
949
+ if (lower === 'every hour' || lower === 'hourly') {
950
+ return { cron: '0 * * * *', humanReadable: 'Every hour' };
951
+ }
952
+
953
+ const dayMatch = lower.match(/every\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/i);
954
+ if (dayMatch) {
955
+ const days = { sunday: 0, monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6 };
956
+ const day = days[dayMatch[1].toLowerCase()];
957
+ let hour = parseInt(dayMatch[2]);
958
+ const minute = parseInt(dayMatch[3] || '0');
959
+ const ampm = dayMatch[4];
960
+ if (ampm === 'pm' && hour < 12) hour += 12;
961
+ if (ampm === 'am' && hour === 12) hour = 0;
962
+ return { cron: `${minute} ${hour} * * ${day}`, humanReadable: `Every ${dayMatch[1]} at ${hour}:${minute.toString().padStart(2, '0')}` };
963
+ }
964
+
965
+ const weekdayMatch = lower.match(/weekdays?\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/i);
966
+ if (weekdayMatch) {
967
+ let hour = parseInt(weekdayMatch[1]);
968
+ const minute = parseInt(weekdayMatch[2] || '0');
969
+ const ampm = weekdayMatch[3];
970
+ if (ampm === 'pm' && hour < 12) hour += 12;
971
+ if (ampm === 'am' && hour === 12) hour = 0;
972
+ return { cron: `${minute} ${hour} * * 1-5`, humanReadable: `Weekdays at ${hour}:${minute.toString().padStart(2, '0')}` };
973
+ }
974
+
975
+ return null;
976
+ }