From 378d3323351d8f71db935db9bde722e74bbc76ad Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 3 Feb 2026 00:03:23 -0700 Subject: [PATCH] feat!: modernize CLI with subcommand structure (v2.0.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: Commands now use subcommand syntax instead of hyphens. Old hyphenated commands are deprecated and will be removed in v3.0.0. Command changes: - mcp-serve → mcp serve - watch-merges → watch merges - merge-status → merge status - sandbox-run → sandbox run - sandbox-logs → sandbox logs - sandbox-download → sandbox download - sandbox-kill → sandbox kill - sandbox-list → sandbox list - sandbox-status → sandbox status Implementation: - Add parent command groups (mcp, watch, merge, sandbox) - Extract action handlers into shared functions for code reuse - Keep deprecated commands with warnings pointing to new syntax - Add cli-deprecation.ts helper module - Add 28 new tests for CLI subcommand structure - Update version to 2.0.0 - Update CLAUDE.md documentation --- CLAUDE.md | 94 +-- package.json | 2 +- src/cli-deprecation.ts | 36 + src/cli.ts | 1230 ++++++++++++++++++++------------- tests/cli-subcommands.test.ts | 269 +++++++ 5 files changed, 1108 insertions(+), 523 deletions(-) create mode 100644 src/cli-deprecation.ts create mode 100644 tests/cli-subcommands.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index c8f9d43..67372b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ `parallel-cc` is a coordinator for running multiple Claude Code sessions in parallel on the same repository. It uses git worktrees to isolate each session's work. -**Current Version:** 1.0.0 +**Current Version:** 2.0.0 ## Architecture @@ -31,7 +31,7 @@ │ │ │ MCP Server (v0.5) │ │ ───────────────── │ -│ parallel-cc mcp-serve (stdio transport) │ +│ parallel-cc mcp serve (stdio transport) │ │ │ │ │ ├──► Session Management (v0.3-v0.4) │ │ │ ├──► get_parallel_status - query active sessions │ @@ -71,7 +71,7 @@ │ │ │ Merge Detection Daemon (v0.4) │ │ ───────────────────────────── │ -│ parallel-cc watch-merges │ +│ parallel-cc watch merges │ │ │ │ │ ├──► Polls git for merged branches │ │ ├──► Records merge events in SQLite │ @@ -98,6 +98,7 @@ ``` src/ ├── cli.ts # Commander-based CLI entry point +├── cli-deprecation.ts # Deprecation warning helpers (v2.0) ├── coordinator.ts # Core logic - session management ├── db.ts # SQLite operations via better-sqlite3 ├── db-validators.ts # Database input validation (v0.5) @@ -137,6 +138,7 @@ scripts/ └── uninstall.sh # Removal script tests/ +├── cli-subcommands.test.ts # CLI subcommand structure tests (v2.0) ├── db.test.ts # SessionDB tests ├── coordinator.test.ts # Coordinator tests ├── gtr.test.ts # GtrWrapper tests @@ -265,7 +267,7 @@ node dist/cli.js install --hooks --local --repo /tmp/test-repo # Test MCP installation (v0.3) node dist/cli.js install --mcp -node dist/cli.js mcp-serve # Start MCP server (stdio) +node dist/cli.js mcp serve # Start MCP server (stdio) # Test wrapper script ./scripts/claude-parallel.sh --help @@ -352,6 +354,8 @@ CREATE INDEX idx_budget_period ON budget_tracking(period, period_start); ## CLI Commands +**Note (v2.0.0):** Commands now use subcommand structure (e.g., `mcp serve` instead of `mcp-serve`). Old hyphenated commands still work but show deprecation warnings and will be removed in v3.0.0. + | Command | Description | |---------|-------------| | `register --repo --pid ` | Register a session, create worktree if needed | @@ -360,12 +364,12 @@ CREATE INDEX idx_budget_period ON budget_tracking(period, period_start); | `status [--repo ]` | Show active sessions | | `cleanup` | Remove stale sessions and worktrees | | `doctor` | Check system health | -| `mcp-serve` | Start MCP server (stdio transport) | +| `mcp serve` | Start MCP server (stdio transport) | | `update` | Update database schema to latest version (v1.1.0) - runs all necessary migrations | -| `watch-merges` | Start merge detection daemon (v0.4) | -| `watch-merges --once` | Run single merge detection poll (v0.4) | -| `merge-status` | Show merge events history (v0.4) | -| `merge-status --subscriptions` | Show active merge subscriptions (v0.4) | +| `watch merges` | Start merge detection daemon (v0.4) | +| `watch merges --once` | Run single merge detection poll (v0.4) | +| `merge status` | Show merge events history (v0.4) | +| `merge status --subscriptions` | Show active merge subscriptions (v0.4) | | `install --all` | Install hooks globally + alias + MCP + update database | | `install --interactive` | Prompted installation for all options | | `install --hooks` | Install heartbeat hooks (interactive) | @@ -375,6 +379,12 @@ CREATE INDEX idx_budget_period ON budget_tracking(period, period_start); | `install --mcp` | Configure MCP server in Claude settings | | `install --uninstall` | Remove installed hooks/alias/MCP | | `install --status` | Check installation status | +| `sandbox run` | Execute autonomous task in E2B sandbox (v1.0) | +| `sandbox logs` | View sandbox execution logs (v1.0) | +| `sandbox download` | Download sandbox results (v1.0) | +| `sandbox kill` | Terminate sandbox (v1.0) | +| `sandbox list` | List all sandbox sessions (v1.0) | +| `sandbox status` | Check sandbox health (v1.0) | | `templates list` | List all available sandbox templates (v1.1) | | `templates show ` | Show detailed template information (v1.1) | | `templates create ` | Create a new custom template (v1.1) | @@ -385,7 +395,6 @@ CREATE INDEX idx_budget_period ON budget_tracking(period, period_start); | `config get ` | Get a configuration value (v1.1) | | `config list` | Display all configuration values (v1.1) | | `budget status` | Show budget and spending status (v1.1) | -| `budget-status` | Alias for `budget status` (backward compat) | ## MCP Server Tools (v0.4) @@ -465,7 +474,8 @@ Get history of detected merge events for a repository. | v0.4 | ✅ Complete | Branch merge detection, rebase assistance, conflict checking | | v0.5 | ✅ Complete | File claims, AST conflict detection, AI auto-fix, 441 tests (100%) | | v1.0 | ✅ Complete | E2B sandbox integration for autonomous execution | -| v1.1 | ✅ Current | Sandbox templates for Node.js, Python, Next.js; project type detection | +| v1.1 | ✅ Complete | Sandbox templates for Node.js, Python, Next.js; project type detection | +| v2.0 | ✅ Current | CLI modernization: subcommand structure (mcp serve, sandbox run, etc.) with backward-compatible deprecation | ## E2B Sandbox Integration (v1.0) @@ -527,46 +537,46 @@ E2B integration enables autonomous Claude Code execution in isolated cloud sandb ```bash # Basic execution (results downloaded as uncommitted changes) -parallel-cc sandbox-run --repo . --prompt "Implement feature X" -parallel-cc sandbox-run --repo . --prompt-file PLAN.md +parallel-cc sandbox run --repo . --prompt "Implement feature X" +parallel-cc sandbox run --repo . --prompt-file PLAN.md # Authentication methods -parallel-cc sandbox-run --repo . --prompt "Fix bug" --auth-method api-key # Default -parallel-cc sandbox-run --repo . --prompt "Fix bug" --auth-method oauth # Use subscription +parallel-cc sandbox run --repo . --prompt "Fix bug" --auth-method api-key # Default +parallel-cc sandbox run --repo . --prompt "Fix bug" --auth-method oauth # Use subscription # Branch management -parallel-cc sandbox-run --repo . --prompt "Add feature" --branch auto # Auto-generate branch + commit -parallel-cc sandbox-run --repo . --prompt "Fix issue #42" --branch feature/issue-42 # Specify branch + commit -parallel-cc sandbox-run --repo . --prompt "Refactor" # Default: uncommitted changes +parallel-cc sandbox run --repo . --prompt "Add feature" --branch auto # Auto-generate branch + commit +parallel-cc sandbox run --repo . --prompt "Fix issue #42" --branch feature/issue-42 # Specify branch + commit +parallel-cc sandbox run --repo . --prompt "Refactor" # Default: uncommitted changes # Combined example -parallel-cc sandbox-run \ +parallel-cc sandbox run \ --repo . \ --prompt "Implement auth system" \ --auth-method oauth \ --branch auto # Override git identity for commits -parallel-cc sandbox-run --repo . --prompt "Fix bug" \ +parallel-cc sandbox run --repo . --prompt "Fix bug" \ --git-user "CI Bot" --git-email "ci@example.com" # Monitor active sandboxes parallel-cc status --sandbox-only -parallel-cc sandbox-logs --session-id --follow +parallel-cc sandbox logs --session-id --follow # Download results without terminating -parallel-cc sandbox-download --session-id +parallel-cc sandbox download --session-id # Kill running sandbox -parallel-cc sandbox-kill --session-id +parallel-cc sandbox kill --session-id # Test setup without execution -parallel-cc sandbox-run --dry-run --repo . +parallel-cc sandbox run --dry-run --repo . # Use managed templates (v1.1) -parallel-cc sandbox-run --repo . --prompt "Build feature" --use-template node-20-typescript -parallel-cc sandbox-run --repo . --prompt "Develop API" --use-template python-3.12-fastapi -parallel-cc sandbox-run --repo . --prompt "Create page" --use-template full-stack-nextjs +parallel-cc sandbox run --repo . --prompt "Build feature" --use-template node-20-typescript +parallel-cc sandbox run --repo . --prompt "Develop API" --use-template python-3.12-fastapi +parallel-cc sandbox run --repo . --prompt "Create page" --use-template full-stack-nextjs ``` ### Sandbox Templates (v1.1) @@ -613,7 +623,7 @@ E2B sandboxes support two authentication methods for Claude CLI: export ANTHROPIC_API_KEY="sk-ant-api03-..." # Run with API key auth (default) -parallel-cc sandbox-run --repo . --prompt "Task" --auth-method api-key +parallel-cc sandbox run --repo . --prompt "Task" --auth-method api-key ``` **2. OAuth Authentication** @@ -630,7 +640,7 @@ claude /login # Exit Claude Code (Ctrl-D), then run with OAuth auth -parallel-cc sandbox-run --repo . --prompt "Task" --auth-method oauth +parallel-cc sandbox run --repo . --prompt "Task" --auth-method oauth ``` **Authentication Method Selection:** @@ -657,17 +667,17 @@ Commits made in E2B sandboxes use a configurable git identity. The system uses a **Usage:** ```bash # Default: auto-detect from local git config -parallel-cc sandbox-run --repo . --prompt "Fix bug" +parallel-cc sandbox run --repo . --prompt "Fix bug" # Override with CLI flags -parallel-cc sandbox-run --repo . --prompt "Fix bug" \ +parallel-cc sandbox run --repo . --prompt "Fix bug" \ --git-user "CI Bot" \ --git-email "ci@example.com" # Set via environment variables export PARALLEL_CC_GIT_USER="Deploy Bot" export PARALLEL_CC_GIT_EMAIL="deploy@example.com" -parallel-cc sandbox-run --repo . --prompt "Deploy feature" +parallel-cc sandbox run --repo . --prompt "Deploy feature" ``` **Notes:** @@ -682,7 +692,7 @@ Control how E2B results are applied to your local worktree: **Default (No `--branch` flag): Uncommitted Changes** ```bash -parallel-cc sandbox-run --repo . --prompt "Fix issue #84" --auth-method oauth +parallel-cc sandbox run --repo . --prompt "Fix issue #84" --auth-method oauth # Results downloaded as uncommitted tracked files # You review, create branch, and commit manually ``` @@ -703,7 +713,7 @@ git push origin fix/84 # Push when ready **Auto-Generate Branch (`--branch auto`): Convenience** ```bash -parallel-cc sandbox-run --repo . --prompt "Fix issue #84" --branch auto +parallel-cc sandbox run --repo . --prompt "Fix issue #84" --branch auto # Creates branch: e2b/fix-issue-84-2025-12-13-23-45 # Commits with: "E2B execution: Fix issue #84..." ``` @@ -721,7 +731,7 @@ git push origin e2b/fix-issue-84-2025-12-13-23-45 # Push the branch **Specify Branch Name (`--branch `): Custom Naming** ```bash -parallel-cc sandbox-run --repo . --prompt "Fix issue #84" --branch feature/issue-84 +parallel-cc sandbox run --repo . --prompt "Fix issue #84" --branch feature/issue-84 # Creates branch: feature/issue-84 # Commits with: "E2B execution: Fix issue #84..." ``` @@ -757,16 +767,16 @@ Git Live Mode (`--git-live`) pushes results directly to a remote feature branch ```bash # Basic git-live: Push and create PR automatically -parallel-cc sandbox-run --repo . --prompt "Fix bug" --git-live +parallel-cc sandbox run --repo . --prompt "Fix bug" --git-live # With custom target branch (default: main) -parallel-cc sandbox-run --repo . --prompt "Add feature" --git-live --target-branch develop +parallel-cc sandbox run --repo . --prompt "Add feature" --git-live --target-branch develop # With custom branch name -parallel-cc sandbox-run --repo . --prompt "Fix #42" --git-live --branch feature/issue-42 +parallel-cc sandbox run --repo . --prompt "Fix #42" --git-live --branch feature/issue-42 # Full example -parallel-cc sandbox-run \ +parallel-cc sandbox run \ --repo . \ --prompt "Implement auth system" \ --auth-method oauth \ @@ -832,15 +842,15 @@ SSH key injection enables access to private Git repositories within E2B sandboxe **Usage:** ```bash # Basic SSH key injection -parallel-cc sandbox-run --repo . --prompt "Clone private repo" \ +parallel-cc sandbox run --repo . --prompt "Clone private repo" \ --ssh-key ~/.ssh/id_ed25519 # Non-interactive (CI/CD) - requires explicit confirmation flag -parallel-cc sandbox-run --repo . --prompt "Build private deps" \ +parallel-cc sandbox run --repo . --prompt "Build private deps" \ --ssh-key ~/.ssh/deploy_key --confirm-ssh-key --json # With OAuth and git-live -parallel-cc sandbox-run --repo . --prompt "Update dependencies" \ +parallel-cc sandbox run --repo . --prompt "Update dependencies" \ --ssh-key ~/.ssh/id_ed25519 --auth-method oauth --git-live ``` diff --git a/package.json b/package.json index 22d5963..efd43ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parallel-cc", - "version": "1.0.0", + "version": "2.0.0", "description": "Coordinate parallel Claude Code sessions using git worktrees and E2B cloud sandboxes for autonomous execution", "type": "module", "bin": { diff --git a/src/cli-deprecation.ts b/src/cli-deprecation.ts new file mode 100644 index 0000000..b1bedee --- /dev/null +++ b/src/cli-deprecation.ts @@ -0,0 +1,36 @@ +/** + * CLI Deprecation Helpers + * + * Provides utilities for showing deprecation warnings when users invoke + * deprecated hyphenated commands (e.g., mcp-serve instead of mcp serve). + */ + +import chalk from 'chalk'; + +/** + * Show a deprecation warning for a hyphenated command. + * Outputs to stderr so it doesn't interfere with command output. + * + * @param oldCmd - The deprecated command name (e.g., 'mcp-serve') + * @param newCmd - The new command syntax (e.g., 'mcp serve') + */ +export function showDeprecationWarning(oldCmd: string, newCmd: string): void { + console.error(chalk.yellow(`⚠ Warning: "${oldCmd}" is deprecated`)); + console.error(chalk.dim(` Use "${newCmd}" instead`)); + console.error(chalk.dim(` The old command will be removed in v3.0.0\n`)); +} + +/** + * Command mapping from deprecated hyphenated names to new subcommand syntax. + */ +export const DEPRECATED_COMMANDS: Record = { + 'mcp-serve': 'mcp serve', + 'watch-merges': 'watch merges', + 'merge-status': 'merge status', + 'sandbox-run': 'sandbox run', + 'sandbox-logs': 'sandbox logs', + 'sandbox-download': 'sandbox download', + 'sandbox-kill': 'sandbox kill', + 'sandbox-list': 'sandbox list', + 'sandbox-status': 'sandbox status', +}; diff --git a/src/cli.ts b/src/cli.ts index 646f3e2..ae499d3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -43,11 +43,12 @@ import * as path from 'path'; import * as os from 'os'; import { randomUUID } from 'crypto'; import { SandboxStatus, type BudgetConfig, type E2BSession, type StatusResult, type SessionInfo } from './types.js'; +import { showDeprecationWarning, DEPRECATED_COMMANDS } from './cli-deprecation.js'; program .name('parallel-cc') .description('Coordinate parallel Claude Code sessions using git worktrees') - .version('0.5.0'); + .version('2.0.0'); /** * Helper to prompt user for input (for interactive mode) @@ -812,11 +813,19 @@ program console.log(''); }); +// ============================================================================ +// MCP Commands (v2.0) +// ============================================================================ + +const mcpCmd = program + .command('mcp') + .description('MCP server operations'); + /** * MCP Server - expose tools for Claude Code to query session status */ -program - .command('mcp-serve') +mcpCmd + .command('serve') .description('Start MCP server for Claude Code integration - exposes v0.5 tools for file claims, conflict detection, and auto-fix (stdio transport)') .action(async () => { try { @@ -829,192 +838,263 @@ program }); /** - * Watch for merged branches (v0.4) - * Starts a background daemon that polls for merged branches and sends notifications + * DEPRECATED: Use 'mcp serve' instead */ program - .command('watch-merges') - .description('Start merge detection daemon to monitor for merged branches (v0.4)') - .option('--interval ', 'Poll interval in seconds', '60') - .option('--once', 'Run a single poll iteration and exit') - .option('--json', 'Output as JSON') - .action(async (options) => { - const db = new SessionDB(); - const pollInterval = parseInt(options.interval, 10); - - if (isNaN(pollInterval) || pollInterval < 5) { - console.error(chalk.red('Poll interval must be at least 5 seconds')); + .command('mcp-serve') + .description('[DEPRECATED] Use "mcp serve" instead') + .action(async () => { + showDeprecationWarning('mcp-serve', 'mcp serve'); + try { + await startMcpServer(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error(`MCP server failed: ${errorMessage}`); process.exit(1); } + }); - const detector = new MergeDetector(db, { - pollIntervalSeconds: pollInterval - }); +// ============================================================================ +// Watch Commands (v2.0) +// ============================================================================ - try { - if (options.once) { - // Single poll run - if (!options.json) { - console.log(chalk.bold('\nRunning single merge detection poll...\n')); - } +/** + * Shared handler for watch-merges functionality + */ +async function handleWatchMerges(options: { interval: string; once?: boolean; json?: boolean }) { + const db = new SessionDB(); + const pollInterval = parseInt(options.interval, 10); - const result = await detector.pollForMerges(); + if (isNaN(pollInterval) || pollInterval < 5) { + console.error(chalk.red('Poll interval must be at least 5 seconds')); + process.exit(1); + } - if (options.json) { - console.log(JSON.stringify(result, null, 2)); - } else { - console.log(`Subscriptions checked: ${result.subscriptionsChecked}`); - console.log(`New merges detected: ${result.newMerges.length}`); - console.log(`Notifications sent: ${result.notificationsSent}`); - - if (result.newMerges.length > 0) { - console.log(chalk.bold('\nNew Merges:')); - for (const merge of result.newMerges) { - console.log(` ${chalk.green('●')} ${merge.branch_name} → ${merge.target_branch}`); - console.log(chalk.dim(` Repo: ${merge.repo_path}`)); - console.log(chalk.dim(` Detected: ${merge.detected_at}`)); - } + const detector = new MergeDetector(db, { + pollIntervalSeconds: pollInterval + }); + + try { + if (options.once) { + // Single poll run + if (!options.json) { + console.log(chalk.bold('\nRunning single merge detection poll...\n')); + } + + const result = await detector.pollForMerges(); + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(`Subscriptions checked: ${result.subscriptionsChecked}`); + console.log(`New merges detected: ${result.newMerges.length}`); + console.log(`Notifications sent: ${result.notificationsSent}`); + + if (result.newMerges.length > 0) { + console.log(chalk.bold('\nNew Merges:')); + for (const merge of result.newMerges) { + console.log(` ${chalk.green('●')} ${merge.branch_name} → ${merge.target_branch}`); + console.log(chalk.dim(` Repo: ${merge.repo_path}`)); + console.log(chalk.dim(` Detected: ${merge.detected_at}`)); } + } - if (result.errors.length > 0) { - console.log(chalk.bold('\nErrors:')); - for (const err of result.errors) { - console.log(chalk.red(` ✗ ${err}`)); - } + if (result.errors.length > 0) { + console.log(chalk.bold('\nErrors:')); + for (const err of result.errors) { + console.log(chalk.red(` ✗ ${err}`)); } - console.log(''); } - } else { - // Continuous polling daemon - console.log(chalk.bold('\nStarting merge detection daemon...\n')); - console.log(chalk.dim(` Poll interval: ${pollInterval} seconds`)); - console.log(chalk.dim(' Press Ctrl+C to stop\n')); - - // Handle graceful shutdown - process.on('SIGINT', () => { - console.log(chalk.yellow('\nStopping merge detection daemon...')); - detector.stopPolling(); - db.close(); - process.exit(0); - }); + console.log(''); + } + } else { + // Continuous polling daemon + console.log(chalk.bold('\nStarting merge detection daemon...\n')); + console.log(chalk.dim(` Poll interval: ${pollInterval} seconds`)); + console.log(chalk.dim(' Press Ctrl+C to stop\n')); + + // Handle graceful shutdown + process.on('SIGINT', () => { + console.log(chalk.yellow('\nStopping merge detection daemon...')); + detector.stopPolling(); + db.close(); + process.exit(0); + }); - process.on('SIGTERM', () => { - detector.stopPolling(); - db.close(); - process.exit(0); - }); + process.on('SIGTERM', () => { + detector.stopPolling(); + db.close(); + process.exit(0); + }); - detector.startPolling(); + detector.startPolling(); - // Keep process alive - await new Promise(() => {}); // Never resolves, runs until signal - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - if (options.json) { - console.log(JSON.stringify({ success: false, error: errorMessage })); - } else { - console.error(chalk.red(`✗ Merge detection failed: ${errorMessage}`)); - } - db.close(); - process.exit(1); + // Keep process alive + await new Promise(() => {}); // Never resolves, runs until signal } - }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + if (options.json) { + console.log(JSON.stringify({ success: false, error: errorMessage })); + } else { + console.error(chalk.red(`✗ Merge detection failed: ${errorMessage}`)); + } + db.close(); + process.exit(1); + } +} + +const watchCmd = program + .command('watch') + .description('Watch and monitoring operations'); /** - * Show merge events (v0.4) - * Display history of detected merge events + * Watch for merged branches (v0.4) */ -program - .command('merge-status') - .description('Show merge events and subscription status (v0.4)') - .option('--repo ', 'Filter by repository path') - .option('--branch ', 'Filter by branch name') - .option('--limit ', 'Limit number of results', '20') - .option('--subscriptions', 'Show active subscriptions instead of merge events') +watchCmd + .command('merges') + .description('Start merge detection daemon to monitor for merged branches (v0.4)') + .option('--interval ', 'Poll interval in seconds', '60') + .option('--once', 'Run a single poll iteration and exit') .option('--json', 'Output as JSON') - .action((options) => { - const db = new SessionDB(); - try { - const limit = parseInt(options.limit, 10) || 20; + .action(handleWatchMerges); - if (options.subscriptions) { - // Show subscriptions - const subscriptions = db.getActiveSubscriptions(); - let filtered = subscriptions; +/** + * DEPRECATED: Use 'watch merges' instead + */ +program + .command('watch-merges') + .description('[DEPRECATED] Use "watch merges" instead') + .option('--interval ', 'Poll interval in seconds', '60') + .option('--once', 'Run a single poll iteration and exit') + .option('--json', 'Output as JSON') + .action(async (options) => { + showDeprecationWarning('watch-merges', 'watch merges'); + await handleWatchMerges(options); + }); - if (options.repo) { - filtered = filtered.filter(s => s.repo_path.includes(options.repo)); - } - if (options.branch) { - filtered = filtered.filter(s => s.branch_name.includes(options.branch)); - } +// ============================================================================ +// Merge Commands (v2.0) +// ============================================================================ - filtered = filtered.slice(0, limit); +/** + * Shared handler for merge-status functionality + */ +function handleMergeStatus(options: { repo?: string; branch?: string; limit: string; subscriptions?: boolean; json?: boolean }) { + const db = new SessionDB(); + try { + const limit = parseInt(options.limit, 10) || 20; - if (options.json) { - console.log(JSON.stringify({ subscriptions: filtered, total: filtered.length }, null, 2)); - } else { - console.log(chalk.bold(`\nActive Merge Subscriptions: ${filtered.length}`)); + if (options.subscriptions) { + // Show subscriptions + const subscriptions = db.getActiveSubscriptions(); + let filtered = subscriptions; - if (filtered.length === 0) { - console.log(chalk.dim(' No active subscriptions')); - } else { - for (const sub of filtered) { - console.log(`\n ${chalk.blue('●')} ${sub.branch_name} → ${sub.target_branch}`); - console.log(chalk.dim(` Session: ${sub.session_id}`)); - console.log(chalk.dim(` Repo: ${sub.repo_path}`)); - console.log(chalk.dim(` Created: ${sub.created_at}`)); - } - } - console.log(''); - } - } else { - // Show merge events - let events = options.repo - ? db.getMergeEventsByRepo(options.repo) - : db.getAllMergeEvents(); + if (options.repo) { + filtered = filtered.filter(s => s.repo_path.includes(options.repo!)); + } + if (options.branch) { + filtered = filtered.filter(s => s.branch_name.includes(options.branch!)); + } - if (options.branch) { - events = events.filter(e => e.branch_name.includes(options.branch)); - } + filtered = filtered.slice(0, limit); - events = events.slice(0, limit); + if (options.json) { + console.log(JSON.stringify({ subscriptions: filtered, total: filtered.length }, null, 2)); + } else { + console.log(chalk.bold(`\nActive Merge Subscriptions: ${filtered.length}`)); - if (options.json) { - console.log(JSON.stringify({ events, total: events.length }, null, 2)); + if (filtered.length === 0) { + console.log(chalk.dim(' No active subscriptions')); } else { - console.log(chalk.bold(`\nMerge Events: ${events.length}`)); - - if (events.length === 0) { - console.log(chalk.dim(' No merge events recorded')); - console.log(chalk.dim(' Run "parallel-cc watch-merges" to start detecting merges')); - } else { - for (const event of events) { - const status = event.notification_sent - ? chalk.green('✓') - : chalk.yellow('○'); - console.log(`\n ${status} ${event.branch_name} → ${event.target_branch}`); - console.log(chalk.dim(` Repo: ${event.repo_path}`)); - console.log(chalk.dim(` Merged: ${event.merged_at}`)); - console.log(chalk.dim(` Detected: ${event.detected_at}`)); - console.log(chalk.dim(` Source commit: ${event.source_commit.substring(0, 8)}`)); - } + for (const sub of filtered) { + console.log(`\n ${chalk.blue('●')} ${sub.branch_name} → ${sub.target_branch}`); + console.log(chalk.dim(` Session: ${sub.session_id}`)); + console.log(chalk.dim(` Repo: ${sub.repo_path}`)); + console.log(chalk.dim(` Created: ${sub.created_at}`)); } - console.log(''); } + console.log(''); } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + } else { + // Show merge events + let events = options.repo + ? db.getMergeEventsByRepo(options.repo) + : db.getAllMergeEvents(); + + if (options.branch) { + events = events.filter(e => e.branch_name.includes(options.branch!)); + } + + events = events.slice(0, limit); + if (options.json) { - console.log(JSON.stringify({ success: false, error: errorMessage })); + console.log(JSON.stringify({ events, total: events.length }, null, 2)); } else { - console.error(chalk.red(`✗ Failed to get merge status: ${errorMessage}`)); + console.log(chalk.bold(`\nMerge Events: ${events.length}`)); + + if (events.length === 0) { + console.log(chalk.dim(' No merge events recorded')); + console.log(chalk.dim(' Run "parallel-cc watch merges" to start detecting merges')); + } else { + for (const event of events) { + const status = event.notification_sent + ? chalk.green('✓') + : chalk.yellow('○'); + console.log(`\n ${status} ${event.branch_name} → ${event.target_branch}`); + console.log(chalk.dim(` Repo: ${event.repo_path}`)); + console.log(chalk.dim(` Merged: ${event.merged_at}`)); + console.log(chalk.dim(` Detected: ${event.detected_at}`)); + console.log(chalk.dim(` Source commit: ${event.source_commit.substring(0, 8)}`)); + } + } + console.log(''); } - process.exit(1); - } finally { - db.close(); } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + if (options.json) { + console.log(JSON.stringify({ success: false, error: errorMessage })); + } else { + console.error(chalk.red(`✗ Failed to get merge status: ${errorMessage}`)); + } + process.exit(1); + } finally { + db.close(); + } +} + +const mergeCmd = program + .command('merge') + .description('Merge detection and status operations'); + +/** + * Show merge events (v0.4) + */ +mergeCmd + .command('status') + .description('Show merge events and subscription status (v0.4)') + .option('--repo ', 'Filter by repository path') + .option('--branch ', 'Filter by branch name') + .option('--limit ', 'Limit number of results', '20') + .option('--subscriptions', 'Show active subscriptions instead of merge events') + .option('--json', 'Output as JSON') + .action(handleMergeStatus); + +/** + * DEPRECATED: Use 'merge status' instead + */ +program + .command('merge-status') + .description('[DEPRECATED] Use "merge status" instead') + .option('--repo ', 'Filter by repository path') + .option('--branch ', 'Filter by branch name') + .option('--limit ', 'Limit number of results', '20') + .option('--subscriptions', 'Show active subscriptions instead of merge events') + .option('--json', 'Output as JSON') + .action((options) => { + showDeprecationWarning('merge-status', 'merge status'); + handleMergeStatus(options); }); /** @@ -1378,11 +1458,41 @@ program // E2B Sandbox Commands (v1.0) // ============================================================================ +// ============================================================================ +// Sandbox Commands (v2.0) +// ============================================================================ + +const sandboxCmd = program + .command('sandbox') + .description('E2B sandbox operations for autonomous task execution (v1.0)'); + +// Type definition for sandbox-run options +interface SandboxRunOptions { + repo: string; + prompt?: string; + promptFile?: string; + template?: string; + useTemplate?: string; + authMethod: string; + dryRun?: boolean; + branch?: string; + gitLive?: boolean; + targetBranch: string; + gitUser?: string; + gitEmail?: string; + sshKey?: string; + confirmSshKey?: boolean; + npmToken?: string; + npmRegistry: string; + budget?: string; + json?: boolean; +} + /** * Execute autonomous task in E2B sandbox */ -program - .command('sandbox-run') +sandboxCmd + .command('run') .description(`Execute autonomous task in E2B sandbox with full worktree isolation (v1.0) Authentication: @@ -1407,22 +1517,22 @@ NPM Authentication (for private packages): Examples: # Default: uncommitted changes, review before committing - parallel-cc sandbox-run --repo . --prompt "Fix bug" + parallel-cc sandbox run --repo . --prompt "Fix bug" # OAuth auth + auto branch - parallel-cc sandbox-run --repo . --prompt "Add feature" --auth-method oauth --branch auto + parallel-cc sandbox run --repo . --prompt "Add feature" --auth-method oauth --branch auto # Custom branch name - parallel-cc sandbox-run --repo . --prompt "Fix #42" --branch feature/issue-42 + parallel-cc sandbox run --repo . --prompt "Fix #42" --branch feature/issue-42 # Override git identity for commits - parallel-cc sandbox-run --repo . --prompt "Fix bug" --git-user "CI Bot" --git-email "ci@example.com" + parallel-cc sandbox run --repo . --prompt "Fix bug" --git-user "CI Bot" --git-email "ci@example.com" # Private NPM packages - parallel-cc sandbox-run --repo . --prompt "Install deps" --npm-token "npm_xxx" + parallel-cc sandbox run --repo . --prompt "Install deps" --npm-token "npm_xxx" # Custom NPM registry - parallel-cc sandbox-run --repo . --prompt "Task" --npm-token "xxx" --npm-registry "https://npm.company.com"`) + parallel-cc sandbox run --repo . --prompt "Task" --npm-token "xxx" --npm-registry "https://npm.company.com"`) .requiredOption('--repo ', 'Repository path') .option('--prompt ', 'Prompt text to execute') .option('--prompt-file ', 'Path to prompt file (e.g., PLAN.md, .apm/Implementation_Plan.md)') @@ -1441,7 +1551,12 @@ Examples: .option('--npm-registry ', 'Custom NPM registry URL (default: https://registry.npmjs.org)', 'https://registry.npmjs.org') .option('--budget ', 'Per-session budget limit in USD (e.g., 0.50 for $0.50)') .option('--json', 'Output as JSON') - .action(async (options) => { + .action(handleSandboxRun); + +/** + * Shared handler for sandbox-run functionality + */ +async function handleSandboxRun(options: SandboxRunOptions) { const coordinator = new Coordinator(); let sandboxId: string | null = null; let sandboxManager: SandboxManager | null = null; @@ -1646,7 +1761,7 @@ Examples: process.exit(1); } } else { - prompt = options.prompt; + prompt = options.prompt!; // Validated earlier that either prompt or promptFile is provided } // Normalize repo path @@ -1890,7 +2005,7 @@ Examples: if (!options.json) { console.log(chalk.yellow('\n✓ DRY RUN complete - skipping execution')); console.log(chalk.dim(' Sandbox will remain active for inspection')); - console.log(chalk.dim(` Use: parallel-cc sandbox-kill --session-id ${sessionId}`)); + console.log(chalk.dim(` Use: parallel-cc sandbox kill --session-id ${sessionId}`)); } if (options.json) { @@ -2191,410 +2306,565 @@ Examples: } finally { coordinator.close(); } - }); +} /** - * View sandbox session logs + * DEPRECATED: Use 'sandbox run' instead */ program - .command('sandbox-logs') - .description('View E2B sandbox execution logs (v1.0)') - .requiredOption('--session-id ', 'Session ID') - .option('--follow', 'Follow log output in real-time (like tail -f)') - .option('--lines ', 'Number of lines to show', '100') + .command('sandbox-run') + .description('[DEPRECATED] Use "sandbox run" instead - Execute autonomous task in E2B sandbox') + .requiredOption('--repo ', 'Repository path') + .option('--prompt ', 'Prompt text to execute') + .option('--prompt-file ', 'Path to prompt file (e.g., PLAN.md)') + .option('--template ', 'E2B sandbox template') + .option('--use-template ', 'Use managed template from templates list') + .option('--auth-method ', 'Authentication method: api-key or oauth', 'api-key') + .option('--dry-run', 'Test upload without execution') + .option('--branch ', 'Create feature branch for changes') + .option('--git-live', 'Push results to remote and create PR') + .option('--target-branch ', 'Target branch for PR', 'main') + .option('--git-user ', 'Git user name for commits') + .option('--git-email ', 'Git user email for commits') + .option('--ssh-key ', 'Path to SSH private key') + .option('--confirm-ssh-key', 'Skip SSH key security warning') + .option('--npm-token ', 'NPM authentication token') + .option('--npm-registry ', 'Custom NPM registry URL', 'https://registry.npmjs.org') + .option('--budget ', 'Per-session budget limit in USD') .option('--json', 'Output as JSON') - .action(async (options) => { - const coordinator = new Coordinator(); - try { - const db = coordinator['db']; - const sessionId = options.sessionId; + .action(async (options: SandboxRunOptions) => { + showDeprecationWarning('sandbox-run', 'sandbox run'); + await handleSandboxRun(options); + }); - // Get E2B session by ID - const sessions = db.listE2BSessions(); - const session = sessions.find(s => s.id === sessionId); +// Type definition for sandbox-logs options +interface SandboxLogsOptions { + sessionId: string; + follow?: boolean; + lines: string; + json?: boolean; +} - if (!session) { - if (options.json) { - console.log(JSON.stringify({ success: false, error: 'Session not found' })); - } else { - console.error(chalk.red(`✗ Session not found: ${sessionId}`)); - } - process.exit(1); +/** + * Shared handler for sandbox-logs functionality + */ +async function handleSandboxLogs(options: SandboxLogsOptions) { + const coordinator = new Coordinator(); + try { + const db = coordinator['db']; + const sessionId = options.sessionId; + + // Get E2B session by ID + const sessions = db.listE2BSessions(); + const session = sessions.find(s => s.id === sessionId); + + if (!session) { + if (options.json) { + console.log(JSON.stringify({ success: false, error: 'Session not found' })); + } else { + console.error(chalk.red(`✗ Session not found: ${sessionId}`)); } + process.exit(1); + } + + if (!options.json) { + console.log(chalk.bold(`\nSandbox Logs: ${sessionId}\n`)); + console.log(chalk.dim(`Sandbox ID: ${session.sandbox_id}`)); + console.log(chalk.dim(`Status: ${session.status}`)); + console.log(chalk.dim(`Created: ${session.created_at}\n`)); + } + + // Get output log from database + const outputLog = session.output_log || ''; + if (options.follow && session.status === SandboxStatus.RUNNING) { if (!options.json) { - console.log(chalk.bold(`\nSandbox Logs: ${sessionId}\n`)); - console.log(chalk.dim(`Sandbox ID: ${session.sandbox_id}`)); - console.log(chalk.dim(`Status: ${session.status}`)); - console.log(chalk.dim(`Created: ${session.created_at}\n`)); + console.log(chalk.yellow('⚠ Follow mode not yet implemented for live sessions')); + console.log(chalk.dim('Showing buffered output:\n')); } + } - // Get output log from database - const outputLog = session.output_log || ''; + if (options.json) { + console.log(JSON.stringify({ + success: true, + sessionId, + sandboxId: session.sandbox_id, + status: session.status, + output: outputLog, + lineCount: outputLog.split('\n').length + }, null, 2)); + } else { + // Show last N lines + const lines = outputLog.split('\n'); + const limitLines = parseInt(options.lines, 10) || 100; + const displayLines = lines.slice(-limitLines); - if (options.follow && session.status === SandboxStatus.RUNNING) { - if (!options.json) { - console.log(chalk.yellow('⚠ Follow mode not yet implemented for live sessions')); - console.log(chalk.dim('Showing buffered output:\n')); - } - } + console.log(displayLines.join('\n')); + console.log(chalk.dim(`\n(Showing last ${displayLines.length} of ${lines.length} lines)`)); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + if (options.json) { + console.log(JSON.stringify({ success: false, error: errorMessage })); + } else { + console.error(chalk.red(`✗ Failed to get logs: ${errorMessage}`)); + } + process.exit(1); + } finally { + coordinator.close(); + } +} + +/** + * View sandbox session logs + */ +sandboxCmd + .command('logs') + .description('View E2B sandbox execution logs (v1.0)') + .requiredOption('--session-id ', 'Session ID') + .option('--follow', 'Follow log output in real-time (like tail -f)') + .option('--lines ', 'Number of lines to show', '100') + .option('--json', 'Output as JSON') + .action(handleSandboxLogs); + +/** + * DEPRECATED: Use 'sandbox logs' instead + */ +program + .command('sandbox-logs') + .description('[DEPRECATED] Use "sandbox logs" instead') + .requiredOption('--session-id ', 'Session ID') + .option('--follow', 'Follow log output in real-time (like tail -f)') + .option('--lines ', 'Number of lines to show', '100') + .option('--json', 'Output as JSON') + .action(async (options: SandboxLogsOptions) => { + showDeprecationWarning('sandbox-logs', 'sandbox logs'); + await handleSandboxLogs(options); + }); + +// Type definition for sandbox-download options +interface SandboxDownloadOptions { + sessionId: string; + output: string; + json?: boolean; +} + +/** + * Shared handler for sandbox-download functionality + */ +async function handleSandboxDownload(options: SandboxDownloadOptions) { + const coordinator = new Coordinator(); + const sandboxManager = new SandboxManager(logger); + + try { + // Validate E2B API key early + try { + SandboxManager.validateApiKey(); + } catch (error) { if (options.json) { console.log(JSON.stringify({ - success: true, - sessionId, - sandboxId: session.sandbox_id, - status: session.status, - output: outputLog, - lineCount: outputLog.split('\n').length - }, null, 2)); + success: false, + error: error instanceof Error ? error.message : 'E2B API key validation failed', + hint: 'Set E2B_API_KEY environment variable. Get your key from https://e2b.dev/dashboard' + })); } else { - // Show last N lines - const lines = outputLog.split('\n'); - const limitLines = parseInt(options.lines, 10) || 100; - const displayLines = lines.slice(-limitLines); - - console.log(displayLines.join('\n')); - console.log(chalk.dim(`\n(Showing last ${displayLines.length} of ${lines.length} lines)`)); + console.error(chalk.red(`✗ ${error instanceof Error ? error.message : 'E2B API key validation failed'}`)); + console.error(chalk.dim(' Get your E2B API key from: https://e2b.dev/dashboard')); } + process.exit(1); + } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + const db = coordinator['db']; + const sessionId = options.sessionId; + + // Get E2B session + const sessions = db.listE2BSessions(); + const session = sessions.find(s => s.id === sessionId); + + if (!session) { if (options.json) { - console.log(JSON.stringify({ success: false, error: errorMessage })); + console.log(JSON.stringify({ success: false, error: 'Session not found' })); } else { - console.error(chalk.red(`✗ Failed to get logs: ${errorMessage}`)); + console.error(chalk.red(`✗ Session not found: ${sessionId}`)); } process.exit(1); - } finally { - coordinator.close(); } - }); + + if (!options.json) { + console.log(chalk.bold(`\nDownloading Sandbox Results\n`)); + console.log(chalk.dim(`Sandbox ID: ${session.sandbox_id}`)); + console.log(chalk.dim(`Output directory: ${options.output}`)); + } + + // Check if sandbox is still running (will attempt reconnection) + const healthCheck = await sandboxManager.monitorSandboxHealth(session.sandbox_id, true); + if (!healthCheck.isHealthy) { + console.error(chalk.red(`✗ Sandbox not accessible: ${healthCheck.error}`)); + process.exit(1); + } + + // Get sandbox instance (reconnect if needed) + const sandbox = await sandboxManager.getOrReconnectSandbox(session.sandbox_id); + if (!sandbox) { + console.error(chalk.red('✗ Failed to connect to sandbox (may have been terminated)')); + process.exit(1); + } + + // Create output directory + await fs.mkdir(options.output, { recursive: true }); + + // Download files + const downloadResult = await downloadChangedFiles(sandbox, '/workspace', options.output); + + if (!downloadResult.success) { + console.error(chalk.red(`✗ Download failed: ${downloadResult.error}`)); + process.exit(1); + } + + if (options.json) { + console.log(JSON.stringify({ + success: true, + sessionId, + sandboxId: session.sandbox_id, + outputPath: options.output, + filesDownloaded: downloadResult.filesDownloaded, + sizeBytes: downloadResult.sizeBytes + }, null, 2)); + } else { + console.log(chalk.green(`\n✓ Downloaded ${downloadResult.filesDownloaded} files`)); + console.log(chalk.dim(` Size: ${(downloadResult.sizeBytes / 1024 / 1024).toFixed(2)} MB`)); + console.log(chalk.dim(` Duration: ${(downloadResult.duration / 1000).toFixed(1)}s`)); + } + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + if (options.json) { + console.log(JSON.stringify({ success: false, error: errorMessage })); + } else { + console.error(chalk.red(`✗ Download failed: ${errorMessage}`)); + } + process.exit(1); + } finally { + coordinator.close(); + } +} /** * Download sandbox results */ +sandboxCmd + .command('download') + .description('Download results from E2B sandbox to local directory (v1.0)') + .requiredOption('--session-id ', 'Session ID') + .requiredOption('--output ', 'Output directory for downloaded files') + .option('--json', 'Output as JSON') + .action(handleSandboxDownload); + +/** + * DEPRECATED: Use 'sandbox download' instead + */ program .command('sandbox-download') - .description('Download results from E2B sandbox to local directory (v1.0)') + .description('[DEPRECATED] Use "sandbox download" instead') .requiredOption('--session-id ', 'Session ID') .requiredOption('--output ', 'Output directory for downloaded files') .option('--json', 'Output as JSON') - .action(async (options) => { - const coordinator = new Coordinator(); - const sandboxManager = new SandboxManager(logger); + .action(async (options: SandboxDownloadOptions) => { + showDeprecationWarning('sandbox-download', 'sandbox download'); + await handleSandboxDownload(options); + }); - try { - // Validate E2B API key early - try { - SandboxManager.validateApiKey(); - } catch (error) { - if (options.json) { - console.log(JSON.stringify({ - success: false, - error: error instanceof Error ? error.message : 'E2B API key validation failed', - hint: 'Set E2B_API_KEY environment variable. Get your key from https://e2b.dev/dashboard' - })); - } else { - console.error(chalk.red(`✗ ${error instanceof Error ? error.message : 'E2B API key validation failed'}`)); - console.error(chalk.dim(' Get your E2B API key from: https://e2b.dev/dashboard')); - } - process.exit(1); - } +// Type definition for sandbox-kill options +interface SandboxKillOptions { + sessionId: string; + json?: boolean; +} - const db = coordinator['db']; - const sessionId = options.sessionId; +/** + * Shared handler for sandbox-kill functionality + */ +async function handleSandboxKill(options: SandboxKillOptions) { + const coordinator = new Coordinator(); + const sandboxManager = new SandboxManager(logger); - // Get E2B session - const sessions = db.listE2BSessions(); - const session = sessions.find(s => s.id === sessionId); + try { + const db = coordinator['db']; + const sessionId = options.sessionId; - if (!session) { - if (options.json) { - console.log(JSON.stringify({ success: false, error: 'Session not found' })); - } else { - console.error(chalk.red(`✗ Session not found: ${sessionId}`)); - } - process.exit(1); - } + // Get E2B session + const sessions = db.listE2BSessions(); + const session = sessions.find(s => s.id === sessionId); - if (!options.json) { - console.log(chalk.bold(`\nDownloading Sandbox Results\n`)); - console.log(chalk.dim(`Sandbox ID: ${session.sandbox_id}`)); - console.log(chalk.dim(`Output directory: ${options.output}`)); + if (!session) { + if (options.json) { + console.log(JSON.stringify({ success: false, error: 'Session not found' })); + } else { + console.error(chalk.red(`✗ Session not found: ${sessionId}`)); } + process.exit(1); + } - // Check if sandbox is still running (will attempt reconnection) - const healthCheck = await sandboxManager.monitorSandboxHealth(session.sandbox_id, true); - if (!healthCheck.isHealthy) { - console.error(chalk.red(`✗ Sandbox not accessible: ${healthCheck.error}`)); - process.exit(1); - } + if (!options.json) { + console.log(chalk.bold(`\nTerminating Sandbox: ${session.sandbox_id}\n`)); + } - // Get sandbox instance (reconnect if needed) - const sandbox = await sandboxManager.getOrReconnectSandbox(session.sandbox_id); - if (!sandbox) { - console.error(chalk.red('✗ Failed to connect to sandbox (may have been terminated)')); - process.exit(1); - } + // Terminate sandbox + const termResult = await sandboxManager.terminateSandbox(session.sandbox_id); - // Create output directory - await fs.mkdir(options.output, { recursive: true }); + if (!termResult.success) { + console.error(chalk.red(`✗ Termination failed: ${termResult.error}`)); + process.exit(1); + } - // Download files - const downloadResult = await downloadChangedFiles(sandbox, '/workspace', options.output); + // Cleanup database record + db.cleanupE2BSession(session.sandbox_id, SandboxStatus.FAILED, true); - if (!downloadResult.success) { - console.error(chalk.red(`✗ Download failed: ${downloadResult.error}`)); - process.exit(1); - } + // Release session if still active + const localSession = db.getSessionByPid(session.pid); + if (localSession) { + await coordinator.release(session.pid); + } - if (options.json) { - console.log(JSON.stringify({ - success: true, - sessionId, - sandboxId: session.sandbox_id, - outputPath: options.output, - filesDownloaded: downloadResult.filesDownloaded, - sizeBytes: downloadResult.sizeBytes - }, null, 2)); - } else { - console.log(chalk.green(`\n✓ Downloaded ${downloadResult.filesDownloaded} files`)); - console.log(chalk.dim(` Size: ${(downloadResult.sizeBytes / 1024 / 1024).toFixed(2)} MB`)); - console.log(chalk.dim(` Duration: ${(downloadResult.duration / 1000).toFixed(1)}s`)); - } + if (options.json) { + console.log(JSON.stringify({ + success: true, + sessionId, + sandboxId: session.sandbox_id, + terminated: termResult.success, + cleanedUp: termResult.cleanedUp + }, null, 2)); + } else { + console.log(chalk.green('\n✓ Sandbox terminated successfully')); + console.log(chalk.dim(` Session: ${sessionId}`)); + console.log(chalk.dim(` Sandbox: ${session.sandbox_id}`)); + } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - if (options.json) { - console.log(JSON.stringify({ success: false, error: errorMessage })); - } else { - console.error(chalk.red(`✗ Download failed: ${errorMessage}`)); - } - process.exit(1); - } finally { - coordinator.close(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + if (options.json) { + console.log(JSON.stringify({ success: false, error: errorMessage })); + } else { + console.error(chalk.red(`✗ Termination failed: ${errorMessage}`)); } - }); + process.exit(1); + } finally { + coordinator.close(); + } +} /** * Terminate sandbox */ -program - .command('sandbox-kill') +sandboxCmd + .command('kill') .description('Terminate E2B sandbox and cleanup resources (v1.0)') .requiredOption('--session-id ', 'Session ID') .option('--json', 'Output as JSON') - .action(async (options) => { - const coordinator = new Coordinator(); - const sandboxManager = new SandboxManager(logger); - - try { - const db = coordinator['db']; - const sessionId = options.sessionId; + .action(handleSandboxKill); - // Get E2B session - const sessions = db.listE2BSessions(); - const session = sessions.find(s => s.id === sessionId); - - if (!session) { - if (options.json) { - console.log(JSON.stringify({ success: false, error: 'Session not found' })); - } else { - console.error(chalk.red(`✗ Session not found: ${sessionId}`)); - } - process.exit(1); - } - - if (!options.json) { - console.log(chalk.bold(`\nTerminating Sandbox: ${session.sandbox_id}\n`)); - } - - // Terminate sandbox - const termResult = await sandboxManager.terminateSandbox(session.sandbox_id); +/** + * DEPRECATED: Use 'sandbox kill' instead + */ +program + .command('sandbox-kill') + .description('[DEPRECATED] Use "sandbox kill" instead') + .requiredOption('--session-id ', 'Session ID') + .option('--json', 'Output as JSON') + .action(async (options: SandboxKillOptions) => { + showDeprecationWarning('sandbox-kill', 'sandbox kill'); + await handleSandboxKill(options); + }); - if (!termResult.success) { - console.error(chalk.red(`✗ Termination failed: ${termResult.error}`)); - process.exit(1); - } +// Type definition for sandbox-list options +interface SandboxListOptions { + repo?: string; + json?: boolean; +} - // Cleanup database record - db.cleanupE2BSession(session.sandbox_id, SandboxStatus.FAILED, true); +/** + * Shared handler for sandbox-list functionality + */ +function handleSandboxList(options: SandboxListOptions) { + const coordinator = new Coordinator(); + try { + const db = coordinator['db']; + const repoPath = options.repo ? path.resolve(options.repo) : undefined; - // Release session if still active - const localSession = db.getSessionByPid(session.pid); - if (localSession) { - await coordinator.release(session.pid); - } + const sessions = db.listE2BSessions(repoPath); - if (options.json) { - console.log(JSON.stringify({ - success: true, - sessionId, - sandboxId: session.sandbox_id, - terminated: termResult.success, - cleanedUp: termResult.cleanedUp - }, null, 2)); - } else { - console.log(chalk.green('\n✓ Sandbox terminated successfully')); - console.log(chalk.dim(` Session: ${sessionId}`)); - console.log(chalk.dim(` Sandbox: ${session.sandbox_id}`)); + if (options.json) { + console.log(JSON.stringify({ sessions, total: sessions.length }, null, 2)); + } else { + console.log(chalk.bold(`\nE2B Sandbox Sessions: ${sessions.length}\n`)); + + if (sessions.length === 0) { + console.log(chalk.dim(' No E2B sessions found')); + } else { + for (const session of sessions) { + const statusColor = + session.status === SandboxStatus.COMPLETED ? chalk.green : + session.status === SandboxStatus.FAILED ? chalk.red : + session.status === SandboxStatus.TIMEOUT ? chalk.yellow : + chalk.blue; + + console.log(` ${statusColor('●')} ${session.id.substring(0, 8)}...`); + console.log(chalk.dim(` Status: ${session.status}`)); + console.log(chalk.dim(` Sandbox: ${session.sandbox_id}`)); + console.log(chalk.dim(` Worktree: ${session.worktree_path}`)); + console.log(chalk.dim(` Created: ${session.created_at}`)); + console.log(chalk.dim(` Prompt: ${session.prompt.substring(0, 80)}${session.prompt.length > 80 ? '...' : ''}`)); + console.log(''); + } } + } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - if (options.json) { - console.log(JSON.stringify({ success: false, error: errorMessage })); - } else { - console.error(chalk.red(`✗ Termination failed: ${errorMessage}`)); - } - process.exit(1); - } finally { - coordinator.close(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + if (options.json) { + console.log(JSON.stringify({ success: false, error: errorMessage })); + } else { + console.error(chalk.red(`✗ Failed to list sessions: ${errorMessage}`)); } - }); + process.exit(1); + } finally { + coordinator.close(); + } +} /** * List all E2B sandbox sessions */ +sandboxCmd + .command('list') + .description('List all E2B sandbox sessions (v1.0)') + .option('--repo ', 'Filter by repository path') + .option('--json', 'Output as JSON') + .action(handleSandboxList); + +/** + * DEPRECATED: Use 'sandbox list' instead + */ program .command('sandbox-list') - .description('List all E2B sandbox sessions (v1.0)') + .description('[DEPRECATED] Use "sandbox list" instead') .option('--repo ', 'Filter by repository path') .option('--json', 'Output as JSON') - .action((options) => { - const coordinator = new Coordinator(); - try { - const db = coordinator['db']; - const repoPath = options.repo ? path.resolve(options.repo) : undefined; + .action((options: SandboxListOptions) => { + showDeprecationWarning('sandbox-list', 'sandbox list'); + handleSandboxList(options); + }); - const sessions = db.listE2BSessions(repoPath); +// Type definition for sandbox-status options +interface SandboxStatusOptions { + sessionId: string; + json?: boolean; +} - if (options.json) { - console.log(JSON.stringify({ sessions, total: sessions.length }, null, 2)); - } else { - console.log(chalk.bold(`\nE2B Sandbox Sessions: ${sessions.length}\n`)); +/** + * Shared handler for sandbox-status functionality + */ +async function handleSandboxStatus(options: SandboxStatusOptions) { + const coordinator = new Coordinator(); + const sandboxManager = new SandboxManager(logger); - if (sessions.length === 0) { - console.log(chalk.dim(' No E2B sessions found')); - } else { - for (const session of sessions) { - const statusColor = - session.status === SandboxStatus.COMPLETED ? chalk.green : - session.status === SandboxStatus.FAILED ? chalk.red : - session.status === SandboxStatus.TIMEOUT ? chalk.yellow : - chalk.blue; - - console.log(` ${statusColor('●')} ${session.id.substring(0, 8)}...`); - console.log(chalk.dim(` Status: ${session.status}`)); - console.log(chalk.dim(` Sandbox: ${session.sandbox_id}`)); - console.log(chalk.dim(` Worktree: ${session.worktree_path}`)); - console.log(chalk.dim(` Created: ${session.created_at}`)); - console.log(chalk.dim(` Prompt: ${session.prompt.substring(0, 80)}${session.prompt.length > 80 ? '...' : ''}`)); - console.log(''); - } - } - } + try { + const db = coordinator['db']; + const sessionId = options.sessionId; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + // Get E2B session + const sessions = db.listE2BSessions(); + const session = sessions.find(s => s.id === sessionId); + + if (!session) { if (options.json) { - console.log(JSON.stringify({ success: false, error: errorMessage })); + console.log(JSON.stringify({ success: false, error: 'Session not found' })); } else { - console.error(chalk.red(`✗ Failed to list sessions: ${errorMessage}`)); + console.error(chalk.red(`✗ Session not found: ${sessionId}`)); } process.exit(1); - } finally { - coordinator.close(); } - }); -/** - * Check sandbox health status - */ -program - .command('sandbox-status') - .description('Check health status of E2B sandbox (v1.0)') - .requiredOption('--session-id ', 'Session ID') - .option('--json', 'Output as JSON') - .action(async (options) => { - const coordinator = new Coordinator(); - const sandboxManager = new SandboxManager(logger); - - try { - const db = coordinator['db']; - const sessionId = options.sessionId; + if (!options.json) { + console.log(chalk.bold(`\nSandbox Status: ${session.sandbox_id}\n`)); + } - // Get E2B session - const sessions = db.listE2BSessions(); - const session = sessions.find(s => s.id === sessionId); + // Check sandbox health + const healthCheck = await sandboxManager.monitorSandboxHealth(session.sandbox_id); + + // Calculate elapsed time + const createdAt = new Date(session.created_at); + const elapsedMinutes = (Date.now() - createdAt.getTime()) / 1000 / 60; + + if (options.json) { + console.log(JSON.stringify({ + success: true, + sessionId, + sandboxId: session.sandbox_id, + status: session.status, + health: { + isHealthy: healthCheck.isHealthy, + message: healthCheck.message, + error: healthCheck.error + }, + createdAt: session.created_at, + elapsedMinutes: elapsedMinutes.toFixed(1), + prompt: session.prompt + }, null, 2)); + } else { + const healthIcon = healthCheck.isHealthy ? chalk.green('✓') : chalk.red('✗'); + console.log(` ${healthIcon} Health: ${healthCheck.isHealthy ? 'Healthy' : 'Unhealthy'}`); + console.log(chalk.dim(` Status: ${session.status}`)); + console.log(chalk.dim(` Sandbox ID: ${session.sandbox_id}`)); + console.log(chalk.dim(` Created: ${session.created_at}`)); + console.log(chalk.dim(` Elapsed: ${elapsedMinutes.toFixed(1)} minutes`)); + console.log(chalk.dim(` Worktree: ${session.worktree_path}`)); - if (!session) { - if (options.json) { - console.log(JSON.stringify({ success: false, error: 'Session not found' })); - } else { - console.error(chalk.red(`✗ Session not found: ${sessionId}`)); - } - process.exit(1); + if (healthCheck.message) { + console.log(chalk.dim(` Message: ${healthCheck.message}`)); } - - if (!options.json) { - console.log(chalk.bold(`\nSandbox Status: ${session.sandbox_id}\n`)); + if (healthCheck.error) { + console.log(chalk.red(` Error: ${healthCheck.error}`)); } - // Check sandbox health - const healthCheck = await sandboxManager.monitorSandboxHealth(session.sandbox_id); - - // Calculate elapsed time - const createdAt = new Date(session.created_at); - const elapsedMinutes = (Date.now() - createdAt.getTime()) / 1000 / 60; - - if (options.json) { - console.log(JSON.stringify({ - success: true, - sessionId, - sandboxId: session.sandbox_id, - status: session.status, - health: { - isHealthy: healthCheck.isHealthy, - message: healthCheck.message, - error: healthCheck.error - }, - createdAt: session.created_at, - elapsedMinutes: elapsedMinutes.toFixed(1), - prompt: session.prompt - }, null, 2)); - } else { - const healthIcon = healthCheck.isHealthy ? chalk.green('✓') : chalk.red('✗'); - console.log(` ${healthIcon} Health: ${healthCheck.isHealthy ? 'Healthy' : 'Unhealthy'}`); - console.log(chalk.dim(` Status: ${session.status}`)); - console.log(chalk.dim(` Sandbox ID: ${session.sandbox_id}`)); - console.log(chalk.dim(` Created: ${session.created_at}`)); - console.log(chalk.dim(` Elapsed: ${elapsedMinutes.toFixed(1)} minutes`)); - console.log(chalk.dim(` Worktree: ${session.worktree_path}`)); + console.log(chalk.dim(`\n Prompt: ${session.prompt.substring(0, 200)}${session.prompt.length > 200 ? '...' : ''}`)); + console.log(''); + } - if (healthCheck.message) { - console.log(chalk.dim(` Message: ${healthCheck.message}`)); - } - if (healthCheck.error) { - console.log(chalk.red(` Error: ${healthCheck.error}`)); - } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + if (options.json) { + console.log(JSON.stringify({ success: false, error: errorMessage })); + } else { + console.error(chalk.red(`✗ Failed to get status: ${errorMessage}`)); + } + process.exit(1); + } finally { + coordinator.close(); + } +} - console.log(chalk.dim(`\n Prompt: ${session.prompt.substring(0, 200)}${session.prompt.length > 200 ? '...' : ''}`)); - console.log(''); - } +/** + * Check sandbox health status + */ +sandboxCmd + .command('status') + .description('Check health status of E2B sandbox (v1.0)') + .requiredOption('--session-id ', 'Session ID') + .option('--json', 'Output as JSON') + .action(handleSandboxStatus); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - if (options.json) { - console.log(JSON.stringify({ success: false, error: errorMessage })); - } else { - console.error(chalk.red(`✗ Failed to get status: ${errorMessage}`)); - } - process.exit(1); - } finally { - coordinator.close(); - } +/** + * DEPRECATED: Use 'sandbox status' instead + */ +program + .command('sandbox-status') + .description('[DEPRECATED] Use "sandbox status" instead') + .requiredOption('--session-id ', 'Session ID') + .option('--json', 'Output as JSON') + .action(async (options: SandboxStatusOptions) => { + showDeprecationWarning('sandbox-status', 'sandbox status'); + await handleSandboxStatus(options); }); // ============================================================================ diff --git a/tests/cli-subcommands.test.ts b/tests/cli-subcommands.test.ts new file mode 100644 index 0000000..08bb2bd --- /dev/null +++ b/tests/cli-subcommands.test.ts @@ -0,0 +1,269 @@ +/** + * CLI Subcommand Tests for parallel-cc v2.0.0 + * + * Tests the CLI refactoring from hyphenated commands to subcommand structure: + * - mcp-serve → mcp serve + * - watch-merges → watch merges + * - merge-status → merge status + * - sandbox-* → sandbox * + * + * Also tests backward compatibility with deprecation warnings. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { Command } from 'commander'; +import { execSync, spawnSync } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; + +// Path to built CLI +const CLI_PATH = path.join(__dirname, '..', 'dist', 'cli.js'); + +describe('CLI Subcommand Structure', () => { + describe('Help Output', () => { + it('should show subcommand groups in main help', () => { + const result = spawnSync('node', [CLI_PATH, '--help'], { encoding: 'utf-8' }); + const helpOutput = result.stdout; + + // Should show new subcommand groups + expect(helpOutput).toContain('mcp'); + expect(helpOutput).toContain('watch'); + expect(helpOutput).toContain('merge'); + expect(helpOutput).toContain('sandbox'); + expect(helpOutput).toContain('templates'); + expect(helpOutput).toContain('config'); + expect(helpOutput).toContain('budget'); + }); + + it('should show mcp subcommand help', () => { + const result = spawnSync('node', [CLI_PATH, 'mcp', '--help'], { encoding: 'utf-8' }); + const helpOutput = result.stdout; + + expect(helpOutput).toContain('serve'); + expect(helpOutput).toContain('MCP server'); + }); + + it('should show watch subcommand help', () => { + const result = spawnSync('node', [CLI_PATH, 'watch', '--help'], { encoding: 'utf-8' }); + const helpOutput = result.stdout; + + expect(helpOutput).toContain('merges'); + expect(helpOutput).toContain('merge detection'); + }); + + it('should show merge subcommand help', () => { + const result = spawnSync('node', [CLI_PATH, 'merge', '--help'], { encoding: 'utf-8' }); + const helpOutput = result.stdout; + + expect(helpOutput).toContain('status'); + expect(helpOutput).toContain('merge events'); + }); + + it('should show sandbox subcommand help', () => { + const result = spawnSync('node', [CLI_PATH, 'sandbox', '--help'], { encoding: 'utf-8' }); + const helpOutput = result.stdout; + + expect(helpOutput).toContain('run'); + expect(helpOutput).toContain('logs'); + expect(helpOutput).toContain('download'); + expect(helpOutput).toContain('kill'); + expect(helpOutput).toContain('list'); + expect(helpOutput).toContain('status'); + }); + }); + + describe('New Subcommand Syntax', () => { + it('should accept "mcp serve" as a valid command', () => { + const result = spawnSync('node', [CLI_PATH, 'mcp', 'serve', '--help'], { encoding: 'utf-8' }); + // Should not error, should show help for mcp serve + expect(result.status).toBe(0); + expect(result.stdout).toContain('Start MCP server'); + }); + + it('should accept "watch merges" as a valid command', () => { + const result = spawnSync('node', [CLI_PATH, 'watch', 'merges', '--help'], { encoding: 'utf-8' }); + expect(result.status).toBe(0); + expect(result.stdout).toContain('merge detection'); + }); + + it('should accept "merge status" as a valid command', () => { + const result = spawnSync('node', [CLI_PATH, 'merge', 'status', '--help'], { encoding: 'utf-8' }); + expect(result.status).toBe(0); + expect(result.stdout).toContain('merge events'); + }); + + it('should accept "sandbox run" as a valid command', () => { + const result = spawnSync('node', [CLI_PATH, 'sandbox', 'run', '--help'], { encoding: 'utf-8' }); + expect(result.status).toBe(0); + expect(result.stdout).toContain('E2B sandbox'); + }); + + it('should accept "sandbox logs" as a valid command', () => { + const result = spawnSync('node', [CLI_PATH, 'sandbox', 'logs', '--help'], { encoding: 'utf-8' }); + expect(result.status).toBe(0); + expect(result.stdout).toContain('logs'); + }); + + it('should accept "sandbox download" as a valid command', () => { + const result = spawnSync('node', [CLI_PATH, 'sandbox', 'download', '--help'], { encoding: 'utf-8' }); + expect(result.status).toBe(0); + expect(result.stdout).toContain('Download'); + }); + + it('should accept "sandbox kill" as a valid command', () => { + const result = spawnSync('node', [CLI_PATH, 'sandbox', 'kill', '--help'], { encoding: 'utf-8' }); + expect(result.status).toBe(0); + expect(result.stdout).toContain('Terminate'); + }); + + it('should accept "sandbox list" as a valid command', () => { + const result = spawnSync('node', [CLI_PATH, 'sandbox', 'list', '--help'], { encoding: 'utf-8' }); + expect(result.status).toBe(0); + expect(result.stdout).toContain('List'); + }); + + it('should accept "sandbox status" as a valid command', () => { + const result = spawnSync('node', [CLI_PATH, 'sandbox', 'status', '--help'], { encoding: 'utf-8' }); + expect(result.status).toBe(0); + expect(result.stdout).toContain('status'); + }); + }); + + describe('Backward Compatibility (Deprecated Commands)', () => { + // Note: Deprecation warnings are shown when commands are executed, not during --help. + // Commander.js --help flag short-circuits command execution before action handlers run. + // These tests verify commands work and show deprecation warnings during actual execution. + + it('should show deprecation notice in help text for deprecated commands', () => { + // Deprecated commands should have "[DEPRECATED]" in their description + const result = spawnSync('node', [CLI_PATH, '--help'], { encoding: 'utf-8' }); + expect(result.status).toBe(0); + // The deprecated commands should have "[DEPRECATED]" in their help text + expect(result.stdout).toContain('[DEPRECATED]'); + }); + + it('should show deprecation warning when merge-status is executed', () => { + // merge-status --json runs quickly and shows deprecation warning + const result = spawnSync('node', [CLI_PATH, 'merge-status', '--json'], { encoding: 'utf-8' }); + // Command may exit 0 or non-zero depending on environment, but stderr should have warning + expect(result.stderr).toContain('deprecated'); + expect(result.stderr).toContain('merge status'); + }); + + it('should show deprecation warning when watch-merges --once is executed', () => { + // watch-merges --once --json runs a single poll and exits + const result = spawnSync('node', [CLI_PATH, 'watch-merges', '--once', '--json'], { encoding: 'utf-8' }); + expect(result.stderr).toContain('deprecated'); + expect(result.stderr).toContain('watch merges'); + }); + + it('should show deprecation warning when sandbox-list is executed', () => { + // sandbox-list --json is a quick command that lists sessions + const result = spawnSync('node', [CLI_PATH, 'sandbox-list', '--json'], { encoding: 'utf-8' }); + expect(result.stderr).toContain('deprecated'); + expect(result.stderr).toContain('sandbox list'); + }); + + it('deprecation warning should include v3.0.0 removal notice', () => { + const result = spawnSync('node', [CLI_PATH, 'merge-status', '--json'], { encoding: 'utf-8' }); + expect(result.stderr).toContain('v3.0.0'); + }); + + it('deprecated commands should still be registered and available', () => { + // Verify all deprecated commands are shown in help + const result = spawnSync('node', [CLI_PATH, '--help'], { encoding: 'utf-8' }); + expect(result.stdout).toContain('mcp-serve'); + expect(result.stdout).toContain('watch-merges'); + expect(result.stdout).toContain('merge-status'); + expect(result.stdout).toContain('sandbox-run'); + expect(result.stdout).toContain('sandbox-logs'); + expect(result.stdout).toContain('sandbox-download'); + expect(result.stdout).toContain('sandbox-kill'); + expect(result.stdout).toContain('sandbox-list'); + expect(result.stdout).toContain('sandbox-status'); + }); + }); + + describe('Non-hyphenated Commands (Unchanged)', () => { + it('should keep core commands unchanged', () => { + const coreCommands = ['register', 'release', 'status', 'cleanup', 'doctor', 'heartbeat', 'install', 'update']; + + for (const cmd of coreCommands) { + const result = spawnSync('node', [CLI_PATH, cmd, '--help'], { encoding: 'utf-8' }); + // Should work without deprecation warning + expect(result.status).toBe(0); + expect(result.stderr).not.toContain('deprecated'); + } + }); + + it('should keep templates subcommand unchanged', () => { + const result = spawnSync('node', [CLI_PATH, 'templates', 'list', '--help'], { encoding: 'utf-8' }); + expect(result.status).toBe(0); + expect(result.stderr).not.toContain('deprecated'); + }); + + it('should keep config subcommand unchanged', () => { + const result = spawnSync('node', [CLI_PATH, 'config', 'list', '--help'], { encoding: 'utf-8' }); + expect(result.status).toBe(0); + expect(result.stderr).not.toContain('deprecated'); + }); + + it('should keep budget subcommand unchanged', () => { + const result = spawnSync('node', [CLI_PATH, 'budget', 'status', '--help'], { encoding: 'utf-8' }); + expect(result.status).toBe(0); + expect(result.stderr).not.toContain('deprecated'); + }); + }); + + describe('Command Option Preservation', () => { + it('sandbox run should preserve all options', () => { + const result = spawnSync('node', [CLI_PATH, 'sandbox', 'run', '--help'], { encoding: 'utf-8' }); + const helpText = result.stdout; + + // Check key options are preserved + expect(helpText).toContain('--repo'); + expect(helpText).toContain('--prompt'); + expect(helpText).toContain('--prompt-file'); + expect(helpText).toContain('--template'); + expect(helpText).toContain('--auth-method'); + expect(helpText).toContain('--dry-run'); + expect(helpText).toContain('--branch'); + expect(helpText).toContain('--git-live'); + expect(helpText).toContain('--target-branch'); + expect(helpText).toContain('--git-user'); + expect(helpText).toContain('--git-email'); + expect(helpText).toContain('--ssh-key'); + expect(helpText).toContain('--npm-token'); + expect(helpText).toContain('--budget'); + expect(helpText).toContain('--json'); + }); + + it('watch merges should preserve all options', () => { + const result = spawnSync('node', [CLI_PATH, 'watch', 'merges', '--help'], { encoding: 'utf-8' }); + const helpText = result.stdout; + + expect(helpText).toContain('--interval'); + expect(helpText).toContain('--once'); + expect(helpText).toContain('--json'); + }); + + it('merge status should preserve all options', () => { + const result = spawnSync('node', [CLI_PATH, 'merge', 'status', '--help'], { encoding: 'utf-8' }); + const helpText = result.stdout; + + expect(helpText).toContain('--repo'); + expect(helpText).toContain('--branch'); + expect(helpText).toContain('--limit'); + expect(helpText).toContain('--subscriptions'); + expect(helpText).toContain('--json'); + }); + }); +}); + +describe('Version Update', () => { + it('should report version 2.0.0', () => { + const result = spawnSync('node', [CLI_PATH, '--version'], { encoding: 'utf-8' }); + expect(result.stdout.trim()).toBe('2.0.0'); + }); +});