@operor/cli 0.1.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.
Files changed (62) hide show
  1. package/README.md +76 -0
  2. package/dist/config-Bn2pbORi.js +34 -0
  3. package/dist/config-Bn2pbORi.js.map +1 -0
  4. package/dist/converse-C_PB7-JH.js +142 -0
  5. package/dist/converse-C_PB7-JH.js.map +1 -0
  6. package/dist/doctor-98gPl743.js +122 -0
  7. package/dist/doctor-98gPl743.js.map +1 -0
  8. package/dist/index.d.ts +1 -0
  9. package/dist/index.js +2268 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/llm-override-BIQl0V6H.js +445 -0
  12. package/dist/llm-override-BIQl0V6H.js.map +1 -0
  13. package/dist/reset-DT8SBgFS.js +87 -0
  14. package/dist/reset-DT8SBgFS.js.map +1 -0
  15. package/dist/simulate-BKv62GJc.js +144 -0
  16. package/dist/simulate-BKv62GJc.js.map +1 -0
  17. package/dist/status-D6LIZvQa.js +82 -0
  18. package/dist/status-D6LIZvQa.js.map +1 -0
  19. package/dist/test-DYjkxbtK.js +177 -0
  20. package/dist/test-DYjkxbtK.js.map +1 -0
  21. package/dist/test-suite-D8H_5uKs.js +209 -0
  22. package/dist/test-suite-D8H_5uKs.js.map +1 -0
  23. package/dist/utils-BuV4q7f6.js +11 -0
  24. package/dist/utils-BuV4q7f6.js.map +1 -0
  25. package/dist/vibe-Bl_js3Jo.js +395 -0
  26. package/dist/vibe-Bl_js3Jo.js.map +1 -0
  27. package/package.json +43 -0
  28. package/src/commands/analytics.ts +408 -0
  29. package/src/commands/chat.ts +310 -0
  30. package/src/commands/config.ts +34 -0
  31. package/src/commands/converse.ts +182 -0
  32. package/src/commands/doctor.ts +154 -0
  33. package/src/commands/history.ts +60 -0
  34. package/src/commands/init.ts +163 -0
  35. package/src/commands/kb.ts +429 -0
  36. package/src/commands/llm-override.ts +480 -0
  37. package/src/commands/reset.ts +72 -0
  38. package/src/commands/simulate.ts +187 -0
  39. package/src/commands/status.ts +112 -0
  40. package/src/commands/test-suite.ts +247 -0
  41. package/src/commands/test.ts +177 -0
  42. package/src/commands/vibe.ts +478 -0
  43. package/src/config.ts +127 -0
  44. package/src/index.ts +190 -0
  45. package/src/log-timestamps.ts +26 -0
  46. package/src/setup.ts +712 -0
  47. package/src/start.ts +573 -0
  48. package/src/utils.ts +6 -0
  49. package/templates/agents/_defaults/SOUL.md +20 -0
  50. package/templates/agents/_defaults/USER.md +16 -0
  51. package/templates/agents/customer-support/IDENTITY.md +6 -0
  52. package/templates/agents/customer-support/INSTRUCTIONS.md +79 -0
  53. package/templates/agents/customer-support/SOUL.md +26 -0
  54. package/templates/agents/faq-bot/IDENTITY.md +6 -0
  55. package/templates/agents/faq-bot/INSTRUCTIONS.md +53 -0
  56. package/templates/agents/faq-bot/SOUL.md +19 -0
  57. package/templates/agents/sales/IDENTITY.md +6 -0
  58. package/templates/agents/sales/INSTRUCTIONS.md +67 -0
  59. package/templates/agents/sales/SOUL.md +20 -0
  60. package/tsconfig.json +9 -0
  61. package/tsdown.config.ts +13 -0
  62. package/vitest.config.ts +8 -0
package/src/setup.ts ADDED
@@ -0,0 +1,712 @@
1
+ import * as clack from '@clack/prompts';
2
+ import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs';
3
+ import { resolve as resolvePath, join } from 'node:path';
4
+ import matter from 'gray-matter';
5
+ import { readConfig, writeConfig, type AppConfig } from './config.js';
6
+ import { AIProvider } from '@operor/llm';
7
+ import { loadSkillCatalog, findSkillInCatalog, catalogEntryToConfig } from '@operor/skills';
8
+
9
+ function detectApiKey(provider: string): string | undefined {
10
+ const envVars: Record<string, string[]> = {
11
+ openai: ['OPENAI_API_KEY'],
12
+ anthropic: ['ANTHROPIC_API_KEY'],
13
+ google: ['GOOGLE_API_KEY', 'GOOGLE_GENERATIVE_AI_API_KEY'],
14
+ groq: ['GROQ_API_KEY'],
15
+ };
16
+
17
+ const vars = envVars[provider] || [];
18
+ for (const varName of vars) {
19
+ const value = process.env[varName];
20
+ if (value) return value;
21
+ }
22
+ return undefined;
23
+ }
24
+
25
+ function maskApiKey(key: string): string {
26
+ if (key.length <= 8) return '••••••••';
27
+ return key.slice(0, 4) + '••••' + key.slice(-4);
28
+ }
29
+
30
+ export async function runQuickSetup(): Promise<void> {
31
+ console.clear();
32
+ clack.intro('Operor - Quick Setup');
33
+
34
+ // 1. LLM Provider
35
+ const llmProvider = await clack.select({
36
+ message: 'Choose your LLM provider',
37
+ options: [
38
+ { value: 'openai', label: 'OpenAI' },
39
+ { value: 'anthropic', label: 'Anthropic (Claude)' },
40
+ { value: 'google', label: 'Google (Gemini)' },
41
+ { value: 'groq', label: 'Groq' },
42
+ { value: 'ollama', label: 'Ollama (local)' },
43
+ ],
44
+ });
45
+
46
+ if (clack.isCancel(llmProvider)) {
47
+ clack.cancel('Setup cancelled');
48
+ process.exit(0);
49
+ }
50
+
51
+ // 2. API Key (skip for ollama)
52
+ let llmApiKey: string | symbol = '';
53
+ if (llmProvider !== 'ollama') {
54
+ const detectedKey = detectApiKey(llmProvider as string);
55
+ if (detectedKey) {
56
+ const useExisting = await clack.confirm({
57
+ message: `Found existing API key (${maskApiKey(detectedKey)}). Use it?`,
58
+ initialValue: true,
59
+ });
60
+ if (clack.isCancel(useExisting)) {
61
+ clack.cancel('Setup cancelled');
62
+ process.exit(0);
63
+ }
64
+ llmApiKey = useExisting ? detectedKey : await clack.password({
65
+ message: 'Enter your API key',
66
+ validate: (value) => (!value ? 'API key is required' : undefined),
67
+ });
68
+ } else {
69
+ llmApiKey = await clack.password({
70
+ message: 'Enter your API key',
71
+ validate: (value) => (!value ? 'API key is required' : undefined),
72
+ });
73
+ }
74
+
75
+ if (clack.isCancel(llmApiKey)) {
76
+ clack.cancel('Setup cancelled');
77
+ process.exit(0);
78
+ }
79
+ }
80
+
81
+ // 3. Validate LLM credentials
82
+ const spinner = clack.spinner();
83
+ spinner.start('Validating LLM credentials');
84
+ try {
85
+ const provider = new AIProvider({
86
+ provider: llmProvider as any,
87
+ apiKey: llmApiKey as string,
88
+ });
89
+ await provider.complete([{ role: 'user', content: 'test' }]);
90
+ spinner.stop('LLM credentials validated');
91
+ } catch (error: any) {
92
+ spinner.stop('LLM validation failed');
93
+ clack.log.error(`Failed to validate LLM credentials: ${error.message}`);
94
+ process.exit(1);
95
+ }
96
+
97
+ // 4. Auto-set sensible defaults
98
+ const config: AppConfig = {
99
+ LLM_PROVIDER: llmProvider as string,
100
+ LLM_API_KEY: llmApiKey as string,
101
+ CHANNEL: 'mock',
102
+ MEMORY_TYPE: 'sqlite',
103
+ INTENT_CLASSIFIER: 'llm',
104
+ KB_ENABLED: 'true',
105
+ KB_EMBEDDING_PROVIDER: llmProvider as string,
106
+ KB_EMBEDDING_API_KEY: llmApiKey as string,
107
+ TRAINING_MODE_ENABLED: 'false',
108
+ ANALYTICS_ENABLED: 'true',
109
+ };
110
+
111
+ // Merge with any existing config to preserve extra keys
112
+ const existingConfig = readConfig();
113
+ const finalConfig = { ...existingConfig, ...config };
114
+ writeConfig(finalConfig);
115
+
116
+ // 5. Summary
117
+ clack.log.success('Configuration saved to .env');
118
+ clack.log.info(` LLM Provider: ${llmProvider}`);
119
+ clack.log.info(` Channel: mock (testing)`);
120
+ clack.log.info(` Memory: sqlite`);
121
+ clack.log.info(` Intent: llm`);
122
+ clack.log.info(` Knowledge Base: enabled`);
123
+ clack.log.info(` Analytics: enabled`);
124
+ clack.log.info(` Training Mode: disabled`);
125
+ clack.outro('Quick setup complete. Starting Operor...');
126
+ }
127
+
128
+ export async function runSetup(): Promise<void> {
129
+ console.clear();
130
+ clack.intro('Operor - Setup');
131
+
132
+ // Load existing config to use as defaults
133
+ const existingConfig = readConfig();
134
+
135
+ // LLM Provider
136
+ const llmProvider = await clack.select({
137
+ message: 'Choose your LLM provider',
138
+ options: [
139
+ { value: 'openai', label: 'OpenAI' },
140
+ { value: 'anthropic', label: 'Anthropic (Claude)' },
141
+ { value: 'google', label: 'Google (Gemini)' },
142
+ { value: 'groq', label: 'Groq' },
143
+ { value: 'ollama', label: 'Ollama (local)' },
144
+ ],
145
+ initialValue: existingConfig.LLM_PROVIDER || undefined,
146
+ });
147
+
148
+ if (clack.isCancel(llmProvider)) {
149
+ clack.cancel('Setup cancelled');
150
+ process.exit(0);
151
+ }
152
+
153
+ // Detect existing API key from .env or environment variables
154
+ const detectedKey = detectApiKey(llmProvider as string);
155
+ const existingApiKey = existingConfig.LLM_API_KEY || detectedKey;
156
+ let llmApiKey: string | symbol;
157
+
158
+ if (existingApiKey && llmProvider !== 'ollama') {
159
+ const useExisting = await clack.confirm({
160
+ message: `Found existing API key (${maskApiKey(existingApiKey)}). Use it?`,
161
+ initialValue: true,
162
+ });
163
+
164
+ if (clack.isCancel(useExisting)) {
165
+ clack.cancel('Setup cancelled');
166
+ process.exit(0);
167
+ }
168
+
169
+ if (useExisting) {
170
+ llmApiKey = existingApiKey;
171
+ } else {
172
+ llmApiKey = await clack.password({
173
+ message: 'Enter your API key',
174
+ validate: (value) => (!value ? 'API key is required' : undefined),
175
+ });
176
+ }
177
+ } else {
178
+ llmApiKey = await clack.password({
179
+ message: 'Enter your API key',
180
+ validate: (value) => {
181
+ if (llmProvider === 'ollama') return; // Ollama doesn't need API key
182
+ if (!value) return 'API key is required';
183
+ },
184
+ });
185
+ }
186
+
187
+ if (clack.isCancel(llmApiKey)) {
188
+ clack.cancel('Setup cancelled');
189
+ process.exit(0);
190
+ }
191
+
192
+ const llmModel = await clack.text({
193
+ message: 'Enter model name (optional, press Enter for default)',
194
+ placeholder: getDefaultModel(llmProvider as string),
195
+ initialValue: existingConfig.LLM_MODEL || undefined,
196
+ });
197
+
198
+ if (clack.isCancel(llmModel)) {
199
+ clack.cancel('Setup cancelled');
200
+ process.exit(0);
201
+ }
202
+
203
+ // Validate LLM credentials
204
+ const spinner = clack.spinner();
205
+ spinner.start('Validating LLM credentials');
206
+ try {
207
+ const provider = new AIProvider({
208
+ provider: llmProvider as any,
209
+ apiKey: llmApiKey as string,
210
+ model: (llmModel as string) || undefined,
211
+ });
212
+ await provider.complete([{ role: 'user', content: 'test' }]);
213
+ spinner.stop('LLM credentials validated');
214
+ } catch (error: any) {
215
+ spinner.stop('LLM validation failed');
216
+ clack.log.error(`Failed to validate LLM credentials: ${error.message}`);
217
+ process.exit(1);
218
+ }
219
+
220
+ // Channel
221
+ const channel = await clack.select({
222
+ message: 'Choose your messaging channel',
223
+ options: [
224
+ { value: 'whatsapp', label: 'WhatsApp (Baileys)' },
225
+ { value: 'wati', label: 'WhatsApp via WATI (Business API)' },
226
+ { value: 'telegram', label: 'Telegram (grammY)' },
227
+ { value: 'mock', label: 'Mock (for testing)' },
228
+ ],
229
+ initialValue: existingConfig.CHANNEL || undefined,
230
+ });
231
+
232
+ if (clack.isCancel(channel)) {
233
+ clack.cancel('Setup cancelled');
234
+ process.exit(0);
235
+ }
236
+
237
+ // Channel-specific credentials (collected separately, merged into config later)
238
+ const channelConfig: AppConfig = {};
239
+
240
+ if (channel === 'telegram') {
241
+ const botToken = await clack.password({
242
+ message: 'Telegram Bot Token (from @BotFather)',
243
+ validate: (value) => (!value ? 'Bot token is required' : undefined),
244
+ });
245
+ if (clack.isCancel(botToken)) {
246
+ clack.cancel('Setup cancelled');
247
+ process.exit(0);
248
+ }
249
+ channelConfig.TELEGRAM_BOT_TOKEN = botToken as string;
250
+ }
251
+
252
+ if (channel === 'wati') {
253
+ const apiToken = await clack.password({
254
+ message: 'WATI API Token (from your WATI dashboard)',
255
+ validate: (value) => (!value ? 'API token is required' : undefined),
256
+ });
257
+ if (clack.isCancel(apiToken)) {
258
+ clack.cancel('Setup cancelled');
259
+ process.exit(0);
260
+ }
261
+
262
+ const tenantId = await clack.text({
263
+ message: 'WATI Tenant ID',
264
+ validate: (value) => (!value ? 'Tenant ID is required' : undefined),
265
+ initialValue: existingConfig.WATI_TENANT_ID || undefined,
266
+ });
267
+ if (clack.isCancel(tenantId)) {
268
+ clack.cancel('Setup cancelled');
269
+ process.exit(0);
270
+ }
271
+
272
+ channelConfig.WATI_API_TOKEN = apiToken as string;
273
+ channelConfig.WATI_TENANT_ID = tenantId as string;
274
+ }
275
+
276
+ // MCP Skills
277
+ const enableSkills = await clack.confirm({
278
+ message: 'Configure MCP skills? (Shopify, Stripe, search, etc. via mcp.json)',
279
+ initialValue: existingConfig.SKILLS_ENABLED === 'true',
280
+ });
281
+
282
+ if (clack.isCancel(enableSkills)) {
283
+ clack.cancel('Setup cancelled');
284
+ process.exit(0);
285
+ }
286
+
287
+ let selectedSkills: string[] = [];
288
+ if (enableSkills) {
289
+ const catalog = loadSkillCatalog();
290
+ const skillOptions = catalog.skills.map((s) => ({
291
+ value: s.name,
292
+ label: `${s.displayName} — ${s.description}`,
293
+ }));
294
+
295
+ const skills = await clack.multiselect({
296
+ message: 'Select MCP skills to enable (space to toggle)',
297
+ options: skillOptions,
298
+ required: false,
299
+ });
300
+ if (clack.isCancel(skills)) {
301
+ clack.cancel('Setup cancelled');
302
+ process.exit(0);
303
+ }
304
+ selectedSkills = skills as string[];
305
+ }
306
+
307
+ // Memory backend
308
+ const memoryType = await clack.select({
309
+ message: 'Choose storage backend',
310
+ options: [
311
+ { value: 'sqlite', label: 'SQLite (recommended, zero config)' },
312
+ { value: 'memory', label: 'In-memory (data lost on restart)' },
313
+ ],
314
+ initialValue: existingConfig.MEMORY_TYPE || undefined,
315
+ });
316
+
317
+ if (clack.isCancel(memoryType)) {
318
+ clack.cancel('Setup cancelled');
319
+ process.exit(0);
320
+ }
321
+
322
+ // Intent classification
323
+ const intentClassifier = await clack.select({
324
+ message: 'Choose intent classification method',
325
+ options: [
326
+ { value: 'llm', label: 'LLM-based (uses your LLM provider)' },
327
+ { value: 'keyword', label: 'Keyword matching (no LLM cost)' },
328
+ ],
329
+ initialValue: existingConfig.INTENT_CLASSIFIER || undefined,
330
+ });
331
+
332
+ if (clack.isCancel(intentClassifier)) {
333
+ clack.cancel('Setup cancelled');
334
+ process.exit(0);
335
+ }
336
+
337
+ // Knowledge Base
338
+ const kbEnabled = await clack.confirm({
339
+ message: 'Enable Knowledge Base (RAG)?',
340
+ initialValue: existingConfig.KB_ENABLED === 'true',
341
+ });
342
+
343
+ if (clack.isCancel(kbEnabled)) {
344
+ clack.cancel('Setup cancelled');
345
+ process.exit(0);
346
+ }
347
+
348
+ let kbEmbeddingProvider: string | undefined;
349
+ let kbEmbeddingApiKey: string | undefined;
350
+ let kbEmbeddingModel: string | undefined;
351
+ let kbDbPath: string | undefined;
352
+
353
+ if (kbEnabled) {
354
+ const embProvider = await clack.select({
355
+ message: 'Embedding provider',
356
+ options: [
357
+ { value: 'openai', label: 'OpenAI (text-embedding-3-small, 1536d)' },
358
+ { value: 'google', label: 'Google (text-embedding-004, 768d)' },
359
+ { value: 'mistral', label: 'Mistral (mistral-embed, 1024d)' },
360
+ { value: 'cohere', label: 'Cohere (embed-english-v3.0, 1024d)' },
361
+ { value: 'ollama', label: 'Ollama (local, nomic-embed-text, 768d)' },
362
+ ],
363
+ initialValue: existingConfig.KB_EMBEDDING_PROVIDER || undefined,
364
+ });
365
+ if (clack.isCancel(embProvider)) {
366
+ clack.cancel('Setup cancelled');
367
+ process.exit(0);
368
+ }
369
+ kbEmbeddingProvider = embProvider as string;
370
+
371
+ if (kbEmbeddingProvider !== 'ollama') {
372
+ const embKey = await clack.password({
373
+ message: 'Embedding API key (or Enter to reuse LLM key)',
374
+ });
375
+ if (clack.isCancel(embKey)) {
376
+ clack.cancel('Setup cancelled');
377
+ process.exit(0);
378
+ }
379
+ kbEmbeddingApiKey = (embKey as string) || (llmApiKey as string);
380
+ }
381
+
382
+ const embModel = await clack.text({
383
+ message: 'Embedding model (optional, press Enter for default)',
384
+ placeholder: getDefaultEmbeddingModel(kbEmbeddingProvider),
385
+ initialValue: existingConfig.KB_EMBEDDING_MODEL || undefined,
386
+ });
387
+ if (clack.isCancel(embModel)) {
388
+ clack.cancel('Setup cancelled');
389
+ process.exit(0);
390
+ }
391
+ kbEmbeddingModel = (embModel as string) || undefined;
392
+
393
+ const dbPath = await clack.text({
394
+ message: 'Knowledge base DB path',
395
+ placeholder: './knowledge.db',
396
+ initialValue: existingConfig.KB_DB_PATH || undefined,
397
+ });
398
+ if (clack.isCancel(dbPath)) {
399
+ clack.cancel('Setup cancelled');
400
+ process.exit(0);
401
+ }
402
+ kbDbPath = (dbPath as string) || './knowledge.db';
403
+ }
404
+
405
+ // Training Mode
406
+ const trainingEnabled = await clack.confirm({
407
+ message: 'Enable training mode? (allows /teach command to add FAQ answers)',
408
+ initialValue: existingConfig.TRAINING_MODE_ENABLED === 'true',
409
+ });
410
+
411
+ if (clack.isCancel(trainingEnabled)) {
412
+ clack.cancel('Setup cancelled');
413
+ process.exit(0);
414
+ }
415
+
416
+ let trainingWhitelist = '';
417
+ if (trainingEnabled) {
418
+ const whitelistInput = await clack.text({
419
+ message: 'Whitelist phone numbers for training (comma-separated, e.g. 85253332683,85291234567)',
420
+ placeholder: '85253332683,85291234567',
421
+ initialValue: existingConfig.TRAINING_MODE_WHITELIST || undefined,
422
+ });
423
+ if (clack.isCancel(whitelistInput)) {
424
+ clack.cancel('Setup cancelled');
425
+ process.exit(0);
426
+ }
427
+ trainingWhitelist = (whitelistInput as string) || '';
428
+ }
429
+
430
+ // Training Copilot (requires KB to be enabled)
431
+ let copilotConfig: Partial<AppConfig> = {};
432
+ if (kbEnabled) {
433
+ const copilotEnabled = await clack.confirm({
434
+ message: 'Enable Training Copilot? (auto-tracks unanswered queries, suggests FAQ additions)',
435
+ initialValue: existingConfig.COPILOT_ENABLED !== 'false',
436
+ });
437
+
438
+ if (clack.isCancel(copilotEnabled)) {
439
+ clack.cancel('Setup cancelled');
440
+ process.exit(0);
441
+ }
442
+
443
+ if (copilotEnabled) {
444
+ copilotConfig.COPILOT_ENABLED = 'true';
445
+
446
+ const copilotDbPath = await clack.text({
447
+ message: 'Copilot database path',
448
+ placeholder: './copilot.db',
449
+ initialValue: existingConfig.COPILOT_DB_PATH || undefined,
450
+ });
451
+ if (clack.isCancel(copilotDbPath)) {
452
+ clack.cancel('Setup cancelled');
453
+ process.exit(0);
454
+ }
455
+ copilotConfig.COPILOT_DB_PATH = (copilotDbPath as string) || './copilot.db';
456
+
457
+ const trackingThreshold = await clack.text({
458
+ message: 'Tracking threshold (KB score below which queries are tracked)',
459
+ placeholder: '0.70',
460
+ initialValue: existingConfig.COPILOT_TRACKING_THRESHOLD || undefined,
461
+ });
462
+ if (clack.isCancel(trackingThreshold)) {
463
+ clack.cancel('Setup cancelled');
464
+ process.exit(0);
465
+ }
466
+ if (trackingThreshold) copilotConfig.COPILOT_TRACKING_THRESHOLD = trackingThreshold as string;
467
+
468
+ const autoSuggest = await clack.confirm({
469
+ message: 'Auto-generate LLM answer suggestions for unanswered queries?',
470
+ initialValue: existingConfig.COPILOT_AUTO_SUGGEST !== 'false',
471
+ });
472
+ if (clack.isCancel(autoSuggest)) {
473
+ clack.cancel('Setup cancelled');
474
+ process.exit(0);
475
+ }
476
+ copilotConfig.COPILOT_AUTO_SUGGEST = autoSuggest ? 'true' : 'false';
477
+ } else {
478
+ copilotConfig.COPILOT_ENABLED = 'false';
479
+ }
480
+ }
481
+
482
+ // Analytics & Reporting (after Copilot)
483
+ let analyticsConfig: Partial<AppConfig> = {};
484
+ const analyticsEnabled = await clack.confirm({
485
+ message: 'Enable Analytics & Reporting? (tracks response times, token usage, conversation metrics)',
486
+ initialValue: existingConfig.ANALYTICS_ENABLED !== 'false',
487
+ });
488
+
489
+ if (clack.isCancel(analyticsEnabled)) {
490
+ clack.cancel('Setup cancelled');
491
+ process.exit(0);
492
+ }
493
+
494
+ if (analyticsEnabled) {
495
+ analyticsConfig.ANALYTICS_ENABLED = 'true';
496
+
497
+ const analyticsDbPath = await clack.text({
498
+ message: 'Analytics database path',
499
+ placeholder: './analytics.db',
500
+ initialValue: existingConfig.ANALYTICS_DB_PATH || undefined,
501
+ });
502
+ if (clack.isCancel(analyticsDbPath)) {
503
+ clack.cancel('Setup cancelled');
504
+ process.exit(0);
505
+ }
506
+ analyticsConfig.ANALYTICS_DB_PATH = (analyticsDbPath as string) || './analytics.db';
507
+
508
+ const digestEnabled = await clack.select({
509
+ message: 'Enable daily digest reports via WhatsApp?',
510
+ options: [
511
+ { value: 'daily', label: 'Yes, daily digest' },
512
+ { value: 'weekly', label: 'Weekly only' },
513
+ { value: 'both', label: 'Both daily and weekly' },
514
+ { value: 'false', label: 'No digest reports' },
515
+ ],
516
+ initialValue: existingConfig.ANALYTICS_DIGEST_ENABLED === 'true'
517
+ ? (existingConfig.ANALYTICS_DIGEST_SCHEDULE || 'daily')
518
+ : 'false',
519
+ });
520
+ if (clack.isCancel(digestEnabled)) {
521
+ clack.cancel('Setup cancelled');
522
+ process.exit(0);
523
+ }
524
+
525
+ if (digestEnabled !== 'false') {
526
+ analyticsConfig.ANALYTICS_DIGEST_ENABLED = 'true';
527
+ analyticsConfig.ANALYTICS_DIGEST_SCHEDULE = digestEnabled as string;
528
+
529
+ const digestTime = await clack.text({
530
+ message: 'Digest delivery time (HH:MM, 24h format)',
531
+ placeholder: '09:00',
532
+ initialValue: existingConfig.ANALYTICS_DIGEST_TIME || undefined,
533
+ validate: (value) => {
534
+ if (!value) return undefined; // accept default
535
+ if (!/^\d{2}:\d{2}$/.test(value)) return 'Use HH:MM format (e.g. 09:00)';
536
+ },
537
+ });
538
+ if (clack.isCancel(digestTime)) {
539
+ clack.cancel('Setup cancelled');
540
+ process.exit(0);
541
+ }
542
+ analyticsConfig.ANALYTICS_DIGEST_TIME = (digestTime as string) || '09:00';
543
+ } else {
544
+ analyticsConfig.ANALYTICS_DIGEST_ENABLED = 'false';
545
+ }
546
+ } else {
547
+ analyticsConfig.ANALYTICS_ENABLED = 'false';
548
+ }
549
+
550
+ // Build new config from wizard answers
551
+ const newConfig: AppConfig = {
552
+ LLM_PROVIDER: llmProvider as string,
553
+ LLM_API_KEY: llmApiKey as string,
554
+ LLM_MODEL: (llmModel as string) || undefined,
555
+ CHANNEL: channel as string,
556
+ SKILLS_ENABLED: enableSkills ? 'true' : 'false',
557
+ MEMORY_TYPE: memoryType as string,
558
+ INTENT_CLASSIFIER: intentClassifier as string,
559
+ ...channelConfig,
560
+ ...(kbEnabled && {
561
+ KB_ENABLED: 'true',
562
+ KB_DB_PATH: kbDbPath,
563
+ KB_EMBEDDING_PROVIDER: kbEmbeddingProvider,
564
+ KB_EMBEDDING_MODEL: kbEmbeddingModel,
565
+ KB_EMBEDDING_API_KEY: kbEmbeddingApiKey,
566
+ }),
567
+ TRAINING_MODE_ENABLED: trainingEnabled ? 'true' : 'false',
568
+ ...(trainingWhitelist && { TRAINING_MODE_WHITELIST: trainingWhitelist }),
569
+ ...copilotConfig,
570
+ ...analyticsConfig,
571
+ };
572
+
573
+ // Collect MCP skill-specific API keys and create mcp.json
574
+ if (enableSkills && selectedSkills.length > 0) {
575
+ const catalog = loadSkillCatalog();
576
+ const mcpSkills: any[] = [];
577
+
578
+ // Read existing mcp.json to determine which skills were removed
579
+ const mcpPath = resolvePath(process.cwd(), 'mcp.json');
580
+ let previousSkillNames: string[] = [];
581
+ try {
582
+ const existing = JSON.parse(readFileSync(mcpPath, 'utf-8'));
583
+ previousSkillNames = (existing.skills || []).map((s: any) => s.name);
584
+ } catch {
585
+ // No existing mcp.json — nothing was previously enabled
586
+ }
587
+
588
+ for (const skillName of selectedSkills) {
589
+ const entry = findSkillInCatalog(catalog, skillName);
590
+ if (!entry) continue;
591
+
592
+ // Prompt for each required env var
593
+ for (const [varName, spec] of Object.entries(entry.envVars)) {
594
+ if (!spec.required) continue;
595
+ const value = await clack.password({
596
+ message: `${entry.displayName}: ${spec.description}`,
597
+ validate: (v) => (!v ? `${varName} is required` : undefined),
598
+ });
599
+ if (clack.isCancel(value)) {
600
+ clack.cancel('Setup cancelled');
601
+ process.exit(0);
602
+ }
603
+ newConfig[varName] = value as string;
604
+ }
605
+
606
+ mcpSkills.push(catalogEntryToConfig(entry));
607
+ }
608
+
609
+ // Write mcp.json
610
+ const mcpConfig = { skills: mcpSkills };
611
+ writeFileSync(mcpPath, JSON.stringify(mcpConfig, null, 2) + '\n');
612
+ clack.log.info(' MCP config saved to mcp.json');
613
+
614
+ // Sync skills to agent INSTRUCTIONS.md files
615
+ const addedSkills = selectedSkills.filter((s) => !previousSkillNames.includes(s));
616
+ const removedSkills = previousSkillNames.filter((s) => !selectedSkills.includes(s));
617
+ const agentsDir = resolvePath(process.cwd(), 'agents');
618
+ const updatedAgents = syncSkillsToAgents(agentsDir, addedSkills, removedSkills);
619
+ if (updatedAgents.length > 0) {
620
+ clack.log.info(` Updated skills in: ${updatedAgents.join(', ')}`);
621
+ }
622
+ }
623
+
624
+ // Merge: existing config as base, wizard values on top.
625
+ // Preserves any keys the wizard doesn't prompt for (e.g. LLM_BASE_URL, custom keys).
626
+ const finalConfig = { ...existingConfig, ...newConfig };
627
+ writeConfig(finalConfig);
628
+ clack.outro('Config saved to .env. Starting Operor...');
629
+ }
630
+
631
+ /**
632
+ * Sync skill names into agent INSTRUCTIONS.md frontmatter.
633
+ * Only touches agents that already have a `skills:` field.
634
+ * If the resulting skills array is empty, removes the `skills` key entirely.
635
+ */
636
+ export function syncSkillsToAgents(
637
+ agentsDir: string,
638
+ addedSkills: string[],
639
+ removedSkills: string[],
640
+ ): string[] {
641
+ if (addedSkills.length === 0 && removedSkills.length === 0) return [];
642
+
643
+ const updated: string[] = [];
644
+
645
+ let entries: string[];
646
+ try {
647
+ entries = readdirSync(agentsDir, { withFileTypes: true })
648
+ .filter((d) => d.isDirectory() && !d.name.startsWith('_'))
649
+ .map((d) => d.name);
650
+ } catch {
651
+ return [];
652
+ }
653
+
654
+ for (const agentName of entries) {
655
+ const instrPath = join(agentsDir, agentName, 'INSTRUCTIONS.md');
656
+ if (!existsSync(instrPath)) continue;
657
+
658
+ const raw = readFileSync(instrPath, 'utf-8');
659
+ const parsed = matter(raw);
660
+
661
+ // Only touch agents that explicitly declare a skills array
662
+ if (!Array.isArray(parsed.data.skills)) continue;
663
+
664
+ const currentSkills: string[] = parsed.data.skills;
665
+ let newSkills = currentSkills.filter((s: string) => !removedSkills.includes(s));
666
+ for (const skill of addedSkills) {
667
+ if (!newSkills.includes(skill)) newSkills.push(skill);
668
+ }
669
+
670
+ // Check if anything changed
671
+ if (
672
+ newSkills.length === currentSkills.length &&
673
+ newSkills.every((s, i) => s === currentSkills[i])
674
+ ) {
675
+ continue;
676
+ }
677
+
678
+ // If empty after removals, drop the skills key so agent gets all tools
679
+ if (newSkills.length === 0) {
680
+ delete parsed.data.skills;
681
+ } else {
682
+ parsed.data.skills = newSkills;
683
+ }
684
+
685
+ writeFileSync(instrPath, matter.stringify(parsed.content, parsed.data));
686
+ updated.push(agentName);
687
+ }
688
+
689
+ return updated;
690
+ }
691
+
692
+ function getDefaultModel(provider: string): string {
693
+ const defaults: Record<string, string> = {
694
+ openai: 'gpt-5-mini',
695
+ anthropic: 'claude-3-5-sonnet-20241022',
696
+ google: 'gemini-2.0-flash-exp',
697
+ groq: 'llama-3.3-70b-versatile',
698
+ ollama: 'llama3.2',
699
+ };
700
+ return defaults[provider] || '';
701
+ }
702
+
703
+ function getDefaultEmbeddingModel(provider: string): string {
704
+ const defaults: Record<string, string> = {
705
+ openai: 'text-embedding-3-small',
706
+ google: 'text-embedding-004',
707
+ mistral: 'mistral-embed',
708
+ cohere: 'embed-english-v3.0',
709
+ ollama: 'nomic-embed-text',
710
+ };
711
+ return defaults[provider] || '';
712
+ }