diff --git a/CHANGELOG.md b/CHANGELOG.md index c7620ac..69dbb45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - Added first-class Bitbucket Cloud support for configuring provider profiles, preparing and checking out pull request review bundles, reading pull request discussions, showing status, and publishing revpack outputs. +- Added primary onboarding commands and help: `revpack connect`, top-level `revpack doctor`, `revpack setup --agent `, a concise top-level workflow, and checkout target examples. ### Changed diff --git a/README.md b/README.md index ed8c8c6..024a94f 100644 --- a/README.md +++ b/README.md @@ -29,35 +29,27 @@ npm install -g @stefanvictora/revpack Open the repository you want to review, then configure a provider profile: ```bash -revpack config setup +revpack connect export REVPACK_GITHUB_TOKEN=ghp_xxxxxxxxxxxx # or export REVPACK_GITLAB_TOKEN=glpat-xxxxxxxxxxxx # or export REVPACK_BITBUCKET_EMAIL=you@example.com export REVPACK_BITBUCKET_TOKEN=ATBBTxxxxxxxxxxxx -revpack config doctor +revpack doctor ``` -Optionally, add review guidance: - -```bash -revpack setup -``` - -Customize `REVIEW.md` when you want agents to follow project-specific review priorities. - -Then add instructions for your agent: +Add review guidance and instructions for your agent: ```bash # Pick one: -revpack setup agent claude -revpack setup agent codex -revpack setup agent cursor -revpack setup agent copilot +revpack setup --agent claude +revpack setup --agent codex +revpack setup --agent cursor +revpack setup --agent copilot ``` -This writes project-level instruction files, such as an agent command, skill, or prompt. It does not install or run the agent. +This creates `REVIEW.md` when missing and writes project-level instruction files, such as an agent command, skill, or prompt. It does not install or run the agent. Use `--dry-run` to preview generated files before writing them. diff --git a/docs/commands.md b/docs/commands.md index a914075..14bec0a 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1,5 +1,19 @@ # Command reference +## Primary workflow + +```bash +revpack connect # create a provider profile interactively +revpack doctor # check the matching provider profile +revpack setup --agent codex # create REVIEW.md and install one agent adapter +revpack prepare # create or refresh the review bundle +# run your agent +revpack status +revpack publish all +``` + +For another PR/MR, use `revpack checkout `. For a local branch review, use `revpack prepare --local [base]`. + ## `prepare [ref]` Creates or refreshes the `.revpack/` bundle for a PR/MR. @@ -105,6 +119,10 @@ Creates project-level files that help agents review consistently. ```bash revpack setup +revpack setup --agent claude +revpack setup --agent codex +revpack setup --agent cursor +revpack setup --agent copilot revpack setup agent claude revpack setup agent codex revpack setup agent cursor @@ -113,6 +131,8 @@ revpack setup --prompts revpack setup --dry-run ``` +`revpack setup` creates only `REVIEW.md`. +`revpack setup --agent ` creates `REVIEW.md` when missing and installs one agent adapter. `revpack setup agent ` writes project-level instruction files for one agent target and does not create `REVIEW.md`. `--prompts` is kept as a deprecated compatibility flag. It creates `REVIEW.md` and installs the Copilot `/revpack-review` prompt. @@ -129,16 +149,22 @@ Generated harness files: Creates, inspects, and edits provider profiles. Revpack stores provider settings in named profiles. Commands such as `show`, `doctor`, `get`, `set`, and `unset` use the profile resolved from the current git remote unless you pass `--profile`; `config profile` commands manage saved profiles directly. ```bash -# Create +# Primary onboarding +revpack connect +revpack doctor +revpack doctor --profile myprofile + +# Compatibility aliases revpack config setup +revpack config doctor + +# Non-interactive profile creation revpack config profile create myBitbucket --provider bitbucket-cloud --url https://bitbucket.org --email-env REVPACK_BITBUCKET_EMAIL --token-env REVPACK_BITBUCKET_TOKEN # Current project revpack config show revpack config show --profile myprofile revpack config show --sources -revpack config doctor -revpack config doctor --profile myprofile # Profile values revpack config get diff --git a/src/cli/commands/checkout.test.ts b/src/cli/commands/checkout.test.ts index d679ed9..39ee40a 100644 --- a/src/cli/commands/checkout.test.ts +++ b/src/cli/commands/checkout.test.ts @@ -97,6 +97,30 @@ describe('checkout command', () => { expect(runSetupMock).toHaveBeenCalledWith({ cwd: process.cwd() }); }); + it('shows concise target examples in help', async () => { + const output: string[] = []; + const program = new Command(); + program.exitOverride(); + program.configureOutput({ + writeOut: (value) => output.push(value), + writeErr: (value) => output.push(value), + }); + registerCheckoutCommand(program); + + try { + await program.parseAsync(['node', 'revpack', 'checkout', '--help']); + } catch { + // Commander exits after printing --help when exitOverride is enabled. + } + + const help = output.join(''); + expect(help).toContain('Examples:'); + expect(help).toContain('revpack checkout !42'); + expect(help).toContain('revpack checkout 58 --repo owner/repo'); + expect(help).toContain('revpack checkout https://github.com/owner/repo/pull/58'); + expect(help).toContain('revpack checkout workspace/repo#42 --profile bitbucket'); + }); + async function parseCheckout(...args: string[]): Promise { const program = new Command(); program.exitOverride(); diff --git a/src/cli/commands/checkout.ts b/src/cli/commands/checkout.ts index 34e79fd..a95dde3 100644 --- a/src/cli/commands/checkout.ts +++ b/src/cli/commands/checkout.ts @@ -6,7 +6,7 @@ import { formatGuidanceLine } from '../output.js'; import { runSetup } from './setup.js'; export function registerCheckoutCommand(program: Command): void { - program + const checkoutCmd = program .command('checkout ') .description( [ @@ -82,6 +82,17 @@ export function registerCheckoutCommand(program: Command): void { handleError(err); } }); + + checkoutCmd.addHelpText( + 'after', + ` +Examples: + revpack checkout !42 + revpack checkout 58 --repo owner/repo + revpack checkout https://github.com/owner/repo/pull/58 + revpack checkout workspace/repo#42 --profile bitbucket +`, + ); } function createPrepareFetchLogger(): (message: string) => void { diff --git a/src/cli/commands/config.test.ts b/src/cli/commands/config.test.ts index e0a5254..93b3e9c 100644 --- a/src/cli/commands/config.test.ts +++ b/src/cli/commands/config.test.ts @@ -12,7 +12,7 @@ import { validateProviderUrlForProvider, } from '../../config/index.js'; import { ConfigError } from '../../core/errors.js'; -import { registerConfigCommand } from './config.js'; +import { registerConfigCommand, registerPrimaryConfigCommands } from './config.js'; describe('config command', () => { it('prints help for the parent command instead of showing resolved config', async () => { @@ -33,15 +33,40 @@ describe('config command', () => { expect(help).toContain('profile'); expect(help).toContain('List, show, create, or delete saved profiles'); expect(help).toContain('Create:'); - expect(help).toContain('revpack config setup'); + expect(help).toContain('revpack connect'); expect(help).toContain('Current project:'); - expect(help).toContain('revpack config doctor'); + expect(help).toContain('revpack doctor'); expect(help).toContain('Saved profiles:'); expect(help).toContain('revpack config profile list'); expect(help).toContain('revpack config profile delete '); + expect(help).toContain('setup'); + expect(help).toContain('doctor'); expect(help).not.toContain('No active profile'); }); + it('registers top-level connect and doctor commands', async () => { + const output: string[] = []; + const program = new Command(); + program.exitOverride(); + program.configureOutput({ + writeOut: (value) => output.push(value), + writeErr: (value) => output.push(value), + }); + registerPrimaryConfigCommands(program); + + try { + await program.parseAsync(['node', 'revpack', 'doctor', '--help']); + } catch { + // Commander exits after printing --help when exitOverride is enabled. + } + + const help = output.join(''); + expect(help).toContain('Usage: revpack doctor [options]'); + expect(help).toContain('--profile '); + expect(help).toContain('--json'); + expect(program.commands.map((command) => command.name())).toEqual(['connect', 'doctor']); + }); + it('describes profile create as the non-interactive creation path', async () => { const output: string[] = []; const program = new Command(); diff --git a/src/cli/commands/config.ts b/src/cli/commands/config.ts index 8729b8e..0ab647c 100644 --- a/src/cli/commands/config.ts +++ b/src/cli/commands/config.ts @@ -48,174 +48,7 @@ export function registerConfigCommand(program: Command): void { .command('setup') .description('Create a profile interactively') .action(async () => { - try { - const remoteUrls = await getRemoteUrlsSafe(); - - // Derive suggested values from git remotes - let suggestedUrl = ''; - let suggestedName = ''; - let detectedProvider: ProviderType | null = null; - for (const remoteUrl of remoteUrls) { - try { - // Handle SSH URLs like git@host:group/project.git - const sshMatch = remoteUrl.match(/@([^:]+):/); - if (sshMatch) { - suggestedUrl = `https://${sshMatch[1]}`; - suggestedName = sshMatch[1].split('.')[0]; - } else { - const parsed = new URL(remoteUrl); - suggestedUrl = `${parsed.protocol}//${parsed.host}`; - suggestedName = parsed.hostname.split('.')[0]; - } - } catch { - continue; - } - // Detect provider from URL - if (remoteUrl.includes('github.com')) { - detectedProvider = 'github'; - } else if (remoteUrl.includes('bitbucket.org')) { - detectedProvider = 'bitbucket-cloud'; - } else if (remoteUrl.includes('gitlab.')) { - detectedProvider = 'gitlab'; - } - - if (detectedProvider) break; - } - - // Use readline for interactive prompts - const { createInterface } = await import('node:readline'); - const rl = createInterface({ input: process.stdin, output: process.stdout }); - const ask = (prompt: string): Promise => new Promise((resolve) => rl.question(prompt, resolve)); - - console.log(chalk.bold('revpack — Profile Setup')); - console.log(''); - if (detectedProvider && suggestedUrl) { - console.log(`Detected ${formatProviderName(detectedProvider)} remote: ${new URL(suggestedUrl).host}`); - console.log(''); - } - - const urlInput = (await ask(`Provider URL${suggestedUrl ? ` [${suggestedUrl}]` : ''}: `)) || suggestedUrl; - let url: string; - try { - url = normalizeProviderUrlInput(urlInput); - } catch (err) { - rl.close(); - throw err; - } - const defaultProvider = getSetupProviderDefault(url, detectedProvider); - const providerInput = shouldPromptForSetupProvider(url) - ? (await ask(`Provider (gitlab/github/bitbucket-cloud) [${defaultProvider}]: `)) || defaultProvider - : defaultProvider; - let provider: ProviderType; - try { - provider = normalizeProviderInput(providerInput); - validateProviderUrlForProvider(url, provider); - } catch (err) { - rl.close(); - throw err; - } - const defaultName = deriveProfileNameFromProviderUrl(url) || suggestedName || 'default'; - const name = (await ask(`Profile name [${defaultName}]: `)) || defaultName; - const defaultTokenEnv = getDefaultTokenEnv(provider); - const tokenEnv = (await ask(`Token environment variable [${defaultTokenEnv}]: `)) || defaultTokenEnv; - let emailEnv = ''; - if (provider === 'bitbucket-cloud') { - emailEnv = - (await ask(`Atlassian account email environment variable [REVPACK_BITBUCKET_EMAIL]: `)) || - 'REVPACK_BITBUCKET_EMAIL'; - } - - // Derive host from URL for matching info - let derivedHost = ''; - if (url) { - try { - derivedHost = new URL(url).host; - } catch { - /* ignore */ - } - } - const extraPatternPrompt = derivedHost - ? `Additional remote match pattern (optional): ` - : `Remote match pattern: `; - const extraPattern = await ask(extraPatternPrompt); - - let caFileInput = ''; - let tlsInput = 'yes'; - let sshCloneInput = 'no'; - const isCloudProvider = isManagedCloudProvider(url, provider); - if (!isCloudProvider) { - caFileInput = await ask(`Custom CA file (optional): `); - tlsInput = (await ask(`Verify TLS certificates [yes]: `)) || 'yes'; - sshCloneInput = (await ask(`Use SSH for git clone (revpack checkout) [no]: `)) || 'no'; - } - - rl.close(); - - const profile: RevpackProfile = { - provider, - }; - if (url) profile.url = url; - if (tokenEnv) profile.tokenEnv = tokenEnv; - if (emailEnv) profile.emailEnv = emailEnv; - if (extraPattern) { - profile.remotePatterns = extraPattern - .split(',') - .map((p) => p.trim()) - .filter(Boolean); - } - if (caFileInput) profile.caFile = caFileInput.replace(/^['"]|['"]$/g, ''); - if (tlsInput.trim().toLowerCase() === 'no' || tlsInput.trim().toLowerCase() === 'false') { - profile.tlsVerify = false; - } - if (['yes', 'true', '1', 'on'].includes(sshCloneInput.trim().toLowerCase())) { - profile.sshClone = true; - } - - // Write - const config = await loadFileConfig(); - config.profiles ??= {}; - config.profiles[name] = profile; - await saveFileConfig(config); - - // Summary - const tokenResolved = profile.tokenEnv ? isTokenEnvResolved(profile.tokenEnv) : false; - console.log(''); - console.log(chalk.green(`✓ Profile "${name}" created`)); - console.log(''); - console.log(` ${chalk.dim('Provider:')} ${profile.provider}`); - if (profile.url) console.log(` ${chalk.dim('URL:')} ${profile.url}`); - if (profile.tokenEnv) { - const tokenStatus = tokenResolved ? chalk.green('set') : chalk.red('missing'); - console.log(` ${chalk.dim('Token:')} ${tokenStatus}`); - console.log(` ${chalk.dim('Token env:')} ${profile.tokenEnv}`); - } - if (profile.emailEnv) { - const emailStatus = isTokenEnvResolved(profile.emailEnv) ? chalk.green('set') : chalk.red('missing'); - console.log(` ${chalk.dim('Email:')} ${emailStatus}`); - console.log(` ${chalk.dim('Email env:')} ${profile.emailEnv}`); - } - const matchDisplay = derivedHost ? `${derivedHost} ${chalk.dim('(derived from URL)')}` : chalk.dim('(none)'); - console.log(` ${chalk.dim('Remote matching:')} ${matchDisplay}`); - if (profile.remotePatterns?.length) { - console.log(` ${chalk.dim('Extra patterns:')} ${profile.remotePatterns.join(', ')}`); - } - if (profile.caFile) console.log(` ${chalk.dim('CA file:')} ${profile.caFile}`); - if (!isCloudProvider) { - console.log(` ${chalk.dim('TLS verify:')} ${profile.tlsVerify === false ? 'false' : 'true'}`); - if (profile.sshClone) console.log(` ${chalk.dim('SSH clone:')} true`); - } - console.log(''); - console.log(chalk.bold('Next:')); - if (profile.tokenEnv && !tokenResolved) { - console.log(` export ${profile.tokenEnv}=...`); - } - if (profile.emailEnv && !isTokenEnvResolved(profile.emailEnv)) { - console.log(` export ${profile.emailEnv}=you@example.com`); - } - console.log(` revpack config doctor --profile ${name}`); - } catch (err) { - handleError(err); - } + await connectAction(); }); // ─── config doctor ─────────────────────────────────────── @@ -226,33 +59,7 @@ export function registerConfigCommand(program: Command): void { .option('--profile ', 'Check a specific profile') .option('--json', 'Output as JSON') .action(async (opts: { profile?: string; json?: boolean }) => { - try { - const remoteUrls = await getRemoteUrlsSafe(); - const result = await runDoctor(remoteUrls, opts.profile); - - if (opts.json) { - outputJson(result); - return; - } - - console.log(chalk.bold('Configuration check')); - console.log(''); - for (const check of result.checks) { - const icon = check.ok ? chalk.green('✓') : chalk.red('✗'); - const detail = check.detail ? chalk.dim(` — ${check.detail}`) : ''; - console.log(` ${icon} ${check.label}${detail}`); - } - - if (result.nextSteps.length > 0) { - console.log(''); - console.log(chalk.bold('Next:')); - for (const step of result.nextSteps) { - console.log(` ${step}`); - } - } - } catch (err) { - handleError(err); - } + await doctorAction(opts); }); // ─── config get ────────────────────────────────────────── @@ -353,7 +160,7 @@ export function registerConfigCommand(program: Command): void { if (names.length === 0) { console.log(chalk.dim('No profiles configured.')); console.log(''); - console.log(`Run ${chalk.cyan('revpack config setup')} to create one.`); + console.log(`Run ${chalk.cyan('revpack connect')} to create one.`); return; } @@ -501,11 +308,11 @@ export function registerConfigCommand(program: Command): void { 'after', ` Create: - revpack config setup + revpack connect Current project: revpack config show - revpack config doctor + revpack doctor Saved profiles: revpack config profile list @@ -514,8 +321,225 @@ Saved profiles: ); } +export function registerPrimaryConfigCommands(program: Command): void { + program + .command('connect') + .description('Create a provider profile interactively') + .action(async () => { + await connectAction(); + }); + + program + .command('doctor') + .description('Check the matching or selected provider profile') + .option('--profile ', 'Check a specific profile') + .option('--json', 'Output as JSON') + .action(async (opts: { profile?: string; json?: boolean }) => { + await doctorAction(opts); + }); +} + // ─── Helpers ───────────────────────────────────────────── +async function connectAction(): Promise { + try { + const remoteUrls = await getRemoteUrlsSafe(); + + // Derive suggested values from git remotes + let suggestedUrl = ''; + let suggestedName = ''; + let detectedProvider: ProviderType | null = null; + for (const remoteUrl of remoteUrls) { + try { + // Handle SSH URLs like git@host:group/project.git + const sshMatch = remoteUrl.match(/@([^:]+):/); + if (sshMatch) { + suggestedUrl = `https://${sshMatch[1]}`; + suggestedName = sshMatch[1].split('.')[0]; + } else { + const parsed = new URL(remoteUrl); + suggestedUrl = `${parsed.protocol}//${parsed.host}`; + suggestedName = parsed.hostname.split('.')[0]; + } + } catch { + continue; + } + // Detect provider from URL + if (remoteUrl.includes('github.com')) { + detectedProvider = 'github'; + } else if (remoteUrl.includes('bitbucket.org')) { + detectedProvider = 'bitbucket-cloud'; + } else if (remoteUrl.includes('gitlab.')) { + detectedProvider = 'gitlab'; + } + + if (detectedProvider) break; + } + + // Use readline for interactive prompts + const { createInterface } = await import('node:readline'); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const ask = (prompt: string): Promise => new Promise((resolve) => rl.question(prompt, resolve)); + + console.log(chalk.bold('revpack — Profile Setup')); + console.log(''); + if (detectedProvider && suggestedUrl) { + console.log(`Detected ${formatProviderName(detectedProvider)} remote: ${new URL(suggestedUrl).host}`); + console.log(''); + } + + const urlInput = (await ask(`Provider URL${suggestedUrl ? ` [${suggestedUrl}]` : ''}: `)) || suggestedUrl; + let url: string; + try { + url = normalizeProviderUrlInput(urlInput); + } catch (err) { + rl.close(); + throw err; + } + const defaultProvider = getSetupProviderDefault(url, detectedProvider); + const providerInput = shouldPromptForSetupProvider(url) + ? (await ask(`Provider (gitlab/github/bitbucket-cloud) [${defaultProvider}]: `)) || defaultProvider + : defaultProvider; + let provider: ProviderType; + try { + provider = normalizeProviderInput(providerInput); + validateProviderUrlForProvider(url, provider); + } catch (err) { + rl.close(); + throw err; + } + const defaultName = deriveProfileNameFromProviderUrl(url) || suggestedName || 'default'; + const name = (await ask(`Profile name [${defaultName}]: `)) || defaultName; + const defaultTokenEnv = getDefaultTokenEnv(provider); + const tokenEnv = (await ask(`Token environment variable [${defaultTokenEnv}]: `)) || defaultTokenEnv; + let emailEnv = ''; + if (provider === 'bitbucket-cloud') { + emailEnv = + (await ask(`Atlassian account email environment variable [REVPACK_BITBUCKET_EMAIL]: `)) || + 'REVPACK_BITBUCKET_EMAIL'; + } + + // Derive host from URL for matching info + let derivedHost = ''; + if (url) { + try { + derivedHost = new URL(url).host; + } catch { + /* ignore */ + } + } + const extraPatternPrompt = derivedHost ? `Additional remote match pattern (optional): ` : `Remote match pattern: `; + const extraPattern = await ask(extraPatternPrompt); + + let caFileInput = ''; + let tlsInput = 'yes'; + let sshCloneInput = 'no'; + const isCloudProvider = isManagedCloudProvider(url, provider); + if (!isCloudProvider) { + caFileInput = await ask(`Custom CA file (optional): `); + tlsInput = (await ask(`Verify TLS certificates [yes]: `)) || 'yes'; + sshCloneInput = (await ask(`Use SSH for git clone (revpack checkout) [no]: `)) || 'no'; + } + + rl.close(); + + const profile: RevpackProfile = { + provider, + }; + if (url) profile.url = url; + if (tokenEnv) profile.tokenEnv = tokenEnv; + if (emailEnv) profile.emailEnv = emailEnv; + if (extraPattern) { + profile.remotePatterns = extraPattern + .split(',') + .map((p) => p.trim()) + .filter(Boolean); + } + if (caFileInput) profile.caFile = caFileInput.replace(/^['"]|['"]$/g, ''); + if (tlsInput.trim().toLowerCase() === 'no' || tlsInput.trim().toLowerCase() === 'false') { + profile.tlsVerify = false; + } + if (['yes', 'true', '1', 'on'].includes(sshCloneInput.trim().toLowerCase())) { + profile.sshClone = true; + } + + // Write + const config = await loadFileConfig(); + config.profiles ??= {}; + config.profiles[name] = profile; + await saveFileConfig(config); + + // Summary + const tokenResolved = profile.tokenEnv ? isTokenEnvResolved(profile.tokenEnv) : false; + console.log(''); + console.log(chalk.green(`✓ Profile "${name}" created`)); + console.log(''); + console.log(` ${chalk.dim('Provider:')} ${profile.provider}`); + if (profile.url) console.log(` ${chalk.dim('URL:')} ${profile.url}`); + if (profile.tokenEnv) { + const tokenStatus = tokenResolved ? chalk.green('set') : chalk.red('missing'); + console.log(` ${chalk.dim('Token:')} ${tokenStatus}`); + console.log(` ${chalk.dim('Token env:')} ${profile.tokenEnv}`); + } + if (profile.emailEnv) { + const emailStatus = isTokenEnvResolved(profile.emailEnv) ? chalk.green('set') : chalk.red('missing'); + console.log(` ${chalk.dim('Email:')} ${emailStatus}`); + console.log(` ${chalk.dim('Email env:')} ${profile.emailEnv}`); + } + const matchDisplay = derivedHost ? `${derivedHost} ${chalk.dim('(derived from URL)')}` : chalk.dim('(none)'); + console.log(` ${chalk.dim('Remote matching:')} ${matchDisplay}`); + if (profile.remotePatterns?.length) { + console.log(` ${chalk.dim('Extra patterns:')} ${profile.remotePatterns.join(', ')}`); + } + if (profile.caFile) console.log(` ${chalk.dim('CA file:')} ${profile.caFile}`); + if (!isCloudProvider) { + console.log(` ${chalk.dim('TLS verify:')} ${profile.tlsVerify === false ? 'false' : 'true'}`); + if (profile.sshClone) console.log(` ${chalk.dim('SSH clone:')} true`); + } + console.log(''); + console.log(chalk.bold('Next:')); + if (profile.tokenEnv && !tokenResolved) { + console.log(` export ${profile.tokenEnv}=...`); + } + if (profile.emailEnv && !isTokenEnvResolved(profile.emailEnv)) { + console.log(` export ${profile.emailEnv}=you@example.com`); + } + console.log(` revpack doctor --profile ${name}`); + } catch (err) { + handleError(err); + } +} + +async function doctorAction(opts: { profile?: string; json?: boolean }): Promise { + try { + const remoteUrls = await getRemoteUrlsSafe(); + const result = await runDoctor(remoteUrls, opts.profile); + + if (opts.json) { + outputJson(result); + return; + } + + console.log(chalk.bold('Configuration check')); + console.log(''); + for (const check of result.checks) { + const icon = check.ok ? chalk.green('✓') : chalk.red('✗'); + const detail = check.detail ? chalk.dim(` — ${check.detail}`) : ''; + console.log(` ${icon} ${check.label}${detail}`); + } + + if (result.nextSteps.length > 0) { + console.log(''); + console.log(chalk.bold('Next:')); + for (const step of result.nextSteps) { + console.log(` ${step}`); + } + } + } catch (err) { + handleError(err); + } +} + async function showAction(opts: { profile?: string; json?: boolean; sources?: boolean }): Promise { try { const remoteUrls = await getRemoteUrlsSafe(); @@ -621,7 +645,7 @@ async function showNoMatchMessage(remoteUrls: string[]): Promise { } console.log(chalk.bold('Next:')); - console.log(` revpack config setup`); + console.log(` revpack connect`); if (remoteUrls.length > 0 && names.length > 0) { const firstName = names[0]; console.log(` revpack config set remotePatterns --profile ${firstName}`); diff --git a/src/cli/commands/setup.test.ts b/src/cli/commands/setup.test.ts index 1582bf8..0acea01 100644 --- a/src/cli/commands/setup.test.ts +++ b/src/cli/commands/setup.test.ts @@ -44,6 +44,15 @@ describe('runSetup', () => { await expect(fileExists(path.join('.claude', 'skills', 'revpack-review', 'SKILL.md'))).resolves.toBe(true); }); + it('installs REVIEW.md and the selected adapter with setup --agent', async () => { + await runSetup({ cwd, agent: 'codex' }); + + await expect(fileExists('REVIEW.md')).resolves.toBe(true); + await expect(fileExists(path.join('.agents', 'skills', 'revpack-review', 'SKILL.md'))).resolves.toBe(true); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Use it in Codex with:')); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining(' $revpack-review')); + }); + it('installs the Cursor adapter at the canonical revpack-review path', async () => { await runSetupAgent({ cwd, target: 'cursor' }); @@ -100,6 +109,18 @@ describe('runSetup', () => { expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Dry run - no files written.')); }); + it('parses setup --agent as combined review guidance and adapter setup', async () => { + process.chdir(cwd); + const program = new Command(); + program.exitOverride(); + registerSetupCommand(program); + + await program.parseAsync(['node', 'revpack', 'setup', '--agent', 'codex']); + + await expect(fileExists('REVIEW.md')).resolves.toBe(true); + await expect(fileExists(path.join('.agents', 'skills', 'revpack-review', 'SKILL.md'))).resolves.toBe(true); + }); + async function fileExists(relativePath: string): Promise { try { await fs.access(path.join(cwd, relativePath)); diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 259cf62..8b456c7 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -26,6 +26,7 @@ interface SetupResult { interface SetupOptions { cwd: string; prompts?: boolean; + agent?: AgentTarget; dryRun?: boolean; } @@ -71,10 +72,15 @@ export function registerSetupCommand(program: Command): void { const setupCmd = program .command('setup') .description('Create REVIEW.md and optional agent harness files') + .option( + '--agent ', + `Also install an agent harness adapter (${SUPPORTED_AGENT_TARGETS.join(', ')})`, + parseAgentTarget, + ) .option('--prompts', 'Deprecated alias for `setup agent copilot`') .option('--dry-run', 'Show what would be created or updated without writing files') - .action(async (opts: { prompts?: boolean; dryRun?: boolean }) => { - await runSetup({ cwd: process.cwd(), prompts: opts.prompts, dryRun: opts.dryRun }); + .action(async (opts: { agent?: AgentTarget; prompts?: boolean; dryRun?: boolean }) => { + await runSetup({ cwd: process.cwd(), agent: opts.agent, prompts: opts.prompts, dryRun: opts.dryRun }); }); setupCmd @@ -89,20 +95,32 @@ export function registerSetupCommand(program: Command): void { export async function runSetup(opts: SetupOptions): Promise { const templatesDir = resolveTemplatesDir(); - const files = opts.prompts ? [REVIEW_CONFIG_FILE, AGENT_FILES.copilot] : [REVIEW_CONFIG_FILE]; + const files = uniqueSetupFiles([ + REVIEW_CONFIG_FILE, + ...(opts.agent ? [AGENT_FILES[opts.agent]] : []), + ...(opts.prompts ? [AGENT_FILES.copilot] : []), + ]); const results = await installCopiedFiles(opts.cwd, templatesDir, files, opts.dryRun); printResults(results, opts.dryRun); + if (opts.agent) { + printAgentUsage(opts.agent); + } + if (!opts.dryRun && results.some((result) => result.status === 'created' || result.status === 'updated')) { console.log(''); console.log(formatGuidanceLine('Next steps:')); if (results.some((result) => result.target === 'REVIEW.md' && result.status === 'created')) { console.log(formatGuidanceLine(' 1. Edit REVIEW.md - tailor review priorities to your project')); } - if (!opts.prompts) { + if (opts.agent) { + console.log(formatGuidanceLine(' revpack prepare')); + } else if (!opts.prompts) { console.log(formatGuidanceLine(' Tip: install an agent adapter, for example:')); console.log(formatGuidanceLine(' revpack setup agent codex')); + console.log(formatGuidanceLine(' Or create both files at once:')); + console.log(formatGuidanceLine(' revpack setup --agent codex')); } else { console.log(formatGuidanceLine(' Tip: `revpack setup --prompts` is deprecated; use:')); console.log(formatGuidanceLine(' revpack setup agent copilot')); @@ -234,6 +252,15 @@ function parseAgentTarget(value: string): AgentTarget { ); } +function uniqueSetupFiles(files: SetupFile[]): SetupFile[] { + const seen = new Set(); + return files.filter((file) => { + if (seen.has(file.target)) return false; + seen.add(file.target); + return true; + }); +} + function normalizeLineEndings(content: string): string { return content.replace(/\r\n?/g, '\n'); } diff --git a/src/cli/index.ts b/src/cli/index.ts index 7dfdbf9..e23ddf5 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -7,7 +7,7 @@ import { registerStatusCommand } from './commands/status.js'; import { registerCheckoutCommand } from './commands/checkout.js'; import { registerCleanCommand } from './commands/clean.js'; import { registerPublishCommand } from './commands/publish.js'; -import { registerConfigCommand } from './commands/config.js'; +import { registerConfigCommand, registerPrimaryConfigCommands } from './commands/config.js'; import { registerSetupCommand } from './commands/setup.js'; const program = new Command(); @@ -19,6 +19,25 @@ program .description('Prepare AI-ready PR/MR review bundles and publish review feedback.') .version(version); +program.addHelpText( + 'after', + ` +Typical workflow: + revpack connect + revpack doctor + revpack setup --agent codex + revpack prepare + + revpack status + revpack publish all + +Other review targets: + revpack checkout + revpack prepare --local [base] +`, +); + +registerPrimaryConfigCommands(program); registerPrepareCommand(program); registerStatusCommand(program); registerCheckoutCommand(program); diff --git a/src/config/index.ts b/src/config/index.ts index 2fea24f..71133d2 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -175,7 +175,7 @@ export async function runDoctor(remoteUrls: string[], explicitProfile?: string): checks.push({ ok: true, label: `Profile: ${profileName}` }); } catch (err) { checks.push({ ok: false, label: 'Profile resolution', detail: (err as Error).message }); - nextSteps.push('revpack config setup'); + nextSteps.push('revpack connect'); return { checks, profileName, nextSteps }; } diff --git a/src/config/profile-resolver.ts b/src/config/profile-resolver.ts index bbbc727..5e02abb 100644 --- a/src/config/profile-resolver.ts +++ b/src/config/profile-resolver.ts @@ -101,7 +101,7 @@ export class ProfileResolver { // 3. Fail throw new ConfigError( - 'No profile matched the current repository.\n\nRun `revpack config setup` to create a profile, or use `--profile ` to select one explicitly.', + 'No profile matched the current repository.\n\nRun `revpack connect` to create a profile, or use `--profile ` to select one explicitly.', ); } }