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.
- Look: print (await page.snapshot({ interactive: true, track: "main" })).full (interactive first on big pages).
- Act: await page.click("ref/e12") / await page.fill("ref/e7", "text") / page.ref("e12") for an ElementHandle.
- 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