Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,13 @@ directly. `skill/chrome-devtools/CUSTOM_SCRIPTING.md` documents `run-script` and

### Daemon Architecture

A background daemon (`/tmp/chrome-devtools-daemon.sock`) keeps a persistent CDP
WebSocket connection. First CLI invocation spawns it; subsequent commands reuse
it. 5-minute idle timeout.
A background daemon keeps a persistent CDP WebSocket connection. On Unix it
listens on `$TMPDIR/chrome-devtools-daemon-<uid>.sock` (uid-suffixed to isolate
users sharing /tmp); on Windows, on a loopback TCP port published via an
unsuffixed `%TEMP%` addr file. First CLI invocation spawns it; subsequent
commands reuse it. 5-minute idle timeout; endpoint/PID files are cleaned up on
panics too, and on Unix on SIGTERM/SIGINT (Windows Ctrl-C cleanup is
best-effort — a background daemon has no console).

`CdpClient::connect` (`cdp.rs`) bounds the WebSocket handshake with a timeout
(`CHROME_CONNECT_TIMEOUT_SECS`, default 10s). Without it, a pending Chrome
Expand Down
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
name = "chrome-devtools-cli"
version = "1.5.0"
edition = "2021"
# std::fs::File::{lock, try_lock} and std::fs::TryLockError (daemon lock path)
# stabilized in 1.89
rust-version = "1.89"
description = "Chrome DevTools Protocol CLI — auto-connects to existing Chrome"
authors = ["Aero <aero.windwalker@gmail.com>"]
license = "MIT"
Expand Down
25 changes: 19 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ cargo build --release
# Binary: ./target/release/chrome-devtools
```

### Rust version

Building from source — including `cargo install` — requires **Rust 1.89 or
newer**. The daemon serializes its startup and cleanup with
`std::fs::File::{lock, try_lock}`, stabilized in 1.89; `rust-version` in
`Cargo.toml` makes Cargo say so instead of failing with a type error. The
Homebrew bottle is prebuilt and carries no toolchain requirement.

## Why this exists

Inspired by [chrome-devtools-mcp](https://github.com/ChromeDevTools/chrome-devtools-mcp) — the official MCP server for Chrome DevTools. It works well, but MCP-based browser tools consume a lot of token context: every interaction sends and receives large protocol payloads through the MCP layer.
Expand All @@ -41,7 +49,8 @@ This is a lightweight Rust binary that talks directly to Chrome's DevTools Proto
```
chrome-devtools navigate https://example.com
├─ Try daemon (Unix socket /tmp/chrome-devtools-daemon.sock)
├─ Try daemon (Unix socket $TMPDIR/chrome-devtools-daemon-<uid>.sock;
│ loopback TCP on Windows)
│ └─ If running → send command → get result
├─ If no daemon → spawn one (background process)
Expand Down Expand Up @@ -215,12 +224,16 @@ Global `--block-url` and `--unblock-url` update the **active tab's** block list

## Daemon details

- **Socket**: `/tmp/chrome-devtools-daemon.sock`
- **PID file**: `/tmp/chrome-devtools-daemon.pid`
- **Idle timeout**: 5 minutes (auto-exits, cleans up socket)
- **Protocol**: Length-prefixed JSON over Unix socket
- **Endpoint (Unix)**: socket at `$TMPDIR/chrome-devtools-daemon-<uid>.sock` (uid-suffixed so users on a shared machine don't collide)
- **Endpoint (Windows)**: loopback TCP listener; its address is written to `%TEMP%\chrome-devtools-daemon.addr` (`%TEMP%` is already per-user, so no suffix)
- **PID file**: `$TMPDIR/chrome-devtools-daemon-<uid>.pid` (Windows: `%TEMP%\chrome-devtools-daemon.pid`)
- **Lock file**: `$TMPDIR/chrome-devtools-daemon-<uid>.lock` (Windows: `%TEMP%\chrome-devtools-daemon.lock`) — serializes daemon startup/cleanup; intentionally never removed automatically. Locks bind to the inode, not the name: deleting the file while any daemon process is still starting, running, or shutting down lets a new process lock a fresh replacement inode and bypass the serialization entirely. Only delete it once no daemon process exists at all — and there's rarely a reason to, since a leftover lock file is harmless.
- **Idle timeout**: 5 minutes (auto-exits, cleans up its files)
- **Cleanup**: endpoint + PID files are also removed on panics, and on Unix on SIGTERM/SIGINT; Windows Ctrl-C cleanup is best-effort only (a background daemon has no console to receive it). SIGQUIT, SIGHUP and SIGKILL skip cleanup by design — the leftover files are harmless and are reclaimed by the next daemon start.
- **Protocol**: Length-prefixed JSON over the Unix socket / loopback TCP
- **Spawned by**: First CLI invocation (transparent to user)
- **Kill**: `chrome-devtools kill-daemon` (or delete the socket + PID file)
- **Kill**: `chrome-devtools kill-daemon` (or delete the socket + PID file; leave the lock file — see above). It sends SIGTERM and returns once the signal is delivered, not once the process is gone: the daemon exits *between* requests, so one that is mid-command finishes it and answers that client first. Expect up to one command's latency, and note that a daemon wedged inside a CDP call outlives the command that stopped it.
- **Kill (Windows)**: not supported — `kill-daemon` says so and exits, and a backgrounded daemon has no console for Ctrl-C. Use `taskkill /PID <pid>` with the PID from `%TEMP%\chrome-devtools-daemon.pid`, or wait out the idle timeout.

The daemon keeps a persistent CDP session on the current page to:
- Continuously collect `Network.*` and `Runtime.consoleAPICalled`/`exceptionThrown` events for `console` and `network` drains.
Expand Down
72 changes: 72 additions & 0 deletions skill/chrome-devtools/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,78 @@ profiles/machines) produces a meaningless result where nearly everything is
reported as both added and removed — the CLI prints a warning on stderr when
it detects this.

### Pattern 16: Headless Chrome (No Login, No Human Approval)

When the flow under test doesn't need the user's cookies/credentials, spawn a
throwaway headless Chrome instead of attaching to the user's browser. Because
the instance is launched with remote debugging already enabled, **no consent
prompt ever appears** — the whole flow runs unattended.

```bash
PROFILE=$(mktemp -d)

# 1. If a daemon is already attached to the user's real Chrome, stop it first
# (the daemon is per-user and sticks to whichever Chrome it first connected to)
chrome-devtools kill-daemon --force

# 2. Spawn headless Chrome with an isolated profile; port 0 = pick a free port
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--headless=new --remote-debugging-port=0 \
--user-data-dir="$PROFILE" \
--no-first-run --no-default-browser-check \
about:blank &
CHROME_PID=$!

# 3. Cleanup — REQUIRED even if a later step fails: the daemon is bound to the
# headless instance and would otherwise hijack later commands aimed at the
# user's real Chrome. A trap runs it on every exit path, not just success.
cleanup() {
chrome-devtools kill-daemon --force
kill "$CHROME_PID" 2>/dev/null
# Chrome shuts down asynchronously, so deleting the profile right after
# SIGTERM races its teardown and can leave it running against a directory
# that no longer exists. Wait for the process to actually go — but bounded
# (5s), then SIGKILL, so a Chrome that ignores SIGTERM can't hang the trap.
for _ in $(seq 1 20); do
kill -0 "$CHROME_PID" 2>/dev/null || break
sleep 0.25
done
if kill -0 "$CHROME_PID" 2>/dev/null; then
kill -9 "$CHROME_PID" 2>/dev/null
fi
wait "$CHROME_PID" 2>/dev/null
rm -rf "$PROFILE"
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
}
trap cleanup EXIT

# 4. DevToolsActivePort is written only after the DevTools server is
# actually listening, so the file's existence — not the process being
# alive — is the readiness signal; connecting earlier races startup.
# Bounded (30s) and watching the PID so a Chrome that crashes or never
# starts fails the script instead of hanging it forever.
for _ in $(seq 1 60); do
[ -f "$PROFILE/DevToolsActivePort" ] && break
kill -0 "$CHROME_PID" 2>/dev/null || { echo "Chrome exited during startup" >&2; exit 1; }
sleep 0.5
done
[ -f "$PROFILE/DevToolsActivePort" ] || { echo "Chrome not ready after 30s" >&2; exit 1; }

# 5. Every command needs --user-data-dir pointing at the headless profile;
# the CLI auto-connects by reading its DevToolsActivePort
chrome-devtools --user-data-dir "$PROFILE" navigate https://example.com
chrome-devtools --user-data-dir "$PROFILE" evaluate 'document.title'
chrome-devtools --user-data-dir "$PROFILE" screenshot --output /tmp/shot.png
```

Linux path: `google-chrome` or `chromium` on `$PATH` replaces the macOS
`.app` binary path.

**⚠️ One daemon per user, bound to one Chrome.** The daemon connects to
whichever Chrome the first command resolved, and later commands reuse it even
if their flags point elsewhere. Always `kill-daemon --force` when switching
between the user's Chrome and a headless instance — in both directions
(step 1 and the EXIT trap above).

## Complete Command Reference

### Navigation
Expand Down
7 changes: 5 additions & 2 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@ async fn connect_daemon() -> Result<TcpStream> {
Ok(TcpStream::connect(addr.trim()).await?)
}

/// Read the daemon wait timeout from `DAEMON_WAIT_TIMEOUT_SECS`, defaulting to 5.
fn daemon_wait_timeout() -> Duration {
/// Read the daemon wait timeout from `DAEMON_WAIT_TIMEOUT_SECS`, defaulting to
/// 5. This is the whole budget a spawned daemon has to become reachable, so the
/// daemon derives its own startup lock wait from it (`daemon::lock_wait_timeout`)
/// — the spawned process inherits this environment.
pub(crate) fn daemon_wait_timeout() -> Duration {
std::env::var("DAEMON_WAIT_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
Expand Down
Loading