Bowser
A Bun-native, drop-in command-compatible alternative to Microsoft playwright-cli for AI agents. Same commands, same flag syntax, same snapshot YAML — replace with and existing playwright-cli skills work unchanged.
Power your agent
คำสั่งของผู้เผยแพร่เอง ไม่ใช่เอนด์พอยต์ของ PlugMCP
Host a copy with our keys and logs.
npx -y @drakulavich/[email protected]Claude Code ไม่มีลิงก์ติดตั้งสำหรับคำสั่งแบบโลคัล ให้คัดลอกคำสั่งด้านล่างแล้วรันในเทอร์มินัล
claude mcp add drakulavich-bowser-cli -- npx -y @drakulavich/bowser-cli@0.12.0รันในเทอร์มินัล
{ "mcpServers": { "drakulavich-bowser-cli": { "command": "npx", "args": [ "-y", "@drakulavich/[email protected]" ] } }}claude_desktop_config.json
URL แบบรีโมตหรือคำสั่งบนเครื่องของคุณ? การติดตั้งสองแบบต่างกันอย่างไร
รันบน PlugMCP
A new server you create. It is not this listing's official endpoint. Use the publisher install when you want their URL and their credentials.
เปิดจากผู้เผยแพร่
A Bun-native, drop-in command-compatible alternative to Microsoft playwright-cli for AI agents. Same commands, same flag syntax, same snapshot YAML — replace with and existing playwright-cli skills work unchanged.
A Bun-native, drop-in command-compatible alternative to Microsoft playwright-cli for AI agents.
playwrightbowserBuilt on Bun.WebView (new in Bun 1.3.12), it drives the native WebKit engine on macOS, so there is no browser to download: nothing to install beyond Bun itself. bowser runs on macOS only. If you need Chromium, or Linux or Windows, use playwright-cli.
What sets it apart from playwright-cli:
bowser runs on macOS only (it needs WebKit, which Bun.WebView provides only there).
# From npm
npm install -g @drakulavich/bowser-cli
# ...or directly from source
git clone https://github.com/drakulavich/bowser.git
cd bowser
bun install
bun link # exposes `bowser` on $PATH
Bun ≥ 1.4.4 must be on your PATH: the npm package runs src/cli.ts with the bun it finds there.
npm does not enforce that, so on an older Bun a command that would start a session fails with the
error "bowser requires Bun >=1.4.4 (found )" (exit 1).
bowser is distributed only through npm and from source; releases no longer attach binaries. If you
used a release binary, run bowser close --all with it, delete it, then
npm i -g @drakulavich/bowser-cli.
On another platform, any command that would start a session fails with the error "bowser requires macOS (WebKit)" (exit 1).
Open sessions survive an upgrade. The first command after it finds the daemon the old bowser
started, stops it, and opens the session again with its profile and its page. It then runs as
asked, and prints one line on stderr first:
"bowser: session '' restarted for bowser (was ): page reloaded at ; refs cleared,
run 'bowser snapshot'". A session opened without --persistent or --profile adds "cookies and
storage were lost (no --persistent)": those lived only in the old daemon. A ref from before the
upgrade fails with "is from a page that is no longer loaded", so take a new snapshot. The page is the
one the old daemon showed, and the session keeps the BOWSER_OP_TIMEOUT_MS it was opened with. A
session started by bowser 0.12 or older gets the default 30 s instead, whatever the first command after
the upgrade sets. The restart has its own budget, so that first command can take up to twice the budget,
plus a few seconds to stop the old daemon.
From 0.12.0 on, a running bowser mcp server hands its calls over to the installed version on the
first tool call after the upgrade, and the restart line comes first in that call's text. Its tool list stays the
one the client got when it connected, so a new command shows up once the client restarts the
server. A server started by 0.11 or older can't hand over: restart it once after the upgrade.
A daemon newer than the bowser you run (after a downgrade) is still refused: "session '' is running bowser (this is ); run 'bowser close -s ', then open it again" (exit 1).
Screenshots are written as PNG files. bowser screenshot --filename out.png writes
to out.png (relative paths resolve against your current directory); without
--filename it writes screenshot-<session>.png, auto-incrementing (-1, -2, …)
if that file already exists. The reply names the absolute path written and the PNG's size in pixels
(wrote /…/out.png (1600×1200), {"ok":true,"filename":"/…/out.png","width":1600,"height":1200});
snapshot --filename names the path too. The size follows the display's pixel ratio: an 800×600 viewport
is 1600×1200 on a Retina display and 800×600 on one at ratio 1. --scale=N fixes it at
innerWidth·N × innerHeight·N by scaling the capture down, or fails with exit code 1 when N is above
the display's ratio, since bowser cannot render more pixels than the display has. A capture is the
viewport, as in playwright-cli without --full-page: bowser has no full-page or
element-bounded screenshots (Bun.WebView captures only the viewport). Use resize to capture more.
WebKit cannot capture a viewport whose pixels fill 4 GiB. On a Retina display (pixel ratio 2)
that is reached only near resize's maximum: resize 16384 16384 cannot be captured,
resize 16384 16383 can. screenshot then fails with exit code 1 and names the size to resize to.
Before it captures, screenshot waits up to 5 s for the web fonts the page itself has asked for (not those
of its iframes), such as a font first used by text a script added after the page loaded. If they are still loading after 5 s it captures
anyway and adds ; fonts still loading to the reply ("fontsLoading":true under --json): the image may
show text in a fallback font or none at all, so retry, or check that the font URL answers.
bowser open https://example.com # navigate, save state
bowser snapshot # the page's aria tree, with [ref=eN]
bowser click e3 # click a ref
bowser fill e5 "[email protected]" # fill a form field
bowser press Enter # submit
bowser screenshot --filename=shot.png # capture
bowser close # end session
Each session runs one persistent browser process (spawned lazily on first command, addressed over a Unix socket). Commands attach, run, and detach — so typed text, modals, dynamic DOM, cookies, and auth all survive across invocations. Session state lives under ~/.bowser/sessions/<name>/. Several commands started at once on a new session still share one browser.
If the page's web process crashes, WebKit relaunches it and reloads the page once. That reload looks like the page reloading itself, so bowser does not report it: page state is gone, and old refs fail with not found in the current page snapshot. If the process dies again, WebKit does not reload the page, and every page command fails with the page crashed (its web process exited); run 'bowser reload' or 'bowser goto <url>' (exit 2). bowser does not reload it for you; reload, goto or open <url> recover it.
If a session's browser exits (it crashed, or was killed), every command but open and close fails with session '<name>' is not open (its browser exited); run 'bowser open' (exit 1), or gives the recorded bowser open --persistent / bowser open --profile=<dir> command when session state records a profile; paths are quoted when needed. State from an older bowser has no profile record, so bowser falls back to checking the default profile directory. This avoids quietly starting an empty browser. bowser open (with the same profile option again, when one was recorded) starts it anew; bowser close clears it. A session that never ran a browser still starts one on its first command.
bowser -s=login open https://app.example.com/login
bowser -s=login fill e1 "[email protected]"
op read op://vault/app/password | bowser -s=login fill e2 --stdin
bowser -s=login click e3
Commands on one session run one at a time: when several clients (parallel shell calls, two MCP servers) send commands to the same session, each waits its turn and runs whole.
A session's browser store is in memory by default: close (or a crash) loses its logins. A persistent profile is the way to keep cookies, and so logins, between sessions: bowser has no cookie commands, and state-save/state-load carry only localStorage. Open the session with --persistent to keep cookies, localStorage and IndexedDB on disk, like playwright-cli open --persistent:
bowser -s=app open https://app.example.com/login --persistent # profile in ~/.bowser/profiles/app/
bowser -s=app close # the profile stays
bowser -s=app open https://app.example.com --persistent # still logged in
bowser -s=work open https://app.example.com --profile=./profiles/work # a directory of your choice
~/.bowser/sessions/<name>/, so close leaves it alone. Delete it with rm -rf ~/.bowser/profiles/<name> (or your --profile directory).open --persistent on a session that is already running with another store fails with exit 1; run bowser close first. open without the flag reuses a running persistent session.bowser answers every alert, confirm and prompt the moment it opens, so no command ever waits on a dialog. Without an answer set, the dialog is dismissed: confirm returns false and prompt returns null. To accept one, set the answer before the action that opens it:
bowser dialog-accept # the next dialog is accepted (confirm → true)
bowser click e7 # the click that opens it
bowser dialog-accept "Ann" # a prompt gets "Ann"; with no text, its own default value
bowser click e8
### Modal state
- ["prompt" dialog with message "Your name?"]: accepted
The command that caused the dialog reports it under ### Modal state. With --json it reports it as "dialogs": [...]. If the command fails, the report follows its error on stderr and the exit code is unchanged. dismissed (run dialog-accept before the action to accept it) means no answer was set. An answer covers one dialog and is dropped when the page navigates.
Differences from playwright-cli:
In playwright-cli, the dialog stays open and dialog-accept answers it after the action. In bowser, dialog-accept after the action prepares the next dialog. It does not answer the one already reported.
WebKit has no dialog events, so bowser replaces window.alert/confirm/prompt in the page, and in every same-origin iframe it can reach, before it acts there. A dialog in such an iframe is reported like the page's own and takes the prepared answer. A dialog that fires between commands (a timer) is reported by the next command that prints dialogs, even when that command leaves the page (reload, goto, open, go-back, press Enter on a form).
A function the page defined itself (window.confirm = m => …) is left alone and runs, as in playwright-cli; its calls are not dialogs and are not reported.
A page function made with .bind() from the browser's own (window.confirm = confirm.bind(window)) looks native to bowser: the shim replaces it, and the dialog is reported.
Not reported, and dismissed by WebKit:
const c = window.confirm), including a page wrapper around it (window.confirm = m => c(m)).In each of these cases a prepared answer stays set until the next dialog bowser sees or a navigation.
A dialog whose handler then navigates the page (if (confirm('Leave?')) location = …) is answered but not reported, because the report leaves with the old page.
beforeunload is not handled.
snapshot prints the page's full accessibility tree in playwright-cli's format: headings, text, state such as [checked] or [active], /url and /placeholder props, and an eN ref on every visible element, not only the interactive ones. This is the todo fixture in tests/fixtures/:
### Page
- Page URL: http://localhost:52047/todo-app.html
- Page Title: Bowser Todo
### Snapshot
```yaml
- generic [active] [ref=e1]:
- heading "Todos" [level=1] [ref=e2]
- generic [ref=e3]:
- textbox "New todo" [ref=e4]:
- /placeholder: What needs doing?
- button "Add" [ref=e5] [cursor=pointer]
- list "Todo list" [ref=e6]:
- listitem [ref=e7]: No todos yet
- generic [ref=e8]:
- generic [ref=e9]: 0 items left
- button "Clear completed" [ref=e10] [cursor=pointer]
```
A ref stays the same across snapshots of one document while the element's role and name do not change; new elements get the next free number, so gaps are normal. After fill e4 "buy milk" and click e5, the list above becomes listitem [ref=e11] holding checkbox "Toggle buy milk" [ref=e12], and e5, e10 still name the same buttons. A navigation or reload starts again at e1.
Password field values are never shown: a filled <input type="password"> prints as textbox "Password" [ref=e3] with no value, state.json does not store it, and it adds nothing to another element's name. This is a deliberate difference from playwright-cli, which prints the value.
An action on a ref whose element a re-render removed fails at once with ref 'eN' not found in the current page snapshot. Try capturing new snapshot. and exit code 1, as in playwright-cli. A ref from another page (from before a navigation or reload, or from the page you left before go-back) fails the same way with ref 'eN' is from a page that is no longer loaded; take a new snapshot. Run snapshot again and use the new refs. A pushState or #hash change keeps the page, and its refs keep working.
An action on a ref whose element changed its role or name since the snapshot (a Buy A button that relabelled itself) also fails, exit code 1, before it touches the page: ref 'e2' now points to button "Delete account", not button "Buy A"; take a new snapshot. click and fill on a ref whose centre another element covers (a backdrop, or a + button the page put where Add was) fail the same way and name what covers it: ref 'e2' (button "Add to cart") is covered by generic <div> at its click point; take a new snapshot or close what covers it. A playwright-cli frame ref such as f1e3 fails with 'f1e3' is a playwright-cli frame ref; bowser does not snapshot iframe contents.
--depth=N prints N levels below the first line: a node at the limit drops its children but keeps its inline text value and its prop lines (/url, /placeholder). --depth=0 or no flag prints the whole tree. Iframe contents and shadow DOM are not walked: an iframe prints as a leaf with a ref.
--json snapshot prints {"snapshot": "<tree>"}: the tree text alone, without the ### Page wrapper. snapshot --filename=f writes the ### Page text to f, with or without --json, and answers wrote /…/f ({"ok":true,"filename":"/…/f"} under --json, with "dialogs" when a dialog was answered).
bowser --json snapshot | jq -r .snapshot | grep 'button'
With --json, and so over MCP, click, fill, hover, select, check and uncheck name the element the ref pointed to, with the role and name its snapshot gave: click e724 answers {"ok":true,"ref":"e724","element":{"role":"button","name":"Add to cart"},"url":"…"}.
Global flags: -s=<name> / --session=<name>, --json, -h/--help, --version (prints the version and runs nothing). A session name uses letters, digits, ., _ and -, and does not start with . or -. It is at most 255 characters, or fewer under a very long HOME: <HOME>/.bowser/sessions/<name>/pid.<pid>.tmp must fit Bun's 1016-character path limit. A longer name is a usage error that gives the limit.
bowser <command> --help (or -h anywhere before --) prints that command's usage, arguments and
flags and runs nothing; bowser mcp --help does not start the server. After --, --help is text
like any other argument.
A command given more arguments than it takes fails before it runs, with
usage: too many arguments for '<cmd>': expected <n>, received <m> and exit code 1. Quote an
argument that has spaces: bowser eval "1 + 1", bowser fill e4 "hello world". Words after --
count too, so fill e1 -- a b is too many. bowser mcp with any extra word fails the same way and
does not start the server.
A command given fewer arguments than it requires fails the same way, with its usage line:
bowser select e3 fails with usage: bowser select <ref> <value> and exit code 1. An empty
argument is a value, as in playwright-cli: bowser select e3 "" selects the option whose value
is empty, and bowser localstorage-set k "" stores an empty string.
bowser mcp runs a Model Context Protocol server over stdio, exposing every browser command as an MCP tool — so MCP clients (Claude Desktop, etc.) can drive the browser without shelling out. Each tool maps 1:1 to a CLI command and takes an optional session argument; outputs are the same JSON as --json mode.
Arguments are checked against the tool's input schema. A wrong type (session: 42, an object where a string belongs, persistent: "false") or a key the tool does not have is refused with an isError result starting usage:, and nothing runs. Where the schema says string, a finite number is also taken (resize {width: 800, height: 600}), and null is the same as leaving the argument out.
Register it in an MCP client config (e.g. claude_desktop_config.json):
{
"mcpServers": {
"bowser": { "command": "bowser", "args": ["mcp"] }
}
}
Notes:
screenshot, snapshot filename, state-save, state-load) resolve against the server's working directory. If the client starts it from / or another directory it cannot write, the server uses <os.tmpdir()>/bowser-mcp instead ($TMPDIR/bowser-mcp). Replies name the absolute path written. An absolute path is used as given.2025-11-25, 2025-06-18 and 2024-11-05: initialize answers with the client's version when it is one of these, and with 2025-11-25 otherwise. The stateless 2026-07-28 revision is not implemented; its server/discover probe gets -32601 Method not found, on which a client falls back to initialize.initialize, ping, tools/list and notifications are answered at once, even while tool calls run.-32600 Invalid Request error with id: null; nothing in a batch runs. MCP 2025-06-18 has no batching.method gets -32600 Invalid Request with its id, and a tools/call of a tool the server does not have gets -32602 Unknown tool. A response from the client (a message with result or error) gets no answer.id).notifications/cancelled drops the call: one still queued behind its session never runs, and a running one finishes but its result is discarded. Either way no response is sent for it, per the MCP spec. A browser operation that has already started is not undone — a cancelled click may still have clicked, and the session's next call waits for it to finish.bowser open spawns a per-session daemon holding a Bun.WebView, navigates, and saves {url, title} to ~/.bowser/sessions/<name>/state.json.bowser snapshot runs a snapshot script in the page that walks the DOM into an aria tree (roles, names, text, refs) and keeps a reference to each ref's element in the page; src/snapshot.ts renders that tree as YAML. Refs are persisted with the CSS path each element had (#id when safe, otherwise an nth-of-type chain), e.g. e3 → html > body > button:nth-of-type(2).bowser click e3 resolves the ref from state, then in the page: the snapshot script kept a reference to each ref's element, so the command checks that element is still in the document and computes its CSS path afresh (a stale ref fails here, before anything is clicked). It then dispatches the click via the daemon, using Bun.WebView's built-in actionability auto-wait — no polling, no hard-coded timeouts.bowser's scripts run in the page's own JavaScript world, and WebKit returns their results through the page's JSON.stringify. bowser sets aside the builtin patches that legacy libraries such as Prototype.js install, and it refuses a page that replaced JSON.stringify. It does not defend against a page written to deceive automation: such a page can forge any check made from inside it.
bun run check # typecheck, lint, then unit tests
bun run typecheck # tsc
bun run lint # Biome (import cycles, layer rules) and Knip (unused files, exports)
bun test # unit + command tests with a fake daemon
BOWSER_E2E=1 bun test # + end-to-end on WebKit
BOWSER_E2E=1 BOWSER_E2E_NET=1 bun test # + live-internet e2e (GitHub search)
End-to-end examples included:
tests/e2e.test.ts — open/snapshot/click on a data: URL (no network)tests/e2e-todo.test.ts — a local todo app served by Bun.serve: add three todos, toggle one, clear completed. Proves the daemon keeps state across commands.tests/e2e-webkit.test.ts — every command driven on WebKit, with page state read back via eval after each one.tests/e2e-compat.test.ts — diffs bowser against playwright-cli on the todo flow, asserting bowser's refs are a subset of playwright-cli's tree; skips without playwright-cli's WebKit installed.tests/e2e-search.test.ts — live web: search GitHub for OpenClaw, find the repo link, type into the search box and press Enter.--depth=Nplaywright-cli's snapshot formatlocalstorage-*, sessionstorage-*, state-save/load)
localstorage-{list,get,set,delete,clear}sessionstorage-{list,get,set,delete,clear}state-save / state-load — Playwright-compatible storageState JSON (per-origin localStorage; no cookies, use open --persistent)tab-list/tab-new/tab-select/tab-close) — deferred: Bun.WebView can't reach popups yet (window.open returns null on WebKit), so tabs would leave out their main use; see the refactor specroute, unroute)markdown: the page, or one element, as Markdowneval, run-coderesizedialog-accept/dismiss — the answer is set before the action; see Dialogsbowser mcp)The 0.2.0 release is a clean break: snap → snapshot, @e3 → e3, --session → -s=, session list → list. See CHANGELOG.md for the full migration table.
MIT
A Bun-native, drop-in command-compatible alternative to Microsoft playwright-cli for AI agents.
{ "mcpServers": { "drakulavich-bowser-cli": { "command": "npx", "args": [ "-y", "@drakulavich/[email protected]" ] } }}เครื่องมือ
ยังไม่มีรายการเครื่องมือ เซิร์ฟเวอร์แบบรีโมตจะได้รายการหลังการตรวจสอบสถานะครั้งแรก
FAQ
คัดลอกคอนฟิก stdio สำหรับ Cursor, Claude Desktop, Claude Code หรือ VS Code ได้ในหน้านี้ คอนฟิกนี้ใช้การติดตั้งของผู้เผยแพร่ ไม่ใช่พร็อกซีของ PlugMCP
ระเบียนนี้ไม่ได้ระบุตัวแปรสภาพแวดล้อมที่จำเป็น
@drakulavich/bowser Cli ทำงานแบบโลคัลผ่าน stdio
ตรวจสอบล่าสุดเมื่อ 2026-10-28 A Bun-native, drop-in command-compatible alternative to Microsoft playwright-cli for AI agents.