@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
@@ -0,0 +1,310 @@
1
+ import fs from 'fs';
2
+ import { Command } from 'commander';
3
+ import * as clack from '@clack/prompts';
4
+ import * as readline from 'node:readline';
5
+ import type { Skill, Tool } from '@operor/core';
6
+ import { readConfig, configExists } from '../config.js';
7
+ import { applyLLMOverride } from './llm-override.js';
8
+
9
+ interface ChatOptions {
10
+ message?: string;
11
+ debug?: boolean;
12
+ kb?: boolean;
13
+ tools?: boolean;
14
+ }
15
+
16
+ async function buildPipeline(opts: ChatOptions) {
17
+ const config = readConfig();
18
+
19
+ if (!config.LLM_PROVIDER || !config.LLM_API_KEY) {
20
+ clack.log.error('LLM_PROVIDER and LLM_API_KEY required. Run "operor setup" first.');
21
+ process.exit(1);
22
+ }
23
+
24
+ const { Operor, LLMIntentClassifier, AgentLoader } = await import('@operor/core');
25
+ const { AIProvider } = await import('@operor/llm');
26
+ const { MockProvider } = await import('@operor/provider-mock');
27
+
28
+ const llm = new AIProvider({
29
+ provider: config.LLM_PROVIDER as 'openai' | 'anthropic' | 'google' | 'groq' | 'ollama',
30
+ apiKey: config.LLM_API_KEY,
31
+ model: config.LLM_MODEL,
32
+ });
33
+
34
+ let intentClassifier: any;
35
+ if (config.INTENT_CLASSIFIER === 'llm') {
36
+ intentClassifier = new LLMIntentClassifier(llm);
37
+ }
38
+
39
+ let skillsModule: any;
40
+ try { skillsModule = await import('@operor/skills'); } catch {}
41
+
42
+ const os = new Operor({
43
+ debug: !!opts.debug,
44
+ ...(intentClassifier && { intentClassifier }),
45
+ ...(skillsModule && { skillsModule }),
46
+ });
47
+
48
+ const provider = new MockProvider();
49
+ os.addProvider(provider);
50
+
51
+ // Load MCP skills + tools
52
+ const skillInstances: Skill[] = [];
53
+
54
+ if (config.SKILLS_ENABLED !== 'false') {
55
+ try {
56
+ const { SkillManager, loadSkillsConfig } = await import('@operor/skills');
57
+ const skillsConfig = loadSkillsConfig();
58
+ const skillManager = new SkillManager();
59
+ const skills = await skillManager.initialize(skillsConfig);
60
+ for (const skill of skills) {
61
+ os.addSkill(skill);
62
+ skillInstances.push(skill);
63
+ if (opts.debug) clack.log.info(`[debug] MCP skill loaded: ${skill.name}`);
64
+ }
65
+ } catch (error: any) {
66
+ if (opts.debug) clack.log.warn(`[debug] MCP skills not available: ${error.message}`);
67
+ }
68
+ }
69
+
70
+ const allTools: Tool[] = opts.tools !== false
71
+ ? skillInstances.flatMap((skill) => Object.values(skill.tools))
72
+ : [];
73
+
74
+ // Helper: get prompt skill name+content for an agent (filtered by skills list)
75
+ const getPromptSkills = (agentSkillNames?: string[]): Array<{name: string, content: string}> => {
76
+ return skillInstances
77
+ .filter((s): s is any => typeof (s as any).getContent === 'function')
78
+ .filter((s) => !agentSkillNames || agentSkillNames.includes(s.name))
79
+ .map((s) => ({ name: s.name, content: (s as any).getContent() }));
80
+ };
81
+
82
+ // KB runtime (optional) — same pattern as start.ts
83
+ let kbRuntime: any;
84
+ let kbStore: any = null;
85
+ if (opts.kb !== false && config.KB_ENABLED === 'true') {
86
+ try {
87
+ const {
88
+ SQLiteKnowledgeStore,
89
+ EmbeddingService,
90
+ TextChunker,
91
+ IngestionPipeline,
92
+ RetrievalPipeline,
93
+ QueryRewriter,
94
+ } = await import('@operor/knowledge');
95
+
96
+ const embedder = new EmbeddingService({
97
+ provider: (config.KB_EMBEDDING_PROVIDER as any) || 'openai',
98
+ apiKey: config.KB_EMBEDDING_API_KEY || config.LLM_API_KEY,
99
+ model: config.KB_EMBEDDING_MODEL,
100
+ });
101
+
102
+ kbStore = new SQLiteKnowledgeStore(
103
+ config.KB_DB_PATH || './knowledge.db',
104
+ embedder.dimensions,
105
+ );
106
+ await kbStore.initialize();
107
+
108
+ // Setup optional LLM query rewriter
109
+ let queryRewriter: InstanceType<typeof QueryRewriter> | undefined;
110
+ try {
111
+ queryRewriter = new QueryRewriter({ model: llm.getModel() });
112
+ } catch { /* LLM not available for rewriting */ }
113
+
114
+ const retrieval = new RetrievalPipeline(kbStore, embedder, {
115
+ faqThreshold: 0.85,
116
+ queryRewriter,
117
+ });
118
+
119
+ kbRuntime = {
120
+ retrieve: async (query: string) => retrieval.retrieve(query),
121
+ getStats: async () => kbStore.getStats(),
122
+ };
123
+
124
+ if (opts.debug) clack.log.info('[debug] Knowledge Base loaded');
125
+ } catch (e: any) {
126
+ clack.log.warn(`Knowledge Base configured but failed to load: ${e?.message}. Continuing without KB.`);
127
+ }
128
+ }
129
+
130
+ // Check if agents/ directory exists for file-based agent definitions
131
+ const agentsDir = `${process.cwd()}/agents`;
132
+ const hasAgentsDir = fs.existsSync(agentsDir);
133
+
134
+ if (hasAgentsDir) {
135
+ // File-based agent definitions — same as start.ts
136
+ const loader = new AgentLoader(process.cwd());
137
+ const definitions = await loader.loadAll();
138
+
139
+ if (definitions.length === 0) {
140
+ clack.log.warn('agents/ directory found but no valid agent definitions. Falling back to default agent.');
141
+ } else {
142
+ for (const def of definitions) {
143
+ const agentTools: Tool[] = def.config.skills
144
+ ? skillInstances
145
+ .filter((skill) => def.config.skills!.includes(skill.name))
146
+ .flatMap((skill) => Object.values(skill.tools))
147
+ : allTools;
148
+
149
+ const agent = os.createAgent({
150
+ ...def.config,
151
+ tools: agentTools,
152
+ });
153
+
154
+ applyLLMOverride(agent, llm, agentTools, {
155
+ systemPrompt: def.systemPrompt,
156
+ kbRuntime,
157
+ useKB: def.config.knowledgeBase,
158
+ guardrails: def.config.guardrails,
159
+ promptSkills: getPromptSkills(def.config.skills),
160
+ });
161
+
162
+ if (opts.debug) {
163
+ clack.log.info(
164
+ `[debug] Loaded agent: ${def.config.name}` +
165
+ (def.config.skills ? ` (skills: ${def.config.skills.join(', ')})` : '') +
166
+ (def.config.knowledgeBase ? ' (KB)' : '')
167
+ );
168
+ }
169
+ }
170
+
171
+ if (opts.debug) clack.log.info(`[debug] Loaded ${definitions.length} agent(s) from agents/`);
172
+
173
+ // Wire up assistant message tracking
174
+ os.on('message:processed', (event) => {
175
+ os.addAssistantMessage(event.customer.id, event.response.text);
176
+ if (opts.debug) {
177
+ clack.log.info(`[debug] Agent: ${event.agent}, intent: ${event.intent?.name || 'default'}, ${event.duration}ms`);
178
+ }
179
+ });
180
+
181
+ return { os, provider, llm, kbStore };
182
+ }
183
+ }
184
+
185
+ // Fallback: single default agent (no agents/ directory or empty)
186
+ const agent = os.createAgent({
187
+ name: 'support',
188
+ purpose: 'Customer support agent',
189
+ personality: 'Friendly, helpful, and professional',
190
+ triggers: ['*'],
191
+ tools: allTools,
192
+ ...(kbRuntime && { knowledgeBase: true }),
193
+ });
194
+
195
+ applyLLMOverride(agent, llm, allTools, {
196
+ kbRuntime,
197
+ promptSkills: getPromptSkills(),
198
+ });
199
+
200
+ // Wire up assistant message tracking
201
+ os.on('message:processed', (event) => {
202
+ os.addAssistantMessage(event.customer.id, event.response.text);
203
+ if (opts.debug) {
204
+ clack.log.info(`[debug] Agent: ${event.agent}, intent: ${event.intent?.name || 'default'}, ${event.duration}ms`);
205
+ }
206
+ });
207
+
208
+ return { os, provider, llm, kbStore };
209
+ }
210
+
211
+ export function registerChatCommand(program: Command): void {
212
+ program
213
+ .command('chat')
214
+ .description('Chat with your agent (full pipeline REPL)')
215
+ .option('-m, --message <text>', 'One-shot mode: send a single message and exit')
216
+ .option('--debug', 'Show intent, agent selection, and KB retrieval details')
217
+ .option('--no-kb', 'Skip Knowledge Base context injection')
218
+ .option('--no-tools', 'Disable tool calling')
219
+ .action(async (opts: ChatOptions) => {
220
+ if (!configExists()) {
221
+ clack.log.error('No configuration found. Run "operor setup" first.');
222
+ process.exit(1);
223
+ }
224
+
225
+ clack.intro('Operor — Chat');
226
+
227
+ const spinner = clack.spinner();
228
+ spinner.start('Loading pipeline...');
229
+
230
+ let pipeline: Awaited<ReturnType<typeof buildPipeline>>;
231
+ try {
232
+ pipeline = await buildPipeline(opts);
233
+ } catch (error: any) {
234
+ spinner.stop('Failed to load pipeline');
235
+ clack.log.error(error.message);
236
+ process.exit(1);
237
+ }
238
+
239
+ const { os, provider, kbStore } = pipeline;
240
+ await os.start();
241
+ spinner.stop('Pipeline ready');
242
+
243
+ const cleanup = () => {
244
+ if (kbStore) kbStore.close();
245
+ };
246
+
247
+ if (opts.message) {
248
+ // One-shot mode
249
+ const responsePromise = new Promise<string>((resolve) => {
250
+ os.on('message:processed', (event) => {
251
+ resolve(event.response.text);
252
+ });
253
+ });
254
+
255
+ provider.simulateIncomingMessage('+cli-user', opts.message);
256
+ const response = await responsePromise;
257
+ console.log(response);
258
+ cleanup();
259
+ clack.outro('');
260
+ return;
261
+ }
262
+
263
+ // Interactive REPL
264
+ clack.log.info('Type your message and press Enter. Type "exit" or Ctrl+C to quit.\n');
265
+
266
+ const rl = readline.createInterface({
267
+ input: process.stdin,
268
+ output: process.stdout,
269
+ });
270
+
271
+ const askQuestion = (): void => {
272
+ rl.question('You: ', async (input) => {
273
+ const trimmed = input.trim();
274
+ if (!trimmed || trimmed === 'exit' || trimmed === 'quit') {
275
+ rl.close();
276
+ cleanup();
277
+ clack.outro('Goodbye');
278
+ return;
279
+ }
280
+
281
+ const responsePromise = new Promise<string>((resolve) => {
282
+ const handler = (event: any) => {
283
+ os.removeListener('message:processed', handler);
284
+ resolve(event.response.text);
285
+ };
286
+ os.on('message:processed', handler);
287
+ });
288
+
289
+ provider.simulateIncomingMessage('+cli-user', trimmed);
290
+
291
+ try {
292
+ const response = await responsePromise;
293
+ console.log(`\nAgent: ${response}\n`);
294
+ } catch (error: any) {
295
+ console.log(`\nError: ${error.message}\n`);
296
+ }
297
+
298
+ askQuestion();
299
+ });
300
+ };
301
+
302
+ rl.on('close', () => {
303
+ cleanup();
304
+ clack.outro('Goodbye');
305
+ process.exit(0);
306
+ });
307
+
308
+ askQuestion();
309
+ });
310
+ }
@@ -0,0 +1,34 @@
1
+ import { readConfig, writeConfig, configExists } from '../config.js';
2
+
3
+ export function showConfig(): void {
4
+ if (!configExists()) {
5
+ console.error('No configuration found. Run "operor setup" first.');
6
+ process.exit(1);
7
+ }
8
+ const config = readConfig();
9
+ for (const [key, value] of Object.entries(config)) {
10
+ const display = key.includes('KEY') || key.includes('TOKEN') || key.includes('SECRET')
11
+ ? maskValue(value || '')
12
+ : value;
13
+ console.log(` ${key}=${display}`);
14
+ }
15
+ }
16
+
17
+ export function setConfigValue(key: string, value: string): void {
18
+ const config = readConfig();
19
+ config[key] = value;
20
+ writeConfig(config);
21
+ console.log(` Set ${key}=${key.includes('KEY') ? maskValue(value) : value}`);
22
+ }
23
+
24
+ export function unsetConfigValue(key: string): void {
25
+ const config = readConfig();
26
+ delete config[key];
27
+ writeConfig(config);
28
+ console.log(` Removed ${key}`);
29
+ }
30
+
31
+ function maskValue(val: string): string {
32
+ if (val.length <= 8) return '••••••••';
33
+ return val.slice(0, 4) + '••••' + val.slice(-4);
34
+ }
@@ -0,0 +1,182 @@
1
+ import { readConfig } from '../config.js';
2
+ import { formatTimestamp } from '../utils.js';
3
+
4
+ export async function runConverse(options: {
5
+ scenario?: string;
6
+ file?: string;
7
+ turns?: number;
8
+ persona?: string;
9
+ verbose?: boolean;
10
+ json?: boolean;
11
+ real?: boolean;
12
+ allowWrites?: boolean;
13
+ dryRun?: boolean;
14
+ }): Promise<void> {
15
+ const config = readConfig();
16
+ const { Operor } = await import('@operor/core');
17
+ const { MockProvider } = await import('@operor/provider-mock');
18
+ const {
19
+ MockShopifySkill,
20
+ CustomerSimulator,
21
+ ConversationEvaluator,
22
+ ConversationRunner,
23
+ ECOMMERCE_SCENARIOS,
24
+ SkillTestHarness,
25
+ } = await import('@operor/testing');
26
+
27
+ // Set up LLM if configured
28
+ let llm: any;
29
+ if (config.LLM_PROVIDER && config.LLM_API_KEY) {
30
+ const { AIProvider } = await import('@operor/llm');
31
+ llm = new AIProvider({
32
+ provider: config.LLM_PROVIDER as any,
33
+ apiKey: config.LLM_API_KEY,
34
+ model: config.LLM_MODEL,
35
+ });
36
+ }
37
+
38
+ // Determine which scenarios to run
39
+ let scenarios = [...ECOMMERCE_SCENARIOS];
40
+
41
+ if (options.file) {
42
+ const fs = await import('fs');
43
+ try {
44
+ const raw = fs.readFileSync(options.file, 'utf-8');
45
+ scenarios = JSON.parse(raw);
46
+ } catch (err) {
47
+ console.error(`Failed to load scenarios from ${options.file}:`, err);
48
+ process.exit(1);
49
+ }
50
+ }
51
+
52
+ if (options.scenario) {
53
+ const match = scenarios.filter(
54
+ (s) => s.name.toLowerCase().includes(options.scenario!.toLowerCase()) ||
55
+ s.id.toLowerCase().includes(options.scenario!.toLowerCase())
56
+ );
57
+ if (match.length === 0) {
58
+ console.error(`No scenario matching "${options.scenario}". Available:`);
59
+ scenarios.forEach((s) => console.error(` - ${s.id}: ${s.name}`));
60
+ process.exit(1);
61
+ }
62
+ scenarios = match;
63
+ }
64
+
65
+ // Apply overrides
66
+ if (options.turns) {
67
+ scenarios = scenarios.map((s) => ({ ...s, maxTurns: options.turns! }));
68
+ }
69
+ if (options.persona) {
70
+ scenarios = scenarios.map((s) => ({ ...s, persona: options.persona! }));
71
+ }
72
+
73
+ // Set up Operor with mocks
74
+ const os = new Operor({ debug: false, batchWindowMs: 0 });
75
+ const provider = new MockProvider();
76
+ const shopify = new MockShopifySkill();
77
+
78
+ // Wrap skill with safety harness if --real or --dry-run
79
+ let skill: any = shopify;
80
+ if (options.real || options.dryRun) {
81
+ skill = new SkillTestHarness(shopify, {
82
+ allowWrites: options.allowWrites ?? false,
83
+ dryRun: options.dryRun ?? false,
84
+ });
85
+ await skill.authenticate();
86
+ }
87
+
88
+ await os.addProvider(provider);
89
+ await os.addSkill(skill);
90
+
91
+ const allTools = [shopify.tools.get_order, shopify.tools.create_discount, shopify.tools.search_products];
92
+
93
+ const agent = os.createAgent({
94
+ name: 'Test Agent',
95
+ purpose: 'Handle customer support conversations',
96
+ personality: 'empathetic and solution-focused',
97
+ triggers: ['order_tracking', 'general'],
98
+ tools: allTools,
99
+ rules: [{
100
+ name: 'Auto-compensation',
101
+ condition: async (_ctx: any, toolResults: any[]) => {
102
+ const order = toolResults.find((t) => t.name === 'get_order');
103
+ return order?.success && order.result?.isDelayed && order.result?.delayDays >= 2;
104
+ },
105
+ action: async () => {
106
+ const discount = await shopify.tools.create_discount.execute({ percent: 10, validDays: 30 });
107
+ return { type: 'discount_created', code: discount.code, percent: 10, validDays: 30 };
108
+ },
109
+ }],
110
+ });
111
+
112
+ // Set up Knowledge Base if enabled
113
+ let kbRuntime: any;
114
+ if (config.KB_ENABLED === 'true') {
115
+ try {
116
+ const { SQLiteKnowledgeStore, EmbeddingService, RetrievalPipeline } = await import('@operor/knowledge');
117
+ const embedder = new EmbeddingService({
118
+ provider: (config.KB_EMBEDDING_PROVIDER || config.LLM_PROVIDER) as any,
119
+ apiKey: config.KB_EMBEDDING_API_KEY || config.LLM_API_KEY || '',
120
+ model: config.KB_EMBEDDING_MODEL,
121
+ });
122
+ const kbStore = new SQLiteKnowledgeStore(config.KB_DB_PATH || './knowledge.db', embedder.dimensions);
123
+ await kbStore.initialize();
124
+ const retrieval = new RetrievalPipeline(kbStore, embedder);
125
+ kbRuntime = { retrieve: (q: string) => retrieval.retrieve(q) };
126
+ console.log('[Operor] 📚 Knowledge Base enabled for conversations');
127
+ } catch (kbError: any) {
128
+ console.warn('[Operor] ⚠️ Failed to initialize Knowledge Base:', kbError.message);
129
+ }
130
+ }
131
+
132
+ // Override agent.process with LLM-based implementation if LLM is configured
133
+ if (llm) {
134
+ const { applyLLMOverride } = await import('./llm-override.js');
135
+ applyLLMOverride(agent, llm, allTools, { kbRuntime });
136
+ }
137
+
138
+ await os.start();
139
+
140
+ // Create runner components with LLM if configured
141
+ const customerSimulator = new CustomerSimulator({ llmProvider: llm });
142
+ const conversationEvaluator = new ConversationEvaluator({ llmProvider: llm });
143
+ const runner = new ConversationRunner({
144
+ agentOS: os,
145
+ customerSimulator,
146
+ conversationEvaluator,
147
+ verbose: options.verbose ?? false,
148
+ });
149
+
150
+ if (!options.json) {
151
+ console.log(`\n Running ${scenarios.length} conversation scenario(s)...\n`);
152
+ }
153
+
154
+ const results = await runner.runScenarios(scenarios);
155
+ await os.stop();
156
+
157
+ // Output results
158
+ if (options.json) {
159
+ console.log(JSON.stringify(results, null, 2));
160
+ } else {
161
+ let passed = 0;
162
+ let failed = 0;
163
+
164
+ for (const r of results) {
165
+ const icon = r.passed ? '✓' : '✗';
166
+ const status = r.passed ? 'PASS' : 'FAIL';
167
+ console.log(` ${icon} ${r.scenario.name} — ${status} (${r.turns.length} turns, ${r.duration}ms)`);
168
+
169
+ if (!r.passed && r.evaluation.feedback) {
170
+ console.log(` ${r.evaluation.feedback}`);
171
+ }
172
+
173
+ if (r.passed) passed++;
174
+ else failed++;
175
+ }
176
+
177
+ console.log(`\n Results: ${passed} passed, ${failed} failed out of ${results.length}`);
178
+ console.log(` Status: ${failed === 0 ? '✓ ALL PASSED' : '✗ SOME FAILED'}\n`);
179
+ }
180
+
181
+ process.exit(results.every((r) => r.passed) ? 0 : 1);
182
+ }
@@ -0,0 +1,154 @@
1
+ import * as clack from '@clack/prompts';
2
+ import { readConfig, configExists } from '../config.js';
3
+
4
+ export async function runDoctor(): Promise<void> {
5
+ clack.intro('Operor Doctor');
6
+
7
+ if (!configExists()) {
8
+ clack.log.error('.env file not found. Run "operor setup" first.');
9
+ process.exit(1);
10
+ }
11
+
12
+ const config = readConfig();
13
+ let allPassed = true;
14
+
15
+ // 1. Check .env exists
16
+ clack.log.success('.env file found');
17
+
18
+ // 2. Validate LLM
19
+ if (config.LLM_PROVIDER && config.LLM_API_KEY) {
20
+ const spinner = clack.spinner();
21
+ spinner.start(`Testing ${config.LLM_PROVIDER} LLM connection...`);
22
+ try {
23
+ const { AIProvider } = await import('@operor/llm');
24
+ const llm = new AIProvider({
25
+ provider: config.LLM_PROVIDER as any,
26
+ apiKey: config.LLM_API_KEY,
27
+ model: config.LLM_MODEL,
28
+ });
29
+ await llm.complete([{ role: 'user', content: 'ping' }], { maxTokens: 5 });
30
+ spinner.stop(`${config.LLM_PROVIDER} LLM: OK`);
31
+ } catch (error: any) {
32
+ spinner.stop(`${config.LLM_PROVIDER} LLM: FAILED — ${error.message}`);
33
+ allPassed = false;
34
+ }
35
+ } else {
36
+ clack.log.warning('LLM not configured');
37
+ allPassed = false;
38
+ }
39
+
40
+ // 3. Validate MCP Skills
41
+ if (config.SKILLS_ENABLED !== 'false') {
42
+ const skillSpinner = clack.spinner();
43
+ skillSpinner.start('Checking MCP Skills...');
44
+ try {
45
+ const { loadSkillsConfig, SkillManager } = await import('@operor/skills');
46
+ const skillsConfig = loadSkillsConfig();
47
+ const enabledSkills = skillsConfig.skills.filter((s: any) => s.enabled !== false);
48
+
49
+ if (enabledSkills.length === 0) {
50
+ skillSpinner.stop('MCP Skills: No skills configured in mcp.json');
51
+ } else {
52
+ // Validate config only (don't start servers)
53
+ const errors: string[] = [];
54
+ for (const skill of enabledSkills) {
55
+ const error = SkillManager.validateConfig(skill);
56
+ if (error) errors.push(`${skill.name}: ${error}`);
57
+ }
58
+ if (errors.length > 0) {
59
+ skillSpinner.stop(`MCP Skills: ${errors.length} config error(s)`);
60
+ for (const err of errors) clack.log.warning(` ${err}`);
61
+ allPassed = false;
62
+ } else {
63
+ skillSpinner.stop(`MCP Skills: ${enabledSkills.length} skill(s) configured`);
64
+ }
65
+ }
66
+ } catch (error: any) {
67
+ skillSpinner.stop('MCP Skills: Not available (mcp.json not found or @operor/skills not installed)');
68
+ }
69
+ }
70
+
71
+ // 4. Validate channel credentials
72
+ if (config.CHANNEL === 'telegram' && config.TELEGRAM_BOT_TOKEN) {
73
+ clack.log.success('Telegram bot token configured');
74
+ } else if (config.CHANNEL === 'wati') {
75
+ if (config.WATI_API_TOKEN && config.WATI_TENANT_ID) {
76
+ clack.log.success('WATI credentials configured');
77
+ } else {
78
+ clack.log.error('WATI: missing WATI_API_TOKEN or WATI_TENANT_ID');
79
+ allPassed = false;
80
+ }
81
+ }
82
+
83
+ // 5. Knowledge Base checks
84
+ if (config.KB_ENABLED === 'true') {
85
+ const kbSpinner = clack.spinner();
86
+ kbSpinner.start('Checking Knowledge Base...');
87
+ try {
88
+ // Check DB path is writable
89
+ const { writeFileSync, unlinkSync, existsSync } = await import('node:fs');
90
+ const { dirname } = await import('node:path');
91
+ const { mkdirSync } = await import('node:fs');
92
+ const dbPath = config.KB_DB_PATH || './knowledge.db';
93
+ const dir = dirname(dbPath);
94
+ if (!existsSync(dir)) {
95
+ mkdirSync(dir, { recursive: true });
96
+ }
97
+ const testFile = dbPath + '.write-test';
98
+ writeFileSync(testFile, '');
99
+ unlinkSync(testFile);
100
+
101
+ // Check embedding API key (unless ollama)
102
+ if (config.KB_EMBEDDING_PROVIDER !== 'ollama' && !config.KB_EMBEDDING_API_KEY) {
103
+ kbSpinner.stop('Knowledge Base: WARNING — no embedding API key set');
104
+ allPassed = false;
105
+ } else {
106
+ // Try loading sqlite-vec
107
+ try {
108
+ await import('@operor/knowledge');
109
+ kbSpinner.stop('Knowledge Base: OK');
110
+ } catch {
111
+ kbSpinner.stop('Knowledge Base: OK (config valid, @operor/knowledge not installed yet)');
112
+ }
113
+ }
114
+ } catch (error: any) {
115
+ kbSpinner.stop(`Knowledge Base: FAILED — ${error.message}`);
116
+ allPassed = false;
117
+ }
118
+ }
119
+
120
+ // 6. Analytics checks
121
+ if (config.ANALYTICS_ENABLED !== 'false') {
122
+ const analyticsSpinner = clack.spinner();
123
+ analyticsSpinner.start('Checking Analytics...');
124
+ try {
125
+ const { writeFileSync, unlinkSync, existsSync } = await import('node:fs');
126
+ const { dirname } = await import('node:path');
127
+ const { mkdirSync } = await import('node:fs');
128
+ const dbPath = config.ANALYTICS_DB_PATH || './analytics.db';
129
+ const dir = dirname(dbPath);
130
+ if (!existsSync(dir)) {
131
+ mkdirSync(dir, { recursive: true });
132
+ }
133
+ const testFile = dbPath + '.write-test';
134
+ writeFileSync(testFile, '');
135
+ unlinkSync(testFile);
136
+ analyticsSpinner.stop('Analytics: OK (DB path writable)');
137
+ } catch (error: any) {
138
+ analyticsSpinner.stop(`Analytics: FAILED — ${error.message}`);
139
+ allPassed = false;
140
+ }
141
+ }
142
+
143
+ // 7. Check Node.js version
144
+ const nodeVersion = process.versions.node;
145
+ const major = parseInt(nodeVersion.split('.')[0]);
146
+ if (major >= 18) {
147
+ clack.log.success(`Node.js ${nodeVersion}`);
148
+ } else {
149
+ clack.log.error(`Node.js ${nodeVersion} — requires >= 18`);
150
+ allPassed = false;
151
+ }
152
+
153
+ clack.outro(allPassed ? 'All checks passed!' : 'Some checks failed. Fix the issues above.');
154
+ }