Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Interact with a local Chrome browser session via Chrome DevTools Protocol — screenshots, JS eval, clicks, navigation, and accessibility trees.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/cdp.mjs
1#!/usr/bin/env node2// cdp - lightweight Chrome DevTools Protocol CLI3// Uses raw CDP over WebSocket, no Puppeteer dependency.4// Requires Node 22+ (built-in WebSocket).5//6// Per-tab persistent daemon: page commands go through a daemon that holds7// the CDP session open. Chrome's "Allow debugging" modal fires once per8// daemon (= once per tab). Daemons auto-exit after 20min idle.910import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from 'fs';11import { homedir } from 'os';12import { resolve } from 'path';13import { spawn } from 'child_process';14import net from 'net';1516const TIMEOUT = 15000;17const NAVIGATION_TIMEOUT = 30000;18const IDLE_TIMEOUT = 20 * 60 * 1000;19const DAEMON_CONNECT_RETRIES = 20;20const DAEMON_CONNECT_DELAY = 300;21const MIN_TARGET_PREFIX_LEN = 8;22const IS_WINDOWS = process.platform === 'win32';23if (!IS_WINDOWS) process.umask(0o077);24const RUNTIME_DIR = IS_WINDOWS25? resolve(process.env.LOCALAPPDATA || resolve(homedir(), 'AppData', 'Local'), 'cdp')26: process.env.XDG_RUNTIME_DIR27? resolve(process.env.XDG_RUNTIME_DIR, 'cdp')28: resolve(homedir(), '.cache', 'cdp');29try { mkdirSync(RUNTIME_DIR, { recursive: true, mode: 0o700 }); } catch {}30const PAGES_CACHE = resolve(RUNTIME_DIR, 'pages.json');3132function sockPath(targetId) {33return IS_WINDOWS34? `\\\\.\\pipe\\cdp-${targetId}`35: resolve(RUNTIME_DIR, `cdp-${targetId}.sock`);36}3738function getWsUrl() {39const home = homedir();40// macOS: ~/Library/Application Support/<name>/DevToolsActivePort41const macBrowsers = [42'Google/Chrome', 'Google/Chrome Beta', 'Google/Chrome for Testing',43'Chromium', 'BraveSoftware/Brave-Browser', 'Microsoft Edge',44];45// Linux: ~/.config/<name>/DevToolsActivePort46const linuxBrowsers = [47'google-chrome', 'google-chrome-beta', 'chromium',48'vivaldi', 'vivaldi-snapshot',49'BraveSoftware/Brave-Browser', 'microsoft-edge',50];51// Linux Flatpak: ~/.var/app/<app-id>/config/<name>/DevToolsActivePort52const flatpakBrowsers = [53['org.chromium.Chromium', 'chromium'],54['com.google.Chrome', 'google-chrome'],55['com.brave.Browser', 'BraveSoftware/Brave-Browser'],56['com.microsoft.Edge', 'microsoft-edge'],57['com.vivaldi.Vivaldi', 'vivaldi'],58];59const candidates = [60process.env.CDP_PORT_FILE,61...macBrowsers.flatMap(b => [62resolve(home, 'Library/Application Support', b, 'DevToolsActivePort'),63resolve(home, 'Library/Application Support', b, 'Default/DevToolsActivePort'),64]),65...linuxBrowsers.flatMap(b => [66resolve(home, '.config', b, 'DevToolsActivePort'),67resolve(home, '.config', b, 'Default/DevToolsActivePort'),68]),69...flatpakBrowsers.flatMap(([appId, name]) => [70resolve(home, '.var/app', appId, 'config', name, 'DevToolsActivePort'),71resolve(home, '.var/app', appId, 'config', name, 'Default/DevToolsActivePort'),72]),73// Windows: %LOCALAPPDATA%/<name>/User Data/DevToolsActivePort74...(IS_WINDOWS ? ['Google/Chrome', 'BraveSoftware/Brave-Browser', 'Microsoft/Edge'].flatMap(b => {75const base = process.env.LOCALAPPDATA || resolve(home, 'AppData/Local');76return [77resolve(base, b, 'User Data/DevToolsActivePort'),78resolve(base, b, 'User Data/Default/DevToolsActivePort'),79];80}) : []),81].filter(Boolean);82const portFile = candidates.find(p => existsSync(p));83if (!portFile) throw new Error('No DevToolsActivePort found. Enable remote debugging at chrome://inspect/#remote-debugging');84const lines = readFileSync(portFile, 'utf8').trim().split('\n');85if (lines.length < 2 || !lines[0] || !lines[1]) throw new Error(`Invalid DevToolsActivePort file: ${portFile}`);86const host = process.env.CDP_HOST || '127.0.0.1';87return `ws://${host}:${lines[0]}${lines[1]}`;88}8990const sleep = (ms) => new Promise(r => setTimeout(r, ms));919293function resolvePrefix(prefix, candidates, noun = 'target', missingHint = '') {94const upper = prefix.toUpperCase();95const matches = candidates.filter(candidate => candidate.toUpperCase().startsWith(upper));96if (matches.length === 0) {97const hint = missingHint ? ` ${missingHint}` : '';98throw new Error(`No ${noun} matching prefix "${prefix}".${hint}`);99}100if (matches.length > 1) {101throw new Error(`Ambiguous prefix "${prefix}" — matches ${matches.length} ${noun}s. Use more characters.`);102}103return matches[0];104}105106function getDisplayPrefixLength(targetIds) {107if (targetIds.length === 0) return MIN_TARGET_PREFIX_LEN;108const maxLen = Math.max(...targetIds.map(id => id.length));109for (let len = MIN_TARGET_PREFIX_LEN; len <= maxLen; len++) {110const prefixes = new Set(targetIds.map(id => id.slice(0, len).toUpperCase()));111if (prefixes.size === targetIds.length) return len;112}113return maxLen;114}115116// ---------------------------------------------------------------------------117// CDP WebSocket client118// ---------------------------------------------------------------------------119120class CDP {121#ws; #id = 0; #pending = new Map(); #eventHandlers = new Map(); #closeHandlers = [];122123async connect(wsUrl) {124return new Promise((res, rej) => {125this.#ws = new WebSocket(wsUrl);126this.#ws.onopen = () => res();127this.#ws.onerror = (e) => rej(new Error('WebSocket error: ' + (e.message || e.type)));128this.#ws.onclose = () => this.#closeHandlers.forEach(h => h());129this.#ws.onmessage = (ev) => {130const msg = JSON.parse(ev.data);131if (msg.id && this.#pending.has(msg.id)) {132const { resolve, reject } = this.#pending.get(msg.id);133this.#pending.delete(msg.id);134if (msg.error) reject(new Error(msg.error.message));135else resolve(msg.result);136} else if (msg.method && this.#eventHandlers.has(msg.method)) {137for (const handler of [...this.#eventHandlers.get(msg.method)]) {138handler(msg.params || {}, msg);139}140}141};142});143}144145send(method, params = {}, sessionId) {146const id = ++this.#id;147return new Promise((resolve, reject) => {148this.#pending.set(id, { resolve, reject });149const msg = { id, method, params };150if (sessionId) msg.sessionId = sessionId;151this.#ws.send(JSON.stringify(msg));152setTimeout(() => {153if (this.#pending.has(id)) {154this.#pending.delete(id);155reject(new Error(`Timeout: ${method}`));156}157}, TIMEOUT);158});159}160161onEvent(method, handler) {162if (!this.#eventHandlers.has(method)) this.#eventHandlers.set(method, new Set());163const handlers = this.#eventHandlers.get(method);164handlers.add(handler);165return () => {166handlers.delete(handler);167if (handlers.size === 0) this.#eventHandlers.delete(method);168};169}170171waitForEvent(method, timeout = TIMEOUT) {172let settled = false;173let off;174let timer;175const promise = new Promise((resolve, reject) => {176off = this.onEvent(method, (params) => {177if (settled) return;178settled = true;179clearTimeout(timer);180off();181resolve(params);182});183timer = setTimeout(() => {184if (settled) return;185settled = true;186off();187reject(new Error(`Timeout waiting for event: ${method}`));188}, timeout);189});190return {191promise,192cancel() {193if (settled) return;194settled = true;195clearTimeout(timer);196off?.();197},198};199}200201onClose(handler) { this.#closeHandlers.push(handler); }202close() { this.#ws.close(); }203}204205// ---------------------------------------------------------------------------206// Command implementations — return strings, take (cdp, sessionId)207// ---------------------------------------------------------------------------208209async function getPages(cdp) {210const { targetInfos } = await cdp.send('Target.getTargets');211return targetInfos.filter(t => t.type === 'page' && !t.url.startsWith('chrome://'));212}213214function formatPageList(pages) {215const prefixLen = getDisplayPrefixLength(pages.map(p => p.targetId));216return pages.map(p => {217const id = p.targetId.slice(0, prefixLen).padEnd(prefixLen);218const title = p.title.substring(0, 54).padEnd(54);219return `${id} ${title} ${p.url}`;220}).join('\n');221}222223function shouldShowAxNode(node, compact = false) {224const role = node.role?.value || '';225const name = node.name?.value ?? '';226const value = node.value?.value;227if (compact && role === 'InlineTextBox') return false;228return role !== 'none' && role !== 'generic' && !(name === '' && (value === '' || value == null));229}230231function formatAxNode(node, depth) {232const role = node.role?.value || '';233const name = node.name?.value ?? '';234const value = node.value?.value;235const indent = ' '.repeat(Math.min(depth, 10));236let line = `${indent}[${role}]`;237if (name !== '') line += ` ${name}`;238if (!(value === '' || value == null)) line += ` = ${JSON.stringify(value)}`;239return line;240}241242function orderedAxChildren(node, nodesById, childrenByParent) {243const children = [];244const seen = new Set();245for (const childId of node.childIds || []) {246const child = nodesById.get(childId);247if (child && !seen.has(child.nodeId)) {248seen.add(child.nodeId);249children.push(child);250}251}252for (const child of childrenByParent.get(node.nodeId) || []) {253if (!seen.has(child.nodeId)) {254seen.add(child.nodeId);255children.push(child);256}257}258return children;259}260261async function snapshotStr(cdp, sid, compact = false) {262const { nodes } = await cdp.send('Accessibility.getFullAXTree', {}, sid);263const nodesById = new Map(nodes.map(node => [node.nodeId, node]));264const childrenByParent = new Map();265for (const node of nodes) {266if (!node.parentId) continue;267if (!childrenByParent.has(node.parentId)) childrenByParent.set(node.parentId, []);268childrenByParent.get(node.parentId).push(node);269}270271const lines = [];272const visited = new Set();273function visit(node, depth) {274if (!node || visited.has(node.nodeId)) return;275visited.add(node.nodeId);276if (shouldShowAxNode(node, compact)) lines.push(formatAxNode(node, depth));277for (const child of orderedAxChildren(node, nodesById, childrenByParent)) {278visit(child, depth + 1);279}280}281282const roots = nodes.filter(node => !node.parentId || !nodesById.has(node.parentId));283for (const root of roots) visit(root, 0);284for (const node of nodes) visit(node, 0);285286return lines.join('\n');287}288289async function evalStr(cdp, sid, expression) {290await cdp.send('Runtime.enable', {}, sid);291const result = await cdp.send('Runtime.evaluate', {292expression, returnByValue: true, awaitPromise: true,293}, sid);294if (result.exceptionDetails) {295throw new Error(result.exceptionDetails.text || result.exceptionDetails.exception?.description);296}297const val = result.result.value;298return typeof val === 'object' ? JSON.stringify(val, null, 2) : String(val ?? '');299}300301async function shotStr(cdp, sid, filePath, targetId) {302// Get device scale factor so we can report coordinate mapping303let dpr = 1;304try {305const metrics = await cdp.send('Page.getLayoutMetrics', {}, sid);306dpr = metrics.visualViewport?.clientWidth307? metrics.cssVisualViewport?.clientWidth308? Math.round((metrics.visualViewport.clientWidth / metrics.cssVisualViewport.clientWidth) * 100) / 100309: 1310: 1;311// Simpler: deviceScaleFactor is on the root Page metrics312const { deviceScaleFactor } = await cdp.send('Emulation.getDeviceMetricsOverride', {}, sid).catch(() => ({}));313if (deviceScaleFactor) dpr = deviceScaleFactor;314} catch {}315// Fallback: try to get DPR from JS316if (dpr === 1) {317try {318const raw = await evalStr(cdp, sid, 'window.devicePixelRatio');319const parsed = parseFloat(raw);320if (parsed > 0) dpr = parsed;321} catch {}322}323324const { data } = await cdp.send('Page.captureScreenshot', { format: 'png' }, sid);325const out = filePath || resolve(RUNTIME_DIR, `screenshot-${(targetId || 'unknown').slice(0, 8)}.png`);326writeFileSync(out, Buffer.from(data, 'base64'));327328const lines = [out];329lines.push(`Screenshot saved. Device pixel ratio (DPR): ${dpr}`);330lines.push(`Coordinate mapping:`);331lines.push(` Screenshot pixels → CSS pixels (for CDP Input events): divide by ${dpr}`);332lines.push(` e.g. screenshot point (${Math.round(100 * dpr)}, ${Math.round(200 * dpr)}) → CSS (100, 200) → use clickxy <target> 100 200`);333if (dpr !== 1) {334lines.push(` On this ${dpr}x display: CSS px = screenshot px / ${dpr} ≈ screenshot px × ${Math.round(100/dpr)/100}`);335}336return lines.join('\n');337}338339async function htmlStr(cdp, sid, selector) {340const expr = selector341? `document.querySelector(${JSON.stringify(selector)})?.outerHTML || 'Element not found'`342: `document.documentElement.outerHTML`;343return evalStr(cdp, sid, expr);344}345346async function waitForDocumentReady(cdp, sid, timeoutMs = NAVIGATION_TIMEOUT) {347const deadline = Date.now() + timeoutMs;348let lastState = '';349let lastError;350while (Date.now() < deadline) {351try {352const state = await evalStr(cdp, sid, 'document.readyState');353lastState = state;354if (state === 'complete') return;355} catch (e) {356lastError = e;357}358await sleep(200);359}360361if (lastState) {362throw new Error(`Timed out waiting for navigation to finish (last readyState: ${lastState})`);363}364if (lastError) {365throw new Error(`Timed out waiting for navigation to finish (${lastError.message})`);366}367throw new Error('Timed out waiting for navigation to finish');368}369370async function navStr(cdp, sid, url) {371try {372const parsed = new URL(url);373if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')374throw new Error(`Only http/https URLs allowed, got: ${url}`);375} catch (e) {376if (e.message.startsWith('Only')) throw e;377throw new Error(`Invalid URL: ${url}`);378}379await cdp.send('Page.enable', {}, sid);380const loadEvent = cdp.waitForEvent('Page.loadEventFired', NAVIGATION_TIMEOUT);381const result = await cdp.send('Page.navigate', { url }, sid);382if (result.errorText) {383loadEvent.cancel();384throw new Error(result.errorText);385}386if (result.loaderId) {387await loadEvent.promise;388} else {389loadEvent.cancel();390}391await waitForDocumentReady(cdp, sid, 5000);392return `Navigated to ${url}`;393}394395async function netStr(cdp, sid) {396const raw = await evalStr(cdp, sid, `JSON.stringify(performance.getEntriesByType('resource').map(e => ({397name: e.name.substring(0, 120), type: e.initiatorType,398duration: Math.round(e.duration), size: e.transferSize399})))`);400return JSON.parse(raw).map(e =>401`${String(e.duration).padStart(5)}ms ${String(e.size || '?').padStart(8)}B ${e.type.padEnd(8)} ${e.name}`402).join('\n');403}404405// Click element by CSS selector406async function clickStr(cdp, sid, selector) {407if (!selector) throw new Error('CSS selector required');408const expr = `409(function() {410const el = document.querySelector(${JSON.stringify(selector)});411if (!el) return { ok: false, error: 'Element not found: ' + ${JSON.stringify(selector)} };412el.scrollIntoView({ block: 'center' });413el.click();414return { ok: true, tag: el.tagName, text: el.textContent.trim().substring(0, 80) };415})()416`;417const result = await evalStr(cdp, sid, expr);418const r = JSON.parse(result);419if (!r.ok) throw new Error(r.error);420return `Clicked <${r.tag}> "${r.text}"`;421}422423// Click at CSS pixel coordinates using Input.dispatchMouseEvent424async function clickXyStr(cdp, sid, x, y) {425const cx = parseFloat(x);426const cy = parseFloat(y);427if (isNaN(cx) || isNaN(cy)) throw new Error('x and y must be numbers (CSS pixels)');428const base = { x: cx, y: cy, button: 'left', clickCount: 1, modifiers: 0 };429await cdp.send('Input.dispatchMouseEvent', { ...base, type: 'mouseMoved' }, sid);430await cdp.send('Input.dispatchMouseEvent', { ...base, type: 'mousePressed' }, sid);431await sleep(50);432await cdp.send('Input.dispatchMouseEvent', { ...base, type: 'mouseReleased' }, sid);433return `Clicked at CSS (${cx}, ${cy})`;434}435436// Type text using Input.insertText (works in cross-origin iframes, unlike eval)437async function typeStr(cdp, sid, text) {438if (text == null || text === '') throw new Error('text required');439await cdp.send('Input.insertText', { text }, sid);440return `Typed ${text.length} characters`;441}442443// Load-more: repeatedly click a button/selector until it disappears444async function loadAllStr(cdp, sid, selector, intervalMs = 1500) {445if (!selector) throw new Error('CSS selector required');446let clicks = 0;447const deadline = Date.now() + 5 * 60 * 1000; // 5-minute hard cap448while (Date.now() < deadline) {449const exists = await evalStr(cdp, sid,450`!!document.querySelector(${JSON.stringify(selector)})`451);452if (exists !== 'true') break;453const clickExpr = `454(function() {455const el = document.querySelector(${JSON.stringify(selector)});456if (!el) return false;457el.scrollIntoView({ block: 'center' });458el.click();459return true;460})()461`;462const clicked = await evalStr(cdp, sid, clickExpr);463if (clicked !== 'true') break;464clicks++;465await sleep(intervalMs);466}467return `Clicked "${selector}" ${clicks} time(s) until it disappeared`;468}469470// Send a raw CDP command and return the result as JSON471async function evalRawStr(cdp, sid, method, paramsJson) {472if (!method) throw new Error('CDP method required (e.g. "DOM.getDocument")');473let params = {};474if (paramsJson) {475try { params = JSON.parse(paramsJson); }476catch { throw new Error(`Invalid JSON params: ${paramsJson}`); }477}478const result = await cdp.send(method, params, sid);479return JSON.stringify(result, null, 2);480}481482// ---------------------------------------------------------------------------483// Per-tab daemon484// ---------------------------------------------------------------------------485486async function runDaemon(targetId) {487const sp = sockPath(targetId);488489const cdp = new CDP();490try {491await cdp.connect(getWsUrl());492} catch (e) {493process.stderr.write(`Daemon: cannot connect to Chrome: ${e.message}\n`);494process.exit(1);495}496497let sessionId;498try {499const res = await cdp.send('Target.attachToTarget', { targetId, flatten: true });500sessionId = res.sessionId;501} catch (e) {502process.stderr.write(`Daemon: attach failed: ${e.message}\n`);503cdp.close();504process.exit(1);505}506507// Shutdown helpers508let alive = true;509function shutdown() {510if (!alive) return;511alive = false;512server.close();513if (!IS_WINDOWS) try { unlinkSync(sp); } catch {}514cdp.close();515process.exit(0);516}517518// Exit if target goes away or Chrome disconnects519cdp.onEvent('Target.targetDestroyed', (params) => {520if (params.targetId === targetId) shutdown();521});522cdp.onEvent('Target.detachedFromTarget', (params) => {523if (params.sessionId === sessionId) shutdown();524});525cdp.onClose(() => shutdown());526process.on('SIGTERM', shutdown);527process.on('SIGINT', shutdown);528529// Idle timer530let idleTimer = setTimeout(shutdown, IDLE_TIMEOUT);531function resetIdle() {532clearTimeout(idleTimer);533idleTimer = setTimeout(shutdown, IDLE_TIMEOUT);534}535536// Handle a command537async function handleCommand({ cmd, args }) {538resetIdle();539try {540let result;541switch (cmd) {542case 'list': {543const pages = await getPages(cdp);544result = formatPageList(pages);545break;546}547case 'list_raw': {548const pages = await getPages(cdp);549result = JSON.stringify(pages);550break;551}552case 'snap': case 'snapshot': result = await snapshotStr(cdp, sessionId, true); break;553case 'eval': result = await evalStr(cdp, sessionId, args[0]); break;554case 'shot': case 'screenshot': result = await shotStr(cdp, sessionId, args[0], targetId); break;555case 'html': result = await htmlStr(cdp, sessionId, args[0]); break;556case 'nav': case 'navigate': result = await navStr(cdp, sessionId, args[0]); break;557case 'net': case 'network': result = await netStr(cdp, sessionId); break;558case 'click': result = await clickStr(cdp, sessionId, args[0]); break;559case 'clickxy': result = await clickXyStr(cdp, sessionId, args[0], args[1]); break;560case 'type': result = await typeStr(cdp, sessionId, args[0]); break;561case 'loadall': result = await loadAllStr(cdp, sessionId, args[0], args[1] ? parseInt(args[1]) : 1500); break;562case 'evalraw': result = await evalRawStr(cdp, sessionId, args[0], args[1]); break;563case 'stop': return { ok: true, result: '', stopAfter: true };564default: return { ok: false, error: `Unknown command: ${cmd}` };565}566return { ok: true, result: result ?? '' };567} catch (e) {568return { ok: false, error: e.message };569}570}571572// Unix socket server — NDJSON protocol573// Wire format: each message is one JSON object followed by \n (newline-delimited JSON).574// Request: { "id": <number>, "cmd": "<command>", "args": ["arg1", "arg2", ...] }575// Response: { "id": <number>, "ok": <boolean>, "result": "<string>" }576// or { "id": <number>, "ok": false, "error": "<message>" }577const server = net.createServer((conn) => {578let buf = '';579conn.on('data', (chunk) => {580buf += chunk.toString();581const lines = buf.split('\n');582buf = lines.pop(); // keep incomplete last line583for (const line of lines) {584if (!line.trim()) continue;585let req;586try {587req = JSON.parse(line);588} catch {589conn.write(JSON.stringify({ ok: false, error: 'Invalid JSON request', id: null }) + '\n');590continue;591}592handleCommand(req).then((res) => {593const payload = JSON.stringify({ ...res, id: req.id }) + '\n';594if (res.stopAfter) conn.end(payload, shutdown);595else conn.write(payload);596});597}598});599});600601server.on('error', (e) => {602process.stderr.write(`Daemon server listen failed: ${e.message}\n`);603process.exit(1);604});605606if (!IS_WINDOWS) try { unlinkSync(sp); } catch {}607server.listen(sp);608}609610// ---------------------------------------------------------------------------611// CLI ↔ daemon communication612// ---------------------------------------------------------------------------613614function connectToSocket(sp) {615return new Promise((resolve, reject) => {616const conn = net.connect(sp);617conn.on('connect', () => resolve(conn));618conn.on('error', reject);619});620}621622async function getOrStartTabDaemon(targetId) {623const sp = sockPath(targetId);624// Try existing daemon625try { return await connectToSocket(sp); } catch {}626627// Clean stale socket628if (!IS_WINDOWS) try { unlinkSync(sp); } catch {}629630// Spawn daemon631const child = spawn(process.execPath, [process.argv[1], '_daemon', targetId], {632detached: true,633stdio: 'ignore',634});635child.unref();636637// Wait for socket (includes time for user to click Allow)638for (let i = 0; i < DAEMON_CONNECT_RETRIES; i++) {639await sleep(DAEMON_CONNECT_DELAY);640try { return await connectToSocket(sp); } catch {}641}642throw new Error('Daemon failed to start — did you click Allow in Chrome?');643}644645function sendCommand(conn, req) {646return new Promise((resolve, reject) => {647let buf = '';648let settled = false;649650const cleanup = () => {651conn.off('data', onData);652conn.off('error', onError);653conn.off('end', onEnd);654conn.off('close', onClose);655};656657const onData = (chunk) => {658buf += chunk.toString();659const idx = buf.indexOf('\n');660if (idx === -1) return;661settled = true;662cleanup();663resolve(JSON.parse(buf.slice(0, idx)));664conn.end();665};666667const onError = (error) => {668if (settled) return;669settled = true;670cleanup();671reject(error);672};673674const onEnd = () => {675if (settled) return;676settled = true;677cleanup();678reject(new Error('Connection closed before response'));679};680681const onClose = () => {682if (settled) return;683settled = true;684cleanup();685reject(new Error('Connection closed before response'));686};687688conn.on('data', onData);689conn.on('error', onError);690conn.on('end', onEnd);691conn.on('close', onClose);692req.id = 1;693conn.write(JSON.stringify(req) + '\n');694});695}696697// ---------------------------------------------------------------------------698// Stop daemons699// ---------------------------------------------------------------------------700701async function stopDaemons(targetPrefix) {702if (!existsSync(PAGES_CACHE)) return;703const pages = JSON.parse(readFileSync(PAGES_CACHE, 'utf8'));704const targets = targetPrefix705? [resolvePrefix(targetPrefix, pages.map(p => p.targetId), 'target')]706: pages.map(p => p.targetId);707708for (const targetId of targets) {709const sp = sockPath(targetId);710try {711const conn = await connectToSocket(sp);712await sendCommand(conn, { cmd: 'stop' });713} catch {714if (!IS_WINDOWS) try { unlinkSync(sp); } catch {}715}716}717}718719// ---------------------------------------------------------------------------720// Main721// ---------------------------------------------------------------------------722723const USAGE = `cdp - lightweight Chrome DevTools Protocol CLI (no Puppeteer)724725Usage: cdp <command> [args]726727list List open pages (shows unique target prefixes)728snap <target> Accessibility tree snapshot729eval <target> <expr> Evaluate JS expression730shot <target> [file] Screenshot (default: screenshot-<target>.png in runtime dir); prints coordinate mapping731html <target> [selector] Get HTML (full page or CSS selector)732nav <target> <url> Navigate to URL and wait for load completion733net <target> Network performance entries734click <target> <selector> Click an element by CSS selector735clickxy <target> <x> <y> Click at CSS pixel coordinates (see coordinate note below)736type <target> <text> Type text at current focus via Input.insertText737Works in cross-origin iframes unlike eval-based approaches738loadall <target> <selector> [ms] Repeatedly click a "load more" button until it disappears739Optional interval in ms between clicks (default 1500)740evalraw <target> <method> [json] Send a raw CDP command; returns JSON result741e.g. evalraw <t> "DOM.getDocument" '{}'742open [url] Open a new tab (default: about:blank)743Note: each new tab triggers a fresh "Allow debugging?" prompt744stop [target] Stop daemon(s)745746<target> is a unique targetId prefix from "cdp list". If a prefix is ambiguous,747use more characters.748749COORDINATE SYSTEM750shot captures the viewport at the device's native resolution.751The screenshot image size = CSS pixels × DPR (device pixel ratio).752For CDP Input events (clickxy, etc.) you need CSS pixels, not image pixels.753754CSS pixels = screenshot image pixels / DPR755756shot prints the DPR and an example conversion for the current page.757Typical Retina (DPR=2): CSS px ≈ screenshot px × 0.5758If your viewer rescales the image further, account for that scaling too.759760EVAL SAFETY NOTE761Avoid index-based DOM selection (querySelectorAll(...)[i]) across multiple762eval calls when the list can change between calls (e.g. after clicking763"Ignore" buttons on a feed — indices shift). Prefer stable selectors or764collect all data in a single eval.765766DAEMON IPC (for advanced use / scripting)767Each tab runs a persistent daemon at Unix socket in the runtime dir (see below).768Protocol: newline-delimited JSON (one JSON object per line, UTF-8).769Request: {"id":<number>, "cmd":"<command>", "args":["arg1","arg2",...]}770Response: {"id":<number>, "ok":true, "result":"<string>"}771or {"id":<number>, "ok":false, "error":"<message>"}772Commands mirror the CLI: snap, eval, shot, html, nav, net, click, clickxy,773type, loadall, evalraw, stop. Use evalraw to send arbitrary CDP methods.774The socket disappears after 20 min of inactivity or when the tab closes.775`;776777const NEEDS_TARGET = new Set([778'snap','snapshot','eval','shot','screenshot','html','nav','navigate',779'net','network','click','clickxy','type','loadall','evalraw',780]);781782async function main() {783const [cmd, ...args] = process.argv.slice(2);784785// Daemon mode (internal)786if (cmd === '_daemon') { await runDaemon(args[0]); return; }787788if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {789console.log(USAGE); process.exit(0);790}791792if (cmd === 'list' || cmd === 'ls') {793const cdp = new CDP();794await cdp.connect(getWsUrl());795const pages = await getPages(cdp);796cdp.close();797writeFileSync(PAGES_CACHE, JSON.stringify(pages), { mode: 0o600 });798console.log(formatPageList(pages));799setTimeout(() => process.exit(0), 100);800return;801}802803// Open new tab804if (cmd === 'open') {805const url = args[0] || 'about:blank';806const cdp = new CDP();807await cdp.connect(getWsUrl());808const { targetId } = await cdp.send('Target.createTarget', { url });809// Refresh cache; new tab may not appear in getTargets immediately, so add it manually810const pages = await getPages(cdp);811if (!pages.some(p => p.targetId === targetId)) {812pages.push({ targetId, title: url, url });813}814cdp.close();815writeFileSync(PAGES_CACHE, JSON.stringify(pages), { mode: 0o600 });816console.log(`Opened new tab: ${targetId.slice(0, 8)} ${url}`);817console.log('Note: this tab will need "Allow debugging?" approval on first access.');818return;819}820821// Stop822if (cmd === 'stop') {823await stopDaemons(args[0]);824return;825}826827// Page commands — need target prefix828if (!NEEDS_TARGET.has(cmd)) {829console.error(`Unknown command: ${cmd}\n`);830console.log(USAGE);831process.exit(1);832}833834const targetPrefix = args[0];835if (!targetPrefix) {836console.error('Error: target ID required. Run "cdp list" first.');837process.exit(1);838}839840// Resolve prefix → full targetId from pages cache841if (!existsSync(PAGES_CACHE)) {842console.error('No page list cached. Run "cdp list" first.');843process.exit(1);844}845const pages = JSON.parse(readFileSync(PAGES_CACHE, 'utf8'));846const targetId = resolvePrefix(targetPrefix, pages.map(p => p.targetId), 'target', 'Run "cdp list".');847848const conn = await getOrStartTabDaemon(targetId);849850const cmdArgs = args.slice(1);851852if (cmd === 'eval') {853const expr = cmdArgs.join(' ');854if (!expr) { console.error('Error: expression required'); process.exit(1); }855cmdArgs[0] = expr;856} else if (cmd === 'type') {857// Join all remaining args as text (allows spaces)858const text = cmdArgs.join(' ');859if (!text) { console.error('Error: text required'); process.exit(1); }860cmdArgs[0] = text;861} else if (cmd === 'evalraw') {862// args: [method, ...jsonParts] — join json parts in case of spaces863if (!cmdArgs[0]) { console.error('Error: CDP method required'); process.exit(1); }864if (cmdArgs.length > 2) cmdArgs[1] = cmdArgs.slice(1).join(' ');865}866867if ((cmd === 'nav' || cmd === 'navigate') && !cmdArgs[0]) {868console.error('Error: URL required');869process.exit(1);870}871872const response = await sendCommand(conn, { cmd, args: cmdArgs });873874if (response.ok) {875if (response.result) console.log(response.result);876} else {877console.error('Error:', response.error);878process.exitCode = 1;879}880}881882main().catch(e => { console.error(e.message); process.exit(1); });883