README
¶
Roddy: Chrome automation from the command line
A Go CLI tool that drives a persistent headless Chrome instance using the rod browser automation library. Each command connects to the same long-running Chrome process, making it easy to script multi-step browser interactions from shell scripts or interactive use.
About this fork
Roddy is a fork of simonw/rodney by Simon Willison, created because upstream development stalled. The last upstream commit was in March 2026, and pull requests — including several fixing crashes we hit daily — have sat unreviewed since February 2026.
This fork exists to keep those fixes available and installable. It is not a hostile fork: upstream pull requests are still open, and if Rodney resumes active development we would be glad to see this work land there instead.
What Roddy adds over upstream
--extensionflag — load unpacked extension directories or packed.crx/.ziparchives into a headless session, and list them withroddy extensions. (upstream PR #52)- Browser-process crash fixes — stops Chromium running in-development features that abort the browser, and reserves
--single-processfor unsandboxed launches (containers, gVisor), never using it on macOS where it aborts the browser as soon as a page touchesnavigator.mediaDevices. (upstream PR #54) - Screenshot and interaction reliability — raises the target before capturing or driving it, fixing captures that timed out and clicks that hung forever when the page was not in the foreground. (upstream PR #55 for captures; the interaction half is fork-only, #29)
Migrating from Rodney
The command, state directory, and environment variable are all renamed:
| Rodney | Roddy |
|---|---|
rodney |
roddy |
~/.rodney/, ./.rodney/ |
~/.roddy/, ./.roddy/ |
RODNEY_HOME |
RODDY_HOME |
Roddy does not read Rodney's state files. Run rodney stop before switching so the old browser process is shut down cleanly rather than orphaned; otherwise the commands and flags are unchanged.
Installing
go install github.com/goeric/roddy@latest
Or build from a checkout:
go build -o roddy .
Requires:
- Go 1.21+
- Google Chrome or Chromium installed (or set
ROD_CHROME_BIN=/path/to/chrome)
Agent skill for Claude Code and Codex
This repo ships an agent skill (skills/roddy/SKILL.md) that teaches
Claude Code and
Codex to reach for Roddy — instead of
Playwright, Puppeteer, or the Chrome DevTools MCP server — whenever a task needs
a real browser: reproducing a UI bug, inspecting the live DOM, reading console
errors, filling a form, or screenshotting a page.
The skill documents the CLI, not just its existence — session lifecycle, the
check commands and their exit codes, extension loading, and the failure modes
worth recognising. It assumes roddy is on PATH, so install the binary first.
It triggers on its own; you don't need to name it.
Claude Code
Install it as a plugin:
/plugin marketplace add goeric/roddy
/plugin install roddy@roddy
Then restart Claude Code. Update later with /plugin update roddy.
Codex
Install it as a plugin, the same way: Codex reads this repo's plugin manifest and marketplace as its own.
codex plugin marketplace add goeric/roddy
codex plugin add roddy@roddy
The skill is available in your next Codex session. Update later with
codex plugin marketplace upgrade roddy, which refetches the snapshot and
reinstalls at the new version; codex plugin remove roddy@roddy uninstalls
it, and codex plugin marketplace remove roddy drops the marketplace too.
Installing the skill without a plugin
The skill is a single self-contained directory (skills/roddy), so it can also
go in on its own. In a Codex session, its built-in installer copies it into
~/.codex/skills/roddy (it aborts with Destination already exists if the
directory is there; delete it to update):
$skill-installer https://github.com/goeric/roddy/tree/main/skills/roddy
The cross-agent skills CLI installs
it under ~/.agents/skills and links it into Codex, and npx skills update roddy updates it. Keep -a codex if the Claude Code plugin is also
installed — without it the CLI offers every agent it detects (all of them under
-y), and a second roddy under ~/.claude/skills would load alongside the
plugin's:
npx skills add goeric/roddy -a codex -g
Or symlink a checkout into a personal skills directory:
git clone https://github.com/goeric/roddy.git
mkdir -p ~/.codex/skills
ln -s "$PWD/roddy/skills/roddy" ~/.codex/skills/roddy # Codex
mkdir -p ~/.claude/skills
ln -s "$PWD/roddy/skills/roddy" ~/.claude/skills/roddy # Claude Code
Restart Claude Code afterwards; Codex picks the skill up on its next turn
(restart it if not). Use one method per agent, not two: both agents load every
copy they find, so two roddy skills compete (Codex lists both as
roddy:roddy).
Architecture
roddy start → launches Chrome (headless, persists after CLI exits)
saves WebSocket debug URL to ~/.roddy/state.json
roddy connect H:P → connects to an existing Chrome on a remote debug port
saves WebSocket debug URL to ~/.roddy/state.json
roddy open URL → connects to running Chrome via WebSocket
navigates the active tab, disconnects
roddy js EXPR → connects, evaluates JS, prints result, disconnects
roddy stop → connects and shuts down Chrome, cleans up state
Each CLI invocation is a short-lived process. Chrome runs independently and tabs persist between commands.
Usage
Start/stop the browser
roddy start # Launch headless Chrome
roddy start --show # Launch with visible browser window
roddy start --insecure # Launch with certificate errors ignored (-k shorthand)
roddy start --no-sandbox # Launch without Chrome's sandbox (containers, gVisor)
roddy start --no-single-process # Keep Chrome multi-process (crash isolation)
roddy start --no-sandbox --single-process # Force single-process (gVisor with no marker files)
roddy connect host:9222 # Connect to existing Chrome on remote debug port
roddy status # Show browser info and active page
roddy stop # Shut down Chrome
A second start, and a connect to a different browser, replace the recorded
session rather than adding one: the auth proxy helper is stopped (while its
port still answers) and a Chrome roddy launched is closed (a browser attached
with connect is left running). connect does this only once the new browser
has answered, and not at all when it names the browser roddy is already
driving. If the recorded session's Chrome cannot be confirmed stopped, connect
refuses and keeps that session, so roddy stop still has a way back to it.
Chrome runs with its sandbox on by default. Running as root implies
--no-sandbox — Chrome refuses to sandbox there — and so does a container
detected by its marker files or environment (Docker, Podman, Kubernetes, and
gVisor under any of them), where the kernel support Chrome's sandbox needs is
usually absent. Both are decided up front, with a note on stderr and no failed
first attempt; an unsandboxed launch also gets --single-process where the
platform takes it, which those environments need for screenshots — unless
extensions are loaded, which drops it again (it breaks them).
Site Isolation is on too: rod's launcher defaults turn it off, roddy turns it
back on, so cross-site frames get renderer processes of their own — a second
boundary behind the sandbox. It is moot under --single-process, where there is
only one process.
--single-process and --no-single-process override that derived default.
--no-single-process is always accepted, and is simply a no-op where the flag
was not going on anyway; it is how you get an unsandboxed multi-process
browser, for crash isolation. --single-process is refused rather than quietly
dropped when it cannot be honoured: on macOS (any page touching
navigator.mediaDevices aborts the browser), with extensions loaded (they break
under it — if the extension came from a WXT build start auto-loaded,
--no-extension opts out), and on a sandboxed launch (--single-process requires --no-sandbox). A session that ends up single-process says "single
process" in start output and status.
The detection is a marker check, not a kernel probe, so containers it does not
recognise (LXC, systemd-nspawn, containerd outside Kubernetes) get the ordinary
sandboxed attempt. That attempt, if it fails for a sandbox reason — Chrome's
own stderr naming the cause, e.g. "Failed to move to new namespace" or "No
usable sandbox!" — retries once with --no-sandbox and says so; any other
failure is reported as-is rather than silently downgraded. --no-sandbox is
the explicit path when you already know the sandbox will not come up.
Browser extensions
--extension loads a Chrome extension at startup. It works in headless mode as
well as with --show:
# An unpacked extension directory
roddy start --extension ./my-extension
# A packed .crx or .zip, which is unpacked into the session directory
roddy start --extension ./my-extension.crx
# Repeat the flag to load more than one
roddy start --extension ./one --extension ./two
roddy extensions lists what was loaded, including the ID Chrome assigned to
each one, which you need to reach pages served by the extension:
roddy extensions
# ldmakemplfmadpiihagnajidjbhnjlcm My Extension 1.0.0 /path/to/my-extension
roddy open chrome-extension://ldmakemplfmadpiihagnajidjbhnjlcm/popup.html
roddy screenshot popup.png
Notes:
- Extensions run in Chrome's new headless mode, which roddy switches to automatically when extensions are loaded — the old headless mode cannot run extensions at all.
- Chrome only loads unpacked extensions from the command line, so a
.crxis unpacked before being loaded. It therefore gets an ID derived from its path on disk rather than the ID it would have when installed from the Web Store. - Only the extensions loaded at
start— via--extension, or the WXT auto-detection below — are enabled, and they stay loaded for the lifetime of the session.
WXT projects load themselves
In a WXT project — a wxt.config.* in the current
directory — plain roddy start auto-loads the built extension from
.output/chrome-mv3, printing a notice:
roddy start
# WXT project detected: loading .output/chrome-mv3 (pass --no-extension to skip)
# tip: use --local to keep this project's browser state isolated (./.roddy/)
# Chrome started (PID 12345)
# Debug URL: http://127.0.0.1:53421/devtools/browser/2f1c...
# Extension loaded: My Extension (ldmakemplfmadpiihagnajidjbhnjlcm)
Pass --no-extension to start without it, or --extension PATH to load
something else — an explicit choice always wins over detection. If the config
is there but .output/chrome-mv3 is not, start says so and continues without
an extension: run wxt build and start again. A build that is there but cannot
be loaded — mid-write, no manifest, unreadable — prints why on stderr and start
continues without it, so detection never turns a working roddy start into an
error.
Extension service workers
An MV3 extension's logic lives in its background service worker, which has no
DOM and is invisible to page-level commands. roddy sw reaches inside it:
roddy sw # list extension service workers
# ldmakemplfmadpiihagnajidjbhnjlcm chrome-extension://ldmakemplfmadpiihagnajidjbhnjlcm/sw.js
# Evaluate in the worker's context — chrome.* APIs work, promises are awaited
roddy sw eval 'chrome.storage.local.get(null)'
roddy sw eval 'chrome.storage.local.set({flag: true}).then(() => "seeded")'
roddy sw eval 'chrome.runtime.getManifest().version'
With several extensions loaded, pick one with --ext ID — sw eval refuses to
guess rather than landing in whichever worker happens to be running. Only
extensions that declare a background.service_worker count towards that, so a
content-script-only extension alongside never forces the choice. Flags may
appear anywhere in the command, before or after the expression; put -- in
front of an expression that itself starts with --.
Both forms wait up to --timeout (default 5s) for the workers to appear — one
per extension that declares one — which covers the startup race after roddy start; roddy sw exits 1 when none are running. The evaluation itself is
bounded separately, by ROD_TIMEOUT (default 30s), so an expression whose
promise never settles fails instead of hanging.
Waiting does not wake a worker Chrome has already suspended for idleness. Any
event the worker has a listener for restarts it, so the way to do that on
demand is to send it one: chrome.runtime.sendMessage from an extension page
dispatches an event that starts the worker.
This makes end-to-end extension tests plain shell — seed storage through the worker, drive the page, assert on both sides:
# Testing a WXT project: plain start auto-loads .output/chrome-mv3
roddy start
roddy storage set user test
roddy open https://example.com
roddy assert 'document.documentElement.dataset.myExtension' 'active'
roddy storage get lastSeen
Because Chrome derives an unpacked extension's ID from its load path — roddy
merely reproduces the computation to print the ID before launch — the ID is
stable across runs, so chrome-extension:// URLs can be hardcoded in test
scripts instead of being discovered at runtime.
Extension storage
Seeding and reading chrome.storage is the most common extension test-setup
act, so it has first-class sugar — no JS required:
roddy storage get # the whole area as JSON
roddy storage get user # one key
roddy storage set user test # a bare word stays a string...
roddy storage set count 3 # ...valid JSON keeps its type
roddy storage set config '{"theme":"dark"}'
roddy storage rm user count # remove keys (absent ones are ignored)
roddy storage clear # wipe the area
--area local|sync|session|managed picks the storage area (default local).
managed is read-only by design, so writing to it reports Chrome's own error.
VALUE is parsed as JSON when it parses — 3, true, {"a":1} — and treated
as a plain string otherwise; force a string that would otherwise parse by
quoting it twice: roddy storage set flag '"true"'.
storage get KEY of a key that is not there prints undefined and exits 1.
Presence of the key decides, so a stored null or false still exits 0:
roddy storage get onboarded || roddy storage set onboarded true
The commands run inside the extension's service worker through the same
plumbing as sw eval: the extension's manifest must declare the storage
permission, --ext ID picks one extension when several are loaded, and
--timeout (default 5s) bounds the wait for the worker. Flags may appear
anywhere; put -- in front of a KEY or VALUE that itself starts with a dash,
which a negative number (-1.5) does not need.
Console output
roddy logs prints the console of the active page — including messages logged
before the command ran, because Chrome replays its console buffer to each
new debugging session. Uncaught exceptions (with stack traces) and
browser-generated entries such as network failures are included.
roddy logs # snapshot: replayed console output, then exit
roddy logs --timeout 10s # collect for up to 10s (default 5s)
roddy logs --follow # keep streaming live output until Ctrl+C
roddy logs --sw # an extension service worker's console instead
roddy logs --sw --ext ID # pick one extension when several are loaded
A service worker has no DOM, so its console is often the only way to see what
it did — roddy logs --sw right after roddy start --extension shows the
worker's startup output. A worker Chrome suspended and later restarted is a new
target with an empty buffer, so that early window is the reliable one.
Notes:
- Replayed object arguments print as
Object— Chrome's buffer keeps no preview. Live output under--followshows a one-level preview like{a: 1, b: "x"}. - A snapshot sorts what it collects by timestamp, because Chrome replays the
console buffer and the browser log buffer as two separate bursts.
--followprints events as they arrive, so its replayed prefix comes out as those two unsorted bursts before the live stream begins. --timeout(default 5s) bounds a snapshot: it returns once the output has been quiet for a moment, or once the timeout is up. A page that logs continuously hits the timeout, prints what it collected, and says so on stderr —--followis the way to keep reading. With--swthe same flag also bounds the wait for the worker to appear.- A page with an empty console prints nothing and exits 0. A stream that ends on its own — the tab was closed, or the browser went away — reports the reason on stderr and exits 2.
Stubbing network requests
roddy stub intercepts requests from pages, content scripts, and extension
service workers, and answers them from a rules file, so tests run against
canned responses instead of live servers (a web app's own service worker is
out of scope — its traffic stays live, and the stub says so at startup):
roddy stub rules.json # holds the session until Ctrl+C
# stub: 4 rules active (Ctrl+C to stop)
# GET https://api.example.com/user → fulfill 200 (rule 1)
# POST https://t.example.com/beacon → abort internetdisconnected (rule 2)
Start the stub before opening the pages it should cover. Interception
applies to documents committed after it starts, so a tab that was already open
keeps the live network — no rules, no log lines — until it is reloaded
(roddy reload). The command says so on stderr when it finds open pages:
note: 2 open page(s) keep the live network until reloaded — start the stub first, or roddy reload
The rules file is a JSON array; the first matching rule wins, top to bottom, and requests no rule matches continue to the real network untouched:
[
{"url": "**/api/user", "fulfill": {"json": {"name": "test"}}},
{"url": "**/telemetry/**", "abort": "internetdisconnected"},
{"url": "**/api/**", "method": "POST", "fulfill": {"status": 500, "body": "boom"}},
{"url": "**/big-fixture", "fulfill": {"path": "fixture.json"}}
]
The conventions are Playwright's, so patterns and verbs copy verbatim from
Playwright tests: URL globs where * stays inside a path segment, **
crosses segments, {a,b} alternates and ? is a literal; verbs fulfill
(status default 200, headers, contentType, and at most one of body /
json / path, the last resolved relative to the rules file), abort (a
Playwright error code such as internetdisconnected or connectionrefused,
or true for failed), and continue. Everything is validated up front — a
typo in a rule fails the command before the browser is touched.
Details that make stubbed tests behave:
-
Extension service workers get their own interception, armed before each worker runs its first instruction — a worker's own fetches bypass browser-wide interception, and its request path is fixed the moment it starts. Workers already running when the stub starts are therefore restarted (their in-memory state resets; stored state is untouched), and workers stay awake while the stub runs.
-
Globs match the URL exactly as Chrome reports it; Playwright's base-URL normalization is not ported, so write a fully-qualified pattern the way the request really appears (scheme, host, port included) or start it with
**. -
CORS preflights are answered with a synthesized 204 when the request they announce would be fulfilled or aborted — the real server may not exist — and passed through otherwise, so genuine CORS behavior stays testable. That policy governs: a rule of your own matching
OPTIONSis not consulted for a real preflight (it still matches a plainOPTIONSrequest). -
Cross-origin fulfills get
Access-Control-Allow-Originreflected automatically unless the rule sets its own. -
Redirect hops pass through without re-matching (only the first request in a chain is stubbed), and interception bypasses the HTTP cache, so every request actually reaches the rules.
-
--verbose(-v) logs the requests no rule matched as well. That is the way to debug a rule that is not firing: the request is there, with the exact URL and method your glob has to match. -
Stopping the stub (Ctrl+C) releases interception. Run one stub at a time: a second one does not warn, it chains behind the first in enable order, and the older holder's fulfills and aborts are the ones that win.
Run it in the background from a test script and stop it when done:
roddy stub rules.json & STUB=$!
roddy open https://example.com
roddy storage get lastSync # the extension saw the stubbed API
kill $STUB
Navigate
roddy open https://example.com # Navigate to URL
roddy open example.com # http:// prefix added automatically
roddy back # Go back
roddy forward # Go forward
roddy reload # Reload page
roddy reload --hard # Reload bypassing cache
roddy clear-cache # Clear the browser cache
back, forward, reload and waitload fail with exit 2 when the page that committed is a Chrome error page, naming what Chrome shows and the URL that failed (error: navigation failed: net::ERR_CONNECTION_REFUSED for http://…); a certificate error carries the same --insecure hint open gives; a DNS failure is named as Chrome's DNS probe renders it — DNS_PROBE_FINISHED_NXDOMAIN, or DNS_PROBE_STARTED while the probe is still running.
Extract information
roddy url # Print current URL
roddy title # Print page title
roddy text "h1" # Print text content of element
roddy html "div.content" # Print outer HTML of element
roddy html # Print full page HTML
roddy attr "a#link" href # Print attribute value
roddy pdf output.pdf # Save page as PDF
Run JavaScript
roddy js document.title # Evaluate expression
roddy js "1 + 2" # Math
roddy js 'document.querySelector("h1").textContent' # DOM queries
roddy js '[1,2,3].map(x => x * 2)' # Returns pretty-printed JSON
roddy js 'document.querySelectorAll("a").length' # Count elements
The expression is automatically wrapped in () => { return (expr); }.
Interact with elements
roddy click "button#submit" # Click element
roddy input "#search" "query" # Type into input field
roddy clear "#search" # Clear input field
roddy file "#upload" photo.png # Set file on a file input
roddy file "#upload" - # Set file from stdin
roddy download "a.pdf-link" # Download href/src target to file
roddy download "a.pdf-link" - # Download to stdout
roddy select "#dropdown" "value" # Select dropdown by value
roddy submit "form#login" # Submit a form
roddy hover ".menu-item" # Hover over element
roddy focus "#email" # Focus element
Wait for conditions
roddy wait ".loaded" # Wait for element to appear and be visible
roddy waitload # Wait for page load event (exit 2 on a Chrome error page,
# naming what Chrome shows)
roddy waitstable # Wait for DOM to stop changing
roddy waitidle # Wait for network to be idle
roddy sleep 2.5 # Sleep for N seconds
Viewport
roddy viewport 320 568 # Persist a CSS viewport for this session
roddy viewport # Print the session viewport as JSON
roddy viewport reset # Restore the default 1280 × 800 viewport
The viewport applies across commands, reloads, and tabs in this session. It
changes CSS dimensions, not the user agent or touch support. Set it before
open when the page reads its size during startup.
Reduced-motion emulation
roddy emulate --reduced-motion reduce # Request reduced motion
roddy emulate --reduced-motion no-preference # Explicitly test ordinary motion
roddy emulate --reduced-motion reset # Use the browser/system preference
roddy emulate # Print the saved setting as JSON
The setting is saved for the session and reapplied across commands and tabs.
Set it before open or newpage to make startup matchMedia calls and CSS
media queries see the preference. It does not disable animations itself: the
page decides how to respond to prefers-reduced-motion.
Chrome clears the active media override when a CLI connection closes. The saved
setting is reapplied during each page command; between commands the page can
observe the system preference, including matchMedia change events. For timed
checks that need uninterrupted emulation, await the interval and assertion in
one js command.
Screenshots
roddy screenshot # Save as screenshot.png
roddy screenshot page.png # Save to specific file
roddy screenshot -w 1280 -h 720 out.png # Temporary viewport width/height
roddy screenshot-el ".chart" chart.png # Screenshot specific element
roddy screenshot --wait-animations page.png
roddy screenshot-el --wait-animations ".chart" chart.png
Screenshots use the session viewport. Without -h, capture includes the whole
page without resizing its responsive layout. -h switches to viewport-only
capture. -w and -h override dimensions for that capture only; unspecified
dimensions keep the session value, and the viewport is restored afterward.
Use viewport when subsequent layout assertions must use the same dimensions.
--wait-animations waits for finite, running CSS transitions/animations and
Web Animations in the top document. It waits after temporary resizing, or after
scrolling an element into view, and requires two animation frames without
eligible animations before capture. Infinite, paused, zero-playback-rate, and
scroll-driven animations keep their current behavior and do not hold up the
wait. Animations finish naturally, including their completion callbacks.
Element capture in this mode includes the full element, even beyond the viewport,
and preserves the browser’s device pixel ratio.
The wait shares ROD_TIMEOUT with the rest of the command and exits 2 on timeout;
any temporary viewport is restored. The flag is opt-in and can appear before or
after the filename. It does not wait for iframe animations, canvas/JavaScript
rendering loops, or animations scheduled after the quiet frames. waitstable
checks DOM stability and does not replace this animation wait.
Manage tabs
roddy pages # List all tabs (* marks active)
roddy newpage https://... # Open URL in new tab
roddy page 1 # Switch to tab by index
roddy closepage 1 # Close tab by index
roddy closepage # Close active tab
Query elements
roddy exists ".loading" # Exit 0 if exists, exit 1 if not
roddy count "li.item" # Print number of matching elements
roddy visible "#modal" # Exit 0 if visible, exit 1 if not
roddy assert 'document.title' 'Home' # Exit 0 if equal, exit 1 if not
roddy assert 'document.querySelector("h1") !== null' # Exit 0 if truthy
Accessibility testing
roddy ax-tree # Dump full accessibility tree
roddy ax-tree --depth 3 # Limit tree depth
roddy ax-tree --json # Output as JSON
roddy ax-find --role button # Find all buttons
roddy ax-find --name "Submit" # Find by accessible name
roddy ax-find --role link --name "Home" # Combine filters
roddy ax-find --role button --json # Output as JSON
roddy ax-node "#submit-btn" # Inspect element's a11y properties
roddy ax-node "h1" --json # Output as JSON
These commands use Chrome's Accessibility CDP domain to expose what assistive technologies see. ax-tree uses getFullAXTree, ax-find uses queryAXTree, and ax-node uses getPartialAXTree.
# CI check: verify all buttons have accessible names
roddy ax-find --role button --json | python3 -c "
import json, sys
buttons = json.load(sys.stdin)
unnamed = [b for b in buttons if not b.get('name', {}).get('value')]
if unnamed:
print(f'FAIL: {len(unnamed)} button(s) missing accessible name')
sys.exit(1)
print(f'PASS: all {len(buttons)} buttons have accessible names')
"
Directory-scoped sessions
By default, Roddy stores state globally in ~/.roddy/. You can instead create a session scoped to the current directory with --local:
roddy start --local # State stored in ./.roddy/state.json
# Chrome data in ./.roddy/chrome-data/
roddy open https://example.com # Auto-detects local session
roddy stop # Cleans up local session
This is useful when you want isolated browser sessions per project — each directory gets its own Chrome instance, cookies, and state.
Auto-detection: When neither --local nor --global is specified, Roddy checks for ./.roddy/state.json in the current directory. If found, it uses the local session; otherwise it falls back to the global ~/.roddy/ session.
# Force global even when a local session exists
roddy --global open https://example.com
# Force local (errors if no local session)
roddy --local status
The --local and --global flags can appear anywhere in the command:
roddy --local start
roddy start --local # Same effect
roddy open --global https://example.com
Add .roddy/ to your .gitignore to keep session state out of version control.
Shell scripting examples
# Wait for page to load and extract data
roddy start
roddy open https://example.com
roddy waitstable
title=$(roddy title)
echo "Page: $title"
# Conditional logic based on element presence
if roddy exists ".error-message"; then
roddy text ".error-message"
fi
# Loop through pages
for url in page1 page2 page3; do
roddy open "https://example.com/$url"
roddy waitstable
roddy screenshot "${url}.png"
done
roddy stop
Exit codes
Roddy uses distinct exit codes to separate check failures from errors:
| Exit code | Meaning |
|---|---|
0 |
Success |
1 |
Check failed — the command ran successfully but the condition/assertion was not met |
2 |
Error — something went wrong (bad arguments, no browser session, timeout, etc.) |
This makes it easy to distinguish between "the assertion is false" and "the command couldn't run" in scripts and CI pipelines.
Using Roddy for checks
Several commands return exit code 1 when a condition is not met, making them useful as assertions in shell scripts and CI pipelines. All of these print their result to stdout and exit cleanly — no error message is written to stderr.
exists — check if an element exists in the DOM
roddy exists "h1"
# Prints "true", exits 0
roddy exists ".nonexistent"
# Prints "false", exits 1
visible — check if an element is visible
roddy visible "#modal"
# Prints "true" and exits 0 if the element exists and is visible
roddy visible "#hidden-div"
# Prints "false" and exits 1 if the element is hidden or doesn't exist
ax-find — check for accessibility nodes
roddy ax-find --role button --name "Submit"
# Prints the matching node(s), exits 0
roddy ax-find --role banner --name "Nonexistent"
# Prints "No matching nodes" to stderr, exits 1
assert — assert a JavaScript expression
With one argument, checks that the expression is truthy. With two arguments, checks that the expression's value equals the expected string. Use --message / -m to set a custom failure message.
# Truthy mode — check that expression evaluates to a truthy value
roddy assert 'document.querySelector(".logged-in") !== null'
# Prints "pass", exits 0
roddy assert 'document.querySelector(".nonexistent")'
# Prints "fail: got null", exits 1
# Equality mode — check that expression result matches expected value
roddy assert 'document.title' 'Dashboard'
# Prints "pass" if title is "Dashboard", exits 0
roddy assert 'document.querySelectorAll(".item").length' '3'
# Prints "pass" if there are exactly 3 items, exits 0
roddy assert 'document.title' 'Wrong Title'
# Prints 'fail: got "Dashboard", expected "Wrong Title"', exits 1
The expression is evaluated the same way as roddy js — the result is converted to its string representation before comparison. This means roddy assert 'document.title' 'Dashboard' compares the unquoted string, and roddy assert '1 + 2' '3' compares the number as a string.
Use --message (or -m) to add a human-readable description to the failure output:
roddy assert 'document.querySelector(".logged-in")' -m "User should be logged in"
# On failure: "fail: User should be logged in (got null)"
roddy assert 'document.title' 'Dashboard' --message "Wrong page loaded"
# On failure: 'fail: Wrong page loaded (got "Home", expected "Dashboard")'
storage get — check an extension storage key
roddy storage get KEY prints the value when the key exists, and prints
undefined and exits 1 when it does not. The key's presence decides, so a
stored null or false still exits 0:
roddy storage get onboarded || roddy storage set onboarded true
Combining checks in a shell script
You can chain these together in a single script to run multiple assertions. Because check failures use exit code 1 while real errors use exit code 2, you can use set -e to abort on errors while handling check failures explicitly:
#!/bin/bash
set -euo pipefail
FAIL=0
check() {
if ! "$@"; then
echo "FAIL: $*"
FAIL=1
fi
}
roddy start
roddy open "https://example.com"
roddy waitstable
# Assert elements exist
check roddy exists "h1"
check roddy exists "nav"
check roddy exists "footer"
# Assert key elements are visible
check roddy visible "h1"
check roddy visible "#main-content"
# Assert JS expressions
check roddy assert 'document.title' 'Example Domain'
check roddy assert 'document.querySelectorAll("p").length' '2'
check roddy assert 'document.querySelector("h1") !== null'
# Assert accessibility requirements
check roddy ax-find --role navigation
check roddy ax-find --role heading --name "Example Domain"
roddy stop
if [ "$FAIL" -ne 0 ]; then
echo "Some checks failed"
exit 1
fi
echo "All checks passed"
This pattern is useful in CI — run Roddy as a post-deploy check, an accessibility audit, or a smoke test against a staging environment. Because exit code 2 signals an actual error (e.g. Chrome didn't start), set -e will abort the script immediately if something is broken rather than reporting a misleading test failure.
Configuration
| Environment Variable | Default | Description |
|---|---|---|
RODDY_HOME |
~/.roddy |
Data directory for state and Chrome profile |
ROD_CHROME_BIN |
/usr/bin/google-chrome |
Path to Chrome/Chromium binary |
ROD_TIMEOUT |
30 |
Budget in seconds for a whole command — connecting to Chrome, listing its pages and every element query inside it — and for a sw eval |
HTTPS_PROXY / HTTP_PROXY |
(none) | Authenticated proxy auto-detected on start |
Global state is stored in ~/.roddy/state.json with Chrome user data in ~/.roddy/chrome-data/. When using --local, state is stored in ./.roddy/state.json and ./.roddy/chrome-data/ in the current directory instead. Set RODDY_HOME to override the default global directory.
Proxy support
In environments with authenticated HTTP proxies (e.g., HTTPS_PROXY=http://user:pass@host:port), roddy start automatically:
- Detects the proxy credentials from environment variables
- Launches a local forwarding proxy that injects
Proxy-Authorizationheaders into CONNECT requests (the credentials are handed to it over stdin, so they are not in its argv, whichpsshows to every user on the machine — its environment still carriesHTTPS_PROXY, visible to the owner and root viaps eww) - Configures Chrome to use the local proxy on the port that proxy reports back
This is necessary because Chrome cannot natively authenticate to proxies during HTTPS tunnel (CONNECT) establishment. The local proxy runs as a background process and is automatically cleaned up by roddy stop.
start waits for that local proxy to report the port it bound; if the helper exits first or never reports one, start stops it and fails (exit 2) with the helper's own output in the error message. That output also lands in proxy.log under the state directory (~/.roddy/, or ./.roddy/ with --local), overwritten on each start.
For a proxied session status also reports the helper: Auth proxy: running (PID N, port P), or Auth proxy: NOT running (PID N recorded; its last output, if any, is in ~/.roddy/proxy.log) once it has died — and Auth proxy: port P answers but PID N is gone — another process owns it; roddy start replaces the helper when something else has taken the port it left. open (and newpage, back, forward, reload, waitload) draws the same distinction when Chrome reports net::ERR_TUNNEL_CONNECTION_FAILED or net::ERR_PROXY_CONNECTION_FAILED: "the proxy helper could not reach the upstream proxy — see proxy.log" while it runs, "the proxy helper is no longer running … roddy start replaces it" once it is gone. Plain http:// requests through a running helper whose upstream is down render the helper's own 502 as a page instead, with no error.
Certificate errors behind a proxy
The local helper is a transparent CONNECT tunnel: it never terminates TLS, so it originates no certificate error. A net::ERR_CERT_* behind a proxy is the target's own certificate or an upstream proxy that inspects TLS and re-signs with its own CA. start keeps certificate validation on that path, and open (and newpage, back, forward, reload, waitload) names both fixes when it hits one:
-
Install that CA where Chrome reads it. On Linux that is the NSS shared DB — Chrome reads it from
~/.pki/nssdbonly, and ignores--user-data-dirfor trust:mkdir -p "$HOME/.pki/nssdb" # certutil fails if the directory is absent certutil -d sql:"$HOME/.pki/nssdb" -A -t "C,," -n <name> -i <ca.pem> roddy stop && roddy start # a running Chrome does not pick up new anchorscertutilships inlibnss3-tools(Debian/Ubuntu) ornss-tools(Fedora). On macOS the login keychain holds it, and importing without-r trustRoottrusts nothing:security add-trusted-cert -r trustRoot -k ~/Library/Keychains/login.keychain-db <ca.pem> -
Or restart with
roddy start --insecure, which ignores certificate errors for every page in the session —startandstatusthen say "certificate errors ignored".
See notes/claude-chrome-proxy/README.md for detailed technical notes.
How it works
The tool uses the rod Go library which communicates with Chrome via the DevTools Protocol (CDP) over WebSocket. Key implementation details:
startuses rod'slauncherpackage to start Chrome withLeakless(false)so Chrome survives after the CLI exits- Proxy auth handled via a local forwarding proxy that bridges Chrome to authenticated upstream proxies
- State persistence via a JSON file containing the WebSocket debug URL and Chrome PID
- Each command creates a new rod
Browserconnection to the same Chrome instance, executes the operation, and disconnects - Element queries use rod's built-in auto-wait with a configurable timeout (default 30s)
- JS evaluation wraps user expressions in arrow functions as required by rod's
Eval - Accessibility commands call CDP's Accessibility domain directly via rod's
protopackage (getFullAXTree,queryAXTree,getPartialAXTree)
Dependencies
- github.com/go-rod/rod v0.116.2 - Chrome DevTools Protocol automation
Commands reference
| Command | Arguments | Description |
|---|---|---|
start |
[--show] [--insecure|-k] [--no-sandbox] [--extension PATH] [--no-extension] [--single-process|--no-single-process] |
Launch Chrome (headless by default, --show for visible) |
connect |
<host:port> |
Connect to existing Chrome on remote debug port (replaces the recorded session, as a second start does) |
stop |
Shut down Chrome | |
status |
Show browser status | |
extensions |
List extensions loaded into this session | |
sw |
[list] [--ext ID] [--timeout DUR] |
List extension service workers, waiting up to --timeout for them (exit 1 if none are running) |
sw eval |
<expr> [--ext ID] [--timeout DUR] |
Evaluate JS inside an extension's service worker (the evaluation is bounded by ROD_TIMEOUT); flags may go either side of the expression |
storage |
get [KEY] | set KEY VALUE | rm KEY... | clear |
Read and write chrome.storage from inside an extension's service worker; --area local|sync|session|managed (default local), plus --ext and --timeout as for sw eval, and -- before a KEY or VALUE starting with a dash |
logs |
[--follow|-f] [--sw] [--ext ID] [--timeout DUR] |
Print console output (replayed + live) |
stub |
<rules-file> [--verbose|-v] |
Answer the browser's network requests from a JSON rules file, held in the foreground until Ctrl+C (--verbose also logs the requests no rule matched) |
open |
<url> |
Navigate to URL |
back |
Go back in history | |
forward |
Go forward in history | |
reload |
[--hard] |
Reload page (--hard bypasses cache) |
clear-cache |
Clear the browser cache | |
url |
Print current URL | |
title |
Print page title | |
html |
[selector] |
Print HTML (page or element) |
text |
<selector> |
Print element text content |
attr |
<selector> <name> |
Print attribute value |
pdf |
[file] |
Save page as PDF |
js |
<expression> |
Evaluate JavaScript |
click |
<selector> |
Click element |
input |
<selector> <text> |
Type into input |
clear |
<selector> |
Clear input |
file |
<selector> <path|-> |
Set file on a file input (- for stdin) |
download |
<selector> [file|-] |
Download href/src target (- for stdout) |
select |
<selector> <value> |
Select dropdown value |
submit |
<selector> |
Submit form |
hover |
<selector> |
Hover over element |
focus |
<selector> |
Focus element |
wait |
<selector> |
Wait for element to appear |
waitload |
Wait for page load (exit 2 on a Chrome error page) | |
waitstable |
Wait for DOM stability | |
waitidle |
Wait for network idle | |
sleep |
<seconds> |
Sleep N seconds |
emulate |
[--reduced-motion reduce|no-preference|reset] |
Inspect or set reduced-motion emulation |
viewport |
[<width> <height> | reset] |
Inspect or persist the session viewport |
screenshot |
[--wait-animations] [-w N] [-h N] [file] |
Page screenshot (optional temporary viewport size) |
screenshot-el |
[--wait-animations] <selector> [file] |
Element screenshot |
pages |
List tabs | |
page |
<index> |
Switch tab |
newpage |
[url] |
Open new tab |
closepage |
[index] |
Close tab |
exists |
<selector> |
Check element exists (exit 1 if not) |
count |
<selector> |
Count matching elements |
visible |
<selector> |
Check element visible (exit 1 if not) |
assert |
<expr> [expected] [-m msg] |
Assert JS expression is truthy or equals expected (exit 1 if not) |
ax-tree |
[--depth N] [--json] |
Dump accessibility tree |
ax-find |
[--name N] [--role R] [--json] |
Find accessible nodes |
ax-node |
<selector> [--json] |
Show element accessibility info |
Global flags
| Flag | Description |
|---|---|
--local |
Use directory-scoped session (./.roddy/) |
--global |
Use global session (~/.roddy/) |
--version |
Print version and exit |
--help, -h, help |
Show help message |