
Browsing
- 378 installs
- 335 repo stars
- Updated June 1, 2026
- obra/superpowers-chrome
browsing is a Claude Code agent skill that controls Chrome via DevTools Protocol through a zero-dependency chrome-ws CLI for developers who need web research, verification, and UI checks from coding agents.
About
browsing is a Claude Code agent skill from obra/superpowers-chrome that teaches agents to control local Chrome browsers through the chrome-ws CLI and Chrome DevTools Protocol. The plugin ships zero npm dependencies, uses tab index syntax instead of WebSocket URLs, and exposes 17 commands covering start, tabs, navigate, click, fill, select, extract, screenshot, and raw CDP access. Developers run chrome-ws start to launch Chrome with remote debugging, then navigate, interact with forms, wait for elements, and extract DOM content from tab indices 0, 1, 2. Port allocation spans 9222–12111 with per-profile persistence in browser-profiles metadata. The skill supports macOS, Linux, and Windows with CHROME_WS_PROFILE for process sharing or isolation. Developers reach for browsing when agents must verify live pages, automate form flows, capture screenshots, or inspect UI behavior without a heavyweight Playwright or Puppeteer install.
- Chrome-driven browsing from agent sessions.
- Research and verification workflows.
- Superpowers Chrome extension companion skill.
Browsing by the numbers
- 378 all-time installs (skills.sh)
- +3 installs in the week ending Jul 26, 2026 (Skillselion tracking)
- Ranked #458 of 2,742 Automation & Workflows skills by installs in the Skillselion catalog
- Security screen: HIGH risk (skills.sh audit)
- Data as of Jul 26, 2026 (Skillselion catalog sync)
npx skills add https://github.com/obra/superpowers-chrome --skill browsingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 378 |
|---|---|
| repo stars | ★ 335 |
| Security audit | 0 / 3 scanners passed |
| Last updated | June 1, 2026 |
| Repository | obra/superpowers-chrome ↗ |
How do agents automate Chrome for web verification?
Browse the web via Chrome automation for research, verification, and UI checks.
Who is it for?
Claude Code and Codex developers needing lightweight Chrome DevTools Protocol automation without npm browser dependencies.
Skip if: Developers requiring cross-browser Playwright test suites, production E2E CI pipelines, or headless farm infrastructure.
When should I use this skill?
User needs agent browser control, live page verification, form automation, or UI inspection via Chrome DevTools Protocol
What you get
Chrome sessions controlled via chrome-ws with navigation, form interaction, DOM extraction, and screenshot artifacts
- Browser session control
- DOM extractions
- Screenshots
By the numbers
- Provides 17 chrome-ws CLI commands for Chrome DevTools Protocol automation
- Zero npm dependencies with debug port range 9222–12111
Files
Browsing with Chrome Direct
Overview
Control Chrome via DevTools Protocol using the use_browser MCP tool. Single unified interface with auto-starting Chrome.
Announce: "I'm using the browsing skill to control Chrome."
When to Use
Use this when:
- Controlling authenticated sessions
- Managing multiple tabs in running browser
- Playwright MCP unavailable or excessive
Use Playwright MCP when:
- Need fresh browser instances
- Generating screenshots/PDFs
- Prefer higher-level abstractions
Auto-Capture
Every DOM action (navigate, click, type, select, eval, keyboard_press, hover, drag_drop, double_click, right_click, file_upload) automatically saves:
{prefix}.png— viewport screenshot{prefix}.md— page content as structured markdown{prefix}.html— full rendered DOM{prefix}-console.txt— browser console messages
Files are saved to the session directory with sequential prefixes (001-navigate, 002-click, etc.). You must check these before using extract or screenshot actions.
The use_browser Tool
Single MCP tool with action-based interface. Chrome auto-starts on first use.
Parameters:
action(required): Operation to performselector(optional): CSS or XPath selector for element operationspayload(optional): Action-specific data (string or object)timeout(optional): Timeout in ms for await operations (default: 5000)
Active tab: Every action operates on the current activeTab. Use switch_tab to change it.
Actions Reference
Navigation
- navigate: Navigate to URL
payload: URL string- Example:
{action: "navigate", payload: "https://example.com"}
- await_element: Wait for element to appear
selector: CSS selectortimeout: Max wait time in ms- Example:
{action: "await_element", selector: ".loaded", timeout: 10000}
- await_text: Wait for text to appear
payload: Text to wait for- Example:
{action: "await_text", payload: "Welcome"}
Interaction
- click: Click element
selector: CSS selector- Example:
{action: "click", selector: "button.submit"}
- type: Text input
selector: Optional — clicks to focus firstpayload: Text to type (\t=Tab,\n=Enter)- Example:
{action: "type", selector: "#email", payload: "user@example.com"}
- double_click: Double-click element (fires dblclick event)
selector: CSS selector- Example:
{action: "double_click", selector: ".item"}
- right_click: Right-click element (fires contextmenu event)
selector: CSS selector- Example:
{action: "right_click", selector: ".row"}
- select: Select dropdown option
selector: CSS selectorpayload: Option value(s)- Example:
{action: "select", selector: "select[name=state]", payload: "CA"}
- keyboard_press: Press special keys (Tab, Enter, Escape, Arrow keys, F1-F12)
payload: Key name (string) or{"key": "Tab", "modifiers": {"shift": true, "ctrl": false, "alt": false, "meta": false}}- Example:
{action: "keyboard_press", payload: "Tab"} - Example with modifiers:
{action: "keyboard_press", payload: {"key": "Tab", "modifiers": {"shift": true}}}
Mouse Actions (CDP-Level)
These use CDP Input.dispatchMouseEvent, bypassing synthetic event restrictions.
- hover: Move mouse over element (CSS :hover, tooltips, menus)
selector: CSS selector- Example:
{action: "hover", selector: ".menu-trigger"}
- drag_drop: Drag element to target (native drag-and-drop via CDP)
selector: Source elementpayload: Target selector or JSON coordinates{"x":N,"y":N}- Example:
{action: "drag_drop", selector: ".card", payload: ".column-2"}
- mouse_move: Move mouse to coordinates
payload: JSON{"x":N,"y":N}(optional:steps,fromX,fromYfor smooth movement)- Example:
{action: "mouse_move", payload: "{\"x\":100,\"y\":200}"}
- scroll: Scroll via mouse wheel events
payload: Direction (up/down/left/right) or JSON{"deltaX":N,"deltaY":N}selector: Optional — scroll within element- Example:
{action: "scroll", payload: "down"}
File Upload
- file_upload: Set files on input[type=file] elements (can't be done via JavaScript)
selector: File input elementpayload: File path or JSON{"files":["/path/a.pdf","/path/b.jpg"]}- Example:
{action: "file_upload", selector: "#upload", payload: "/tmp/doc.pdf"}
Extraction
- extract: Get page content
payload: Format ('markdown'|'text'|'html')selector: Optional - limit to element- Example:
{action: "extract", payload: "markdown"} - Example:
{action: "extract", payload: "text", selector: "h1"}
- attr: Get element attribute
selector: CSS selectorpayload: Attribute name- Example:
{action: "attr", selector: "a.download", payload: "href"}
- eval: Execute JavaScript
payload: JavaScript code- Example:
{action: "eval", payload: "document.title"}
Export
- screenshot: Capture screenshot of a specific element
payload: Filenameselector: Optional - screenshot specific element- Viewport screenshots are auto-captured after every DOM action. Use this only when you need a specific element.
- Example:
{action: "screenshot", payload: "/tmp/chart.png", selector: ".chart"}
Tab Management
- list_tabs: List all open tabs
- Example:
{action: "list_tabs"}
- new_tab: Create new tab
- Example:
{action: "new_tab"}
- close_tab: Close the active tab
- Example:
{action: "close_tab"}
- switch_tab: Switch the active tab (sticky — stays until changed)
payload: Tab index (number), URL substring, or title substring- Example:
{action: "switch_tab", payload: 1}(by index) - Example:
{action: "switch_tab", payload: "example.com"}(by URL substring) - Example:
{action: "switch_tab", payload: "GitHub"}(by title substring)
Browser Mode Control
- show_browser: Make browser window visible (headed mode)
- Example:
{action: "show_browser"} - ⚠️ WARNING: Restarts Chrome, reloads pages via GET, loses POST state
- hide_browser: Switch to headless mode (invisible browser)
- Example:
{action: "hide_browser"} - ⚠️ WARNING: Restarts Chrome, reloads pages via GET, loses POST state
- browser_mode: Check current browser mode, port, and profile
- Example:
{action: "browser_mode"} - Returns:
{"headless": true|false, "mode": "headless"|"headed", "running": true|false, "port": 9222, "profile": "name", "profileDir": "/path"}
Profile Management
- set_profile: Change Chrome profile (must kill Chrome first)
- Example:
{action: "set_profile", "payload": "browser-user"} - ⚠️ WARNING: Chrome must be stopped first
- Side effect: marks the profile as explicit, opting out of auto-disambiguation (see below)
- get_profile: Get current profile name and directory
- Example:
{action: "get_profile"} - Returns:
{"profile": "name", "profileDir": "/path"}
Default behavior: Chrome starts in headless mode with "superpowers-chrome" profile on a dynamically allocated port (range 9222-12111). Override the port with CHROME_WS_PORT; override the profile with CHROME_WS_PROFILE.
Auto-disambiguation across parallel MCPs: When two MCP servers start on the same host with the default profile, the first claims superpowers-chrome (port 9222) and later ones silently fall through to superpowers-chrome-2 (port 9223), superpowers-chrome-3, etc. Each MCP drives its own Chrome with its own profile dir; they don't fight over activeTab. The bridge tracks ownership via a lock file at ~/.cache/superpowers/browser-profiles/<profile>.mcp.lock; stale locks (dead PIDs) are reclaimed automatically.
To opt out of disambiguation — e.g., to intentionally share Chrome between a long-lived chrome-ws CLI session and your MCP — set the profile name explicitly:
- Env var:
CHROME_WS_PROFILE=my-profile - Or:
{action: "set_profile", payload: "my-profile"}at runtime
An explicit profile name still acquires the lock, but on conflict the bridge shares rather than disambiguates — the second process reconnects to the first's Chrome (the original reconnect-on-restart behavior).
Chrome Lifecycle (Recovery)
- kill_chrome: Kill the Chrome process this MCP is driving
- Example:
{action: "kill_chrome"} - Releases the meta.json; next page action auto-restarts Chrome
- restart_chrome: kill_chrome + immediate spawn
- Example:
{action: "restart_chrome"}
Auto-restart banner: when the bridge has to spawn a fresh Chrome (because the previous one died or was killed externally — e.g., kill -9 <pid> from the shell), the first response after the restart prepends:
[Chrome auto-restarted; URL reset to about:blank. Re-navigate to continue.]Treat this as a signal that your prior URL / tab state is gone — re-navigate before assuming anything about the current page.
Console Logging
Capture browser console output for the active tab. Buffer is keyed by the page session's sessionId, so it survives close_tab/new_tab ordering quirks. Levels: log, info, warn, error.
- enable_console_logging: Start capturing
- Example:
{action: "enable_console_logging"}
- get_console_messages: Read captured messages
- All:
{action: "get_console_messages"} - Since timestamp (epoch ms):
{action: "get_console_messages", payload: {since: 1716000000000}} - Returns: array of
{timestamp, level, text}entries
- clear_console_messages: Reset the buffer
- Example:
{action: "clear_console_messages"}
Dialog Handling
Native dialogs (JS alert/confirm/prompt, beforeunload, HTTP basic-auth, permission prompts, device choosers) pause the page. While a dialog is open, page-targeted actions (extract, click, eval, etc.) return a refusal whose text contains Page is behind a dialog and lists the available dialog::* selectors.
When a dialog fires during a navigate (typical for HTTP basic-auth), navigate itself throws with the dialog grammar in the message — you don't have to issue a separate page-targeted call to discover the dialog.
Handle dialogs by clicking/typing a dialog::* selector:
{action: "click", selector: "dialog::accept"}— accept JS alert/confirm/prompt, beforeunload, permission grant{action: "click", selector: "dialog::dismiss"}— dismiss / cancel / deny{action: "type", selector: "dialog::prompt", payload: "text"}then accept — respond to JS prompt{action: "type", selector: "dialog::username", payload: "alice"}+{action: "type", selector: "dialog::password", payload: "secret"}+{action: "click", selector: "dialog::accept"}— HTTP basic-auth{action: "click", selector: "dialog::device[id=\"<deviceId>\"]"}— pick a WebUSB/Bluetooth/Serial/HID device
Critical caveats when toggling modes: 1. Chrome must restart - Cannot switch headless/headed mode on running Chrome 2. Pages reload via GET - All open tabs are reopened with GET requests 3. POST state is lost - Form submissions, POST results, and POST-based navigation will be lost 4. Session state is lost - Any client-side state (JavaScript variables, etc.) is cleared 5. Cookies/auth may persist - Uses same user data directory, so logged-in sessions may survive
When to use headed mode:
- Debugging visual rendering issues
- Demonstrating browser behavior to user
- Testing features that only work with visible browser
- Debugging issues that don't reproduce in headless mode
When to stay in headless mode (default):
- All other cases - faster, cleaner, less intrusive
- Screenshots work perfectly in headless mode
- Most automation works identically in both modes
Profile management: Profiles store persistent browser data (cookies, localStorage, extensions, auth sessions).
Profile locations:
- macOS:
~/Library/Caches/superpowers/browser-profiles/{name}/ - Linux:
~/.cache/superpowers/browser-profiles/{name}/ - Windows:
%LOCALAPPDATA%/superpowers/browser-profiles/{name}/
When to use separate profiles:
- Default profile ("superpowers-chrome"): General automation, shared sessions
- Agent-specific profiles: Isolate different agents' browser state
- Example: browser-user agent uses "browser-user" profile
- Task-specific profiles: Testing with different user contexts
- Example: "test-logged-in" vs "test-logged-out"
Profile data persists across:
- Chrome restarts
- Mode toggles (headless ↔ headed)
- System reboots (data is in cache directory)
To use a different profile: 1. Kill Chrome if running: await chromeLib.killChrome() 2. Set profile: {action: "set_profile", "payload": "my-profile"} 3. Start Chrome: Next navigate/action will use new profile
Quick Start Pattern
Navigate and extract:
{action: "navigate", payload: "https://example.com"}
{action: "await_element", selector: "h1"}
{action: "extract", payload: "text", selector: "h1"}Common Patterns
Fill and Submit Form
{action: "navigate", payload: "https://example.com/login"}
{action: "await_element", selector: "input[name=email]"}
{action: "type", selector: "input[name=email]", payload: "user@example.com"}
{action: "type", selector: "input[name=password]", payload: "pass123"}
{action: "keyboard_press", payload: "Enter"}
{action: "await_text", payload: "Welcome"}Uses keyboard_press to submit the form.
Multi-Tab Workflow
{action: "list_tabs"}
{action: "switch_tab", payload: 2}
{action: "click", selector: "a.email"}
{action: "await_element", selector: ".content"}
{action: "extract", payload: "text", selector: ".amount"}Dynamic Content
{action: "navigate", payload: "https://example.com"}
{action: "type", selector: "input[name=q]", payload: "query"}
{action: "click", selector: "button.search"}
{action: "await_element", selector: ".results"}
{action: "extract", payload: "text", selector: ".result-title"}Get Link Attribute
{action: "navigate", payload: "https://example.com"}
{action: "await_element", selector: "a.download"}
{action: "attr", selector: "a.download", payload: "href"}Execute JavaScript
{action: "eval", payload: "document.querySelectorAll('a').length"}
{action: "eval", payload: "Array.from(document.querySelectorAll('a')).map(a => a.href)"}Resize Viewport (Responsive Testing)
Use eval to resize the browser window for testing responsive layouts:
{action: "eval", payload: "window.resizeTo(375, 812); 'Resized to mobile'"}
{action: "eval", payload: "window.resizeTo(768, 1024); 'Resized to tablet'"}
{action: "eval", payload: "window.resizeTo(1920, 1080); 'Resized to desktop'"}Note: This resizes the window, not device emulation. It won't change:
- Device pixel ratio (retina displays)
- Touch events
- User-Agent string
For most responsive testing, window resize is sufficient.
Clear Cookies
Use eval to clear cookies accessible to JavaScript:
{action: "eval", payload: "document.cookie.split(';').forEach(c => { document.cookie = c.trim().split('=')[0] + '=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/'; }); 'Cookies cleared'"}Note: This clears cookies accessible to JavaScript. It won't clear:
- httpOnly cookies (server-side only)
- Cookies from other domains
For most logout/reset scenarios, this is sufficient.
Scroll Page
{action: "scroll", payload: "down"}
{action: "scroll", payload: "up"}
{action: "scroll", selector: ".container", payload: "{\"deltaX\":0,\"deltaY\":500}"}Uses real mouse wheel events (vs eval + scrollTo which bot detectors flag).
Tips
Always wait before interaction: Don't click or fill immediately after navigate - pages need time to load.
// BAD - might fail if page slow
{action: "navigate", payload: "https://example.com"}
{action: "click", selector: "button"} // May fail!
// GOOD - wait first
{action: "navigate", payload: "https://example.com"}
{action: "await_element", selector: "button"}
{action: "click", selector: "button"}Use specific selectors: Avoid generic selectors that match multiple elements.
// BAD - matches first button
{action: "click", selector: "button"}
// GOOD - specific
{action: "click", selector: "button[type=submit]"}
{action: "click", selector: "#login-button"}Submit forms: Use keyboard_press with Enter after type, or append \n to the payload.
{action: "type", selector: "#search", payload: "query"}
{action: "keyboard_press", payload: "Enter"}Check content first: Extract page content to verify selectors before building workflow.
{action: "extract", payload: "html"}Troubleshooting
Element not found:
- Use
await_elementbefore interaction - Verify selector with
extractaction using 'html' format
Timeout errors:
- Increase timeout:
{timeout: 30000}for slow pages - Wait for specific element instead of text
Wrong tab active:
- Use
list_tabsto see all open tabs - Use
switch_tabwith a URL or title substring to reliably switch tabs - Tab indices shift when tabs close — prefer URL/title-based switching
eval returns `[object Object]`:
- Use
JSON.stringify()for complex objects:{action: "eval", payload: "JSON.stringify({name: 'test'})"} - For async functions:
{action: "eval", payload: "JSON.stringify(await yourAsyncFunction())"}
Test Automation (Advanced)
<details> <summary>Click to expand test automation guidance</summary>
When building test automation, you have two approaches:
Approach 1: use_browser MCP (Simple Tests)
Best for: Single-step tests, direct Claude control during conversation
{"action": "navigate", "payload": "https://app.com"}
{"action": "click", "selector": "#test-button"}
{"action": "eval", "payload": "JSON.stringify({passed: document.querySelector('.success') !== null})"}Approach 2: chrome-ws CLI (Complex Tests)
Best for: Multi-step test suites, standalone automation scripts
Key insight: chrome-ws is the reference implementation showing proper Chrome DevTools Protocol usage. When use_browser doesn't work as expected, examine how chrome-ws handles the same operation.
# Example: Automated form testing
./chrome-ws navigate 0 "https://app.com/form"
./chrome-ws fill 0 "#email" "test@example.com"
./chrome-ws click 0 "button[type=submit]"
./chrome-ws wait-text 0 "Success"When use_browser Fails
1. Check chrome-ws source code - It shows the correct CDP pattern 2. Use chrome-ws to verify - Test the same operation via CLI 3. Adapt the pattern - Apply the working CDP approach to use_browser
Common Test Automation Patterns
- Form validation: Fill forms, check error states
- UI state testing: Click elements, verify DOM changes
- Performance testing: Measure load times, capture metrics
- Screenshot comparison: Capture before/after states
</details>
Advanced Usage
For command-line usage outside Claude Code, see COMMANDLINE-USAGE.md.
For detailed examples, see EXAMPLES.md.
Protocol Reference
Full CDP documentation: https://chromedevtools.github.io/devtools-protocol/
node_modules/
package-lock.json
IMPLEMENTATION_SUMMARY.md
TEST_RESULTS.md
test-*.png
test-*.md
#!/usr/bin/env node
const process = require('process');
// Parse --port=N flag from anywhere in argv (filter it out of positional args)
const allArgs = process.argv.slice(2);
const portArg = allArgs.find(a => a.startsWith('--port='));
const positionalArgs = allArgs.filter(a => !a.startsWith('--port='));
const [command, wsUrlOrIndex, ...args] = positionalArgs;
// Handle --help and --version before any other processing
if (command === '--help' || command === '-h' || !command) {
console.log(`Usage: chrome-ws <command> [args]
Commands:
start [port] Start Chrome with remote debugging
stop Kill Chrome
pid Print Chrome PID
info Print Chrome info (JSON)
tabs List open tabs
new <url> Open a new tab
close <tab> Close a tab
navigate <tab> <url> Navigate tab to URL
extract <tab> <selector> Extract element text content
attr <tab> <selector> <attribute> Get element attribute
html <tab> [selector] Get HTML content
click <tab> <selector> Click an element
fill <tab> <selector> <text> Fill an input field
select <tab> <selector> <value> Select a dropdown option
eval <tab> <js> Evaluate JavaScript
wait-for <tab> <selector> [timeout-ms] Wait for element to appear
wait-text <tab> <text> [timeout-ms] Wait for text to appear
screenshot <tab> <filename.png> [--fullpage] Take a screenshot
markdown <tab> <filename.md> Save page as markdown
har <tab> <filename.har> Export HAR (after har-start)
raw <ws-url> <json-rpc-payload> Send raw CDP command
--help, -h Show this help
--version, -v Show version
--port=N Override CHROME_WS_PORT env var
Tab arg: numeric index (0, 1, 2...) or full ws:// URL.
`);
process.exit(0);
}
if (command === '--version' || command === '-v') {
const pkg = require('../../package.json');
console.log(pkg.version);
process.exit(0);
}
const hostOverride = require('./host-override').createOverride();
const { createSession } = require('./chrome-ws-lib');
const CHROME_DEBUG_HOST = hostOverride.getHost();
const CHROME_DEBUG_PORT = hostOverride.getPort();
const WS_OVERRIDE_ENABLED = hostOverride.isOverrideEnabled();
const rewriteWsUrl = hostOverride.rewriteWsUrl;
// Effective port: --port=N flag overrides CHROME_WS_PORT env / default 9222
const effectivePort = portArg ? parseInt(portArg.split('=')[1], 10) : CHROME_DEBUG_PORT;
// Session pointed at the effective port. Built after effectivePort is known
// so the lib's pooled connections target the right Chrome instance.
const session = createSession({ host: CHROME_DEBUG_HOST, port: effectivePort });
// Minimal WebSocket client implementation (dependency-free)
class WebSocketClient {
constructor(url) {
this.url = new URL(url);
this.callbacks = {};
this.socket = null;
this.buffer = Buffer.alloc(0);
}
on(event, callback) {
this.callbacks[event] = callback;
}
connect() {
return new Promise((resolve, reject) => {
const http = require('http');
const crypto = require('crypto');
const key = crypto.randomBytes(16).toString('base64');
const options = {
hostname: this.url.hostname,
port: this.url.port || 80,
path: this.url.pathname + this.url.search,
headers: {
'Upgrade': 'websocket',
'Connection': 'Upgrade',
'Sec-WebSocket-Key': key,
'Sec-WebSocket-Version': '13'
}
};
const req = http.request(options);
req.on('upgrade', (res, socket) => {
this.socket = socket;
socket.on('data', (data) => {
this.buffer = Buffer.concat([this.buffer, data]);
this.processFrames();
});
socket.on('error', (err) => {
if (this.callbacks.error) this.callbacks.error(err);
});
if (this.callbacks.open) this.callbacks.open();
resolve();
});
req.on('error', reject);
req.end();
});
}
processFrames() {
while (this.buffer.length >= 2) {
const firstByte = this.buffer[0];
const secondByte = this.buffer[1];
const fin = (firstByte & 0x80) !== 0;
const opcode = firstByte & 0x0F;
const masked = (secondByte & 0x80) !== 0;
let payloadLen = secondByte & 0x7F;
let offset = 2;
if (payloadLen === 126) {
if (this.buffer.length < 4) return;
payloadLen = this.buffer.readUInt16BE(2);
offset = 4;
} else if (payloadLen === 127) {
if (this.buffer.length < 10) return;
payloadLen = Number(this.buffer.readBigUInt64BE(2));
offset = 10;
}
if (this.buffer.length < offset + payloadLen) return;
let payload = this.buffer.slice(offset, offset + payloadLen);
this.buffer = this.buffer.slice(offset + payloadLen);
if (opcode === 0x1 && this.callbacks.message) {
this.callbacks.message(payload.toString('utf8'));
}
}
}
send(data) {
const payload = Buffer.from(data, 'utf8');
const payloadLen = payload.length;
let frame;
let offset = 2;
if (payloadLen < 126) {
frame = Buffer.alloc(payloadLen + 6);
frame[1] = payloadLen | 0x80;
} else if (payloadLen < 65536) {
frame = Buffer.alloc(payloadLen + 8);
frame[1] = 126 | 0x80;
frame.writeUInt16BE(payloadLen, 2);
offset = 4;
} else {
frame = Buffer.alloc(payloadLen + 14);
frame[1] = 127 | 0x80;
frame.writeBigUInt64BE(BigInt(payloadLen), 2);
offset = 10;
}
frame[0] = 0x81; // FIN + text frame
const mask = Buffer.alloc(4);
require('crypto').randomFillSync(mask);
mask.copy(frame, offset);
offset += 4;
for (let i = 0; i < payloadLen; i++) {
frame[offset + i] = payload[i] ^ mask[i % 4];
}
this.socket.write(frame);
}
close() {
if (this.socket) {
this.socket.end();
this.socket = null;
}
}
}
// Helper to convert string tab specifier to the type expected by session methods.
// session.fill/evaluate/etc. use getPageSession which accepts a number (index) or
// a ws:// string — but NOT a numeric string like "0".
function resolveTabArg(wsUrlOrIndex) {
if (wsUrlOrIndex && wsUrlOrIndex.startsWith('ws://')) {
return wsUrlOrIndex; // Already a ws URL string
}
const index = parseInt(wsUrlOrIndex, 10);
if (!isNaN(index)) {
return index; // Numeric tab index as a number
}
throw new Error(`Invalid tab specifier: ${wsUrlOrIndex}`);
}
// Helper to resolve tab index or ws URL to actual ws URL
async function resolveWsUrl(wsUrlOrIndex) {
// If it's already a WebSocket URL, return it
if (wsUrlOrIndex && wsUrlOrIndex.startsWith('ws://')) {
return wsUrlOrIndex;
}
// If it's a number (tab index), resolve it
const index = parseInt(wsUrlOrIndex);
if (!isNaN(index)) {
const tabs = await chromeHttp('/json');
const pageTabs = Array.isArray(tabs)
? tabs
.filter(t => t.type === 'page')
.map(tab => WS_OVERRIDE_ENABLED
? { ...tab, webSocketDebuggerUrl: rewriteWsUrl(tab.webSocketDebuggerUrl) }
: tab
)
: [];
// Auto-create tab if none exist (similar to auto-start Chrome behavior)
if (pageTabs.length === 0) {
const newTabInfo = await chromeHttp('/json/new?about:blank', 'PUT');
return WS_OVERRIDE_ENABLED ? rewriteWsUrl(newTabInfo.webSocketDebuggerUrl) : newTabInfo.webSocketDebuggerUrl;
}
if (index < 0 || index >= pageTabs.length) {
throw new Error(`Tab index ${index} out of range (0-${pageTabs.length - 1})`);
}
return WS_OVERRIDE_ENABLED ? rewriteWsUrl(pageTabs[index].webSocketDebuggerUrl) : pageTabs[index].webSocketDebuggerUrl;
}
throw new Error(`Invalid tab specifier: ${wsUrlOrIndex}`);
}
// Helper to make HTTP requests to Chrome on the effective port
async function chromeHttp(path, method = 'GET') {
const http = require('http');
return new Promise((resolve, reject) => {
const options = {
hostname: CHROME_DEBUG_HOST,
port: effectivePort,
path,
method: method
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
if (!data) {
resolve({});
return;
}
try {
resolve(JSON.parse(data));
} catch (e) {
// Some endpoints return plain text (e.g., "Target is closing")
resolve({ message: data });
}
});
});
req.on('error', reject);
req.end();
});
}
// Command: start - launch Chrome with remote debugging
if (command === 'start') {
const { spawn } = require('child_process');
const { existsSync } = require('fs');
const os = require('os');
// Platform-specific Chrome paths
const chromePaths = {
darwin: [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium'
],
linux: [
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/chromium',
'/usr/bin/chromium-browser'
],
win32: [
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe'
]
};
const platform = os.platform();
const paths = chromePaths[platform];
if (!paths) {
console.error(`Unsupported platform: ${platform}`);
process.exit(1);
}
// Find Chrome executable (CHROME_WS_BROWSER env var overrides auto-detection)
const chromePath = process.env.CHROME_WS_BROWSER || paths.find(p => existsSync(p));
if (!chromePath) {
console.error('Chrome not found. Searched:');
paths.forEach(p => console.error(` ${p}`));
process.exit(1);
}
// Launch Chrome
const userDataDir = platform === 'win32'
? 'C:\\temp\\chrome-debug'
: '/tmp/chrome-debug';
const chromeArgs = [
`--remote-debugging-port=${effectivePort}`,
`--user-data-dir=${userDataDir}`
];
console.log(`Starting Chrome: ${chromePath}`);
const chrome = spawn(chromePath, chromeArgs, {
detached: true,
stdio: 'ignore'
});
chrome.unref();
const debugBase = `http://${CHROME_DEBUG_HOST}:${effectivePort}`;
// Wait and verify
setTimeout(async () => {
try {
const version = await chromeHttp('/json/version');
console.log(`Chrome started: ${version.Browser}`);
console.log(`Remote debugging: ${debugBase}`);
} catch (e) {
console.error('Chrome started but remote debugging not accessible');
console.error(`Try: curl ${debugBase}/json/version`);
process.exit(1);
}
}, 2000);
return;
}
// Command: stop - kill the Chrome process this session manages
if (command === 'stop') {
(async () => {
try {
await session.killChrome();
console.log('Chrome stopped');
} catch (e) {
console.error('Failed to stop Chrome:', e.message);
process.exit(1);
}
})();
return;
}
// Command: pid - print Chrome PID (for X11 window management, etc.)
if (command === 'pid') {
const pid = session.getChromePid();
if (pid === null) {
console.error('Chrome is not running (started via MCP). PID is only available when Chrome was started in this process.');
process.exit(1);
}
console.log(pid);
return;
}
// Command: info - print Chrome info as JSON (pid, mode, profile, port)
if (command === 'info') {
(async () => {
try {
const mode = await session.getBrowserMode();
// Also try to get PID from meta.json if available
const meta = session.readProfileMeta ? session.readProfileMeta(mode.profile) : null;
const info = {
pid: meta ? meta.pid : mode.pid,
port: meta ? meta.port : mode.port,
mode: meta ? (meta.headless ? 'headless' : 'headed') : mode.mode,
profile: mode.profile,
profileDir: mode.profileDir,
running: meta !== null
};
console.log(JSON.stringify(info, null, 2));
} catch (e) {
console.error('Failed to get Chrome info:', e.message);
process.exit(1);
}
})();
return;
}
// Command: tabs - list all tabs
if (command === 'tabs') {
(async () => {
try {
const tabs = await chromeHttp('/json');
tabs.forEach(tab => {
if (tab.type === 'page') {
console.log(`${tab.id}\t${tab.url}\t${tab.title}`);
}
});
} catch (e) {
console.error('Failed to list tabs:', e.message);
process.exit(1);
}
})();
return;
}
// Command: new - create new tab
if (command === 'new') {
// For this command, wsUrlOrIndex variable contains the URL parameter
if (!wsUrlOrIndex) {
console.error('Usage: chrome-ws new <url>');
process.exit(1);
}
const url = wsUrlOrIndex;
(async () => {
try {
const encoded = encodeURIComponent(url);
const tab = await chromeHttp(`/json/new?${encoded}`, 'PUT');
const wsUrl = WS_OVERRIDE_ENABLED ? rewriteWsUrl(tab.webSocketDebuggerUrl) : tab.webSocketDebuggerUrl;
console.log(wsUrl);
} catch (e) {
console.error('Failed to create tab:', e.message);
process.exit(1);
}
})();
return;
}
// Command: close - close tab by ws URL or numeric index
if (command === 'close') {
if (!wsUrlOrIndex) {
console.error('Usage: chrome-ws close <tab>');
process.exit(1);
}
(async () => {
try {
const tabWsUrl = await resolveWsUrl(wsUrlOrIndex);
// Extract tab ID from ws URL
const match = tabWsUrl.match(/\/devtools\/page\/([A-F0-9-]+)/i);
if (!match) {
console.error('Invalid WebSocket URL');
process.exit(1);
}
await chromeHttp(`/json/close/${match[1]}`);
console.log('Tab closed');
} catch (e) {
console.error('Failed to close tab:', e.message);
process.exit(1);
}
})();
return;
}
// Helper to send CDP command via WebSocket
async function sendCdpCommand(wsUrl, method, params = {}) {
return new Promise(async (resolve, reject) => {
const ws = new WebSocketClient(wsUrl);
const id = Math.floor(Math.random() * 1000000);
const timeout = setTimeout(() => {
ws.close();
reject(new Error('Timeout after 30s'));
}, 30000);
ws.on('message', (data) => {
const response = JSON.parse(data);
if (response.id === id) {
clearTimeout(timeout);
if (response.error) {
ws.close();
reject(new Error(response.error.message));
} else {
ws.close();
resolve(response.result);
}
}
});
ws.on('error', (err) => {
clearTimeout(timeout);
reject(err);
});
try {
await ws.connect();
ws.send(JSON.stringify({ id, method, params }));
} catch (err) {
clearTimeout(timeout);
reject(err);
}
});
}
// Command: navigate
if (command === 'navigate') {
const [url] = args;
if (!wsUrlOrIndex || !url) {
console.error('Usage: chrome-ws navigate <tab-index-or-ws-url> <url>');
process.exit(1);
}
(async () => {
try {
const wsUrl = await resolveWsUrl(wsUrlOrIndex);
await sendCdpCommand(wsUrl, 'Page.navigate', { url });
console.log(`Navigated to ${url}`);
} catch (e) {
console.error('Navigation failed:', e.message);
process.exit(1);
}
})();
return;
}
// Command: wait-for - wait for selector to appear
if (command === 'wait-for') {
const [selector, timeoutArg] = args;
if (!wsUrlOrIndex || !selector) {
console.error('Usage: chrome-ws wait-for <tab-index-or-ws-url> <selector> [timeout-ms]');
process.exit(1);
}
const timeout = timeoutArg ? parseInt(timeoutArg, 10) : 5000;
if (Number.isNaN(timeout) || timeout < 0) {
console.error(`Invalid timeout: ${timeoutArg}`);
process.exit(1);
}
(async () => {
try {
await session.waitForElement(resolveTabArg(wsUrlOrIndex), selector, timeout);
console.log(`Element found: ${selector}`);
process.exit(0);
} catch (e) {
console.error('Wait failed:', e.message);
process.exit(1);
}
})();
return;
}
// Command: click
if (command === 'click') {
const [selector] = args;
if (!wsUrlOrIndex || !selector) {
console.error('Usage: chrome-ws click <tab-index-or-ws-url> <selector>');
process.exit(1);
}
(async () => {
try {
const wsUrl = await resolveWsUrl(wsUrlOrIndex);
const js = `document.querySelector(${JSON.stringify(selector)}).click()`;
await sendCdpCommand(wsUrl, 'Runtime.evaluate', { expression: js });
console.log(`Clicked: ${selector}`);
} catch (e) {
console.error('Click failed:', e.message);
process.exit(1);
}
})();
return;
}
// Command: fill
if (command === 'fill') {
const [selector, value] = args;
if (!wsUrlOrIndex || !selector || value === undefined) {
console.error('Usage: chrome-ws fill <tab-index-or-ws-url> <selector> <value>');
process.exit(1);
}
(async () => {
try {
await session.fill(resolveTabArg(wsUrlOrIndex), selector, value);
console.log(`Filled: ${selector}`);
process.exit(0);
} catch (e) {
console.error('Fill failed:', e.message);
process.exit(1);
}
})();
return;
}
// Command: select - select dropdown option
if (command === 'select') {
const [selector, value] = args;
if (!wsUrlOrIndex || !selector || value === undefined) {
console.error('Usage: chrome-ws select <tab-index-or-ws-url> <selector> <value-or-label-or-json-array>');
process.exit(1);
}
(async () => {
try {
// Accept JSON array (multi-select) or plain string (value or label).
let selectValue = value;
if (typeof value === 'string' && value.trim().startsWith('[')) {
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed) && parsed.every(v => typeof v === 'string')) {
selectValue = parsed;
}
} catch (_e) { /* not JSON, treat as plain string */ }
}
const result = await session.selectOption(resolveTabArg(wsUrlOrIndex), selector, selectValue);
console.log(JSON.stringify(result.matched.map(o => o.value)));
process.exit(0);
} catch (e) {
console.error('Select failed:', e.message);
process.exit(1);
}
})();
return;
}
// Command: eval - evaluate JavaScript
if (command === 'eval') {
const expression = args.join(' ');
if (!wsUrlOrIndex || !expression) {
console.error('Usage: chrome-ws eval <tab-index-or-ws-url> <js-expression>');
process.exit(1);
}
(async () => {
try {
const value = await session.evaluate(resolveTabArg(wsUrlOrIndex), expression);
console.log(JSON.stringify(value, null, 2));
process.exit(0);
} catch (e) {
console.error('Eval failed:', e.message);
process.exit(1);
}
})();
return;
}
// Command: extract - get element text content
if (command === 'extract') {
const [selector] = args;
if (!wsUrlOrIndex || !selector) {
console.error('Usage: chrome-ws extract <tab-index-or-ws-url> <selector>');
process.exit(1);
}
(async () => {
try {
const wsUrl = await resolveWsUrl(wsUrlOrIndex);
const js = `document.querySelector(${JSON.stringify(selector)})?.textContent`;
const result = await sendCdpCommand(wsUrl, 'Runtime.evaluate', {
expression: js,
returnByValue: true
});
console.log(result.result.value);
} catch (e) {
console.error('Extract failed:', e.message);
process.exit(1);
}
})();
return;
}
// Command: attr - get element attribute
if (command === 'attr') {
const [selector, attrName] = args;
if (!wsUrlOrIndex || !selector || !attrName) {
console.error('Usage: chrome-ws attr <tab-index-or-ws-url> <selector> <attribute>');
process.exit(1);
}
(async () => {
try {
const wsUrl = await resolveWsUrl(wsUrlOrIndex);
const js = `document.querySelector(${JSON.stringify(selector)})?.getAttribute(${JSON.stringify(attrName)})`;
const result = await sendCdpCommand(wsUrl, 'Runtime.evaluate', {
expression: js,
returnByValue: true
});
console.log(result.result.value);
} catch (e) {
console.error('Attr failed:', e.message);
process.exit(1);
}
})();
return;
}
// Command: html - get HTML content
if (command === 'html') {
const [selector] = args;
if (!wsUrlOrIndex) {
console.error('Usage: chrome-ws html <tab-index-or-ws-url> [selector]');
process.exit(1);
}
(async () => {
try {
const wsUrl = await resolveWsUrl(wsUrlOrIndex);
const js = selector
? `document.querySelector(${JSON.stringify(selector)})?.innerHTML`
: 'document.documentElement.outerHTML';
const result = await sendCdpCommand(wsUrl, 'Runtime.evaluate', {
expression: js,
returnByValue: true
});
console.log(result.result.value);
} catch (e) {
console.error('HTML failed:', e.message);
process.exit(1);
}
})();
return;
}
// Command: wait-text - wait for text to appear
if (command === 'wait-text') {
// Last positional arg is treated as timeout if it parses as a non-negative
// integer; otherwise everything is text. This handles both:
// wait-text 0 "the text" 3000
// wait-text 0 "text without timeout"
if (!wsUrlOrIndex || args.length === 0) {
console.error('Usage: chrome-ws wait-text <tab-index-or-ws-url> <text> [timeout-ms]');
process.exit(1);
}
let textArgs = args;
let timeout = 5000;
const last = args[args.length - 1];
const parsedLast = parseInt(last, 10);
if (args.length >= 2 && Number.isFinite(parsedLast) && parsedLast >= 0 && String(parsedLast) === last.trim()) {
timeout = parsedLast;
textArgs = args.slice(0, -1);
}
const text = textArgs.join(' ');
if (!text) {
console.error('Usage: chrome-ws wait-text <tab-index-or-ws-url> <text> [timeout-ms]');
process.exit(1);
}
(async () => {
try {
await session.waitForText(resolveTabArg(wsUrlOrIndex), text, timeout);
console.log(`Text found: ${text}`);
process.exit(0);
} catch (e) {
console.error('Wait failed:', e.message);
process.exit(1);
}
})();
return;
}
// Command: screenshot - capture screenshot
if (command === 'screenshot') {
const fullPage = args.includes('--fullpage');
const cleanArgs = args.filter(a => a !== '--fullpage');
const [filename] = cleanArgs;
if (!wsUrlOrIndex || !filename) {
console.error('Usage: chrome-ws screenshot <tab-index-or-ws-url> <filename.png> [--fullpage]');
process.exit(1);
}
(async () => {
try {
const savedPath = await session.screenshot(resolveTabArg(wsUrlOrIndex), filename, null, fullPage);
console.log(`Screenshot saved to ${savedPath}`);
process.exit(0);
} catch (e) {
console.error('Screenshot failed:', e.message);
process.exit(1);
}
})();
return;
}
// Command: markdown - save page as markdown
if (command === 'markdown') {
const [filename] = args;
if (!wsUrlOrIndex || !filename) {
console.error('Usage: chrome-ws markdown <tab-index-or-ws-url> <filename.md>');
process.exit(1);
}
(async () => {
try {
const wsUrl = await resolveWsUrl(wsUrlOrIndex);
// Extract page content intelligently
const js = `
(() => {
const title = document.title;
const url = window.location.href;
// Get main content (try article, main, or body)
let content = document.querySelector('article') ||
document.querySelector('main') ||
document.body;
// Convert to markdown-ish text
function nodeToMarkdown(node, level = 0) {
let md = '';
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent.trim();
return text ? text + ' ' : '';
}
if (node.nodeType !== Node.ELEMENT_NODE) return '';
const tag = node.tagName.toLowerCase();
// Headers
if (/^h[1-6]$/.test(tag)) {
const hLevel = parseInt(tag[1]);
md += '\\n' + '#'.repeat(hLevel) + ' ' + node.textContent.trim() + '\\n\\n';
return md;
}
// Paragraphs
if (tag === 'p') {
md += node.textContent.trim() + '\\n\\n';
return md;
}
// Links
if (tag === 'a') {
const href = node.getAttribute('href') || '';
const text = node.textContent.trim();
return \`[\${text}](\${href}) \`;
}
// Lists
if (tag === 'li') {
return '- ' + node.textContent.trim() + '\\n';
}
// Code
if (tag === 'code' || tag === 'pre') {
return '\`' + node.textContent.trim() + '\` ';
}
// Recurse for other elements
for (const child of node.childNodes) {
md += nodeToMarkdown(child, level + 1);
}
if (tag === 'div' || tag === 'section') md += '\\n';
return md;
}
const markdown = nodeToMarkdown(content);
return \`# \${title}\\n\\nSource: \${url}\\n\\n\${markdown}\`;
})()
`;
const result = await sendCdpCommand(wsUrl, 'Runtime.evaluate', {
expression: js,
returnByValue: true
});
const fs = require('fs');
fs.writeFileSync(filename, result.result.value);
console.log(`Markdown saved to ${filename}`);
} catch (e) {
console.error('Markdown conversion failed:', e.message);
process.exit(1);
}
})();
return;
}
// Command: har - save network traffic as HAR
if (command === 'har') {
const [filename] = args;
if (!wsUrlOrIndex || !filename) {
console.error('Usage: chrome-ws har <tab-index-or-ws-url> <filename.har>');
console.error('Note: Start recording with "chrome-ws har-start <tab>" first');
process.exit(1);
}
(async () => {
try {
const wsUrl = await resolveWsUrl(wsUrlOrIndex);
// Get HAR data
const js = `window.__chrome_ws_har__ || []`;
const result = await sendCdpCommand(wsUrl, 'Runtime.evaluate', {
expression: js,
returnByValue: true
});
const har = {
log: {
version: '1.2',
creator: { name: 'chrome-ws', version: '1.0.0' },
entries: result.result.value || []
}
};
const fs = require('fs');
fs.writeFileSync(filename, JSON.stringify(har, null, 2));
console.log(`HAR saved to ${filename} (${har.log.entries.length} entries)`);
} catch (e) {
console.error('HAR export failed:', e.message);
process.exit(1);
}
})();
return;
}
// Past all the named-command dispatches without a return → either it's the
// raw escape hatch or it's a typo. Separate those cases so users get an
// actionable error instead of a confusing "Usage: chrome-ws raw ..." banner.
if (command !== 'raw') {
console.error(`Unknown command: ${command}`);
console.error(`Run 'chrome-ws --help' for the list of commands.`);
process.exit(1);
}
if (!wsUrlOrIndex || args.length === 0) {
console.error('Usage: chrome-ws raw <tab-index-or-ws-url> <json-rpc-payload>');
process.exit(1);
}
const payload = args.join(' ');
let message;
try {
message = JSON.parse(payload);
} catch (e) {
console.error('Invalid JSON payload:', e.message);
process.exit(1);
}
// For raw command, wsUrlOrIndex must be a full WebSocket URL (not an index)
// since this is the low-level escape hatch
if (!wsUrlOrIndex.startsWith('ws://')) {
console.error('raw command requires full WebSocket URL, not tab index');
console.error('Use: chrome-ws tabs # to get WebSocket URLs');
process.exit(1);
}
(async () => {
const ws = new WebSocketClient(wsUrlOrIndex);
const timeout = setTimeout(() => {
console.error('Timeout after 30s');
ws.close();
process.exit(1);
}, 30000);
ws.on('message', (data) => {
const response = JSON.parse(data);
if (response.id === message.id) {
clearTimeout(timeout);
console.log(JSON.stringify(response, null, 2));
ws.close();
process.exit(0);
}
});
ws.on('error', (error) => {
clearTimeout(timeout);
console.error('WebSocket error:', error.message);
process.exit(1);
});
try {
await ws.connect();
ws.send(JSON.stringify(message));
} catch (err) {
clearTimeout(timeout);
console.error('Connection failed:', err.message);
process.exit(1);
}
})();
/**
* Chrome WebSocket Library - Core CDP automation functions
* Used by both CLI and MCP server
*
* Fixes implemented:
* - JRV-130: Connection pooling for persistent focus
* - JRV-127: keyboard_press action for special keys
* - JRV-123: React-compatible input via Input.insertText
* - JRV-124: React-compatible click via Input.dispatchMouseEvent
* - JRV-125: Tab key handling (via keyboard_press)
* - JRV-126: Better eval return handling
* - JRV-128: SPA navigation support
* - JRV-129: Multi-element selector warnings
*/
const { getElementSelector } = require('./lib/element-selector');
const { KEY_DEFINITIONS } = require('./lib/key-definitions');
const { generateHtmlDiff } = require('./lib/html-diff');
const { createState } = require('./lib/session-state');
const { attachCookies } = require('./lib/cookies');
const { attachViewport } = require('./lib/viewport');
const { attachEvaluation } = require('./lib/evaluation');
const { attachMouse } = require('./lib/mouse');
const { attachChromeProcess } = require('./lib/chrome-process');
const { attachCapture } = require('./lib/capture');
const { attachNavigation } = require('./lib/navigation');
const { attachKeyboardInput } = require('./lib/keyboard-input');
const { attachExtraction } = require('./lib/extraction');
const { attachScreenshot } = require('./lib/screenshot');
const { attachTabs, createPageSessionResolver } = require('./lib/tabs');
const { createBrowserSession } = require('./lib/browser-session');
const { attachBrowserBridge } = require('./lib/browser-bridge');
const { attachFileUpload } = require('./lib/file-upload');
const { attachConsoleLogging } = require('./lib/console-logging');
const { attachSelectOption } = require('./lib/select-option');
const { attachDialogs, DialogRefusedError } = require('./lib/dialogs');
const { renderSyntheticArtifacts } = require('./lib/dialogs-render');
const {
getXdgCacheHome,
getChromeProfileDir,
getProfileMetaPath,
readProfileMeta,
writeProfileMeta,
clearProfileMeta,
findAvailablePort,
buildChromeArgs,
} = require('./lib/chrome-launcher-helpers');
/**
* Session methods whose CDP work targets the page (tab) target.
* When a native browser dialog is open, these methods will wedge waiting for a
* CDP response that never arrives because the dialog blocks the JS runtime.
* The session-boundary wrapper below refuses them with a descriptive error
* rather than hanging until timeout.
*
* Browser-target methods (getTabs, newTab, closeTab, startChrome, …) are NOT
* listed here — they route through the browser target and work fine while a
* dialog is open.
*/
const PAGE_TARGET_SESSION_METHODS = new Set([
'navigate',
'back',
'forward',
'click',
'fill',
'selectOption',
'evaluate',
'extractText',
'getHtml',
'getAttribute',
'waitForElement',
'waitForText',
'screenshot',
'hover',
'drag',
'mouseMove',
'scroll',
'doubleClick',
'rightClick',
'humanType',
'fileUpload',
'keyboardPress',
'clickWithCapture',
'fillWithCapture',
'selectOptionWithCapture',
'evaluateWithCapture',
// captureActionWithDiff is intentionally excluded: it is a meta-wrapper whose
// second arg is an action-type string ('type', 'click', …), not a selector.
// The inner actions it wraps (humanType, click, hover, etc.) are individually
// listed above and each have their own dialog gating via
// withDialogAwarenessForSession in capture.js. Re-gating the wrapper at this
// boundary would cause it to refuse dialog::* selectors before the inner action
// ever sees them (scenario 10C basic-auth typing bug).
'setViewport',
'clearViewport',
'getViewport',
]);
/**
* Build a fresh Chrome session — a state-bag scoped to a single Chrome target.
*
* Pre-factory, every consumer that required this file shared module-level
* state: the connection pool, console-message buffers, the chosen profile
* name, the launched Chrome process handle, the active CDP port, and the
* host-override config. Two consumers in the same process therefore drove a
* single Chrome — fine for the CLI and the MCP server (each owns its
* process), but a hazard for any caller that wants to drive multiple Chromes
* concurrently from one Node process (different ports, different profiles).
*
* `createSession({ host, port })` returns a fresh instance with private state
* and methods bound to that state. Two instances do not share a connection
* pool, console-message map, profile, Chrome process, or host-override —
* mutating one (e.g. setProfileName, startChrome) has no effect on the other.
* Pass `host`/`port` to seed the host-override; omit them to seed from the
* `CHROME_WS_HOST` / `CHROME_WS_PORT` env vars exactly as before.
*
* The returned object preserves the legacy module-level export shape — the
* one-line consumer migration is `require(...)` becomes
* `require(...).createSession()`.
*/
function createSession({ host, port, _testFakes } = {}) {
const state = createState({ host, port });
// =============================================================================
const dialogs = attachDialogs({ state });
const { chromeHttp, resolveWsUrl, getTabs, newTab, closeTab } = attachTabs({ state });
// Bridge primitives — single root WebSocket with flatten-mode page sessions.
// The browser-session is constructed immediately (lazy connect on first use).
// attachBrowserBridge issues Target.setDiscoverTargets which connects the root
// WS, so we defer it behind state.ensureBridge() (lazy).
const effectiveChromeHttp = (_testFakes && _testFakes.chromeHttp) ? _testFakes.chromeHttp : chromeHttp;
const browserSessionFactory = () => createBrowserSession({
host: state.hostOverride.getHost(),
port: state.hostOverride.getPort(),
rewriteWsUrl: state.rewriteWsUrl,
chromeHttp: effectiveChromeHttp,
WebSocketClient: _testFakes && _testFakes.WebSocketClient,
});
state.browserSession = browserSessionFactory();
let bridgePromise = null;
// Reset all bridge-layer state so the next ensureBridge() call re-attaches from
// scratch. Called by killChrome (explicit kill) and ensureBridge (stale detection).
// Does NOT call detach on cached pageSessions — the underlying WebSocket is
// already dead at call time, so detach would fail. Use resolver.release() per-tab
// before calling resetBridge if graceful cleanup is possible.
state.resetBridge = () => {
if (state.pageSessionResolver) {
state.pageSessionResolver.releaseAll();
}
state.pageSessionResolver = null;
state.browserBridge = null;
state.browserSession = browserSessionFactory();
bridgePromise = null;
};
state.ensureBridge = () => {
// Detect stale bridge: if the cached browserSession is no longer connected,
// reset everything so we re-attach to the restarted Chrome process.
if (state.browserBridge && state.browserSession && !state.browserSession.isConnected()) {
state.resetBridge();
}
if (state.browserBridge) return Promise.resolve(state.browserBridge);
if (bridgePromise) return bridgePromise;
bridgePromise = (async () => {
const bridge = await attachBrowserBridge({
browser: state.browserSession,
host: state.hostOverride.getHost(),
port: state.hostOverride.getPort(),
rewriteWsUrl: state.rewriteWsUrl,
autoAttach: true,
onPageSession: async (ps) => {
// Install dialog shim before the paused target resumes.
// This gives popups, OAuth windows, and child frames dialog
// handling from their very first script.
try {
await dialogs.attachToPageSession(ps);
} catch (e) {
console.error('onPageSession dialog attach failed:', e);
}
// Prime the pageSession resolver cache so subsequent getPageSession(popup)
// calls return THIS session rather than issuing a duplicate Target.attachToTarget.
// The dialog is registered under THIS session's sessionId; agent commands
// must route through the same session to handle the dialog.
if (state.pageSessionResolver && ps.targetId) {
state.pageSessionResolver.prime(ps.targetId, ps);
}
},
});
state.browserBridge = bridge;
state.pageSessionResolver = createPageSessionResolver({ bridge });
return bridge;
})();
// Clear bridgePromise on failure so the next call retries
bridgePromise.catch(() => { bridgePromise = null; });
return bridgePromise;
};
// getPageSession(tabIndexOrWsUrl) — shared resolver for pageSession-migrated libs.
// Accepts either a numeric tab index or a ws:// URL, lazy-boots the bridge, and
// returns a cached pageSession for the target. Reused by E2-E13 migration libs.
async function getPageSession(tabIndexOrWsUrl) {
await state.ensureBridge();
let tab;
if (typeof tabIndexOrWsUrl === 'number') {
const tabs = await getTabs();
tab = tabs[tabIndexOrWsUrl];
if (!tab) throw new Error(`No tab at index ${tabIndexOrWsUrl}`);
} else if (typeof tabIndexOrWsUrl === 'string') {
// Extract targetId from a ws URL like ws://host:port/devtools/page/<targetId>
const m = /\/devtools\/page\/([^/]+)$/.exec(tabIndexOrWsUrl);
if (!m) throw new Error(`Cannot extract targetId from: ${tabIndexOrWsUrl}`);
tab = { id: m[1] };
} else if (tabIndexOrWsUrl && tabIndexOrWsUrl.id) {
// Already a tab handle
tab = tabIndexOrWsUrl;
} else {
throw new Error('Unrecognized tabIndexOrWsUrl');
}
const ps = await state.pageSessionResolver(tab);
// Ensure dialog event listeners are wired up on the bridge session the first
// time a page session is obtained. This enables Page.javascriptDialogOpening
// events to arrive via the bridge path (stored under sessionId).
await dialogs.attachToPageSession(ps);
return ps;
}
const { click, hover, drag, mouseMove, scroll, doubleClick, rightClick } =
attachMouse({ getPageSession, dialogs });
const { keyboardPress, fill, humanType } =
attachKeyboardInput({ state, getPageSession, click, dialogs });
const { fileUpload } = attachFileUpload({ getPageSession });
const { selectOption } = attachSelectOption({ getPageSession });
const { evaluate } = attachEvaluation({ getPageSession });
// =============================================================================
const { extractText, getHtml, getAttribute } = attachExtraction({ getPageSession });
// getSessionDir is a lazy thunk: capture.js populates state.sessionDir via
// initializeSession(). We close over `state` so screenshot.js always reads
// the freshly-set value. If no capture has happened yet, we delegate to
// captureInitializer (set below after attachCapture) to create the dir.
// The ref itself must live before attachScreenshot and attachCapture, but the
// actual initializeSession function is injected after attachCapture runs.
const screenshotDirRef = { initializeSession: null };
const { screenshot } = attachScreenshot({
getPageSession,
state,
initializeSession: () => {
if (screenshotDirRef.initializeSession) return screenshotDirRef.initializeSession();
// Fallback if called before attachCapture (shouldn't happen in normal flow).
if (state.sessionDir) return state.sessionDir;
throw new Error('Session directory not yet initialized. Call an auto-capture action first.');
},
});
const { startChrome, killChrome, showBrowser, hideBrowser, getBrowserMode, getChromePid, getActivePort, getProfileName, setProfileName } =
attachChromeProcess({ state, chromeHttp, getTabs, newTab });
const { enableConsoleLogging, getConsoleMessages, clearConsoleMessages } =
attachConsoleLogging({ state, getPageSession });
const {
initializeSession,
cleanupSession,
createCapturePrefix,
generateDomSummary,
getPageSize,
generateMarkdown,
capturePageArtifacts,
captureActionWithDiff,
clickWithCapture,
fillWithCapture,
selectOptionWithCapture,
evaluateWithCapture,
} = attachCapture({
state,
getPageSession,
getHtml,
screenshot,
actions: { click, fill, selectOption, evaluate },
dialogs,
});
// Wire the forward reference so screenshot.js can call initializeSession.
screenshotDirRef.initializeSession = initializeSession;
const { navigate, waitForElement, waitForText, back, forward } =
attachNavigation({ state, getPageSession, capturePageArtifacts, evaluate });
const { setViewport, clearViewport, getViewport } = attachViewport({ getPageSession });
const { clearCookies } = attachCookies({ getPageSession });
// ---------------------------------------------------------------------------
// Session-boundary dialog gate
//
// Wraps every page-target method so that any call issued while a native dialog
// is open returns a structured refusal instead of hanging until a CDP timeout.
//
// Convention (mirrors all other page-target methods in this library):
// fn(tabIndexOrWsUrl, selectorOrArg, ...rest)
//
// If the second argument is a string beginning with "dialog::", it is a
// dialog-selector call (e.g. click("dialog::accept")) and must be allowed
// through so the existing internal routers in mouse.js and keyboard-input.js
// can handle it.
// ---------------------------------------------------------------------------
function wrapWithDialogGate(_name, fn) {
return async function dialogGated(tabIndexOrWsUrl, secondArg, ...rest) {
// Resolve the ws URL so we can look up dialog state.
// resolveWsUrl may throw (e.g., no Chrome running) — let it propagate
// naturally; that's not a dialog problem.
let wsUrl;
try {
wsUrl = await resolveWsUrl(tabIndexOrWsUrl);
} catch {
// Can't resolve the URL — delegate and let the method surface the error.
return fn(tabIndexOrWsUrl, secondArg, ...rest);
}
// Look up dialog state keyed by sessionId (via targetId→sessionId map populated
// by attachToPageSession). dialogs.getOpen() handles both direct sessionId keys
// and wsUrl paths by extracting the targetId from the URL.
const open = dialogs.getOpen(wsUrl);
const isDialogSelector = typeof secondArg === 'string' && secondArg.startsWith('dialog::');
if (open && !isDialogSelector) {
throw new DialogRefusedError({ dialog: open, artifacts: renderSyntheticArtifacts(open) });
}
return fn(tabIndexOrWsUrl, secondArg, ...rest);
};
}
// Build the raw session object, then wrap page-target methods.
const rawSession = {
// State bag (exposed for bridge consumers and testing)
state,
// Internal helpers (exported for testing)
getElementSelector,
// Core browser actions (click/fill now use CDP events by default for React compatibility)
getTabs,
newTab,
closeTab,
navigate,
click, // Uses CDP mouse events, falls back to el.click()
fill, // Uses CDP insertText, falls back to el.value=
selectOption, // Warns if selector matches multiple elements
evaluate,
extractText,
getHtml,
getAttribute,
waitForElement,
waitForText,
back,
forward,
screenshot,
// Mouse actions (CDP-level, bypasses synthetic event restrictions)
hover, // Move mouse over element (CSS :hover, tooltips)
drag, // Drag-and-drop via native mouse event sequence
mouseMove, // Raw coordinate mouse movement
scroll, // Mouse wheel scrolling
doubleClick, // Double-click with dblclick event
rightClick, // Right-click with contextmenu event
// Human-like typing (individual keyDown/keyUp with realistic timing)
humanType,
// File upload (DOM.setFileInputFiles — can't be done via JS)
fileUpload,
// Keyboard support for special keys (Tab, Enter, Escape, Arrow keys, etc.)
keyboardPress,
KEY_DEFINITIONS,
// Chrome lifecycle
startChrome,
buildChromeArgs,
killChrome,
showBrowser,
hideBrowser,
getBrowserMode,
getChromePid,
// Profile management
getChromeProfileDir,
getProfileName,
setProfileName,
// Console logging
enableConsoleLogging,
getConsoleMessages,
clearConsoleMessages,
// Session management
getXdgCacheHome,
initializeSession,
cleanupSession,
createCapturePrefix,
// Auto-capture utilities
generateDomSummary,
getPageSize,
generateMarkdown,
capturePageArtifacts,
clickWithCapture,
fillWithCapture,
selectOptionWithCapture,
evaluateWithCapture,
// DOM diff capture (before/after with diff)
generateHtmlDiff,
captureActionWithDiff,
// Dynamic port allocation and per-profile meta.json
getActivePort,
findAvailablePort,
getProfileMetaPath,
readProfileMeta,
writeProfileMeta,
clearProfileMeta,
// Viewport/device emulation
setViewport,
clearViewport,
getViewport,
// Cookie management
clearCookies,
// Dialog awareness
dialogs,
};
// Apply the session-boundary dialog gate to every page-target method.
for (const name of PAGE_TARGET_SESSION_METHODS) {
if (typeof rawSession[name] === 'function') {
rawSession[name] = wrapWithDialogGate(name, rawSession[name]);
}
}
return rawSession;
}
module.exports = { createSession, PAGE_TARGET_SESSION_METHODS, DialogRefusedError };
Command-Line Usage: chrome-ws Tool
Direct command-line access to Chrome DevTools Protocol via the chrome-ws bash tool.
Note: For use within Claude Code, the MCP use_browser tool is recommended. This document is for direct command-line usage or integration with other tools.
Setup
cd ~/.claude/plugins/cache/using-chrome-directly/skills/using-chrome-directly
chmod +x chrome-ws
./chrome-ws start # Auto-detects platform, launches Chrome
./chrome-ws tabs # Verify runningChrome starts with --remote-debugging-port=9222 and separate profile in /tmp/chrome-debug (or C:\temp\chrome-debug on Windows).
Environment Variables
| Variable | Default | Description |
|---|---|---|
CHROME_WS_BROWSER | (auto-detect) | Path to browser executable. Overrides auto-detection. |
CHROME_WS_HOST | 127.0.0.1 | Debug host address |
CHROME_WS_PORT | 9222 | Debug port number |
CHROME_WS_PROFILE | (auto) | Profile name. Default is superpowers-chrome; if another live process holds that profile's lock, the CLI / MCP falls through to superpowers-chrome-2, -3, etc. Set this to opt out — explicit names always claim the named profile (sharing with whoever else has it). |
Examples:
# Force Chromium instead of Chrome
CHROME_WS_BROWSER=/usr/bin/chromium ./chrome-ws start
# Use custom port
CHROME_WS_PORT=9333 ./chrome-ws start
# Use Brave browser
CHROME_WS_BROWSER="/usr/bin/brave-browser" ./chrome-ws startCommand Reference
Lifecycle:
chrome-ws start [port] # Launch Chrome (auto-detects platform)
chrome-ws stop # Kill Chrome
chrome-ws pid # Print Chrome PID
chrome-ws info # Print Chrome info (JSON: pid, port, mode, profile, profileDir, running)
chrome-ws --help # Show usage
chrome-ws --version # Print versionchrome-ws --port=N <command> overrides CHROME_WS_PORT for a single invocation.
Unknown commands now print Unknown command: <name> and point at --help instead of the raw-specific usage banner.
Tab Management:
chrome-ws tabs # List tabs as TSV (id<TAB>url<TAB>title); use `info` for JSON
chrome-ws new <url> # Create tab
chrome-ws close <tab> # Close tab (accepts numeric index or ws-url)Navigation:
chrome-ws navigate <tab> <url> # Navigate
chrome-ws wait-for <tab> <selector> # Wait for element
chrome-ws wait-text <tab> <text> # Wait for textInteraction:
chrome-ws click <tab> <selector> # Click
chrome-ws fill <tab> <selector> <value> # Fill input
chrome-ws select <tab> <selector> <value> # Select dropdownExtraction:
chrome-ws eval <tab> <js> # Execute JavaScript
chrome-ws extract <tab> <selector> # Get text content
chrome-ws attr <tab> <selector> <attr> # Get attribute
chrome-ws html <tab> [selector] # Get HTMLExport:
chrome-ws screenshot <tab> <file.png> # Capture screenshot
chrome-ws markdown <tab> <file.md> # Save as markdownRaw Protocol:
chrome-ws raw <ws-url> <json-rpc> # Direct CDP access<tab> accepts either tab index (0, 1, 2) or full WebSocket URL.
Examples
Basic Operations
Extract page content:
chrome-ws navigate 0 "https://example.com"
chrome-ws wait-for 0 "h1"
# Get page title
TITLE=$(chrome-ws eval 0 "document.title")
# Get main heading
HEADING=$(chrome-ws extract 0 "h1")
# Get first link URL
LINK=$(chrome-ws attr 0 "a" "href")Get all links:
chrome-ws navigate 0 "https://example.com"
LINKS=$(chrome-ws eval 0 "Array.from(document.querySelectorAll('a')).map(a => ({
text: a.textContent.trim(),
href: a.href
}))")
echo "$LINKS"Extract table data:
chrome-ws navigate 0 "https://example.com/data"
chrome-ws wait-for 0 "table"
# Convert table to JSON array
TABLE=$(chrome-ws eval 0 "
Array.from(document.querySelectorAll('table tr')).map(row =>
Array.from(row.cells).map(cell => cell.textContent.trim())
)
")Form Automation
Simple login:
chrome-ws navigate 0 "https://app.example.com/login"
chrome-ws wait-for 0 "input[name=email]"
# Fill credentials
chrome-ws fill 0 "input[name=email]" "user@example.com"
chrome-ws fill 0 "input[name=password]" "securepass123"
# Submit and wait for dashboard
chrome-ws click 0 "button[type=submit]"
chrome-ws wait-text 0 "Dashboard"Multi-step form:
chrome-ws navigate 0 "https://example.com/register"
# Step 1: Personal information
chrome-ws fill 0 "input[name=firstName]" "John"
chrome-ws fill 0 "input[name=lastName]" "Doe"
chrome-ws fill 0 "input[name=email]" "john@example.com"
chrome-ws click 0 "button.next"
# Wait for step 2 to load
chrome-ws wait-for 0 "input[name=address]"
# Step 2: Address
chrome-ws fill 0 "input[name=address]" "123 Main St"
chrome-ws select 0 "select[name=state]" "IL"
chrome-ws fill 0 "input[name=zip]" "62701"
chrome-ws click 0 "button.submit"
chrome-ws wait-text 0 "Registration complete"Search with filters:
chrome-ws navigate 0 "https://library.example.com/search"
chrome-ws wait-for 0 "form"
# Select category dropdown
chrome-ws select 0 "select[name=category]" "books"
# Fill search term
chrome-ws fill 0 "input[name=query]" "chrome devtools"
# Submit search
chrome-ws click 0 "button[type=submit]"
chrome-ws wait-for 0 ".results"
# Count results
RESULTS=$(chrome-ws eval 0 "document.querySelectorAll('.result').length")
echo "Found $RESULTS results"Web Scraping
Article content:
chrome-ws navigate 0 "https://blog.example.com/article"
chrome-ws wait-for 0 "article"
# Extract metadata
TITLE=$(chrome-ws extract 0 "article h1")
AUTHOR=$(chrome-ws extract 0 ".author-name")
DATE=$(chrome-ws extract 0 "time")
CONTENT=$(chrome-ws extract 0 "article .content")
# Save to file
cat > article.txt <<EOF
Title: $TITLE
Author: $AUTHOR
Date: $DATE
$CONTENT
EOFProduct information:
chrome-ws navigate 0 "https://shop.example.com/product/123"
chrome-ws wait-for 0 ".product-details"
NAME=$(chrome-ws extract 0 "h1.product-name")
PRICE=$(chrome-ws extract 0 ".price")
IMAGE=$(chrome-ws attr 0 ".product-image img" "src")
STOCK=$(chrome-ws extract 0 ".stock-status")
# Output as JSON
cat <<EOF
{
"name": "$NAME",
"price": "$PRICE",
"image": "$IMAGE",
"in_stock": "$STOCK"
}
EOFBatch process URLs:
URLS=("page1" "page2" "page3")
for URL in "${URLS[@]}"; do
chrome-ws navigate 0 "https://example.com/$URL"
chrome-ws wait-for 0 "h1"
TITLE=$(chrome-ws extract 0 "h1")
echo "$URL: $TITLE" >> results.txt
doneMulti-Tab Workflows
Email extraction:
# List all tabs
chrome-ws tabs
# Use the email tab index from output (e.g., tab 2)
EMAIL_TAB=2
# Click specific email
chrome-ws click $EMAIL_TAB "a[title*='Organization receipt']"
# Wait for email to load
chrome-ws wait-for $EMAIL_TAB ".email-body"
# Extract donation amount
AMOUNT=$(chrome-ws extract $EMAIL_TAB ".donation-amount")
echo "Donation: $AMOUNT"Price comparison:
chrome-ws navigate 0 "https://store1.com/product"
chrome-ws new "https://store2.com/product"
chrome-ws new "https://store3.com/product"
sleep 3 # Let pages load
PRICE1=$(chrome-ws extract 0 ".price")
PRICE2=$(chrome-ws extract 1 ".price")
PRICE3=$(chrome-ws extract 2 ".price")
echo "Store 1: $PRICE1"
echo "Store 2: $PRICE2"
echo "Store 3: $PRICE3"Cross-reference between sites:
# Get phone number from company site
chrome-ws navigate 0 "https://company.com/contact"
chrome-ws wait-for 0 ".phone"
PHONE=$(chrome-ws extract 0 ".phone")
# Look up phone number in verification site
chrome-ws new "https://lookup.com"
chrome-ws fill 1 "input[name=phone]" "$PHONE"
chrome-ws click 1 "button.search"
chrome-ws wait-for 1 ".results"
chrome-ws extract 1 ".verification-status"Dynamic Content
Wait for AJAX to complete:
chrome-ws navigate 0 "https://app.com/dashboard"
# Wait for spinner to disappear
chrome-ws eval 0 "new Promise(resolve => {
const check = () => {
if (!document.querySelector('.spinner')) {
resolve(true);
} else {
setTimeout(check, 100);
}
};
check();
})"
# Now safe to extract
chrome-ws extract 0 ".dashboard-data"Infinite scroll:
chrome-ws navigate 0 "https://example.com/feed"
chrome-ws wait-for 0 ".feed-item"
# Scroll 5 times
for i in {1..5}; do
chrome-ws eval 0 "window.scrollTo(0, document.body.scrollHeight)"
sleep 2
done
# Count loaded items
chrome-ws eval 0 "document.querySelectorAll('.feed-item').length"Monitor for changes:
chrome-ws navigate 0 "https://example.com/status"
END=$(($(date +%s) + 300))
while [ $(date +%s) -lt $END ]; do
STATUS=$(chrome-ws extract 0 ".status")
echo "[$(date +%H:%M:%S)] $STATUS"
if [[ "$STATUS" == *"ERROR"* ]]; then
echo "ALERT: Error detected"
break
fi
sleep 10
doneAdvanced Patterns
Multi-step workflow:
chrome-ws navigate 0 "https://booking.example.com"
# Search
chrome-ws fill 0 "input[name=destination]" "San Francisco"
chrome-ws fill 0 "input[name=checkin]" "2025-12-01"
chrome-ws click 0 "button.search"
# Select hotel
chrome-ws wait-for 0 ".hotel-results"
chrome-ws click 0 ".hotel-card:first-child .select"
# Choose room
chrome-ws wait-for 0 ".room-options"
chrome-ws click 0 ".room[data-type=deluxe] .book"
# Fill guest info
chrome-ws wait-for 0 "form.guest-info"
chrome-ws fill 0 "input[name=firstName]" "Jane"
chrome-ws fill 0 "input[name=lastName]" "Smith"
chrome-ws fill 0 "input[name=email]" "jane@example.com"
# Review
chrome-ws click 0 "button.review"
chrome-ws wait-for 0 ".summary"
# Extract confirmation
HOTEL=$(chrome-ws extract 0 ".hotel-name")
TOTAL=$(chrome-ws extract 0 ".total-price")
echo "$HOTEL: $TOTAL"Cookies and localStorage:
# Get cookies
chrome-ws eval 0 "document.cookie"
# Set cookie
chrome-ws eval 0 "document.cookie = 'theme=dark; path=/'"
# Get localStorage
chrome-ws eval 0 "JSON.stringify(localStorage)"
# Set localStorage
chrome-ws eval 0 "localStorage.setItem('lastVisit', new Date().toISOString())"Handle modals:
chrome-ws click 0 "button.open-modal"
chrome-ws wait-for 0 ".modal.visible"
# Fill modal form
chrome-ws fill 0 ".modal input[name=username]" "testuser"
chrome-ws click 0 ".modal button.submit"
# Wait for modal to close
chrome-ws eval 0 "new Promise(resolve => {
const check = () => {
if (!document.querySelector('.modal.visible')) {
resolve(true);
} else {
setTimeout(check, 100);
}
};
check();
})"Network monitoring with raw CDP:
# Enable network monitoring
chrome-ws raw 0 '{"id":1,"method":"Network.enable","params":{}}'
# Navigate and capture traffic
chrome-ws navigate 0 "https://api.example.com"
# Get performance metrics
chrome-ws raw 0 '{"id":2,"method":"Performance.getMetrics","params":{}}'Screenshots and PDF:
# Capture screenshot
chrome-ws screenshot 0 "page.png"
# Or use raw CDP for more control
SCREENSHOT=$(chrome-ws raw 0 '{
"id":1,
"method":"Page.captureScreenshot",
"params":{"format":"png","quality":80}
}')
# Extract base64 and save
echo "$SCREENSHOT" | node -pe "JSON.parse(require('fs').readFileSync(0)).result.data" | base64 -d > screenshot.pngError Handling
Check element exists:
# Verify button exists
EXISTS=$(chrome-ws eval 0 "!!document.querySelector('.important-button')")
if [ "$EXISTS" = "true" ]; then
chrome-ws click 0 ".important-button"
else
echo "Button not found on page"
fiVerify command success:
if ! chrome-ws navigate 0 "https://example.com"; then
echo "Navigation failed - Chrome not running?"
exit 1
fiRetry pattern:
for attempt in {1..3}; do
if chrome-ws click 0 ".submit-button"; then
echo "Click succeeded"
break
fi
echo "Attempt $attempt failed, retrying..."
sleep 2
doneBest Practices
Always wait before interaction:
# BAD - might fail if page slow to load
chrome-ws navigate 0 "https://example.com"
chrome-ws click 0 "button" # May fail!
# GOOD - wait for element first
chrome-ws navigate 0 "https://example.com"
chrome-ws wait-for 0 "button"
chrome-ws click 0 "button"Use specific selectors:
# BAD - matches first button on page
chrome-ws click 0 "button"
# GOOD - specific selector
chrome-ws click 0 "button[type=submit]"
chrome-ws click 0 "button.login-button"
chrome-ws click 0 "#submit-form"Test selectors with html command:
# Check page structure
chrome-ws html 0 | grep "submit"
# Check specific element exists
chrome-ws html 0 "form"Escape special characters:
# Use double quotes for variables
chrome-ws fill 0 "input[name=search]" "$SEARCH_TERM"
# Use single quotes for literal strings with special chars
chrome-ws eval 0 'document.querySelector(".item").textContent'Common Pitfalls
Don't cache tab indices - they change when tabs close:
# BAD - index might be stale
TAB=2
# ... much later ...
chrome-ws click $TAB "button" # Tab 2 might not exist anymore
# GOOD - fetch fresh before use
chrome-ws tabs
chrome-ws click 2 "button"Don't forget to wait for dynamic content:
# BAD - tries to extract before content loads
chrome-ws navigate 0 "https://app.com"
chrome-ws extract 0 ".user-name" # Might be empty!
# GOOD - wait for content
chrome-ws navigate 0 "https://app.com"
chrome-ws wait-for 0 ".user-name"
chrome-ws extract 0 ".user-name"Handle element state:
# Check if button is disabled
DISABLED=$(chrome-ws eval 0 "document.querySelector('button.submit').disabled")
if [ "$DISABLED" = "false" ]; then
chrome-ws click 0 "button.submit"
else
echo "Button is disabled"
fiTroubleshooting
Connection refused: Verify Chrome running with curl http://127.0.0.1:9222/json
Element not found: Check page structure with chrome-ws html 0
Timeout: Use wait-for before interaction. Chrome has 30s timeout.
Tab index out of range: Run chrome-ws tabs to get current indices.
Protocol Reference
Full CDP documentation: https://chromedevtools.github.io/devtools-protocol/
Common methods via raw command:
Page.navigateRuntime.evaluateNetwork.enablePerformance.getMetrics
Chrome Direct Access Examples (MCP Tool)
Examples using the use_browser MCP tool. For command-line bash examples, see COMMANDLINE-USAGE.md.
Table of Contents
1. Basic Operations 2. Form Automation 3. Web Scraping 4. Multi-Tab Workflows 5. Dynamic Content 6. Dialogs — basic-auth, JS confirm, popup-with-confirm 7. Recovery — auto-restart, kill/restart cycle 8. Multi-MCP isolation 9. Advanced Patterns
---
Basic Operations
Extract Page Content
Navigate to a page and extract various elements:
{action: "navigate", payload: "https://example.com"}
{action: "await_element", selector: "h1"}
// Get page title
{action: "eval", payload: "document.title"}
// Get main heading text
{action: "extract", payload: "text", selector: "h1"}
// Get first link URL
{action: "attr", selector: "a", payload: "href"}Get All Links
Use JavaScript evaluation to get structured data:
{action: "navigate", payload: "https://example.com"}
{action: "eval", payload: "Array.from(document.querySelectorAll('a')).map(a => ({ text: a.textContent.trim(), href: a.href }))"}Extract Table Data
Convert HTML table to structured data:
{action: "navigate", payload: "https://example.com/data"}
{action: "await_element", selector: "table"}
// Convert table to JSON array
{action: "eval", payload: "Array.from(document.querySelectorAll('table tr')).map(row => Array.from(row.cells).map(cell => cell.textContent.trim()))"}Get Page as Markdown
Extract entire page content in markdown format:
{action: "navigate", payload: "https://example.com"}
{action: "await_element", selector: "body"}
{action: "extract", payload: "markdown"}---
Form Automation
Simple Login
Navigate, fill credentials, and submit:
{action: "navigate", payload: "https://app.example.com/login"}
{action: "await_element", selector: "input[name=email]"}
// Fill credentials
{action: "type", selector: "input[name=email]", payload: "user@example.com"}
{action: "type", selector: "input[name=password]", payload: "securepass123\n"}
// Wait for successful login
{action: "await_text", payload: "Dashboard"}Note: The \n at the end of the password submits the form.
Multi-Step Form
Handle forms that show steps progressively:
{action: "navigate", payload: "https://example.com/register"}
// Step 1: Personal information
{action: "type", selector: "input[name=firstName]", payload: "John"}
{action: "type", selector: "input[name=lastName]", payload: "Doe"}
{action: "type", selector: "input[name=email]", payload: "john@example.com"}
{action: "click", selector: "button.next"}
// Wait for step 2 to load
{action: "await_element", selector: "input[name=address]"}
// Step 2: Address
{action: "type", selector: "input[name=address]", payload: "123 Main St"}
{action: "select", selector: "select[name=state]", payload: "IL"}
{action: "type", selector: "input[name=zip]", payload: "62701"}
{action: "click", selector: "button.submit"}
{action: "await_text", payload: "Registration complete"}Search with Filters
Use dropdowns and text inputs together:
{action: "navigate", payload: "https://library.example.com/search"}
{action: "await_element", selector: "form"}
// Select category dropdown
{action: "select", selector: "select[name=category]", payload: "books"}
// Fill search term
{action: "type", selector: "input[name=query]", payload: "chrome devtools"}
// Submit and count results
{action: "click", selector: "button[type=submit]"}
{action: "await_element", selector: ".results"}
// Count results
{action: "eval", payload: "document.querySelectorAll('.result').length"}---
Web Scraping
Article Content
Extract article metadata and content:
{action: "navigate", payload: "https://blog.example.com/article"}
{action: "await_element", selector: "article"}
// Extract metadata
{action: "extract", payload: "text", selector: "article h1"}
{action: "extract", payload: "text", selector: ".author-name"}
{action: "extract", payload: "text", selector: "time"}
{action: "extract", payload: "text", selector: "article .content"}Product Information
Scrape product details from e-commerce site:
{action: "navigate", payload: "https://shop.example.com/product/123"}
{action: "await_element", selector: ".product-details"}
// Extract product data
{action: "extract", payload: "text", selector: "h1.product-name"}
{action: "extract", payload: "text", selector: ".price"}
{action: "attr", selector: ".product-image img", payload: "src"}
{action: "extract", payload: "text", selector: ".stock-status"}Batch Extract Structured Data
Get multiple products at once using JavaScript:
{action: "navigate", payload: "https://shop.example.com/category/electronics"}
{action: "await_element", selector: ".product-grid"}
// Extract all products as structured data
{action: "eval", payload: `
Array.from(document.querySelectorAll('.product-card')).map(card => ({
name: card.querySelector('.product-name').textContent,
price: card.querySelector('.price').textContent,
image: card.querySelector('img').src,
url: card.querySelector('a').href
}))
`}---
Multi-Tab Workflows
Email Extraction
List tabs, then switch to the correct tab and extract data:
// Find email tab
{action: "list_tabs"}
// Switch to tab 2 (from list_tabs output), then operate on active tab
{action: "switch_tab", payload: 2}
{action: "click", selector: "a[title*='Organization receipt']"}
{action: "await_element", selector: ".email-body"}
// Extract donation amount
{action: "extract", payload: "text", selector: ".donation-amount"}Price Comparison
Open multiple stores and compare prices:
// Navigate first tab (already active)
{action: "navigate", payload: "https://store1.com/product"}
// Open additional tabs and navigate each
{action: "new_tab"}
{action: "navigate", payload: "https://store2.com/product"}
{action: "new_tab"}
{action: "navigate", payload: "https://store3.com/product"}
// Switch back to each tab and extract prices
{action: "switch_tab", payload: "store1.com"}
{action: "await_element", selector: ".price"}
{action: "extract", payload: "text", selector: ".price"}
{action: "switch_tab", payload: "store2.com"}
{action: "await_element", selector: ".price"}
{action: "extract", payload: "text", selector: ".price"}
{action: "switch_tab", payload: "store3.com"}
{action: "await_element", selector: ".price"}
{action: "extract", payload: "text", selector: ".price"}Cross-Reference Between Sites
Extract data from one site and use in another:
// Get phone number from company site
{action: "navigate", payload: "https://company.com/contact"}
{action: "await_element", selector: ".phone"}
{action: "extract", payload: "text", selector: ".phone"}
// Store the result, then open verification site in a new tab
{action: "new_tab"}
{action: "navigate", payload: "https://lookup.com"}
{action: "await_element", selector: "input[name=phone]"}
// Fill with extracted phone number (new tab is already active)
{action: "type", selector: "input[name=phone]", payload: "<phone-from-previous-extract>"}
{action: "click", selector: "button.search"}
{action: "await_element", selector: ".results"}
{action: "extract", payload: "text", selector: ".verification-status"}---
Dynamic Content
Wait for AJAX to Complete
Wait for loading spinner to disappear:
{action: "navigate", payload: "https://app.com/dashboard"}
// Wait for spinner to disappear using custom JavaScript
{action: "eval", payload: `
new Promise(resolve => {
const check = () => {
if (!document.querySelector('.spinner')) {
resolve(true);
} else {
setTimeout(check, 100);
}
};
check();
})
`}
// Now safe to extract
{action: "extract", payload: "text", selector: ".dashboard-data"}Infinite Scroll
Scroll to load more content:
{action: "navigate", payload: "https://example.com/feed"}
{action: "await_element", selector: ".feed-item"}
// Scroll multiple times
{action: "eval", payload: "window.scrollTo(0, document.body.scrollHeight)"}
{action: "await_element", selector: ".feed-item", timeout: 2000}
{action: "eval", payload: "window.scrollTo(0, document.body.scrollHeight)"}
{action: "await_element", selector: ".feed-item", timeout: 2000}
{action: "eval", payload: "window.scrollTo(0, document.body.scrollHeight)"}
{action: "await_element", selector: ".feed-item", timeout: 2000}
// Count loaded items
{action: "eval", payload: "document.querySelectorAll('.feed-item').length"}Wait for Element to Become Enabled
Wait for button to be clickable:
{action: "click", selector: "button.start"}
// Wait for continue button to enable
{action: "eval", payload: `
new Promise(resolve => {
const check = () => {
const btn = document.querySelector('button.continue');
if (btn && !btn.disabled) {
resolve(true);
} else {
setTimeout(check, 100);
}
};
check();
})
`}
{action: "click", selector: "button.continue"}---
Dialogs
HTTP basic-auth (dialog surfaces during navigate)
When the page returns 401 + WWW-Authenticate, Chrome stages a basic-auth dialog. The bridge intercepts the Fetch.authRequired event, holds the navigation, and surfaces a dialog refusal — navigate throws with the dialog grammar in the message. The response text contains basic-auth and lists the dialog::username/dialog::password/dialog::accept selectors.
# Step 1: navigate fails with the dialog payload
{action: "navigate", payload: "http://localhost:8766/", timeout: 15000}
# (response includes "basic-auth", "dialog::username", "dialog::password")
# Step 2-4: stage credentials and submit
{action: "type", selector: "dialog::username", payload: "alice"}
{action: "type", selector: "dialog::password", payload: "secret"}
{action: "click", selector: "dialog::accept"}
# Step 5: the original navigation completes; the page is now loaded
{action: "extract", selector: "h1", payload: "text"}
# → "hi alice"JS confirm/alert dispatched by a click
A button whose onclick calls confirm() opens a dialog as soon as the click event fires. The click itself may report a CDP timeout (Chrome pauses the main thread on the dialog); that's expected. The bridge has already populated state.dialogs[sid] and any subsequent page-targeted call gets refused with the dialog grammar.
{action: "navigate", payload: "<page with onclick=confirm('Proceed?')>"}
{action: "click", selector: "#ask"}
# Click times out — expected.
{action: "extract", selector: "#result", payload: "text"}
# Refused: response contains "Page is behind a dialog", "dialog::accept",
# "dialog::dismiss", and the prompt "Proceed?".
{action: "click", selector: "dialog::accept"}
# Dialog accepted; state.dialogs cleared eagerly.
{action: "eval", payload: "window.__userChoice"}
# → truePopup with synchronous dialog (Phase F headline case)
A page that opens a popup whose first inline script calls confirm() works without races. The bridge attaches to the popup target via Target.setAutoAttach({waitForDebuggerOnStart: true}), installs the dialog shim, then resumes execution — so the synchronous confirm is observed.
{action: "navigate", payload: "http://localhost:8765/popup-opener.html"}
{action: "click", selector: "#open"} # opens window.open('popup.html')
{action: "list_tabs"} # popup is enumerated
{action: "switch_tab", payload: "Popup"} # route to the popup tab
{action: "extract", selector: "*", payload: "text"}
# Refused with dialog grammar — the popup's confirm was caught.
{action: "click", selector: "dialog::accept"}
{action: "eval", payload: "window.__userChoice"}
# → trueRecovery
Chrome killed externally
If something kills your Chrome (kill -9 <pid>, OOM killer, a user closing the headed window), the bridge auto-restarts on the next page action. The response is prefixed with a banner so you know the previous URL/tab state is gone.
{action: "navigate", payload: "https://example.com"}
{action: "extract", selector: "h1", payload: "text"} # → "Example Domain"
{action: "browser_mode"} # records pid=N
# (from your shell or another process: kill -9 N)
{action: "navigate", payload: "https://example.com"}
# Response starts with:
# [Chrome auto-restarted; URL reset to about:blank. Re-navigate to continue.]
# Navigated to https://example.com
# ...browser_mode also reports the real PID even when the bridge adopted a Chrome it didn't spawn (a leftover from a previous MCP session). So "get pid, kill -9 it, watch the restart" works regardless of how Chrome got there.
Explicit kill + restart cycle
{action: "kill_chrome"} # Chrome killed.
{action: "restart_chrome"} # Chrome restarted in headless mode.
{action: "navigate", payload: "data:text/html,<h1>fresh</h1>"}Multi-MCP isolation
By default the bridge handles parallel MCP servers on the same host automatically: the first claims superpowers-chrome:9222, the next silently falls through to superpowers-chrome-2:9223, then -3:9224, etc. Each MCP drives its own Chrome with its own profile directory.
To intentionally share a Chrome between processes (e.g., a long-lived chrome-ws start from the shell + a Claude MCP attaching to it), pick a fixed profile name on both sides:
# Shell:
CHROME_WS_PROFILE=shared chrome-ws start
# In the MCP, on first call:
{action: "set_profile", payload: "shared"}
{action: "navigate", payload: "https://example.com"}
# Reconnects to the shell-started Chrome — same tabs, same cookies.Either set CHROME_WS_PROFILE=shared in the MCP's environment, or call set_profile at runtime. Both mark the profile as explicit, so the bridge shares rather than disambiguates.
---
Advanced Patterns
Multi-Step Workflow
Complete booking flow with validation:
{action: "navigate", payload: "https://booking.example.com"}
// Search
{action: "type", selector: "input[name=destination]", payload: "San Francisco"}
{action: "type", selector: "input[name=checkin]", payload: "2025-12-01"}
{action: "click", selector: "button.search"}
// Select hotel
{action: "await_element", selector: ".hotel-results"}
{action: "click", selector: ".hotel-card:first-child .select"}
// Choose room
{action: "await_element", selector: ".room-options"}
{action: "click", selector: ".room[data-type=deluxe] .book"}
// Fill guest info
{action: "await_element", selector: "form.guest-info"}
{action: "type", selector: "input[name=firstName]", payload: "Jane"}
{action: "type", selector: "input[name=lastName]", payload: "Smith"}
{action: "type", selector: "input[name=email]", payload: "jane@example.com"}
// Review (don't complete)
{action: "click", selector: "button.review"}
{action: "await_element", selector: ".summary"}
// Extract confirmation details
{action: "extract", payload: "text", selector: ".hotel-name"}
{action: "extract", payload: "text", selector: ".total-price"}Cookies and LocalStorage
Access browser storage:
// Get cookies
{action: "eval", payload: "document.cookie"}
// Set cookie
{action: "eval", payload: "document.cookie = 'theme=dark; path=/'"}
// Get localStorage
{action: "eval", payload: "JSON.stringify(localStorage)"}
// Set localStorage
{action: "eval", payload: "localStorage.setItem('lastVisit', new Date().toISOString())"}Handle Modals
Interact with modal dialogs:
{action: "click", selector: "button.open-modal"}
{action: "await_element", selector: ".modal.visible"}
// Fill modal form
{action: "type", selector: ".modal input[name=username]", payload: "testuser"}
{action: "click", selector: ".modal button.submit"}
// Wait for modal to close
{action: "eval", payload: `
new Promise(resolve => {
const check = () => {
if (!document.querySelector('.modal.visible')) {
resolve(true);
} else {
setTimeout(check, 100);
}
};
check();
})
`}Screenshots
Capture full page or specific elements:
// Full page screenshot
{action: "navigate", payload: "https://example.com"}
{action: "await_element", selector: "body"}
{action: "screenshot", payload: "/tmp/page.png"}
// Element-specific screenshot
{action: "screenshot", payload: "/tmp/element.png", selector: ".important-section"}Check Element State
Verify element properties before interaction:
// Check if button is disabled
{action: "eval", payload: "document.querySelector('button.submit').disabled"}
// Check if element is visible
{action: "eval", payload: "!!document.querySelector('.important-button') && window.getComputedStyle(document.querySelector('.important-button')).display !== 'none'"}
// Check element exists
{action: "eval", payload: "!!document.querySelector('.important-button')"}---
Tips and Best Practices
Always Wait Before Interaction
Don't interact with elements immediately after navigation:
// BAD - might fail if page slow to load
{action: "navigate", payload: "https://example.com"}
{action: "click", selector: "button"} // May fail!
// GOOD - wait for element first
{action: "navigate", payload: "https://example.com"}
{action: "await_element", selector: "button"}
{action: "click", selector: "button"}Use Specific Selectors
Avoid generic selectors that match multiple elements:
// BAD - matches first button on page
{action: "click", selector: "button"}
// GOOD - specific selector
{action: "click", selector: "button[type=submit]"}
{action: "click", selector: "button.login-button"}
{action: "click", selector: "#submit-form"}Verify Selectors First
Check page structure before building workflow:
// Check page HTML
{action: "extract", payload: "html"}
// Or check specific element
{action: "extract", payload: "html", selector: "form"}Handle Dynamic Content
Wait for content to load before extraction:
// BAD - tries to extract before content loads
{action: "navigate", payload: "https://app.com"}
{action: "extract", payload: "text", selector: ".user-name"} // Might be empty!
// GOOD - wait for content
{action: "navigate", payload: "https://app.com"}
{action: "await_element", selector: ".user-name"}
{action: "extract", payload: "text", selector: ".user-name"}Use \n for Form Submission
Append newline to auto-submit forms:
// Submit search without explicit click
{action: "type", selector: "#search-input", payload: "my query\n"}
// Submit login form
{action: "type", selector: "input[name=email]", payload: "user@example.com"}
{action: "type", selector: "input[name=password]", payload: "password123\n"}---
Common Pitfalls
Don't Rely on Tab Indices
Tab indices change when tabs close — use URL or title substrings for reliable switching:
// BAD - index might be stale after closing tabs
{action: "switch_tab", payload: 2}
{action: "click", selector: "button"}
// GOOD - switch by URL or title substring (stable across tab changes)
{action: "switch_tab", payload: "example.com"}
{action: "click", selector: "button"}
// Or list tabs first to confirm the index
{action: "list_tabs"}
{action: "switch_tab", payload: 2}
{action: "click", selector: "button"}Increase Timeout for Slow Pages
Default timeout is 5000ms, increase if needed:
// For slow-loading elements
{action: "await_element", selector: ".lazy-content", timeout: 30000}
// For slow AJAX requests
{action: "await_text", payload: "Data loaded", timeout: 15000}Extract Structured Data with JavaScript
For complex data extraction, use JavaScript evaluation:
// Instead of multiple extract calls, use one eval
{action: "eval", payload: `
{
title: document.querySelector('h1').textContent,
author: document.querySelector('.author').textContent,
date: document.querySelector('time').textContent,
links: Array.from(document.querySelectorAll('a')).map(a => a.href)
}
`}---
Reference
- SKILL.md - Complete tool reference
- COMMANDLINE-USAGE.md - Command-line bash examples
- Chrome DevTools Protocol - Full protocol documentation
const DEFAULT_PORT = 9222;
const DEFAULT_HOST = '127.0.0.1';
/**
* Build a per-instance host-override configuration.
*
* The legacy module-level constants and `rewriteWsUrl` above are baked at
* module-load time and shared across every consumer that requires this
* file. That's fine for the single-Chrome use case — the CLI and the MCP
* server each own their process, so module-level state is effectively
* per-process. It breaks down when one process needs to drive several
* independent Chrome instances concurrently (different host/port pairs):
* the load-time constants can only describe one of them.
*
* `createOverride({ host, port })` returns a fresh state-bag with its own
* host/port/override-enabled flag, plus methods (`getHost`, `getPort`,
* `getBase`, `isOverrideEnabled`, `rewriteWsUrl`, `setDefaults`) bound to
* that state. Two instances do not share state — mutating one via
* `setDefaults()` does not affect the other. Callers that don't need
* per-instance isolation can keep using the module-level constants and
* `rewriteWsUrl` exactly as before; nothing about the legacy API has
* changed.
*
* Defaults: if both `host` and `port` are omitted, the instance seeds from
* the `CHROME_WS_HOST` / `CHROME_WS_PORT` env vars. If either argument is
* supplied, both are taken from the arguments (filling in defaults for the
* missing one) and the instance's `overrideEnabled` flag starts true —
* matching `setDefaults()` semantics.
*/
function createOverride({ host, port } = {}) {
let instanceHost;
let instancePort;
let instanceOverrideEnabled;
if (host !== undefined || port !== undefined) {
instanceHost = host !== undefined ? host : DEFAULT_HOST;
instancePort = port !== undefined ? port : DEFAULT_PORT;
instanceOverrideEnabled = true;
} else {
instanceHost = process.env.CHROME_WS_HOST || DEFAULT_HOST;
const parsed = parseInt(process.env.CHROME_WS_PORT || `${DEFAULT_PORT}`, 10);
instancePort = Number.isNaN(parsed) ? DEFAULT_PORT : parsed;
instanceOverrideEnabled =
process.env.CHROME_WS_HOST !== undefined || process.env.CHROME_WS_PORT !== undefined;
}
function setDefaults(nextHost, nextPort) {
instanceHost = nextHost;
instancePort = nextPort;
instanceOverrideEnabled = true;
}
function getHost() {
return instanceHost;
}
function getPort() {
return instancePort;
}
function getBase() {
return `http://${instanceHost}:${instancePort}`;
}
function isOverrideEnabled() {
return instanceOverrideEnabled;
}
function instanceRewriteWsUrl(originalUrl, overrideHost, overridePort) {
if (!originalUrl || typeof originalUrl !== 'string') {
return originalUrl;
}
if (!instanceOverrideEnabled) {
return originalUrl;
}
const useHost = overrideHost !== undefined ? overrideHost : instanceHost;
const usePort = overridePort !== undefined ? overridePort : instancePort;
try {
const url = new URL(originalUrl);
url.hostname = useHost;
url.port = `${usePort}`;
return url.toString();
} catch {
return originalUrl;
}
}
return {
setDefaults,
getHost,
getPort,
getBase,
isOverrideEnabled,
rewriteWsUrl: instanceRewriteWsUrl,
};
}
module.exports = { createOverride };
'use strict';
const { createCdpRouter } = require('./cdp-router');
const { attachPageSession, buildPageSessionFromAttached } = require('./page-session');
/**
* attachBrowserBridge({browser, host, port, rewriteWsUrl}) — consumer-facing
* bridge over the browser-session.
*
* Exposes:
* targets.list() — synchronous snapshot of current targets
* targets.onCreated(handler) — register listener; returns unsub fn
* targets.onDestroyed(handler)
* targets.waitForNew(predicate, {timeoutMs})
* createBrowserContext({proxyServer?}) -> {browserContextId, createPage, dispose}
* attachPageSession(targetId) — page session over the browser-WS via flatten mode
*
* host/port/rewriteWsUrl are needed by createBrowserContext.createPage to construct
* per-page WS URLs for callers that still want one (the bridge itself never uses
* them — page sessions ride the browser-WS).
*/
async function attachBrowserBridge({ browser, host, port, rewriteWsUrl, autoAttach = false, onPageSession = null }) {
// The cdp-router sits between browser-session and bridge consumers.
// Page-session-tagged messages dispatch to the right session; root-session
// events (Target.*, etc.) fire root listeners. Command responses without
// sessionId stay correlated by browser-session.js's pendingRequests
// (single source of truth for root-session correlation).
const router = createCdpRouter({ browser });
const targetMap = new Map(); // targetId -> targetInfo
const onCreatedFns = new Set();
const onDestroyedFns = new Set();
router.getRootListeners().add((msg) => {
if (msg.method === 'Target.targetCreated') {
const t = msg.params.targetInfo;
targetMap.set(t.targetId, t);
for (const fn of onCreatedFns) {
try { fn(t); } catch (e) { console.error('targets onCreated handler threw:', e); }
}
} else if (msg.method === 'Target.targetInfoChanged') {
const t = msg.params.targetInfo;
targetMap.set(t.targetId, t);
} else if (msg.method === 'Target.targetDestroyed') {
const t = targetMap.get(msg.params.targetId);
targetMap.delete(msg.params.targetId);
if (t) {
for (const fn of onDestroyedFns) {
try { fn(t); } catch (e) { console.error('targets onDestroyed handler threw:', e); }
}
}
}
});
// Handle auto-attached targets (popups, child frames, etc.) when autoAttach is on.
// Chrome emits this with an already-allocated sessionId — no Target.attachToTarget needed.
//
// Only act on targets that are paused (waitingForDebugger: true). Existing tabs
// that Chrome retrospectively reports via attachedToTarget (waitingForDebugger: false)
// are not paused and will be set up through the normal getPageSession/attachPageSession
// path. Installing a page session here for those targets would register the same
// sessionId twice in the cdp-router, causing duplicate-id protocol errors.
router.getRootListeners().add(async (msg) => {
if (msg.method !== 'Target.attachedToTarget') return;
const { sessionId, targetInfo, waitingForDebugger } = msg.params;
if (!waitingForDebugger) return;
const ps = buildPageSessionFromAttached({ browser, router, sessionId, targetId: targetInfo.targetId });
// Only run the onPageSession hook for page-type targets. Non-page targets
// (service_worker, background_page, etc.) don't support the Page CDP domain
// and would cause 'Page.enable' wasn't found errors in the hook.
if (onPageSession && targetInfo.type === 'page') {
try { await onPageSession(ps); }
catch (e) { console.error('onPageSession hook threw:', e); }
}
// Resume the paused target AFTER the hook so any shims (e.g. dialog) are
// installed before the page's scripts run.
try { await ps.send('Runtime.runIfWaitingForDebugger', {}); }
catch (e) { console.error('Runtime.runIfWaitingForDebugger failed:', e); }
});
// Subscribe — replays existing targets as targetCreated events.
await browser.send('Target.setDiscoverTargets', { discover: true });
if (autoAttach) {
await browser.send('Target.setAutoAttach', {
autoAttach: true,
waitForDebuggerOnStart: true,
flatten: true,
});
}
function list() { return Array.from(targetMap.values()); }
function onCreated(fn) { onCreatedFns.add(fn); return () => onCreatedFns.delete(fn); }
function onDestroyed(fn) { onDestroyedFns.add(fn); return () => onDestroyedFns.delete(fn); }
function waitForNew(predicate, { timeoutMs = 15000 } = {}) {
return new Promise((resolve, reject) => {
let unsub = null;
const timeout = setTimeout(() => {
if (unsub) unsub();
reject(new Error(`waitForNew: timed out after ${timeoutMs}ms`));
}, timeoutMs);
unsub = onCreated((t) => {
let match;
try { match = predicate(t); }
catch (e) {
clearTimeout(timeout);
if (unsub) unsub();
reject(e);
return;
}
if (match) {
clearTimeout(timeout);
if (unsub) unsub();
resolve(t);
}
});
});
}
/**
* createBrowserContext({proxyServer?}) — creates a Chrome BrowserContext.
* Returns {browserContextId, createPage, dispose}.
*
* createPage(url) calls Target.createTarget({url, browserContextId}) and
* constructs a tab-shape-compatible page handle whose webSocketDebuggerUrl
* is run through rewriteWsUrl.
*
* dispose() is atomic — Chrome tears down cookies/storage/IDB/SW for the
* context in one call.
*/
async function createBrowserContext(opts = {}) {
const params = {};
if (opts.proxyServer) params.proxyServer = opts.proxyServer;
const { browserContextId } = await browser.send('Target.createBrowserContext', params);
let disposed = false;
async function createPage(url = 'about:blank') {
if (disposed) throw new Error('BrowserContext disposed');
const { targetId } = await browser.send('Target.createTarget', { url, browserContextId });
const rawWsUrl = `ws://${host}:${port}/devtools/page/${targetId}`;
return {
id: targetId, targetId,
webSocketDebuggerUrl: rewriteWsUrl(rawWsUrl, host, port),
type: 'page', url, browserContextId,
};
}
async function dispose() {
if (disposed) return;
disposed = true;
try { await browser.send('Target.disposeBrowserContext', { browserContextId }); }
catch (e) { console.warn('BrowserContext.dispose() failed:', e && e.message); }
}
return { browserContextId, createPage, dispose };
}
async function attachPage(targetId) {
return attachPageSession({ browser, router }, targetId);
}
return {
targets: { list, onCreated, onDestroyed, waitForNew },
createBrowserContext,
attachPageSession: attachPage,
router,
};
}
module.exports = { attachBrowserBridge };
'use strict';
const { WebSocketClient: DefaultWebSocketClient } = require('./websocket-client');
/**
* createBrowserSession({host, port, rewriteWsUrl, chromeHttp, WebSocketClient?}) -> bridge handle.
*
* Owns the one root WebSocket to /devtools/browser/<id>. Page-action commands ride
* per-page sessions (attached via Target.attachToTarget({flatten:true})) and envelope
* messages with a sessionId via sendRaw — the page session manages its own pendingRequests
* (in the cdp-router). browser-session correlates ROOT-session command responses only.
*
* Returned API:
* send(method, params?, {timeoutMs?}) -> Promise<result> // root command
* onEvent(handler) -> unsub fn
* close() -> Promise<void>
* isConnected() -> boolean
* sendRaw(json) -> void
*/
function createBrowserSession({ host, port, rewriteWsUrl, chromeHttp, WebSocketClient = DefaultWebSocketClient }) {
let ws = null;
const pendingRequests = new Map(); // id -> {resolve, reject, timeout}
let messageIdCounter = 1;
const eventListeners = new Set();
let connectPromise = null;
let closed = false;
async function ensureConnected() {
if (ws && ws.isConnected()) return;
if (connectPromise) { await connectPromise; return; }
connectPromise = (async () => {
try {
const versionInfo = await chromeHttp('/json/version');
if (!versionInfo || !versionInfo.webSocketDebuggerUrl) {
throw new Error('chromeHttp(/json/version) returned no webSocketDebuggerUrl');
}
const url = rewriteWsUrl(versionInfo.webSocketDebuggerUrl, host, port);
const next = new WebSocketClient(url);
next.on('message', (raw) => {
let data;
try { data = JSON.parse(raw); } catch (e) {
console.error('browser-session: bad JSON from CDP:', e);
return;
}
// Correlate ROOT-session command responses (id without sessionId). Page-session
// responses carry {id, result, sessionId} and fall through to event listeners
// for the cdp-router to dispatch.
if (data.id !== undefined && data.sessionId === undefined) {
const pending = pendingRequests.get(data.id);
if (pending) {
clearTimeout(pending.timeout);
pendingRequests.delete(data.id);
if (data.error) {
pending.reject(new Error(data.error.message || JSON.stringify(data.error)));
} else {
pending.resolve(data.result);
}
return;
}
}
for (const fn of eventListeners) {
try { fn(data); } catch (e) { console.error('browser-session listener threw:', e); }
}
});
next.on('close', () => {
for (const [, p] of pendingRequests) {
clearTimeout(p.timeout);
p.reject(new Error('Browser session WS closed'));
}
pendingRequests.clear();
});
await next.connect();
// Assign ws only after a successful connect so concurrent callers that hit the
// `ws && ws.isConnected()` early-return don't see a half-initialized socket.
ws = next;
} catch (e) {
// Allow retry after a transient failure (network blip, Chrome not yet ready, etc.).
// We do NOT null on success — leaving the resolved promise in place makes subsequent
// ensureConnected() awaits a no-op.
connectPromise = null;
throw e;
}
})();
await connectPromise;
}
async function send(method, params = {}, { timeoutMs = 10000 } = {}) {
if (closed) throw new Error('Browser session closed');
await ensureConnected();
if (closed) throw new Error('Browser session closed');
const id = messageIdCounter++;
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
pendingRequests.delete(id);
reject(new Error(`Browser session timeout: ${method}`));
}, timeoutMs);
pendingRequests.set(id, { resolve, reject, timeout });
try {
ws.send(JSON.stringify({ id, method, params }));
} catch (e) {
clearTimeout(timeout);
pendingRequests.delete(id);
reject(e);
}
});
}
function onEvent(handler) {
eventListeners.add(handler);
return () => eventListeners.delete(handler);
}
async function close() {
closed = true;
if (ws) { ws.close(); ws = null; }
for (const [, p] of pendingRequests) {
clearTimeout(p.timeout);
p.reject(new Error('Browser session closed'));
}
pendingRequests.clear();
eventListeners.clear();
}
function isConnected() { return ws !== null && ws.isConnected(); }
function sendRaw(json) {
if (closed) throw new Error('Browser session closed');
if (!ws || !ws.isConnected()) {
throw new Error('Browser WS not connected (call send() first to lazy-open)');
}
ws.send(json);
}
return { send, onEvent, close, isConnected, sendRaw };
}
module.exports = { createBrowserSession };
'use strict';
/**
* createCdpRouter({browser}) — sessionId-aware dispatcher for browser-WS messages.
*
* Routing rules:
* - msg.sessionId set -> per-session pendingRequests (if msg.id) or eventListeners (if msg.method)
* - msg.method, no sessionId -> root listeners (target events, etc.)
* - msg.id, no sessionId -> falls through (browser-session.js owns root correlation)
*
* Per-session message-id counters are independent. {id:1, sessionId:"A"} and
* {id:1, sessionId:"B"} correlate independently on one WS — collapsing id space
* across sessions would silently break correlation.
*/
function createCdpRouter({ browser }) {
const sessions = new Map(); // sessionId -> { pendingRequests, eventListeners }
const rootListeners = new Set();
browser.onEvent((msg) => {
const sid = msg.sessionId;
if (sid) {
const sess = sessions.get(sid);
if (!sess) return; // detached or never registered — drop silently
if (msg.id !== undefined) {
const pending = sess.pendingRequests.get(msg.id);
if (pending) {
clearTimeout(pending.timeout);
sess.pendingRequests.delete(msg.id);
if (msg.error) {
pending.reject(new Error(msg.error.message || JSON.stringify(msg.error)));
} else {
pending.resolve(msg.result);
}
}
} else if (msg.method) {
for (const fn of sess.eventListeners) {
try { fn(msg); } catch (e) { console.error('cdp-router page listener threw:', e); }
}
}
} else if (msg.method) {
for (const fn of rootListeners) {
try { fn(msg); } catch (e) { console.error('cdp-router root listener threw:', e); }
}
}
// Untagged responses (msg.id with no sessionId, no method) intentionally
// fall through — browser-session.js's pendingRequests Map handles them.
});
function registerSession(sessionId) {
const sess = { pendingRequests: new Map(), eventListeners: new Set() };
sessions.set(sessionId, sess);
return sess;
}
function unregisterSession(sessionId) {
const sess = sessions.get(sessionId);
if (!sess) return;
for (const [, p] of sess.pendingRequests) {
clearTimeout(p.timeout);
p.reject(new Error('Page session detached'));
}
sess.pendingRequests.clear();
sess.eventListeners.clear();
sessions.delete(sessionId);
}
function getRootListeners() { return rootListeners; }
return { registerSession, unregisterSession, getRootListeners };
}
module.exports = { createCdpRouter };
/**
* Shared utilities for CDP responses.
*
* `throwIfExceptionDetails(result)` inspects a `Runtime.evaluate` reply and
* throws if the page-side JS threw or a Promise rejected. Without this,
* callers silently see `undefined` instead of the actual error — which has
* caused real bugs (waitForElement timeouts swallowed, evaluate returning {}
* for thrown errors). Use after every `sendCdpCommand(...,'Runtime.evaluate',...)`.
*/
function throwIfExceptionDetails(result) {
if (!result || !result.exceptionDetails) return;
const desc = result.exceptionDetails.exception?.description
|| result.exceptionDetails.text
|| 'unknown evaluation error';
throw new Error(`evaluate failed: ${desc}`);
}
module.exports = { throwIfExceptionDetails };
/**
* Page console-message capture.
*
* `enableConsoleLogging` subscribes to `Runtime.consoleAPICalled` events on
* the existing pageSession (bridge) connection and streams console output into
* `state.consoleMessages` keyed by `sessionId`.
*
* `getConsoleMessages` reads the buffer — optionally filtered by timestamp.
* `clearConsoleMessages` resets the buffer for a tab.
*
* `attachConsoleLogging({ state, getPageSession })` returns the bound API.
*/
function attachConsoleLogging({ state, getPageSession }) {
async function enableConsoleLogging(tabIndexOrWsUrl) {
const ps = await getPageSession(tabIndexOrWsUrl);
if (!state.consoleMessages.has(ps.sessionId)) {
state.consoleMessages.set(ps.sessionId, []);
}
await ps.enableDomain('Runtime');
ps.onEvent((msg) => {
if (msg.method === 'Runtime.consoleAPICalled') {
const entry = msg.params;
const timestamp = new Date().toISOString();
const level = entry.type || 'log';
const args = entry.args || [];
const text = args.map(arg => {
if (arg.type === 'string') return arg.value;
if (arg.type === 'number') return String(arg.value);
if (arg.type === 'boolean') return String(arg.value);
if (arg.type === 'object') return arg.description || '[Object]';
return String(arg.value || arg.description || arg.type);
}).join(' ');
const messages = state.consoleMessages.get(ps.sessionId) || [];
// Dedup: skip if the last entry has the same level+text at the same
// timestamp (prevents double-fire when multiple CDP event listeners
// route the same console call through the same handler).
const last = messages[messages.length - 1];
if (!last || last.timestamp !== timestamp || last.level !== level || last.text !== text) {
messages.push({ timestamp, level, text });
state.consoleMessages.set(ps.sessionId, messages);
}
}
});
}
async function getConsoleMessages(tabIndexOrWsUrl, sinceTime = null) {
const ps = await getPageSession(tabIndexOrWsUrl);
const messages = state.consoleMessages.get(ps.sessionId) || [];
if (!sinceTime) {
return messages;
}
return messages.filter(msg => new Date(msg.timestamp) > sinceTime);
}
async function clearConsoleMessages(tabIndexOrWsUrl) {
const ps = await getPageSession(tabIndexOrWsUrl);
state.consoleMessages.set(ps.sessionId, []);
}
return { enableConsoleLogging, getConsoleMessages, clearConsoleMessages };
}
module.exports = { attachConsoleLogging };
/**
* Cookie management — currently just a single "clear everything" action.
*
* Takes `getPageSession(tabIndexOrWsUrl)`: a resolver provided by chrome-ws-lib
* that handles both tab-index and ws-url inputs, lazy-bootstraps the CDP bridge,
* and returns a pageSession driving CDP via flatten mode.
*/
function attachCookies({ getPageSession }) {
async function clearCookies(tabIndexOrWsUrl) {
const ps = await getPageSession(tabIndexOrWsUrl);
await ps.send('Network.clearBrowserCookies', {});
}
return { clearCookies };
}
module.exports = { attachCookies };
'use strict';
function renderSyntheticArtifacts(s) {
const origin = s.payload.url || '(unknown)';
let markdown;
if (s.kind === 'alert') {
markdown = [
`# Dialog: alert`,
`Tab origin: ${origin}`,
``,
`> ${s.payload.message}`,
``,
`Buttons:`,
` - dialog::accept (OK)`,
``,
`To interact:`,
` click selector="dialog::accept"`,
].join('\n');
} else if (s.kind === 'confirm') {
markdown = [
`# Dialog: confirm`,
`Tab origin: ${origin}`,
``,
`> ${s.payload.message}`,
``,
`Buttons:`,
` - dialog::accept (OK)`,
` - dialog::dismiss (Cancel)`,
``,
`To interact:`,
` click selector="dialog::accept"`,
` click selector="dialog::dismiss"`,
].join('\n');
} else if (s.kind === 'prompt') {
const lines = [
`# Dialog: prompt`,
`Tab origin: ${origin}`,
``,
`> ${s.payload.message}`,
];
if (s.payload.defaultPrompt) lines.push(`Default: "${s.payload.defaultPrompt}"`);
lines.push(``, `Input: dialog::prompt (type text here, then click dialog::accept)`);
lines.push(`Buttons:`, ` - dialog::accept`, ` - dialog::dismiss`);
markdown = lines.join('\n');
} else if (s.kind === 'beforeunload') {
markdown = [
`# Dialog: beforeunload`,
`Tab origin: ${origin}`,
``,
`> ${s.payload.message || 'The page wants to confirm you really want to leave.'}`,
``,
`Buttons:`,
` - dialog::accept (Leave)`,
` - dialog::dismiss (Stay)`,
``,
`To interact:`,
` click selector="dialog::accept"`,
` click selector="dialog::dismiss"`,
].join('\n');
} else if (s.kind === 'device-chooser') {
const kindLabel = { usb: 'USB', bluetooth: 'Bluetooth', serial: 'Serial', hid: 'HID' }[s.payload.deviceKind] || s.payload.deviceKind;
const lines = [
`# Dialog: device-chooser (${s.payload.deviceKind})`,
`Origin requested a ${kindLabel} device.`,
``,
];
if (s.payload.devices.length === 0) {
lines.push(`(No devices visible.)`);
} else {
lines.push(`Devices:`);
for (const d of s.payload.devices) {
lines.push(` - dialog::device[id="${d.id}"] "${d.name}"`);
}
}
lines.push(``, `Buttons:`, ` - dialog::dismiss (Cancel)`);
markdown = lines.join('\n');
} else if (s.kind === 'permission') {
markdown = [
`# Dialog: permission`,
`Origin ${s.payload.origin} requested: ${s.payload.name}`,
`JS API: ${s.payload.jsApi}`,
``,
`Buttons:`,
` - dialog::accept (grant for this origin)`,
` - dialog::dismiss (deny for this origin)`,
].join('\n');
} else if (s.kind === 'basic-auth') {
const header = s.payload.realm
? `Origin ${s.payload.origin} — realm "${s.payload.realm}"`
: `Origin ${s.payload.origin}`;
markdown = [
`# Dialog: basic-auth`,
header,
``,
`Inputs:`,
` dialog::username`,
` dialog::password`,
``,
`Buttons:`,
` - dialog::accept`,
` - dialog::dismiss`,
].join('\n');
} else {
markdown = `# Dialog: ${s.kind}\n(unsupported in this render path)`;
}
const htmlParts = [
'<!doctype html>',
'<html><head><title>Dialog</title></head><body>',
`<h1>Dialog: ${s.kind}</h1>`,
];
if (s.kind === 'prompt') {
htmlParts.push('<input id="dialog-prompt" type="text">');
}
if (s.kind === 'basic-auth') {
htmlParts.push('<input id="dialog-username" type="text">');
htmlParts.push('<input id="dialog-password" type="password">');
}
if (s.kind === 'device-chooser') {
for (const d of s.payload.devices) {
htmlParts.push(`<button data-device-id="${d.id}">${d.name}</button>`);
}
}
const acceptKinds = new Set(['alert', 'confirm', 'prompt', 'beforeunload', 'permission', 'basic-auth']);
const dismissKinds = new Set(['confirm', 'prompt', 'beforeunload', 'device-chooser', 'permission', 'basic-auth']);
if (acceptKinds.has(s.kind)) htmlParts.push('<button id="dialog-accept">Accept</button>');
if (dismissKinds.has(s.kind)) htmlParts.push('<button id="dialog-dismiss">Dismiss</button>');
htmlParts.push('</body></html>');
const html = htmlParts.join('\n');
return { markdown, html, consoleSnapshot: '' };
}
function renderResponseSummary(s, tabIndex) {
const lines = [];
lines.push(`Dialog open on tab ${tabIndex}: ${s.kind}`);
if (s.payload.message) lines.push(` Message: "${s.payload.message}"`);
if (s.kind === 'alert') {
lines.push(` Handle with: click dialog::accept`);
} else if (s.kind === 'device-chooser') {
lines.push(` Handle with: click dialog::device[id="..."] | click dialog::dismiss`);
} else if (s.kind === 'basic-auth') {
lines.push(` Handle with: type dialog::username, type dialog::password, click dialog::accept | click dialog::dismiss`);
} else if (s.kind === 'prompt') {
lines.push(` Handle with: type dialog::prompt, click dialog::accept | click dialog::dismiss`);
} else {
lines.push(` Handle with: click dialog::accept | click dialog::dismiss`);
}
lines.push(`(no screenshot — dialog overlay is browser-native UI)`);
return lines.join('\n');
}
module.exports = { renderSyntheticArtifacts, renderResponseSummary };
/**
* Programmatic file upload via `DOM.setFileInputFiles` — the only way to
* set files on an `<input type="file">` from outside the page, since JS
* security restrictions block synthetic file assignment.
*
* Resolves the input element via `DOM.querySelector` for CSS selectors
* or `DOM.performSearch` + `DOM.getSearchResults` for XPath, then attaches
* the absolute file paths to the input.
*
* `attachFileUpload({ getPageSession })` returns the bound action.
*/
function attachFileUpload({ getPageSession }) {
async function fileUpload(tabIndexOrWsUrl, selector, filePaths) {
const pageSession = await getPageSession(tabIndexOrWsUrl);
const docResult = await pageSession.send('DOM.getDocument', {});
const rootNodeId = docResult.root.nodeId;
let nodeId;
if (selector.startsWith('/') || selector.startsWith('//')) {
const searchResult = await pageSession.send('DOM.performSearch', {
query: selector
});
if (searchResult.resultCount === 0) {
throw new Error(`File input not found: ${selector}`);
}
const nodesResult = await pageSession.send('DOM.getSearchResults', {
searchId: searchResult.searchId,
fromIndex: 0,
toIndex: 1
});
nodeId = nodesResult.nodeIds[0];
} else {
const queryResult = await pageSession.send('DOM.querySelector', {
nodeId: rootNodeId,
selector: selector
});
nodeId = queryResult.nodeId;
}
if (!nodeId) {
throw new Error(`File input not found: ${selector}`);
}
await pageSession.send('DOM.setFileInputFiles', {
files: filePaths,
nodeId: nodeId
});
return { uploaded: true, files: filePaths.length };
}
return { fileUpload };
}
module.exports = { attachFileUpload };
Related skills
How it compares
Pick browsing over Playwright-based skills when agents need a zero-dependency local Chrome CLI for quick verification rather than cross-browser CI test suites.
FAQ
Does browsing require npm install?
browsing uses obra/superpowers-chrome with zero npm dependencies. Agents run the chrome-ws CLI directly; Node.js 16+ and a Chrome binary with remote debugging are the only requirements.
How many commands does chrome-ws provide?
chrome-ws exposes 17 commands covering Chrome start, tab management, navigation, form interaction, content extraction, screenshots, waits, and raw Chrome DevTools Protocol access.
Is Browsing safe to install?
skills.sh reports 0 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.