repositories / dotfiles
dotfiles
bugabingas dorkfiles
owned by admin
pi/agent/skills/optional/chrome-cdp/scripts/cdp.mjs
Raw#!/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 <target> 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": <number>, "cmd": "<command>", "args": ["arg1", "arg2", ...] }
// Response: { "id": <number>, "ok": <boolean>, "result": "<string>" }
// or { "id": <number>, "ok": false, "error": "<message>" }
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 <command> [args]
list List open pages (shows unique target prefixes)
snap <target> Accessibility tree snapshot
eval <target> <expr> Evaluate JS expression
shot <target> [file] Screenshot (default: state-dir/screenshot.png); prints coordinate mapping
html <target> [selector] Get HTML (full page or CSS selector)
nav <target> <url> Navigate to URL and wait for load completion
net <target> Network performance entries
click <target> <selector> Click an element by CSS selector
clickxy <target> <x> <y> Click at CSS pixel coordinates (see coordinate note below)
type <target> <text> Type text at current focus via Input.insertText
Works in cross-origin iframes unlike eval-based approaches
loadall <target> <selector> [ms] Repeatedly click a "load more" button until it disappears
Optional interval in ms between clicks (default 500)
evalraw <target> <method> [json] Send a raw CDP command; returns JSON result
e.g. evalraw <t> "DOM.getDocument" '{}'
batch <target> <json> Run multiple page commands in one daemon roundtrip
JSON: [{"cmd":"eval","args":["document.title"]}, ...]
stop [target] Stop daemon(s)
<target> 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-<uid>, or the system temp fallback.
Protocol: newline-delimited JSON (one JSON object per line, UTF-8).
Request: {"id":<number>, "cmd":"<command>", "args":["arg1","arg2",...]}
Response: {"id":<number>, "ok":true, "result":"<string>"}
or {"id":<number>, "ok":false, "error":"<message>"}
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);
});
}