@vibedx/vibekit 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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +368 -0
  3. package/assets/config.yml +35 -0
  4. package/assets/default.md +47 -0
  5. package/assets/instructions/README.md +46 -0
  6. package/assets/instructions/claude.md +83 -0
  7. package/assets/instructions/codex.md +19 -0
  8. package/index.js +106 -0
  9. package/package.json +90 -0
  10. package/src/commands/close/index.js +66 -0
  11. package/src/commands/close/index.test.js +235 -0
  12. package/src/commands/get-started/index.js +138 -0
  13. package/src/commands/get-started/index.test.js +246 -0
  14. package/src/commands/init/index.js +51 -0
  15. package/src/commands/init/index.test.js +159 -0
  16. package/src/commands/link/index.js +395 -0
  17. package/src/commands/link/index.test.js +28 -0
  18. package/src/commands/lint/index.js +657 -0
  19. package/src/commands/lint/index.test.js +569 -0
  20. package/src/commands/list/index.js +131 -0
  21. package/src/commands/list/index.test.js +153 -0
  22. package/src/commands/new/index.js +305 -0
  23. package/src/commands/new/index.test.js +256 -0
  24. package/src/commands/refine/index.js +741 -0
  25. package/src/commands/refine/index.test.js +28 -0
  26. package/src/commands/review/index.js +957 -0
  27. package/src/commands/review/index.test.js +193 -0
  28. package/src/commands/start/index.js +180 -0
  29. package/src/commands/start/index.test.js +88 -0
  30. package/src/commands/unlink/index.js +123 -0
  31. package/src/commands/unlink/index.test.js +22 -0
  32. package/src/utils/arrow-select.js +233 -0
  33. package/src/utils/cli.js +489 -0
  34. package/src/utils/cli.test.js +9 -0
  35. package/src/utils/git.js +146 -0
  36. package/src/utils/git.test.js +330 -0
  37. package/src/utils/index.js +193 -0
  38. package/src/utils/index.test.js +375 -0
  39. package/src/utils/prompts.js +47 -0
  40. package/src/utils/prompts.test.js +165 -0
  41. package/src/utils/test-helpers.js +492 -0
  42. package/src/utils/ticket.js +423 -0
  43. package/src/utils/ticket.test.js +190 -0
@@ -0,0 +1,957 @@
1
+ import { execSync } from 'child_process';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import yaml from 'js-yaml';
5
+ import chalk from 'chalk';
6
+ import { getTicketsDir, getConfig } from '../../utils/index.js';
7
+ import { isGitRepository, getCurrentBranch } from '../../utils/git.js';
8
+
9
+ /**
10
+ * Extract ticket ID from branch name
11
+ * @returns {string|null} Ticket ID or null if not found
12
+ */
13
+ function extractTicketFromBranch() {
14
+ try {
15
+ const branch = getCurrentBranch();
16
+ if (!branch) return null;
17
+
18
+ const match = branch.match(/TKT-\d{3}/);
19
+ return match ? match[0] : null;
20
+ } catch (error) {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Normalize ticket ID format with input sanitization
27
+ * @param {string} input - Input ticket ID or number
28
+ * @returns {string|null} Normalized ticket ID (TKT-XXX format) or null if invalid
29
+ */
30
+ function normalizeTicketId(input) {
31
+ // Handle null, undefined, or non-string inputs
32
+ if (!input || typeof input !== 'string') {
33
+ return null;
34
+ }
35
+
36
+ // Sanitize input: trim whitespace and convert to uppercase
37
+ const sanitized = input.trim().toUpperCase();
38
+
39
+ // Handle empty string after trimming
40
+ if (!sanitized) {
41
+ return null;
42
+ }
43
+
44
+ // Validate maximum length to prevent potential issues
45
+ if (sanitized.length > 20) {
46
+ return null;
47
+ }
48
+
49
+ // If it's just a number, convert to TKT-XXX format
50
+ if (/^\d+$/.test(sanitized)) {
51
+ const num = parseInt(sanitized, 10);
52
+
53
+ // Validate reasonable range (1-999)
54
+ if (num < 1 || num > 999) {
55
+ return null;
56
+ }
57
+
58
+ const paddedNumber = sanitized.padStart(3, '0');
59
+ return `TKT-${paddedNumber}`;
60
+ }
61
+
62
+ // If it's already in TKT-XXX format, validate and return
63
+ if (/^TKT-\d{3}$/.test(sanitized)) {
64
+ const num = parseInt(sanitized.substring(4), 10);
65
+
66
+ // Validate reasonable range (1-999)
67
+ if (num < 1 || num > 999) {
68
+ return null;
69
+ }
70
+
71
+ return sanitized;
72
+ }
73
+
74
+ // Handle partial formats like "TKT001" or "TKT-1"
75
+ if (/^TKT\d{1,3}$/.test(sanitized)) {
76
+ const numPart = sanitized.substring(3);
77
+ const num = parseInt(numPart, 10);
78
+
79
+ if (num < 1 || num > 999) {
80
+ return null;
81
+ }
82
+
83
+ const paddedNumber = numPart.padStart(3, '0');
84
+ return `TKT-${paddedNumber}`;
85
+ }
86
+
87
+ if (/^TKT-\d{1,2}$/.test(sanitized)) {
88
+ const numPart = sanitized.substring(4);
89
+ const num = parseInt(numPart, 10);
90
+
91
+ if (num < 1 || num > 999) {
92
+ return null;
93
+ }
94
+
95
+ const paddedNumber = numPart.padStart(3, '0');
96
+ return `TKT-${paddedNumber}`;
97
+ }
98
+
99
+ // Invalid format
100
+ return null;
101
+ }
102
+
103
+ /**
104
+ * Validate ticket ID format and existence
105
+ * @param {string} ticketId - The ticket ID to validate
106
+ * @returns {Object} Validation result with isValid flag and error message
107
+ */
108
+ function validateTicketId(ticketId) {
109
+ if (!ticketId) {
110
+ return { isValid: false, error: 'Ticket ID is required' };
111
+ }
112
+
113
+ const normalizedId = normalizeTicketId(ticketId);
114
+ if (!normalizedId) {
115
+ return { isValid: false, error: `Invalid ticket ID format: ${ticketId}. Expected format: TKT-XXX or just the number (e.g., TKT-001 or 1)` };
116
+ }
117
+
118
+ const ticketsDir = getTicketsDir();
119
+ const ticketFiles = fs.readdirSync(ticketsDir)
120
+ .filter(file => file.endsWith('.md') && file.startsWith(normalizedId));
121
+
122
+ if (ticketFiles.length === 0) {
123
+ return { isValid: false, error: `Ticket not found: ${normalizedId}` };
124
+ }
125
+
126
+ return { isValid: true, ticketFile: path.join(ticketsDir, ticketFiles[0]), ticketId: normalizedId };
127
+ }
128
+
129
+ /**
130
+ * Parse ticket content and extract requirements
131
+ * @param {string} filePath - Path to the ticket file
132
+ * @returns {Object} Parsed ticket data
133
+ */
134
+ function parseTicketRequirements(filePath) {
135
+ try {
136
+ const content = fs.readFileSync(filePath, 'utf-8');
137
+
138
+ if (!content.startsWith('---')) {
139
+ throw new Error('Invalid ticket format: missing frontmatter');
140
+ }
141
+
142
+ const parts = content.split('---');
143
+ if (parts.length < 3) {
144
+ throw new Error('Invalid ticket format: malformed frontmatter');
145
+ }
146
+
147
+ const frontmatter = yaml.load(parts[1]);
148
+ const ticketContent = parts.slice(2).join('---');
149
+
150
+ // Extract sections
151
+ const sections = {};
152
+ const sectionRegex = /^##\s+(.+?)$([\s\S]*?)(?=^##\s+|$)/gm;
153
+ let match;
154
+
155
+ while ((match = sectionRegex.exec(ticketContent)) !== null) {
156
+ const sectionName = match[1].trim();
157
+ const sectionContent = match[2].trim();
158
+ sections[sectionName] = sectionContent;
159
+ }
160
+
161
+ return {
162
+ metadata: {
163
+ id: frontmatter.id,
164
+ title: frontmatter.title,
165
+ status: frontmatter.status,
166
+ priority: frontmatter.priority
167
+ },
168
+ sections
169
+ };
170
+ } catch (error) {
171
+ throw new Error(`Failed to parse ticket: ${error.message}`);
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Get staged changes from git
177
+ * @returns {string} Staged changes diff
178
+ */
179
+ function getStagedChanges() {
180
+ try {
181
+ return execSync('git diff --staged', { encoding: 'utf-8' });
182
+ } catch (error) {
183
+ throw new Error(`Failed to get staged changes: ${error.message}`);
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Get list of staged files
189
+ * @returns {string[]} Array of staged file paths
190
+ */
191
+ function getStagedFiles() {
192
+ try {
193
+ const output = execSync('git diff --staged --name-only', { encoding: 'utf-8' });
194
+ return output.trim().split('\n').filter(file => file.length > 0);
195
+ } catch (error) {
196
+ return [];
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Create ticket information section for AI prompt
202
+ * @param {Object} metadata - Ticket metadata
203
+ * @returns {string} Formatted ticket information
204
+ */
205
+ function createTicketInfoSection(metadata) {
206
+ return `TICKET INFORMATION:
207
+ ID: ${metadata.id}
208
+ Title: ${metadata.title}
209
+ Status: ${metadata.status}
210
+ Priority: ${metadata.priority}`;
211
+ }
212
+
213
+ /**
214
+ * Create requirements section for AI prompt
215
+ * @param {Object} sections - Ticket sections
216
+ * @returns {string} Formatted requirements section
217
+ */
218
+ function createRequirementsSection(sections) {
219
+ return `REQUIREMENTS:
220
+ ${sections['Description'] || 'No description provided'}
221
+
222
+ ACCEPTANCE CRITERIA:
223
+ ${sections['Acceptance Criteria'] || 'No acceptance criteria provided'}
224
+
225
+ CODE QUALITY REQUIREMENTS:
226
+ ${sections['Code Quality'] || 'No code quality requirements specified'}
227
+
228
+ IMPLEMENTATION NOTES:
229
+ ${sections['Implementation Notes'] || 'No implementation notes provided'}
230
+
231
+ TESTING REQUIREMENTS:
232
+ ${sections['Testing & Test Cases'] || 'No testing requirements specified'}`;
233
+ }
234
+
235
+ /**
236
+ * Create code changes section for AI prompt
237
+ * @param {string[]} stagedFiles - List of staged files
238
+ * @param {string} stagedChanges - Git diff of staged changes
239
+ * @returns {string} Formatted code changes section
240
+ */
241
+ function createCodeChangesSection(stagedFiles, stagedChanges) {
242
+ return `STAGED FILES:
243
+ ${stagedFiles.map(file => `- ${file}`).join('\n')}
244
+
245
+ STAGED CHANGES:
246
+ \`\`\`diff
247
+ ${stagedChanges}
248
+ \`\`\``;
249
+ }
250
+
251
+ /**
252
+ * Create analysis instructions for AI prompt
253
+ * @returns {string} Formatted analysis instructions
254
+ */
255
+ function createAnalysisInstructions() {
256
+ return `Please analyze and provide SPECIFIC feedback with:
257
+ 1. Overall completion percentage (0-100%)
258
+ 2. Detailed breakdown referencing specific functions, files, or line ranges where possible
259
+ 3. Code quality assessment with specific examples
260
+ 4. Any issues with exact locations (file:function or file:line when possible)
261
+ 5. Specific, actionable recommendations
262
+
263
+ BE SPECIFIC: Reference actual function names, file paths, and specific code patterns from the diff.
264
+ For issues and recommendations, provide concrete examples like "In src/commands/review/index.js:validateTicketId(), consider..." or "Lines 45-60 in file.js should..."`;
265
+ }
266
+
267
+ /**
268
+ * Create JSON response format for AI prompt
269
+ * @returns {string} Formatted JSON schema
270
+ */
271
+ function createResponseFormat() {
272
+ return `Format your response as JSON:
273
+ {
274
+ "completionPercentage": 85,
275
+ "status": "good|warning|poor",
276
+ "summary": "Brief overall assessment",
277
+ "completed": [
278
+ "Specific completed requirements with file/function references"
279
+ ],
280
+ "missing": [
281
+ "Specific missing requirements with suggested locations"
282
+ ],
283
+ "issues": [
284
+ "Specific code quality issues with file:function or file:line references"
285
+ ],
286
+ "recommendations": [
287
+ "Specific actionable recommendations with exact locations"
288
+ ]
289
+ }`;
290
+ }
291
+
292
+ /**
293
+ * Create AI review prompt
294
+ * @param {Object} ticket - Parsed ticket data
295
+ * @param {string} stagedChanges - Git diff of staged changes
296
+ * @param {string[]} stagedFiles - List of staged files
297
+ * @returns {string} AI prompt for review
298
+ */
299
+ function createReviewPrompt(ticket, stagedChanges, stagedFiles) {
300
+ const sections = [
301
+ 'Please review the following code changes against the ticket requirements and provide a detailed, specific analysis.',
302
+ '',
303
+ createTicketInfoSection(ticket.metadata),
304
+ '',
305
+ createRequirementsSection(ticket.sections),
306
+ '',
307
+ createCodeChangesSection(stagedFiles, stagedChanges),
308
+ '',
309
+ createAnalysisInstructions(),
310
+ '',
311
+ createResponseFormat()
312
+ ];
313
+
314
+ return sections.join('\n');
315
+ }
316
+
317
+ /**
318
+ * Call Claude Code CLI for code review with timeout handling
319
+ * @param {string} prompt - Review prompt
320
+ * @param {number} timeoutMs - Timeout in milliseconds (default: 60000)
321
+ * @returns {Promise<Object>} AI review response
322
+ */
323
+ async function callClaudeForReview(prompt, timeoutMs = 60000) {
324
+ console.log(chalk.yellow('šŸ¤– Analyzing changes with Claude...'));
325
+
326
+ try {
327
+ // Create a timeout promise
328
+ const timeoutPromise = new Promise((_, reject) => {
329
+ const timeoutId = setTimeout(() => {
330
+ reject(new Error(`Claude analysis timed out after ${timeoutMs}ms`));
331
+ }, timeoutMs);
332
+
333
+ return timeoutId;
334
+ });
335
+
336
+ // Create the actual Claude processing promise
337
+ const claudeProcessingPromise = new Promise(async (resolve, reject) => {
338
+ try {
339
+ // Call Claude Code CLI with --print flag for non-interactive output
340
+ const claudeResponse = execSync(
341
+ 'claude --print',
342
+ {
343
+ input: prompt,
344
+ encoding: 'utf-8',
345
+ timeout: timeoutMs - 5000, // Leave 5s buffer for cleanup
346
+ maxBuffer: 1024 * 1024 * 5 // 5MB buffer
347
+ }
348
+ );
349
+
350
+ // Parse Claude's response as JSON
351
+ let parsedResponse;
352
+ try {
353
+ // Try to extract JSON from Claude's response
354
+ const jsonMatch = claudeResponse.match(/\{[\s\S]*\}/);
355
+ if (jsonMatch) {
356
+ parsedResponse = JSON.parse(jsonMatch[0]);
357
+ } else {
358
+ // Fallback: create structured response from text
359
+ parsedResponse = parseTextResponse(claudeResponse);
360
+ }
361
+ } catch (parseError) {
362
+ // If JSON parsing fails, create a structured response
363
+ parsedResponse = parseTextResponse(claudeResponse);
364
+ }
365
+
366
+ // Validate response structure
367
+ const validatedResponse = validateClaudeResponse(parsedResponse);
368
+ resolve(validatedResponse);
369
+
370
+ } catch (error) {
371
+ if (error.code === 'ENOENT') {
372
+ reject(new Error('Claude CLI not found. Please ensure Claude Code is installed and accessible.'));
373
+ } else if (error.signal === 'SIGTERM') {
374
+ reject(new Error('Claude analysis was terminated due to timeout'));
375
+ } else {
376
+ reject(new Error(`Claude analysis failed: ${error.message}`));
377
+ }
378
+ }
379
+ });
380
+
381
+ // Race between timeout and Claude processing
382
+ const result = await Promise.race([claudeProcessingPromise, timeoutPromise]);
383
+ return result;
384
+
385
+ } catch (error) {
386
+ // Handle timeout and other errors gracefully
387
+ if (error.message.includes('timed out') || error.message.includes('timeout')) {
388
+ console.error(chalk.yellow('āš ļø Claude analysis timed out. Manual review recommended.'));
389
+ throw new Error('Claude analysis timed out - please review manually or increase timeout');
390
+ }
391
+
392
+ if (error.message.includes('not found')) {
393
+ console.error(chalk.red('āŒ Claude CLI not available.'));
394
+ throw new Error('Claude CLI not found - please ensure Claude Code is installed');
395
+ }
396
+
397
+ // Re-throw other errors
398
+ throw error;
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Parse Claude's text response into structured format
404
+ * @param {string} textResponse - Raw text response from Claude
405
+ * @returns {Object} Structured response object
406
+ */
407
+ function parseTextResponse(textResponse) {
408
+ // Extract completion percentage
409
+ const percentageMatch = textResponse.match(/(\d+)%/);
410
+ const completionPercentage = percentageMatch ? parseInt(percentageMatch[1], 10) : 0;
411
+
412
+ // Determine status based on percentage
413
+ let status = 'poor';
414
+ if (completionPercentage >= 80) status = 'good';
415
+ else if (completionPercentage >= 60) status = 'warning';
416
+
417
+ // Extract sections using common patterns
418
+ const extractSection = (sectionName) => {
419
+ const patterns = [
420
+ new RegExp(`${sectionName}:?\\s*([\\s\\S]*?)(?=\\n\\n|\\n[A-Z][^:]*:|$)`, 'i'),
421
+ new RegExp(`${sectionName}\\s*([\\s\\S]*?)(?=\\n\\n|\\n[A-Z][^:]*:|$)`, 'i')
422
+ ];
423
+
424
+ for (const pattern of patterns) {
425
+ const match = textResponse.match(pattern);
426
+ if (match) {
427
+ return match[1].trim().split('\n')
428
+ .map(line => line.replace(/^[-•*]\s*/, '').trim())
429
+ .filter(line => line.length > 0);
430
+ }
431
+ }
432
+ return [];
433
+ };
434
+
435
+ return {
436
+ completionPercentage,
437
+ status,
438
+ summary: textResponse.split('\n')[0] || 'Code review completed',
439
+ completed: extractSection('completed|done|implemented|working'),
440
+ missing: extractSection('missing|incomplete|todo|needed'),
441
+ issues: extractSection('issues|problems|concerns|bugs'),
442
+ recommendations: extractSection('recommendations|suggestions|improvements|consider')
443
+ };
444
+ }
445
+
446
+ /**
447
+ * Validate and normalize Claude's response
448
+ * @param {Object} response - Response object from Claude
449
+ * @returns {Object} Validated response
450
+ */
451
+ function validateClaudeResponse(response) {
452
+ const validated = {
453
+ completionPercentage: Math.max(0, Math.min(100, response.completionPercentage || 0)),
454
+ status: ['good', 'warning', 'poor'].includes(response.status) ? response.status : 'warning',
455
+ summary: response.summary || 'Code review completed',
456
+ completed: Array.isArray(response.completed) ? response.completed : [],
457
+ missing: Array.isArray(response.missing) ? response.missing : [],
458
+ issues: Array.isArray(response.issues) ? response.issues : [],
459
+ recommendations: Array.isArray(response.recommendations) ? response.recommendations : []
460
+ };
461
+
462
+ // Ensure status matches percentage
463
+ if (validated.completionPercentage >= 80 && validated.status === 'poor') {
464
+ validated.status = 'good';
465
+ } else if (validated.completionPercentage >= 60 && validated.status === 'poor') {
466
+ validated.status = 'warning';
467
+ }
468
+
469
+ return validated;
470
+ }
471
+
472
+
473
+
474
+ /**
475
+ * Display review results with color coding based on configuration thresholds
476
+ * @param {Object} review - AI review results
477
+ * @param {number} review.completionPercentage - Completion percentage (0-100)
478
+ * @param {string} review.status - Review status (good|warning|poor)
479
+ * @param {string} review.summary - Brief assessment summary
480
+ * @param {string[]} review.completed - List of completed requirements
481
+ * @param {string[]} review.missing - List of missing requirements
482
+ * @param {string[]} review.issues - List of identified issues
483
+ * @param {string[]} review.recommendations - List of recommendations
484
+ * @param {Object} ticket - Ticket metadata
485
+ * @param {Object} ticket.metadata - Ticket metadata object
486
+ * @param {string} ticket.metadata.id - Ticket ID
487
+ * @param {string} ticket.metadata.title - Ticket title
488
+ * @param {Object} config - Configuration object
489
+ * @param {Object} [config.review] - Review configuration
490
+ * @param {number} [config.review.good_threshold=80] - Threshold for green status
491
+ * @param {number} [config.review.warning_threshold=60] - Threshold for yellow status
492
+ */
493
+ function displayReviewResults(review, ticket, config) {
494
+ const percentage = review.completionPercentage;
495
+ const thresholds = config.review || {};
496
+ const goodThreshold = thresholds.good_threshold || 80;
497
+ const warningThreshold = thresholds.warning_threshold || 60;
498
+
499
+ let percentageColor;
500
+ let statusIcon;
501
+
502
+ if (percentage >= goodThreshold) {
503
+ percentageColor = chalk.green;
504
+ statusIcon = 'āœ…';
505
+ } else if (percentage >= warningThreshold) {
506
+ percentageColor = chalk.yellow;
507
+ statusIcon = 'āš ļø';
508
+ } else {
509
+ percentageColor = chalk.red;
510
+ statusIcon = 'āŒ';
511
+ }
512
+
513
+ console.log(chalk.blue('\nšŸ” AI Code Review Results'));
514
+ console.log(chalk.blue('═'.repeat(50)));
515
+ console.log(`${chalk.bold('Ticket:')} ${ticket.metadata.id} - ${ticket.metadata.title}`);
516
+ console.log(`${chalk.bold('Completion:')} ${statusIcon} ${percentageColor(`${percentage}%`)}`);
517
+ console.log(`${chalk.bold('Summary:')} ${review.summary}`);
518
+ console.log(chalk.blue('═'.repeat(50)));
519
+
520
+ // Completed items
521
+ if (review.completed.length > 0) {
522
+ console.log(chalk.green('\nāœ… Completed Requirements:'));
523
+ review.completed.forEach(item => {
524
+ console.log(chalk.green(` • ${item}`));
525
+ });
526
+ }
527
+
528
+ // Missing items
529
+ if (review.missing.length > 0) {
530
+ console.log(chalk.red('\nāŒ Missing/Incomplete:'));
531
+ review.missing.forEach(item => {
532
+ console.log(chalk.red(` • ${item}`));
533
+ });
534
+ }
535
+
536
+ // Issues
537
+ if (review.issues.length > 0) {
538
+ console.log(chalk.yellow('\nāš ļø Issues Found:'));
539
+ review.issues.forEach(item => {
540
+ console.log(chalk.yellow(` • ${item}`));
541
+ });
542
+ }
543
+
544
+ // Recommendations
545
+ if (review.recommendations.length > 0) {
546
+ console.log(chalk.blue('\nšŸ’” Recommendations:'));
547
+ review.recommendations.forEach(item => {
548
+ console.log(chalk.blue(` • ${item}`));
549
+ });
550
+ }
551
+
552
+ console.log(chalk.gray('\nšŸ“‹ Use --copy or -c to copy detailed feedback to clipboard'));
553
+ }
554
+
555
+ /**
556
+ * Format review results for clipboard in plain text format
557
+ * @param {Object} review - AI review results
558
+ * @param {number} review.completionPercentage - Completion percentage
559
+ * @param {string} review.summary - Review summary
560
+ * @param {string[]} review.completed - Completed items
561
+ * @param {string[]} review.missing - Missing items
562
+ * @param {string[]} review.issues - Issues found
563
+ * @param {string[]} review.recommendations - Recommendations
564
+ * @param {Object} ticket - Ticket metadata
565
+ * @param {Object} ticket.metadata - Ticket metadata object
566
+ * @param {string} ticket.metadata.id - Ticket ID
567
+ * @param {string} ticket.metadata.title - Ticket title
568
+ * @returns {string} Formatted text for clipboard with proper spacing and sections
569
+ * @example
570
+ * const formattedText = formatReviewForClipboard(reviewResult, ticketData);
571
+ * // Returns formatted string ready for clipboard
572
+ */
573
+ function formatReviewForClipboard(review, ticket) {
574
+ let output = `Code Review Results - ${ticket.metadata.id}\n`;
575
+ output += `${'='.repeat(50)}\n`;
576
+ output += `Ticket: ${ticket.metadata.id} - ${ticket.metadata.title}\n`;
577
+ output += `Completion: ${review.completionPercentage}%\n`;
578
+ output += `Summary: ${review.summary}\n\n`;
579
+
580
+ if (review.completed.length > 0) {
581
+ output += `Completed Requirements:\n`;
582
+ review.completed.forEach(item => {
583
+ output += ` • ${item}\n`;
584
+ });
585
+ output += '\n';
586
+ }
587
+
588
+ if (review.missing.length > 0) {
589
+ output += `Missing/Incomplete:\n`;
590
+ review.missing.forEach(item => {
591
+ output += ` • ${item}\n`;
592
+ });
593
+ output += '\n';
594
+ }
595
+
596
+ if (review.issues.length > 0) {
597
+ output += `Issues Found:\n`;
598
+ review.issues.forEach(item => {
599
+ output += ` • ${item}\n`;
600
+ });
601
+ output += '\n';
602
+ }
603
+
604
+ if (review.recommendations.length > 0) {
605
+ output += `Recommendations:\n`;
606
+ review.recommendations.forEach(item => {
607
+ output += ` • ${item}\n`;
608
+ });
609
+ }
610
+
611
+ return output;
612
+ }
613
+
614
+ /**
615
+ * Copy text to clipboard with platform-specific commands and fallback to cache file
616
+ * @param {string} text - Text to copy to clipboard
617
+ * @param {string} ticketId - Ticket ID for cache file naming
618
+ * @returns {boolean} True if copied to clipboard, false if saved to cache file
619
+ */
620
+ function copyToClipboard(text, ticketId) {
621
+ const commands = {
622
+ darwin: 'pbcopy',
623
+ win32: 'clip',
624
+ linux: ['xclip -selection clipboard', 'xsel --clipboard --input']
625
+ };
626
+
627
+ const platform = process.platform;
628
+ const platformCommands = commands[platform] || commands.linux;
629
+ const commandList = Array.isArray(platformCommands) ? platformCommands : [platformCommands];
630
+
631
+ // Try each clipboard command
632
+ for (const command of commandList) {
633
+ try {
634
+ execSync(command, { input: text, stdio: 'pipe' });
635
+ console.log(chalk.green('\nšŸ“‹ Review results copied to clipboard!'));
636
+ return true;
637
+ } catch (error) {
638
+ continue; // Try next command
639
+ }
640
+ }
641
+
642
+ // Fallback: save to cache file
643
+ const cacheDir = path.join(process.cwd(), `.vibe/.cache/review/logs/${ticketId}`);
644
+ fs.mkdirSync(cacheDir, { recursive: true });
645
+
646
+ const gitignorePath = path.join(process.cwd(), '.vibe/.cache/.gitignore');
647
+ if (!fs.existsSync(gitignorePath)) {
648
+ fs.writeFileSync(gitignorePath, '*\n!.gitignore\n');
649
+ }
650
+
651
+ const timestamp = new Date().toISOString().replace(/:/g, '-').split('.')[0];
652
+ const tempFile = path.join(cacheDir, `review-${timestamp}.txt`);
653
+ fs.writeFileSync(tempFile, text);
654
+
655
+ console.log(chalk.yellow('\nšŸ“‹ Clipboard not available. Review results saved to:'));
656
+ console.log(chalk.cyan(` ${tempFile}`));
657
+ console.log(chalk.gray(' Copy manually or run: vibe review clean'));
658
+
659
+ // Auto-cleanup after 60 seconds
660
+ setTimeout(() => {
661
+ if (fs.existsSync(tempFile)) {
662
+ fs.unlinkSync(tempFile);
663
+ cleanupEmptyDirs(path.dirname(tempFile));
664
+ }
665
+ }, 60000);
666
+
667
+ return false;
668
+ }
669
+
670
+ /**
671
+ * Recursively remove empty directories from cache structure
672
+ * @param {string} dirPath - Directory path to clean up
673
+ * @throws {Error} Silently handles and ignores cleanup errors
674
+ * @example
675
+ * cleanupEmptyDirs('.vibe/.cache/review/logs/TKT-001');
676
+ */
677
+ function cleanupEmptyDirs(dirPath) {
678
+ try {
679
+ if (!fs.existsSync(dirPath)) return;
680
+
681
+ const entries = fs.readdirSync(dirPath);
682
+ if (entries.length === 0) {
683
+ fs.rmdirSync(dirPath);
684
+ // Recursively check parent directory
685
+ const parentDir = path.dirname(dirPath);
686
+ if (parentDir !== dirPath && parentDir.includes('.vibe/.cache')) {
687
+ cleanupEmptyDirs(parentDir);
688
+ }
689
+ }
690
+ } catch (error) {
691
+ // Ignore cleanup errors
692
+ }
693
+ }
694
+
695
+ /**
696
+ * Clean review cache files with optional ticket filtering
697
+ * @param {string[]} args - Command arguments for filtering specific tickets
698
+ * @param {string} [args[0]] - Optional ticket ID to clean specific ticket files
699
+ * @example
700
+ * cleanReviewFiles([]); // Clean all review cache files
701
+ * cleanReviewFiles(['TKT-001']); // Clean only TKT-001 cache files
702
+ * cleanReviewFiles(['11']); // Clean TKT-011 cache files (auto-normalized)
703
+ */
704
+ function cleanReviewFiles(args) {
705
+ const cacheBasePath = path.join(process.cwd(), '.vibe/.cache/review');
706
+
707
+ if (!fs.existsSync(cacheBasePath)) {
708
+ console.log(chalk.green('āœ… No review cache files to clean'));
709
+ return;
710
+ }
711
+
712
+ // Check for specific ticket ID
713
+ let targetPath = cacheBasePath;
714
+ let ticketFilter = null;
715
+
716
+ if (args.length > 0 && args[0] !== 'clean') {
717
+ const ticketId = args[0];
718
+ const normalizedId = normalizeTicketId(ticketId);
719
+ if (normalizedId) {
720
+ targetPath = path.join(cacheBasePath, 'logs', normalizedId);
721
+ ticketFilter = normalizedId;
722
+ }
723
+ }
724
+
725
+ try {
726
+ if (!fs.existsSync(targetPath)) {
727
+ if (ticketFilter) {
728
+ console.log(chalk.yellow(`āš ļø No cache files found for ticket ${ticketFilter}`));
729
+ } else {
730
+ console.log(chalk.green('āœ… No review cache files to clean'));
731
+ }
732
+ return;
733
+ }
734
+
735
+ // Count files before deletion
736
+ let fileCount = 0;
737
+ function countFiles(dir) {
738
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
739
+ for (const entry of entries) {
740
+ if (entry.isFile()) {
741
+ fileCount++;
742
+ } else if (entry.isDirectory()) {
743
+ countFiles(path.join(dir, entry.name));
744
+ }
745
+ }
746
+ }
747
+ countFiles(targetPath);
748
+
749
+ // Remove the directory and all contents
750
+ fs.rmSync(targetPath, { recursive: true, force: true });
751
+
752
+ // Clean up empty parent directories
753
+ cleanupEmptyDirs(path.dirname(targetPath));
754
+
755
+ if (ticketFilter) {
756
+ console.log(chalk.green(`🧹 Cleaned ${fileCount} review files for ticket ${ticketFilter}`));
757
+ } else {
758
+ console.log(chalk.green(`🧹 Cleaned ${fileCount} review cache files`));
759
+ }
760
+
761
+ } catch (error) {
762
+ console.error(chalk.red(`āŒ Failed to clean cache files: ${error.message}`));
763
+ }
764
+ }
765
+
766
+ /**
767
+ * Main review command implementation with AI-powered code analysis
768
+ * @param {string[]} args - Command line arguments
769
+ * @param {string} [args[0]] - Subcommand ('clean') or ticket ID
770
+ * @param {string} [args[1]] - Additional arguments (ticket ID for clean, flags)
771
+ * @description Analyzes staged git changes against ticket requirements using AI.
772
+ * Supports auto-detection of ticket ID from branch names, clipboard functionality,
773
+ * configurable acceptance thresholds, and organized cache management.
774
+ *
775
+ * @example
776
+ * // Basic usage
777
+ * await reviewCommand(['TKT-011']); // Review TKT-011
778
+ * await reviewCommand(['11']); // Same as above (auto-normalized)
779
+ * await reviewCommand([]); // Auto-detect from branch
780
+ *
781
+ * // With options
782
+ * await reviewCommand(['TKT-011', '--copy']); // Copy results to clipboard
783
+ * await reviewCommand(['TKT-011', '-c']); // Same as above
784
+ *
785
+ * // Clean commands
786
+ * await reviewCommand(['clean']); // Clean all cache files
787
+ * await reviewCommand(['clean', 'TKT-011']); // Clean specific ticket
788
+ *
789
+ * // Help
790
+ * await reviewCommand(['--help']); // Show help
791
+ *
792
+ * @throws {Error} Exits process with code 1 for validation errors or review failures
793
+ * @throws {Error} Exits process with code 0 for successful reviews above threshold
794
+ */
795
+ async function reviewCommand(args) {
796
+ // Check for clean subcommand
797
+ if (args[0] === 'clean') {
798
+ cleanReviewFiles(args.slice(1));
799
+ return;
800
+ }
801
+
802
+ // Check for help flag
803
+ if (args.includes('--help') || args.includes('-h')) {
804
+ console.log(`
805
+ ${chalk.blue('šŸ” vibe review')} - AI-powered code review against ticket requirements
806
+
807
+ ${chalk.bold('Usage:')}
808
+ vibe review [ticket-id] [options]
809
+ vibe review clean [ticket-id]
810
+
811
+ ${chalk.bold('Arguments:')}
812
+ ticket-id Ticket ID to review against (e.g., TKT-001, 1, 11)
813
+ If omitted, extracts ticket ID from current branch name
814
+
815
+ ${chalk.bold('Commands:')}
816
+ clean Clean review temp files (all or specific ticket)
817
+
818
+ ${chalk.bold('Options:')}
819
+ --copy, -c Copy detailed feedback to clipboard
820
+ --verbose, -v Show detailed debug information
821
+ --help, -h Show this help message
822
+
823
+ ${chalk.bold('Examples:')}
824
+ vibe review TKT-011 # Review staged changes against TKT-011
825
+ vibe review 11 # Same as above (auto-formats to TKT-011)
826
+ vibe review # Auto-detect from branch (feature/TKT-011-*)
827
+ vibe review TKT-001 -c # Review and copy results to clipboard
828
+ vibe review TKT-001 -v # Review with verbose debug output
829
+ vibe review clean # Clean all review temp files
830
+ vibe review clean TKT-011 # Clean temp files for specific ticket
831
+
832
+ ${chalk.bold('Description:')}
833
+ Uses AI to analyze staged changes against ticket requirements.
834
+ Provides completion percentage, identifies missing items, and gives recommendations.
835
+
836
+ ${chalk.bold('Color Coding:')}
837
+ 🟢 80%+ - Good (Green)
838
+ 🟔 60-79% - Warning (Yellow)
839
+ šŸ”“ <60% - Poor (Red)
840
+ `);
841
+ return;
842
+ }
843
+
844
+ // Check if we're in a git repository
845
+ if (!isGitRepository()) {
846
+ console.error(chalk.red('āŒ Not in a git repository. Please run this command from within a git repository.'));
847
+ process.exit(1);
848
+ }
849
+
850
+ // Parse arguments
851
+ let ticketId = args[0];
852
+ const shouldCopy = args.includes('--copy') || args.includes('-c');
853
+ const isVerbose = args.includes('--verbose') || args.includes('-v');
854
+
855
+ // If no ticket ID provided, try to extract from branch
856
+ if (!ticketId) {
857
+ ticketId = extractTicketFromBranch();
858
+ if (!ticketId) {
859
+ console.error(chalk.red('āŒ Please provide a ticket ID or work on a feature branch'));
860
+ console.error(chalk.gray(' Examples: vibe review TKT-011, vibe review 11, or work on feature/TKT-011-* branch'));
861
+ process.exit(1);
862
+ }
863
+ console.log(chalk.blue(`šŸ” Detected ticket from branch: ${ticketId}`));
864
+ }
865
+
866
+ // Load configuration
867
+ let config;
868
+ try {
869
+ config = getConfig();
870
+ } catch (error) {
871
+ console.error(chalk.red(`āŒ Failed to load configuration: ${error.message}`));
872
+ process.exit(1);
873
+ }
874
+
875
+ // Validate ticket ID and existence
876
+ const validation = validateTicketId(ticketId);
877
+ if (!validation.isValid) {
878
+ console.error(chalk.red(`āŒ ${validation.error}`));
879
+ process.exit(1);
880
+ }
881
+
882
+ try {
883
+ // Parse ticket requirements
884
+ const ticket = parseTicketRequirements(validation.ticketFile);
885
+
886
+ // Get staged changes
887
+ const stagedChanges = getStagedChanges();
888
+ if (!stagedChanges.trim()) {
889
+ console.error(chalk.yellow('āš ļø No staged changes found.'));
890
+ console.error(chalk.gray(' Use "git add <files>" to stage changes for review.'));
891
+ console.error(chalk.blue('šŸ’” Remember: Only staged changes are reviewed, not all working directory changes.'));
892
+ process.exit(1);
893
+ }
894
+
895
+ console.log(chalk.blue('ā„¹ļø Reviewing staged changes only. Make sure all changes you want reviewed are staged with "git add".'));
896
+
897
+ const stagedFiles = getStagedFiles();
898
+
899
+ // Create AI prompt
900
+ const prompt = createReviewPrompt(ticket, stagedChanges, stagedFiles);
901
+
902
+ // Debug output in verbose mode
903
+ if (isVerbose) {
904
+ console.log(chalk.gray('\nšŸ” Verbose Debug Information:'));
905
+ console.log(chalk.gray('═'.repeat(40)));
906
+ console.log(chalk.gray(`Ticket ID: ${ticket.metadata.id}`));
907
+ console.log(chalk.gray(`Staged files count: ${stagedFiles.length}`));
908
+ console.log(chalk.gray(`Staged files: ${stagedFiles.join(', ')}`));
909
+ console.log(chalk.gray(`Changes size: ${stagedChanges.length} characters`));
910
+ console.log(chalk.gray(`Config acceptance threshold: ${config.review?.acceptance_threshold || 80}%`));
911
+ }
912
+
913
+ // Call AI for review
914
+ const review = await callClaudeForReview(prompt);
915
+
916
+ // More debug output in verbose mode
917
+ if (isVerbose) {
918
+ console.log(chalk.gray('\nšŸ¤– AI Response Debug:'));
919
+ console.log(chalk.gray('═'.repeat(40)));
920
+ console.log(chalk.gray(`Response completion: ${review.completionPercentage}%`));
921
+ console.log(chalk.gray(`Response status: ${review.status}`));
922
+ console.log(chalk.gray(`Completed items: ${review.completed.length}`));
923
+ console.log(chalk.gray(`Missing items: ${review.missing.length}`));
924
+ console.log(chalk.gray(`Issues found: ${review.issues.length}`));
925
+ console.log(chalk.gray(`Recommendations: ${review.recommendations.length}`));
926
+ console.log(chalk.gray('═'.repeat(40)));
927
+ }
928
+
929
+ // Display results
930
+ displayReviewResults(review, ticket, config);
931
+
932
+ // Copy to clipboard if requested
933
+ if (shouldCopy) {
934
+ const clipboardText = formatReviewForClipboard(review, ticket);
935
+ copyToClipboard(clipboardText, ticket.metadata.id);
936
+ }
937
+
938
+ // Exit with appropriate code for git hooks
939
+ const reviewConfig = config.review || {};
940
+ const acceptanceThreshold = reviewConfig.acceptance_threshold || 80;
941
+
942
+ if (review.completionPercentage < acceptanceThreshold) {
943
+ console.log(chalk.red(`\n🚫 Review failed: ${review.completionPercentage}% < ${acceptanceThreshold}% (acceptance threshold)`));
944
+ console.log(chalk.gray(' Configure acceptance_threshold in .vibe/config.yml'));
945
+ process.exit(1); // Fail git hook
946
+ } else {
947
+ console.log(chalk.green(`\nāœ… Review passed: ${review.completionPercentage}% > ${acceptanceThreshold}% (acceptance threshold)`));
948
+ process.exit(0); // Pass git hook
949
+ }
950
+
951
+ } catch (error) {
952
+ console.error(chalk.red(`āŒ ${error.message}`));
953
+ process.exit(1);
954
+ }
955
+ }
956
+
957
+ export default reviewCommand;