From 1e4b1f1fd6d4b167cabbcb0ec65c4febd4851ed6 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Thu, 16 Apr 2026 07:12:13 +0000 Subject: [PATCH 1/7] feat(cli): auto-update when version mismatch detected Replace "Continue anyway?" prompt with "Update now? (Y/n)" when required_version doesn't satisfy the installed version. Selecting Y runs self-update inline using the config's version range (respecting major-version constraints), then exits with a re-run message. N continues the command without interruption. Extract shared update logic into self-update.ts so both the version check and `agentv self update` command reuse the same code. Closes #1125 Co-Authored-By: Claude Opus 4.6 --- apps/cli/src/commands/self/index.ts | 76 ++++---------------- apps/cli/src/self-update.ts | 105 ++++++++++++++++++++++++++++ apps/cli/src/version-check.ts | 42 ++++++++--- 3 files changed, 154 insertions(+), 69 deletions(-) create mode 100644 apps/cli/src/self-update.ts diff --git a/apps/cli/src/commands/self/index.ts b/apps/cli/src/commands/self/index.ts index beeff3834..44f40b3e2 100644 --- a/apps/cli/src/commands/self/index.ts +++ b/apps/cli/src/commands/self/index.ts @@ -1,34 +1,12 @@ -import { spawn } from 'node:child_process'; import { command, flag, subcommands } from 'cmd-ts'; import packageJson from '../../../package.json' with { type: 'json' }; +import { + detectPackageManager, + performSelfUpdate, +} from '../../self-update.js'; -/** - * Detect package manager from the script path. - * If the path contains '.bun', it was installed via bun; otherwise assume npm. - */ -export function detectPackageManagerFromPath(scriptPath: string): 'bun' | 'npm' { - if (scriptPath.includes('.bun')) { - return 'bun'; - } - return 'npm'; -} - -function detectPackageManager(): 'bun' | 'npm' { - return detectPackageManagerFromPath(process.argv[1] ?? ''); -} - -function runCommand(cmd: string, args: string[]): Promise<{ exitCode: number; stdout: string }> { - return new Promise((resolve, reject) => { - const child = spawn(cmd, args, { stdio: ['inherit', 'pipe', 'inherit'], shell: true }); - let stdout = ''; - child.stdout?.on('data', (data: Buffer) => { - process.stdout.write(data); - stdout += data.toString(); - }); - child.on('error', reject); - child.on('close', (code) => resolve({ exitCode: code ?? 1, stdout })); - }); -} +// Re-export for existing tests +export { detectPackageManagerFromPath } from '../../self-update.js'; const updateCommand = command({ name: 'update', @@ -56,41 +34,17 @@ const updateCommand = command({ console.log(`Current version: ${currentVersion}`); console.log(`Updating agentv using ${pm}...\n`); - const args = pm === 'npm' ? ['install', '-g', 'agentv@latest'] : ['add', '-g', 'agentv@latest']; - - try { - const result = await runCommand(pm, args); - - if (result.exitCode !== 0) { - console.error('\nUpdate failed.'); - process.exit(1); - } + const result = await performSelfUpdate({ pm, currentVersion }); - // Get new version - let newVersion: string | undefined; - try { - const versionResult = await runCommand('agentv', ['--version']); - newVersion = versionResult.stdout.trim(); - } catch { - // Ignore - version check is best-effort - } + if (!result.success) { + console.error('\nUpdate failed.'); + process.exit(1); + } - if (newVersion) { - console.log(`\nUpdate complete: ${currentVersion} → ${newVersion}`); - } else { - console.log('\nUpdate complete.'); - } - } catch (error) { - if (error instanceof Error) { - if (error.message.includes('ENOENT') || error.message.includes('not found')) { - const alternative = pm === 'npm' ? 'bun' : 'npm'; - console.error(`Error: ${pm} not found. Try using --${alternative} flag.`); - } else { - console.error(`Error: ${error.message}`); - } - process.exit(1); - } - throw error; + if (result.newVersion) { + console.log(`\nUpdate complete: ${currentVersion} → ${result.newVersion}`); + } else { + console.log('\nUpdate complete.'); } }, }); diff --git a/apps/cli/src/self-update.ts b/apps/cli/src/self-update.ts new file mode 100644 index 000000000..a549e3ddd --- /dev/null +++ b/apps/cli/src/self-update.ts @@ -0,0 +1,105 @@ +/** + * Shared self-update logic for agentv. + * + * Used by both `agentv self update` and the version-check prompt + * when the installed version doesn't satisfy `required_version`. + * + * When called from the version-check prompt, a `versionRange` (from the + * project's `required_version` config) is passed through as the npm/bun + * version specifier (e.g., `agentv@">=4.1.0"`). This ensures the update + * respects the project's constraints and avoids unintended major-version jumps. + * + * When called from `agentv self update` (no range), it installs `@latest`. + * + * To add a new package manager: add a case to `detectPackageManagerFromPath()` + * and a corresponding install-args entry in `getInstallArgs()`. + */ + +import { spawn } from 'node:child_process'; + +/** + * Detect package manager from the script path. + * If the path contains '.bun', it was installed via bun; otherwise assume npm. + */ +export function detectPackageManagerFromPath(scriptPath: string): 'bun' | 'npm' { + if (scriptPath.includes('.bun')) { + return 'bun'; + } + return 'npm'; +} + +export function detectPackageManager(): 'bun' | 'npm' { + return detectPackageManagerFromPath(process.argv[1] ?? ''); +} + +export function runCommand( + cmd: string, + args: string[], +): Promise<{ exitCode: number; stdout: string }> { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { stdio: ['inherit', 'pipe', 'inherit'], shell: true }); + let stdout = ''; + child.stdout?.on('data', (data: Buffer) => { + process.stdout.write(data); + stdout += data.toString(); + }); + child.on('error', reject); + child.on('close', (code) => resolve({ exitCode: code ?? 1, stdout })); + }); +} + +function getInstallArgs(pm: 'bun' | 'npm', versionSpec: string): string[] { + const pkg = `agentv@${versionSpec}`; + return pm === 'npm' ? ['install', '-g', pkg] : ['add', '-g', pkg]; +} + +/** + * Run the self-update flow: install agentv globally using the detected + * (or specified) package manager. + * + * @param options.pm - Force a specific package manager + * @param options.currentVersion - Current installed version (for display) + * @param options.versionRange - Semver range from config (e.g., ">=4.1.0"). + * When provided, used as the npm/bun version specifier so the update + * stays within the project's constraints. When omitted, installs `@latest`. + */ +export async function performSelfUpdate(options?: { + pm?: 'bun' | 'npm'; + currentVersion?: string; + versionRange?: string; +}): Promise<{ success: boolean; currentVersion: string; newVersion?: string }> { + const pm = options?.pm ?? detectPackageManager(); + const currentVersion = options?.currentVersion ?? 'unknown'; + const versionSpec = options?.versionRange ?? 'latest'; + + const args = getInstallArgs(pm, versionSpec); + + try { + const result = await runCommand(pm, args); + + if (result.exitCode !== 0) { + return { success: false, currentVersion }; + } + + // Best-effort version check after update + let newVersion: string | undefined; + try { + const versionResult = await runCommand('agentv', ['--version']); + newVersion = versionResult.stdout.trim(); + } catch { + // Ignore - version check is best-effort + } + + return { success: true, currentVersion, newVersion }; + } catch (error) { + if (error instanceof Error) { + if (error.message.includes('ENOENT') || error.message.includes('not found')) { + const alternative = pm === 'npm' ? 'bun' : 'npm'; + console.error(`Error: ${pm} not found. Try using --${alternative} flag.`); + } else { + console.error(`Error: ${error.message}`); + } + } + return { success: false, currentVersion }; + } +} diff --git a/apps/cli/src/version-check.ts b/apps/cli/src/version-check.ts index b0356ce6e..93b178215 100644 --- a/apps/cli/src/version-check.ts +++ b/apps/cli/src/version-check.ts @@ -1,9 +1,11 @@ import { coerce, satisfies, validRange } from 'semver'; import packageJson from '../package.json' with { type: 'json' }; +import { performSelfUpdate } from './self-update.js'; const ANSI_YELLOW = '\u001b[33m'; const ANSI_RED = '\u001b[31m'; +const ANSI_GREEN = '\u001b[32m'; const ANSI_RESET = '\u001b[0m'; export interface VersionCheckResult { @@ -38,7 +40,10 @@ export function checkVersion(requiredVersion: string): VersionCheckResult { * - If the version satisfies the range, returns silently. * - If the range is malformed, prints an error and exits with code 1. * - If the version is below the range: - * - Interactive (TTY): warns and prompts to continue or abort. + * - Interactive (TTY): warns and prompts "Update now? (Y/n)". + * Y → runs self-update inline (constrained to the config range), + * then exits with a message to re-run the command. + * N → continues the command as-is. * - Non-interactive: warns to stderr, continues (unless strict). * - Strict mode: warns and exits with code 1. */ @@ -58,7 +63,7 @@ export async function enforceRequiredVersion( return; } - const warning = `${ANSI_YELLOW}Warning: This project requires agentv ${result.requiredRange} but you have ${result.currentVersion}.${ANSI_RESET}\n Run \`agentv self update\` to upgrade.`; + const warning = `${ANSI_YELLOW}Warning: This project requires agentv ${result.requiredRange} but you have ${result.currentVersion}.${ANSI_RESET}`; if (options?.strict) { console.error(warning); @@ -70,17 +75,38 @@ export async function enforceRequiredVersion( if (process.stdin.isTTY && process.stdout.isTTY) { console.warn(warning); - const shouldContinue = await promptContinue(); - if (!shouldContinue) { - process.exit(1); + const shouldUpdate = await promptUpdate(); + if (shouldUpdate) { + await runInlineUpdate(result.currentVersion, result.requiredRange); } + // N → continue the command without interruption } else { // Non-interactive: warn to stderr and continue - process.stderr.write(`${warning}\n`); + process.stderr.write(`${warning}\n Run \`agentv self update\` to upgrade.\n`); } } -async function promptContinue(): Promise { +async function promptUpdate(): Promise { const { confirm } = await import('@inquirer/prompts'); - return confirm({ message: 'Continue anyway?', default: false }); + return confirm({ message: 'Update now?', default: true }); +} + +async function runInlineUpdate(currentVersion: string, versionRange: string): Promise { + console.log(''); + const result = await performSelfUpdate({ currentVersion, versionRange }); + + if (!result.success) { + console.error(`${ANSI_RED}Update failed. Run \`agentv self update\` manually.${ANSI_RESET}`); + process.exit(1); + } + + if (result.newVersion) { + console.log( + `\n${ANSI_GREEN}Update complete: ${currentVersion} → ${result.newVersion}${ANSI_RESET}`, + ); + } else { + console.log(`\n${ANSI_GREEN}Update complete.${ANSI_RESET}`); + } + console.log('Please re-run your command.'); + process.exit(0); } From 8a75e7dc3e1cfee516ce2b302735eed23f61419d Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Thu, 16 Apr 2026 07:13:46 +0000 Subject: [PATCH 2/7] style: fix biome formatting for import statement Co-Authored-By: Claude Opus 4.6 --- apps/cli/src/commands/self/index.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/cli/src/commands/self/index.ts b/apps/cli/src/commands/self/index.ts index 44f40b3e2..0a08a7923 100644 --- a/apps/cli/src/commands/self/index.ts +++ b/apps/cli/src/commands/self/index.ts @@ -1,9 +1,6 @@ import { command, flag, subcommands } from 'cmd-ts'; import packageJson from '../../../package.json' with { type: 'json' }; -import { - detectPackageManager, - performSelfUpdate, -} from '../../self-update.js'; +import { detectPackageManager, performSelfUpdate } from '../../self-update.js'; // Re-export for existing tests export { detectPackageManagerFromPath } from '../../self-update.js'; From 5baf6229db70ee61e63cb6ba0b1342de3b74d172 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Thu, 16 Apr 2026 07:18:44 +0000 Subject: [PATCH 3/7] fix(cli): cap auto-update at current major version Intersect the config's required_version range with <(major+1).0.0 so auto-update never pulls in a new major version with potential breaking changes. E.g., if current is 4.14.2 and range is ">=4.1.0", installs ">=4.1.0 <5.0.0" instead of ">=4.1.0". Co-Authored-By: Claude Opus 4.6 --- apps/cli/src/version-check.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/version-check.ts b/apps/cli/src/version-check.ts index 93b178215..94e0a6d47 100644 --- a/apps/cli/src/version-check.ts +++ b/apps/cli/src/version-check.ts @@ -1,4 +1,4 @@ -import { coerce, satisfies, validRange } from 'semver'; +import { coerce, major, satisfies, validRange } from 'semver'; import packageJson from '../package.json' with { type: 'json' }; import { performSelfUpdate } from './self-update.js'; @@ -92,8 +92,14 @@ async function promptUpdate(): Promise { } async function runInlineUpdate(currentVersion: string, versionRange: string): Promise { + // Cap at the current major version to avoid unintended breaking changes. + // e.g., if current is 4.14.2 and range is ">=4.1.0", install ">=4.1.0 <5.0.0" + // so that a hypothetical 5.0.0 is never pulled in by auto-update. + const currentMajor = major(coerce(currentVersion) ?? currentVersion); + const safeRange = `${versionRange} <${currentMajor + 1}.0.0`; + console.log(''); - const result = await performSelfUpdate({ currentVersion, versionRange }); + const result = await performSelfUpdate({ currentVersion, versionRange: safeRange }); if (!result.success) { console.error(`${ANSI_RED}Update failed. Run \`agentv self update\` manually.${ANSI_RESET}`); From 0079a0279d05e1c682c53a0506079599673c6da9 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Thu, 16 Apr 2026 07:22:29 +0000 Subject: [PATCH 4/7] fix(cli): prevent shell injection with semver ranges in self-update Remove shell: true from spawn() to avoid shell interpretation of semver operators (>, <, |) in version ranges. Also restore upgrade hint in strict mode output and unexport runCommand. Co-Authored-By: Claude Opus 4.6 --- apps/cli/src/self-update.ts | 6 ++++-- apps/cli/src/version-check.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/self-update.ts b/apps/cli/src/self-update.ts index a549e3ddd..8c7ae77c0 100644 --- a/apps/cli/src/self-update.ts +++ b/apps/cli/src/self-update.ts @@ -32,12 +32,14 @@ export function detectPackageManager(): 'bun' | 'npm' { return detectPackageManagerFromPath(process.argv[1] ?? ''); } -export function runCommand( +function runCommand( cmd: string, args: string[], ): Promise<{ exitCode: number; stdout: string }> { return new Promise((resolve, reject) => { - const child = spawn(cmd, args, { stdio: ['inherit', 'pipe', 'inherit'], shell: true }); + // No shell: true — args are passed directly to execvp, avoiding shell + // interpretation of semver operators (>, <, |) in version ranges. + const child = spawn(cmd, args, { stdio: ['inherit', 'pipe', 'inherit'] }); let stdout = ''; child.stdout?.on('data', (data: Buffer) => { process.stdout.write(data); diff --git a/apps/cli/src/version-check.ts b/apps/cli/src/version-check.ts index 94e0a6d47..d3bbaeed1 100644 --- a/apps/cli/src/version-check.ts +++ b/apps/cli/src/version-check.ts @@ -66,7 +66,7 @@ export async function enforceRequiredVersion( const warning = `${ANSI_YELLOW}Warning: This project requires agentv ${result.requiredRange} but you have ${result.currentVersion}.${ANSI_RESET}`; if (options?.strict) { - console.error(warning); + console.error(`${warning}\n Run \`agentv self update\` to upgrade.`); console.error( `${ANSI_RED}Aborting: --strict mode requires the installed version to satisfy the required range.${ANSI_RESET}`, ); From df0502fb0c6be48ce4dcf6ff470d9ee0b66092cf Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Thu, 16 Apr 2026 07:23:56 +0000 Subject: [PATCH 5/7] style: fix biome formatting for runCommand signature Co-Authored-By: Claude Opus 4.6 --- apps/cli/src/self-update.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/cli/src/self-update.ts b/apps/cli/src/self-update.ts index 8c7ae77c0..a7409d748 100644 --- a/apps/cli/src/self-update.ts +++ b/apps/cli/src/self-update.ts @@ -32,10 +32,7 @@ export function detectPackageManager(): 'bun' | 'npm' { return detectPackageManagerFromPath(process.argv[1] ?? ''); } -function runCommand( - cmd: string, - args: string[], -): Promise<{ exitCode: number; stdout: string }> { +function runCommand(cmd: string, args: string[]): Promise<{ exitCode: number; stdout: string }> { return new Promise((resolve, reject) => { // No shell: true — args are passed directly to execvp, avoiding shell // interpretation of semver operators (>, <, |) in version ranges. From 9e678e35361081b7c6c742c2ae4dea448ad6eb2a Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Thu, 16 Apr 2026 07:35:33 +0000 Subject: [PATCH 6/7] =?UTF-8?q?docs:=20add=20step=207=20to=20E2E=20checkli?= =?UTF-8?q?st=20=E2=80=94=20check=20off=20test=20plan=20items=20in=20PR=20?= =?UTF-8?q?body?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make it explicit that agents must execute each test plan item from the issue/PR checklist, mark them [x], and include CLI output as evidence. Co-Authored-By: Claude Opus 4.6 --- AGENTS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 0ec1d5789..c121b9baa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -315,7 +315,9 @@ Before marking any branch as ready for review, complete this checklist: 6. **Studio UX verification**: For changes affecting config, scoring display, or studio API, use `agent-browser` to verify the studio UI still renders and functions correctly (settings page loads, pass/fail indicators are correct, config saves work). -7. **Mark PR as ready** only after steps 1-6 have been completed AND red/green UAT evidence is included in the PR. +7. **Check off test plan items in the PR body.** If the issue or PR has a test plan checklist, execute each item and mark it `[x]` in the PR description. Include the actual CLI output as E2E evidence (in a fenced code block) so reviewers can see the before/after results without re-running. + +8. **Mark PR as ready** only after steps 1-7 have been completed AND red/green UAT evidence is included in the PR. ## Documentation Updates From 4885ef02400dd006bdd7e6cbb072b17133aee4b9 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Thu, 16 Apr 2026 07:39:06 +0000 Subject: [PATCH 7/7] docs: clarify draft PR timing and E2E checklist ordering in AGENTS.md - Step 4: push + draft PR after FIRST commit (not after implementation) - Step 5: implement incrementally, push at each checkpoint - Step 6: E2E verification is BLOCKING before marking ready; includes checking off test plan items with CLI output evidence Co-Authored-By: Claude Opus 4.6 --- AGENTS.md | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c121b9baa..255f4c5f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -315,9 +315,7 @@ Before marking any branch as ready for review, complete this checklist: 6. **Studio UX verification**: For changes affecting config, scoring display, or studio API, use `agent-browser` to verify the studio UI still renders and functions correctly (settings page loads, pass/fail indicators are correct, config saves work). -7. **Check off test plan items in the PR body.** If the issue or PR has a test plan checklist, execute each item and mark it `[x]` in the PR description. Include the actual CLI output as E2E evidence (in a fenced code block) so reviewers can see the before/after results without re-running. - -8. **Mark PR as ready** only after steps 1-7 have been completed AND red/green UAT evidence is included in the PR. +7. **Mark PR as ready** only after steps 1-6 have been completed AND red/green UAT evidence is included in the PR. ## Documentation Updates @@ -409,21 +407,22 @@ When working on a GitHub issue, **ALWAYS** follow this workflow: The feature branch must be based on the freshly updated `main`, not a stale local checkout. -4. **Implement the changes** and commit following the commit convention - -5. **Push regularly and open a draft Pull Request early**: +4. **After your first commit, push and open a draft PR immediately:** ```bash git push -u origin gh pr create --draft --title "(scope): description" --body "Closes #" ``` - Push incremental commits to the draft PR as you work so progress is visible and recoverable. - -6. **Before marking the PR ready for review or merging a low-risk change**, ensure (in this order): - 1. **E2E verification completed** (see "Completing Work — E2E Checklist") — this must pass first. - 2. For CLI or other user-facing changes, run at least one manual end-to-end check of the real user flow, not just unit/integration tests. - 3. **After e2e passes**, spawn a final subagent code review pass and address or call out any findings. Do NOT run the code review before e2e — if e2e fails you'll need to fix it first, which invalidates the review. - 4. CI pipeline passes (all checks green). - 5. No merge conflicts with `main`. + Do NOT wait until implementation is complete. The draft PR is a handoff artifact — if the session is interrupted, the user or another agent can pick up where you left off. + +5. **Implement the changes.** Commit and push incrementally as you work. Every meaningful checkpoint (feature compiles, tests pass, new behavior added) should be pushed to the draft PR so progress is visible and recoverable. + +6. **Complete E2E verification** (see "Completing Work — E2E Checklist") — this is BLOCKING. Do NOT mark the PR ready for review until every step of the E2E checklist has passed and evidence is documented in the PR body. Specifically: + 1. Run unit tests. + 2. Execute every test plan item from the issue/PR checklist, mark each `[x]`, and paste CLI output as evidence. + 3. Manual red/green UAT with before/after evidence. + 4. **After e2e passes**, spawn a final subagent code review pass and address or call out any findings. Do NOT run the code review before e2e — if e2e fails you'll need to fix it first, which invalidates the review. + 5. CI pipeline passes (all checks green). + 6. No merge conflicts with `main`. 7. **Only after verification is complete**: - Mark the draft PR ready for review, or