#!/usr/bin/env node // cdp - lightweight Chrome DevTools Protocol CLI // Uses raw CDP over WebSocket, no Puppeteer dependency. // Requires Node 22+ (built-in WebSocket). // // Per-tab persistent daemon: page commands go through a daemon that holds // the CDP session open. Chrome's "Allow debugging" modal fires once per // daemon (= once per tab). Daemons auto-exit after 20min idle. import { spawn } from "node:child_process"; import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs"; import net from "node:net"; import { homedir, tmpdir, userInfo } from "node:os"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; function envInt(name, fallback, { min = 1 } = {}) { const raw = process.env[name]; if (!raw) return fallback; const parsed = Number.parseInt(raw, 10); return Number.isFinite(parsed) && parsed >= min ? parsed : fallback; } const TIMEOUT = envInt("PI_CHROME_CDP_CDP_TIMEOUT_MS", 15000); const NAVIGATION_TIMEOUT = envInt("PI_CHROME_CDP_NAVIGATION_TIMEOUT_MS", 30000); const IDLE_TIMEOUT = envInt("PI_CHROME_CDP_IDLE_TIMEOUT_MS", 20 * 60 * 1000); const DAEMON_CONNECT_RETRIES = envInt( "PI_CHROME_CDP_DAEMON_CONNECT_RETRIES", 60, ); const DAEMON_CONNECT_DELAY = envInt( "PI_CHROME_CDP_DAEMON_CONNECT_DELAY_MS", 100, { min: 0 }, ); const CLICK_HOLD_MS = envInt("PI_CHROME_CDP_CLICK_HOLD_MS", 16, { min: 0 }); const LOADALL_INTERVAL_MS = envInt("PI_CHROME_CDP_LOADALL_INTERVAL_MS", 500, { min: 0, }); const LOADALL_TIMEOUT_MS = envInt( "PI_CHROME_CDP_LOADALL_TIMEOUT_MS", 5 * 60 * 1000, ); const MAX_SNAP_LINES = envInt("PI_CHROME_CDP_MAX_SNAP_LINES", 4000, { min: 0 }); const MAX_HTML_CHARS = envInt("PI_CHROME_CDP_MAX_HTML_CHARS", 500000, { min: 0, }); const MAX_NET_ENTRIES = envInt("PI_CHROME_CDP_MAX_NET_ENTRIES", 1000, { min: 0, }); const MAX_BATCH_COMMANDS = envInt("PI_CHROME_CDP_MAX_BATCH_COMMANDS", 100); const MIN_TARGET_PREFIX_LEN = 8; const STATE_DIR = initStateDir(); const PAGES_CACHE = join(STATE_DIR, "pages.json"); function safeUserId() { if (typeof process.getuid === "function") return String(process.getuid()); return userInfo().username.replace(/[^A-Za-z0-9_.-]/g, "_"); } function initStateDir() { const dir = process.env.PI_CHROME_CDP_STATE_DIR ? resolve(process.env.PI_CHROME_CDP_STATE_DIR) : join( process.env.XDG_RUNTIME_DIR || tmpdir(), `pi-chrome-cdp-${safeUserId()}`, ); mkdirSync(dir, { recursive: true, mode: 0o700 }); try { chmodSync(dir, 0o700); } catch {} return dir; } function sockPath(targetId) { return join(STATE_DIR, `cdp-${targetId}.sock`); } function devtoolsPortFiles() { return [ process.env.PI_CHROME_CDP_DEVTOOLS_ACTIVE_PORT, resolve( homedir(), "Library/Application Support/Google/Chrome/DevToolsActivePort", ), resolve(homedir(), ".config/google-chrome/DevToolsActivePort"), resolve(homedir(), ".config/chromium/DevToolsActivePort"), resolve(homedir(), ".config/google-chrome-unstable/DevToolsActivePort"), resolve(homedir(), ".config/microsoft-edge/DevToolsActivePort"), ].filter(Boolean); } async function getWsUrl() { const explicit = process.env.PI_CHROME_CDP_WS_URL || process.env.CHROME_DEVTOOLS_WS_URL; if (explicit) return explicit; const checked = []; for (const portFile of devtoolsPortFiles()) { if (!existsSync(portFile)) { checked.push(`${portFile} (missing)`); continue; } const lines = readFileSync(portFile, "utf8").trim().split("\n"); if (lines.length < 2) { checked.push(`${portFile} (invalid)`); continue; } const port = lines[0]; try { const res = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(500), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const version = await res.json(); checked.push(`${portFile} (ok)`); return ( version.webSocketDebuggerUrl || `ws://127.0.0.1:${port}${lines[1]}` ); } catch (e) { checked.push(`${portFile} (dead: ${e.message})`); } } throw new Error( "No live Chrome DevTools endpoint found. Enable Chrome remote debugging at chrome://inspect/#remote-debugging. " + `Checked: ${checked.join(", ")}`, ); } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function listDaemonSockets() { return readdirSync(STATE_DIR) .filter((f) => f.startsWith("cdp-") && f.endsWith(".sock")) .map((f) => ({ targetId: f.slice(4, -5), socketPath: join(STATE_DIR, f), })); } function resolvePrefix(prefix, candidates, noun = "target", missingHint = "") { const upper = prefix.toUpperCase(); const matches = candidates.filter((candidate) => candidate.toUpperCase().startsWith(upper), ); if (matches.length === 0) { const hint = missingHint ? ` ${missingHint}` : ""; throw new Error(`No ${noun} matching prefix "${prefix}".${hint}`); } if (matches.length > 1) { throw new Error( `Ambiguous prefix "${prefix}" — matches ${matches.length} ${noun}s. Use more characters.`, ); } return matches[0]; } function getDisplayPrefixLength(targetIds) { if (targetIds.length === 0) return MIN_TARGET_PREFIX_LEN; const maxLen = Math.max(...targetIds.map((id) => id.length)); for (let len = MIN_TARGET_PREFIX_LEN; len <= maxLen; len++) { const prefixes = new Set( targetIds.map((id) => id.slice(0, len).toUpperCase()), ); if (prefixes.size === targetIds.length) return len; } return maxLen; } function limitTextMiddle(text, maxChars, label = "output") { if (!maxChars || text.length <= maxChars) return text; const marker = ` … (${label} truncated: ${text.length} chars > ${maxChars}; set limit env to 0 to disable) `; const keep = Math.max(0, maxChars - marker.length); const head = Math.ceil(keep * 0.7); const tail = Math.floor(keep * 0.3); return text.slice(0, head) + marker + text.slice(text.length - tail); } function limitLinesMiddle(lines, maxLines, label = "output") { if (!maxLines || lines.length <= maxLines) return lines; const marker = `… (${label} truncated: ${lines.length} lines > ${maxLines}; set limit env to 0 to disable)`; const keep = Math.max(0, maxLines - 1); const head = Math.ceil(keep * 0.7); const tail = Math.floor(keep * 0.3); return [...lines.slice(0, head), marker, ...lines.slice(lines.length - tail)]; } // --------------------------------------------------------------------------- // CDP WebSocket client // --------------------------------------------------------------------------- class CDP { #ws; #id = 0; #pending = new Map(); #eventHandlers = new Map(); #closeHandlers = []; async connect(wsUrl) { return new Promise((res, rej) => { this.#ws = new WebSocket(wsUrl); this.#ws.onopen = () => res(); this.#ws.onerror = (e) => rej(new Error("WebSocket error: " + (e.message || e.type))); this.#ws.onclose = () => { for (const { reject } of this.#pending.values()) reject(new Error("WebSocket closed")); this.#pending.clear(); this.#closeHandlers.forEach((h) => h()); }; this.#ws.onmessage = (ev) => { const msg = JSON.parse(ev.data); if (msg.id && this.#pending.has(msg.id)) { const { resolve, reject } = this.#pending.get(msg.id); this.#pending.delete(msg.id); if (msg.error) reject(new Error(msg.error.message)); else resolve(msg.result); } else if (msg.method && this.#eventHandlers.has(msg.method)) { for (const handler of [...this.#eventHandlers.get(msg.method)]) { handler(msg.params || {}, msg); } } }; }); } send(method, params = {}, sessionId) { const id = ++this.#id; return new Promise((resolve, reject) => { const timer = setTimeout(() => { if (!this.#pending.has(id)) return; this.#pending.delete(id); reject(new Error(`Timeout: ${method}`)); }, TIMEOUT); this.#pending.set(id, { resolve: (value) => { clearTimeout(timer); resolve(value); }, reject: (error) => { clearTimeout(timer); reject(error); }, }); const msg = { id, method, params }; if (sessionId) msg.sessionId = sessionId; this.#ws.send(JSON.stringify(msg)); }); } onEvent(method, handler) { if (!this.#eventHandlers.has(method)) this.#eventHandlers.set(method, new Set()); const handlers = this.#eventHandlers.get(method); handlers.add(handler); return () => { handlers.delete(handler); if (handlers.size === 0) this.#eventHandlers.delete(method); }; } waitForEvent(method, timeout = TIMEOUT) { let settled = false; let off; let timer; const promise = new Promise((resolve, reject) => { off = this.onEvent(method, (params) => { if (settled) return; settled = true; clearTimeout(timer); off(); resolve(params); }); timer = setTimeout(() => { if (settled) return; settled = true; off(); reject(new Error(`Timeout waiting for event: ${method}`)); }, timeout); }); return { promise, cancel() { if (settled) return; settled = true; clearTimeout(timer); off?.(); }, }; } onClose(handler) { this.#closeHandlers.push(handler); } close() { this.#ws.close(); } } // --------------------------------------------------------------------------- // Command implementations — return strings, take (cdp, sessionId) // --------------------------------------------------------------------------- async function getPages(cdp) { const { targetInfos } = await cdp.send("Target.getTargets"); return targetInfos.filter( (t) => t.type === "page" && !t.url.startsWith("chrome://"), ); } function formatPageList(pages) { const prefixLen = getDisplayPrefixLength(pages.map((p) => p.targetId)); return pages .map((p) => { const id = p.targetId.slice(0, prefixLen).padEnd(prefixLen); const title = p.title.substring(0, 54).padEnd(54); return `${id} ${title} ${p.url}`; }) .join("\n"); } function shouldShowAxNode(node, compact = false) { const role = node.role?.value || ""; const name = node.name?.value ?? ""; const value = node.value?.value; if (compact && role === "InlineTextBox") return false; return ( role !== "none" && role !== "generic" && !(name === "" && (value === "" || value == null)) ); } function formatAxNode(node, depth) { const role = node.role?.value || ""; const name = node.name?.value ?? ""; const value = node.value?.value; const indent = " ".repeat(Math.min(depth, 10)); let line = `${indent}[${role}]`; if (name !== "") line += ` ${name}`; if (!(value === "" || value == null)) line += ` = ${JSON.stringify(value)}`; return line; } function orderedAxChildren(node, nodesById, childrenByParent) { const children = []; const seen = new Set(); for (const childId of node.childIds || []) { const child = nodesById.get(childId); if (child && !seen.has(child.nodeId)) { seen.add(child.nodeId); children.push(child); } } for (const child of childrenByParent.get(node.nodeId) || []) { if (!seen.has(child.nodeId)) { seen.add(child.nodeId); children.push(child); } } return children; } async function snapshotStr(cdp, sid, compact = false) { const { nodes } = await cdp.send("Accessibility.getFullAXTree", {}, sid); const nodesById = new Map(nodes.map((node) => [node.nodeId, node])); const childrenByParent = new Map(); for (const node of nodes) { if (!node.parentId) continue; if (!childrenByParent.has(node.parentId)) childrenByParent.set(node.parentId, []); childrenByParent.get(node.parentId).push(node); } const lines = []; const visited = new Set(); function visit(node, depth) { if (!node || visited.has(node.nodeId)) return; visited.add(node.nodeId); if (shouldShowAxNode(node, compact)) lines.push(formatAxNode(node, depth)); for (const child of orderedAxChildren(node, nodesById, childrenByParent)) { visit(child, depth + 1); } } const roots = nodes.filter( (node) => !node.parentId || !nodesById.has(node.parentId), ); for (const root of roots) visit(root, 0); for (const node of nodes) visit(node, 0); return limitLinesMiddle(lines, MAX_SNAP_LINES, "accessibility snapshot").join( "\n", ); } async function evalStr(cdp, sid, expression) { await cdp.send("Runtime.enable", {}, sid); const result = await cdp.send( "Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true, }, sid, ); if (result.exceptionDetails) { throw new Error( result.exceptionDetails.text || result.exceptionDetails.exception?.description, ); } const val = result.result.value; if (val == null) return ""; return typeof val === "object" ? JSON.stringify(val, null, 2) : String(val); } async function shotStr(cdp, sid, filePath) { // Get device scale factor so we can report coordinate mapping. let dpr = 1; try { const raw = await evalStr(cdp, sid, "window.devicePixelRatio"); const parsed = parseFloat(raw); if (parsed > 0) dpr = parsed; } catch { try { const metrics = await cdp.send("Page.getLayoutMetrics", {}, sid); if ( metrics.visualViewport?.clientWidth && metrics.cssVisualViewport?.clientWidth ) { dpr = Math.round( (metrics.visualViewport.clientWidth / metrics.cssVisualViewport.clientWidth) * 100, ) / 100; } } catch {} } const { data } = await cdp.send( "Page.captureScreenshot", { format: "png" }, sid, ); const out = filePath || join(STATE_DIR, "screenshot.png"); writeFileSync(out, Buffer.from(data, "base64")); const lines = [out]; lines.push(`Screenshot saved. Device pixel ratio (DPR): ${dpr}`); lines.push(`Coordinate mapping:`); lines.push( ` Screenshot pixels → CSS pixels (for CDP Input events): divide by ${dpr}`, ); lines.push( ` e.g. screenshot point (${Math.round(100 * dpr)}, ${Math.round(200 * dpr)}) → CSS (100, 200) → use clickxy 100 200`, ); if (dpr !== 1) { lines.push( ` On this ${dpr}x display: CSS px = screenshot px / ${dpr} ≈ screenshot px × ${Math.round(100 / dpr) / 100}`, ); } return lines.join("\n"); } async function htmlStr(cdp, sid, selector) { const expr = selector ? `document.querySelector(${JSON.stringify(selector)})?.outerHTML || 'Element not found'` : `document.documentElement.outerHTML`; return limitTextMiddle( await evalStr(cdp, sid, expr), MAX_HTML_CHARS, selector ? "element HTML" : "page HTML", ); } async function waitForDocumentReady(cdp, sid, timeoutMs = NAVIGATION_TIMEOUT) { const deadline = Date.now() + timeoutMs; let lastState = ""; let lastError; while (Date.now() < deadline) { try { const state = await evalStr(cdp, sid, "document.readyState"); lastState = state; if (state === "complete") return; } catch (e) { lastError = e; } await sleep(200); } if (lastState) { throw new Error( `Timed out waiting for navigation to finish (last readyState: ${lastState})`, ); } if (lastError) { throw new Error( `Timed out waiting for navigation to finish (${lastError.message})`, ); } throw new Error("Timed out waiting for navigation to finish"); } async function navStr(cdp, sid, url) { await cdp.send("Page.enable", {}, sid); const loadEvent = cdp.waitForEvent("Page.loadEventFired", NAVIGATION_TIMEOUT); const result = await cdp.send("Page.navigate", { url }, sid); if (result.errorText) { loadEvent.cancel(); throw new Error(result.errorText); } if (result.loaderId) { await loadEvent.promise; } else { loadEvent.cancel(); } await waitForDocumentReady(cdp, sid, 5000); return `Navigated to ${url}`; } async function netStr(cdp, sid) { const raw = await evalStr( cdp, sid, `JSON.stringify(performance.getEntriesByType('resource').map(e => ({ name: e.name.substring(0, 120), type: e.initiatorType, duration: Math.round(e.duration), size: e.transferSize })))`, ); const entries = JSON.parse(raw); const shown = MAX_NET_ENTRIES ? entries.slice(0, MAX_NET_ENTRIES) : entries; const lines = shown.map( (e) => `${String(e.duration).padStart(5)}ms ${String(e.size || "?").padStart(8)}B ${(e.type || "?").padEnd(8)} ${e.name}`, ); if (MAX_NET_ENTRIES && entries.length > shown.length) { lines.push( `… (network entries truncated: ${entries.length} entries > ${MAX_NET_ENTRIES}; set PI_CHROME_CDP_MAX_NET_ENTRIES=0 to disable)`, ); } return lines.join("\n"); } // Click element by CSS selector async function clickStr(cdp, sid, selector) { if (!selector) throw new Error("CSS selector required"); const expr = ` (function() { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return { ok: false, error: 'Element not found: ' + ${JSON.stringify(selector)} }; el.scrollIntoView({ block: 'center' }); el.click(); return { ok: true, tag: el.tagName, text: el.textContent.trim().substring(0, 80) }; })() `; const result = await evalStr(cdp, sid, expr); const r = JSON.parse(result); if (!r.ok) throw new Error(r.error); return `Clicked <${r.tag}> "${r.text}"`; } // Click at CSS pixel coordinates using Input.dispatchMouseEvent async function clickXyStr(cdp, sid, x, y) { const cx = parseFloat(x); const cy = parseFloat(y); if (!Number.isFinite(cx) || !Number.isFinite(cy)) throw new Error("x and y must be finite numbers (CSS pixels)"); const base = { x: cx, y: cy, button: "left", clickCount: 1, modifiers: 0 }; await cdp.send( "Input.dispatchMouseEvent", { ...base, type: "mouseMoved" }, sid, ); await cdp.send( "Input.dispatchMouseEvent", { ...base, type: "mousePressed" }, sid, ); if (CLICK_HOLD_MS > 0) await sleep(CLICK_HOLD_MS); await cdp.send( "Input.dispatchMouseEvent", { ...base, type: "mouseReleased" }, sid, ); return `Clicked at CSS (${cx}, ${cy})`; } // Type text using Input.insertText (works in cross-origin iframes, unlike eval) async function typeStr(cdp, sid, text) { if (text == null || text === "") throw new Error("text required"); await cdp.send("Input.insertText", { text }, sid); return `Typed ${text.length} characters`; } // Load-more: repeatedly click a button/selector until it disappears async function loadAllStr( cdp, sid, selector, intervalMs = LOADALL_INTERVAL_MS, ) { if (!selector) throw new Error("CSS selector required"); let clicks = 0; const deadline = Date.now() + LOADALL_TIMEOUT_MS; while (Date.now() < deadline) { const clickExpr = ` (function() { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return { exists: false, clicked: false }; el.scrollIntoView({ block: 'center' }); el.click(); return { exists: true, clicked: true }; })() `; const raw = await evalStr(cdp, sid, clickExpr); const result = JSON.parse(raw); if (!result.exists || !result.clicked) break; clicks++; if (intervalMs > 0) await sleep(intervalMs); } return `Clicked "${selector}" ${clicks} time(s) until it disappeared`; } // Send a raw CDP command and return the result as JSON async function evalRawStr(cdp, sid, method, paramsJson) { if (!method) throw new Error('CDP method required (e.g. "DOM.getDocument")'); let params = {}; if (paramsJson) { try { params = JSON.parse(paramsJson); } catch { throw new Error(`Invalid JSON params: ${paramsJson}`); } } const result = await cdp.send(method, params, sid); return JSON.stringify(result, null, 2); } // --------------------------------------------------------------------------- // Per-tab daemon // --------------------------------------------------------------------------- async function runDaemon(targetId) { const sp = sockPath(targetId); const cdp = new CDP(); try { await cdp.connect(await getWsUrl()); } catch (e) { process.stderr.write(`Daemon: cannot connect to Chrome: ${e.message}\n`); process.exit(1); } let sessionId; try { const res = await cdp.send("Target.attachToTarget", { targetId, flatten: true, }); sessionId = res.sessionId; } catch (e) { process.stderr.write(`Daemon: attach failed: ${e.message}\n`); cdp.close(); process.exit(1); } // Shutdown helpers let alive = true; function shutdown() { if (!alive) return; alive = false; server.close(); try { unlinkSync(sp); } catch {} cdp.close(); process.exit(0); } // Exit if target goes away or Chrome disconnects cdp.onEvent("Target.targetDestroyed", (params) => { if (params.targetId === targetId) shutdown(); }); cdp.onEvent("Target.detachedFromTarget", (params) => { if (params.sessionId === sessionId) shutdown(); }); cdp.onClose(() => shutdown()); process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown); // Idle timer let idleTimer = setTimeout(shutdown, IDLE_TIMEOUT); function resetIdle() { clearTimeout(idleTimer); idleTimer = setTimeout(shutdown, IDLE_TIMEOUT); } // Handle a command async function handleCommand({ cmd, args = [] }) { resetIdle(); try { let result; switch (cmd) { case "list": { const pages = await getPages(cdp); result = formatPageList(pages); break; } case "list_raw": { const pages = await getPages(cdp); result = JSON.stringify(pages); break; } case "batch": { let commands; try { commands = JSON.parse(args[0] || "[]"); } catch { throw new Error("Invalid batch JSON"); } if (!Array.isArray(commands)) throw new Error("Batch JSON must be an array"); if (commands.length > MAX_BATCH_COMMANDS) throw new Error( `Batch too large: ${commands.length} > ${MAX_BATCH_COMMANDS}`, ); const disallowed = new Set(["batch", "list", "list_raw", "stop"]); const results = []; for (const item of commands) { if (!item || typeof item.cmd !== "string") { results.push({ ok: false, error: "Batch item missing cmd" }); continue; } if (disallowed.has(item.cmd)) { results.push({ ok: false, error: `Command not allowed in batch: ${item.cmd}`, }); continue; } results.push( await handleCommand({ cmd: item.cmd, args: Array.isArray(item.args) ? item.args : [], }), ); } const failed = results.find((item) => !item.ok); if (failed) throw new Error(`Batch failed: ${failed.error}`); result = JSON.stringify(results, null, 2); break; } case "snap": case "snapshot": result = await snapshotStr(cdp, sessionId, true); break; case "eval": result = await evalStr(cdp, sessionId, args[0]); break; case "shot": case "screenshot": result = await shotStr(cdp, sessionId, args[0]); break; case "html": result = await htmlStr(cdp, sessionId, args[0]); break; case "nav": case "navigate": result = await navStr(cdp, sessionId, args[0]); break; case "net": case "network": result = await netStr(cdp, sessionId); break; case "click": result = await clickStr(cdp, sessionId, args[0]); break; case "clickxy": result = await clickXyStr(cdp, sessionId, args[0], args[1]); break; case "type": result = await typeStr(cdp, sessionId, args[0]); break; case "loadall": { const interval = args[1] ? parseInt(args[1], 10) : undefined; if (args[1] && !Number.isFinite(interval)) throw new Error("interval must be a finite number"); result = await loadAllStr(cdp, sessionId, args[0], interval); break; } case "evalraw": result = await evalRawStr(cdp, sessionId, args[0], args[1]); break; case "stop": return { ok: true, result: "", stopAfter: true }; default: return { ok: false, error: `Unknown command: ${cmd}` }; } return { ok: true, result: result ?? "" }; } catch (e) { return { ok: false, error: e.message }; } } // Unix socket server — NDJSON protocol // Wire format: each message is one JSON object followed by \n (newline-delimited JSON). // Request: { "id": , "cmd": "", "args": ["arg1", "arg2", ...] } // Response: { "id": , "ok": , "result": "" } // or { "id": , "ok": false, "error": "" } const server = net.createServer((conn) => { let buf = ""; conn.on("data", (chunk) => { buf += chunk.toString(); const lines = buf.split("\n"); buf = lines.pop(); // keep incomplete last line for (const line of lines) { if (!line.trim()) continue; let req; try { req = JSON.parse(line); } catch { conn.write( JSON.stringify({ ok: false, error: "Invalid JSON request", id: null, }) + "\n", ); continue; } handleCommand(req).then((res) => { const payload = JSON.stringify({ ...res, id: req.id }) + "\n"; if (res.stopAfter) conn.end(payload, shutdown); else conn.write(payload); }); } }); }); try { unlinkSync(sp); } catch {} server.listen(sp, () => { try { chmodSync(sp, 0o600); } catch {} }); } // --------------------------------------------------------------------------- // CLI ↔ daemon communication // --------------------------------------------------------------------------- function connectToSocket(sp) { return new Promise((resolve, reject) => { const conn = net.connect(sp); conn.on("connect", () => resolve(conn)); conn.on("error", reject); }); } async function getOrStartTabDaemon(targetId) { const sp = sockPath(targetId); // Try existing daemon try { return await connectToSocket(sp); } catch {} // Clean stale socket try { unlinkSync(sp); } catch {} // Spawn daemon const child = spawn( process.execPath, [process.argv[1], "_daemon", targetId], { detached: true, stdio: "ignore", }, ); child.unref(); // Wait for socket (includes time for user to click Allow) for (let i = 0; i <= DAEMON_CONNECT_RETRIES; i++) { if (i > 0) await sleep(DAEMON_CONNECT_DELAY); try { return await connectToSocket(sp); } catch {} } throw new Error("Daemon failed to start — did you click Allow in Chrome?"); } function sendCommand(conn, req) { return new Promise((resolve, reject) => { let buf = ""; let settled = false; const cleanup = () => { conn.off("data", onData); conn.off("error", onError); conn.off("end", onEnd); conn.off("close", onClose); }; const onData = (chunk) => { buf += chunk.toString(); const idx = buf.indexOf("\n"); if (idx === -1) return; settled = true; cleanup(); resolve(JSON.parse(buf.slice(0, idx))); conn.end(); }; const onError = (error) => { if (settled) return; settled = true; cleanup(); reject(error); }; const onEnd = () => { if (settled) return; settled = true; cleanup(); reject(new Error("Connection closed before response")); }; const onClose = () => { if (settled) return; settled = true; cleanup(); reject(new Error("Connection closed before response")); }; conn.on("data", onData); conn.on("error", onError); conn.on("end", onEnd); conn.on("close", onClose); req.id = 1; conn.write(JSON.stringify(req) + "\n"); }); } // Find any running daemon socket to reuse for list function findAnyDaemonSocket() { return listDaemonSockets()[0]?.socketPath || null; } // --------------------------------------------------------------------------- // Stop daemons // --------------------------------------------------------------------------- async function stopDaemons(targetPrefix) { const daemons = listDaemonSockets(); if (targetPrefix) { const targetId = resolvePrefix( targetPrefix, daemons.map((d) => d.targetId), "daemon", ); const daemon = daemons.find((d) => d.targetId === targetId); try { const conn = await connectToSocket(daemon.socketPath); await sendCommand(conn, { cmd: "stop" }); } catch { try { unlinkSync(daemon.socketPath); } catch {} } return; } for (const daemon of daemons) { try { const conn = await connectToSocket(daemon.socketPath); await sendCommand(conn, { cmd: "stop" }); } catch { try { unlinkSync(daemon.socketPath); } catch {} } } } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- const USAGE = `cdp - lightweight Chrome DevTools Protocol CLI (no Puppeteer) Usage: cdp [args] list List open pages (shows unique target prefixes) snap Accessibility tree snapshot eval Evaluate JS expression shot [file] Screenshot (default: state-dir/screenshot.png); prints coordinate mapping html [selector] Get HTML (full page or CSS selector) nav Navigate to URL and wait for load completion net Network performance entries click Click an element by CSS selector clickxy Click at CSS pixel coordinates (see coordinate note below) type Type text at current focus via Input.insertText Works in cross-origin iframes unlike eval-based approaches loadall [ms] Repeatedly click a "load more" button until it disappears Optional interval in ms between clicks (default 500) evalraw [json] Send a raw CDP command; returns JSON result e.g. evalraw "DOM.getDocument" '{}' batch Run multiple page commands in one daemon roundtrip JSON: [{"cmd":"eval","args":["document.title"]}, ...] stop [target] Stop daemon(s) is a unique targetId prefix from "cdp list". If a prefix is ambiguous, use more characters. COORDINATE SYSTEM shot captures the viewport at the device's native resolution. The screenshot image size = CSS pixels × DPR (device pixel ratio). For CDP Input events (clickxy, etc.) you need CSS pixels, not image pixels. CSS pixels = screenshot image pixels / DPR shot prints the DPR and an example conversion for the current page. Typical Retina (DPR=2): CSS px ≈ screenshot px × 0.5 If your viewer rescales the image further, account for that scaling too. EVAL SAFETY NOTE Avoid index-based DOM selection (querySelectorAll(...)[i]) across multiple eval calls when the list can change between calls (e.g. after clicking "Ignore" buttons on a feed — indices shift). Prefer stable selectors or collect all data in a single eval. DAEMON IPC (for advanced use / scripting) Each tab runs a persistent daemon under $PI_CHROME_CDP_STATE_DIR, $XDG_RUNTIME_DIR/pi-chrome-cdp-, or the system temp fallback. Protocol: newline-delimited JSON (one JSON object per line, UTF-8). Request: {"id":, "cmd":"", "args":["arg1","arg2",...]} Response: {"id":, "ok":true, "result":""} or {"id":, "ok":false, "error":""} Commands mirror the CLI: snap, eval, shot, html, nav, net, click, clickxy, type, loadall, evalraw, batch, stop. Use evalraw to send arbitrary CDP methods. The socket disappears after 20 min of inactivity or when the tab closes. `; const NEEDS_TARGET = new Set([ "snap", "snapshot", "eval", "shot", "screenshot", "html", "nav", "navigate", "net", "network", "click", "clickxy", "type", "loadall", "evalraw", "batch", ]); async function main() { const [cmd, ...args] = process.argv.slice(2); // Daemon mode (internal) if (cmd === "_daemon") { await runDaemon(args[0]); return; } if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") { console.log(USAGE); process.exit(0); } // List — use existing daemon if available, otherwise direct if (cmd === "list" || cmd === "ls") { let pages; const existingSock = findAnyDaemonSocket(); if (existingSock) { try { const conn = await connectToSocket(existingSock); const resp = await sendCommand(conn, { cmd: "list_raw" }); if (resp.ok) pages = JSON.parse(resp.result); } catch {} } if (!pages) { // No daemon running — connect directly (will trigger one Allow) const cdp = new CDP(); await cdp.connect(await getWsUrl()); pages = await getPages(cdp); cdp.close(); } writeFileSync(PAGES_CACHE, JSON.stringify(pages)); console.log(formatPageList(pages)); setImmediate(() => process.exit(0)); return; } // Stop if (cmd === "stop") { await stopDaemons(args[0]); return; } // Page commands — need target prefix if (!NEEDS_TARGET.has(cmd)) { console.error(`Unknown command: ${cmd}\n`); console.log(USAGE); process.exit(1); } const targetPrefix = args[0]; if (!targetPrefix) { console.error('Error: target ID required. Run "cdp list" first.'); process.exit(1); } // Resolve prefix → full targetId from cache or running daemon let targetId; const daemonTargetIds = listDaemonSockets().map((d) => d.targetId); const daemonMatches = daemonTargetIds.filter((id) => id.toUpperCase().startsWith(targetPrefix.toUpperCase()), ); if (daemonMatches.length > 0) { targetId = resolvePrefix(targetPrefix, daemonTargetIds, "daemon"); } else { if (!existsSync(PAGES_CACHE)) { console.error('No page list cached. Run "cdp list" first.'); process.exit(1); } const pages = JSON.parse(readFileSync(PAGES_CACHE, "utf8")); targetId = resolvePrefix( targetPrefix, pages.map((p) => p.targetId), "target", 'Run "cdp list".', ); } const cmdArgs = args.slice(1); if (cmd === "eval") { const expr = cmdArgs.join(" "); if (!expr) { console.error("Error: expression required"); process.exit(1); } cmdArgs[0] = expr; } else if (cmd === "type") { // Join all remaining args as text (allows spaces) const text = cmdArgs.join(" "); if (!text) { console.error("Error: text required"); process.exit(1); } cmdArgs[0] = text; } else if (cmd === "evalraw") { // args: [method, ...jsonParts] — join json parts in case of spaces if (!cmdArgs[0]) { console.error("Error: CDP method required"); process.exit(1); } if (cmdArgs.length > 2) cmdArgs[1] = cmdArgs.slice(1).join(" "); } else if (cmd === "batch") { const json = cmdArgs.join(" "); if (!json) { console.error("Error: batch JSON required"); process.exit(1); } cmdArgs.length = 1; cmdArgs[0] = json; } if ((cmd === "nav" || cmd === "navigate") && !cmdArgs[0]) { console.error("Error: URL required"); process.exit(1); } const conn = await getOrStartTabDaemon(targetId); const response = await sendCommand(conn, { cmd, args: cmdArgs }); if (response.ok) { if (response.result) console.log(response.result); } else { console.error("Error:", response.error); process.exitCode = 1; } } export const __test = { envInt, evalStr, getDisplayPrefixLength, limitLinesMiddle, limitTextMiddle, navStr, netStr, resolvePrefix, sendCommand, shotStr, snapshotStr, waitForDocumentReady, }; if (process.argv[1] === fileURLToPath(import.meta.url)) { main().catch((e) => { console.error(e.message); process.exit(1); }); }