Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
87 changes: 63 additions & 24 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,65 @@ Key files:
| File | Role |
|---|---|
| `src/cli.ts` | Command parsing and dispatch (`opera-browser-cli <command>`) |
| `src/client.ts` | HTTP client for the bridge + bridge lifecycle (start/stop/health) |
| `src/client.ts` | HTTP client for the bridge + bridge lifecycle (discovery, start lock, recovery) |
| `src/bridge.ts` | Persistent HTTP ↔ MCP adapter; spawns `opera-devtools-mcp` as a child process |
| `src/bridge.ts` → `runBridge()` | Entry point for the bridge process |
| `src/identity.ts` | Bridge identity contract — version skew and PID-recycling safety |
| `src/detect.ts` | Locating installed Opera builds |
| `src/config.ts` | Config read/write/validate, and first-run autoconfiguration |
| `src/profile.ts` | Profile lock (`SingletonLock`) and debug-port (`DevToolsActivePort`) inspection |
| `src/browser-target.ts` | Decides launch vs attach; quits and relaunches a browser on takeover |
| `src/version.ts` | Package version lookup shared by CLI, bridge, and `/health` |
| `bin/opera-browser-cli-bridge.js` | Bridge binary entrypoint (calls `runBridge`) |

### Bridge lifecycle invariants

Four rules hold the lifecycle together. Breaking any of them reintroduces a class of bug
that M1 removed — see `specs/robustness-hardening.md`.

1. **Never signal an unidentified PID.** A process is only signalled once it has answered
`/health` as ours, or its PID file entry records the *current* boot (`identity.ts` →
`sameBoot`). After a reboot a recycled PID may belong to anyone.
2. **Version equality, not just health.** A bridge on a different package version is
unusable however healthy it looks — it is serving pre-upgrade code from memory.
3. **Bind the port before connecting to MCP.** `runBridge` listens first so that losing a
start race costs nothing; connecting first would launch a browser only to discard it.
4. **Never silently replay an Opera AI tool.** `callTool` recovers dropped connections by
restarting and retrying, except for `opera_do`/`opera_make`/`opera_research`/`opera_chat`,
which may already have acted and are billable to re-run.

### Browser target invariants

5. **`--remote-debugging-port` is startup-only.** A browser the user opened normally can
never be attached to. Everything in `browser-target.ts` follows from this.
6. **A live debug port beats the lock.** If `DevToolsActivePort` answers, attach — whatever
`SingletonLock` says. Both files outlive the browser (a crash leaves the lock, a clean
exit leaves the port file), so neither is trusted without confirming against the system.
7. **Never quit a browser unprompted.** Takeover needs a TTY answer or an explicit
`--takeover`. Agents and other non-interactive callers fall back to a separate profile.
8. **SIGTERM, never SIGKILL, for a browser.** Chromium treats SIGTERM as a clean shutdown;
SIGKILL risks a corrupted profile and loses the user's tabs. A browser that will not
quit is reported, not forced.

### Caller-contract invariants

11. **Exit codes are a public interface.** `EXIT_CODES` in `cli.ts` is documented in
`README.md` and `SKILL.md`, and agents branch on it. Changing a mapping is a breaking
change; adding an `ErrorCode` means adding its exit code too.
12. **`AUTH_REQUIRED` is distinct from `BROWSER_ERROR`.** "Ask the user" (4) and "the
environment is broken" (3) call for different responses, so entitlement failures must
not be folded back into `BROWSER_ERROR`.

### Configuration invariants

9. **Config is a cache, not a prerequisite.** An absent config means "detect it now", never
"fail" or "tell the user to run setup". `ensureConfigured` runs before every
browser-touching command and works identically under an agent.
10. **Headless stays the default without a configured browser.** Headed is chosen only when
an Opera binary is configured, because Opera AI sign-in needs a window. Machines with
no display and no Opera — CI, Docker, the openclaw sidecar — must keep working.
`OPERA_CLI_HEADED=0`/`=1` overrides either way.

## Benchmarks

Token-cost and agentic-quality measurements live in `benchmarks/`. See `benchmarks/CLAUDE.md` for file roles and how to run them.
Expand All @@ -26,36 +80,21 @@ Always check there before starting implementation work.

| Spec | Status |
|---|---|
| [`specs/robustness-hardening.md`](specs/robustness-hardening.md) | Planned — self-healing bridge, zero-config first run, graceful error handling |
| [`specs/fix-parallel-streaming-routing.md`](specs/fix-parallel-streaming-routing.md) | Planned — parallel chunk routing for concurrent Opera AI calls |
| [`specs/chat-model-selector.md`](specs/chat-model-selector.md) | Planned — model selector for chat command |

## Common issues

### Stale bridge process after update (`BRIDGE_NOT_READY` / "different server")

**Symptom:** `opera-browser-cli` commands fail with:
```
error: Port 9224 is in use by a different server (not opera-devtools-mcp).
code: BRIDGE_NOT_READY
```
even though the bridge is running (`lsof -i :9224` shows a `node` process).
### Stale bridge after a rebuild — resolved as of M1

**Cause:** The bridge process was started before `dist/src/bridge.js` was rebuilt. The
running process has old code in memory; its `/health` response is missing the
`server: "opera-browser-cli"` field that `checkPortStatus` (`client.ts`) requires to
recognise the bridge as healthy. Without that field the port is classified as a conflict.
This used to require `opera-browser-cli stop`, or `lsof -ti :9224 | xargs kill` when the
PID file was missing. It no longer does: `/health` carries the package version, and a
bridge running different code is shut down and replaced automatically on the next
command. `opera-browser-cli status` shows the skew if you want to see it happen.

**Fix:** Restart the bridge:
```sh
opera-browser-cli stop
# next command auto-starts a fresh bridge with current code
```

If `stop` does nothing (the bridge was started without a PID file, or the PID file was
deleted), kill it by port instead:
```sh
lsof -ti :9224 | xargs kill
```
If a bridge ever does get wedged, `opera-browser-cli restart` is the one command to
reach for — it escalates to SIGKILL and clears any stale PID file.

## Architecture notes

Expand Down
114 changes: 98 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@ It wraps [opera-devtools-mcp](https://github.com/operasoftware/opera-devtools-mc

```sh
npm install -g opera-browser-cli
opera-browser-cli setup # interactive wizard — run in a terminal where you can answer prompts
opera-browser-cli open https://example.com
```

That is the whole setup. The first command detects your Opera installation,
writes `~/.opera-browser-cli/config`, and gets on with it. Run
`opera-browser-cli setup` only when you want to change what it chose.

Once installed, `open` navigates to a URL and returns a structured snapshot you can act on:

```sh
Expand Down Expand Up @@ -64,29 +67,38 @@ Prerequisites: **Node.js >= 20**, **Opera** browser ([Opera Neon](https://www.op
npm install -g opera-browser-cli
```

Run first-time setup — this is an interactive wizard, so run it in a terminal where you can answer prompts:
No setup step is required. The first command you run detects your Opera
installation, writes `~/.opera-browser-cli/config`, and continues:

```sh
opera-browser-cli setup
opera-browser-cli --version
opera-browser-cli open https://example.com
```

This detects Opera installations, lets you pick one, saves configuration to `~/.opera-browser-cli/config`, and installs the skill to `~/.claude/skills/opera-browser-cli/SKILL.md` (Claude Code) and `~/.agents/skills/opera-browser-cli/SKILL.md` (generic cross-agent path used by Codex and other agents).

Verify:
`setup` exists for when you want to change that choice — pick a different
browser or profile, or install the agent skill files:

```sh
opera-browser-cli --version
opera-browser-cli open https://example.com
opera-browser-cli setup # interactive wizard
opera-browser-cli setup -y # detect and accept, no prompts
opera-browser-cli setup --executable "/Applications/Opera Neon.app/Contents/MacOS/Opera" \
--profile skip --headless
```

It saves to `~/.opera-browser-cli/config` and installs the skill to
`~/.claude/skills/opera-browser-cli/SKILL.md` (Claude Code) and
`~/.agents/skills/opera-browser-cli/SKILL.md` (generic cross-agent path used by
Codex and other agents). The non-interactive form needs no terminal, so agents
and provisioning scripts can run it too.

### From source

```sh
# in this repo
npm install && npm run build && npm link
```

Then run `opera-browser-cli setup` as above.
Then just run a command — configuration happens on first use.

### Usage examples

Expand Down Expand Up @@ -127,7 +139,7 @@ OPERA_CLI_BROWSER_URL=http://127.0.0.1:9222 opera-browser-cli open https://examp
```

- **Persistent bridge** — a detached process keeps the MCP session alive across commands, so Chrome doesn't restart every invocation
- **Auto-lifecycle** — the bridge starts on first command and writes a PID file to `~/.opera-browser-cli/bridge.pid`
- **Auto-lifecycle** — the bridge starts on first command, writes a PID file to `~/.opera-browser-cli/bridge.pid`, and restarts itself on version skew or a dropped connection
- **Snapshot parsing** — accessibility tree snapshots are extracted and analyzed for interactive elements (`uid=` refs)
- **TOON encoding** — structured metadata uses [TOON format](https://www.npmjs.com/package/@toon-format/toon) for compact, token-efficient output

Expand Down Expand Up @@ -219,14 +231,83 @@ opera-browser-cli eval "(() => { const rows = [...document.querySelectorAll('tr'
|----------|--------------------------------------------------|
| `setup` | Interactive first-time setup (browser path, etc) |
| `doctor` | Check configuration and environment |
| `doctor --fix` | Repair what can be repaired mechanically |
| `login` | Sign in to your Opera account (needed for AI) |
| `logs` | Show bridge server logs |

### Using your real Opera profile

By default the CLI launches its own browser. To drive **your** Opera — with your
logins, your session — the browser has to have been started with a debugging
port. That flag cannot be added to a browser that is already open, so there are
two ways in:

```sh
opera-browser-cli launch-args # prints the command to start Opera with a port
```

Start Opera that way once, and every later command finds it automatically — the
port is recorded in `DevToolsActivePort` inside the profile, so nothing needs
configuring. Or let the CLI do it for you:

```sh
opera-browser-cli open example.com # detects the conflict, offers to restart Opera
opera-browser-cli open example.com --takeover # skip the prompt (scripts, agents)
```

If Opera is already running on the configured profile and has no debugging port,
the CLI asks whether to restart it (tabs are restored). Without a terminal to ask
in, it quietly uses a separate profile instead — an agent will never quit your
browser on its own. Restarting is always SIGTERM, never SIGKILL: a forced kill
risks a corrupted profile.

```sh
opera-browser-cli attach --port 9222 # connect to a specific endpoint
opera-browser-cli attach --clear # go back to a CLI-launched browser
```

> **Note:** a debugging port has no authentication of its own — the CLI's bearer
> token protects the bridge, not the browser. Any local process can drive a
> browser with an open port, and this one is signed into everything you are. The
> CLI lets the browser pick a random port rather than a predictable 9222, binds
> it to loopback, and never passes `--remote-allow-origins`, which is what stops
> a web page from driving it. Close the browser when you are done.

### Bridge

| Command | Description |
|---------|-------------------------|
| `start` | Start the bridge server |
| `stop` | Stop the bridge server |
| Command | Description |
|-----------|--------------------------------------------------------------------|
| `start` | Start the bridge server |
| `stop` | Stop the bridge server (escalates to SIGKILL; clears a stale PID) |
| `restart` | Stop and start again — forces a clean state |
| `status` | Report bridge pid, port, and running version without starting one |

You should rarely need any of these. The bridge starts on first use, and repairs
itself without being asked:

- **Upgraded package** — a bridge running pre-upgrade code is detected by version
and replaced on the next command.
- **Crashed or killed bridge** — the next command restarts it and retries. Opera AI
commands are the exception: they are never silently re-run, since they may already
have acted on the page.
- **Port in use** — the next port in the range is used instead of failing.
- **Several commands at once** — a start lock means exactly one bridge comes up.
- **Stale PID file** — cleared automatically, and never signalled if the PID could
belong to an unrelated process from before a reboot.

### Exit codes

Scripts and agents can branch on why a command failed without parsing messages:

| Code | Meaning | Caller action |
|---|---|---|
| 0 | Success | — |
| 1 | Unknown / internal | Report |
| 2 | Bad arguments, or unsupported on this browser | Fix the command |
| 3 | Environment not ready after auto-recovery | Run `doctor` |
| 4 | Sign-in, subscription, or consent required | Ask the user |
| 5 | Timed out | Retry |
| 6 | Stale element ref or closed page | Re-snapshot, then retry |

Running with no command shows the CLI home view. It prepends `bin` and
`description` metadata, then includes the current snapshot when a browser
Expand Down Expand Up @@ -265,14 +346,15 @@ session is active or the no-session status/help block when one is not.

| Variable | Default | Purpose |
|-----------------------------|----------------------------------|------------------------------------------------------------------|
| `OPERA_CLI_PORT` | `9225` | Bridge server port |
| `OPERA_CLI_PORT` | `9225` | Base bridge port; the next 9 are tried if it is occupied |
| `OPERA_CLI_MCP_BIN` | _(bundled `opera-devtools-mcp`)_ | Override the MCP server binary |
| `OPERA_CLI_EXECUTABLE_PATH` | _(system Chrome)_ | Custom browser binary |
| `OPERA_CLI_BROWSER_URL` | — | Connect to an existing browser instance instead of launching one |
| `OPERA_CLI_USER_DATA_DIR` | — | Persistent Chrome profile directory (skips isolated mode) |
| `OPERA_CLI_HEADED` | — | Set to `1` to run in headed (visible) mode |
| `OPERA_CLI_HEADED` | `1` when an Opera binary is configured | `1` headed, `0` headless. Opera AI needs a window to sign in |
| `OPERA_CLI_CHROME_ARGS` | — | Extra Chrome flags, space-separated |
| `OPERA_CLI_ENABLE_HOOKS` | — | Set to `1` to auto-install session hooks on startup |
| `OPERA_CLI_TAKEOVER` | — | Set to `1` to restart a running Opera without asking |

State is stored in `~/.opera-browser-cli/`:

Expand Down
33 changes: 31 additions & 2 deletions SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: opera-browser-cli
description: Browser automation and web interaction using the opera-browser-cli tool. Use for navigating pages, clicking elements, filling forms, taking screenshots, inspecting console/network, running performance audits, and Opera AI features (chat available on any Opera browser; invoke-do, make, research require Opera Neon).
description: Browser automation and web interaction using the opera-browser-cli tool. Use for navigating pages, clicking elements, filling forms, taking screenshots, inspecting console/network, running performance audits, and Opera AI features (chat available on any Opera browser; invoke-do, make, research require Opera Neon). When a browser is already running without automation enabled, this tool can restart it with a debug port (takeover) or use a separate profile — ask the user which they prefer.
metadata: {"openclaw": {"requires": {"bins": ["opera-browser-cli"]}}}
---

Expand Down Expand Up @@ -47,6 +47,35 @@ Commands that accept these flags: `open`, `snapshot`, `click`, `fill`, `type`, `

To wire this CLI into a Docker-based OpenClaw setup (Chromium sidecar, shared netns, config bootstrap), see [`openclaw/README.md`](openclaw/README.md).

## Exit codes

Branch on the exit code rather than parsing messages:

| Code | Meaning | What to do |
|---|---|---|
| 0 | Success | — |
| 2 | Bad arguments, or the browser cannot do this | Fix the command; do not retry as-is |
| 3 | Environment not ready after auto-recovery | Run `opera-browser-cli doctor` |
| 4 | Sign-in, subscription, or consent needed | Ask the user — you cannot fix this |
| 5 | Timed out | Retry |
| 6 | Stale element ref or closed page | Re-run `snapshot`, then retry with fresh refs |
| 1 | Anything else | Report it |

## Recovery is automatic

The bridge restarts itself on version skew, a crash, or a dropped connection, and falls back to another port if one is taken. Do not run `stop`/`restart` speculatively — just re-run the command. The exception is the Opera AI tools (`invoke-do`, `make`, `research`, `chat`): if one reports the connection dropped mid-call, it was **not** retried, because it may already have acted. Ask before re-running it.

## When the browser can't be automated

The CLI only drives a browser started with a debug port. If the user's Opera is already open **without** one, the CLI can't attach to that window — `open`/AI then fail with **"Could not connect to Chrome"**. Run `opera-browser-cli doctor` (it reports the profile state); the bridge self-heals, so don't restart it blindly.

**Always ask the user** — restarting their browser is their call, not a judgement you infer:
> "Your Opera is open but wasn't started with automation. May I restart it with a debug port (tabs restored)? Or should I use a separate profile (you'd sign in there)?"

- **They approve restart** → run with `--takeover` (or `OPERA_CLI_TAKEOVER=1`). Restarts with a debug port, restores tabs, attaches — drives the real browser thereafter.
- **They decline** → no flag → separate profile at `~/.opera-browser-cli/profile` (they sign in there; AI may then return exit `4` — surface it).
- `opera-browser-cli launch-args` prints the flags to start Opera attachable so a restart is never needed later.

## Sign-in errors

If you hit `Opera: user is not signed in` on an AI command, suggest signing in to their Opera account. Run `opera-browser-cli setup` or `opera-browser-cli doctor` to configure or diagnose.
If an AI command fails with `AUTH_REQUIRED` (exit code 4) — not signed in, no subscription, or consent pending — tell the user to run `opera-browser-cli login`, which opens the Opera account page in a visible window. `opera-browser-cli login --check` verifies the current state. Run `opera-browser-cli doctor` to diagnose anything else.
Loading
Loading