From 099197e0ebfc238abd88bbf57f3fba2d30771bea Mon Sep 17 00:00:00 2001 From: Antonio Mancini Date: Sat, 10 Oct 2026 05:33:09 +0000 Subject: [PATCH] fix: provision the agent-browser runtime for its skill stub The web preset installs the agent-browser skill stub but never its CLI or browser binaries, so every agent-browser command failed with command not found (#4). - aginit init gains a conditional provisioning step: npm i -g agent-browser when the CLI is missing, then agent-browser install for the browser binaries; failures mark the setup incomplete - init and doctor detect kernel.apparmor_restrict_unprivileged_userns=1 (and kernel.unprivileged_userns_clone=0) and report the persisted sysctl fix plus the narrower --no-sandbox alternative, never changing system settings on the user's behalf - aginit doctor verifies the runtime when the project ships the skill: CLI presence and upstream agent-browser doctor --quick --offline --json findings --- CHANGELOG.md | 11 ++ README.md | 7 +- src/adapters/agent-browser.ts | 162 +++++++++++++++ src/cli/doctor.ts | 73 +++++++ src/cli/new.ts | 21 +- src/index.ts | 1 + tests/agent-browser.test.ts | 363 ++++++++++++++++++++++++++++++++++ 7 files changed, 633 insertions(+), 5 deletions(-) create mode 100644 src/adapters/agent-browser.ts create mode 100644 tests/agent-browser.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d639da8..d160775 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased + +### Fixed + +- Web projects no longer ship an agent-browser skill stub without its runtime: `aginit init` now installs the `agent-browser` CLI (`npm i -g agent-browser`) and its browser binaries (`agent-browser install`) when the skill is configured or already on disk, and reports a failed provisioning as an incomplete setup (#4). +- `aginit init` and `aginit doctor` detect the Linux restriction that blocks Chrome's sandbox (`kernel.apparmor_restrict_unprivileged_userns=1`) and report the persisted sysctl fix plus the narrower `--no-sandbox` alternative instead of failing later with `No usable sandbox!`. + +### Added + +- `aginit doctor` verifies the agent-browser runtime when the project ships the skill: CLI presence, upstream `agent-browser doctor --quick --offline --json` findings, and Chrome sandbox prerequisites. + ## 0.2.0 — 2026-10-09 ### Breaking diff --git a/README.md b/README.md index 3c9b7a9..d07a0dc 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ The web preset supports `--framework none` (default), `vite`, `next`, or `existi ### Agent configuration - `AGENTS.md`: project instructions shared by agents. -- `.agents/skills/`: the 27 stable skills from Matt Pocock (engineering + productivity), and `humanizer` from Blake Robbins. Web projects also include Impeccable and agent-browser. +- `.agents/skills/`: the 27 stable skills from Matt Pocock (engineering + productivity), and `humanizer` from Blake Robbins. Web projects also include Impeccable and agent-browser; for those, `aginit init` also installs the `agent-browser` CLI and its browser binaries so the skill works out of the box. - Graft: a codebase graph for `graft map` and `graft ask`. - `aginit.config.json`: saved preset, framework, package manager, agents, and skill sources. @@ -87,7 +87,7 @@ aginit doctor aginit update ``` -`doctor` checks Node.js, the project package manager, Git, Graft, Codex, the Skills CLI, and project configuration. It may download the Skills CLI through npx. +`doctor` checks Node.js, the project package manager, Git, Graft, Codex, the Skills CLI, the agent-browser runtime (CLI, browser binaries, Chrome sandbox prerequisites) when the project ships that skill, and project configuration. It may download the Skills CLI through npx. `update` runs project-scoped `skills update` and rebuilds the Graft index. Disabled integrations are skipped; failed updates return a nonzero exit status. @@ -95,7 +95,8 @@ aginit update - Rerunning the same initialization is idempotent. Manifest additions and explicit configuration changes are applied without replacing application code. - Framework scaffold failures stop the command. -- Missing Graft or failed skill installation returns a nonzero exit status. The scaffold is retained so you can fix the prerequisite and rerun `aginit init`. +- Missing Graft, failed skill installation, or a failed agent-browser runtime setup returns a nonzero exit status. The scaffold is retained so you can fix the prerequisite and rerun `aginit init`. +- Linux kernels that restrict unprivileged user namespaces block Chrome's sandbox (`No usable sandbox!`). aginit reports the sysctl fix and the narrower `--no-sandbox` alternative; it never changes system settings on your behalf. - `--dry-run` lists planned operations without writing files or downloading framework tools. Files produced by upstream scaffolders are only known after they run. - Playwright's starter test is skipped until you add an application URL and assertions. Generated test harnesses still need application-specific tests. diff --git a/src/adapters/agent-browser.ts b/src/adapters/agent-browser.ts new file mode 100644 index 0000000..4d1ef7f --- /dev/null +++ b/src/adapters/agent-browser.ts @@ -0,0 +1,162 @@ +import path from 'node:path'; +import { fileExists, readTextFile } from '../utils/fs.js'; +import { commandExists, runExecutable } from '../utils/shell.js'; +import { logger } from '../utils/logger.js'; +import { SkillSourceConfig } from '../config/schema.js'; + +const AGENT_BROWSER_SKILL = 'agent-browser'; +const AGENT_BROWSER_CLI = 'agent-browser'; +const AGENT_BROWSER_PACKAGE = 'vercel-labs/agent-browser'; + +/** + * Whether a configured skill source will install the agent-browser skill stub + * whose instructions assume the `agent-browser` CLI exists. + */ +export function agentBrowserConfigured(sources: SkillSourceConfig[]): boolean { + return sources.some( + (source) => + source.skills.includes(AGENT_BROWSER_SKILL) || + (source.package === AGENT_BROWSER_PACKAGE && + (source.skills.length === 0 || source.skills.includes('*'))) + ); +} + +/** Whether the agent-browser skill stub is already on disk. */ +export function agentBrowserStubInstalled(targetDir: string): boolean { + return fileExists(path.join(targetDir, '.agents', 'skills', AGENT_BROWSER_SKILL, 'SKILL.md')); +} + +export interface UsernsRestriction { + sysctl: string; + value: string; + fixValue: string; +} + +/** + * Detect the Linux sysctls that block Chrome's sandbox when set to their + * restrictive value: Chrome's renderer sandbox runs on unprivileged user + * namespaces, so agent-browser launches die with "No usable sandbox!". + * + * Returns null when the kernel does not expose the knobs (other distros, + * macOS, Windows) or none of them is restrictive. + */ +export function findUsernsRestriction(procRoot = '/proc'): UsernsRestriction | null { + const knobs: Array<[sysctl: string, blockingValue: string, fixValue: string]> = [ + ['kernel.apparmor_restrict_unprivileged_userns', '1', '0'], + ['kernel.unprivileged_userns_clone', '0', '1'] + ]; + for (const [sysctl, blockingValue, fixValue] of knobs) { + const raw = readTextFile(path.join(procRoot, 'sys', ...sysctl.split('.'))); + if (raw !== null && raw.trim() === blockingValue) { + return { sysctl, value: raw.trim(), fixValue }; + } + } + return null; +} + +export interface AgentBrowserSetupOptions { + dryRun?: boolean; + silent?: boolean; + /** Working directory for the provisioning commands (defaults to cwd). */ + cwd?: string; + /** Root of the proc filesystem; overridable for tests. */ + procRoot?: string; +} + +/** + * Provision the runtime the agent-browser skill stub depends on: the globally + * installed CLI (`npm i -g agent-browser`, matching the skill's own install + * line — npm ships with Node and works regardless of the project's package + * manager) and its browser binaries (`agent-browser install`, idempotent). + * + * A blocked Chrome sandbox is reported as a warning only: it needs a system + * change aginit must not make on the user's behalf. Returns false when the + * CLI or the browser binaries could not be provisioned. + */ +export async function setupAgentBrowser(options: AgentBrowserSetupOptions = {}): Promise { + const { dryRun = false, silent = false, cwd = process.cwd(), procRoot = '/proc' } = options; + + const cliPresent = await commandExists('agent-browser'); + if (!cliPresent) { + if (!silent) logger.dim('Installing agent-browser CLI globally (npm -g)...'); + const cli = await runExecutable('npm', ['install', '-g', AGENT_BROWSER_SKILL], { + cwd, + dryRun, + silent: true, + timeout: 600_000 + }); + if (!cli.ok && !dryRun) { + if (!silent) { + logger.warn(`Failed installing agent-browser CLI: ${cli.stderr || cli.stdout}`); + logger.dim(' Install it manually: npm i -g agent-browser && agent-browser install'); + } + return false; + } + } + + if (!silent) logger.dim('Ensuring agent-browser browser binaries...'); + const browser = await runExecutable(AGENT_BROWSER_CLI, ['install'], { + cwd, + dryRun, + silent: true, + timeout: 600_000 + }); + if (!browser.ok && !dryRun) { + if (!silent) { + logger.warn(`Failed downloading agent-browser browsers: ${browser.stderr || browser.stdout}`); + logger.dim(' Retry manually: agent-browser install'); + } + return false; + } + + const restriction = findUsernsRestriction(procRoot); + if (restriction && !silent) { + logger.warn( + `Chrome's sandbox is blocked: ${restriction.sysctl}=${restriction.value} restricts unprivileged user namespaces, so agent-browser launches fail with "No usable sandbox!".` + ); + logger.dim( + ` → Fix (machine-wide): echo '${restriction.sysctl}=${restriction.fixValue}' | sudo tee /etc/sysctl.d/99-agent-browser-userns.conf && sudo sysctl --system` + ); + logger.dim( + ' → Narrower option: pass --args "--no-sandbox" to agent-browser for that launch (Chrome sandbox disabled for that session)' + ); + } + return true; +} + +export interface AgentBrowserDiagnostic { + message: string; + fix?: string; +} + +/** + * Run upstream's own diagnostics (`agent-browser doctor --quick --offline + * --json`) and return the failing checks, so aginit reports browser health + * instead of reimplementing it. Returns null when the report cannot be + * parsed (older CLI, unexpected output) — callers should then skip the check + * rather than guess. + */ +export async function runAgentBrowserDiagnostics(): Promise { + const res = await runExecutable( + AGENT_BROWSER_CLI, + ['doctor', '--quick', '--offline', '--json'], + { + silent: true, + timeout: 60_000 + } + ); + try { + const parsed = JSON.parse(res.stdout) as { + checks?: Array<{ status?: string; message?: string; fix?: string }>; + }; + if (!Array.isArray(parsed.checks)) return null; + return parsed.checks + .filter((check) => check?.status === 'fail') + .map((check) => ({ + message: String(check?.message || 'check failed'), + ...(check?.fix ? { fix: String(check.fix) } : {}) + })); + } catch { + return null; + } +} diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index e56c590..6b4702c 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -10,6 +10,12 @@ import { } from '../config/schema.js'; import { detectPackageManager } from '../adapters/package-manager.js'; import { getInstalledSkills } from '../adapters/skills.js'; +import { + agentBrowserConfigured, + agentBrowserStubInstalled, + findUsernsRestriction, + runAgentBrowserDiagnostics +} from '../adapters/agent-browser.js'; import { readProjectConfig } from '../config/validation.js'; import { logger } from '../utils/logger.js'; @@ -214,6 +220,73 @@ export async function runDoctor(targetDir: string = process.cwd()): Promise d.message).join('; '), + suggestion: + diagnostics.length === 0 + ? undefined + : (diagnostics.find((d) => d.fix)?.fix ?? 'Run `agent-browser doctor` for details') + }); + } + } + } + + // 11. Chrome sandbox prerequisites — agent-browser and Playwright both + // launch Chrome from the same sandbox on Linux. + const sandboxRelevant = agentBrowserWanted || projectConfig?.browser.playwright === true; + if (sandboxRelevant) { + const restriction = findUsernsRestriction(); + if (restriction) { + checks.push({ + name: 'Chrome sandbox (unprivileged user namespaces)', + category: 'Environment', + ok: false, + detail: `${restriction.sysctl}=${restriction.value} blocks Chrome's sandbox: browser launches fail with "No usable sandbox!"`, + suggestion: `Lift it: echo '${restriction.sysctl}=${restriction.fixValue}' | sudo tee /etc/sysctl.d/99-agent-browser-userns.conf && sudo sysctl --system — or pass --args "--no-sandbox" per launch` + }); + } else if (process.platform === 'linux') { + checks.push({ + name: 'Chrome sandbox (unprivileged user namespaces)', + category: 'Environment', + ok: true, + detail: 'Unprivileged user namespaces available to Chrome' + }); + } + } + // Print grouped results const categories: Array<'Environment' | 'AI Tooling' | 'Project Config'> = [ 'Environment', diff --git a/src/cli/new.ts b/src/cli/new.ts index 62e2b7e..a8fa4f7 100644 --- a/src/cli/new.ts +++ b/src/cli/new.ts @@ -12,6 +12,11 @@ import { setupGit } from '../adapters/git.js'; import { setupAgentsMarkdown } from '../adapters/agents-md.js'; import { installAllSkills } from '../adapters/skills.js'; import { initGraft } from '../adapters/graft.js'; +import { + agentBrowserConfigured, + agentBrowserStubInstalled, + setupAgentBrowser +} from '../adapters/agent-browser.js'; import { ensureDir, writeJsonFile } from '../utils/fs.js'; import { readProjectConfig, @@ -100,7 +105,11 @@ export async function createProject( ); let complete = true; - const totalSteps = 6; + // The agent-browser skill stub is inert without its CLI and browser + // binaries, so provisioning them gets its own step when the skill applies. + const agentBrowserWanted = + agentBrowserConfigured(config.skills.sources) || agentBrowserStubInstalled(targetDir); + const totalSteps = agentBrowserWanted ? 7 : 6; let currentStep = 1; // Scaffold into the clean directory before Git adds files that upstream tools reject. @@ -151,7 +160,15 @@ export async function createProject( logger.dim('Skills installation skipped (--no-skills or empty configuration)'); } - // Step 6: Codebase intelligence (Graft) + // Provision the agent-browser runtime the skill stub drives + if (agentBrowserWanted) { + if (!silent) logger.step(currentStep++, totalSteps, 'Provisioning agent-browser runtime'); + const abOk = await setupAgentBrowser({ dryRun, silent, cwd: targetDir }); + complete &&= abOk; + if (!silent && abOk) logger.success('agent-browser runtime ready (CLI + browser)'); + } + + // Codebase intelligence (Graft) if (!silent) logger.step(currentStep++, totalSteps, 'Configuring codebase intelligence'); if (config.codebase.graft && options.graft !== false) { const graftOk = await initGraft(targetDir, { dryRun, silent }); diff --git a/src/index.ts b/src/index.ts index 7225898..f6f8581 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ export * from './adapters/skills.js'; export * from './adapters/graft.js'; export * from './adapters/agents-md.js'; export * from './adapters/browser.js'; +export * from './adapters/agent-browser.js'; export * from './cli/new.js'; export * from './cli/init.js'; export * from './cli/doctor.js'; diff --git a/tests/agent-browser.test.ts b/tests/agent-browser.test.ts new file mode 100644 index 0000000..524ace9 --- /dev/null +++ b/tests/agent-browser.test.ts @@ -0,0 +1,363 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { + agentBrowserConfigured, + agentBrowserStubInstalled, + findUsernsRestriction, + setupAgentBrowser, + runAgentBrowserDiagnostics +} from '../src/adapters/agent-browser.js'; +import { createProject } from '../src/cli/new.js'; +import { runDoctor } from '../src/cli/doctor.js'; +import { getDefaultConfig } from '../src/config/defaults.js'; + +const shell = vi.hoisted(() => ({ runExecutable: vi.fn(), commandExists: vi.fn() })); +vi.mock('../src/utils/shell.js', async (original) => ({ + ...(await original()), + ...shell +})); + +const success = { ok: true, exitCode: 0, stdout: '', stderr: '' }; +let dir: string; +let log: ReturnType; +beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'aginit-ab-')); + log = vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + shell.commandExists.mockResolvedValue(false); + shell.runExecutable.mockReset().mockResolvedValue(success); +}); +afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(dir, { recursive: true, force: true }); +}); +const output = () => log.mock.calls.flat().join('\n'); +const writeConfig = (config: unknown) => + fs.writeFileSync(path.join(dir, 'aginit.config.json'), JSON.stringify(config)); + +/** A fake proc root holding the kernel sysctls under test. */ +function fakeProc(values: Record): string { + const proc = fs.mkdtempSync(path.join(os.tmpdir(), 'aginit-proc-')); + fs.mkdirSync(path.join(proc, 'sys', 'kernel'), { recursive: true }); + for (const [sysctl, value] of Object.entries(values)) { + fs.writeFileSync(path.join(proc, 'sys', ...sysctl.split('.')), `${value}\n`); + } + return proc; +} + +describe('agent-browser skill detection', () => { + it('detects when a config will install the agent-browser skill stub', () => { + expect(agentBrowserConfigured(getDefaultConfig('app', 'web').skills.sources)).toBe(true); + expect(agentBrowserConfigured(getDefaultConfig('app', 'cli').skills.sources)).toBe(false); + expect(agentBrowserConfigured(getDefaultConfig('app', 'generic').skills.sources)).toBe(false); + expect(agentBrowserConfigured([{ package: 'vercel-labs/agent-browser', skills: [] }])).toBe( + true + ); + expect( + agentBrowserConfigured([{ package: 'vercel-labs/agent-browser', skills: ['*'] }]) + ).toBe(true); + expect( + agentBrowserConfigured([{ package: 'vercel-labs/agent-browser', skills: ['readme'] }]) + ).toBe(false); + expect(agentBrowserConfigured([{ package: 'other/repo', skills: ['agent-browser'] }])).toBe( + true + ); + }); + + it('recognizes an agent-browser skill stub already on disk', () => { + expect(agentBrowserStubInstalled(dir)).toBe(false); + const stub = path.join(dir, '.agents', 'skills', 'agent-browser', 'SKILL.md'); + fs.mkdirSync(path.dirname(stub), { recursive: true }); + fs.writeFileSync(stub, '# agent-browser'); + expect(agentBrowserStubInstalled(dir)).toBe(true); + }); +}); + +describe('Chrome sandbox restriction detection', () => { + it('reads the Linux sysctls that block Chrome sandbox launches', () => { + const proc = fakeProc({}); + try { + expect(findUsernsRestriction(proc)).toBeNull(); + + fs.writeFileSync( + path.join(proc, 'sys', 'kernel', 'apparmor_restrict_unprivileged_userns'), + '0\n' + ); + expect(findUsernsRestriction(proc)).toBeNull(); + + fs.writeFileSync( + path.join(proc, 'sys', 'kernel', 'apparmor_restrict_unprivileged_userns'), + '1\n' + ); + expect(findUsernsRestriction(proc)).toEqual({ + sysctl: 'kernel.apparmor_restrict_unprivileged_userns', + value: '1', + fixValue: '0' + }); + + fs.writeFileSync( + path.join(proc, 'sys', 'kernel', 'apparmor_restrict_unprivileged_userns'), + '0\n' + ); + fs.writeFileSync(path.join(proc, 'sys', 'kernel', 'unprivileged_userns_clone'), '0\n'); + expect(findUsernsRestriction(proc)).toEqual({ + sysctl: 'kernel.unprivileged_userns_clone', + value: '0', + fixValue: '1' + }); + } finally { + fs.rmSync(proc, { recursive: true, force: true }); + } + }); +}); + +describe('setupAgentBrowser', () => { + it('installs the missing CLI and then the browser binaries', async () => { + shell.commandExists.mockResolvedValue(false); + expect(await setupAgentBrowser({ silent: true })).toBe(true); + expect(shell.runExecutable).toHaveBeenCalledWith( + 'npm', + ['install', '-g', 'agent-browser'], + expect.objectContaining({ silent: true }) + ); + expect(shell.runExecutable).toHaveBeenCalledWith( + 'agent-browser', + ['install'], + expect.objectContaining({ silent: true }) + ); + }); + + it('skips the global install when the CLI is already on PATH', async () => { + shell.commandExists.mockResolvedValue(true); + expect(await setupAgentBrowser({ silent: true })).toBe(true); + expect(shell.runExecutable).toHaveBeenCalledTimes(1); + expect(shell.runExecutable).toHaveBeenCalledWith( + 'agent-browser', + ['install'], + expect.anything() + ); + }); + + it('reports failed provisioning instead of leaving a broken skill', async () => { + shell.commandExists.mockResolvedValue(false); + shell.runExecutable.mockResolvedValueOnce({ ...success, ok: false, stderr: 'EACCES' }); + expect(await setupAgentBrowser({ silent: true })).toBe(false); + + shell.commandExists.mockResolvedValue(true); + shell.runExecutable.mockResolvedValueOnce({ ...success, ok: false, stderr: 'network down' }); + expect(await setupAgentBrowser({ silent: true })).toBe(false); + }); + + it('lists both commands during a dry run', async () => { + shell.commandExists.mockResolvedValue(false); + expect(await setupAgentBrowser({ dryRun: true, silent: true })).toBe(true); + expect(shell.runExecutable).toHaveBeenCalledWith( + 'npm', + ['install', '-g', 'agent-browser'], + expect.objectContaining({ dryRun: true }) + ); + expect(shell.runExecutable).toHaveBeenCalledWith( + 'agent-browser', + ['install'], + expect.objectContaining({ dryRun: true }) + ); + }); + + it('warns with both fixes when the sandbox is blocked, without failing', async () => { + const proc = fakeProc({ 'kernel.apparmor_restrict_unprivileged_userns': '1' }); + try { + shell.commandExists.mockResolvedValue(true); + expect(await setupAgentBrowser({ procRoot: proc })).toBe(true); + const text = output(); + expect(text).toContain('kernel.apparmor_restrict_unprivileged_userns=1'); + expect(text).toContain('99-agent-browser-userns.conf'); + expect(text).toContain('--no-sandbox'); + } finally { + fs.rmSync(proc, { recursive: true, force: true }); + } + }); + + it('stays quiet about sandbox settings in silent mode', async () => { + const proc = fakeProc({ 'kernel.apparmor_restrict_unprivileged_userns': '1' }); + try { + shell.commandExists.mockResolvedValue(true); + expect(await setupAgentBrowser({ procRoot: proc, silent: true })).toBe(true); + expect(output()).not.toContain('--no-sandbox'); + } finally { + fs.rmSync(proc, { recursive: true, force: true }); + } + }); +}); + +describe('runAgentBrowserDiagnostics', () => { + it('surfaces upstream doctor failures with their fixes', async () => { + shell.runExecutable.mockResolvedValueOnce({ + ok: false, + exitCode: 1, + stdout: JSON.stringify({ + success: false, + checks: [ + { + id: 'chrome.installed', + status: 'fail', + message: 'No browser downloaded', + fix: 'agent-browser install' + }, + { id: 'env.version', status: 'pass', message: 'ok' } + ] + }), + stderr: '' + }); + expect(await runAgentBrowserDiagnostics()).toEqual([ + { message: 'No browser downloaded', fix: 'agent-browser install' } + ]); + }); + + it('returns null when the report cannot be parsed', async () => { + shell.runExecutable.mockResolvedValueOnce({ ...success, stdout: 'agent-browser 0.39.0' }); + expect(await runAgentBrowserDiagnostics()).toBeNull(); + }); +}); + +describe('createProject provisioning', () => { + it('provisions the runtime when a web project installs the skill', async () => { + const ok = await createProject('webapp', { + preset: 'web', + targetDir: dir, + git: false, + graft: false, + silent: true + }); + expect(ok).toBe(true); + expect(shell.runExecutable).toHaveBeenCalledWith( + 'npm', + ['install', '-g', 'agent-browser'], + expect.anything() + ); + expect(shell.runExecutable).toHaveBeenCalledWith( + 'agent-browser', + ['install'], + expect.anything() + ); + }); + + it('leaves the runtime alone when the skill is not configured', async () => { + await createProject('cliapp', { + preset: 'cli', + skills: false, + targetDir: dir, + git: false, + graft: false, + silent: true + }); + expect(shell.runExecutable).not.toHaveBeenCalledWith('npm', expect.anything(), expect.anything()); + expect(shell.runExecutable).not.toHaveBeenCalledWith( + 'agent-browser', + expect.anything(), + expect.anything() + ); + }); + + it('provisions for a skill stub left on disk even with --no-skills', async () => { + const stub = path.join(dir, '.agents', 'skills', 'agent-browser', 'SKILL.md'); + fs.mkdirSync(path.dirname(stub), { recursive: true }); + fs.writeFileSync(stub, '# agent-browser'); + await createProject('existing', { + preset: 'web', + skills: false, + targetDir: dir, + git: false, + graft: false, + silent: true + }); + expect(shell.runExecutable).toHaveBeenCalledWith( + 'agent-browser', + ['install'], + expect.anything() + ); + }); + + it('fails the run when provisioning fails', async () => { + shell.runExecutable.mockImplementation(async (bin: string, args: string[]) => { + if (bin === 'npm') return { ...success, ok: false, stderr: 'EACCES' }; + return success; + }); + await expect( + createProject('webapp', { + preset: 'web', + targetDir: dir, + git: false, + graft: false, + silent: true + }) + ).resolves.toBe(false); + }); + + it('announces the provisioning step only when the skill applies', async () => { + const strip = (text: string) => text.replace(/\x1b\[[0-9;]*m/g, ''); + await createProject('webapp', { + preset: 'web', + targetDir: path.join(dir, 'web'), + git: false, + graft: false, + silent: false + }); + expect(strip(output())).toContain('Provisioning agent-browser runtime'); + expect(strip(output())).toContain('[7/7]'); + + log.mockClear(); + await createProject('cliapp', { + preset: 'cli', + skills: false, + targetDir: path.join(dir, 'cli'), + git: false, + graft: false, + silent: false + }); + expect(strip(output())).not.toContain('Provisioning agent-browser runtime'); + expect(strip(output())).toContain('[6/6]'); + }); +}); + +describe('doctor agent-browser checks', () => { + it('flags a configured skill whose CLI is missing', async () => { + writeConfig(getDefaultConfig('app', 'web')); + shell.commandExists.mockResolvedValue(false); + await runDoctor(dir); + expect(output()).toContain('agent-browser CLI'); + expect(output()).toContain('npm i -g agent-browser'); + }); + + it('stays quiet about agent-browser in projects that do not use it', async () => { + writeConfig(getDefaultConfig('app', 'generic')); + await runDoctor(dir); + expect(output()).not.toContain('agent-browser'); + }); + + it('reports upstream runtime failures when the CLI exists', async () => { + writeConfig(getDefaultConfig('app', 'web')); + shell.commandExists.mockResolvedValue(true); + shell.runExecutable.mockImplementation(async (bin: string, args: string[]) => { + if (bin === 'agent-browser' && args.includes('doctor')) { + return { + ok: false, + exitCode: 1, + stdout: JSON.stringify({ + success: false, + checks: [ + { status: 'fail', message: 'No browser downloaded', fix: 'agent-browser install' } + ] + }), + stderr: '' + }; + } + if (bin === 'agent-browser') return { ...success, stdout: 'agent-browser 0.39.0' }; + return success; + }); + await runDoctor(dir); + expect(output()).toContain('No browser downloaded'); + expect(output()).toContain('agent-browser install'); + }); +});