@vibedx/vibekit 0.8.7 → 0.9.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.
@@ -0,0 +1,283 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import yaml from 'js-yaml';
4
+ import { execSync, spawn } from 'child_process';
5
+ import { getTicketsDir, getConfig } from '../../utils/index.js';
6
+ import {
7
+ isGitRepository,
8
+ getRepoName,
9
+ getRepoRoot,
10
+ getWorktreePath,
11
+ createWorktree,
12
+ createWorktreeExistingBranch,
13
+ branchExistsLocally,
14
+ branchExistsRemotely,
15
+ getDefaultBaseBranch
16
+ } from '../../utils/git.js';
17
+ import toTicketCommand from './to-ticket.js';
18
+
19
+ function parseTicketIds(args) {
20
+ const ids = [];
21
+ const flags = {};
22
+ let i = 0;
23
+ while (i < args.length) {
24
+ const arg = args[i];
25
+ if (arg === '--base' && i + 1 < args.length) {
26
+ flags.baseBranch = args[i + 1];
27
+ i += 2;
28
+ } else if (arg === '--dry-run') {
29
+ flags.dryRun = true;
30
+ i++;
31
+ } else if (arg === '--no-install') {
32
+ flags.noInstall = true;
33
+ i++;
34
+ } else if (arg === '--prompt' && i + 1 < args.length) {
35
+ flags.prompt = args[i + 1];
36
+ i += 2;
37
+ } else if (arg.startsWith('--status=')) {
38
+ flags.statusFilter = arg.split('=')[1];
39
+ i++;
40
+ } else if (!arg.startsWith('-')) {
41
+ ids.push(arg);
42
+ i++;
43
+ } else {
44
+ i++;
45
+ }
46
+ }
47
+ return { ids, flags };
48
+ }
49
+
50
+ function normalizeTicketId(input) {
51
+ if (input.startsWith('TKT-')) return input;
52
+ if (/^\d+$/.test(input)) return `TKT-${input.padStart(3, '0')}`;
53
+ return null;
54
+ }
55
+
56
+ function loadTicket(ticketsDir, ticketId) {
57
+ const files = fs.readdirSync(ticketsDir).filter(f => f.startsWith(`${ticketId}-`));
58
+ if (files.length === 0) return null;
59
+ const filePath = path.join(ticketsDir, files[0]);
60
+ const content = fs.readFileSync(filePath, 'utf-8');
61
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
62
+ if (!match) return null;
63
+ const frontmatter = yaml.load(match[1]);
64
+ return { frontmatter, content, filePath, fileName: files[0] };
65
+ }
66
+
67
+ function loadTicketsByStatus(ticketsDir, status) {
68
+ const files = fs.readdirSync(ticketsDir).filter(f => f.endsWith('.md'));
69
+ const tickets = [];
70
+ for (const file of files) {
71
+ const filePath = path.join(ticketsDir, file);
72
+ const content = fs.readFileSync(filePath, 'utf-8');
73
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
74
+ if (match) {
75
+ const frontmatter = yaml.load(match[1]);
76
+ if (frontmatter.status === status) {
77
+ tickets.push({ frontmatter, content, filePath, fileName: file });
78
+ }
79
+ }
80
+ }
81
+ return tickets;
82
+ }
83
+
84
+ function getBranchName(ticket, config) {
85
+ const branchPrefix = config.git?.branch_prefix || '';
86
+ const slug = ticket.frontmatter.slug || ticket.frontmatter.id;
87
+ return slug.includes(ticket.frontmatter.id)
88
+ ? `${branchPrefix}${slug}`
89
+ : `${branchPrefix}${ticket.frontmatter.id}-${slug}`;
90
+ }
91
+
92
+ function updateTicketStatus(ticket, worktreePath) {
93
+ const now = new Date().toISOString();
94
+ let updatedContent = ticket.content;
95
+
96
+ if (updatedContent.match(/^worktree_path: .+$/m)) {
97
+ updatedContent = updatedContent.replace(/^worktree_path: .+$/m, `worktree_path: "${worktreePath}"`);
98
+ } else {
99
+ updatedContent = updatedContent.replace(/^(updated_at: .+)$/m, `$1\nworktree_path: "${worktreePath}"`);
100
+ }
101
+
102
+ updatedContent = updatedContent
103
+ .replace(/^status: (.+)$/m, 'status: in_progress')
104
+ .replace(/^updated_at: (.+)$/m, `updated_at: "${now}"`);
105
+
106
+ fs.writeFileSync(ticket.filePath, updatedContent, 'utf-8');
107
+ }
108
+
109
+ async function planCommand(args) {
110
+ // Handle subcommands
111
+ if (args.length > 0 && args[0] === 'to-ticket') {
112
+ return toTicketCommand(args.slice(1));
113
+ }
114
+
115
+ if (!isGitRepository()) {
116
+ console.error('āŒ Not in a git repository.');
117
+ process.exit(1);
118
+ }
119
+
120
+ const { ids, flags } = parseTicketIds(args);
121
+
122
+ if (ids.length === 0 && !flags.statusFilter) {
123
+ console.error('āŒ Usage:');
124
+ console.error(' vibe plan <TKT-001> <TKT-002> ... Spawn parallel agents for tickets');
125
+ console.error(' vibe plan to-ticket <file> [--auto] [--dry-run] Convert plan to tickets');
126
+ console.error('');
127
+ console.error('Options for parallel agents:');
128
+ console.error(' --status=<status> Start all tickets with this status (e.g. --status=open)');
129
+ console.error(' --base <branch> Base branch for worktrees (default: main)');
130
+ console.error(' --dry-run Show what would happen without doing it');
131
+ console.error(' --no-install Skip npm install in worktrees');
132
+ console.error(' --prompt "..." Custom prompt to pass to each Claude agent');
133
+ console.error('');
134
+ console.error('Options for plan to-ticket:');
135
+ console.error(' --auto Automatically create tickets (shows preview by default)');
136
+ console.error(' --dry-run Show extracted tickets without creating them');
137
+ process.exit(1);
138
+ }
139
+
140
+ const config = getConfig();
141
+ const ticketsDir = getTicketsDir();
142
+ const repoName = getRepoName();
143
+ const repoRoot = getRepoRoot();
144
+
145
+ let tickets = [];
146
+
147
+ if (flags.statusFilter) {
148
+ tickets = loadTicketsByStatus(ticketsDir, flags.statusFilter);
149
+ if (tickets.length === 0) {
150
+ console.error(`āŒ No tickets found with status: ${flags.statusFilter}`);
151
+ process.exit(1);
152
+ }
153
+ console.log(`šŸ“‹ Found ${tickets.length} ticket(s) with status "${flags.statusFilter}"`);
154
+ }
155
+
156
+ for (const id of ids) {
157
+ const ticketId = normalizeTicketId(id);
158
+ if (!ticketId) {
159
+ console.error(`āŒ Invalid ticket ID: ${id}`);
160
+ process.exit(1);
161
+ }
162
+ const ticket = loadTicket(ticketsDir, ticketId);
163
+ if (!ticket) {
164
+ console.error(`āŒ Ticket ${ticketId} not found.`);
165
+ process.exit(1);
166
+ }
167
+ if (!tickets.find(t => t.frontmatter.id === ticket.frontmatter.id)) {
168
+ tickets.push(ticket);
169
+ }
170
+ }
171
+
172
+ if (tickets.length === 0) {
173
+ console.error('āŒ No tickets to start.');
174
+ process.exit(1);
175
+ }
176
+
177
+ console.log('');
178
+ console.log(`šŸš€ Planning ${tickets.length} ticket(s) for parallel work:\n`);
179
+
180
+ const worktreeInfos = [];
181
+
182
+ for (const ticket of tickets) {
183
+ const branchName = getBranchName(ticket, config);
184
+ const worktreePath = getWorktreePath(repoName, branchName);
185
+ const title = ticket.frontmatter.title || 'Untitled';
186
+
187
+ worktreeInfos.push({
188
+ ticket,
189
+ branchName,
190
+ worktreePath,
191
+ title,
192
+ alreadyExists: fs.existsSync(worktreePath)
193
+ });
194
+
195
+ const status = fs.existsSync(worktreePath) ? '(exists)' : '(new)';
196
+ console.log(` ${ticket.frontmatter.id} — ${title}`);
197
+ console.log(` 🌿 ${branchName} ${status}`);
198
+ console.log(` šŸ“‚ ${worktreePath}`);
199
+ console.log('');
200
+ }
201
+
202
+ if (flags.dryRun) {
203
+ console.log('šŸ Dry run complete. No changes made.');
204
+ return;
205
+ }
206
+
207
+ // Create worktrees
208
+ for (const info of worktreeInfos) {
209
+ if (info.alreadyExists) {
210
+ console.log(`āœ… ${info.ticket.frontmatter.id}: Worktree already exists`);
211
+ continue;
212
+ }
213
+
214
+ try {
215
+ const branchExists = branchExistsLocally(info.branchName) || branchExistsRemotely(info.branchName);
216
+ if (branchExists) {
217
+ createWorktreeExistingBranch(info.worktreePath, info.branchName);
218
+ } else {
219
+ createWorktree(info.worktreePath, info.branchName, flags.baseBranch);
220
+ }
221
+ console.log(`āœ… ${info.ticket.frontmatter.id}: Worktree created`);
222
+ } catch (error) {
223
+ console.error(`āŒ ${info.ticket.frontmatter.id}: Failed to create worktree — ${error.message}`);
224
+ continue;
225
+ }
226
+
227
+ updateTicketStatus(info.ticket, info.worktreePath);
228
+ }
229
+
230
+ // Install dependencies in worktrees
231
+ if (!flags.noInstall) {
232
+ const pkgExists = fs.existsSync(path.join(repoRoot, 'package.json'));
233
+ if (pkgExists) {
234
+ console.log('\nšŸ“¦ Installing dependencies in worktrees...');
235
+ for (const info of worktreeInfos) {
236
+ if (fs.existsSync(path.join(info.worktreePath, 'package.json'))) {
237
+ try {
238
+ execSync('npm install --silent', { cwd: info.worktreePath, stdio: 'ignore' });
239
+ console.log(` āœ… ${info.ticket.frontmatter.id}: npm install done`);
240
+ } catch (error) {
241
+ console.warn(` āš ļø ${info.ticket.frontmatter.id}: npm install failed — ${error.message}`);
242
+ }
243
+ }
244
+ }
245
+ }
246
+ }
247
+
248
+ // Spawn Claude agents
249
+ console.log('\nšŸ¤– Spawning Claude agents...\n');
250
+
251
+ const agents = [];
252
+ for (const info of worktreeInfos) {
253
+ const ticketContent = fs.readFileSync(info.ticket.filePath, 'utf-8');
254
+ const prompt = flags.prompt
255
+ ? flags.prompt
256
+ : `You are working on ticket ${info.ticket.frontmatter.id}: ${info.title}\n\nHere is the full ticket:\n\n${ticketContent}\n\nImplement the ticket requirements. Follow the acceptance criteria. Commit your work when done.`;
257
+
258
+ try {
259
+ const agentProcess = spawn('claude', ['-p', prompt, '--allowedTools', 'Edit,Write,Bash,Read,Glob,Grep'], {
260
+ cwd: info.worktreePath,
261
+ stdio: 'ignore',
262
+ detached: true
263
+ });
264
+
265
+ agentProcess.unref();
266
+ agents.push({ id: info.ticket.frontmatter.id, pid: agentProcess.pid });
267
+ console.log(` šŸ¤– ${info.ticket.frontmatter.id}: Agent spawned (PID ${agentProcess.pid}) in ${info.worktreePath}`);
268
+ } catch (error) {
269
+ console.error(` āŒ ${info.ticket.frontmatter.id}: Failed to spawn agent — ${error.message}`);
270
+ }
271
+ }
272
+
273
+ console.log('\nšŸ All agents launched!\n');
274
+ console.log('Monitor progress:');
275
+ console.log(' vibe list --status=in_progress # see active tickets');
276
+ console.log(' git worktree list # see all worktrees');
277
+ console.log('');
278
+ console.log('When agents finish, open PRs:');
279
+ console.log(' vibe pr --all # open PRs for all worktree branches');
280
+ console.log(` vibe pr ${tickets.map(t => t.frontmatter.id).join(' ')} # open PRs for specific tickets`);
281
+ }
282
+
283
+ export default planCommand;
@@ -0,0 +1,127 @@
1
+ import { jest } from '@jest/globals';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { execSync } from 'child_process';
5
+
6
+ // Mock dependencies
7
+ jest.unstable_mockModule('child_process', () => ({
8
+ execSync: jest.fn(),
9
+ spawn: jest.fn(() => ({ unref: jest.fn(), pid: 12345 }))
10
+ }));
11
+
12
+ let ticketIdCounter = 1;
13
+ jest.unstable_mockModule('../../utils/index.js', () => ({
14
+ getTicketsDir: jest.fn(),
15
+ getConfig: jest.fn(() => ({ git: { branch_prefix: 'feature/' } })),
16
+ createSlug: jest.fn(title => title.toLowerCase().replace(/\s+/g, '-')),
17
+ getNextTicketId: jest.fn(() => `TKT-${String(ticketIdCounter++).padStart(3, '0')}`)
18
+ }));
19
+
20
+ jest.unstable_mockModule('../../utils/git.js', () => ({
21
+ isGitRepository: jest.fn(() => true),
22
+ getRepoName: jest.fn(() => 'test-repo'),
23
+ getRepoRoot: jest.fn(() => '/tmp/test-repo'),
24
+ getWorktreePath: jest.fn((repo, branch) => `/tmp/.vibekit/worktrees/${repo}/${branch}`),
25
+ createWorktree: jest.fn(),
26
+ createWorktreeExistingBranch: jest.fn(),
27
+ branchExistsLocally: jest.fn(() => false),
28
+ branchExistsRemotely: jest.fn(() => false),
29
+ getDefaultBaseBranch: jest.fn(() => 'main')
30
+ }));
31
+
32
+ const { default: planCommand } = await import('./index.js');
33
+ const { getTicketsDir, getConfig } = await import('../../utils/index.js');
34
+ const { isGitRepository, getWorktreePath, createWorktree } = await import('../../utils/git.js');
35
+ const childProcess = await import('child_process');
36
+
37
+ const TICKET_CONTENT = `---
38
+ id: TKT-001
39
+ title: Add login page
40
+ slug: TKT-001-add-login-page
41
+ status: open
42
+ priority: high
43
+ assignee: dev1
44
+ author: pm
45
+ created_at: "2026-01-01T00:00:00Z"
46
+ updated_at: "2026-01-01T00:00:00Z"
47
+ ---
48
+
49
+ ## Description
50
+ Add a login page.
51
+
52
+ ## Acceptance Criteria
53
+ - [ ] Login form exists
54
+ `;
55
+
56
+ describe('plan command', () => {
57
+ let tempDir;
58
+ let mockExit;
59
+ let mockConsoleLog;
60
+ let mockConsoleError;
61
+
62
+ beforeEach(() => {
63
+ tempDir = fs.mkdtempSync(path.join('/tmp', 'vibe-plan-test-'));
64
+ const ticketsDir = path.join(tempDir, '.vibe', 'tickets');
65
+ fs.mkdirSync(ticketsDir, { recursive: true });
66
+ fs.writeFileSync(path.join(ticketsDir, 'TKT-001-add-login-page.md'), TICKET_CONTENT);
67
+
68
+ getTicketsDir.mockReturnValue(ticketsDir);
69
+ mockExit = jest.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); });
70
+ mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => {});
71
+ mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
72
+ childProcess.execSync.mockImplementation(() => '');
73
+
74
+ // Worktree path doesn't exist yet
75
+ getWorktreePath.mockReturnValue(path.join(tempDir, 'worktree-TKT-001'));
76
+ });
77
+
78
+ afterEach(() => {
79
+ mockExit.mockRestore();
80
+ mockConsoleLog.mockRestore();
81
+ mockConsoleError.mockRestore();
82
+ fs.rmSync(tempDir, { recursive: true, force: true });
83
+ jest.clearAllMocks();
84
+ });
85
+
86
+ test('shows usage when no arguments provided', async () => {
87
+ await expect(planCommand([])).rejects.toThrow('process.exit');
88
+ expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Usage:'));
89
+ });
90
+
91
+ test('exits if not in git repo', async () => {
92
+ isGitRepository.mockReturnValueOnce(false);
93
+ await expect(planCommand(['TKT-001'])).rejects.toThrow('process.exit');
94
+ expect(mockConsoleError).toHaveBeenCalledWith('āŒ Not in a git repository.');
95
+ });
96
+
97
+ test('dry run shows plan without creating worktrees', async () => {
98
+ await planCommand(['TKT-001', '--dry-run']);
99
+ expect(mockConsoleLog).toHaveBeenCalledWith('šŸ Dry run complete. No changes made.');
100
+ expect(createWorktree).not.toHaveBeenCalled();
101
+ });
102
+
103
+ test('creates worktree and spawns agent for ticket', async () => {
104
+ await planCommand(['TKT-001', '--no-install']);
105
+ expect(createWorktree).toHaveBeenCalled();
106
+ expect(childProcess.spawn).toHaveBeenCalledWith(
107
+ 'claude',
108
+ expect.arrayContaining(['-p']),
109
+ expect.objectContaining({ stdio: 'ignore', detached: true })
110
+ );
111
+ });
112
+
113
+ test('handles --status=open filter', async () => {
114
+ await planCommand(['--status=open', '--dry-run']);
115
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('TKT-001'));
116
+ });
117
+
118
+ test('errors on invalid ticket ID', async () => {
119
+ await expect(planCommand(['INVALID'])).rejects.toThrow('process.exit');
120
+ expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Invalid ticket ID'));
121
+ });
122
+
123
+ test('errors when ticket not found', async () => {
124
+ await expect(planCommand(['TKT-999'])).rejects.toThrow('process.exit');
125
+ expect(mockConsoleError).toHaveBeenCalledWith('āŒ Ticket TKT-999 not found.');
126
+ });
127
+ });