chrome-cdp attaches to the user's already-running Chrome over the DevTools Protocol (CDP) and drives it from the command line.
It never launches a headless browser for real work — the whole point is to reuse the live session's cookies, logins, and extensions.
Every command emits a single result.Envelope (internal/result/result.go) and exits with a code derived from it.
This contract is the load-bearing interface: both humans and the Claude skill parse against it, so treat it as public API.
error.codestrings (fine-grained, e.g.target_not_found) map to process exit codes viaresult.codeToExit/ExitCodeFor.- Exit codes are the coarse, stable contract:
0ok,2usage,3connection,4target/timeout,5cdp,6daemon. - Adding a new failure mode means adding a
Code*constant and itscodeToExitentry — an unmapped code silently degrades toExitGeneric. - Usage/validation errors (exit
2) are checked before touching Chrome, so a bad flag never launches or connects.
Data flows outermost → innermost: cli parses → resolves a target → gets a chrome.Browser (via daemon or direct) → emits a result.
result— the envelope,Err, and the exit-code table. No dependencies; the root of the contract.target— the target grammar (idprefix | url:<s> | title:<s> | @N) andResolveagainst a tab list.config— layered defaults: built-in < config file (~/.config/chrome-cdp/config.toml) <CHROME_CDP_*env < flag.Builtin(),Resolve(),FromEnv().browser— endpoint discovery and classification: finds Chrome'sDevToolsActivePortfile, computes the per-endpoint key, and probes the debug endpoint's WebSocket upgrade (WSState,AwaitUpgrade) — see connection model below.chrome— theBrowserinterface and its chromedp-backed implementation: snapshot, click/type/fill/select, grid, wait, raw CDP. The real driver logic lives here.chrometest—StubBrowser, a permissivechrome.Browserdouble embedded by theclianddaemontests.state— the sticky current-target store, keyed per endpoint and per--sessionname, so distinct--ports and distinct sessions don't share a "current tab".daemon— the held-connection RPC: a background process holds the CDP attach so Chrome's consent prompt appears once per session, not per command.cli— the cobra command tree (app.go= wiring + envelope emission,commands.go= the verbs). Knows nothing about how the browser connects;maininjects that.
cli.App is deliberately ignorant of daemons, sockets, and process spawning — it holds function seams that main wires up:
WithConnector— how to get achrome.Browser(daemon client vs.--no-daemondirect connect), invoked lazily only when a command needs Chrome.WithStickyTarget— get/set the current target; keyed byConnOptsso each endpoint and each--sessionname has its own.WithDaemonCtl— start/stop/status for the per-endpoint daemon.WithDefaults— inject config+env defaults (tests keepconfig.Builtin()).
This is why tests can inject a chrometest.StubBrowser directly and never spawn a process.
When adding a command that needs a new capability, add the method to the chrome.Browser interface, give it a default in chrometest.StubBrowser (one place), then implement it in internal/chrome.
- The endpoint key derives from the port file + effective
--port; it names both the daemon socket and the sticky-state file. It cannot be computed once at startup —--portisn't known until cobra parses flags — somaincomputes it per command fromConnOpts. - The daemon (
chrome-cdp __daemon <socket>, a hidden mode) holds one CDP connection for ~30 min and serves commands over a Unix socket, so the "Allow debugging?" consent fires once.--no-daemonbypasses it and connects directly (used by tests and one-shot scripts). - Chrome M136+ dropped the classic
--remote-debugging-portfor the default profile;browserreadsDevToolsActivePortand connects directly, which is why it keeps working where older tools broke. - The consent prompt is a third connection state, not a failure (RFC-0013).
While Chrome holds "Allow remote debugging?" it accepts the TCP connect and then stalls the WebSocket upgrade forever — no error, only silence — so
browser.WSStateis three-way (WSRefused/WSPending/WSReady) andDecideConnectionmaps an open-but-hanging endpoint to its ownConsentPendingaction. The upgrade itself lives inchrome/probe.go(AwaitUpgrade,ProbeWS,ResolveWSURL), next to the connection it feeds;browserkeeps the vocabulary and the ladder and stays free of I/O against Chrome. The daemon holds that upgrade open forconsent_timeout(default 120s, clamped to[1s, 10m]bychrome.ClampConsentTimeoutwhere flag/env/config resolve) and publishes a<socket>.pendingmarker soEnsureextends its own deadline instead of declaring a live daemon dead; a refused endpoint still fails in milliseconds, which is what makes the long wait safe.WSState.String()is the wire valuedoctorreports asstate, so there is one vocabulary rather than a hand-maintained second list. Never lead a failure message with thechrome://inspecttoggle:browser.EnableAdviceis the one authored answer, and it recommends--remote-debugging-portfirst because that path never prompts.browser.ConsentPromptAdviceis the matching one for describing the dialog itself — modal to the browser, behind the window, no other input accepted — and every message that mentions the prompt composes it rather than rewriting it. - Anything
doctorreports is a claim it has to have verified, and anything it echoes ends up in an agent transcript (the Skill runsdoctor --jsonfirst). A daemon'srunning: trueis not evidence — the socket outlives the connection — so__statusreportsconnectedfrom theListround trip it makes, anddoctorrequires that before sayingready. The status payload carries atarget_count, never the tab list: titles and URLs are not an answer to "can I connect?".
--json emits the raw envelope; otherwise renderHuman prints a terse line (✓ … / ✗ … to stderr), honoring NO_COLOR and --quiet.
The human path is a courtesy; the JSON envelope is the contract. Never let a human-formatting change alter the envelope shape.