@myvillage/cli 1.3.0 → 1.5.1

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,965 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import inquirer from 'inquirer';
4
+ import { z } from 'zod';
5
+ import { createInterface } from 'readline';
6
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
7
+ import { join, resolve } from 'path';
8
+ import { isAuthenticated } from '../utils/auth.js';
9
+ import { getConfig } from '../utils/config.js';
10
+ import { brand, villageSpinner, formatAIResponse } from '../utils/brand.js';
11
+ import {
12
+ createBizReqsSubmission,
13
+ listBizReqsSubmissions,
14
+ getBizReqsSubmission,
15
+ getBizReqsStatus,
16
+ saveBizReqsSpec,
17
+ } from '../utils/api.js';
18
+ import {
19
+ formatBizReqsList,
20
+ formatBizReqsStatusCounts,
21
+ formatBizReqsStatus,
22
+ formatRecommendationBox,
23
+ } from '../utils/formatters.js';
24
+
25
+ // ── AI System Prompt ────────────────────────────────────
26
+
27
+ const INTAKE_SYSTEM_PROMPT = `You are a business solutions consultant for MyVillage Project, a community impact organization where student developers build real technology solutions for businesses and nonprofits.
28
+
29
+ Your job is to analyze a client's initial description and quickly arrive at a recommendation. You're warm, professional, and concise. You speak in plain language — no jargon.
30
+
31
+ ## How the Conversation Works
32
+
33
+ The client has already written a description of their organization, what they do, and what challenges they face. You will receive this upfront.
34
+
35
+ 1. **First response:** Briefly acknowledge what you heard (2-3 sentences max). Then, if you have enough to make a recommendation, call the ready_for_recommendation tool. If something critical is unclear, ask ONE specific follow-up question — never more than one question per response.
36
+ 2. **Follow-up responses:** After the client answers, either call ready_for_recommendation or ask ONE more question. You should need at most 2 follow-ups. Do not ask open-ended questions — be specific about what's missing.
37
+
38
+ ## Rules
39
+ - NEVER ask more than one question per response
40
+ - NEVER ask a question that bundles multiple sub-questions (e.g., "What's your budget and timeline and team size?")
41
+ - Prefer calling ready_for_recommendation over asking another question — only ask if truly critical info is missing
42
+ - Keep responses short — no long summaries or recaps unless the client asks
43
+ - Do not repeat back everything the client said
44
+
45
+ ## MyVillageOS Capability Mapping
46
+
47
+ When thinking about recommendations, map client needs to these platform tools:
48
+
49
+ | Client Need | Platform Tool | Description |
50
+ |-------------|--------------|-------------|
51
+ | Website, dashboard, admin panel | portal | Web portal creation |
52
+ | Youth engagement, gamified learning | game (M-UNI) | Educational game platform |
53
+ | Automation, monitoring, reminders | agent | Autonomous AI agent |
54
+ | Community updates, social presence | feed (MAN) | Community network |
55
+ | Staff training, pattern recognition | model | Custom ML/AI model |
56
+ | Phone system, voice assistant | voice | Voice cloning + agent |
57
+ | STEM, hardware, robotics | robot | TechRoots robotics |
58
+
59
+ ## Honesty Guidelines
60
+ - Be honest about what MyVillageOS can and cannot do well
61
+ - If something doesn't map well to the platform, say so and suggest what DOES fit
62
+ - Don't oversell — recommend only what genuinely serves the client's needs
63
+ - Most real-world needs map to a COMBINATION of tools, not just one`;
64
+
65
+ // ── Simple text input (avoids inquirer readline cursor issues) ──
66
+
67
+ function askInput(prompt) {
68
+ // Print styled prompt separately so readline doesn't count ANSI codes as width
69
+ process.stdout.write(brand.gold('? ') + chalk.bold(prompt) + ' ');
70
+ return new Promise((resolve) => {
71
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
72
+ rl.question('', (answer) => {
73
+ rl.close();
74
+ resolve(answer.trim());
75
+ });
76
+ });
77
+ }
78
+
79
+ // ── Tool Schemas (Zod — required by Vercel AI SDK v4) ──
80
+
81
+ const readyForRecommendationParams = z.object({
82
+ situation: z.string().describe('Brief summary of the organization and what they do'),
83
+ problems: z.string().describe('Key challenges and pain points identified'),
84
+ goals: z.string().optional().describe('Aspirations or goals mentioned, if any'),
85
+ });
86
+
87
+ const recommendationParams = z.object({
88
+ solutionName: z.string().describe('A descriptive name for the recommended solution (e.g., "NorthStar Learning Hub")'),
89
+ components: z.array(z.object({
90
+ name: z.string().describe('Component name'),
91
+ type: z.enum(['portal', 'game', 'agent', 'feed', 'model', 'voice', 'robot']).describe('MyVillageOS platform tool type'),
92
+ description: z.string().describe('What this component does for the client'),
93
+ complexity: z.enum(['low', 'medium', 'high']).describe('Component complexity'),
94
+ })).describe('Recommended solution components'),
95
+ estimatedTimeline: z.string().describe('Estimated build timeline (e.g., "6-8 weeks")'),
96
+ estimatedMVT: z.number().describe('Estimated MVT token budget'),
97
+ overallComplexity: z.enum(['LOW', 'MEDIUM', 'HIGH']).describe('Overall project complexity'),
98
+ priorityOrder: z.string().optional().describe('Recommended build order / priority for components'),
99
+ });
100
+
101
+ // ── Helper: Get Anthropic client ────────────────────────
102
+
103
+ async function getAnthropicProvider() {
104
+ const { createAnthropic } = await import('@ai-sdk/anthropic');
105
+ const config = getConfig();
106
+ let apiKey = config.anthropicApiKey || process.env.ANTHROPIC_API_KEY;
107
+
108
+ if (!apiKey) {
109
+ console.log(chalk.yellow('\n Anthropic API key required for AI-powered intake.'));
110
+ console.log(brand.teal(' Get yours at: https://console.anthropic.com\n'));
111
+
112
+ const { key } = await inquirer.prompt([{
113
+ type: 'password',
114
+ name: 'key',
115
+ message: 'Anthropic API key:',
116
+ mask: '*',
117
+ validate: (input) => input.trim().length > 0 || 'API key is required',
118
+ }]);
119
+
120
+ apiKey = key.trim();
121
+ const { setConfig } = await import('../utils/config.js');
122
+ setConfig({ anthropicApiKey: apiKey });
123
+ console.log(brand.green(' ✓ API key saved to ~/.myvillage/config.json\n'));
124
+ }
125
+
126
+ return createAnthropic({ apiKey });
127
+ }
128
+
129
+
130
+ // ── bizreqs list ────────────────────────────────────────
131
+
132
+ export async function bizreqsListCommand(options) {
133
+ if (!isAuthenticated()) {
134
+ console.log(chalk.red(' ✗ Authentication required. Run \'myvillage login\' first.'));
135
+ return;
136
+ }
137
+
138
+ const spinner = villageSpinner('Loading submissions...').start();
139
+
140
+ try {
141
+ const params = {
142
+ limit: parseInt(options.limit) || 50,
143
+ offset: parseInt(options.offset) || 0,
144
+ counts: 'true',
145
+ };
146
+ if (options.status) params.status = options.status.toUpperCase().replace('-', '_');
147
+ if (options.city) params.city = options.city;
148
+ if (options.sort) params.sort = options.sort;
149
+ if (options.search) params.search = options.search;
150
+
151
+ const result = await listBizReqsSubmissions(params);
152
+ spinner.stop();
153
+
154
+ if (options.json) {
155
+ console.log(JSON.stringify(result, null, 2));
156
+ return;
157
+ }
158
+
159
+ const statusLabel = options.status
160
+ ? ` — ${options.status.replace('-', ' ').replace(/\b\w/g, c => c.toUpperCase())}`
161
+ : '';
162
+
163
+ console.log(`\n ${chalk.bold(`Business Requirements Pipeline${statusLabel}`)}`);
164
+ formatBizReqsList(result.submissions);
165
+
166
+ if (result.statusCounts) {
167
+ formatBizReqsStatusCounts(result.statusCounts);
168
+ }
169
+
170
+ if (result.pagination?.hasMore) {
171
+ console.log(brand.teal(` ── More results available. Use --offset=${result.pagination.offset + result.pagination.limit} to see next page\n`));
172
+ }
173
+ } catch (err) {
174
+ const message = err.response?.data?.error || err.message;
175
+ spinner.fail(`Failed to load submissions: ${message}`);
176
+ }
177
+ }
178
+
179
+ // ── bizreqs status ──────────────────────────────────────
180
+
181
+ export async function bizreqsStatusCommand(id) {
182
+ if (!isAuthenticated()) {
183
+ console.log(chalk.red(' ✗ Authentication required. Run \'myvillage login\' first.'));
184
+ return;
185
+ }
186
+
187
+ const spinner = villageSpinner(`Loading status for ${id}...`).start();
188
+
189
+ try {
190
+ const result = await getBizReqsStatus(id);
191
+ spinner.stop();
192
+
193
+ if (!result.success || !result.status) {
194
+ console.log(chalk.red(` ✗ Submission not found: ${id}`));
195
+ return;
196
+ }
197
+
198
+ console.log(`\n ${chalk.bold('Project Status')}`);
199
+ formatBizReqsStatus(result.status);
200
+ } catch (err) {
201
+ if (err.response?.status === 404) {
202
+ spinner.fail(`Submission not found: ${id}`);
203
+ } else {
204
+ const message = err.response?.data?.error || err.message;
205
+ spinner.fail(`Failed to load status: ${message}`);
206
+ }
207
+ }
208
+ }
209
+
210
+ // ── bizreqs new ─────────────────────────────────────────
211
+
212
+ export async function bizreqsNewCommand(options) {
213
+ if (!isAuthenticated()) {
214
+ console.log(chalk.red(' ✗ Authentication required. Run \'myvillage login\' first.'));
215
+ return;
216
+ }
217
+
218
+ let anthropic;
219
+ try {
220
+ anthropic = await getAnthropicProvider();
221
+ } catch (err) {
222
+ console.log(chalk.red(` ✗ ${err.message}`));
223
+ return;
224
+ }
225
+
226
+ const { streamText, generateText } = await import('ai');
227
+
228
+ // Gather basic context
229
+ console.log(`\n ${brand.gold(chalk.bold('\u2726 MyVillage Project \u2014 Business Solutions Intake'))}\n`);
230
+ console.log(brand.cream(' Tell us about your organization and what you need.'));
231
+ console.log(brand.cream(' Our student developers will use this to build'));
232
+ console.log(brand.cream(' something great for you.\n'));
233
+ console.log(brand.teal(' Tip: Type /quit at any prompt to cancel.\n'));
234
+
235
+ let orgName = options.org;
236
+ let contactName = options.contact;
237
+
238
+ if (!orgName) {
239
+ const answer = await inquirer.prompt([{
240
+ type: 'input',
241
+ name: 'orgName',
242
+ message: 'Organization name:',
243
+ validate: v => v.trim() ? true : 'Required',
244
+ }]);
245
+ orgName = answer.orgName.trim();
246
+ }
247
+
248
+ if (!contactName) {
249
+ const answer = await inquirer.prompt([{
250
+ type: 'input',
251
+ name: 'contactName',
252
+ message: 'Contact name (optional):',
253
+ }]);
254
+ contactName = answer.contactName.trim() || undefined;
255
+ }
256
+
257
+ // Read pre-context from file/URL if provided
258
+ let preContext = '';
259
+ if (options.fromFile) {
260
+ try {
261
+ preContext = readFileSync(resolve(options.fromFile), 'utf-8');
262
+ console.log(brand.teal(`\n 📎 Read context from: ${options.fromFile}\n`));
263
+ } catch (err) {
264
+ console.log(chalk.yellow(` ⚠ Could not read file: ${err.message}`));
265
+ }
266
+ }
267
+
268
+ if (options.fromUrl) {
269
+ try {
270
+ const { default: axios } = await import('axios');
271
+ const response = await axios.get(options.fromUrl, { timeout: 10000 });
272
+ const text = response.data
273
+ .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
274
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
275
+ .replace(/<[^>]+>/g, ' ')
276
+ .replace(/\s+/g, ' ')
277
+ .trim()
278
+ .substring(0, 3000);
279
+ preContext += `\n\nWebsite content from ${options.fromUrl}:\n${text}`;
280
+ console.log(brand.teal(` 🌐 Scanned: ${options.fromUrl}\n`));
281
+ } catch (err) {
282
+ console.log(chalk.yellow(` ⚠ Could not fetch URL: ${err.message}`));
283
+ }
284
+ }
285
+
286
+ // Set up conversation
287
+ const messages = [];
288
+ const intakeSummary = {};
289
+ const maxFollowUps = options.quick ? 0 : 3;
290
+
291
+ // Collect bulk input upfront
292
+ console.log(brand.darkGold(' Describe your organization and the challenges you\'re facing.'));
293
+ console.log(brand.cream(' Include what you do, who you serve, what\'s working, what\'s not,'));
294
+ console.log(brand.cream(' and what you\'d love to see in the future. The more you share'));
295
+ console.log(brand.cream(' now, the fewer follow-up questions we\'ll need.\n'));
296
+
297
+ const bulkInput = await askInput('Your description');
298
+
299
+ if (!bulkInput) {
300
+ console.log(chalk.yellow('\n No input provided. Intake cancelled.\n'));
301
+ return;
302
+ }
303
+ if (bulkInput.toLowerCase() === '/quit' || bulkInput.toLowerCase() === '/exit') {
304
+ console.log(brand.teal('\n Intake cancelled.\n'));
305
+ return;
306
+ }
307
+
308
+ // Build initial message with all context
309
+ let initialMessage = `Organization: "${orgName}"`;
310
+ if (contactName) initialMessage += ` | Contact: ${contactName}`;
311
+ if (preContext) initialMessage += `\n\nPre-provided context:\n${preContext}`;
312
+ initialMessage += `\n\nClient's description:\n${bulkInput}`;
313
+
314
+ messages.push({ role: 'user', content: initialMessage });
315
+
316
+ // Follow-up loop: AI can ask single questions or signal ready
317
+ for (let i = 0; i <= maxFollowUps; i++) {
318
+ let fullResponse = '';
319
+ let toolCallMade = false;
320
+
321
+ try {
322
+ const result = await streamText({
323
+ model: anthropic('claude-sonnet-4-5-20250929'),
324
+ system: INTAKE_SYSTEM_PROMPT + `\n\nThe organization is "${orgName}". This is follow-up ${i} of ${maxFollowUps} max.${i === maxFollowUps ? ' You MUST call ready_for_recommendation now — no more questions.' : ''}`,
325
+ messages,
326
+ tools: {
327
+ ready_for_recommendation: {
328
+ description: 'Signal that you have enough information to generate a recommendation. Call this instead of asking another question.',
329
+ parameters: readyForRecommendationParams,
330
+ },
331
+ },
332
+ maxTokens: 512,
333
+ });
334
+
335
+ const aiSpinner = villageSpinner('Thinking...').start();
336
+ for await (const chunk of result.textStream) {
337
+ fullResponse += chunk;
338
+ }
339
+
340
+ const finalResult = await result;
341
+ aiSpinner.stop();
342
+
343
+ if (fullResponse.trim()) {
344
+ console.log(formatAIResponse(fullResponse.trim()));
345
+ messages.push({ role: 'assistant', content: fullResponse });
346
+ }
347
+
348
+ if (finalResult.toolCalls && finalResult.toolCalls.length > 0) {
349
+ for (const call of finalResult.toolCalls) {
350
+ if (call.toolName === 'ready_for_recommendation') {
351
+ toolCallMade = true;
352
+ intakeSummary.situation = call.args.situation;
353
+ intakeSummary.problems = call.args.problems;
354
+ intakeSummary.goals = call.args.goals || null;
355
+ }
356
+ }
357
+ }
358
+ } catch (err) {
359
+ console.log(chalk.red(`\n AI error: ${err.message}`));
360
+ break;
361
+ }
362
+
363
+ if (toolCallMade) break;
364
+
365
+ // If this was the last allowed follow-up, force move on
366
+ if (i === maxFollowUps) break;
367
+
368
+ // Get user response to the follow-up question
369
+ const userInput = await askInput('Your response');
370
+
371
+ if (!userInput) continue;
372
+ if (userInput.toLowerCase() === '/quit' || userInput.toLowerCase() === '/exit') {
373
+ console.log(brand.teal('\n Intake cancelled.\n'));
374
+ return;
375
+ }
376
+
377
+ messages.push({ role: 'user', content: userInput });
378
+ }
379
+
380
+ // Generate recommendation
381
+ console.log(`\n \uD83C\uDFAF ${brand.gold('Generating recommendation...')}\n`);
382
+
383
+ const recSpinner = villageSpinner('Analyzing intake data...').start();
384
+
385
+ const recPrompt = `Based on the entire conversation above about "${orgName}", generate a comprehensive solution recommendation. Consider all the problems discussed, the client's aspirations, and which MyVillageOS platform tools would best serve their needs. Call the generate_recommendation tool with your recommendation.`;
386
+
387
+ messages.push({ role: 'user', content: recPrompt });
388
+
389
+ let recommendation = null;
390
+
391
+ try {
392
+ const recResult = await generateText({
393
+ model: anthropic('claude-sonnet-4-5-20250929'),
394
+ system: INTAKE_SYSTEM_PROMPT,
395
+ messages,
396
+ tools: {
397
+ generate_recommendation: {
398
+ description: 'Generate a structured solution recommendation based on the intake conversation',
399
+ parameters: recommendationParams,
400
+ },
401
+ },
402
+ maxTokens: 2048,
403
+ });
404
+
405
+ if (recResult.toolCalls && recResult.toolCalls.length > 0) {
406
+ for (const call of recResult.toolCalls) {
407
+ if (call.toolName === 'generate_recommendation') {
408
+ recommendation = call.args;
409
+ }
410
+ }
411
+ }
412
+
413
+ recSpinner.stop();
414
+
415
+ if (!recommendation) {
416
+ console.log(chalk.yellow(' ⚠ AI did not generate a structured recommendation. Saving transcript only.'));
417
+ } else {
418
+ formatRecommendationBox({ ...recommendation, organizationName: orgName });
419
+
420
+ // Ask for confirmation
421
+ const { confirmed } = await inquirer.prompt([{
422
+ type: 'confirm',
423
+ name: 'confirmed',
424
+ message: 'Does this recommendation look right?',
425
+ default: true,
426
+ }]);
427
+
428
+ if (!confirmed) {
429
+ const { adjustments } = await inquirer.prompt([{
430
+ type: 'input',
431
+ name: 'adjustments',
432
+ message: 'What would you like to adjust?',
433
+ }]);
434
+
435
+ if (adjustments.trim()) {
436
+ messages.push({
437
+ role: 'user',
438
+ content: `Please adjust the recommendation: ${adjustments}. Call generate_recommendation again with the updated recommendation.`,
439
+ });
440
+
441
+ const adjustSpinner = villageSpinner('Adjusting recommendation...').start();
442
+
443
+ const adjustResult = await generateText({
444
+ model: anthropic('claude-sonnet-4-5-20250929'),
445
+ system: INTAKE_SYSTEM_PROMPT,
446
+ messages,
447
+ tools: {
448
+ generate_recommendation: {
449
+ description: 'Generate a structured solution recommendation based on the intake conversation',
450
+ parameters: recommendationParams,
451
+ },
452
+ },
453
+ maxTokens: 2048,
454
+ });
455
+
456
+ adjustSpinner.stop();
457
+
458
+ if (adjustResult.toolCalls?.length > 0) {
459
+ for (const call of adjustResult.toolCalls) {
460
+ if (call.toolName === 'generate_recommendation') {
461
+ recommendation = call.args;
462
+ }
463
+ }
464
+ formatRecommendationBox({ ...recommendation, organizationName: orgName });
465
+ }
466
+ }
467
+ }
468
+ }
469
+ } catch (err) {
470
+ recSpinner.fail(`AI recommendation failed: ${err.message}`);
471
+ console.log(brand.teal(' Saving transcript without recommendation...\n'));
472
+ }
473
+
474
+ // Save to backend (with local fallback)
475
+ const payload = {
476
+ organizationName: orgName,
477
+ contactName,
478
+ intakeMethod: 'CLI_INTERACTIVE',
479
+ conversationTranscript: messages.filter(m => m.role !== 'system'),
480
+ extractedSituation: intakeSummary.situation || null,
481
+ extractedProblems: intakeSummary.problems ? [intakeSummary.problems] : null,
482
+ extractedDreams: intakeSummary.goals ? [intakeSummary.goals] : null,
483
+ };
484
+
485
+ if (recommendation) {
486
+ payload.solutionName = recommendation.solutionName;
487
+ payload.components = recommendation.components;
488
+ payload.estimatedTimeline = recommendation.estimatedTimeline;
489
+ payload.estimatedMVT = recommendation.estimatedMVT;
490
+ payload.overallComplexity = recommendation.overallComplexity;
491
+ }
492
+
493
+ const saveSpinner = villageSpinner('Saving submission...').start();
494
+
495
+ try {
496
+ const result = await createBizReqsSubmission(payload);
497
+ saveSpinner.stop();
498
+
499
+ const sub = result.submission;
500
+ console.log(brand.green(`\n \u2713 Intake complete! Submission ID: ${chalk.bold(sub.submissionId)}\n`));
501
+ console.log(brand.teal(' \uD83D\uDCC4 Run \'myvillage bizreqs spec ' + sub.submissionId + '\' to generate a full project specification'));
502
+ console.log(brand.teal(' \uD83D\uDCCB Run \'myvillage bizreqs status ' + sub.submissionId + '\' to check status'));
503
+ console.log('');
504
+ } catch (err) {
505
+ saveSpinner.stop();
506
+
507
+ // Save locally so nothing is lost
508
+ const savedPath = saveIntakeLocally(payload);
509
+ const message = err.response?.data?.error || err.message;
510
+ console.log(chalk.yellow(`\n \u26A0 Could not reach server: ${message}`));
511
+ console.log(brand.green(` \u2713 Intake saved locally: ${chalk.bold(savedPath)}`));
512
+ console.log(brand.teal(`\n You can submit it later with:`));
513
+ console.log(brand.gold(` myvillage bizreqs import --file ${savedPath}\n`));
514
+ }
515
+ }
516
+
517
+ function saveIntakeLocally(payload) {
518
+ const homeDir = process.env.HOME || process.env.USERPROFILE;
519
+ const draftsDir = join(homeDir, '.myvillage', 'bizreqs-drafts');
520
+ if (!existsSync(draftsDir)) mkdirSync(draftsDir, { recursive: true });
521
+
522
+ const slug = payload.organizationName
523
+ .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
524
+ const ts = new Date().toISOString().slice(0, 10);
525
+ const filename = `${slug}-${ts}.json`;
526
+ const filepath = join(draftsDir, filename);
527
+
528
+ writeFileSync(filepath, JSON.stringify(payload, null, 2));
529
+ return filepath;
530
+ }
531
+
532
+ // ── bizreqs spec ────────────────────────────────────────
533
+
534
+ export async function bizreqsSpecCommand(id, options) {
535
+ if (!isAuthenticated()) {
536
+ console.log(chalk.red(' ✗ Authentication required. Run \'myvillage login\' first.'));
537
+ return;
538
+ }
539
+
540
+ let anthropic;
541
+ try {
542
+ anthropic = await getAnthropicProvider();
543
+ } catch (err) {
544
+ console.log(chalk.red(` ✗ ${err.message}`));
545
+ return;
546
+ }
547
+
548
+ const { generateText } = await import('ai');
549
+
550
+ // Fetch submission
551
+ const fetchSpinner = villageSpinner(`Loading submission ${id}...`).start();
552
+ let submission;
553
+
554
+ try {
555
+ const result = await getBizReqsSubmission(id);
556
+ submission = result.submission;
557
+ fetchSpinner.stop();
558
+
559
+ if (!submission) {
560
+ console.log(chalk.red(` ✗ Submission not found: ${id}`));
561
+ return;
562
+ }
563
+
564
+ if (!submission.solutionName && !submission.components) {
565
+ console.log(chalk.yellow(' ⚠ This submission has no recommendation data yet.'));
566
+ console.log(brand.teal(' Run \'myvillage bizreqs new\' to generate a recommendation first.\n'));
567
+ return;
568
+ }
569
+ } catch (err) {
570
+ if (err.response?.status === 404) {
571
+ fetchSpinner.fail(`Submission not found: ${id}`);
572
+ } else {
573
+ fetchSpinner.fail(`Failed to load submission: ${err.response?.data?.error || err.message}`);
574
+ }
575
+ return;
576
+ }
577
+
578
+ // Generate spec
579
+ const detailLevel = options.detail || 'standard';
580
+ const genSpinner = villageSpinner('Generating project specification...').start();
581
+
582
+ const specPrompt = `Generate a comprehensive project specification document in Markdown format.
583
+
584
+ ## Submission Details
585
+
586
+ - **Submission ID:** ${submission.submissionId}
587
+ - **Organization:** ${submission.organizationName}
588
+ - **Contact:** ${submission.contactName || 'N/A'}
589
+ - **City:** ${submission.city || 'N/A'}
590
+ - **Org Type:** ${submission.orgType || 'N/A'}
591
+
592
+ ## Recommended Solution: ${submission.solutionName}
593
+
594
+ ### Components
595
+ ${JSON.stringify(submission.components, null, 2)}
596
+
597
+ ### Estimates
598
+ - Timeline: ${submission.estimatedTimeline || 'TBD'}
599
+ - MVT Budget: ${submission.estimatedMVT || 'TBD'}
600
+ - Complexity: ${submission.overallComplexity || 'TBD'}
601
+
602
+ ## Intake Context
603
+ ${submission.extractedSituation ? `### Situation\n${submission.extractedSituation}` : ''}
604
+ ${submission.extractedProblems ? `### Problems\n${JSON.stringify(submission.extractedProblems)}` : ''}
605
+ ${submission.extractedDreams ? `### Dreams\n${JSON.stringify(submission.extractedDreams)}` : ''}
606
+
607
+ ## Conversation Transcript
608
+ ${submission.conversationTranscript ? JSON.stringify(submission.conversationTranscript, null, 2) : 'No transcript available'}
609
+
610
+ ---
611
+
612
+ Generate the spec with the following 9 sections:
613
+ 1. Executive Summary
614
+ 2. Client Context
615
+ 3. Solution Architecture
616
+ 4. Component Specifications (one subsection per component with user stories, key screens/mechanics, acceptance criteria)
617
+ 5. Implementation Plan (phases, sprints, milestones)
618
+ 6. MVT Budget (allocation per component and role)
619
+ 7. Success Metrics
620
+ 8. Client Deliverables
621
+ 9. Appendix
622
+
623
+ Detail level: ${detailLevel}
624
+ ${detailLevel === 'brief' ? 'Keep it concise — 2-3 pages, high-level overview.' : ''}
625
+ ${detailLevel === 'comprehensive' ? 'Be thorough — include wireframe descriptions, data models, and detailed user stories.' : ''}
626
+
627
+ Format as clean Markdown. Start with a YAML-style header block.`;
628
+
629
+ try {
630
+ const specResult = await generateText({
631
+ model: anthropic('claude-sonnet-4-5-20250929'),
632
+ system: 'You are a technical project specification writer for MyVillage Project. Generate clear, actionable project specifications that student developers can use to build real solutions. Use Markdown formatting.',
633
+ messages: [{ role: 'user', content: specPrompt }],
634
+ maxTokens: 8192,
635
+ });
636
+
637
+ genSpinner.stop();
638
+
639
+ const specContent = specResult.text;
640
+
641
+ if (!specContent || specContent.trim().length < 100) {
642
+ console.log(chalk.yellow(' ⚠ Spec generation produced insufficient content.'));
643
+ return;
644
+ }
645
+
646
+ // Save locally
647
+ const outputDir = resolve(options.output || './specs');
648
+ if (!existsSync(outputDir)) {
649
+ mkdirSync(outputDir, { recursive: true });
650
+ }
651
+
652
+ const slug = (submission.solutionName || submission.organizationName)
653
+ .toLowerCase()
654
+ .replace(/[^a-z0-9]+/g, '-')
655
+ .replace(/^-+|-+$/g, '')
656
+ .substring(0, 40);
657
+
658
+ const fileName = `${submission.submissionId}-${slug}.md`;
659
+ const filePath = join(outputDir, fileName);
660
+ writeFileSync(filePath, specContent, 'utf-8');
661
+
662
+ console.log(brand.green(`\n ✅ Specification generated!\n`));
663
+ console.log(` ${brand.teal('📄 Saved to:')} ${filePath}`);
664
+
665
+ // Save to backend
666
+ const saveSpinner = villageSpinner('Saving spec to backend...').start();
667
+ try {
668
+ await saveBizReqsSpec(submission.submissionId, {
669
+ specContent,
670
+ format: 'markdown',
671
+ detailLevel,
672
+ });
673
+ saveSpinner.stop();
674
+ console.log(brand.teal(' ☁️ Saved to MyVillageOS backend'));
675
+ } catch (err) {
676
+ saveSpinner.fail(`Could not save to backend: ${err.response?.data?.error || err.message}`);
677
+ }
678
+
679
+ console.log('');
680
+ console.log(brand.teal(` Next: Run 'myvillage bizreqs status ${submission.submissionId}' to check status\n`));
681
+ } catch (err) {
682
+ genSpinner.fail(`Spec generation failed: ${err.message}`);
683
+ }
684
+ }
685
+
686
+ // ── bizreqs import ──────────────────────────────────────
687
+
688
+ export async function bizreqsImportCommand(options) {
689
+ if (!isAuthenticated()) {
690
+ console.log(chalk.red(' ✗ Authentication required. Run \'myvillage login\' first.'));
691
+ return;
692
+ }
693
+
694
+ if (!options.file && !options.url) {
695
+ console.log(chalk.red(' ✗ Please provide --file or --url'));
696
+ console.log(brand.teal(' Usage: myvillage bizreqs import --file ./requirements.md'));
697
+ console.log(brand.teal(' myvillage bizreqs import --url https://example.org\n'));
698
+ return;
699
+ }
700
+
701
+ let anthropic;
702
+ try {
703
+ anthropic = await getAnthropicProvider();
704
+ } catch (err) {
705
+ console.log(chalk.red(` ✗ ${err.message}`));
706
+ return;
707
+ }
708
+
709
+ const { generateText } = await import('ai');
710
+
711
+ console.log(brand.teal('\n 📄 Importing business requirements...\n'));
712
+
713
+ // Read content
714
+ let content = '';
715
+ let sourceName = '';
716
+
717
+ if (options.file) {
718
+ const filePath = resolve(options.file);
719
+ const ext = filePath.split('.').pop()?.toLowerCase();
720
+
721
+ if (!['txt', 'md'].includes(ext)) {
722
+ console.log(chalk.yellow(` ⚠ Only .txt and .md files are supported in v1. Got: .${ext}`));
723
+ return;
724
+ }
725
+
726
+ try {
727
+ content = readFileSync(filePath, 'utf-8');
728
+ sourceName = options.file;
729
+ console.log(brand.teal(` 📎 Reading: ${options.file} (${content.length} chars)`));
730
+ } catch (err) {
731
+ console.log(chalk.red(` ✗ Could not read file: ${err.message}`));
732
+ return;
733
+ }
734
+ }
735
+
736
+ if (options.url) {
737
+ try {
738
+ const { default: axios } = await import('axios');
739
+ const spinner = villageSpinner(`Fetching ${options.url}...`).start();
740
+ const response = await axios.get(options.url, { timeout: 15000 });
741
+ const text = response.data
742
+ .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
743
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
744
+ .replace(/<[^>]+>/g, ' ')
745
+ .replace(/\s+/g, ' ')
746
+ .trim()
747
+ .substring(0, 5000);
748
+ content += (content ? '\n\n---\n\n' : '') + `Content from ${options.url}:\n${text}`;
749
+ sourceName = sourceName ? `${sourceName} + ${options.url}` : options.url;
750
+ spinner.stop();
751
+ console.log(brand.teal(` 🌐 Scanned: ${options.url}`));
752
+ } catch (err) {
753
+ console.log(chalk.yellow(` ⚠ Could not fetch URL: ${err.message}`));
754
+ if (!content) return;
755
+ }
756
+ }
757
+
758
+ // Prompt for org/contact
759
+ let orgName = options.org;
760
+ let contactName = options.contact;
761
+
762
+ if (!orgName) {
763
+ const answer = await inquirer.prompt([{
764
+ type: 'input',
765
+ name: 'orgName',
766
+ message: 'Organization name (or press Enter to auto-detect):',
767
+ }]);
768
+ orgName = answer.orgName.trim() || null;
769
+ }
770
+
771
+ if (!contactName) {
772
+ const answer = await inquirer.prompt([{
773
+ type: 'input',
774
+ name: 'contactName',
775
+ message: 'Contact name (optional):',
776
+ }]);
777
+ contactName = answer.contactName.trim() || undefined;
778
+ }
779
+
780
+ // AI analysis
781
+ const analysisSpinner = villageSpinner('Analyzing content...').start();
782
+
783
+ try {
784
+ const analysisResult = await generateText({
785
+ model: anthropic('claude-sonnet-4-5-20250929'),
786
+ system: INTAKE_SYSTEM_PROMPT,
787
+ messages: [{
788
+ role: 'user',
789
+ content: `Analyze the following content from a potential client${orgName ? ` (${orgName})` : ''}. Extract:
790
+
791
+ 1. Organization name and profile (if not provided, infer from content)
792
+ 2. Their current situation
793
+ 3. Problems and challenges
794
+ 4. Any stated dreams or goals
795
+ 5. 2-3 follow-up questions for anything unclear
796
+
797
+ Source: ${sourceName}
798
+
799
+ Content:
800
+ ${content}
801
+
802
+ After your analysis, if you have enough information, call the generate_recommendation tool to create a solution recommendation. If you need more information first, ask your follow-up questions.`,
803
+ }],
804
+ tools: {
805
+ generate_recommendation: {
806
+ description: 'Generate a structured solution recommendation based on the intake conversation',
807
+ parameters: recommendationParams,
808
+ },
809
+ },
810
+ maxTokens: 2048,
811
+ });
812
+
813
+ analysisSpinner.stop();
814
+
815
+ // Display analysis text
816
+ if (analysisResult.text) {
817
+ console.log('\n' + formatAIResponse(analysisResult.text.trim()));
818
+ }
819
+
820
+ let recommendation = null;
821
+
822
+ // Check if recommendation was generated
823
+ if (analysisResult.toolCalls?.length > 0) {
824
+ for (const call of analysisResult.toolCalls) {
825
+ if (call.toolName === 'generate_recommendation') {
826
+ recommendation = call.args;
827
+ }
828
+ }
829
+ }
830
+
831
+ // If no recommendation yet, do follow-up Q&A
832
+ if (!recommendation) {
833
+ console.log('');
834
+ const messages = [
835
+ { role: 'user', content: `Content to analyze:\n${content}` },
836
+ { role: 'assistant', content: analysisResult.text || 'Analysis complete.' },
837
+ ];
838
+
839
+ // 2-3 follow-up exchanges
840
+ for (let i = 0; i < 3; i++) {
841
+ const answer = await askInput('Your response (/done to finish)');
842
+
843
+ if (!answer || answer.toLowerCase() === '/done' || answer.toLowerCase() === '/skip') break;
844
+
845
+ messages.push({ role: 'user', content: answer });
846
+
847
+ const followUp = await generateText({
848
+ model: anthropic('claude-sonnet-4-5-20250929'),
849
+ system: INTAKE_SYSTEM_PROMPT,
850
+ messages,
851
+ tools: {
852
+ generate_recommendation: {
853
+ description: 'Generate a structured solution recommendation based on the intake conversation',
854
+ parameters: recommendationParams,
855
+ },
856
+ },
857
+ maxTokens: 1024,
858
+ });
859
+
860
+ if (followUp.text) {
861
+ console.log('\n' + formatAIResponse(followUp.text.trim()));
862
+ messages.push({ role: 'assistant', content: followUp.text });
863
+ }
864
+
865
+ if (followUp.toolCalls?.length > 0) {
866
+ for (const call of followUp.toolCalls) {
867
+ if (call.toolName === 'generate_recommendation') {
868
+ recommendation = call.args;
869
+ }
870
+ }
871
+ if (recommendation) break;
872
+ }
873
+ }
874
+
875
+ // Force recommendation if still not generated
876
+ if (!recommendation) {
877
+ const forceSpinner = villageSpinner('Generating recommendation...').start();
878
+ messages.push({
879
+ role: 'user',
880
+ content: 'Based on everything discussed, please call generate_recommendation with your best recommendation.',
881
+ });
882
+
883
+ const forceResult = await generateText({
884
+ model: anthropic('claude-sonnet-4-5-20250929'),
885
+ system: INTAKE_SYSTEM_PROMPT,
886
+ messages,
887
+ tools: {
888
+ generate_recommendation: {
889
+ description: 'Generate a structured solution recommendation based on the intake conversation',
890
+ parameters: recommendationParams,
891
+ },
892
+ },
893
+ maxTokens: 2048,
894
+ });
895
+
896
+ forceSpinner.stop();
897
+
898
+ if (forceResult.toolCalls?.length > 0) {
899
+ for (const call of forceResult.toolCalls) {
900
+ if (call.toolName === 'generate_recommendation') {
901
+ recommendation = call.args;
902
+ }
903
+ }
904
+ }
905
+ }
906
+ }
907
+
908
+ if (recommendation) {
909
+ if (!orgName) orgName = recommendation.organizationName || 'Unknown Organization';
910
+ formatRecommendationBox({ ...recommendation, organizationName: orgName });
911
+
912
+ const { confirmed } = await inquirer.prompt([{
913
+ type: 'confirm',
914
+ name: 'confirmed',
915
+ message: 'Save this to the pipeline?',
916
+ default: true,
917
+ }]);
918
+
919
+ if (!confirmed) {
920
+ console.log(brand.teal('\n Import cancelled.\n'));
921
+ return;
922
+ }
923
+ } else {
924
+ console.log(chalk.yellow('\n ⚠ Could not generate a recommendation. Saving as-is.\n'));
925
+ if (!orgName) {
926
+ const answer = await inquirer.prompt([{
927
+ type: 'input',
928
+ name: 'orgName',
929
+ message: 'Organization name (required):',
930
+ validate: v => v.trim() ? true : 'Required',
931
+ }]);
932
+ orgName = answer.orgName.trim();
933
+ }
934
+ }
935
+
936
+ // Save to backend
937
+ const saveSpinner = villageSpinner('Saving submission...').start();
938
+
939
+ const payload = {
940
+ organizationName: orgName,
941
+ contactName,
942
+ intakeMethod: 'CLI_IMPORT',
943
+ conversationTranscript: [{ role: 'system', content: `Imported from: ${sourceName}` }, { role: 'user', content }],
944
+ extractedSituation: analysisResult.text || null,
945
+ };
946
+
947
+ if (recommendation) {
948
+ payload.solutionName = recommendation.solutionName;
949
+ payload.components = recommendation.components;
950
+ payload.estimatedTimeline = recommendation.estimatedTimeline;
951
+ payload.estimatedMVT = recommendation.estimatedMVT;
952
+ payload.overallComplexity = recommendation.overallComplexity;
953
+ }
954
+
955
+ const result = await createBizReqsSubmission(payload);
956
+ saveSpinner.stop();
957
+
958
+ const sub = result.submission;
959
+ console.log(brand.green(`\n ✅ Import complete! Submission ID: ${chalk.bold(sub.submissionId)}\n`));
960
+ console.log(brand.teal(' 📄 Run \'myvillage bizreqs spec ' + sub.submissionId + '\' to generate a full project specification\n'));
961
+ } catch (err) {
962
+ analysisSpinner.stop();
963
+ console.log(chalk.red(` ✗ Import failed: ${err.message}`));
964
+ }
965
+ }