@apmantza/greedysearch-pi 1.8.6 → 1.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cdp.mjs CHANGED
@@ -1,1010 +1,1095 @@
1
- #!/usr/bin/env node
2
-
3
- // cdp - lightweight Chrome DevTools Protocol CLI (no Puppeteer)
4
- // Forked from https://github.com/pasky/chrome-cdp-skill with Windows fixes:
5
- // - getWsUrl() uses platform-aware DevToolsActivePort path
6
- // - SOCK_PREFIX / PAGES_CACHE use os.tmpdir() instead of hardcoded /tmp
7
- //
8
- // Per-tab persistent daemon: page commands go through a daemon that holds
9
- // the CDP session open. Chrome's "Allow debugging" modal fires once per
10
- // daemon (= once per tab). Daemons auto-exit after 20min idle.
11
-
12
- import { spawn } from "node:child_process";
13
- import {
14
- existsSync,
15
- readdirSync,
16
- readFileSync,
17
- unlinkSync,
18
- writeFileSync,
19
- } from "node:fs";
20
- import net from "node:net";
21
- import { homedir, platform, tmpdir } from "node:os";
22
- import { join } from "node:path";
23
-
24
- const TIMEOUT = 15000;
25
- const NAVIGATION_TIMEOUT = 30000;
26
- const IDLE_TIMEOUT = 20 * 60 * 1000;
27
- const DAEMON_CONNECT_RETRIES = 20;
28
- const DAEMON_CONNECT_DELAY = 300;
29
- const MIN_TARGET_PREFIX_LEN = 8;
30
-
31
- const _tmpdir = tmpdir().replaceAll("\\", "/");
32
- const PAGES_CACHE = `${_tmpdir}/cdp-pages.json`;
33
-
34
- function sockPath(targetId) {
35
- // Windows: use named pipes (reliable cross-platform IPC in Node.js)
36
- if (platform() === "win32") return `\\\\.\\pipe\\cdp-${targetId}`;
37
- return `${_tmpdir}/cdp-${targetId}.sock`;
38
- }
39
-
40
- function getDevToolsActivePortPath() {
41
- const os = platform();
42
- if (os === "win32")
43
- return join(
44
- homedir(),
45
- "AppData",
46
- "Local",
47
- "Google",
48
- "Chrome",
49
- "User Data",
50
- "DevToolsActivePort",
51
- );
52
- if (os === "darwin")
53
- return join(
54
- homedir(),
55
- "Library",
56
- "Application Support",
57
- "Google",
58
- "Chrome",
59
- "DevToolsActivePort",
60
- );
61
- return join(homedir(), ".config", "google-chrome", "DevToolsActivePort");
62
- }
63
-
64
- function getWsUrl() {
65
- // If CDP_PROFILE_DIR is set (by search.mjs), prefer that profile's port file
66
- // so GreedySearch targets its own Chrome, not the user's main session.
67
- const profileDir = process.env.CDP_PROFILE_DIR;
68
- if (profileDir) {
69
- const p = `${profileDir.replaceAll("\\", "/")}/DevToolsActivePort`;
70
- if (existsSync(p)) {
71
- const lines = readFileSync(p, "utf8").trim().split("\n");
72
- return `ws://localhost:${lines[0]}${lines[1]}`;
73
- }
74
- throw new Error(
75
- `GreedySearch DevToolsActivePort not found at ${p}. Refusing to fall back to the main Chrome session.`,
76
- );
77
- }
78
- const portFile = getDevToolsActivePortPath();
79
- const lines = readFileSync(portFile, "utf8").trim().split("\n");
80
- return `ws://localhost:${lines[0]}${lines[1]}`;
81
- }
82
-
83
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
84
-
85
- function listDaemonSockets() {
86
- // Named pipes on Windows aren't enumerable as filesystem entries
87
- if (platform() === "win32") return [];
88
- const tmp = tmpdir();
89
- try {
90
- return readdirSync(tmp)
91
- .filter((f) => f.startsWith("cdp-") && f.endsWith(".sock"))
92
- .map((f) => ({
93
- targetId: f.slice(4, -5),
94
- socketPath: join(tmp, f),
95
- }));
96
- } catch {
97
- return [];
98
- }
99
- }
100
-
101
- function resolvePrefix(prefix, candidates, noun = "target", missingHint = "") {
102
- const upper = prefix.toUpperCase();
103
- const matches = candidates.filter((candidate) =>
104
- candidate.toUpperCase().startsWith(upper),
105
- );
106
- if (matches.length === 0) {
107
- const hint = missingHint ? ` ${missingHint}` : "";
108
- throw new Error(`No ${noun} matching prefix "${prefix}".${hint}`);
109
- }
110
- if (matches.length > 1) {
111
- throw new Error(
112
- `Ambiguous prefix "${prefix}" — matches ${matches.length} ${noun}s. Use more characters.`,
113
- );
114
- }
115
- return matches[0];
116
- }
117
-
118
- function getDisplayPrefixLength(targetIds) {
119
- if (targetIds.length === 0) return MIN_TARGET_PREFIX_LEN;
120
- const maxLen = Math.max(...targetIds.map((id) => id.length));
121
- for (let len = MIN_TARGET_PREFIX_LEN; len <= maxLen; len++) {
122
- const prefixes = new Set(
123
- targetIds.map((id) => id.slice(0, len).toUpperCase()),
124
- );
125
- if (prefixes.size === targetIds.length) return len;
126
- }
127
- return maxLen;
128
- }
129
-
130
- // ---------------------------------------------------------------------------
131
- // CDP WebSocket client
132
- // ---------------------------------------------------------------------------
133
-
134
- class CDP {
135
- #ws;
136
- #id = 0;
137
- #pending = new Map();
138
- #eventHandlers = new Map();
139
- #closeHandlers = [];
140
-
141
- async connect(wsUrl) {
142
- return new Promise((res, rej) => {
143
- this.#ws = new WebSocket(wsUrl);
144
- this.#ws.onopen = () => res();
145
- this.#ws.onerror = (e) =>
146
- rej(new Error(`WebSocket error: ${e.message || e.type}`));
147
- this.#ws.onclose = () => this.#closeHandlers.forEach((h) => h());
148
- this.#ws.onmessage = (ev) => {
149
- const msg = JSON.parse(ev.data);
150
- if (msg.id && this.#pending.has(msg.id)) {
151
- const { resolve, reject } = this.#pending.get(msg.id);
152
- this.#pending.delete(msg.id);
153
- if (msg.error) reject(new Error(msg.error.message));
154
- else resolve(msg.result);
155
- } else if (msg.method && this.#eventHandlers.has(msg.method)) {
156
- for (const handler of [...this.#eventHandlers.get(msg.method)]) {
157
- handler(msg.params || {}, msg);
158
- }
159
- }
160
- };
161
- });
162
- }
163
-
164
- send(method, params = {}, sessionId) {
165
- const id = ++this.#id;
166
- return new Promise((resolve, reject) => {
167
- this.#pending.set(id, { resolve, reject });
168
- const msg = { id, method, params };
169
- if (sessionId) msg.sessionId = sessionId;
170
- this.#ws.send(JSON.stringify(msg));
171
- setTimeout(() => {
172
- if (this.#pending.has(id)) {
173
- this.#pending.delete(id);
174
- reject(new Error(`Timeout: ${method}`));
175
- }
176
- }, TIMEOUT);
177
- });
178
- }
179
-
180
- onEvent(method, handler) {
181
- if (!this.#eventHandlers.has(method))
182
- this.#eventHandlers.set(method, new Set());
183
- const handlers = this.#eventHandlers.get(method);
184
- handlers.add(handler);
185
- return () => {
186
- handlers.delete(handler);
187
- if (handlers.size === 0) this.#eventHandlers.delete(method);
188
- };
189
- }
190
-
191
- waitForEvent(method, timeout = TIMEOUT) {
192
- let settled = false;
193
- let off;
194
- let timer;
195
- const promise = new Promise((resolve, reject) => {
196
- off = this.onEvent(method, (params) => {
197
- if (settled) return;
198
- settled = true;
199
- clearTimeout(timer);
200
- off();
201
- resolve(params);
202
- });
203
- timer = setTimeout(() => {
204
- if (settled) return;
205
- settled = true;
206
- off();
207
- reject(new Error(`Timeout waiting for event: ${method}`));
208
- }, timeout);
209
- });
210
- return {
211
- promise,
212
- cancel() {
213
- if (settled) return;
214
- settled = true;
215
- clearTimeout(timer);
216
- off?.();
217
- },
218
- };
219
- }
220
-
221
- onClose(handler) {
222
- this.#closeHandlers.push(handler);
223
- }
224
- close() {
225
- this.#ws.close();
226
- }
227
- }
228
-
229
- // ---------------------------------------------------------------------------
230
- // Command implementations — return strings, take (cdp, sessionId)
231
- // ---------------------------------------------------------------------------
232
-
233
- async function getPages(cdp) {
234
- const { targetInfos } = await cdp.send("Target.getTargets");
235
- return targetInfos.filter(
236
- (t) =>
237
- t.type === "page" &&
238
- (!t.url.startsWith("chrome://") || t.url === "chrome://newtab/"),
239
- );
240
- }
241
-
242
- function formatPageList(pages) {
243
- const prefixLen = getDisplayPrefixLength(pages.map((p) => p.targetId));
244
- return pages
245
- .map((p) => {
246
- const id = p.targetId.slice(0, prefixLen).padEnd(prefixLen);
247
- const title = p.title.substring(0, 54).padEnd(54);
248
- return `${id} ${title} ${p.url}`;
249
- })
250
- .join("\n");
251
- }
252
-
253
- function shouldShowAxNode(node, compact = false) {
254
- const role = node.role?.value || "";
255
- const name = node.name?.value ?? "";
256
- const value = node.value?.value;
257
- if (compact && role === "InlineTextBox") return false;
258
- return (
259
- role !== "none" &&
260
- role !== "generic" &&
261
- !(name === "" && (value === "" || value == null))
262
- );
263
- }
264
-
265
- function formatAxNode(node, depth) {
266
- const role = node.role?.value || "";
267
- const name = node.name?.value ?? "";
268
- const value = node.value?.value;
269
- const indent = " ".repeat(Math.min(depth, 10));
270
- let line = `${indent}[${role}]`;
271
- if (name !== "") line += ` ${name}`;
272
- if (!(value === "" || value == null)) line += ` = ${JSON.stringify(value)}`;
273
- return line;
274
- }
275
-
276
- function orderedAxChildren(node, nodesById, childrenByParent) {
277
- const children = [];
278
- const seen = new Set();
279
- for (const childId of node.childIds || []) {
280
- const child = nodesById.get(childId);
281
- if (child && !seen.has(child.nodeId)) {
282
- seen.add(child.nodeId);
283
- children.push(child);
284
- }
285
- }
286
- for (const child of childrenByParent.get(node.nodeId) || []) {
287
- if (!seen.has(child.nodeId)) {
288
- seen.add(child.nodeId);
289
- children.push(child);
290
- }
291
- }
292
- return children;
293
- }
294
-
295
- async function snapshotStr(cdp, sid, compact = false) {
296
- const { nodes } = await cdp.send("Accessibility.getFullAXTree", {}, sid);
297
- const nodesById = new Map(nodes.map((node) => [node.nodeId, node]));
298
- const childrenByParent = new Map();
299
- for (const node of nodes) {
300
- if (!node.parentId) continue;
301
- if (!childrenByParent.has(node.parentId))
302
- childrenByParent.set(node.parentId, []);
303
- childrenByParent.get(node.parentId).push(node);
304
- }
305
-
306
- const lines = [];
307
- const visited = new Set();
308
- function visit(node, depth) {
309
- if (!node || visited.has(node.nodeId)) return;
310
- visited.add(node.nodeId);
311
- if (shouldShowAxNode(node, compact)) lines.push(formatAxNode(node, depth));
312
- for (const child of orderedAxChildren(node, nodesById, childrenByParent)) {
313
- visit(child, depth + 1);
314
- }
315
- }
316
-
317
- const roots = nodes.filter(
318
- (node) => !node.parentId || !nodesById.has(node.parentId),
319
- );
320
- for (const root of roots) visit(root, 0);
321
- for (const node of nodes) visit(node, 0);
322
-
323
- return lines.join("\n");
324
- }
325
-
326
- async function evalStr(cdp, sid, expression) {
327
- await cdp.send("Runtime.enable", {}, sid);
328
- const result = await cdp.send(
329
- "Runtime.evaluate",
330
- {
331
- expression,
332
- returnByValue: true,
333
- awaitPromise: true,
334
- },
335
- sid,
336
- );
337
- if (result.exceptionDetails) {
338
- throw new Error(
339
- result.exceptionDetails.text ||
340
- result.exceptionDetails.exception?.description,
341
- );
342
- }
343
- const val = result.result.value;
344
- return typeof val === "object"
345
- ? JSON.stringify(val, null, 2)
346
- : String(val ?? "");
347
- }
348
-
349
- async function shotStr(cdp, sid, filePath) {
350
- let dpr = 1;
351
- try {
352
- const metrics = await cdp.send("Page.getLayoutMetrics", {}, sid);
353
- dpr = metrics.visualViewport?.clientWidth
354
- ? metrics.cssVisualViewport?.clientWidth
355
- ? Math.round(
356
- (metrics.visualViewport.clientWidth /
357
- metrics.cssVisualViewport.clientWidth) *
358
- 100,
359
- ) / 100
360
- : 1
361
- : 1;
362
- const { deviceScaleFactor } = await cdp
363
- .send("Emulation.getDeviceMetricsOverride", {}, sid)
364
- .catch(() => ({}));
365
- if (deviceScaleFactor) dpr = deviceScaleFactor;
366
- } catch {}
367
- if (dpr === 1) {
368
- try {
369
- const raw = await evalStr(cdp, sid, "window.devicePixelRatio");
370
- const parsed = Number.parseFloat(raw);
371
- if (parsed > 0) dpr = parsed;
372
- } catch {}
373
- }
374
-
375
- const { data } = await cdp.send(
376
- "Page.captureScreenshot",
377
- { format: "png" },
378
- sid,
379
- );
380
- const out = filePath || join(tmpdir(), "screenshot.png");
381
- writeFileSync(out, Buffer.from(data, "base64"));
382
-
383
- const lines = [out];
384
- lines.push(`Screenshot saved. Device pixel ratio (DPR): ${dpr}`);
385
- lines.push(`Coordinate mapping:`);
386
- lines.push(
387
- ` Screenshot pixels CSS pixels (for CDP Input events): divide by ${dpr}`,
388
- );
389
- lines.push(
390
- ` e.g. screenshot point (${Math.round(100 * dpr)}, ${Math.round(200 * dpr)}) → CSS (100, 200) → use clickxy <target> 100 200`,
391
- );
392
- if (dpr !== 1) {
393
- lines.push(
394
- ` On this ${dpr}x display: CSS px = screenshot px / ${dpr} ≈ screenshot px × ${Math.round(100 / dpr) / 100}`,
395
- );
396
- }
397
- return lines.join("\n");
398
- }
399
-
400
- async function htmlStr(cdp, sid, selector) {
401
- const expr = selector
402
- ? `document.querySelector(${JSON.stringify(selector)})?.outerHTML || 'Element not found'`
403
- : `document.documentElement.outerHTML`;
404
- return evalStr(cdp, sid, expr);
405
- }
406
-
407
- async function waitForDocumentReady(cdp, sid, timeoutMs = NAVIGATION_TIMEOUT) {
408
- const deadline = Date.now() + timeoutMs;
409
- let lastState = "";
410
- let lastError;
411
- while (Date.now() < deadline) {
412
- try {
413
- const state = await evalStr(cdp, sid, "document.readyState");
414
- lastState = state;
415
- if (state === "complete") return;
416
- } catch (e) {
417
- lastError = e;
418
- }
419
- await sleep(200);
420
- }
421
-
422
- if (lastState) {
423
- throw new Error(
424
- `Timed out waiting for navigation to finish (last readyState: ${lastState})`,
425
- );
426
- }
427
- if (lastError) {
428
- throw new Error(
429
- `Timed out waiting for navigation to finish (${lastError.message})`,
430
- );
431
- }
432
- throw new Error("Timed out waiting for navigation to finish");
433
- }
434
-
435
- async function navStr(cdp, sid, url) {
436
- await cdp.send("Page.enable", {}, sid);
437
- const loadEvent = cdp.waitForEvent("Page.loadEventFired", NAVIGATION_TIMEOUT);
438
- const result = await cdp.send("Page.navigate", { url }, sid);
439
- if (result.errorText) {
440
- loadEvent.cancel();
441
- throw new Error(result.errorText);
442
- }
443
- if (result.loaderId) {
444
- await loadEvent.promise;
445
- } else {
446
- loadEvent.cancel();
447
- }
448
- await waitForDocumentReady(cdp, sid, 5000);
449
- return `Navigated to ${url}`;
450
- }
451
-
452
- async function netStr(cdp, sid) {
453
- const raw = await evalStr(
454
- cdp,
455
- sid,
456
- `JSON.stringify(performance.getEntriesByType('resource').map(e => ({
457
- name: e.name.substring(0, 120), type: e.initiatorType,
458
- duration: Math.round(e.duration), size: e.transferSize
459
- })))`,
460
- );
461
- return JSON.parse(raw)
462
- .map(
463
- (e) =>
464
- `${String(e.duration).padStart(5)}ms ${String(e.size || "?").padStart(8)}B ${e.type.padEnd(8)} ${e.name}`,
465
- )
466
- .join("\n");
467
- }
468
-
469
- async function clickStr(cdp, sid, selector) {
470
- if (!selector) throw new Error("CSS selector required");
471
- const expr = `
472
- (function() {
473
- const el = document.querySelector(${JSON.stringify(selector)});
474
- if (!el) return { ok: false, error: 'Element not found: ' + ${JSON.stringify(selector)} };
475
- el.scrollIntoView({ block: 'center' });
476
- el.click();
477
- return { ok: true, tag: el.tagName, text: el.textContent.trim().substring(0, 80) };
478
- })()
479
- `;
480
- const result = await evalStr(cdp, sid, expr);
481
- const r = JSON.parse(result);
482
- if (!r.ok) throw new Error(r.error);
483
- return `Clicked <${r.tag}> "${r.text}"`;
484
- }
485
-
486
- async function clickXyStr(cdp, sid, x, y) {
487
- const cx = Number.parseFloat(x);
488
- const cy = Number.parseFloat(y);
489
- if (Number.isNaN(cx) || Number.isNaN(cy))
490
- throw new Error("x and y must be numbers (CSS pixels)");
491
- const base = { x: cx, y: cy, button: "left", clickCount: 1, modifiers: 0 };
492
- await cdp.send(
493
- "Input.dispatchMouseEvent",
494
- { ...base, type: "mouseMoved" },
495
- sid,
496
- );
497
- await cdp.send(
498
- "Input.dispatchMouseEvent",
499
- { ...base, type: "mousePressed" },
500
- sid,
501
- );
502
- await sleep(50);
503
- await cdp.send(
504
- "Input.dispatchMouseEvent",
505
- { ...base, type: "mouseReleased" },
506
- sid,
507
- );
508
- return `Clicked at CSS (${cx}, ${cy})`;
509
- }
510
-
511
- async function typeStr(cdp, sid, text) {
512
- if (text == null || text === "") throw new Error("text required");
513
- await cdp.send("Input.insertText", { text }, sid);
514
- return `Typed ${text.length} characters`;
515
- }
516
-
517
- async function loadAllStr(cdp, sid, selector, intervalMs = 1500) {
518
- if (!selector) throw new Error("CSS selector required");
519
- intervalMs = Math.min(
520
- Math.max(Number.parseInt(intervalMs, 10) || 1500, 100),
521
- 30000,
522
- );
523
- let clicks = 0;
524
- const deadline = Date.now() + 5 * 60 * 1000;
525
- while (Date.now() < deadline) {
526
- const exists = await evalStr(
527
- cdp,
528
- sid,
529
- `!!document.querySelector(${JSON.stringify(selector)})`,
530
- );
531
- if (exists !== "true") break;
532
- const clickExpr = `
533
- (function() {
534
- const el = document.querySelector(${JSON.stringify(selector)});
535
- if (!el) return false;
536
- el.scrollIntoView({ block: 'center' });
537
- el.click();
538
- return true;
539
- })()
540
- `;
541
- const clicked = await evalStr(cdp, sid, clickExpr);
542
- if (clicked !== "true") break;
543
- clicks++;
544
- await sleep(intervalMs);
545
- }
546
- return `Clicked "${selector}" ${clicks} time(s) until it disappeared`;
547
- }
548
-
549
- async function evalRawStr(cdp, sid, method, paramsJson) {
550
- if (!method) throw new Error('CDP method required (e.g. "DOM.getDocument")');
551
- let params = {};
552
- if (paramsJson) {
553
- try {
554
- params = JSON.parse(paramsJson);
555
- } catch {
556
- throw new Error(`Invalid JSON params: ${paramsJson}`);
557
- }
558
- }
559
- const result = await cdp.send(method, params, sid);
560
- return JSON.stringify(result, null, 2);
561
- }
562
-
563
- // ---------------------------------------------------------------------------
564
- // Per-tab daemon
565
- // ---------------------------------------------------------------------------
566
-
567
- async function runDaemon(targetId) {
568
- const sp = sockPath(targetId);
569
-
570
- const cdp = new CDP();
571
- try {
572
- await cdp.connect(getWsUrl());
573
- } catch (e) {
574
- process.stderr.write(`Daemon: cannot connect to Chrome: ${e.message}\n`);
575
- process.exit(1);
576
- }
577
-
578
- let sessionId;
579
- try {
580
- const res = await cdp.send("Target.attachToTarget", {
581
- targetId,
582
- flatten: true,
583
- });
584
- sessionId = res.sessionId;
585
- } catch (e) {
586
- process.stderr.write(`Daemon: attach failed: ${e.message}\n`);
587
- cdp.close();
588
- process.exit(1);
589
- }
590
-
591
- let alive = true;
592
- function shutdown() {
593
- if (!alive) return;
594
- alive = false;
595
- server.close();
596
- try {
597
- unlinkSync(sp);
598
- } catch {}
599
- cdp.close();
600
- process.exit(0);
601
- }
602
-
603
- cdp.onEvent("Target.targetDestroyed", (params) => {
604
- if (params.targetId === targetId) shutdown();
605
- });
606
- cdp.onEvent("Target.detachedFromTarget", (params) => {
607
- if (params.sessionId === sessionId) shutdown();
608
- });
609
- cdp.onClose(() => shutdown());
610
- process.on("SIGTERM", shutdown);
611
- process.on("SIGINT", shutdown);
612
-
613
- let idleTimer = setTimeout(shutdown, IDLE_TIMEOUT);
614
- function resetIdle() {
615
- clearTimeout(idleTimer);
616
- idleTimer = setTimeout(shutdown, IDLE_TIMEOUT);
617
- }
618
-
619
- async function handleCommand({ cmd, args }) {
620
- resetIdle();
621
- try {
622
- let result;
623
- switch (cmd) {
624
- case "list": {
625
- const pages = await getPages(cdp);
626
- result = formatPageList(pages);
627
- break;
628
- }
629
- case "list_raw": {
630
- const pages = await getPages(cdp);
631
- result = JSON.stringify(pages);
632
- break;
633
- }
634
- case "snap":
635
- case "snapshot":
636
- result = await snapshotStr(cdp, sessionId, true);
637
- break;
638
- case "eval":
639
- result = await evalStr(cdp, sessionId, args[0]);
640
- break;
641
- case "shot":
642
- case "screenshot":
643
- result = await shotStr(cdp, sessionId, args[0]);
644
- break;
645
- case "html":
646
- result = await htmlStr(cdp, sessionId, args[0]);
647
- break;
648
- case "nav":
649
- case "navigate":
650
- result = await navStr(cdp, sessionId, args[0]);
651
- break;
652
- case "net":
653
- case "network":
654
- result = await netStr(cdp, sessionId);
655
- break;
656
- case "click":
657
- result = await clickStr(cdp, sessionId, args[0]);
658
- break;
659
- case "clickxy":
660
- result = await clickXyStr(cdp, sessionId, args[0], args[1]);
661
- break;
662
- case "type":
663
- result = await typeStr(cdp, sessionId, args[0]);
664
- break;
665
- case "loadall":
666
- result = await loadAllStr(
667
- cdp,
668
- sessionId,
669
- args[0],
670
- args[1] ? Number.parseInt(args[1], 10) : 1500,
671
- );
672
- break;
673
- case "evalraw":
674
- result = await evalRawStr(cdp, sessionId, args[0], args[1]);
675
- break;
676
- case "stop":
677
- return { ok: true, result: "", stopAfter: true };
678
- default:
679
- return { ok: false, error: `Unknown command: ${cmd}` };
680
- }
681
- return { ok: true, result: result ?? "" };
682
- } catch (e) {
683
- return { ok: false, error: e.message };
684
- }
685
- }
686
-
687
- const server = net.createServer((conn) => {
688
- let buf = "";
689
- conn.on("data", (chunk) => {
690
- buf += chunk.toString();
691
- const lines = buf.split("\n");
692
- buf = lines.pop();
693
- for (const line of lines) {
694
- if (!line.trim()) continue;
695
- let req;
696
- try {
697
- req = JSON.parse(line);
698
- } catch {
699
- conn.write(
700
- `${JSON.stringify({ ok: false, error: "Invalid JSON request", id: null })}\n`,
701
- );
702
- continue;
703
- }
704
- handleCommand(req).then((res) => {
705
- const payload = `${JSON.stringify({ ...res, id: req.id })}\n`;
706
- if (res.stopAfter) conn.end(payload, shutdown);
707
- else conn.write(payload);
708
- });
709
- }
710
- });
711
- });
712
-
713
- try {
714
- unlinkSync(sp);
715
- } catch {}
716
- server.listen(sp);
717
- }
718
-
719
- // ---------------------------------------------------------------------------
720
- // CLI ↔ daemon communication
721
- // ---------------------------------------------------------------------------
722
-
723
- function connectToSocket(sp) {
724
- return new Promise((resolve, reject) => {
725
- const conn = net.connect(sp);
726
- conn.on("connect", () => resolve(conn));
727
- conn.on("error", reject);
728
- });
729
- }
730
-
731
- async function getOrStartTabDaemon(targetId) {
732
- const sp = sockPath(targetId);
733
- try {
734
- return await connectToSocket(sp);
735
- } catch {}
736
- try {
737
- unlinkSync(sp);
738
- } catch {}
739
-
740
- const child = spawn(
741
- process.execPath,
742
- [process.argv[1], "_daemon", targetId],
743
- {
744
- detached: true,
745
- stdio: "ignore",
746
- },
747
- );
748
- child.unref();
749
-
750
- for (let i = 0; i < DAEMON_CONNECT_RETRIES; i++) {
751
- await sleep(DAEMON_CONNECT_DELAY);
752
- try {
753
- return await connectToSocket(sp);
754
- } catch {}
755
- }
756
- throw new Error("Daemon failed to start — did you click Allow in Chrome?");
757
- }
758
-
759
- function sendCommand(conn, req) {
760
- return new Promise((resolve, reject) => {
761
- let buf = "";
762
- let settled = false;
763
-
764
- const cleanup = () => {
765
- conn.off("data", onData);
766
- conn.off("error", onError);
767
- conn.off("end", onEnd);
768
- conn.off("close", onClose);
769
- };
770
-
771
- const onData = (chunk) => {
772
- buf += chunk.toString();
773
- const idx = buf.indexOf("\n");
774
- if (idx === -1) return;
775
- settled = true;
776
- cleanup();
777
- resolve(JSON.parse(buf.slice(0, idx)));
778
- conn.end();
779
- };
780
-
781
- const onError = (error) => {
782
- if (settled) return;
783
- settled = true;
784
- cleanup();
785
- reject(error);
786
- };
787
-
788
- const onEnd = () => {
789
- if (settled) return;
790
- settled = true;
791
- cleanup();
792
- reject(new Error("Connection closed before response"));
793
- };
794
-
795
- const onClose = () => {
796
- if (settled) return;
797
- settled = true;
798
- cleanup();
799
- reject(new Error("Connection closed before response"));
800
- };
801
-
802
- conn.on("data", onData);
803
- conn.on("error", onError);
804
- conn.on("end", onEnd);
805
- conn.on("close", onClose);
806
- req.id = 1;
807
- conn.write(`${JSON.stringify(req)}\n`);
808
- });
809
- }
810
-
811
- function findAnyDaemonSocket() {
812
- return listDaemonSockets()[0]?.socketPath || null;
813
- }
814
-
815
- // ---------------------------------------------------------------------------
816
- // Stop daemons
817
- // ---------------------------------------------------------------------------
818
-
819
- async function stopDaemons(targetPrefix) {
820
- const daemons = listDaemonSockets();
821
-
822
- if (targetPrefix) {
823
- const targetId = resolvePrefix(
824
- targetPrefix,
825
- daemons.map((d) => d.targetId),
826
- "daemon",
827
- );
828
- const daemon = daemons.find((d) => d.targetId === targetId);
829
- try {
830
- const conn = await connectToSocket(daemon.socketPath);
831
- await sendCommand(conn, { cmd: "stop" });
832
- } catch {
833
- try {
834
- unlinkSync(daemon.socketPath);
835
- } catch {}
836
- }
837
- return;
838
- }
839
-
840
- for (const daemon of daemons) {
841
- try {
842
- const conn = await connectToSocket(daemon.socketPath);
843
- await sendCommand(conn, { cmd: "stop" });
844
- } catch {
845
- try {
846
- unlinkSync(daemon.socketPath);
847
- } catch {}
848
- }
849
- }
850
- }
851
-
852
- // ---------------------------------------------------------------------------
853
- // Main
854
- // ---------------------------------------------------------------------------
855
-
856
- const USAGE = `cdp - lightweight Chrome DevTools Protocol CLI (no Puppeteer)
857
-
858
- Usage: cdp <command> [args]
859
-
860
- list List open pages (shows unique target prefixes)
861
- snap <target> Accessibility tree snapshot
862
- eval <target> <expr> Evaluate JS expression
863
- shot <target> [file] Screenshot; prints coordinate mapping
864
- html <target> [selector] Get HTML (full page or CSS selector)
865
- nav <target> <url> Navigate to URL and wait for load completion
866
- net <target> Network performance entries
867
- click <target> <selector> Click an element by CSS selector
868
- clickxy <target> <x> <y> Click at CSS pixel coordinates
869
- type <target> <text> Type text at current focus
870
- loadall <target> <selector> [ms] Repeatedly click a "load more" button
871
- evalraw <target> <method> [json] Send a raw CDP command; returns JSON result
872
- stop [target] Stop daemon(s)
873
- `;
874
-
875
- const NEEDS_TARGET = new Set([
876
- "snap",
877
- "snapshot",
878
- "eval",
879
- "shot",
880
- "screenshot",
881
- "html",
882
- "nav",
883
- "navigate",
884
- "net",
885
- "network",
886
- "click",
887
- "clickxy",
888
- "type",
889
- "loadall",
890
- "evalraw",
891
- ]);
892
-
893
- async function main() {
894
- const [cmd, ...args] = process.argv.slice(2);
895
-
896
- if (cmd === "_daemon") {
897
- await runDaemon(args[0]);
898
- return;
899
- }
900
-
901
- if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
902
- console.log(USAGE);
903
- process.exit(0);
904
- }
905
-
906
- if (cmd === "list" || cmd === "ls") {
907
- let pages;
908
- const existingSock = findAnyDaemonSocket();
909
- if (existingSock) {
910
- try {
911
- const conn = await connectToSocket(existingSock);
912
- const resp = await sendCommand(conn, { cmd: "list_raw" });
913
- if (resp.ok) pages = JSON.parse(resp.result);
914
- } catch {}
915
- }
916
- if (!pages) {
917
- const cdp = new CDP();
918
- await cdp.connect(getWsUrl());
919
- pages = await getPages(cdp);
920
- cdp.close();
921
- }
922
- writeFileSync(PAGES_CACHE, JSON.stringify(pages));
923
- console.log(formatPageList(pages));
924
- setTimeout(() => process.exit(0), 100);
925
- return;
926
- }
927
-
928
- if (cmd === "stop") {
929
- await stopDaemons(args[0]);
930
- return;
931
- }
932
-
933
- if (!NEEDS_TARGET.has(cmd)) {
934
- console.error(`Unknown command: ${cmd}\n`);
935
- console.log(USAGE);
936
- process.exit(1);
937
- }
938
-
939
- const targetPrefix = args[0];
940
- if (!targetPrefix) {
941
- console.error('Error: target ID required. Run "cdp list" first.');
942
- process.exit(1);
943
- }
944
-
945
- let targetId;
946
- const daemonTargetIds = listDaemonSockets().map((d) => d.targetId);
947
- const daemonMatches = daemonTargetIds.filter((id) =>
948
- id.toUpperCase().startsWith(targetPrefix.toUpperCase()),
949
- );
950
-
951
- if (daemonMatches.length > 0) {
952
- targetId = resolvePrefix(targetPrefix, daemonTargetIds, "daemon");
953
- } else {
954
- if (!existsSync(PAGES_CACHE)) {
955
- console.error('No page list cached. Run "cdp list" first.');
956
- process.exit(1);
957
- }
958
- const pages = JSON.parse(readFileSync(PAGES_CACHE, "utf8"));
959
- targetId = resolvePrefix(
960
- targetPrefix,
961
- pages.map((p) => p.targetId),
962
- "target",
963
- 'Run "cdp list".',
964
- );
965
- }
966
-
967
- const conn = await getOrStartTabDaemon(targetId);
968
- const cmdArgs = args.slice(1);
969
-
970
- if (cmd === "eval") {
971
- const expr = cmdArgs.join(" ");
972
- if (!expr) {
973
- console.error("Error: expression required");
974
- process.exit(1);
975
- }
976
- cmdArgs[0] = expr;
977
- } else if (cmd === "type") {
978
- const text = cmdArgs.join(" ");
979
- if (!text) {
980
- console.error("Error: text required");
981
- process.exit(1);
982
- }
983
- cmdArgs[0] = text;
984
- } else if (cmd === "evalraw") {
985
- if (!cmdArgs[0]) {
986
- console.error("Error: CDP method required");
987
- process.exit(1);
988
- }
989
- if (cmdArgs.length > 2) cmdArgs[1] = cmdArgs.slice(1).join(" ");
990
- }
991
-
992
- if ((cmd === "nav" || cmd === "navigate") && !cmdArgs[0]) {
993
- console.error("Error: URL required");
994
- process.exit(1);
995
- }
996
-
997
- const response = await sendCommand(conn, { cmd, args: cmdArgs });
998
-
999
- if (response.ok) {
1000
- if (response.result) console.log(response.result);
1001
- } else {
1002
- console.error("Error:", response.error);
1003
- process.exitCode = 1;
1004
- }
1005
- }
1006
-
1007
- main().catch((e) => {
1008
- console.error(e.message);
1009
- process.exit(1);
1010
- });
1
+ #!/usr/bin/env node
2
+
3
+ // cdp - lightweight Chrome DevTools Protocol CLI (no Puppeteer)
4
+ // Forked from https://github.com/pasky/chrome-cdp-skill with Windows fixes:
5
+ // - getWsUrl() uses platform-aware DevToolsActivePort path
6
+ // - SOCK_PREFIX / PAGES_CACHE use os.tmpdir() instead of hardcoded /tmp
7
+ //
8
+ // Per-tab persistent daemon: page commands go through a daemon that holds
9
+ // the CDP session open. Chrome's "Allow debugging" modal fires once per
10
+ // daemon (= once per tab). Daemons auto-exit after 20min idle.
11
+
12
+ import { spawn } from "node:child_process";
13
+ import {
14
+ existsSync,
15
+ readdirSync,
16
+ readFileSync,
17
+ unlinkSync,
18
+ writeFileSync,
19
+ } from "node:fs";
20
+ import net from "node:net";
21
+ import { homedir, platform, tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+
24
+ const TIMEOUT = 90000;
25
+ const NAVIGATION_TIMEOUT = 30000;
26
+ const IDLE_TIMEOUT = 20 * 60 * 1000;
27
+ const DAEMON_CONNECT_RETRIES = 20;
28
+ const DAEMON_CONNECT_DELAY = 300;
29
+ const MIN_TARGET_PREFIX_LEN = 8;
30
+
31
+ const _tmpdir = tmpdir().replaceAll("\\", "/");
32
+ const PAGES_CACHE = `${_tmpdir}/cdp-pages.json`;
33
+
34
+ function sockPath(targetId) {
35
+ // Windows: use named pipes (reliable cross-platform IPC in Node.js)
36
+ if (platform() === "win32") return `\\\\.\\pipe\\cdp-${targetId}`;
37
+ return `${_tmpdir}/cdp-${targetId}.sock`;
38
+ }
39
+
40
+ function getDevToolsActivePortPath() {
41
+ const os = platform();
42
+ if (os === "win32")
43
+ return join(
44
+ homedir(),
45
+ "AppData",
46
+ "Local",
47
+ "Google",
48
+ "Chrome",
49
+ "User Data",
50
+ "DevToolsActivePort",
51
+ );
52
+ if (os === "darwin")
53
+ return join(
54
+ homedir(),
55
+ "Library",
56
+ "Application Support",
57
+ "Google",
58
+ "Chrome",
59
+ "DevToolsActivePort",
60
+ );
61
+ return join(homedir(), ".config", "google-chrome", "DevToolsActivePort");
62
+ }
63
+
64
+ function getWsUrl() {
65
+ // If CDP_PROFILE_DIR is set (by search.mjs), prefer that profile's port file
66
+ // so GreedySearch targets its own Chrome, not the user's main session.
67
+ const profileDir = process.env.CDP_PROFILE_DIR;
68
+ if (profileDir) {
69
+ const p = `${profileDir.replaceAll("\\", "/")}/DevToolsActivePort`;
70
+ if (existsSync(p)) {
71
+ const lines = readFileSync(p, "utf8").trim().split("\n");
72
+ return `ws://localhost:${lines[0]}${lines[1]}`;
73
+ }
74
+ throw new Error(
75
+ `GreedySearch DevToolsActivePort not found at ${p}. Refusing to fall back to the main Chrome session.`,
76
+ );
77
+ }
78
+ const portFile = getDevToolsActivePortPath();
79
+ const lines = readFileSync(portFile, "utf8").trim().split("\n");
80
+ return `ws://localhost:${lines[0]}${lines[1]}`;
81
+ }
82
+
83
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
84
+
85
+ function listDaemonSockets() {
86
+ // Named pipes on Windows aren't enumerable as filesystem entries
87
+ if (platform() === "win32") return [];
88
+ const tmp = tmpdir();
89
+ try {
90
+ return readdirSync(tmp)
91
+ .filter((f) => f.startsWith("cdp-") && f.endsWith(".sock"))
92
+ .map((f) => ({
93
+ targetId: f.slice(4, -5),
94
+ socketPath: join(tmp, f),
95
+ }));
96
+ } catch {
97
+ return [];
98
+ }
99
+ }
100
+
101
+ function resolvePrefix(prefix, candidates, noun = "target", missingHint = "") {
102
+ const upper = prefix.toUpperCase();
103
+ const matches = candidates.filter((candidate) =>
104
+ candidate.toUpperCase().startsWith(upper),
105
+ );
106
+ if (matches.length === 0) {
107
+ const hint = missingHint ? ` ${missingHint}` : "";
108
+ throw new Error(`No ${noun} matching prefix "${prefix}".${hint}`);
109
+ }
110
+ if (matches.length > 1) {
111
+ throw new Error(
112
+ `Ambiguous prefix "${prefix}" — matches ${matches.length} ${noun}s. Use more characters.`,
113
+ );
114
+ }
115
+ return matches[0];
116
+ }
117
+
118
+ function getDisplayPrefixLength(targetIds) {
119
+ if (targetIds.length === 0) return MIN_TARGET_PREFIX_LEN;
120
+ const maxLen = Math.max(...targetIds.map((id) => id.length));
121
+ for (let len = MIN_TARGET_PREFIX_LEN; len <= maxLen; len++) {
122
+ const prefixes = new Set(
123
+ targetIds.map((id) => id.slice(0, len).toUpperCase()),
124
+ );
125
+ if (prefixes.size === targetIds.length) return len;
126
+ }
127
+ return maxLen;
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // CDP WebSocket client
132
+ // ---------------------------------------------------------------------------
133
+
134
+ class CDP {
135
+ #ws;
136
+ #id = 0;
137
+ #pending = new Map();
138
+ #eventHandlers = new Map();
139
+ #closeHandlers = [];
140
+
141
+ async connect(wsUrl) {
142
+ return new Promise((res, rej) => {
143
+ this.#ws = new WebSocket(wsUrl);
144
+ this.#ws.onopen = () => res();
145
+ this.#ws.onerror = (e) =>
146
+ rej(new Error(`WebSocket error: ${e.message || e.type}`));
147
+ this.#ws.onclose = () => this.#closeHandlers.forEach((h) => h());
148
+ this.#ws.onmessage = (ev) => {
149
+ const msg = JSON.parse(ev.data);
150
+ if (msg.id && this.#pending.has(msg.id)) {
151
+ const { resolve, reject } = this.#pending.get(msg.id);
152
+ this.#pending.delete(msg.id);
153
+ if (msg.error) reject(new Error(msg.error.message));
154
+ else resolve(msg.result);
155
+ } else if (msg.method && this.#eventHandlers.has(msg.method)) {
156
+ for (const handler of [...this.#eventHandlers.get(msg.method)]) {
157
+ handler(msg.params || {}, msg);
158
+ }
159
+ }
160
+ };
161
+ });
162
+ }
163
+
164
+ send(method, params = {}, sessionId) {
165
+ const id = ++this.#id;
166
+ return new Promise((resolve, reject) => {
167
+ this.#pending.set(id, { resolve, reject });
168
+ const msg = { id, method, params };
169
+ if (sessionId) msg.sessionId = sessionId;
170
+ this.#ws.send(JSON.stringify(msg));
171
+ setTimeout(() => {
172
+ if (this.#pending.has(id)) {
173
+ this.#pending.delete(id);
174
+ reject(new Error(`Timeout: ${method}`));
175
+ }
176
+ }, TIMEOUT);
177
+ });
178
+ }
179
+
180
+ onEvent(method, handler) {
181
+ if (!this.#eventHandlers.has(method))
182
+ this.#eventHandlers.set(method, new Set());
183
+ const handlers = this.#eventHandlers.get(method);
184
+ handlers.add(handler);
185
+ return () => {
186
+ handlers.delete(handler);
187
+ if (handlers.size === 0) this.#eventHandlers.delete(method);
188
+ };
189
+ }
190
+
191
+ waitForEvent(method, timeout = TIMEOUT) {
192
+ let settled = false;
193
+ let off;
194
+ let timer;
195
+ const promise = new Promise((resolve, reject) => {
196
+ off = this.onEvent(method, (params) => {
197
+ if (settled) return;
198
+ settled = true;
199
+ clearTimeout(timer);
200
+ off();
201
+ resolve(params);
202
+ });
203
+ timer = setTimeout(() => {
204
+ if (settled) return;
205
+ settled = true;
206
+ off();
207
+ reject(new Error(`Timeout waiting for event: ${method}`));
208
+ }, timeout);
209
+ });
210
+ return {
211
+ promise,
212
+ cancel() {
213
+ if (settled) return;
214
+ settled = true;
215
+ clearTimeout(timer);
216
+ off?.();
217
+ },
218
+ };
219
+ }
220
+
221
+ onClose(handler) {
222
+ this.#closeHandlers.push(handler);
223
+ }
224
+ close() {
225
+ this.#ws.close();
226
+ }
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Command implementations — return strings, take (cdp, sessionId)
231
+ // ---------------------------------------------------------------------------
232
+
233
+ async function getPages(cdp) {
234
+ const { targetInfos } = await cdp.send("Target.getTargets");
235
+ return targetInfos.filter(
236
+ (t) =>
237
+ t.type === "page" &&
238
+ (!t.url.startsWith("chrome://") || t.url === "chrome://newtab/"),
239
+ );
240
+ }
241
+
242
+ function formatPageList(pages) {
243
+ const prefixLen = getDisplayPrefixLength(pages.map((p) => p.targetId));
244
+ return pages
245
+ .map((p) => {
246
+ const id = p.targetId.slice(0, prefixLen).padEnd(prefixLen);
247
+ const title = p.title.substring(0, 54).padEnd(54);
248
+ return `${id} ${title} ${p.url}`;
249
+ })
250
+ .join("\n");
251
+ }
252
+
253
+ function shouldShowAxNode(node, compact = false) {
254
+ const role = node.role?.value || "";
255
+ const name = node.name?.value ?? "";
256
+ const value = node.value?.value;
257
+ if (compact && role === "InlineTextBox") return false;
258
+ return (
259
+ role !== "none" &&
260
+ role !== "generic" &&
261
+ !(name === "" && (value === "" || value == null))
262
+ );
263
+ }
264
+
265
+ function formatAxNode(node, depth) {
266
+ const role = node.role?.value || "";
267
+ const name = node.name?.value ?? "";
268
+ const value = node.value?.value;
269
+ const indent = " ".repeat(Math.min(depth, 10));
270
+ let line = `${indent}[${role}]`;
271
+ if (name !== "") line += ` ${name}`;
272
+ if (!(value === "" || value == null)) line += ` = ${JSON.stringify(value)}`;
273
+ return line;
274
+ }
275
+
276
+ function orderedAxChildren(node, nodesById, childrenByParent) {
277
+ const children = [];
278
+ const seen = new Set();
279
+ for (const childId of node.childIds || []) {
280
+ const child = nodesById.get(childId);
281
+ if (child && !seen.has(child.nodeId)) {
282
+ seen.add(child.nodeId);
283
+ children.push(child);
284
+ }
285
+ }
286
+ for (const child of childrenByParent.get(node.nodeId) || []) {
287
+ if (!seen.has(child.nodeId)) {
288
+ seen.add(child.nodeId);
289
+ children.push(child);
290
+ }
291
+ }
292
+ return children;
293
+ }
294
+
295
+ async function snapshotStr(cdp, sid, compact = false) {
296
+ const { nodes } = await cdp.send("Accessibility.getFullAXTree", {}, sid);
297
+ const nodesById = new Map(nodes.map((node) => [node.nodeId, node]));
298
+ const childrenByParent = new Map();
299
+ for (const node of nodes) {
300
+ if (!node.parentId) continue;
301
+ if (!childrenByParent.has(node.parentId))
302
+ childrenByParent.set(node.parentId, []);
303
+ childrenByParent.get(node.parentId).push(node);
304
+ }
305
+
306
+ const lines = [];
307
+ const visited = new Set();
308
+ function visit(node, depth) {
309
+ if (!node || visited.has(node.nodeId)) return;
310
+ visited.add(node.nodeId);
311
+ if (shouldShowAxNode(node, compact)) lines.push(formatAxNode(node, depth));
312
+ for (const child of orderedAxChildren(node, nodesById, childrenByParent)) {
313
+ visit(child, depth + 1);
314
+ }
315
+ }
316
+
317
+ const roots = nodes.filter(
318
+ (node) => !node.parentId || !nodesById.has(node.parentId),
319
+ );
320
+ for (const root of roots) visit(root, 0);
321
+ for (const node of nodes) visit(node, 0);
322
+
323
+ return lines.join("\n");
324
+ }
325
+
326
+ /**
327
+ * Per-session main execution context cache.
328
+ * Key = sessionId, value = main-world executionContextId.
329
+ *
330
+ * Why: Runtime.enable is the primary CDP detection vector — Cloudflare
331
+ * and DataDome watch for Runtime.consoleAPICalled timing gaps that
332
+ * only appear when the Runtime domain is enabled. Chrome 146 also
333
+ * exposes CDP activity through the navigator.modelContext API.
334
+ *
335
+ * Our fix: enable Runtime BRIEFLY at daemon startup (50-100ms) to
336
+ * capture the main executionContextId, then immediately disable.
337
+ * After that, ALL evals pass the explicit contextId so Runtime.enable
338
+ * is never called again — the detection window is a one-shot ~100ms
339
+ * blip at session open, not a persistent leak for the entire session.
340
+ *
341
+ * See: rebrowser.net — "Runtime.Enable CDP Detection"
342
+ * securityboulevard.com — V8 May 2025 patches
343
+ */
344
+ const _mainCtx = new Map();
345
+
346
+ /**
347
+ * Enable Runtime briefly, capture ALL execution contexts, then disable.
348
+ * Returns the main-world contextId (the one with isDefault + type:default).
349
+ */
350
+ async function captureMainContext(cdp, sid) {
351
+ // Register listener BEFORE Runtime.enable — events may fire synchronously
352
+ // in the same WebSocket frame batch as the enable response.
353
+ const contexts = [];
354
+ const off = cdp.onEvent("Runtime.executionContextCreated", (params) => {
355
+ if (params?.context) contexts.push(params.context);
356
+ });
357
+
358
+ await cdp.send("Runtime.enable", {}, sid);
359
+
360
+ // Short settle — events fire synchronously after Runtime.enable, 100ms
361
+ // covers WebSocket latency for event delivery.
362
+ await sleep(100);
363
+ off();
364
+
365
+ // Always disable Runtime after capturing
366
+ await cdp.send("Runtime.disable", {}, sid).catch(() => {});
367
+
368
+ // Find the main world context
369
+ const main = contexts.find(
370
+ (ctx) => ctx.auxData?.isDefault && ctx.auxData?.type === "default",
371
+ );
372
+ return main?.id ?? null;
373
+ }
374
+
375
+ /**
376
+ * Evaluate a JS expression in the main execution context WITHOUT
377
+ * persistent Runtime.enable. Uses the contextId captured at daemon
378
+ * startup via brief Runtime.enable → Runtime.disable.
379
+ */
380
+ async function evalStr(cdp, sid, expression) {
381
+ // Lazy capture: first eval after daemon start grabs the main context
382
+ let contextId = _mainCtx.get(sid);
383
+ if (contextId == null) {
384
+ contextId = await captureMainContext(cdp, sid);
385
+ if (contextId == null) {
386
+ throw new Error(
387
+ "Failed to capture main execution context is the page loaded?",
388
+ );
389
+ }
390
+ _mainCtx.set(sid, contextId);
391
+ }
392
+
393
+ async function _evalWith(cid) {
394
+ const result = await cdp.send(
395
+ "Runtime.evaluate",
396
+ {
397
+ expression,
398
+ contextId: cid,
399
+ returnByValue: true,
400
+ awaitPromise: true,
401
+ },
402
+ sid,
403
+ );
404
+ if (result.exceptionDetails) {
405
+ throw new Error(
406
+ result.exceptionDetails.text ||
407
+ result.exceptionDetails.exception?.description,
408
+ );
409
+ }
410
+ const val = result.result.value;
411
+ return typeof val === "object"
412
+ ? JSON.stringify(val, null, 2)
413
+ : String(val ?? "");
414
+ }
415
+
416
+ // Fast path: cached context is still valid (the common case)
417
+ try {
418
+ return await _evalWith(contextId);
419
+ } catch (_e) {
420
+ // Context was invalidated (e.g. page navigated to a new origin).
421
+ // Clear stale cache, re-capture, retry once.
422
+ _mainCtx.delete(sid);
423
+ contextId = await captureMainContext(cdp, sid);
424
+ if (contextId == null) {
425
+ throw new Error(
426
+ "Failed to re-capture execution context after navigation",
427
+ );
428
+ }
429
+ _mainCtx.set(sid, contextId);
430
+ return _evalWith(contextId);
431
+ }
432
+ }
433
+
434
+ async function shotStr(cdp, sid, filePath) {
435
+ let dpr = 1;
436
+ try {
437
+ const metrics = await cdp.send("Page.getLayoutMetrics", {}, sid);
438
+ dpr = metrics.visualViewport?.clientWidth
439
+ ? metrics.cssVisualViewport?.clientWidth
440
+ ? Math.round(
441
+ (metrics.visualViewport.clientWidth /
442
+ metrics.cssVisualViewport.clientWidth) *
443
+ 100,
444
+ ) / 100
445
+ : 1
446
+ : 1;
447
+ const { deviceScaleFactor } = await cdp
448
+ .send("Emulation.getDeviceMetricsOverride", {}, sid)
449
+ .catch(() => ({}));
450
+ if (deviceScaleFactor) dpr = deviceScaleFactor;
451
+ } catch {}
452
+ if (dpr === 1) {
453
+ try {
454
+ const raw = await evalStr(cdp, sid, "window.devicePixelRatio");
455
+ const parsed = Number.parseFloat(raw);
456
+ if (parsed > 0) dpr = parsed;
457
+ } catch {}
458
+ }
459
+
460
+ const { data } = await cdp.send(
461
+ "Page.captureScreenshot",
462
+ { format: "png" },
463
+ sid,
464
+ );
465
+ const out = filePath || join(tmpdir(), "screenshot.png");
466
+ writeFileSync(out, Buffer.from(data, "base64"));
467
+
468
+ const lines = [out];
469
+ lines.push(`Screenshot saved. Device pixel ratio (DPR): ${dpr}`);
470
+ lines.push(`Coordinate mapping:`);
471
+ lines.push(
472
+ ` Screenshot pixels → CSS pixels (for CDP Input events): divide by ${dpr}`,
473
+ );
474
+ lines.push(
475
+ ` e.g. screenshot point (${Math.round(100 * dpr)}, ${Math.round(200 * dpr)}) → CSS (100, 200) → use clickxy <target> 100 200`,
476
+ );
477
+ if (dpr !== 1) {
478
+ lines.push(
479
+ ` On this ${dpr}x display: CSS px = screenshot px / ${dpr} ≈ screenshot px × ${Math.round(100 / dpr) / 100}`,
480
+ );
481
+ }
482
+ return lines.join("\n");
483
+ }
484
+
485
+ async function htmlStr(cdp, sid, selector) {
486
+ const expr = selector
487
+ ? `document.querySelector(${JSON.stringify(selector)})?.outerHTML || 'Element not found'`
488
+ : `document.documentElement.outerHTML`;
489
+ return evalStr(cdp, sid, expr);
490
+ }
491
+
492
+ async function waitForDocumentReady(cdp, sid, timeoutMs = NAVIGATION_TIMEOUT) {
493
+ const deadline = Date.now() + timeoutMs;
494
+ let lastState = "";
495
+ let lastError;
496
+ while (Date.now() < deadline) {
497
+ try {
498
+ const state = await evalStr(cdp, sid, "document.readyState");
499
+ lastState = state;
500
+ if (state === "complete") return;
501
+ } catch (e) {
502
+ lastError = e;
503
+ }
504
+ await sleep(200);
505
+ }
506
+
507
+ if (lastState) {
508
+ throw new Error(
509
+ `Timed out waiting for navigation to finish (last readyState: ${lastState})`,
510
+ );
511
+ }
512
+ if (lastError) {
513
+ throw new Error(
514
+ `Timed out waiting for navigation to finish (${lastError.message})`,
515
+ );
516
+ }
517
+ throw new Error("Timed out waiting for navigation to finish");
518
+ }
519
+
520
+ async function navStr(cdp, sid, url) {
521
+ await cdp.send("Page.enable", {}, sid);
522
+ const loadEvent = cdp.waitForEvent("Page.loadEventFired", NAVIGATION_TIMEOUT);
523
+ const result = await cdp.send("Page.navigate", { url }, sid);
524
+ if (result.errorText) {
525
+ loadEvent.cancel();
526
+ throw new Error(result.errorText);
527
+ }
528
+ if (result.loaderId) {
529
+ await loadEvent.promise;
530
+ } else {
531
+ loadEvent.cancel();
532
+ }
533
+ await waitForDocumentReady(cdp, sid, 5000);
534
+ return `Navigated to ${url}`;
535
+ }
536
+
537
+ async function netStr(cdp, sid) {
538
+ const raw = await evalStr(
539
+ cdp,
540
+ sid,
541
+ `JSON.stringify(performance.getEntriesByType('resource').map(e => ({
542
+ name: e.name.substring(0, 120), type: e.initiatorType,
543
+ duration: Math.round(e.duration), size: e.transferSize
544
+ })))`,
545
+ );
546
+ return JSON.parse(raw)
547
+ .map(
548
+ (e) =>
549
+ `${String(e.duration).padStart(5)}ms ${String(e.size || "?").padStart(8)}B ${e.type.padEnd(8)} ${e.name}`,
550
+ )
551
+ .join("\n");
552
+ }
553
+
554
+ async function clickStr(cdp, sid, selector) {
555
+ if (!selector) throw new Error("CSS selector required");
556
+ const expr = `
557
+ (function() {
558
+ const el = document.querySelector(${JSON.stringify(selector)});
559
+ if (!el) return { ok: false, error: 'Element not found: ' + ${JSON.stringify(selector)} };
560
+ el.scrollIntoView({ block: 'center' });
561
+ el.click();
562
+ return { ok: true, tag: el.tagName, text: el.textContent.trim().substring(0, 80) };
563
+ })()
564
+ `;
565
+ const result = await evalStr(cdp, sid, expr);
566
+ const r = JSON.parse(result);
567
+ if (!r.ok) throw new Error(r.error);
568
+ return `Clicked <${r.tag}> "${r.text}"`;
569
+ }
570
+
571
+ async function clickXyStr(cdp, sid, x, y) {
572
+ const cx = Number.parseFloat(x);
573
+ const cy = Number.parseFloat(y);
574
+ if (Number.isNaN(cx) || Number.isNaN(cy))
575
+ throw new Error("x and y must be numbers (CSS pixels)");
576
+ const base = { x: cx, y: cy, button: "left", clickCount: 1, modifiers: 0 };
577
+ await cdp.send(
578
+ "Input.dispatchMouseEvent",
579
+ { ...base, type: "mouseMoved" },
580
+ sid,
581
+ );
582
+ await cdp.send(
583
+ "Input.dispatchMouseEvent",
584
+ { ...base, type: "mousePressed" },
585
+ sid,
586
+ );
587
+ await sleep(50);
588
+ await cdp.send(
589
+ "Input.dispatchMouseEvent",
590
+ { ...base, type: "mouseReleased" },
591
+ sid,
592
+ );
593
+ return `Clicked at CSS (${cx}, ${cy})`;
594
+ }
595
+
596
+ async function typeStr(cdp, sid, text) {
597
+ if (text == null || text === "") throw new Error("text required");
598
+ await cdp.send("Input.insertText", { text }, sid);
599
+ return `Typed ${text.length} characters`;
600
+ }
601
+
602
+ async function loadAllStr(cdp, sid, selector, intervalMs = 1500) {
603
+ if (!selector) throw new Error("CSS selector required");
604
+ intervalMs = Math.min(
605
+ Math.max(Number.parseInt(intervalMs, 10) || 1500, 100),
606
+ 30000,
607
+ );
608
+ let clicks = 0;
609
+ const deadline = Date.now() + 5 * 60 * 1000;
610
+ while (Date.now() < deadline) {
611
+ const exists = await evalStr(
612
+ cdp,
613
+ sid,
614
+ `!!document.querySelector(${JSON.stringify(selector)})`,
615
+ );
616
+ if (exists !== "true") break;
617
+ const clickExpr = `
618
+ (function() {
619
+ const el = document.querySelector(${JSON.stringify(selector)});
620
+ if (!el) return false;
621
+ el.scrollIntoView({ block: 'center' });
622
+ el.click();
623
+ return true;
624
+ })()
625
+ `;
626
+ const clicked = await evalStr(cdp, sid, clickExpr);
627
+ if (clicked !== "true") break;
628
+ clicks++;
629
+ await sleep(intervalMs);
630
+ }
631
+ return `Clicked "${selector}" ${clicks} time(s) until it disappeared`;
632
+ }
633
+
634
+ async function evalRawStr(cdp, sid, method, paramsJson) {
635
+ if (!method) throw new Error('CDP method required (e.g. "DOM.getDocument")');
636
+ let params = {};
637
+ if (paramsJson) {
638
+ try {
639
+ params = JSON.parse(paramsJson);
640
+ } catch {
641
+ throw new Error(`Invalid JSON params: ${paramsJson}`);
642
+ }
643
+ }
644
+ const result = await cdp.send(method, params, sid);
645
+ return JSON.stringify(result, null, 2);
646
+ }
647
+
648
+ // ---------------------------------------------------------------------------
649
+ // Per-tab daemon
650
+ // ---------------------------------------------------------------------------
651
+
652
+ async function runDaemon(targetId) {
653
+ const sp = sockPath(targetId);
654
+
655
+ const cdp = new CDP();
656
+ try {
657
+ await cdp.connect(getWsUrl());
658
+ } catch (e) {
659
+ process.stderr.write(`Daemon: cannot connect to Chrome: ${e.message}\n`);
660
+ process.exit(1);
661
+ }
662
+
663
+ let sessionId;
664
+ try {
665
+ const res = await cdp.send("Target.attachToTarget", {
666
+ targetId,
667
+ flatten: true,
668
+ });
669
+ sessionId = res.sessionId;
670
+ } catch (e) {
671
+ process.stderr.write(`Daemon: attach failed: ${e.message}\n`);
672
+ cdp.close();
673
+ process.exit(1);
674
+ }
675
+
676
+ let alive = true;
677
+ function shutdown() {
678
+ if (!alive) return;
679
+ alive = false;
680
+ server.close();
681
+ try {
682
+ unlinkSync(sp);
683
+ } catch {}
684
+ cdp.close();
685
+ process.exit(0);
686
+ }
687
+
688
+ cdp.onEvent("Target.targetDestroyed", (params) => {
689
+ if (params.targetId === targetId) shutdown();
690
+ });
691
+ cdp.onEvent("Target.detachedFromTarget", (params) => {
692
+ if (params.sessionId === sessionId) shutdown();
693
+ });
694
+ cdp.onClose(() => shutdown());
695
+ process.on("SIGTERM", shutdown);
696
+ process.on("SIGINT", shutdown);
697
+
698
+ let idleTimer = setTimeout(shutdown, IDLE_TIMEOUT);
699
+ function resetIdle() {
700
+ clearTimeout(idleTimer);
701
+ idleTimer = setTimeout(shutdown, IDLE_TIMEOUT);
702
+ }
703
+
704
+ async function handleCommand({ cmd, args }) {
705
+ resetIdle();
706
+ try {
707
+ let result;
708
+ switch (cmd) {
709
+ case "list": {
710
+ const pages = await getPages(cdp);
711
+ result = formatPageList(pages);
712
+ break;
713
+ }
714
+ case "list_raw": {
715
+ const pages = await getPages(cdp);
716
+ result = JSON.stringify(pages);
717
+ break;
718
+ }
719
+ case "snap":
720
+ case "snapshot":
721
+ result = await snapshotStr(cdp, sessionId, true);
722
+ break;
723
+ case "eval":
724
+ result = await evalStr(cdp, sessionId, args[0]);
725
+ break;
726
+ case "shot":
727
+ case "screenshot":
728
+ result = await shotStr(cdp, sessionId, args[0]);
729
+ break;
730
+ case "html":
731
+ result = await htmlStr(cdp, sessionId, args[0]);
732
+ break;
733
+ case "nav":
734
+ case "navigate":
735
+ result = await navStr(cdp, sessionId, args[0]);
736
+ break;
737
+ case "net":
738
+ case "network":
739
+ result = await netStr(cdp, sessionId);
740
+ break;
741
+ case "click":
742
+ result = await clickStr(cdp, sessionId, args[0]);
743
+ break;
744
+ case "clickxy":
745
+ result = await clickXyStr(cdp, sessionId, args[0], args[1]);
746
+ break;
747
+ case "type":
748
+ result = await typeStr(cdp, sessionId, args[0]);
749
+ break;
750
+ case "loadall":
751
+ result = await loadAllStr(
752
+ cdp,
753
+ sessionId,
754
+ args[0],
755
+ args[1] ? Number.parseInt(args[1], 10) : 1500,
756
+ );
757
+ break;
758
+ case "evalraw":
759
+ result = await evalRawStr(cdp, sessionId, args[0], args[1]);
760
+ break;
761
+ case "stop":
762
+ return { ok: true, result: "", stopAfter: true };
763
+ default:
764
+ return { ok: false, error: `Unknown command: ${cmd}` };
765
+ }
766
+ return { ok: true, result: result ?? "" };
767
+ } catch (e) {
768
+ return { ok: false, error: e.message };
769
+ }
770
+ }
771
+
772
+ const server = net.createServer((conn) => {
773
+ let buf = "";
774
+ conn.on("data", (chunk) => {
775
+ buf += chunk.toString();
776
+ const lines = buf.split("\n");
777
+ buf = lines.pop();
778
+ for (const line of lines) {
779
+ if (!line.trim()) continue;
780
+ let req;
781
+ try {
782
+ req = JSON.parse(line);
783
+ } catch {
784
+ conn.write(
785
+ `${JSON.stringify({ ok: false, error: "Invalid JSON request", id: null })}\n`,
786
+ );
787
+ continue;
788
+ }
789
+ handleCommand(req).then((res) => {
790
+ const payload = `${JSON.stringify({ ...res, id: req.id })}\n`;
791
+ if (res.stopAfter) conn.end(payload, shutdown);
792
+ else conn.write(payload);
793
+ });
794
+ }
795
+ });
796
+ });
797
+
798
+ try {
799
+ unlinkSync(sp);
800
+ } catch {}
801
+ server.listen(sp);
802
+ }
803
+
804
+ // ---------------------------------------------------------------------------
805
+ // CLI ↔ daemon communication
806
+ // ---------------------------------------------------------------------------
807
+
808
+ function connectToSocket(sp) {
809
+ return new Promise((resolve, reject) => {
810
+ const conn = net.connect(sp);
811
+ conn.on("connect", () => resolve(conn));
812
+ conn.on("error", reject);
813
+ });
814
+ }
815
+
816
+ async function getOrStartTabDaemon(targetId) {
817
+ const sp = sockPath(targetId);
818
+ try {
819
+ return await connectToSocket(sp);
820
+ } catch {}
821
+ try {
822
+ unlinkSync(sp);
823
+ } catch {}
824
+
825
+ const child = spawn(
826
+ process.execPath,
827
+ [process.argv[1], "_daemon", targetId],
828
+ {
829
+ detached: true,
830
+ stdio: "ignore",
831
+ },
832
+ );
833
+ child.unref();
834
+
835
+ for (let i = 0; i < DAEMON_CONNECT_RETRIES; i++) {
836
+ await sleep(DAEMON_CONNECT_DELAY);
837
+ try {
838
+ return await connectToSocket(sp);
839
+ } catch {}
840
+ }
841
+ throw new Error("Daemon failed to start — did you click Allow in Chrome?");
842
+ }
843
+
844
+ function sendCommand(conn, req) {
845
+ return new Promise((resolve, reject) => {
846
+ let buf = "";
847
+ let settled = false;
848
+
849
+ const cleanup = () => {
850
+ conn.off("data", onData);
851
+ conn.off("error", onError);
852
+ conn.off("end", onEnd);
853
+ conn.off("close", onClose);
854
+ };
855
+
856
+ const onData = (chunk) => {
857
+ buf += chunk.toString();
858
+ const idx = buf.indexOf("\n");
859
+ if (idx === -1) return;
860
+ settled = true;
861
+ cleanup();
862
+ resolve(JSON.parse(buf.slice(0, idx)));
863
+ conn.end();
864
+ };
865
+
866
+ const onError = (error) => {
867
+ if (settled) return;
868
+ settled = true;
869
+ cleanup();
870
+ reject(error);
871
+ };
872
+
873
+ const onEnd = () => {
874
+ if (settled) return;
875
+ settled = true;
876
+ cleanup();
877
+ reject(new Error("Connection closed before response"));
878
+ };
879
+
880
+ const onClose = () => {
881
+ if (settled) return;
882
+ settled = true;
883
+ cleanup();
884
+ reject(new Error("Connection closed before response"));
885
+ };
886
+
887
+ conn.on("data", onData);
888
+ conn.on("error", onError);
889
+ conn.on("end", onEnd);
890
+ conn.on("close", onClose);
891
+ req.id = 1;
892
+ conn.write(`${JSON.stringify(req)}\n`);
893
+ });
894
+ }
895
+
896
+ function findAnyDaemonSocket() {
897
+ return listDaemonSockets()[0]?.socketPath || null;
898
+ }
899
+
900
+ // ---------------------------------------------------------------------------
901
+ // Stop daemons
902
+ // ---------------------------------------------------------------------------
903
+
904
+ async function stopDaemons(targetPrefix) {
905
+ const daemons = listDaemonSockets();
906
+
907
+ if (targetPrefix) {
908
+ const targetId = resolvePrefix(
909
+ targetPrefix,
910
+ daemons.map((d) => d.targetId),
911
+ "daemon",
912
+ );
913
+ const daemon = daemons.find((d) => d.targetId === targetId);
914
+ try {
915
+ const conn = await connectToSocket(daemon.socketPath);
916
+ await sendCommand(conn, { cmd: "stop" });
917
+ } catch {
918
+ try {
919
+ unlinkSync(daemon.socketPath);
920
+ } catch {}
921
+ }
922
+ return;
923
+ }
924
+
925
+ for (const daemon of daemons) {
926
+ try {
927
+ const conn = await connectToSocket(daemon.socketPath);
928
+ await sendCommand(conn, { cmd: "stop" });
929
+ } catch {
930
+ try {
931
+ unlinkSync(daemon.socketPath);
932
+ } catch {}
933
+ }
934
+ }
935
+ }
936
+
937
+ // ---------------------------------------------------------------------------
938
+ // Main
939
+ // ---------------------------------------------------------------------------
940
+
941
+ const USAGE = `cdp - lightweight Chrome DevTools Protocol CLI (no Puppeteer)
942
+
943
+ Usage: cdp <command> [args]
944
+
945
+ list List open pages (shows unique target prefixes)
946
+ snap <target> Accessibility tree snapshot
947
+ eval <target> <expr> Evaluate JS expression
948
+ shot <target> [file] Screenshot; prints coordinate mapping
949
+ html <target> [selector] Get HTML (full page or CSS selector)
950
+ nav <target> <url> Navigate to URL and wait for load completion
951
+ net <target> Network performance entries
952
+ click <target> <selector> Click an element by CSS selector
953
+ clickxy <target> <x> <y> Click at CSS pixel coordinates
954
+ type <target> <text> Type text at current focus
955
+ loadall <target> <selector> [ms] Repeatedly click a "load more" button
956
+ evalraw <target> <method> [json] Send a raw CDP command; returns JSON result
957
+ stop [target] Stop daemon(s)
958
+ `;
959
+
960
+ const NEEDS_TARGET = new Set([
961
+ "snap",
962
+ "snapshot",
963
+ "eval",
964
+ "shot",
965
+ "screenshot",
966
+ "html",
967
+ "nav",
968
+ "navigate",
969
+ "net",
970
+ "network",
971
+ "click",
972
+ "clickxy",
973
+ "type",
974
+ "loadall",
975
+ "evalraw",
976
+ ]);
977
+
978
+ async function main() {
979
+ const [cmd, ...args] = process.argv.slice(2);
980
+
981
+ if (cmd === "_daemon") {
982
+ await runDaemon(args[0]);
983
+ return;
984
+ }
985
+
986
+ if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
987
+ console.log(USAGE);
988
+ process.exit(0);
989
+ }
990
+
991
+ if (cmd === "list" || cmd === "ls") {
992
+ let pages;
993
+ const existingSock = findAnyDaemonSocket();
994
+ if (existingSock) {
995
+ try {
996
+ const conn = await connectToSocket(existingSock);
997
+ const resp = await sendCommand(conn, { cmd: "list_raw" });
998
+ if (resp.ok) pages = JSON.parse(resp.result);
999
+ } catch {}
1000
+ }
1001
+ if (!pages) {
1002
+ const cdp = new CDP();
1003
+ await cdp.connect(getWsUrl());
1004
+ pages = await getPages(cdp);
1005
+ cdp.close();
1006
+ }
1007
+ writeFileSync(PAGES_CACHE, JSON.stringify(pages));
1008
+ console.log(formatPageList(pages));
1009
+ setTimeout(() => process.exit(0), 100);
1010
+ return;
1011
+ }
1012
+
1013
+ if (cmd === "stop") {
1014
+ await stopDaemons(args[0]);
1015
+ return;
1016
+ }
1017
+
1018
+ if (!NEEDS_TARGET.has(cmd)) {
1019
+ console.error(`Unknown command: ${cmd}\n`);
1020
+ console.log(USAGE);
1021
+ process.exit(1);
1022
+ }
1023
+
1024
+ const targetPrefix = args[0];
1025
+ if (!targetPrefix) {
1026
+ console.error('Error: target ID required. Run "cdp list" first.');
1027
+ process.exit(1);
1028
+ }
1029
+
1030
+ let targetId;
1031
+ const daemonTargetIds = listDaemonSockets().map((d) => d.targetId);
1032
+ const daemonMatches = daemonTargetIds.filter((id) =>
1033
+ id.toUpperCase().startsWith(targetPrefix.toUpperCase()),
1034
+ );
1035
+
1036
+ if (daemonMatches.length > 0) {
1037
+ targetId = resolvePrefix(targetPrefix, daemonTargetIds, "daemon");
1038
+ } else {
1039
+ if (!existsSync(PAGES_CACHE)) {
1040
+ console.error('No page list cached. Run "cdp list" first.');
1041
+ process.exit(1);
1042
+ }
1043
+ const pages = JSON.parse(readFileSync(PAGES_CACHE, "utf8"));
1044
+ targetId = resolvePrefix(
1045
+ targetPrefix,
1046
+ pages.map((p) => p.targetId),
1047
+ "target",
1048
+ 'Run "cdp list".',
1049
+ );
1050
+ }
1051
+
1052
+ const conn = await getOrStartTabDaemon(targetId);
1053
+ const cmdArgs = args.slice(1);
1054
+
1055
+ if (cmd === "eval") {
1056
+ const expr = cmdArgs.join(" ");
1057
+ if (!expr) {
1058
+ console.error("Error: expression required");
1059
+ process.exit(1);
1060
+ }
1061
+ cmdArgs[0] = expr;
1062
+ } else if (cmd === "type") {
1063
+ const text = cmdArgs.join(" ");
1064
+ if (!text) {
1065
+ console.error("Error: text required");
1066
+ process.exit(1);
1067
+ }
1068
+ cmdArgs[0] = text;
1069
+ } else if (cmd === "evalraw") {
1070
+ if (!cmdArgs[0]) {
1071
+ console.error("Error: CDP method required");
1072
+ process.exit(1);
1073
+ }
1074
+ if (cmdArgs.length > 2) cmdArgs[1] = cmdArgs.slice(1).join(" ");
1075
+ }
1076
+
1077
+ if ((cmd === "nav" || cmd === "navigate") && !cmdArgs[0]) {
1078
+ console.error("Error: URL required");
1079
+ process.exit(1);
1080
+ }
1081
+
1082
+ const response = await sendCommand(conn, { cmd, args: cmdArgs });
1083
+
1084
+ if (response.ok) {
1085
+ if (response.result) console.log(response.result);
1086
+ } else {
1087
+ console.error("Error:", response.error);
1088
+ process.exitCode = 1;
1089
+ }
1090
+ }
1091
+
1092
+ main().catch((e) => {
1093
+ console.error(e.message);
1094
+ process.exit(1);
1095
+ });