@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,153 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
2
+ import {
3
+ createTempDir,
4
+ cleanupTempDir,
5
+ mockConsole,
6
+ mockProcessCwd,
7
+ mockProcessExit,
8
+ createMockVibeProject
9
+ } from '../../utils/test-helpers.js';
10
+ import listCommand from './index.js';
11
+
12
+ describe('list command', () => {
13
+ let tempDir;
14
+ let consoleMock;
15
+ let restoreCwd;
16
+ let exitMock;
17
+
18
+ beforeEach(() => {
19
+ tempDir = createTempDir('list-test');
20
+ consoleMock = mockConsole();
21
+ restoreCwd = mockProcessCwd(tempDir);
22
+ exitMock = mockProcessExit();
23
+ });
24
+
25
+ afterEach(() => {
26
+ consoleMock.restore();
27
+ restoreCwd();
28
+ exitMock.restore();
29
+ cleanupTempDir(tempDir);
30
+ });
31
+
32
+ describe('basic listing', () => {
33
+ it('should show message when no tickets exist', () => {
34
+ // Arrange
35
+ createMockVibeProject(tempDir);
36
+
37
+ // Act
38
+ expect(() => listCommand([])).toThrow('process.exit(0)');
39
+
40
+ // Assert
41
+ expect(exitMock.exitCalls).toContain(0);
42
+ expect(consoleMock.logs.log.some(log =>
43
+ log.includes('No tickets found')
44
+ )).toBe(true);
45
+ });
46
+
47
+ it('should list all tickets with their details', () => {
48
+ // Arrange
49
+ createMockVibeProject(tempDir, {
50
+ withTickets: [
51
+ {
52
+ id: 'TKT-001',
53
+ title: 'First ticket',
54
+ status: 'open',
55
+ priority: 'high',
56
+ slug: 'first-ticket'
57
+ },
58
+ {
59
+ id: 'TKT-002',
60
+ title: 'Second ticket',
61
+ status: 'in_progress',
62
+ priority: 'medium',
63
+ slug: 'second-ticket'
64
+ }
65
+ ]
66
+ });
67
+
68
+ // Act
69
+ listCommand([]);
70
+
71
+ // Assert
72
+ const output = consoleMock.logs.log.join(' ');
73
+ expect(output).toContain('TKT-001');
74
+ expect(output).toContain('First ticket');
75
+ expect(output).toContain('TKT-002');
76
+ expect(output).toContain('Second ticket');
77
+ });
78
+
79
+ it('should show ticket status and priority', () => {
80
+ // Arrange
81
+ createMockVibeProject(tempDir, {
82
+ withTickets: [
83
+ {
84
+ id: 'TKT-001',
85
+ title: 'Test ticket',
86
+ status: 'done',
87
+ priority: 'urgent',
88
+ slug: 'test-ticket'
89
+ }
90
+ ]
91
+ });
92
+
93
+ // Act
94
+ listCommand([]);
95
+
96
+ // Assert
97
+ const output = consoleMock.logs.log.join(' ');
98
+ expect(output).toContain('done');
99
+ expect(output).toContain('TKT-001');
100
+ });
101
+ });
102
+
103
+ describe('error handling', () => {
104
+ it('should handle missing vibe directory gracefully', () => {
105
+ // Act - no vibe project created
106
+ expect(() => listCommand([])).toThrow('process.exit(1)');
107
+
108
+ // Assert
109
+ expect(exitMock.exitCalls).toContain(1);
110
+ });
111
+
112
+ it('should handle corrupted ticket files', async () => {
113
+ // Arrange
114
+ const vibeProject = createMockVibeProject(tempDir);
115
+
116
+ // Create a corrupted ticket file
117
+ const fs = await import('fs');
118
+ fs.writeFileSync(
119
+ `${vibeProject.ticketsDir}/TKT-001-corrupted.md`,
120
+ 'invalid yaml content without frontmatter',
121
+ 'utf-8'
122
+ );
123
+
124
+ // Act
125
+ expect(() => listCommand([])).toThrow('process.exit(0)');
126
+
127
+ // Assert - should handle gracefully and continue
128
+ expect(exitMock.exitCalls).toContain(0);
129
+ });
130
+ });
131
+
132
+ describe('filtering and sorting', () => {
133
+ it('should handle multiple tickets correctly', () => {
134
+ // Arrange
135
+ createMockVibeProject(tempDir, {
136
+ withTickets: [
137
+ { id: 'TKT-003', title: 'Third', status: 'open' },
138
+ { id: 'TKT-001', title: 'First', status: 'done' },
139
+ { id: 'TKT-002', title: 'Second', status: 'in_progress' }
140
+ ]
141
+ });
142
+
143
+ // Act
144
+ listCommand([]);
145
+
146
+ // Assert
147
+ const output = consoleMock.logs.log.join(' ');
148
+ expect(output).toContain('TKT-001');
149
+ expect(output).toContain('TKT-002');
150
+ expect(output).toContain('TKT-003');
151
+ });
152
+ });
153
+ });
@@ -0,0 +1,305 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import yaml from 'js-yaml';
4
+ import { getTicketsDir, getConfig, getNextTicketId, createSlug } from '../../utils/index.js';
5
+ import { confirmPrompt } from '../../utils/prompts.js';
6
+ import { logger } from '../../utils/cli.js';
7
+
8
+ // Configuration constants
9
+ const DEFAULT_PRIORITY = 'medium';
10
+ const DEFAULT_STATUS = 'open';
11
+ const GIT_STATUS_CHECK_TIMEOUT = 5000;
12
+
13
+ /**
14
+ * Check if AI is enabled in config
15
+ * @param {Object} config - Configuration object
16
+ * @returns {boolean} True if AI is enabled
17
+ */
18
+ function checkAiEnabled(config) {
19
+ return config && config.ai && config.ai.enabled && config.ai.provider !== 'none';
20
+ }
21
+
22
+ /**
23
+ * Parse command arguments into structured data
24
+ * @param {Array} args - Command line arguments
25
+ * @returns {Object} Parsed arguments with title, priority, and status
26
+ */
27
+ function parseArguments(args) {
28
+ if (!Array.isArray(args)) {
29
+ throw new Error('Arguments must be an array');
30
+ }
31
+
32
+ let titleParts = [];
33
+ let priority = DEFAULT_PRIORITY;
34
+ let status = DEFAULT_STATUS;
35
+
36
+ for (let i = 0; i < args.length; i++) {
37
+ const arg = args[i];
38
+
39
+ if (arg === '--priority' && i + 1 < args.length) {
40
+ priority = args[i + 1];
41
+ i++; // Skip the next argument as it's the priority value
42
+ } else if (arg === '--status' && i + 1 < args.length) {
43
+ status = args[i + 1];
44
+ i++; // Skip the next argument as it's the status value
45
+ } else if (!arg.startsWith('--')) {
46
+ titleParts.push(arg);
47
+ }
48
+ }
49
+
50
+ const title = titleParts.join(' ').trim();
51
+
52
+ if (!title) {
53
+ throw new Error('Please provide a title for the new ticket.');
54
+ }
55
+
56
+ return { title, priority, status };
57
+ }
58
+
59
+ /**
60
+ * Validate priority and status against config options
61
+ * @param {string} priority - Priority value
62
+ * @param {string} status - Status value
63
+ * @param {Object} config - Configuration object
64
+ * @returns {Object} Validated priority and status
65
+ */
66
+ function validateOptions(priority, status, config) {
67
+ let validatedPriority = priority;
68
+ let validatedStatus = status;
69
+
70
+ // Validate priority
71
+ if (config.tickets?.priority_options && !config.tickets.priority_options.includes(priority)) {
72
+ logger.warning(`Priority '${priority}' not in config options. Using default.`);
73
+ validatedPriority = DEFAULT_PRIORITY;
74
+ }
75
+
76
+ // Validate status
77
+ if (config.tickets?.status_options && !config.tickets.status_options.includes(status)) {
78
+ logger.warning(`Status '${status}' not in config options. Using default.`);
79
+ validatedStatus = DEFAULT_STATUS;
80
+ }
81
+
82
+ return { priority: validatedPriority, status: validatedStatus };
83
+ }
84
+
85
+ /**
86
+ * Create ticket content from template
87
+ * @param {string} template - Template content
88
+ * @param {Object} ticketData - Ticket data
89
+ * @returns {string} Generated ticket content
90
+ */
91
+ function createTicketContent(template, ticketData) {
92
+ if (typeof template !== 'string') {
93
+ throw new Error('Template must be a string');
94
+ }
95
+
96
+ const { ticketId, title, slug, priority, status, timestamp } = ticketData;
97
+ const paddedId = ticketId.replace('TKT-', '');
98
+
99
+ return template
100
+ .replace(/{id}/g, paddedId)
101
+ .replace(/{title}/g, title)
102
+ .replace(/{slug}/g, slug)
103
+ .replace(/{date}/g, timestamp)
104
+ .replace(/^priority: .*$/m, `priority: ${priority}`)
105
+ .replace(/^status: .*$/m, `status: ${status}`);
106
+ }
107
+
108
+ /**
109
+ * Check if file is untracked in git
110
+ * @param {string} filename - File to check
111
+ * @returns {Promise<boolean>} True if file is untracked
112
+ */
113
+ async function isFileUntracked(filename) {
114
+ try {
115
+ const { spawn } = await import('child_process');
116
+
117
+ return new Promise((resolve) => {
118
+ const child = spawn('git', ['status', '--porcelain', filename], {
119
+ cwd: process.cwd(),
120
+ stdio: ['pipe', 'pipe', 'pipe'],
121
+ timeout: GIT_STATUS_CHECK_TIMEOUT
122
+ });
123
+
124
+ let output = '';
125
+ child.stdout.on('data', (data) => {
126
+ output += data.toString();
127
+ });
128
+
129
+ child.on('close', (code) => {
130
+ // If output starts with '??', file is untracked
131
+ resolve(output.trim().startsWith('??'));
132
+ });
133
+
134
+ child.on('error', () => {
135
+ resolve(false); // Assume tracked if git check fails
136
+ });
137
+
138
+ // Timeout handler
139
+ setTimeout(() => {
140
+ try {
141
+ child.kill('SIGTERM');
142
+ } catch (killError) {
143
+ // Ignore kill errors
144
+ }
145
+ resolve(false);
146
+ }, GIT_STATUS_CHECK_TIMEOUT);
147
+ });
148
+ } catch (error) {
149
+ return false; // Assume tracked if anything fails
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Handle file renaming based on AI-generated slug
155
+ * @param {string} originalPath - Original file path
156
+ * @param {string} ticketDir - Tickets directory
157
+ * @returns {Promise<void>}
158
+ */
159
+ async function handleFileRename(originalPath, ticketDir) {
160
+ try {
161
+ const filename = path.basename(originalPath);
162
+ const isUntracked = await isFileUntracked(filename);
163
+
164
+ if (!isUntracked) {
165
+ return; // Don't rename tracked files
166
+ }
167
+
168
+ const { parseTicket } = await import('../../utils/ticket.js');
169
+ const ticketData = parseTicket(originalPath);
170
+
171
+ if (!ticketData.metadata.slug) {
172
+ return; // No slug to use for renaming
173
+ }
174
+
175
+ const newFilename = `${ticketData.metadata.slug}.md`;
176
+ const newPath = path.join(ticketDir, newFilename);
177
+
178
+ if (newPath !== originalPath && !fs.existsSync(newPath)) {
179
+ fs.renameSync(originalPath, newPath);
180
+ logger.info(`Renamed ticket file to: ${newFilename}`);
181
+ }
182
+ } catch (error) {
183
+ logger.debug(`File rename skipped - ${error.message}`);
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Create a new ticket
189
+ * @param {Array} args - Command arguments
190
+ * @returns {Promise<void>}
191
+ * @throws {Error} If ticket creation fails
192
+ */
193
+ async function newCommand(args) {
194
+ try {
195
+ // Parse and validate arguments
196
+ const { title, priority, status } = parseArguments(args);
197
+
198
+ // Check required files and paths
199
+ const configPath = path.join(process.cwd(), '.vibe', 'config.yml');
200
+ const templatePath = path.join(process.cwd(), '.vibe', '.templates', 'default.md');
201
+
202
+ if (!fs.existsSync(configPath)) {
203
+ throw new Error('Missing config.yml. Run "vibe init" first.');
204
+ }
205
+
206
+ if (!fs.existsSync(templatePath)) {
207
+ throw new Error('Missing default.md template. Run "vibe init" first.');
208
+ }
209
+
210
+ // Load configuration and template
211
+ let config, template;
212
+ try {
213
+ config = yaml.load(fs.readFileSync(configPath, 'utf-8'));
214
+ template = fs.readFileSync(templatePath, 'utf-8');
215
+ } catch (readError) {
216
+ throw new Error(`Failed to read configuration or template: ${readError.message}`);
217
+ }
218
+
219
+ if (!config) {
220
+ throw new Error('Configuration file is empty or invalid');
221
+ }
222
+
223
+ // Validate options against config
224
+ const validatedOptions = validateOptions(priority, status, config);
225
+
226
+ // Generate ticket data
227
+ const ticketId = getNextTicketId();
228
+ const simpleSlug = createSlug(title, config);
229
+ const ticketSlug = `${ticketId}-${simpleSlug}`;
230
+ const filename = `${ticketId}-${simpleSlug}.md`;
231
+ const timestamp = new Date().toISOString();
232
+
233
+ const ticketData = {
234
+ ticketId,
235
+ title,
236
+ slug: ticketSlug,
237
+ priority: validatedOptions.priority,
238
+ status: validatedOptions.status,
239
+ timestamp
240
+ };
241
+
242
+ // Create ticket content
243
+ const content = createTicketContent(template, ticketData);
244
+
245
+ // Write ticket file
246
+ const ticketDir = path.join(process.cwd(), config.tickets?.path || '.vibe/tickets');
247
+ const outputPath = path.join(ticketDir, filename);
248
+
249
+ try {
250
+ fs.writeFileSync(outputPath, content, 'utf-8');
251
+ } catch (writeError) {
252
+ throw new Error(`Failed to create ticket file: ${writeError.message}`);
253
+ }
254
+
255
+ logger.success(`Created ticket: ${filename} (priority: ${ticketData.priority}, status: ${ticketData.status})`);
256
+
257
+ // Offer AI enhancement if available
258
+ if (checkAiEnabled(config)) {
259
+ await offerAiEnhancement(ticketId, outputPath, ticketDir);
260
+ }
261
+
262
+ } catch (error) {
263
+ logger.error(error.message);
264
+ process.exit(1);
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Offer AI enhancement for newly created ticket
270
+ * @param {string} ticketId - The ticket ID
271
+ * @param {string} outputPath - Path to the ticket file
272
+ * @param {string} ticketDir - Tickets directory
273
+ * @returns {Promise<void>}
274
+ */
275
+ async function offerAiEnhancement(ticketId, outputPath, ticketDir) {
276
+ logger.ai('AI enhancement is available for this ticket.');
277
+
278
+ const shouldRefine = await confirmPrompt('🚀', 'Do you want to refine this ticket automatically based on your codebase?', true);
279
+
280
+ if (!shouldRefine) {
281
+ return;
282
+ }
283
+
284
+ logger.step('Starting AI enhancement...');
285
+
286
+ try {
287
+ // Import and run refine command
288
+ const refineModule = await import('../refine/index.js');
289
+ const refineCommand = refineModule.default;
290
+
291
+ // Extract ticket number for refine command
292
+ const ticketNumber = ticketId.replace('TKT-', '');
293
+ await refineCommand([ticketNumber], { fromNewCommand: true });
294
+
295
+ // Handle potential file renaming based on AI-generated slug
296
+ await handleFileRename(outputPath, ticketDir);
297
+
298
+ } catch (error) {
299
+ logger.error(`Failed to enhance ticket: ${error.message}`);
300
+ const ticketNumber = ticketId.replace('TKT-', '');
301
+ logger.tip(`You can manually enhance it later with: vibe refine ${ticketNumber}`);
302
+ }
303
+ }
304
+
305
+ export default newCommand;