Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,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 +216,15 @@ 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)
- **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)

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
60 changes: 60 additions & 0 deletions skill/chrome-devtools/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,66 @@ 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
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
Loading