Files
2026-09-04 17:03:13 -07:00

29 KiB

dev-browser {{VERSION}} — browser automation CLI for coding agents (Puppeteer scripts, named pages, snapshot refs)

USAGE dev-browser [flags] < script.js run a script from stdin (heredoc) dev-browser [flags] -e 'code' run inline code dev-browser [flags] run FILE run a script file dev-browser pages | browsers | status | stop [NAME] | install [--force] | install-skill [--claude|--codex|--agents] dev-browser migrate-from-doobie copy durable ~/.doobie state into the isolated v1 state directory dev-browser chrome [--profile NAME] [--port N] [--chrome PATH] [--headless] [--list] [URL] dev-browser help [topic] topics: quickstart workflow scripts pages snapshot refs screenshots waiting forms errors output connect chrome config migration json mcp examples tips

FLAGS -b, --browser NAME launch/reuse a persistent named profile (default: "default"); ~/.dev-browser/v1/browsers/NAME -c, --connect [URL] attach to a running Chrome: auto (bare flag) | PORT | host:PORT | http://... | ws://... | unix:/path --headless launch headless (1280x720); default headed (viewport: null). config.json can flip the default --headed force headed even if config.json says headless --ignore-https-errors accept self-signed/invalid TLS certs (own browser instance + profile dir) -t, --timeout SECONDS one deadline for the whole request: connect + script + teardown (default 30, >= 1) -e, --eval CODE inline script instead of stdin/FILE --json NDJSON frames on stdout instead of text (see help json) --idle-timeout D close a launched browser after D idle: 30s, 5m, 1h, ms, or 0 = never (default 30m) --quiet-page do not print page console errors/warnings/uncaught exceptions/dialogs --no-cap do not cap stdout at 50k chars (see help output) -h, --help this text -V, --version print version Flags accept --flag=value, before or after the subcommand, except: everything after install/install-skill/chrome belongs to it; stop NAME / help TOPIC take their argument immediately. A TTY with no script prints help (exit 2).

quickstart

dev-browser <<'EOF' const page = await browser.getPage("main"); // named page: persists across runs, created on first use await page.goto("https://example.com"); // waits for domcontentloaded (not load) by default await page.snapshot({ interactive: true }) // last expression = return value, printed raw EOF dev-browser -e 'const p = await browser.getPage("main"); await p.click("ref/e6"); p.url()' # act by ref dev-browser -e 'const p = await browser.getPage("main"); (await p.shot()).path' # then Read the image First run starts a daemon + Chrome (~0.5 s); later runs reuse both (~15 ms). No Chrome? dev-browser install.

workflow

Each invocation is one decision-sized step: snapshot -> act by ref -> verify with the cheapest state check.

  1. Look: print (await page.snapshot({ interactive: true, track: "main" })).full (interactive first on big pages).
  2. Act: await page.click("ref/e12") / await page.fill("ref/e7", "text") / page.ref("e12") for an ElementHandle.
  3. Verify: print (await page.snapshot({ interactive: true, track: "main" })).incremental — a diff of only what changed (same track name AND same options as step 1) — plus page.url(). Batch look/act/verify in one script when the target is known; split when you must read output to decide. End every script by logging only the state the next decision needs (url, title, a diff, one value). Never dump HTML. Cheapest state check wins: url/title < incremental snapshot < interactive snapshot < full snapshot < screenshot. Named pages persist: do not re-navigate; getPage("checkout") resumes where the last script (or failure) left off. page.click/fill/type/hover/select do NOT wait for the element and ignore { timeout }: they throw at once if it is missing. If it may not be there yet: await page.waitForSelector(sel, { visible: true, timeout: 3000 }) first, or page.locator(sel).setTimeout(3000).click(). Keep waits short and -t small (-t 15) so failures return fast. Inside page.evaluate(...) write plain browser JavaScript (no TypeScript, no dev-browser globals, no closures over script vars; pass values as arguments: page.evaluate((n) => ..., n)). Never assume page structure: snapshot first. A failed script leaves the page where it stopped; the error prints [page NAME] url "title" so you can resume.

scripts

Runtime: your code is the body of one async function run in a fresh node:vm context inside the warm daemon with real Puppeteer objects. This isolates globals only — it is NOT a security sandbox; scripts can do anything the daemon can. Top-level await works. return x works. If the last statement is an expression its value is the result (no return needed; a promise there is awaited, so page.title() alone prints the title). Code never runs twice. ASI trap: end lines with semicolons. A line starting with ( or [ continues the previous line without one, so const p = await browser.getPage("m") + newline + (await p.shot()).path runs getPage("m")(...) -> ReferenceError ("Cannot access 'p' before initialization"). A trailing { a: 1, url: p.url() } is returned as an object (the block/literal ambiguity is detected), but parens — ({ a, b }) — are always safe. Result printing: undefined -> nothing; string -> raw; anything else -> pretty JSON. Map -> object, Set -> array, Error -> "Name: message", bigint -> string; Puppeteer objects shown as [ElementHandle] / [Page url]; other non-serializable values (HTTPResponse from goto, DOM nodes/window from evaluate, circular values) print as {} or nothing — return .textContent/.outerHTML/attributes/response.status() instead (Locator.wait() returns a serialized value; .waitHandle() for an ElementHandle). console.log/info/debug/table/dir -> stdout, streamed live; console.warn/error -> stderr. console.log(JSON.stringify(x)) is the reliable way to emit structured data. Globals: browser, console, saveFile(name, data) -> path, readFile(name) -> string (both jailed to ~/.dev-browser/v1/tmp, names [A-Za-z0-9._-] only, no paths), fetch, URL, URLSearchParams, Buffer, TextEncoder/Decoder, atob/btoa, crypto, performance, structuredClone, queueMicrotask, setTimeout/setInterval/setImmediate (+ clear*), AbortController/ AbortSignal, Blob, FormData, Headers, Request, Response. Not available: require, import/export (SyntaxError with a hint), process, fs, page.waitForTimeout (use await new Promise(r => setTimeout(r, ms))). Syntax errors cost no browser time. Unhandled promise rejections inside the script are NOT reported: await your promises. Timeouts: --timeout (default 30 s) is one absolute deadline over connect + script + teardown -> exit 124 with TimeoutError: Timed out after 30s (deadline) (+ while in page.waitForSelector("#x") when a call was in flight). After the deadline the script is a zombie: its next page/handle/frame call rejects (script deadline passed), its timers and listeners are cleared. The deadline fires between awaits only: synchronous CPU-bound code (a busy loop) cannot be interrupted; an infinite one is ended by the client watchdog, which restarts the daemon and its launched Chrome (named pages lost). Inside the script, waitFor*/locator/goto calls default to 5 s (navigation 15 s); override per call with { timeout }. setDefaultTimeout/setDefaultNavigationTimeout/setRequestInterception(true) are undone when the script ends. Scripts run concurrently; only launch/connect, page creation and input on different tabs of one browser (bring-to-front lock) are serialized. Two scripts on one named page interleave: never run them in parallel.

pages

browser.getPage(name) get-or-create a named tab; the name -> tab mapping is stored in ~/.dev-browser/v1/pages/ and survives daemon restarts while Chrome lives. A closed tab is recreated on next use. browser.getPage(targetId) attach to any open tab by its 32-hex CDP target id (from listPages / dev-browser pages). browser.newPage() anonymous tab. NOT auto-closed; close it yourself or it stays open. Prefer getPage(name). browser.listPages() -> [{ id, name|null, url, title }] (one CDP call, no per-tab attach). browser.closePage(name) close and forget a named page. Anonymous/attached tabs: (await browser.getPage(id)).close(). Use descriptive names ("login", "checkout"), not "page1"; reuse them across scripts. Names are per browser key ("default", "default:headless", "work", "cdp:ws://...") and die with that browser: the same name under --headless and headed is two tabs in two Chromes. -b NAME is a separate Chrome with its own profile/cookies; headed and headless are separate instances with separate profile dirs (browsers/NAME/profile vs profile-headless): logins do not carry over. dev-browser browsers lists them; dev-browser pages lists tabs for every running browser (with -b/--connect it launches/ attaches that browser and lists only its tabs); dev-browser stop NAME closes one (profile persists; exit 1 if none matched; a script running on it fails with BrowserStoppedError); dev-browser stop closes all and exits the daemon. Tabs Chrome restores from the previous session are closed at relaunch. Idle launched browsers close after --idle-timeout (30m; only scripts reset the idle clock, not pages/status); the daemon exits 15 min after its last browser and restarts automatically on the next call. Background tabs are brought forward automatically when you act on them (input/shot/waits on different tabs of one browser take turns under a per-browser lock; a long action such as type({ delay }) delays the other tab). Pages from Puppeteer itself (page.browser().newPage(), popups) get the dev-browser helpers too; with --connect only tabs you touch (getPage, newPage, their popups) are extended — the user's other tabs keep their dialogs and scripts. Downloads from launched browsers land in ~/.dev-browser/v1/tmp/downloads/ (readFile("downloads/") reads them); attached browsers keep Chrome's download dir. goto() of an attachment URL throws net::ERR_ABORTED although the file still lands.

snapshot

await page.snapshot(opts?) -> string (or { full, incremental } when opts.track is set) opts: { scope?: "e12" | "css selector", interactive?: boolean, depth?: number, track?: string, boxes?: boolean, urls?: boolean (default true; false drops "- /url:" lines), maxChars?: number (default 20000), frames?: boolean (default true) } Output is an ARIA YAML tree (Playwright grammar; names fall back to placeholder, ::before/::after, svg