Surf — Drive Your Real Chrome (macOS AppleScript)
surf drives the browser you're already logged into — your session (cookies, logins, tabs) stays intact. It speaks macOS AppleScript + Chrome's "Allow JavaScript from Apple Events": no daemon, no port 9222, no extension, no CDP, no "Allow remote debugging?" dialog. Rodney-style fire-and-forget CLI.
Quick Start
cd skills/surf && ./install.sh # macOS + Google Chrome required
surf setup # one-time: enable Chrome JS-from-AppleScript
surf doctor # ✓/✗ check: macOS · Chrome · JS toggle · Screen Recording · Accessibility
surf tabs && surf here # smoke test — list tabs, show the active one
surf help # categorized overview; `surf help <cmd>` drills in
Manual toggle (only if surf setup can't click it — Chromium menus ignore scripted clicks): Chrome menu → View → Developer ▸ → Allow JavaScript from Apple Events (a ✓ appears). Needed for text/click/fill/eval/title; pure navigation (open/tabs/here) works without it.
Verify before assuming failure: when a command misbehaves, run surf doctor first — it pinpoints the missing permission or toggle rather than guessing.
Usage — run each command as a separate bash call
surf is a CLI, not an MCP tool — invoke each subcommand via bash, one per call. It targets the active tab of the front window unless you surf select a tab. Selectors are CSS. The map below is the vocabulary; for every flag, exact return shape, and worked recipe see references/commands.md.
Self-discovery: surf help prints the categorized overview below with examples; surf help <command> (or surf <command> --help) shows usage, return value, and an example for any single command — no need to leave the terminal.
Command map
Navigation & tabs
surf tabs list windows → tabs (refs like w1.t3); --json
surf here active/target tab: URL | title; --json
surf select [wN.tN | reset] pin a tab (operate background tabs w/o focus); reset to clear
surf find-tab "<q>" [--activate] search tabs by URL/title; --activate focuses first match
surf open <url> [--new] reuse an open tab (exact URL) or navigate; --new forces a fresh nav
surf new [<url>] · reload · back · fwd · close
Read
surf title | url
surf text "<sel>" · html "<sel>" · attr "<sel>" <name>
surf count "<sel>" · list "<sel>" · table [sel] (list & table → JSON; table = {headers,rows})
surf eval "<js>" run JS in the page, print stringified result
surf cookie [name] · localstorage [key] (cookie = JS-visible/non-HttpOnly only)
Interact
surf click "<sel>" · fill "<sel>" "<val>" · form 'sel=val' ... · hover "<sel>"
surf select-option "<sel>" "<val>" · submit "<sel>" · download "<sel>"
surf scroll down|up|top|bottom [N] · scroll-to "<sel>"
surf press "<key>" real key/chord (enter, tab, escape, cmd+a)
Wait (all take --timeout N; exit 1 on timeout)
surf wait "<sel>" · wait-url "<sub>" · wait-stable
Assert (exit 1 on fail — CI-friendly)
surf exists "<sel>" · visible "<sel>" · assert "<js>" [expected]
Screenshots
surf shot [<path>] · shot-el "<sel>" [<path>] · shot-full [<path>]
Bookmarks (file-based — no browser or JS toggle needed)
surf bookmarks [query] [--profile NAME] [--json]
Meta
surf batch · surf doctor · surf setup · surf --version | --selfcheck | help
Batch — many ops, one browser call
Feed a JSON steps array on stdin; get a JSON array of {op,v} back. One execute javascript round-trip cuts osascript launch overhead in agent loops. Each step is try/catch-wrapped, so one bad selector returns {err} instead of aborting the run; v is exactly what the standalone op prints.
surf batch <<'EOF'
[{"op":"title"},{"op":"count","sel":"a"},{"op":"text","sel":"h1"},{"op":"fill","sel":"#q","val":"x"}]
EOF
Ops: title/url/text/html/attr/count/list/exists/visible/click/fill/hover/eval. Not batchable (different mechanism — use the standalone command): press, shot/shot-el, navigation (open/new/reload/back/fwd/close), wait*.
When to pick something else
| Need |
Use |
| Your real session on macOS, zero ceremony |
surf |
| CI / headless / cross-platform, or a fresh isolated browser |
rodney |
| Perf traces / Lighthouse / console / network — needs CDP |
chrome-devtools-mcp |
surf drives your visible Chrome on macOS-only. It has no headless mode, no CDP-level introspection, and no Linux/Windows path — reach for the tool above instead.
Gotchas
- Tabs that lie about JS.
x.com, Chrome app/PWA windows, and incognito windows make execute javascript fail with "Executing JavaScript through AppleScript is turned off" even when the global toggle is ON. If reads/eval fail on one tab, surf select a normal-site tab and retry. surf doctor probes JS on a throwaway about:blank tab, so it is immune to this and reliably reports the true global state.
wait-stable foregrounds the tab. Chrome throttles background-tab timers to ~1/sec, so a setInterval-driven mutation in a background tab can read as "quiet" and exit early. Keep the mutating tab foreground, or mutate via continuous DOM changes — the observer also fires on appendChild/attributes/characterData.
select is drift-resilient and never silent. It stores W T URL; if indices shift (reorder/close) the next op re-resolves by URL and re-pins (note on stderr); a tab that navigated in place is followed silently; a gone tab falls back to the active tab. It never deletes the target on uncertainty. Re-list current refs with surf tabs.
shot captures the window by its CGWindowID (screencapture -l) — the window's own backing surface, so content is captured even when another window overlaps it (no focus steal). Falls back to a screen-region grab if the ID can't be resolved. Needs Screen Recording for your terminal.
press uses real key synthesis — so Enter submits and cmd+a selects all (unlike JS-dispatched events) — and activates the target window first; it cannot press keys on a background tab. Needs Accessibility for your terminal.
eval returns one stringified value. For complex shapes, return JSON: surf eval 'JSON.stringify({...})'.
- Exit codes:
0 = success · 1 = error / assertion failed / timeout. Not-found is not an error for read/interact commands (they return JSON {ok:false} with rc 0); assertions and wait* return rc 1 on failure.
open reuses by default, across ALL Chrome windows. surf open <url> finds an already-open tab in any window instead of navigating — no duplicate tabs. Tier 1: exact URL match (trailing slash ignored). Tier 2: same-origin path-segment prefix — open localhost:3000/dashboard reuses a tab at localhost:3000/dashboard/ai-chat, landing on the deeper (already logged-in) page. Reuse pins the tab as the target without stealing focus (Chrome stays in the background). Pass --new to force a fresh navigation of the target tab.
References
- references/commands.md — full command reference: every flag, exact return shapes,
press key/chord syntax, environment variables (SURF_APP, SURF_TARGET_FILE, SURF_WAIT_*), how-it-works internals, and recipes. Read when you need a flag, an exact return value, env tuning, or a worked example beyond the map above.
- surf-todo.md — roadmap and known limitations. Read when a behavior surprises you (e.g. JS-failure detection on x.com/incognito is heuristic;
--json is not yet uniform across all commands).
1---2name: surf3description: Drive the user's real, logged-in Google Chrome on macOS for web scraping, form filling, screenshots, and tab-aware automation — no daemon, no debug port, no extension, no per-connection dialog. Sessions stay intact (cookies, logins, tabs). Uses macOS AppleScript + Chrome's 'Allow JavaScript from Apple Events'. Use when the user wants to automate, scrape, click, fill, read, or screenshot the browser they are already logged into. Triggers on: control my Chrome, drive my browser, automate my logged-in browser, scrape this page, fill this form, click this, read the page, take a browser screenshot, surf.4---56# Surf — Drive Your Real Chrome (macOS AppleScript)78`surf` drives the browser you're already logged into — your **session** (cookies, logins, tabs) stays intact. It speaks macOS AppleScript + Chrome's "Allow JavaScript from Apple Events": no daemon, no port 9222, no extension, no CDP, no "Allow remote debugging?" dialog. Rodney-style fire-and-forget CLI.910## Quick Start1112```bash13cd skills/surf && ./install.sh # macOS + Google Chrome required14surf setup # one-time: enable Chrome JS-from-AppleScript15surf doctor # ✓/✗ check: macOS · Chrome · JS toggle · Screen Recording · Accessibility16surf tabs && surf here # smoke test — list tabs, show the active one17surf help # categorized overview; `surf help <cmd>` drills in18```1920**Manual toggle** (only if `surf setup` can't click it — Chromium menus ignore scripted clicks): Chrome menu → **View → Developer ▸ → Allow JavaScript from Apple Events** (a ✓ appears). Needed for `text`/`click`/`fill`/`eval`/`title`; pure navigation (`open`/`tabs`/`here`) works without it.2122**Verify before assuming failure:** when a command misbehaves, run `surf doctor` first — it pinpoints the missing permission or toggle rather than guessing.2324## Usage — run each command as a separate bash call2526`surf` is a CLI, not an MCP tool — invoke each subcommand via **bash**, one per call. It targets the **active tab of the front window** unless you `surf select` a tab. Selectors are CSS. The map below is the vocabulary; for every flag, exact return shape, and worked recipe see `references/commands.md`.2728**Self-discovery:** `surf help` prints the categorized overview below with examples; `surf help <command>` (or `surf <command> --help`) shows usage, return value, and an example for any single command — no need to leave the terminal.2930## Command map3132```33Navigation & tabs34 surf tabs list windows → tabs (refs like w1.t3); --json35 surf here active/target tab: URL | title; --json36 surf select [wN.tN | reset] pin a tab (operate background tabs w/o focus); reset to clear37 surf find-tab "<q>" [--activate] search tabs by URL/title; --activate focuses first match38 surf open <url> [--new] reuse an open tab (exact URL) or navigate; --new forces a fresh nav39 surf new [<url>] · reload · back · fwd · close4041Read42 surf title | url43 surf text "<sel>" · html "<sel>" · attr "<sel>" <name>44 surf count "<sel>" · list "<sel>" · table [sel] (list & table → JSON; table = {headers,rows})45 surf eval "<js>" run JS in the page, print stringified result46 surf cookie [name] · localstorage [key] (cookie = JS-visible/non-HttpOnly only)4748Interact49 surf click "<sel>" · fill "<sel>" "<val>" · form 'sel=val' ... · hover "<sel>"50 surf select-option "<sel>" "<val>" · submit "<sel>" · download "<sel>"51 surf scroll down|up|top|bottom [N] · scroll-to "<sel>"52 surf press "<key>" real key/chord (enter, tab, escape, cmd+a)5354Wait (all take --timeout N; exit 1 on timeout)55 surf wait "<sel>" · wait-url "<sub>" · wait-stable5657Assert (exit 1 on fail — CI-friendly)58 surf exists "<sel>" · visible "<sel>" · assert "<js>" [expected]5960Screenshots61 surf shot [<path>] · shot-el "<sel>" [<path>] · shot-full [<path>]6263Bookmarks (file-based — no browser or JS toggle needed)64 surf bookmarks [query] [--profile NAME] [--json]6566Meta67 surf batch · surf doctor · surf setup · surf --version | --selfcheck | help68```6970## Batch — many ops, one browser call7172Feed a JSON steps array on stdin; get a JSON array of `{op,v}` back. One `execute javascript` round-trip cuts `osascript` launch overhead in agent loops. Each step is try/catch-wrapped, so one bad selector returns `{err}` instead of aborting the run; `v` is exactly what the standalone op prints.7374```bash75surf batch <<'EOF'76[{"op":"title"},{"op":"count","sel":"a"},{"op":"text","sel":"h1"},{"op":"fill","sel":"#q","val":"x"}]77EOF78```7980Ops: `title`/`url`/`text`/`html`/`attr`/`count`/`list`/`exists`/`visible`/`click`/`fill`/`hover`/`eval`. **Not batchable** (different mechanism — use the standalone command): `press`, `shot`/`shot-el`, navigation (`open`/`new`/`reload`/`back`/`fwd`/`close`), `wait*`.8182## When to pick something else8384| Need | Use |85|---|---|86| Your real **session** on macOS, zero ceremony | **surf** |87| CI / headless / cross-platform, or a fresh isolated browser | **rodney** |88| Perf traces / Lighthouse / console / network — needs CDP | **chrome-devtools-mcp** |8990`surf` drives your **visible** Chrome on **macOS-only**. It has no headless mode, no CDP-level introspection, and no Linux/Windows path — reach for the tool above instead.9192## Gotchas9394- **Tabs that lie about JS.** `x.com`, Chrome **app/PWA windows**, and **incognito** windows make `execute javascript` fail with *"Executing JavaScript through AppleScript is turned off"* **even when the global toggle is ON**. If reads/`eval` fail on one tab, `surf select` a normal-site tab and retry. `surf doctor` probes JS on a throwaway `about:blank` tab, so it is immune to this and reliably reports the true global state.95- **`wait-stable` foregrounds the tab.** Chrome throttles background-tab timers to ~1/sec, so a `setInterval`-driven mutation in a background tab can read as "quiet" and exit early. Keep the mutating tab foreground, or mutate via continuous DOM changes — the observer also fires on `appendChild`/attributes/`characterData`.96- **`select` is drift-resilient and never silent.** It stores `W T URL`; if indices shift (reorder/close) the next op re-resolves by URL and re-pins (note on stderr); a tab that navigated in place is followed silently; a gone tab falls back to the active tab. It never deletes the target on uncertainty. Re-list current refs with `surf tabs`.97- **`shot` captures the window by its CGWindowID** (`screencapture -l`) — the window's own backing surface, so content is captured **even when another window overlaps it** (no focus steal). Falls back to a screen-region grab if the ID can't be resolved. Needs **Screen Recording** for your terminal.98- **`press` uses real key synthesis** — so Enter submits and `cmd+a` selects all (unlike JS-dispatched events) — and activates the target window first; it cannot press keys on a background tab. Needs **Accessibility** for your terminal.99- **`eval` returns one stringified value.** For complex shapes, return JSON: `surf eval 'JSON.stringify({...})'`.100- **Exit codes:** `0` = success · `1` = error / assertion failed / timeout. Not-found is *not* an error for read/interact commands (they return JSON `{ok:false}` with rc 0); assertions and `wait*` return rc 1 on failure.101- **`open` reuses by default, across ALL Chrome windows.** `surf open <url>` finds an already-open tab in any window instead of navigating — no duplicate tabs. Tier 1: exact URL match (trailing slash ignored). Tier 2: same-origin path-segment prefix — `open localhost:3000/dashboard` reuses a tab at `localhost:3000/dashboard/ai-chat`, landing on the deeper (already logged-in) page. Reuse pins the tab as the target **without stealing focus** (Chrome stays in the background). Pass `--new` to force a fresh navigation of the target tab.102103## References104105- **[references/commands.md](references/commands.md)** — full command reference: every flag, exact return shapes, `press` key/chord syntax, environment variables (`SURF_APP`, `SURF_TARGET_FILE`, `SURF_WAIT_*`), how-it-works internals, and recipes. **Read when** you need a flag, an exact return value, env tuning, or a worked example beyond the map above.106- **[surf-todo.md](surf-todo.md)** — roadmap and known limitations. **Read when** a behavior surprises you (e.g. JS-failure detection on x.com/incognito is heuristic; `--json` is not yet uniform across all commands).