Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 12 additions & 11 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -407,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 <branch-name>
gh pr create --draft --title "<type>(scope): description" --body "Closes #<issue-number>"
```
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
Expand Down
73 changes: 12 additions & 61 deletions apps/cli/src/commands/self/index.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,9 @@
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',
Expand Down Expand Up @@ -56,41 +31,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.');
}
},
});
Expand Down
104 changes: 104 additions & 0 deletions apps/cli/src/self-update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* 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] ?? '');
}

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.
const child = spawn(cmd, args, { stdio: ['inherit', 'pipe', 'inherit'] });
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 };
}
}
52 changes: 42 additions & 10 deletions apps/cli/src/version-check.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
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';

const ANSI_YELLOW = '\u001b[33m';
const ANSI_RED = '\u001b[31m';
const ANSI_GREEN = '\u001b[32m';
const ANSI_RESET = '\u001b[0m';

export interface VersionCheckResult {
Expand Down Expand Up @@ -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.
*/
Expand All @@ -58,10 +63,10 @@ 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);
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}`,
);
Expand All @@ -70,17 +75,44 @@ 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<boolean> {
async function promptUpdate(): Promise<boolean> {
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<void> {
// 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: safeRange });

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);
}
Loading