@vibedx/vibekit 0.6.3 → 0.7.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.
package/assets/config.yml CHANGED
@@ -24,6 +24,9 @@ tickets:
24
24
  team:
25
25
  path: .vibe/team.yml
26
26
 
27
+ agent:
28
+ timeout: 900
29
+
27
30
  ai:
28
31
  enabled: false
29
32
 
package/assets/team.yml CHANGED
@@ -6,17 +6,23 @@
6
6
  # vibe team add <id> --name "Name" ... - Add a member
7
7
  # vibe team remove <id> - Remove a member
8
8
  # vibe new "task" --assignee <id> - Assign ticket to member
9
+ #
10
+ # No need to edit this file manually — use the CLI commands above.
11
+ # But if you prefer to edit directly, just add entries under `members:`
12
+ # like this:
13
+ #
14
+ # members:
15
+ # jane-doe:
16
+ # name: Jane Doe
17
+ # github: jane-doe
18
+ # slack: U04ABC12DEF
19
+ # x: janedoe
20
+ # role: Founder
21
+ # john-smith:
22
+ # name: John Smith
23
+ # github: john-smith
24
+ # slack: U05XYZ34GHI
25
+ # x: johnsmith
26
+ # role: Engineer
9
27
 
10
28
  members: {}
11
- # Example:
12
- # mani-yadv:
13
- # name: Mani
14
- # github: mani-yadv
15
- # slack: U0XXXXXXXX
16
- # x: vernon1943
17
- # role: Founder
18
- # opusaku:
19
- # name: Opus
20
- # github: opusaku
21
- # slack: U0XXXXXXXX
22
- # role: Senior Engineer
package/index.js CHANGED
@@ -23,7 +23,8 @@ const __dirname = dirname(__filename);
23
23
  // Available commands in VibeKit
24
24
  const AVAILABLE_COMMANDS = [
25
25
  'init', 'new', 'close', 'list', 'get-started',
26
- 'start', 'link', 'unlink', 'refine', 'lint', 'review', 'team', 'skills'
26
+ 'start', 'link', 'unlink', 'refine', 'lint', 'review', 'team', 'skills',
27
+ 'status', 'pr'
27
28
  ];
28
29
 
29
30
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibedx/vibekit",
3
- "version": "0.6.3",
3
+ "version": "0.7.0",
4
4
  "description": "A powerful CLI tool for managing development tickets and project workflows",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,259 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import yaml from 'js-yaml';
4
+ import { execSync } from 'child_process';
5
+ import { getTicketsDir, getConfig } from '../../utils/index.js';
6
+ import {
7
+ isGitRepository,
8
+ getCurrentBranch,
9
+ getDefaultBaseBranch,
10
+ listWorktrees,
11
+ getRepoRoot
12
+ } from '../../utils/git.js';
13
+
14
+ function parseArgs(args) {
15
+ const flags = {};
16
+ const ids = [];
17
+
18
+ let i = 0;
19
+ while (i < args.length) {
20
+ const arg = args[i];
21
+ if (arg === '--all' || arg === '-a') {
22
+ flags.all = true;
23
+ i++;
24
+ } else if (arg === '--base' && i + 1 < args.length) {
25
+ flags.baseBranch = args[i + 1];
26
+ i += 2;
27
+ } else if (arg === '--draft' || arg === '-d') {
28
+ flags.draft = true;
29
+ i++;
30
+ } else if (arg === '--dry-run') {
31
+ flags.dryRun = true;
32
+ i++;
33
+ } else if (!arg.startsWith('-')) {
34
+ ids.push(arg);
35
+ i++;
36
+ } else {
37
+ i++;
38
+ }
39
+ }
40
+ return { ids, flags };
41
+ }
42
+
43
+ function normalizeTicketId(input) {
44
+ if (input.startsWith('TKT-')) return input;
45
+ if (/^\d+$/.test(input)) return `TKT-${input.padStart(3, '0')}`;
46
+ return null;
47
+ }
48
+
49
+ function findTicketForBranch(ticketsDir, branch) {
50
+ const files = fs.readdirSync(ticketsDir).filter(f => f.endsWith('.md'));
51
+ for (const file of files) {
52
+ const filePath = path.join(ticketsDir, file);
53
+ const content = fs.readFileSync(filePath, 'utf-8');
54
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
55
+ if (match) {
56
+ const fm = yaml.load(match[1]);
57
+ const slug = fm.slug || '';
58
+ if (branch.includes(fm.id) || branch.includes(slug)) {
59
+ const body = content.split('---').slice(2).join('---').trim();
60
+ return { frontmatter: fm, body, filePath };
61
+ }
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+
67
+ function loadTicket(ticketsDir, ticketId) {
68
+ const files = fs.readdirSync(ticketsDir).filter(f => f.startsWith(`${ticketId}-`));
69
+ if (files.length === 0) return null;
70
+ const filePath = path.join(ticketsDir, files[0]);
71
+ const content = fs.readFileSync(filePath, 'utf-8');
72
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
73
+ if (!match) return null;
74
+ const fm = yaml.load(match[1]);
75
+ const body = content.split('---').slice(2).join('---').trim();
76
+ return { frontmatter: fm, body, filePath };
77
+ }
78
+
79
+ function hasRemote() {
80
+ try {
81
+ execSync('git remote get-url origin', { stdio: 'ignore' });
82
+ return true;
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ function pushBranch(branch, cwd) {
89
+ try {
90
+ execSync(`git push -u origin ${branch}`, { cwd, stdio: 'pipe' });
91
+ return true;
92
+ } catch (error) {
93
+ console.error(` ❌ Failed to push ${branch}: ${error.message}`);
94
+ return false;
95
+ }
96
+ }
97
+
98
+ function createPR(branch, baseBranch, title, body, draft, cwd) {
99
+ const draftFlag = draft ? ' --draft' : '';
100
+ const safeTitle = title.replace(/"/g, '\\"');
101
+ const safeBody = body.replace(/'/g, "'\\''");
102
+ try {
103
+ const result = execSync(
104
+ `gh pr create --head "${branch}" --base "${baseBranch}" --title "${safeTitle}"${draftFlag} --body '${safeBody}'`,
105
+ { cwd, encoding: 'utf-8' }
106
+ );
107
+ return result.trim();
108
+ } catch (error) {
109
+ if (error.message.includes('already exists')) {
110
+ try {
111
+ const existing = execSync(`gh pr view "${branch}" --json url -q .url`, { cwd, encoding: 'utf-8' });
112
+ return existing.trim() + ' (already existed)';
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+ console.error(` ❌ Failed to create PR: ${error.message}`);
118
+ return null;
119
+ }
120
+ }
121
+
122
+ function buildPRBody(ticket) {
123
+ if (!ticket) return 'No ticket context found.';
124
+
125
+ const fm = ticket.frontmatter;
126
+ const sections = [];
127
+ sections.push(`## ${fm.id}: ${fm.title}`);
128
+ if (fm.priority) sections.push(`**Priority:** ${fm.priority}`);
129
+ if (fm.assignee) sections.push(`**Assignee:** ${fm.assignee}`);
130
+ sections.push('');
131
+ sections.push(ticket.body);
132
+ return sections.join('\n');
133
+ }
134
+
135
+ function getBranchCwd(worktrees, branch) {
136
+ const wt = worktrees.find(w => w.branch === branch);
137
+ return wt ? wt.path : null;
138
+ }
139
+
140
+ async function prCommand(args) {
141
+ if (!isGitRepository()) {
142
+ console.error('❌ Not in a git repository.');
143
+ process.exit(1);
144
+ }
145
+
146
+ if (!hasRemote()) {
147
+ console.error('❌ No git remote "origin" found. Push your repo to GitHub first.');
148
+ process.exit(1);
149
+ }
150
+
151
+ try {
152
+ execSync('gh --version', { stdio: 'ignore' });
153
+ } catch {
154
+ console.error('❌ GitHub CLI (gh) is required. Install it: https://cli.github.com');
155
+ process.exit(1);
156
+ }
157
+
158
+ const { ids, flags } = parseArgs(args);
159
+ const config = getConfig();
160
+ const ticketsDir = getTicketsDir();
161
+ const baseBranch = flags.baseBranch || config.git?.default_base || getDefaultBaseBranch();
162
+ const worktrees = listWorktrees();
163
+ const repoRoot = getRepoRoot();
164
+
165
+ let branches = [];
166
+
167
+ if (flags.all) {
168
+ // Gather all worktree branches (excluding the main worktree)
169
+ const mainRoot = repoRoot;
170
+ branches = worktrees
171
+ .filter(wt => wt.branch && wt.path !== mainRoot && wt.branch !== baseBranch)
172
+ .map(wt => ({ branch: wt.branch, cwd: wt.path }));
173
+
174
+ if (branches.length === 0) {
175
+ console.error('❌ No worktree branches found. Use `vibe plan` to create them.');
176
+ process.exit(1);
177
+ }
178
+ } else if (ids.length > 0) {
179
+ // Look up branches for specific ticket IDs
180
+ for (const id of ids) {
181
+ const ticketId = normalizeTicketId(id);
182
+ if (!ticketId) {
183
+ console.error(`❌ Invalid ticket ID: ${id}`);
184
+ process.exit(1);
185
+ }
186
+ const ticket = loadTicket(ticketsDir, ticketId);
187
+ if (!ticket) {
188
+ console.error(`❌ Ticket ${ticketId} not found.`);
189
+ process.exit(1);
190
+ }
191
+ const branchPrefix = config.git?.branch_prefix || '';
192
+ const slug = ticket.frontmatter.slug || ticket.frontmatter.id;
193
+ const branchName = slug.includes(ticket.frontmatter.id)
194
+ ? `${branchPrefix}${slug}`
195
+ : `${branchPrefix}${ticket.frontmatter.id}-${slug}`;
196
+
197
+ const wtCwd = getBranchCwd(worktrees, branchName);
198
+ branches.push({ branch: branchName, cwd: wtCwd || repoRoot, ticketId });
199
+ }
200
+ } else {
201
+ // Current branch
202
+ const currentBranch = getCurrentBranch();
203
+ if (!currentBranch || currentBranch === baseBranch) {
204
+ console.error(`❌ You're on ${currentBranch || 'a detached HEAD'}. Switch to a feature branch or specify ticket IDs.`);
205
+ console.error('');
206
+ console.error('Usage: vibe pr [TKT-001 ...] [--all] [--draft] [--base main]');
207
+ process.exit(1);
208
+ }
209
+ branches.push({ branch: currentBranch, cwd: repoRoot });
210
+ }
211
+
212
+ console.log(`\n📝 Opening ${branches.length} PR(s) against ${baseBranch}:\n`);
213
+
214
+ if (flags.dryRun) {
215
+ for (const { branch } of branches) {
216
+ const ticket = findTicketForBranch(ticketsDir, branch);
217
+ const title = ticket ? `${ticket.frontmatter.id}: ${ticket.frontmatter.title}` : branch;
218
+ console.log(` ${branch} → "${title}"`);
219
+ }
220
+ console.log('\n🏁 Dry run complete. No PRs created.');
221
+ return;
222
+ }
223
+
224
+ const results = [];
225
+ for (const { branch, cwd } of branches) {
226
+ const ticket = findTicketForBranch(ticketsDir, branch);
227
+ const title = ticket ? `${ticket.frontmatter.id}: ${ticket.frontmatter.title}` : branch;
228
+ const body = buildPRBody(ticket);
229
+
230
+ console.log(` 📤 ${branch}`);
231
+
232
+ if (!pushBranch(branch, cwd)) continue;
233
+
234
+ const prUrl = createPR(branch, baseBranch, title, body, flags.draft, cwd);
235
+ if (prUrl) {
236
+ console.log(` ✅ ${prUrl}`);
237
+ results.push({ branch, url: prUrl });
238
+
239
+ // Update ticket status to review
240
+ if (ticket) {
241
+ try {
242
+ const currentContent = fs.readFileSync(ticket.filePath, 'utf-8');
243
+ const now = new Date().toISOString();
244
+ const updatedContent = currentContent
245
+ .replace(/^status: (.+)$/m, 'status: review')
246
+ .replace(/^updated_at: (.+)$/m, `updated_at: "${now}"`);
247
+ fs.writeFileSync(ticket.filePath, updatedContent, 'utf-8');
248
+ } catch {
249
+ // Non-critical — skip
250
+ }
251
+ }
252
+ }
253
+ console.log('');
254
+ }
255
+
256
+ console.log(`🏁 Done! ${results.length}/${branches.length} PR(s) opened.`);
257
+ }
258
+
259
+ export default prCommand;
@@ -0,0 +1,139 @@
1
+ import { jest } from '@jest/globals';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+
5
+ jest.unstable_mockModule('child_process', () => ({
6
+ execSync: jest.fn()
7
+ }));
8
+
9
+ jest.unstable_mockModule('../../utils/index.js', () => ({
10
+ getTicketsDir: jest.fn(),
11
+ getConfig: jest.fn(() => ({ git: { branch_prefix: 'feature/', default_base: 'main' } }))
12
+ }));
13
+
14
+ jest.unstable_mockModule('../../utils/git.js', () => ({
15
+ isGitRepository: jest.fn(() => true),
16
+ getCurrentBranch: jest.fn(() => 'feature/TKT-001-add-login'),
17
+ getDefaultBaseBranch: jest.fn(() => 'main'),
18
+ listWorktrees: jest.fn(() => []),
19
+ getRepoRoot: jest.fn(() => '/tmp/test-repo')
20
+ }));
21
+
22
+ const { default: prCommand } = await import('./index.js');
23
+ const { getTicketsDir } = await import('../../utils/index.js');
24
+ const { isGitRepository, getCurrentBranch, listWorktrees, getRepoRoot } = await import('../../utils/git.js');
25
+ const childProcess = await import('child_process');
26
+
27
+ const TICKET_CONTENT = `---
28
+ id: TKT-001
29
+ title: Add login page
30
+ slug: TKT-001-add-login-page
31
+ status: in_progress
32
+ priority: high
33
+ assignee: dev1
34
+ author: pm
35
+ created_at: "2026-01-01T00:00:00Z"
36
+ updated_at: "2026-01-01T00:00:00Z"
37
+ ---
38
+
39
+ ## Description
40
+ Add a login page.
41
+ `;
42
+
43
+ describe('pr command', () => {
44
+ let tempDir;
45
+ let mockExit;
46
+ let mockConsoleLog;
47
+ let mockConsoleError;
48
+
49
+ beforeEach(() => {
50
+ tempDir = fs.mkdtempSync(path.join('/tmp', 'vibe-pr-test-'));
51
+ const ticketsDir = path.join(tempDir, '.vibe', 'tickets');
52
+ fs.mkdirSync(ticketsDir, { recursive: true });
53
+ fs.writeFileSync(path.join(ticketsDir, 'TKT-001-add-login-page.md'), TICKET_CONTENT);
54
+
55
+ getTicketsDir.mockReturnValue(ticketsDir);
56
+ mockExit = jest.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); });
57
+ mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => {});
58
+ mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
59
+
60
+ // Default: gh is installed, origin exists
61
+ childProcess.execSync.mockImplementation((cmd) => {
62
+ if (cmd === 'gh --version') return 'gh version 2.0.0';
63
+ if (cmd.includes('remote get-url')) return 'git@github.com:org/repo.git';
64
+ if (cmd.includes('git push')) return '';
65
+ if (cmd.includes('gh pr create')) return 'https://github.com/org/repo/pull/1';
66
+ return '';
67
+ });
68
+ });
69
+
70
+ afterEach(() => {
71
+ mockExit.mockRestore();
72
+ mockConsoleLog.mockRestore();
73
+ mockConsoleError.mockRestore();
74
+ fs.rmSync(tempDir, { recursive: true, force: true });
75
+ jest.clearAllMocks();
76
+ });
77
+
78
+ test('exits if not in git repo', async () => {
79
+ isGitRepository.mockReturnValueOnce(false);
80
+ await expect(prCommand([])).rejects.toThrow('process.exit');
81
+ expect(mockConsoleError).toHaveBeenCalledWith('❌ Not in a git repository.');
82
+ });
83
+
84
+ test('exits if no remote', async () => {
85
+ childProcess.execSync.mockImplementation((cmd) => {
86
+ if (cmd.includes('remote get-url')) throw new Error('no remote');
87
+ return '';
88
+ });
89
+ await expect(prCommand([])).rejects.toThrow('process.exit');
90
+ expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('No git remote'));
91
+ });
92
+
93
+ test('exits if gh not installed', async () => {
94
+ childProcess.execSync.mockImplementation((cmd) => {
95
+ if (cmd === 'gh --version') throw new Error('not found');
96
+ if (cmd.includes('remote get-url')) return 'git@github.com:org/repo.git';
97
+ return '';
98
+ });
99
+ await expect(prCommand([])).rejects.toThrow('process.exit');
100
+ expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('GitHub CLI'));
101
+ });
102
+
103
+ test('creates PR for current branch', async () => {
104
+ await prCommand([]);
105
+ expect(childProcess.execSync).toHaveBeenCalledWith(
106
+ expect.stringContaining('gh pr create'),
107
+ expect.any(Object)
108
+ );
109
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('https://github.com'));
110
+ });
111
+
112
+ test('dry run shows branches without creating PRs', async () => {
113
+ await prCommand(['--dry-run']);
114
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Dry run complete'));
115
+ const prCreateCalls = childProcess.execSync.mock.calls.filter(([cmd]) =>
116
+ typeof cmd === 'string' && cmd.includes('gh pr create')
117
+ );
118
+ expect(prCreateCalls.length).toBe(0);
119
+ });
120
+
121
+ test('errors when on base branch with no args', async () => {
122
+ getCurrentBranch.mockReturnValueOnce('main');
123
+ await expect(prCommand([])).rejects.toThrow('process.exit');
124
+ expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining("You're on main"));
125
+ });
126
+
127
+ test('--all collects worktree branches', async () => {
128
+ listWorktrees.mockReturnValueOnce([
129
+ { path: '/tmp/test-repo', branch: 'main' },
130
+ { path: '/tmp/wt-1', branch: 'feature/TKT-001-login' },
131
+ { path: '/tmp/wt-2', branch: 'feature/TKT-002-signup' }
132
+ ]);
133
+ getRepoRoot.mockReturnValueOnce('/tmp/test-repo');
134
+
135
+ await prCommand(['--all']);
136
+ const prCalls = childProcess.execSync.mock.calls.filter(([cmd]) => cmd.includes('gh pr create'));
137
+ expect(prCalls.length).toBe(2);
138
+ });
139
+ });
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import yaml from 'js-yaml';
4
+ import { execSync, spawn } from 'child_process';
4
5
  import { getTicketsDir, getConfig, createSlug } from '../../utils/index.js';
5
6
  import {
6
7
  isGitRepository,
@@ -13,230 +14,341 @@ import {
13
14
  getRepoName,
14
15
  getWorktreePath,
15
16
  createWorktree,
16
- createWorktreeExistingBranch
17
+ createWorktreeExistingBranch,
18
+ getRepoRoot,
19
+ getDefaultBaseBranch
17
20
  } from '../../utils/git.js';
18
21
 
22
+ function parseTicketIds(args) {
23
+ const ids = [];
24
+ const flags = {};
25
+ let i = 0;
26
+ while (i < args.length) {
27
+ const arg = args[i];
28
+ if (arg === '--base' && i + 1 < args.length) {
29
+ flags.baseBranch = args[i + 1];
30
+ i += 2;
31
+ } else if (arg === '--dry-run') {
32
+ flags.dryRun = true;
33
+ i++;
34
+ } else if (arg === '--no-install') {
35
+ flags.noInstall = true;
36
+ i++;
37
+ } else if (arg === '--agent') {
38
+ flags.agent = true;
39
+ i++;
40
+ } else if (arg === '--worktree' || arg === '-w') {
41
+ flags.worktree = true;
42
+ i++;
43
+ } else if (arg === '--update-status' || arg === '-u') {
44
+ flags.updateStatus = true;
45
+ i++;
46
+ } else if (arg === '--prompt' && i + 1 < args.length) {
47
+ flags.prompt = args[i + 1];
48
+ i += 2;
49
+ } else if (arg.startsWith('--status=')) {
50
+ flags.statusFilter = arg.split('=')[1];
51
+ i++;
52
+ } else if (!arg.startsWith('-')) {
53
+ ids.push(arg);
54
+ i++;
55
+ } else {
56
+ i++;
57
+ }
58
+ }
59
+ return { ids, flags };
60
+ }
61
+
62
+ function normalizeTicketId(input) {
63
+ if (input.startsWith('TKT-')) return input;
64
+ if (/^\d+$/.test(input)) return `TKT-${input.padStart(3, '0')}`;
65
+ return null;
66
+ }
67
+
68
+ function loadTicket(ticketsDir, ticketId) {
69
+ const files = fs.readdirSync(ticketsDir).filter(f => f.startsWith(`${ticketId}-`));
70
+ if (files.length === 0) return null;
71
+ const filePath = path.join(ticketsDir, files[0]);
72
+ const content = fs.readFileSync(filePath, 'utf-8');
73
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
74
+ if (!match) return null;
75
+ const frontmatter = yaml.load(match[1]);
76
+ return { frontmatter, content, filePath, fileName: files[0] };
77
+ }
78
+
79
+ function loadTicketsByStatus(ticketsDir, status) {
80
+ const files = fs.readdirSync(ticketsDir).filter(f => f.endsWith('.md'));
81
+ const tickets = [];
82
+ for (const file of files) {
83
+ const filePath = path.join(ticketsDir, file);
84
+ const content = fs.readFileSync(filePath, 'utf-8');
85
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
86
+ if (match) {
87
+ const frontmatter = yaml.load(match[1]);
88
+ if (frontmatter.status === status) {
89
+ tickets.push({ frontmatter, content, filePath, fileName: file });
90
+ }
91
+ }
92
+ }
93
+ return tickets;
94
+ }
95
+
96
+ function getBranchName(ticket, config) {
97
+ const branchPrefix = config.git?.branch_prefix || '';
98
+ const slug = String(ticket.frontmatter.slug || ticket.frontmatter.id);
99
+ return slug.includes(ticket.frontmatter.id)
100
+ ? `${branchPrefix}${slug}`
101
+ : `${branchPrefix}${ticket.frontmatter.id}-${slug}`;
102
+ }
103
+
104
+ function updateTicketStatus(ticket, worktreePath) {
105
+ const now = new Date().toISOString();
106
+ let updatedContent = ticket.content;
107
+
108
+ if (updatedContent.match(/^worktree_path: .+$/m)) {
109
+ updatedContent = updatedContent.replace(/^worktree_path: .+$/m, `worktree_path: "${worktreePath}"`);
110
+ } else {
111
+ updatedContent = updatedContent.replace(/^(updated_at: .+)$/m, `$1\nworktree_path: "${worktreePath}"`);
112
+ }
113
+
114
+ updatedContent = updatedContent
115
+ .replace(/^status: (.+)$/m, 'status: in_progress')
116
+ .replace(/^updated_at: (.+)$/m, `updated_at: "${now}"`);
117
+
118
+ fs.writeFileSync(ticket.filePath, updatedContent, 'utf-8');
119
+ }
120
+
19
121
  /**
20
- * Start working on a ticket by checking out its branch
122
+ * Start working on ticket(s) by checking out branches or creating worktrees
21
123
  * @param {string[]} args Command arguments
22
124
  */
23
125
  function startCommand(args) {
24
- // Check if we're in a git repository
25
126
  if (!isGitRepository()) {
26
- console.error('❌ Not in a git repository. Please run this command from within a git repository.');
127
+ console.error('❌ Not in a git repository.');
27
128
  process.exit(1);
28
129
  }
29
-
30
- // Parse arguments
31
- if (args.length === 0) {
32
- console.error('❌ Please provide a ticket ID (e.g., vibe start TKT-006)');
130
+
131
+ const { ids, flags } = parseTicketIds(args);
132
+
133
+ if (ids.length === 0 && !flags.statusFilter) {
134
+ console.error('❌ Usage: vibe start <TKT-001> [<TKT-002> ...] [-w] [--agent] [--base main] [--dry-run]');
135
+ console.error('');
136
+ console.error('Start working on tickets. Without -w, checks out branch locally. With -w, creates worktrees.');
137
+ console.error('');
138
+ console.error('Options:');
139
+ console.error(' -w, --worktree Create worktrees for tickets');
140
+ console.error(' --agent Spawn Claude agents to work on tickets');
141
+ console.error(' --status=<status> Start all tickets with this status (e.g. --status=open)');
142
+ console.error(' --base <branch> Base branch for new branches/worktrees (default: main)');
143
+ console.error(' --dry-run Show what would happen without doing it');
144
+ console.error(' --no-install Skip npm install in worktrees');
33
145
  process.exit(1);
34
146
  }
35
-
36
- // Extract ticket ID and options
37
- let ticketId = args[0];
38
- let baseBranch = null;
39
- let updateStatus = true;
40
- let useWorktree = false;
41
-
42
- // Process additional arguments
43
- for (let i = 1; i < args.length; i++) {
44
- if (args[i] === '--base' && i + 1 < args.length) {
45
- baseBranch = args[i + 1];
46
- i++;
47
- } else if (args[i] === '--update-status' || args[i] === '-u') {
48
- updateStatus = true;
49
- } else if (args[i] === '--worktree' || args[i] === '-w') {
50
- useWorktree = true;
147
+
148
+ const config = getConfig();
149
+ const ticketsDir = getTicketsDir();
150
+ const repoName = getRepoName();
151
+ const repoRoot = getRepoRoot();
152
+
153
+ let tickets = [];
154
+
155
+ if (flags.statusFilter) {
156
+ tickets = loadTicketsByStatus(ticketsDir, flags.statusFilter);
157
+ if (tickets.length === 0) {
158
+ console.error(`❌ No tickets found with status: ${flags.statusFilter}`);
159
+ process.exit(1);
51
160
  }
161
+ console.log(`📋 Found ${tickets.length} ticket(s) with status "${flags.statusFilter}"`);
52
162
  }
53
-
54
- // Normalize ticket ID format (add TKT- prefix if not present)
55
- if (!ticketId.startsWith('TKT-')) {
56
- // Check if it's just a number
57
- if (/^\d+$/.test(ticketId)) {
58
- ticketId = `TKT-${ticketId.padStart(3, '0')}`;
59
- } else {
60
- console.error('❌ Invalid ticket ID format. Expected TKT-XXX or just the number.');
163
+
164
+ for (const id of ids) {
165
+ const ticketId = normalizeTicketId(id);
166
+ if (!ticketId) {
167
+ console.error(`❌ Invalid ticket ID: ${id}`);
168
+ process.exit(1);
169
+ }
170
+ const ticket = loadTicket(ticketsDir, ticketId);
171
+ if (!ticket) {
172
+ console.error(`❌ Ticket ${ticketId} not found.`);
61
173
  process.exit(1);
62
174
  }
175
+ if (!tickets.find(t => t.frontmatter.id === ticket.frontmatter.id)) {
176
+ tickets.push(ticket);
177
+ }
63
178
  }
64
-
65
- // Get configuration
66
- const config = getConfig();
67
- const ticketsDir = getTicketsDir();
68
-
69
- // Check if the ticket exists
70
- const ticketFiles = fs.readdirSync(ticketsDir).filter(file => file.startsWith(`${ticketId}-`));
71
-
72
- if (ticketFiles.length === 0) {
73
- console.error(`❌ Ticket ${ticketId} not found.`);
179
+
180
+ if (tickets.length === 0) {
181
+ console.error('❌ No tickets to start.');
74
182
  process.exit(1);
75
183
  }
76
-
77
- const ticketFile = ticketFiles[0];
78
- const ticketPath = path.join(ticketsDir, ticketFile);
79
-
80
- // Read the ticket content to get the title and slug
81
- const ticketContent = fs.readFileSync(ticketPath, 'utf-8');
82
- const titleMatch = ticketContent.match(/title: (.+)/);
83
- const title = titleMatch ? titleMatch[1].trim() : '';
84
-
85
- // Look for the slug in the frontmatter
86
- const slugMatch = ticketContent.match(/slug: (.+)/);
87
- let slug;
88
-
89
- if (slugMatch && slugMatch[1].trim()) {
90
- // Use the slug from the ticket file
91
- slug = slugMatch[1].trim();
92
- } else {
93
- // Generate a slug from the title as fallback
94
- slug = `${ticketId}-${createSlug(title)}`;
95
- console.log(`⚠️ No slug found in ticket. Generated slug: ${slug}`);
96
-
97
- // Update the ticket with the generated slug
98
- try {
99
- let updatedContent = ticketContent;
100
- if (updatedContent.includes('slug:')) {
101
- updatedContent = updatedContent.replace(/slug:.*/, `slug: ${slug}`);
102
- } else {
103
- updatedContent = updatedContent.replace(/---/, `---\nslug: ${slug}`);
104
- }
105
-
106
- fs.writeFileSync(ticketPath, updatedContent, 'utf-8');
107
- console.log(`✅ Updated ticket with slug: ${slug}`);
108
- } catch (error) {
109
- console.error(`❌ Failed to update ticket with slug: ${error.message}`);
110
- }
184
+
185
+ const useWorktree = flags.worktree;
186
+ const spawnAgent = flags.agent;
187
+
188
+ if (spawnAgent && !useWorktree && tickets.length > 1) {
189
+ console.error('❌ --agent without -w only supports a single ticket. Use -w for multiple tickets.');
190
+ process.exit(1);
111
191
  }
112
-
113
- // Get branch prefix from config or use default (empty)
114
- const branchPrefix = config.git?.branch_prefix || '';
115
-
116
- // Create branch name - if slug already contains the ticket ID, don't add it again
117
- const branchName = slug.includes(ticketId)
118
- ? `${branchPrefix}${slug}`
119
- : `${branchPrefix}${ticketId}-${slug}`;
120
-
121
- if (useWorktree) {
122
- const repoName = getRepoName();
123
- const worktreePath = getWorktreePath(repoName, branchName);
124
192
 
125
- if (fs.existsSync(worktreePath)) {
126
- console.log(`🔍 Worktree already exists at: ${worktreePath}`);
127
- console.log(`✅ Ready to work in: ${worktreePath}`);
193
+ console.log('');
194
+ console.log(`🚀 Starting ${tickets.length} ticket(s):\n`);
195
+
196
+ const worktreeInfos = [];
197
+
198
+ for (const ticket of tickets) {
199
+ const branchName = getBranchName(ticket, config);
200
+ const title = ticket.frontmatter.title || 'Untitled';
201
+
202
+ if (useWorktree) {
203
+ const worktreePath = getWorktreePath(repoName, branchName);
204
+ worktreeInfos.push({
205
+ ticket,
206
+ branchName,
207
+ worktreePath,
208
+ title,
209
+ alreadyExists: fs.existsSync(worktreePath)
210
+ });
211
+ const status = fs.existsSync(worktreePath) ? '(exists)' : '(new)';
212
+ console.log(` ${ticket.frontmatter.id} — ${title}`);
213
+ console.log(` 🌿 ${branchName} ${status}`);
214
+ console.log(` 📂 ${worktreePath}`);
128
215
  } else {
129
- const branchExistsLocal = branchExistsLocally(branchName);
130
- const branchExistsRemote = branchExistsRemotely(branchName);
216
+ console.log(` ${ticket.frontmatter.id} ${title}`);
217
+ console.log(` 🌿 ${branchName}`);
218
+ }
219
+ console.log('');
220
+ }
221
+
222
+ if (flags.dryRun) {
223
+ console.log('🏁 Dry run complete. No changes made.');
224
+ return;
225
+ }
226
+
227
+ if (useWorktree) {
228
+ for (const info of worktreeInfos) {
229
+ if (info.alreadyExists) {
230
+ console.log(`✅ ${info.ticket.frontmatter.id}: Worktree already exists`);
231
+ continue;
232
+ }
131
233
 
132
234
  try {
133
- if (branchExistsLocal || branchExistsRemote) {
134
- console.log(`🔍 Creating worktree for existing branch: ${branchName}`);
135
- createWorktreeExistingBranch(worktreePath, branchName);
235
+ const branchExists = branchExistsLocally(info.branchName) || branchExistsRemotely(info.branchName);
236
+ if (branchExists) {
237
+ createWorktreeExistingBranch(info.worktreePath, info.branchName);
136
238
  } else {
137
- console.log(`🔍 Creating worktree with new branch: ${branchName}`);
138
- createWorktree(worktreePath, branchName, baseBranch);
239
+ createWorktree(info.worktreePath, info.branchName, flags.baseBranch);
139
240
  }
140
- console.log(`✅ Worktree created at: ${worktreePath}`);
241
+ console.log(`✅ ${info.ticket.frontmatter.id}: Worktree created`);
141
242
  } catch (error) {
142
- console.error(`❌ Failed to create worktree: ${error.message}`);
143
- process.exit(1);
243
+ console.error(`❌ ${info.ticket.frontmatter.id}: Failed to create worktree ${error.message}`);
244
+ continue;
144
245
  }
145
- }
146
246
 
147
- // Store worktree_path in ticket frontmatter
148
- try {
149
- const currentContent = fs.readFileSync(ticketPath, 'utf-8');
150
- const now = new Date().toISOString();
151
- let updatedContent = currentContent;
247
+ updateTicketStatus(info.ticket, info.worktreePath);
248
+ }
152
249
 
153
- if (updatedContent.match(/^worktree_path: .+$/m)) {
154
- updatedContent = updatedContent.replace(/^worktree_path: .+$/m, `worktree_path: "${worktreePath}"`);
155
- } else {
156
- updatedContent = updatedContent.replace(/^(updated_at: .+)$/m, `$1\nworktree_path: "${worktreePath}"`);
250
+ if (!flags.noInstall) {
251
+ const pkgExists = fs.existsSync(path.join(repoRoot, 'package.json'));
252
+ if (pkgExists) {
253
+ console.log('\n📦 Installing dependencies in worktrees...');
254
+ for (const info of worktreeInfos) {
255
+ if (fs.existsSync(path.join(info.worktreePath, 'package.json'))) {
256
+ try {
257
+ execSync('npm install --silent', { cwd: info.worktreePath, stdio: 'ignore' });
258
+ console.log(` ✅ ${info.ticket.frontmatter.id}: npm install done`);
259
+ } catch (error) {
260
+ console.warn(` ⚠️ ${info.ticket.frontmatter.id}: npm install failed — ${error.message}`);
261
+ }
262
+ }
263
+ }
157
264
  }
265
+ }
158
266
 
159
- if (updateStatus) {
160
- updatedContent = updatedContent
161
- .replace(/^status: (.+)$/m, 'status: in_progress')
162
- .replace(/^updated_at: (.+)$/m, `updated_at: "${now}"`);
163
- }
267
+ if (spawnAgent) {
268
+ const agentTimeout = config.worktree?.agent?.timeout || 900;
269
+ console.log('\n🤖 Spawning Claude agents...\n');
270
+ for (const info of worktreeInfos) {
271
+ const ticketContent = fs.readFileSync(info.ticket.filePath, 'utf-8');
272
+ const prompt = flags.prompt
273
+ ? flags.prompt
274
+ : `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. Update the ticket status to done when complete.`;
275
+
276
+ try {
277
+ const agentProcess = spawn('claude', ['-p', prompt, '--timeout', String(agentTimeout * 1000)], {
278
+ cwd: info.worktreePath,
279
+ stdio: 'ignore',
280
+ detached: true
281
+ });
164
282
 
165
- fs.writeFileSync(ticketPath, updatedContent, 'utf-8');
166
- if (updateStatus) {
167
- console.log(`✅ Updated ticket status to: in_progress`);
283
+ agentProcess.unref();
284
+ console.log(` 🤖 ${info.ticket.frontmatter.id}: Agent spawned in ${info.worktreePath} (timeout: ${agentTimeout}s)`);
285
+ } catch (error) {
286
+ console.error(` ❌ ${info.ticket.frontmatter.id}: Failed to spawn agent — ${error.message}`);
287
+ }
168
288
  }
169
- } catch (error) {
170
- console.error(`❌ Failed to update ticket: ${error.message}`);
171
- }
172
289
 
173
- console.log('');
174
- console.log(`🎯 Now working on: ${ticketId} - ${title}`);
175
- console.log(`🌿 Branch: ${branchName}`);
176
- console.log(`📂 Worktree: ${worktreePath}`);
177
- console.log('');
178
- console.log('To start working in the worktree:');
179
- console.log(` cd ${worktreePath}`);
180
- console.log('');
181
- console.log('💡 Run `npm install` in the worktree to install dependencies.');
182
- } else {
183
- // Check if there are uncommitted changes
184
- const gitStatus = getGitStatus();
185
- if (gitStatus) {
186
- console.warn('⚠️ You have uncommitted changes. Stash or commit them before switching branches.');
290
+ console.log('\n🏁 All agents launched!\n');
291
+ console.log('Monitor progress:');
292
+ console.log(' vibe status # see active worktree work');
293
+ console.log(' vibe list --status=in_progress # see in-progress tickets');
294
+ } else {
295
+ console.log('\n🏁 Worktrees created!\n');
296
+ console.log('To work in a worktree:');
297
+ console.log(` cd ${worktreeInfos[0]?.worktreePath || '<worktree-path>'}`);
187
298
  console.log('');
299
+ console.log('When done:');
300
+ console.log(' vibe pr --all # open PRs for all worktree branches');
188
301
  }
302
+ } else {
303
+ for (const ticket of tickets) {
304
+ const branchName = getBranchName(ticket, config);
305
+ const branchExistsLocal = branchExistsLocally(branchName);
306
+ const branchExistsRemote = branchExistsRemotely(branchName);
189
307
 
190
- // Check if the branch already exists
191
- const branchExistsLocal = branchExistsLocally(branchName);
192
- const branchExistsRemote = branchExistsRemotely(branchName);
193
-
194
- if (branchExistsLocal || branchExistsRemote) {
195
- console.log(`🔍 Branch ${branchName} already exists.`);
196
-
197
- if (checkoutBranch(branchName)) {
198
- console.log(`✅ Switched to branch: ${branchName}`);
308
+ if (branchExistsLocal || branchExistsRemote) {
309
+ console.log(`🔍 ${ticket.frontmatter.id}: Branch exists`);
199
310
  } else {
200
- console.error(`❌ Failed to switch to branch: ${branchName}`);
201
- process.exit(1);
311
+ try {
312
+ createAndCheckoutBranch(branchName, flags.baseBranch);
313
+ console.log(`✅ ${ticket.frontmatter.id}: Created branch ${branchName}`);
314
+ } catch (error) {
315
+ console.error(`❌ ${ticket.frontmatter.id}: Failed to create branch — ${error.message}`);
316
+ }
202
317
  }
203
- } else {
204
- console.log(`🔍 Creating new branch: ${branchName}`);
205
318
 
206
- if (createAndCheckoutBranch(branchName, baseBranch)) {
207
- console.log(`✅ Created and switched to branch: ${branchName}`);
208
- } else {
209
- console.error(`❌ Failed to create branch: ${branchName}`);
210
- process.exit(1);
211
- }
319
+ updateTicketStatus(ticket, null);
212
320
  }
213
321
 
214
- // Update ticket status if requested
215
- if (updateStatus) {
216
- try {
217
- const currentContent = fs.readFileSync(ticketPath, 'utf-8');
218
- const now = new Date().toISOString();
219
-
220
- let updatedContent = currentContent
221
- .replace(/^status: (.+)$/m, 'status: in_progress')
222
- .replace(/^updated_at: (.+)$/m, `updated_at: ${now}`);
322
+ if (spawnAgent) {
323
+ const ticket = tickets[0];
324
+ const agentTimeout = config.worktree?.agent?.timeout || 900;
325
+ const ticketContent = fs.readFileSync(ticket.filePath, 'utf-8');
326
+ const prompt = flags.prompt
327
+ ? flags.prompt
328
+ : `You are working on ticket ${ticket.frontmatter.id}: ${ticket.frontmatter.title}\n\nHere is the full ticket:\n\n${ticketContent}\n\nImplement the ticket requirements. Follow the acceptance criteria. Commit your work when done. Update the ticket status to done when complete.`;
223
329
 
224
- fs.writeFileSync(ticketPath, updatedContent, 'utf-8');
330
+ console.log('\n🤖 Spawning Claude agent...\n');
331
+ try {
332
+ const agentProcess = spawn('claude', ['-p', prompt, '--timeout', String(agentTimeout * 1000)], {
333
+ cwd: process.cwd(),
334
+ stdio: 'ignore',
335
+ detached: true
336
+ });
225
337
 
226
- console.log(`✅ Updated ticket status to: in_progress`);
227
- console.log(`✅ Updated timestamp to: ${now}`);
338
+ agentProcess.unref();
339
+ console.log(` 🤖 ${ticket.frontmatter.id}: Agent spawned (timeout: ${agentTimeout}s)`);
340
+ console.log('\n🏁 Agent launched!\n');
341
+ console.log('Monitor progress:');
342
+ console.log(' vibe status # see ticket status');
343
+ console.log(' vibe list --status=in_progress # see in-progress tickets');
228
344
  } catch (error) {
229
- console.error(`❌ Failed to update ticket status: ${error.message}`);
345
+ console.error(` ❌ ${ticket.frontmatter.id}: Failed to spawn agent ${error.message}`);
230
346
  }
347
+ } else {
348
+ console.log('\n🏁 Branches ready!\n');
349
+ console.log('To switch to a branch:');
350
+ console.log(` git checkout ${getBranchName(tickets[0], config)}`);
231
351
  }
232
-
233
- // Summary
234
- console.log('');
235
- console.log(`🎯 Now working on: ${ticketId} - ${title}`);
236
- console.log(`🌿 Branch: ${branchName}`);
237
- console.log('');
238
- console.log('To push this branch to remote:');
239
- console.log(` git push -u origin ${branchName}`);
240
352
  }
241
353
  }
242
354
 
@@ -49,7 +49,7 @@ describe('start command', () => {
49
49
 
50
50
  // Assert
51
51
  expect(exitMock.exitCalls).toContain(1);
52
- expect(consoleMock.logs.error).toContain(' Please provide a ticket ID (e.g., vibe start TKT-006)');
52
+ expect(consoleMock.logs.error[0]).toContain('Usage: vibe start');
53
53
  });
54
54
 
55
55
  it('should show error for invalid ticket ID format', () => {
@@ -61,7 +61,7 @@ describe('start command', () => {
61
61
 
62
62
  // Assert
63
63
  expect(exitMock.exitCalls).toContain(1);
64
- expect(consoleMock.logs.error).toContain('❌ Invalid ticket ID format. Expected TKT-XXX or just the number.');
64
+ expect(consoleMock.logs.error).toContain('❌ Invalid ticket ID: invalid-format');
65
65
  });
66
66
  });
67
67
 
@@ -79,10 +79,21 @@ describe('start command', () => {
79
79
  });
80
80
  });
81
81
 
82
- // Note: Full git integration testing would require:
83
- // - Proper git repository setup
84
- // - Mocking all git utility functions
85
- // - Testing branch creation and checkout
86
- // - Testing ticket status updates
87
- // This is better handled in integration tests with proper git mocking
82
+ describe('--agent flag validation', () => {
83
+ it('should error when --agent used without -w for multiple tickets', () => {
84
+ // Arrange
85
+ createMockVibeProject(tempDir, {
86
+ withTickets: [
87
+ { id: 'TKT-001', title: 'First', status: 'open' },
88
+ { id: 'TKT-002', title: 'Second', status: 'open' }
89
+ ]
90
+ });
91
+
92
+ // Act
93
+ expect(() => startCommand(['TKT-001', 'TKT-002', '--agent'])).toThrow('process.exit(1)');
94
+
95
+ // Assert
96
+ expect(consoleMock.logs.error[0]).toContain('--agent without -w only supports a single ticket');
97
+ });
98
+ });
88
99
  });
@@ -0,0 +1,80 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import yaml from 'js-yaml';
4
+ import { execSync } from 'child_process';
5
+ import { getTicketsDir, getConfig } from '../../utils/index.js';
6
+ import { isGitRepository, getRepoName } from '../../utils/git.js';
7
+
8
+ function getWorktreesStatus() {
9
+ try {
10
+ const output = execSync('git worktree list --porcelain', { encoding: 'utf-8' });
11
+ const lines = output.trim().split('\n').filter(l => l);
12
+ const worktrees = [];
13
+ for (const line of lines) {
14
+ const [path_, branch_] = line.split(' ');
15
+ if (path_ && path_ !== '.') {
16
+ const branch = branch_.replace('branch ', '').replace(/^refs\/heads\//, '');
17
+ worktrees.push({ path: path_, branch });
18
+ }
19
+ }
20
+ return worktrees;
21
+ } catch (error) {
22
+ return [];
23
+ }
24
+ }
25
+
26
+ function loadTicketFromPath(ticketsDir, worktreePath) {
27
+ const files = fs.readdirSync(ticketsDir).filter(f => f.endsWith('.md'));
28
+ for (const file of files) {
29
+ const filePath = path.join(ticketsDir, file);
30
+ const content = fs.readFileSync(filePath, 'utf-8');
31
+ if (content.includes(`worktree_path: "${worktreePath}"`)) {
32
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
33
+ if (match) {
34
+ const frontmatter = yaml.load(match[1]);
35
+ return { id: frontmatter.id, title: frontmatter.title, status: frontmatter.status, filePath };
36
+ }
37
+ }
38
+ }
39
+ return null;
40
+ }
41
+
42
+ function statusCommand(args) {
43
+ if (!isGitRepository()) {
44
+ console.error('❌ Not in a git repository.');
45
+ process.exit(1);
46
+ }
47
+
48
+ const ticketsDir = getTicketsDir();
49
+ const worktrees = getWorktreesStatus();
50
+
51
+ if (worktrees.length === 0) {
52
+ console.log('📭 No active worktrees.\n');
53
+ console.log('Start a ticket with worktrees:');
54
+ console.log(' vibe start <TKT-001> -w');
55
+ return;
56
+ }
57
+
58
+ console.log('\n🌳 Active Worktrees\n');
59
+
60
+ for (const wt of worktrees) {
61
+ const ticket = loadTicketFromPath(ticketsDir, wt.path);
62
+ if (ticket) {
63
+ console.log(` ${ticket.id} — ${ticket.title}`);
64
+ console.log(` 📂 ${wt.path}`);
65
+ console.log(` 🌿 ${wt.branch}`);
66
+ console.log(` ✓ ${ticket.status}`);
67
+ } else {
68
+ console.log(` ${wt.branch}`);
69
+ console.log(` 📂 ${wt.path}`);
70
+ }
71
+ console.log('');
72
+ }
73
+
74
+ console.log('Commands:');
75
+ console.log(' cd <path> # Enter a worktree');
76
+ console.log(' vibe pr --all # Open PRs for all branches');
77
+ console.log(' git worktree remove # Remove a worktree when done');
78
+ }
79
+
80
+ export default statusCommand;
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
2
+ import {
3
+ createTempDir,
4
+ cleanupTempDir,
5
+ mockConsole,
6
+ mockProcessCwd,
7
+ createMockVibeProject
8
+ } from '../../utils/test-helpers.js';
9
+ import statusCommand from './index.js';
10
+
11
+ describe('status command', () => {
12
+ let tempDir;
13
+ let consoleMock;
14
+ let restoreCwd;
15
+
16
+ beforeEach(() => {
17
+ tempDir = createTempDir('status-test');
18
+ consoleMock = mockConsole();
19
+ restoreCwd = mockProcessCwd(tempDir);
20
+ });
21
+
22
+ afterEach(() => {
23
+ consoleMock.restore();
24
+ restoreCwd();
25
+ cleanupTempDir(tempDir);
26
+ });
27
+
28
+ describe('basic validation', () => {
29
+ it('should validate that status command exists and is callable', () => {
30
+ expect(typeof statusCommand).toBe('function');
31
+ });
32
+
33
+ it('should accept arguments parameter', () => {
34
+ expect(statusCommand.length).toBe(1);
35
+ });
36
+
37
+ it('should show message when no active worktrees', () => {
38
+ // Arrange
39
+ createMockVibeProject(tempDir);
40
+
41
+ // Act
42
+ statusCommand([]);
43
+
44
+ // Assert
45
+ const output = consoleMock.logs.log[0];
46
+ expect(output.includes('No active worktrees') || output.includes('Active Worktrees')).toBe(true);
47
+ });
48
+ });
49
+ });