wiggum-cli 0.7.3 → 0.7.5

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 (35) hide show
  1. package/dist/ai/conversation/conversation-manager.d.ts +31 -1
  2. package/dist/ai/conversation/conversation-manager.d.ts.map +1 -1
  3. package/dist/ai/conversation/conversation-manager.js +48 -3
  4. package/dist/ai/conversation/conversation-manager.js.map +1 -1
  5. package/dist/ai/conversation/index.d.ts +3 -2
  6. package/dist/ai/conversation/index.d.ts.map +1 -1
  7. package/dist/ai/conversation/index.js +1 -0
  8. package/dist/ai/conversation/index.js.map +1 -1
  9. package/dist/ai/conversation/interview-tools.d.ts +85 -0
  10. package/dist/ai/conversation/interview-tools.d.ts.map +1 -0
  11. package/dist/ai/conversation/interview-tools.js +255 -0
  12. package/dist/ai/conversation/interview-tools.js.map +1 -0
  13. package/dist/ai/conversation/spec-generator.d.ts +36 -0
  14. package/dist/ai/conversation/spec-generator.d.ts.map +1 -1
  15. package/dist/ai/conversation/spec-generator.js +219 -30
  16. package/dist/ai/conversation/spec-generator.js.map +1 -1
  17. package/dist/commands/init.d.ts.map +1 -1
  18. package/dist/commands/init.js +10 -20
  19. package/dist/commands/init.js.map +1 -1
  20. package/dist/utils/repl-prompts.d.ts +58 -0
  21. package/dist/utils/repl-prompts.d.ts.map +1 -0
  22. package/dist/utils/repl-prompts.js +231 -0
  23. package/dist/utils/repl-prompts.js.map +1 -0
  24. package/dist/utils/tui.d.ts +61 -0
  25. package/dist/utils/tui.d.ts.map +1 -0
  26. package/dist/utils/tui.js +214 -0
  27. package/dist/utils/tui.js.map +1 -0
  28. package/package.json +1 -1
  29. package/src/ai/conversation/conversation-manager.ts +66 -4
  30. package/src/ai/conversation/index.ts +7 -0
  31. package/src/ai/conversation/interview-tools.ts +293 -0
  32. package/src/ai/conversation/spec-generator.ts +287 -34
  33. package/src/commands/init.ts +10 -22
  34. package/src/utils/repl-prompts.ts +286 -0
  35. package/src/utils/tui.ts +262 -0
@@ -1,19 +1,42 @@
1
1
  /**
2
2
  * Spec Generator
3
3
  * AI-powered feature specification generator with interview flow
4
+ * Enhanced with codebase tools and Claude Code-like UX
4
5
  */
5
6
 
6
7
  import readline from 'node:readline';
7
8
  import pc from 'picocolors';
8
9
  import { ConversationManager } from './conversation-manager.js';
9
- import { fetchContent, isUrl, type FetchedContent } from './url-fetcher.js';
10
+ import { fetchContent } from './url-fetcher.js';
11
+ import { createInterviewTools } from './interview-tools.js';
12
+ import { createTavilySearchTool, canUseTavily } from '../tools/tavily.js';
13
+ import { createContext7Tools, canUseContext7 } from '../tools/context7.js';
10
14
  import type { AIProvider } from '../providers.js';
11
15
  import type { ScanResult } from '../../scanner/types.js';
16
+ import type { EnhancedScanResult, AIAnalysisResult } from '../enhancer.js';
12
17
  import { simpson } from '../../utils/colors.js';
18
+ import {
19
+ displayPhaseHeader,
20
+ displayToolUse,
21
+ displaySessionContext,
22
+ displayGarbledInputWarning,
23
+ type Phase,
24
+ } from '../../utils/tui.js';
13
25
 
14
26
  /** Maximum number of interview questions before auto-completing */
15
27
  const MAX_INTERVIEW_QUESTIONS = 10;
16
28
 
29
+ /**
30
+ * Session context from /init analysis
31
+ */
32
+ export interface SessionContext {
33
+ entryPoints?: string[];
34
+ keyDirectories?: Record<string, string>;
35
+ commands?: { build?: string; dev?: string; test?: string };
36
+ namingConventions?: string;
37
+ implementationGuidelines?: string[];
38
+ }
39
+
17
40
  /**
18
41
  * Spec generator options
19
42
  */
@@ -23,6 +46,12 @@ export interface SpecGeneratorOptions {
23
46
  provider: AIProvider;
24
47
  model: string;
25
48
  scanResult?: ScanResult;
49
+ /** Rich session context from /init */
50
+ sessionContext?: SessionContext;
51
+ /** Tavily API key for web search */
52
+ tavilyApiKey?: string;
53
+ /** Context7 API key for docs lookup */
54
+ context7ApiKey?: string;
26
55
  }
27
56
 
28
57
  /**
@@ -48,9 +77,10 @@ async function promptUser(prompt: string): Promise<string> {
48
77
  }
49
78
 
50
79
  /**
51
- * Display streaming text
80
+ * Display streaming text with AI prefix
52
81
  */
53
82
  async function displayStream(stream: AsyncIterable<string>): Promise<string> {
83
+ process.stdout.write(simpson.blue('AI: '));
54
84
  let fullText = '';
55
85
  for await (const chunk of stream) {
56
86
  process.stdout.write(chunk);
@@ -60,7 +90,39 @@ async function displayStream(stream: AsyncIterable<string>): Promise<string> {
60
90
  return fullText;
61
91
  }
62
92
 
63
- const SPEC_SYSTEM_PROMPT = `You are an expert product manager and technical writer helping to create detailed feature specifications.
93
+ /**
94
+ * Check if input looks garbled (common paste issues)
95
+ */
96
+ function looksGarbled(input: string): boolean {
97
+ const trimmed = input.trim();
98
+
99
+ // Too short to be meaningful
100
+ if (trimmed.length < 3) return false;
101
+
102
+ // Common patterns from truncated pastes
103
+ const garbledPatterns = [
104
+ /^[a-z]+';$/i, // Just "js';" or "ts';"
105
+ /^[,\.\;\{\}\[\]]+$/, // Just punctuation
106
+ /^\s*['"`]\s*$/, // Just quotes
107
+ /^[a-z]{1,3}$/i, // Just 1-3 letters
108
+ /^\d+$/, // Just numbers
109
+ /^[^\w\s]+$/, // Only special characters
110
+ ];
111
+
112
+ return garbledPatterns.some(p => p.test(trimmed));
113
+ }
114
+
115
+ /**
116
+ * Build enhanced system prompt with project context and tool awareness
117
+ */
118
+ function buildSystemPrompt(
119
+ sessionContext?: SessionContext,
120
+ hasTools?: { codebase: boolean; tavily: boolean; context7: boolean }
121
+ ): string {
122
+ const parts: string[] = [];
123
+
124
+ // Base prompt
125
+ parts.push(`You are an expert product manager and technical writer helping to create detailed feature specifications.
64
126
 
65
127
  Your role is to:
66
128
  1. Understand the user's feature goals through targeted questions
@@ -71,8 +133,82 @@ When interviewing:
71
133
  - Ask one focused question at a time
72
134
  - Acknowledge answers before asking the next question
73
135
  - Stop asking when you have enough information (usually 3-5 questions)
74
- - Say "I have enough information to generate the spec" when ready
136
+ - Say "I have enough information to generate the spec" when ready`);
137
+
138
+ // Add tool awareness
139
+ if (hasTools) {
140
+ const toolList: string[] = [];
141
+ if (hasTools.codebase) {
142
+ toolList.push('- read_file: Read project files to understand existing code');
143
+ toolList.push('- search_codebase: Search for patterns, functions, or imports');
144
+ toolList.push('- list_directory: Explore project structure');
145
+ }
146
+ if (hasTools.tavily) {
147
+ toolList.push('- tavily_search: Search the web for best practices and documentation');
148
+ }
149
+ if (hasTools.context7) {
150
+ toolList.push('- resolveLibraryId/queryDocs: Look up library documentation');
151
+ }
152
+
153
+ if (toolList.length > 0) {
154
+ parts.push(`
155
+ ## Available Tools
156
+ You have access to the following tools to help understand the project and gather information:
157
+ ${toolList.join('\n')}
158
+
159
+ USE THESE TOOLS PROACTIVELY:
160
+ - When the user describes a feature, read relevant files to understand existing patterns
161
+ - When unsure about implementation, search the codebase for similar code
162
+ - When discussing best practices, search the web for current recommendations
163
+ - Don't ask the user to paste code - read it yourself`);
164
+ }
165
+ }
166
+
167
+ // Add project context from /init
168
+ if (sessionContext) {
169
+ const contextParts: string[] = ['## Project Context (from analysis)'];
170
+
171
+ if (sessionContext.entryPoints && sessionContext.entryPoints.length > 0) {
172
+ contextParts.push(`\nEntry Points:\n${sessionContext.entryPoints.map(e => `- ${e}`).join('\n')}`);
173
+ }
174
+
175
+ if (sessionContext.keyDirectories && Object.keys(sessionContext.keyDirectories).length > 0) {
176
+ contextParts.push(`\nKey Directories:`);
177
+ for (const [dir, purpose] of Object.entries(sessionContext.keyDirectories)) {
178
+ contextParts.push(`- ${dir}: ${purpose}`);
179
+ }
180
+ }
75
181
 
182
+ if (sessionContext.commands) {
183
+ const cmds = sessionContext.commands;
184
+ const cmdList = Object.entries(cmds).filter(([_, v]) => v);
185
+ if (cmdList.length > 0) {
186
+ contextParts.push(`\nCommands:`);
187
+ for (const [name, cmd] of cmdList) {
188
+ contextParts.push(`- ${name}: ${cmd}`);
189
+ }
190
+ }
191
+ }
192
+
193
+ if (sessionContext.namingConventions) {
194
+ contextParts.push(`\nNaming Conventions: ${sessionContext.namingConventions}`);
195
+ }
196
+
197
+ if (sessionContext.implementationGuidelines && sessionContext.implementationGuidelines.length > 0) {
198
+ contextParts.push(`\nImplementation Guidelines:`);
199
+ for (const guideline of sessionContext.implementationGuidelines) {
200
+ contextParts.push(`- ${guideline}`);
201
+ }
202
+ }
203
+
204
+ if (contextParts.length > 1) {
205
+ parts.push(contextParts.join('\n'));
206
+ }
207
+ }
208
+
209
+ // Spec format
210
+ parts.push(`
211
+ ## Spec Format
76
212
  When generating the spec, use this format:
77
213
 
78
214
  # [Feature Name] Feature Specification
@@ -104,8 +240,30 @@ When generating the spec, use this format:
104
240
  - [ ] Specific, testable conditions
105
241
 
106
242
  ## Out of Scope
107
- - Items explicitly not included
108
- `;
243
+ - Items explicitly not included`);
244
+
245
+ return parts.join('\n\n');
246
+ }
247
+
248
+ /**
249
+ * Extract session context from EnhancedScanResult
250
+ */
251
+ function extractSessionContext(scanResult: ScanResult): SessionContext | undefined {
252
+ // Check if this is an EnhancedScanResult with aiAnalysis
253
+ const enhanced = scanResult as EnhancedScanResult;
254
+ if (!enhanced.aiAnalysis) {
255
+ return undefined;
256
+ }
257
+
258
+ const ai = enhanced.aiAnalysis;
259
+ return {
260
+ entryPoints: ai.projectContext?.entryPoints,
261
+ keyDirectories: ai.projectContext?.keyDirectories,
262
+ commands: ai.commands,
263
+ namingConventions: ai.projectContext?.namingConventions,
264
+ implementationGuidelines: ai.implementationGuidelines,
265
+ };
266
+ }
109
267
 
110
268
  /**
111
269
  * AI-powered spec generator with interview flow
@@ -116,15 +274,61 @@ export class SpecGenerator {
116
274
  private readonly featureName: string;
117
275
  private readonly projectRoot: string;
118
276
  private generatedSpec: string = '';
277
+ private questionCount: number = 0;
278
+ private readonly hasTools: { codebase: boolean; tavily: boolean; context7: boolean };
279
+ private readonly sessionContext?: SessionContext;
119
280
 
120
281
  constructor(options: SpecGeneratorOptions) {
121
282
  this.featureName = options.featureName;
122
283
  this.projectRoot = options.projectRoot;
123
284
 
285
+ // Get API keys from options or environment
286
+ const tavilyApiKey = options.tavilyApiKey || process.env.TAVILY_API_KEY;
287
+ const context7ApiKey = options.context7ApiKey || process.env.CONTEXT7_API_KEY;
288
+
289
+ // Track which tools are available
290
+ this.hasTools = {
291
+ codebase: true, // Always available
292
+ tavily: canUseTavily(tavilyApiKey),
293
+ context7: canUseContext7(context7ApiKey),
294
+ };
295
+
296
+ // Build tools object
297
+ const tools: Record<string, unknown> = {};
298
+
299
+ // Add codebase tools
300
+ const codebaseTools = createInterviewTools(options.projectRoot);
301
+ Object.assign(tools, codebaseTools);
302
+
303
+ // Add Tavily search if available
304
+ if (this.hasTools.tavily && tavilyApiKey) {
305
+ tools.tavily_search = createTavilySearchTool(tavilyApiKey);
306
+ }
307
+
308
+ // Add Context7 tools if available
309
+ if (this.hasTools.context7 && context7ApiKey) {
310
+ const context7Tools = createContext7Tools(context7ApiKey);
311
+ Object.assign(tools, context7Tools);
312
+ }
313
+
314
+ // Extract session context from scan result or use provided
315
+ this.sessionContext = options.sessionContext || (
316
+ options.scanResult ? extractSessionContext(options.scanResult) : undefined
317
+ );
318
+
319
+ // Build enhanced system prompt
320
+ const systemPrompt = buildSystemPrompt(this.sessionContext, this.hasTools);
321
+
322
+ // Create conversation manager with tools
124
323
  this.conversation = new ConversationManager({
125
324
  provider: options.provider,
126
325
  model: options.model,
127
- systemPrompt: SPEC_SYSTEM_PROMPT,
326
+ systemPrompt,
327
+ tools: tools as Record<string, never>,
328
+ onToolUse: (toolName, args) => {
329
+ displayToolUse(toolName, args);
330
+ },
331
+ maxToolSteps: 8,
128
332
  });
129
333
 
130
334
  if (options.scanResult) {
@@ -132,11 +336,37 @@ export class SpecGenerator {
132
336
  }
133
337
  }
134
338
 
339
+ /**
340
+ * Display the current phase header
341
+ */
342
+ private displayHeader(): void {
343
+ displayPhaseHeader(this.featureName, this.phase, {
344
+ current: this.questionCount,
345
+ max: MAX_INTERVIEW_QUESTIONS,
346
+ });
347
+ }
348
+
349
+ /**
350
+ * Display session context at start
351
+ */
352
+ private displayContext(): void {
353
+ // Build project name from package.json or directory
354
+ const projectName = this.projectRoot.split('/').pop() || 'Project';
355
+
356
+ displaySessionContext({
357
+ projectName,
358
+ entryPoints: this.sessionContext?.entryPoints,
359
+ tools: this.hasTools,
360
+ });
361
+ }
362
+
135
363
  /**
136
364
  * Phase 1: Gather context from URLs/files
137
365
  */
138
366
  private async gatherContext(): Promise<void> {
139
- console.log('');
367
+ this.displayHeader();
368
+ this.displayContext();
369
+
140
370
  console.log(simpson.yellow('Context Gathering'));
141
371
  console.log(pc.dim('Share any reference URLs or files (press Enter to skip):'));
142
372
  console.log('');
@@ -148,7 +378,7 @@ export class SpecGenerator {
148
378
  break;
149
379
  }
150
380
 
151
- process.stdout.write(pc.dim('Fetching... '));
381
+ process.stdout.write(pc.dim(' Fetching... '));
152
382
  const result = await fetchContent(input, this.projectRoot);
153
383
 
154
384
  if (result.error) {
@@ -166,7 +396,8 @@ export class SpecGenerator {
166
396
  * Phase 2: Discuss goals
167
397
  */
168
398
  private async discussGoals(): Promise<void> {
169
- console.log('');
399
+ this.displayHeader();
400
+
170
401
  console.log(simpson.yellow('Feature Goals'));
171
402
  console.log(pc.dim('Describe what you want to build:'));
172
403
  console.log('');
@@ -188,9 +419,10 @@ export class SpecGenerator {
188
419
 
189
420
  console.log('');
190
421
  const response = await this.conversation.chat(
191
- `The user wants to create a feature called "${this.featureName}". Acknowledge their goals and ask your first clarifying question to better understand the requirements.`
422
+ `The user wants to create a feature called "${this.featureName}". First, use your tools to explore the codebase and understand the existing structure. Then acknowledge their goals and ask your first clarifying question.`
192
423
  );
193
424
 
425
+ console.log('');
194
426
  console.log(simpson.blue('AI:'), response);
195
427
  console.log('');
196
428
 
@@ -201,19 +433,21 @@ export class SpecGenerator {
201
433
  * Phase 3: Conduct interview
202
434
  */
203
435
  private async conductInterview(): Promise<void> {
436
+ this.displayHeader();
437
+
204
438
  console.log(simpson.yellow('Interview'));
205
439
  console.log(pc.dim('Answer the questions (type "done" when ready to generate spec):'));
206
440
  console.log('');
207
441
 
208
- let questionCount = 0;
209
-
210
- while (questionCount < MAX_INTERVIEW_QUESTIONS) {
442
+ while (this.questionCount < MAX_INTERVIEW_QUESTIONS) {
211
443
  const answer = await promptUser(`${simpson.brown('you>')} `);
212
444
 
445
+ // Handle exit commands
213
446
  if (answer.toLowerCase() === 'done' || answer.toLowerCase() === 'skip') {
214
447
  break;
215
448
  }
216
449
 
450
+ // Handle empty input
217
451
  if (!answer) {
218
452
  console.log(pc.dim('(Press Enter again to skip, or type your answer)'));
219
453
  const confirm = await promptUser(`${simpson.brown('you>')} `);
@@ -221,38 +455,57 @@ export class SpecGenerator {
221
455
  break;
222
456
  }
223
457
  // Process the confirmation as the answer
224
- console.log('');
225
- const response = await this.conversation.chat(confirm);
226
- console.log(simpson.blue('AI:'), response);
227
- console.log('');
228
- } else {
229
- console.log('');
230
- const response = await this.conversation.chat(answer);
231
- console.log(simpson.blue('AI:'), response);
232
- console.log('');
458
+ await this.processAnswer(confirm);
459
+ continue;
460
+ }
233
461
 
234
- // Check if AI indicates it has enough information
235
- if (
236
- response.toLowerCase().includes('enough information') ||
237
- response.toLowerCase().includes('ready to generate') ||
238
- response.toLowerCase().includes("let me generate") ||
239
- response.toLowerCase().includes("i'll now generate")
240
- ) {
241
- break;
242
- }
462
+ // Check for garbled input
463
+ if (looksGarbled(answer)) {
464
+ displayGarbledInputWarning(answer);
465
+ continue;
243
466
  }
244
467
 
245
- questionCount++;
468
+ // Process normal answer
469
+ const shouldBreak = await this.processAnswer(answer);
470
+ if (shouldBreak) break;
246
471
  }
247
472
 
248
473
  this.phase = 'generation';
249
474
  }
250
475
 
476
+ /**
477
+ * Process a user answer and get AI response
478
+ */
479
+ private async processAnswer(answer: string): Promise<boolean> {
480
+ console.log('');
481
+ const response = await this.conversation.chat(answer);
482
+ console.log('');
483
+ console.log(simpson.blue('AI:'), response);
484
+ console.log('');
485
+
486
+ this.questionCount++;
487
+
488
+ // Check if AI indicates it has enough information
489
+ const lowerResponse = response.toLowerCase();
490
+ if (
491
+ lowerResponse.includes('enough information') ||
492
+ lowerResponse.includes('ready to generate') ||
493
+ lowerResponse.includes("let me generate") ||
494
+ lowerResponse.includes("i'll now generate") ||
495
+ lowerResponse.includes("i will now generate")
496
+ ) {
497
+ return true;
498
+ }
499
+
500
+ return false;
501
+ }
502
+
251
503
  /**
252
504
  * Phase 4: Generate spec
253
505
  */
254
506
  private async generateSpec(): Promise<string> {
255
- console.log('');
507
+ this.displayHeader();
508
+
256
509
  console.log(simpson.yellow('Generating Specification...'));
257
510
  console.log('');
258
511
 
@@ -18,7 +18,7 @@ import {
18
18
  getAvailableProvider,
19
19
  AVAILABLE_MODELS,
20
20
  } from '../ai/providers.js';
21
- import * as prompts from '@clack/prompts';
21
+ import * as replPrompts from '../utils/repl-prompts.js';
22
22
  import fs from 'fs';
23
23
  import path from 'path';
24
24
  import {
@@ -33,23 +33,8 @@ import { createShimmerSpinner, type ShimmerSpinner } from '../utils/spinner.js';
33
33
  import { startRepl, createSessionState } from '../repl/index.js';
34
34
  import { loadConfigWithDefaults } from '../utils/config.js';
35
35
 
36
- /**
37
- * Secure password input using @clack/prompts
38
- * Works correctly in both CLI and REPL contexts
39
- */
40
- async function securePasswordInput(message: string): Promise<string | null> {
41
- const result = await prompts.password({
42
- message,
43
- mask: '*',
44
- });
45
-
46
- if (prompts.isCancel(result)) {
47
- return null;
48
- }
49
-
50
- // Return trimmed input, filtering any control characters
51
- return (result as string).trim().replace(/[\x00-\x1F\x7F]/g, '') || null;
52
- }
36
+ // Use REPL-friendly prompts for interactive input
37
+ const prompts = replPrompts;
53
38
 
54
39
  export interface InitOptions {
55
40
  provider?: AIProvider;
@@ -154,8 +139,10 @@ async function collectApiKeys(
154
139
  provider = providerChoice as AIProvider;
155
140
  const envVar = getApiKeyEnvVar(provider);
156
141
 
157
- // Get API key with fixed-length mask (doesn't reveal key length)
158
- const apiKeyInput = await securePasswordInput(`Enter your ${envVar}:`);
142
+ // Get API key with masked input
143
+ const apiKeyInput = await prompts.password({
144
+ message: `Enter your ${envVar}:`,
145
+ });
159
146
 
160
147
  if (!apiKeyInput) {
161
148
  logger.error('API key is required to use Ralph.');
@@ -382,12 +369,13 @@ export async function runInitWorkflow(
382
369
  logger.success('Wiggum initialized successfully!');
383
370
 
384
371
  // Load config and return result
372
+ // Use enhancedResult to preserve aiAnalysis for session context
385
373
  const config = await loadConfigWithDefaults(projectRoot);
386
374
  return {
387
375
  success: true,
388
376
  provider: apiKeys.provider,
389
377
  model: apiKeys.model,
390
- scanResult,
378
+ scanResult: enhancedResult,
391
379
  config,
392
380
  };
393
381
  } else {
@@ -397,7 +385,7 @@ export async function runInitWorkflow(
397
385
  success: true, // Still return success to continue
398
386
  provider: apiKeys.provider,
399
387
  model: apiKeys.model,
400
- scanResult,
388
+ scanResult: enhancedResult,
401
389
  config,
402
390
  };
403
391
  }