barebrowse 0.7.1 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,11 @@
1
1
  # barebrowse -- Integration Guide
2
2
 
3
3
  > For AI assistants and developers wiring barebrowse into a project.
4
- > v0.7.1 | Node.js >= 22 | 0 required deps | MIT
4
+ > v0.9.1 | Node.js >= 22 | 0 required deps | Apache-2.0
5
5
 
6
6
  ## What this is
7
7
 
8
- barebrowse is a CDP-direct browsing library for autonomous agents (~1,800 lines). URL in, pruned ARIA snapshot out. It launches the user's installed Chromium browser, navigates, handles consent/permissions/cookies, and returns a token-efficient ARIA tree with `[ref=N]` markers for interaction.
8
+ barebrowse is a CDP-direct browsing library for autonomous agents (~3,600 lines in `src/` across 14 modules). URL in, pruned ARIA snapshot out. It launches the user's installed Chromium browser (or attaches to one already running), navigates, handles consent/permissions/cookies, walks iframes, captures downloads, and returns a token-efficient ARIA tree with `[ref=N]` markers for interaction.
9
9
 
10
10
  No Playwright. No bundled browser. No build step. Vanilla JS, ES modules.
11
11
 
@@ -25,6 +25,9 @@ Three integration paths:
25
25
  | `headless` (default) | Launches a fresh Chromium, no UI | Scraping, reading, fast automation |
26
26
  | `headed` | Auto-launches a visible Chromium window | Bot-detected sites, debugging, visual tasks |
27
27
  | `hybrid` | Tries headless first, headed fallback per-navigation (switches back to headless next time) | General-purpose agent browsing |
28
+ | `connect({ port })` (attach) | Attaches to a Chromium *you* started with `--remote-debugging-port=N` — your real logged-in profile, no clone | When you need the user's real session (auth cookies, localStorage, IndexedDB). `close()` only kills the tab we opened, not the browser. |
29
+
30
+ Attach mode skips three things vs. spawn modes: stealth patches (would persist via `addScriptToEvaluateOnNewDocument`), `Browser.setPermission` calls (browser-wide — would leak deny-states into the user's other tabs), and `Browser.setDownloadBehavior` (don't override the user's download preference). Stealth is unnecessary anyway because the user's real browser doesn't look headless.
28
31
 
29
32
  ## Minimal usage: one-shot browse
30
33
 
@@ -55,6 +58,7 @@ const snapshot = await browse('https://example.com', {
55
58
  | `goto(url, timeout?)` | url: string, timeout: number (default 30000) | void | Navigate + wait for load + dismiss consent |
56
59
  | `goBack()` | -- | void | Navigate back in browser history |
57
60
  | `goForward()` | -- | void | Navigate forward in browser history |
61
+ | `reload(opts?)` | { ignoreCache?: boolean, timeout?: number } | void | Reload the current page. Clears refMap (refs from pre-reload reject). |
58
62
  | `snapshot(pruneOpts?)` | false or { mode: 'act'\|'read' } | string | ARIA tree with `[ref=N]` markers. Pass `false` for raw. |
59
63
  | `click(ref)` | ref: string | void | Scroll into view + mouse press+release at center |
60
64
  | `type(ref, text, opts?)` | ref: string, text: string, opts: { clear?, keyEvents? } | void | Focus + insert text. `clear: true` replaces existing. |
@@ -73,16 +77,20 @@ const snapshot = await browse('https://example.com', {
73
77
  | `waitForNetworkIdle(opts?)` | { timeout?: number, idle?: number } | void | Wait until no pending requests for `idle` ms (default 500) |
74
78
  | `saveState(filePath)` | filePath: string | void | Export cookies + localStorage to JSON file |
75
79
  | `injectCookies(url, opts?)` | url: string, { browser?: string } | void | Extract cookies from user's browser and inject via CDP |
76
- | `botBlocked` | -- | boolean | True if last `goto()` hit a bot challenge (ARIA node count <50). Resets on each navigation. |
80
+ | `botBlocked` | -- | boolean | True if last `goto()` hit a bot challenge. Heuristic tightened in v0.9.0 (H9): Cloudflare-strong phrases fire alone; generic phrases ("access denied"/"unknown error") only fire on near-empty pages. Resets on each navigation. |
77
81
  | `dialogLog` | -- | Array<{type, message, timestamp}> | Auto-dismissed JS dialog history |
78
- | `cdp` | -- | object | Raw CDP session for escape hatch: `page.cdp.send(method, params)` |
82
+ | `onDialog(handler)` | handler: ({type, message, defaultPrompt}) => {accept, promptText} \| undefined, or null to remove | void | Override the auto-accept default. Handler receives the dialog params; return `{accept: false}` to cancel, `{accept: true, promptText: 'x'}` to supply prompt input. Pass `null` to restore defaults. |
83
+ | `downloads` | -- | Array<{guid, url, suggestedFilename, savedPath, state, totalBytes, receivedBytes}> | Live array of every `Content-Disposition: attachment` download captured during this session. `state`: `inProgress` → `completed` \| `canceled`. |
84
+ | `cdp` | -- | object | Raw CDP session (getter — survives hybrid fallback and switchTab) for escape hatch: `page.cdp.send(method, params)` |
79
85
  | `createTab()` | -- | tab handle | New tab in same browser. Returns `{ goto, botBlocked, injectCookies, waitForNetworkIdle, cdp, close }`. Tab close doesn't affect session. |
80
86
  | `close()` | -- | void | Close page, disconnect CDP, kill browser (if headless) |
81
87
 
82
88
  **connect() options** (in addition to mode/port/consent):
89
+ - `port: 9222` — Attach to a Chromium already running with `--remote-debugging-port=N` instead of spawning one. The browser keeps running on `close()`. Stealth + permission denial + download capture are skipped to avoid mutating the user's running browser.
83
90
  - `proxy: 'http://...'` — HTTP/SOCKS proxy for browser
84
91
  - `viewport: '1280x720'` — Set viewport dimensions
85
92
  - `storageState: 'file.json'` — Load cookies/localStorage from saved state
93
+ - `downloadPath: '/abs/dir'` — Where downloads land. Default: per-session `mkdtemp` under `/tmp/barebrowse-dl-*` that gets removed on `close()`. Caller-supplied paths are not cleaned up — caller owns the lifecycle.
86
94
 
87
95
  ## Snapshot format
88
96
 
@@ -152,8 +160,10 @@ barebrowse can inject cookies from the user's real browser sessions, bypassing l
152
160
  | Off-screen elements | `DOM.scrollIntoViewIfNeeded` before every click, JS `.click()` fallback for no-layout elements | Both |
153
161
  | Form submission | `press('Enter')` triggers onsubmit | Both |
154
162
  | SPA navigation | `waitForNavigation()` uses loadEventFired + frameNavigated | Both |
155
- | Bot detection | ARIA node count (<50 = bot-blocked) + text heuristics. `botBlocked` flag set after every `goto()`. Hybrid fallback switches to headed. Snapshot shows `[BOT CHALLENGE DETECTED]` warning. | Hybrid |
156
- | `navigator.webdriver` | Stealth patches in headless (webdriver, plugins, chrome obj) | Headless |
163
+ | Bot detection | v0.9.0 (H9): Cloudflare-strong phrases ("Just a moment", "Attention Required", "verify you are human") fire alone; generic phrases ("access denied", "unknown error") only fire on near-empty pages — no more false-positive headed-launches on legitimate 4xx/5xx pages. `botBlocked` flag set after every `goto()`. Hybrid fallback switches to headed. Snapshot shows `[BOT CHALLENGE DETECTED]` warning. | Hybrid |
164
+ | Stealth (headless tells) | v0.9.0 (H4): `Network.setUserAgentOverride` strips "HeadlessChrome" from UA in HTTP headers AND `navigator.userAgent`; JS patches for webdriver, plugins, languages, full `chrome.runtime` enum shape, `Notification` constructor + `permission: 'default'`, `hardwareConcurrency: 8`, `deviceMemory: 8`, WebGL `UNMASKED_VENDOR_WEBGL`/`UNMASKED_RENDERER_WEBGL` spoofed to Intel | Headless |
165
+ | iframe / OOPIF content (Stripe, reCAPTCHA, embedded forms) | v0.9.0 (H2): `Target.setAutoAttach({flatten:true})` registers a CDP session per iframe; `ariaTree()` walks `Page.getFrameTree`, fetches each frame's AX tree on the right session, splices children under iframe placeholders via `DOM.getFrameOwner`. Refs route via `{session, backendNodeId}` so clicks dispatch in the iframe's Input domain. `--site-per-process` launch flag forces every iframe — including same-origin — into OOPIF so coords work. | Both |
166
+ | Downloads | v0.9.0 (H7): `Browser.setDownloadBehavior({behavior:'allowAndName', downloadPath, eventsEnabled:true})` + listeners populate `page.downloads`. Files land at `savedPath` (under `--download-path` if supplied, else per-session `/tmp/barebrowse-dl-*`). | Headless + Headed (skipped in attach mode) |
157
167
  | Profile locking | Unique temp dir per headless instance | Headless |
158
168
  | Shared memory crash (Linux) | `--disable-dev-shm-usage` flag prevents `/dev/shm` exhaustion | Headless |
159
169
  | ARIA noise | 9-step pruning: wrapper collapse, noise removal, landmark promotion | Both |
@@ -184,10 +194,12 @@ try {
184
194
  ```
185
195
 
186
196
  `createBrowseTools(opts)` returns:
187
- - `tools` -- array of bareagent-compatible tool objects (browse, goto, snapshot, click, type, press, scroll, select, hover, back, forward, drag, upload, tabs, switchTab, pdf, screenshot, plus assess if wearehere installed)
197
+ - `tools` -- array of bareagent-compatible tool objects: `browse`, `goto`, `snapshot`, `click`, `type`, `press`, `scroll`, `select`, `hover`, `back`, `forward`, `reload` (v0.9.0), `drag`, `upload`, `tabs`, `switchTab`, `pdf`, `screenshot`, `wait_for` (v0.9.0), `downloads` (v0.9.0), plus `assess` if wearehere installed
188
198
  - `close()` -- cleanup function, call when done
189
199
 
190
- Action tools (click, type, press, scroll, hover, goto, back, forward, drag, upload, select, switchTab) auto-return a fresh snapshot so the LLM always sees the result. 300ms settle delay after actions for DOM updates.
200
+ Action tools (click, type, press, scroll, hover, goto, back, forward, reload, drag, upload, select, switchTab, wait_for) auto-return a fresh snapshot so the LLM always sees the result. 300ms settle delay after actions for DOM updates.
201
+
202
+ `onDialog` is intentionally not exposed as a tool — it's a callback shape that doesn't fit a request/response tool loop. If your bareagent flow needs to override a confirm/prompt, drop to `import { connect }` directly and pass the page through.
191
203
 
192
204
  ## CLI session mode
193
205
 
@@ -199,6 +211,8 @@ barebrowse snapshot # → .barebrowse/page-<timestamp>.yml
199
211
  barebrowse click 8 # Click element ref=8
200
212
  barebrowse type 12 hello world # Type into element ref=12
201
213
  barebrowse back # Go back in history
214
+ barebrowse reload [--no-cache] # v0.9.0 — reload current page (bypass cache optional)
215
+ barebrowse downloads # v0.9.0 — JSON array of captured downloads (savedPath, state...)
202
216
  barebrowse upload 7 /path/to/file.pdf # Upload file to file input
203
217
  barebrowse pdf # → .barebrowse/page-<timestamp>.pdf
204
218
  barebrowse wait-for --text="Success" # Wait for content to appear
@@ -207,7 +221,7 @@ barebrowse save-state # → .barebrowse/state-<timestamp>.json
207
221
  barebrowse close # Kill daemon + browser
208
222
  ```
209
223
 
210
- **Open flags:** `--mode=headless|headed|hybrid`, `--proxy=URL`, `--viewport=WxH`, `--storage-state=FILE`, `--no-cookies`, `--browser=firefox|chromium`, `--timeout=N`
224
+ **Open flags:** `--mode=headless|headed|hybrid`, `--port=N` (attach to running browser), `--proxy=URL`, `--viewport=WxH`, `--storage-state=FILE`, `--download-path=DIR` (v0.9.0), `--no-cookies`, `--browser=firefox|chromium`, `--timeout=N`
211
225
 
212
226
  Session lifecycle: `open` spawns a background daemon holding a `connect()` session. Subsequent commands POST to the daemon over HTTP (localhost). `close` shuts everything down. JS dialogs (alert/confirm/prompt) are auto-dismissed and logged.
213
227
 
@@ -219,7 +233,9 @@ barebrowse ships an MCP server for direct use with Claude Desktop, Cursor, or an
219
233
 
220
234
  **Claude Code:** `claude mcp add barebrowse -- npx barebrowse mcp`
221
235
 
222
- **Claude Desktop / Cursor:** `npx barebrowse install` (auto-detects and writes config)
236
+ **Claude Desktop / Cursor:** `npx barebrowse install` (auto-detects and writes config; pass `--force` to overwrite an existing entry pointing at a different endpoint)
237
+
238
+ **Diagnose scope conflicts:** `npx barebrowse doctor` scans every known MCP config location (Claude Code user/project/local, Claude Desktop, Cursor, VS Code) and prints which `barebrowse` entries are registered + where they point. Flags `CONFLICT` when two scopes point at different paths — OAuth tokens are stored per endpoint, so a split silently breaks auth. The MCP server itself also writes a one-line banner to stderr at startup (`barebrowse mcp v<X.Y.Z> | serving from <abs path> | pid <N>`) so a stuck agent is diagnosable from the MCP client log.
223
239
 
224
240
  **Manual config** (`claude_desktop_config.json`, `.cursor/mcp.json`):
225
241
  ```json
@@ -233,15 +249,17 @@ barebrowse ships an MCP server for direct use with Claude Desktop, Cursor, or an
233
249
  }
234
250
  ```
235
251
 
236
- 12 core tools: `browse` (one-shot), `goto`, `snapshot`, `click`, `type`, `press`, `scroll`, `back`, `forward`, `drag`, `upload`, `pdf`. Plus `assess` (privacy scan) if `wearehere` is installed (`npm install wearehere`).
252
+ 18 core tools as of v0.9.0: `browse` (one-shot), `goto`, `snapshot`, `click`, `type`, `press`, `scroll`, `hover`, `select`, `back`, `forward`, `reload`, `drag`, `upload`, `pdf`, `screenshot`, `wait_for`, `tabs`. Plus `assess` (privacy scan) if `wearehere` is installed (`npm install wearehere`). Plus the **opt-in `eval` tool** gated by `BAREBROWSE_MCP_EVAL=1` (default OFF) — `Runtime.evaluate` in the user's authenticated session can read cookies/localStorage and hit any same-origin endpoint, so opt-in only.
237
253
 
238
254
  Action tools return `'ok'` -- the agent calls `snapshot` explicitly to observe. This avoids double-token output since MCP tool calls are cheap to chain.
239
255
 
240
- `browse` and `snapshot` accept a `maxChars` param (default 30000). If the snapshot exceeds the limit, it's saved to `.barebrowse/page-<timestamp>.yml` and a short message with the file path is returned instead.
256
+ `browse` and `snapshot` accept a `maxChars` param (default 30000). If the snapshot exceeds the limit, it's saved to `.barebrowse/page-<timestamp>.yml` and a short message with the file path is returned instead. `screenshot` always saves to `.barebrowse/screenshot-<timestamp>.{png,jpeg,webp}` and returns the file path (raw base64 in a JSON-RPC response would blow `maxChars`). `tabs` returns the JSON array, or with `switchTo: N` it switches and returns `'ok'`.
257
+
258
+ `browse` and `snapshot` also accept `pruneMode: 'act'|'read'`. `act` (the default) keeps interactive elements and short labels — best for clicking/filling. `read` keeps paragraphs, headings, and long text — best for articles, docs, and content extraction. Same surface on the bareagent adapter. If act mode collapses a content-heavy page (raw > 5 KB → pruned < 500 chars AND < 5% of raw), the result includes a `hint: act mode dropped most of the page — retry with pruneMode='read' …` line between the stats and the tree so the caller knows to re-snapshot in read mode instead of bailing to a separate HTTP fetch.
241
259
 
242
260
  Session runs in hybrid mode (headless with automatic headed fallback on bot detection). `goto` injects cookies from the user's browser before navigation for authenticated access.
243
261
 
244
- Session tools share a singleton page, lazy-created on first use. All session tools have auto-retry on transient failures (browser crash, WebSocket close, navigation timeout) each attempt gets its own 30s deadline, session resets between attempts, retries once automatically. Scroll accepts `direction: "up"/"down"` in addition to numeric `deltaY`. Click falls back to JS `.click()` when elements have no layout. `browse` has a 60s timeout (no retry — stateless). Assess tries headless first; if bot-blocked, retries headed. Browser OOM/crash auto-recovers (session resets, server stays alive).
262
+ Session tools share a singleton page, lazy-created on first use. All session tools have auto-retry on transient failures (browser crash, WebSocket close, navigation timeout) on a per-tool deadline (v0.9.0 H5): `goto`/`reload`/`wait_for` 60s, `back`/`forward` 30s, interactive ops (`click`/`type`/`press`/`scroll`/`hover`/`select`/`drag`/`snapshot`/`eval`) 15s, `tabs` 5s, heavy I/O (`pdf`/`screenshot`/`upload`) 45s — replaces the prior blanket 30s. Session resets between attempts. Idempotent tools retry once; mutating tools (`click`/`type`/`upload`/etc.) `{ retry: false }` so partial first attempts don't replay on a fresh page. Scroll accepts `direction: "up"/"down"` in addition to numeric `deltaY`. Click falls back to JS `.click()` when elements have no layout. `browse` has a 60s timeout (no retry — stateless). Assess tries headless first; if bot-blocked, retries headed. Browser OOM/crash auto-recovers (session resets, server stays alive).
245
263
 
246
264
  ## Architecture
247
265
 
@@ -261,17 +279,18 @@ URL -> chromium.js (find/launch browser, permission flags)
261
279
 
262
280
  | Module | Lines | Purpose |
263
281
  |---|---|---|
264
- | `src/index.js` | ~370 | Public API: `browse()`, `connect()`, screenshot, network idle, hybrid |
282
+ | `src/index.js` | ~940 | Public API: `browse()`, `connect()`, attach mode, iframe frame-tree walking, downloads, onDialog, isChallengePage |
265
283
  | `src/cdp.js` | 148 | WebSocket CDP client, flattened sessions |
266
- | `src/chromium.js` | 148 | Find/launch Chromium browsers, permission-suppressing flags |
284
+ | `src/chromium.js` | ~160 | Find/launch Chromium browsers, `attach({port})`, `cleanupBrowser`, permission-suppressing flags, `--site-per-process` |
267
285
  | `src/aria.js` | 69 | Format ARIA tree as text |
268
286
  | `src/auth.js` | 279 | Cookie extraction (Chromium AES + keyring, Firefox), CDP injection |
269
287
  | `src/prune.js` | 472 | ARIA pruning pipeline (ported from mcprune) |
270
288
  | `src/interact.js` | ~170 | Click, type, press, scroll, hover, select |
271
289
  | `src/consent.js` | 200 | Auto-dismiss cookie consent dialogs across languages |
272
- | `src/stealth.js` | ~40 | Navigator patches for headless anti-detection |
273
- | `src/bareagent.js` | ~250 | Tool adapter for bareagent Loop |
274
- | `mcp-server.js` | ~340 | MCP server (JSON-RPC over stdio, assess session reuse + concurrency) |
290
+ | `src/stealth.js` | ~110 | UA override + JS patches (webdriver, WebGL, hardware, Notification, chrome.runtime) |
291
+ | `src/network-idle.js` | ~50 | Set-based network-idle wait (extracted in v0.8.0, F9) |
292
+ | `src/bareagent.js` | ~330 | Tool adapter for bareagent Loop (21 tools) |
293
+ | `mcp-server.js` | ~660 | MCP server (JSON-RPC over stdio, `runStdio()`, `TIMEOUTS`/`TOOLS` exports, opt-in eval, assess session reuse + concurrency) |
275
294
 
276
295
  ## Privacy assessment (optional)
277
296
 
@@ -323,6 +342,14 @@ Useful for agent threshold decisions: "skip sites above score 40", "warn if term
323
342
 
324
343
  10. **Chromium-only.** CDP protocol limits us to Chrome, Chromium, Edge, Brave, Vivaldi (~80% desktop share). Firefox support via WebDriver BiDi is not yet implemented.
325
344
 
345
+ 11. **`--site-per-process` is on by default (v0.9.0).** Required for iframe support — without it, same-origin iframes stay in the parent process and `Input.dispatchMouseEvent` coords don't match `DOM.getBoxModel` coords for iframe-internal elements. Memory cost: +50-150MB per cross-origin frame. Real Chrome does this for cross-origin by default; we just extend it to all iframes. If you attach via `connect({port})`, the user's browser is whatever they launched it as — for iframe interaction reliability, start it with `--site-per-process` too.
346
+
347
+ 12. **Attach mode (`connect({port})`) skips three things on purpose.** No stealth (would inject persistent JS via `addScriptToEvaluateOnNewDocument`), no `Browser.setPermission` (browser-wide — would leak deny-states into the user's other tabs), no `Browser.setDownloadBehavior` (don't override the user's download preference). The trade-off: `page.downloads` is always empty in attach mode. If you need download capture in an attached session, start the browser with `--remote-debugging-port=N` *and* configure download preferences in the browser UI first.
348
+
349
+ 13. **Refs are globally flat across frames.** v0.9.0 (H2) assigns refs from a shared counter across the merged frame tree, so a `[ref=42]` from an iframe and a `[ref=43]` from the parent come from one address space. The visible `[ref=N]` format is unchanged. refMap stores `{session, backendNodeId}` so `click(ref)` automatically dispatches in the right frame's session.
350
+
351
+ 14. **`eval` MCP tool is opt-in.** Set `BAREBROWSE_MCP_EVAL=1` to register it. Default off because `Runtime.evaluate` in an authenticated session can read cookies/localStorage, post on the user's behalf, hit any same-origin endpoint. CLI/connect()/daemon all keep `eval` because the developer is the caller; MCP gates it because the agent acts with less judgment.
352
+
326
353
  ## Constraints
327
354
 
328
355
  - **Node >= 22** -- built-in WebSocket, built-in SQLite
package/cli.js CHANGED
@@ -17,9 +17,15 @@ const cmd = args[0];
17
17
  if (args.includes('--daemon-internal')) {
18
18
  await runDaemonInternal();
19
19
  } else if (cmd === 'mcp') {
20
- await import('./mcp-server.js');
20
+ // Explicitly start the JSON-RPC loop — relying on the previous "isMain
21
+ // auto-start" guard inside mcp-server.js would silently hang here because
22
+ // process.argv[1] is cli.js, not mcp-server.js.
23
+ const { runStdio } = await import('./mcp-server.js');
24
+ runStdio();
21
25
  } else if (cmd === 'install') {
22
26
  install();
27
+ } else if (cmd === 'doctor') {
28
+ doctor();
23
29
  } else if (cmd === 'browse' && args[1]) {
24
30
  await oneShot();
25
31
  } else if (cmd === 'open') {
@@ -60,6 +66,10 @@ if (args.includes('--daemon-internal')) {
60
66
  await cmdProxy('back');
61
67
  } else if (cmd === 'forward') {
62
68
  await cmdProxy('forward');
69
+ } else if (cmd === 'reload') {
70
+ await cmdProxy('reload', { ignoreCache: hasFlag('--no-cache') });
71
+ } else if (cmd === 'downloads') {
72
+ await cmdProxy('downloads');
63
73
  } else if (cmd === 'drag' && args[1] && args[2]) {
64
74
  await cmdProxy('drag', { fromRef: args[1], toRef: args[2] });
65
75
  } else if (cmd === 'upload' && args[1] && args[2]) {
@@ -106,6 +116,7 @@ async function cmdOpen() {
106
116
  proxy: parseFlag('--proxy'),
107
117
  viewport: parseFlag('--viewport'),
108
118
  storageState: parseFlag('--storage-state'),
119
+ downloadPath: parseFlag('--download-path'),
109
120
  };
110
121
 
111
122
  try {
@@ -206,6 +217,7 @@ async function runDaemonInternal() {
206
217
  proxy: parseFlag('--proxy'),
207
218
  viewport: parseFlag('--viewport'),
208
219
  storageState: parseFlag('--storage-state'),
220
+ downloadPath: parseFlag('--download-path'),
209
221
  };
210
222
  const outputDir = parseFlag('--output-dir') || resolve('.barebrowse');
211
223
  const url = parseFlag('--url');
@@ -257,8 +269,33 @@ function install() {
257
269
  if (!config.mcpServers) config.mcpServers = {};
258
270
 
259
271
  if (config.mcpServers.barebrowse) {
260
- console.log(` ${target.name}: already configured`);
261
- installed++;
272
+ // Detect a stale entry pointing at a different location/command —
273
+ // common when a contributor has both a global install (`npx`) and
274
+ // a worktree-local entry (`node /abs/path/mcp-server.js`). OAuth
275
+ // tokens are stored per endpoint, so leaving the stale one means
276
+ // auth from one path silently won't carry over to the other.
277
+ const existing = config.mcpServers.barebrowse;
278
+ const sameEndpoint =
279
+ existing.command === mcpEntry.command &&
280
+ JSON.stringify(existing.args || []) === JSON.stringify(mcpEntry.args);
281
+ if (!sameEndpoint) {
282
+ if (hasFlag('--force')) {
283
+ config.mcpServers.barebrowse = mcpEntry;
284
+ writeFileSync(target.path, JSON.stringify(config, null, 2) + '\n');
285
+ console.log(` ${target.name}: REPLACED stale entry`);
286
+ console.log(` was: ${existing.command} ${(existing.args || []).join(' ')}`);
287
+ console.log(` now: ${mcpEntry.command} ${mcpEntry.args.join(' ')}`);
288
+ installed++;
289
+ } else {
290
+ console.log(` ${target.name}: CONFLICT — different endpoint already registered`);
291
+ console.log(` existing: ${existing.command} ${(existing.args || []).join(' ')}`);
292
+ console.log(` new: ${mcpEntry.command} ${mcpEntry.args.join(' ')}`);
293
+ console.log(` Pass --force to overwrite, or edit ${target.path} by hand.`);
294
+ }
295
+ } else {
296
+ console.log(` ${target.name}: already configured`);
297
+ installed++;
298
+ }
262
299
  continue;
263
300
  }
264
301
 
@@ -339,6 +376,74 @@ function readJsonOrEmpty(path) {
339
376
  }
340
377
  }
341
378
 
379
+ /**
380
+ * Scan every known MCP config location for a `barebrowse` entry and print
381
+ * what's there. Built for the Claude Code "Conflicting scopes" warning,
382
+ * which is generated when the same MCP server name resolves to different
383
+ * absolute endpoints across scopes — OAuth tokens are stored per-endpoint
384
+ * so a split silently breaks auth.
385
+ */
386
+ function doctor() {
387
+ const home = homedir();
388
+ const cwd = process.cwd();
389
+ const os = platform();
390
+
391
+ // (label, file path, key) — `key` is the top-level config key that holds
392
+ // the servers map. Claude Code / Desktop / Cursor use `mcpServers`; VS
393
+ // Code's .vscode/mcp.json uses `servers`.
394
+ const locations = [
395
+ ['Claude Code (user)', join(home, '.claude.json'), 'mcpServers'],
396
+ ['Claude Code (project)', join(cwd, '.mcp.json'), 'mcpServers'],
397
+ ['Claude Code (local)', join(cwd, '.claude.json'), 'mcpServers'],
398
+ ['VS Code (project)', join(cwd, '.vscode', 'mcp.json'), 'servers'],
399
+ ['Cursor (user)', join(home, '.cursor', 'mcp.json'), 'mcpServers'],
400
+ ];
401
+ // Claude Desktop varies by OS
402
+ if (os === 'darwin') {
403
+ locations.push(['Claude Desktop', join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'), 'mcpServers']);
404
+ } else if (os === 'linux') {
405
+ locations.push(['Claude Desktop', join(home, '.config', 'Claude', 'claude_desktop_config.json'), 'mcpServers']);
406
+ } else if (os === 'win32') {
407
+ locations.push(['Claude Desktop', join(home, 'AppData', 'Roaming', 'Claude', 'claude_desktop_config.json'), 'mcpServers']);
408
+ }
409
+
410
+ console.log('barebrowse doctor — scanning known MCP config locations:\n');
411
+ const findings = [];
412
+ for (const [label, path, key] of locations) {
413
+ if (!existsSync(path)) {
414
+ console.log(` - ${label.padEnd(22)} ${path} (not present)`);
415
+ continue;
416
+ }
417
+ const cfg = readJsonOrEmpty(path);
418
+ const entry = cfg[key]?.barebrowse;
419
+ if (!entry) {
420
+ console.log(` - ${label.padEnd(22)} ${path} (no barebrowse entry)`);
421
+ continue;
422
+ }
423
+ const sig = `${entry.command || '?'} ${(entry.args || []).join(' ')}`;
424
+ console.log(` ✓ ${label.padEnd(22)} ${path}`);
425
+ console.log(` endpoint: ${sig}`);
426
+ findings.push({ label, path, sig });
427
+ }
428
+
429
+ if (findings.length <= 1) {
430
+ console.log(`\n${findings.length} registration${findings.length === 1 ? '' : 's'} found. No scope conflict.`);
431
+ } else {
432
+ const unique = new Set(findings.map((f) => f.sig));
433
+ if (unique.size === 1) {
434
+ console.log(`\n${findings.length} registrations found, all pointing at the same endpoint. No conflict.`);
435
+ } else {
436
+ console.log(`\n⚠ CONFLICT: ${findings.length} registrations across ${unique.size} different endpoints.`);
437
+ console.log(` Claude Code stores OAuth tokens per endpoint — authenticating in one scope`);
438
+ console.log(` will not carry over to the other. Recommended fix: keep one, remove the rest.\n`);
439
+ console.log(` Claude Code: claude mcp remove barebrowse -s user (or -s project / -s local)`);
440
+ console.log(` Other clients: edit the JSON file shown above and delete the barebrowse key.\n`);
441
+ console.log(` Tip: run \`barebrowse mcp\` directly to see the startup banner —`);
442
+ console.log(` the absolute serving path it prints to stderr is the one currently in use.`);
443
+ }
444
+ }
445
+ }
446
+
342
447
 
343
448
  // --- Usage ---
344
449
 
@@ -361,11 +466,13 @@ Session:
361
466
  --proxy=URL HTTP/SOCKS proxy server
362
467
  --viewport=WxH Viewport size (e.g. 1280x720)
363
468
  --storage-state=FILE Load cookies/localStorage from JSON file
469
+ --download-path=DIR Directory for downloaded files (default: per-session temp dir)
364
470
 
365
471
  Navigation:
366
472
  barebrowse goto <url> Navigate to URL
367
473
  barebrowse back Go back in history
368
474
  barebrowse forward Go forward in history
475
+ barebrowse reload [--no-cache] Reload current page
369
476
  barebrowse snapshot [--mode=M] ARIA snapshot -> .barebrowse/page-*.yml
370
477
  barebrowse screenshot [--format] Screenshot -> .barebrowse/screenshot-*.png
371
478
  barebrowse pdf [--landscape] PDF export -> .barebrowse/page-*.pdf
@@ -395,6 +502,7 @@ Debugging:
395
502
  barebrowse console-logs Console logs -> .barebrowse/console-*.json
396
503
  barebrowse network-log Network log -> .barebrowse/network-*.json
397
504
  barebrowse dialog-log JS dialog log -> .barebrowse/dialogs-*.json
505
+ barebrowse downloads List Content-Disposition downloads + savedPath (JSON)
398
506
  barebrowse save-state Cookies + localStorage -> .barebrowse/state-*.json
399
507
 
400
508
  One-shot:
@@ -402,6 +510,9 @@ One-shot:
402
510
 
403
511
  MCP:
404
512
  barebrowse mcp Start MCP server (JSON-RPC over stdio)
513
+ barebrowse install [--force] Add barebrowse to detected MCP clients (--force replaces stale entries)
514
+ barebrowse install --skill Install Claude Code skill file
515
+ barebrowse doctor Scan MCP config locations for barebrowse entries + flag scope conflicts
405
516
  barebrowse install Auto-configure MCP for Claude Desktop / Cursor
406
517
  barebrowse install --skill Install SKILL.md for Claude Code
407
518