chromux 0.2.0

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/README.md ADDED
@@ -0,0 +1,152 @@
1
+ # chromux
2
+
3
+ tmux for Chrome tabs — zero-dependency parallel Chrome tab controller via raw CDP.
4
+
5
+ ## Why
6
+
7
+ AI agents need to browse the web in parallel using the user's **real Chrome** (with logins preserved, no bot detection). Existing tools either bundle their own Chromium (Playwright/Puppeteer) or can't isolate tabs properly (agent-browser `--cdp --session`).
8
+
9
+ chromux solves this by talking to Chrome's DevTools Protocol directly using only Node.js built-ins — no Playwright, no Puppeteer, no npm dependencies.
10
+
11
+ | | Playwright/Puppeteer | agent-browser `--cdp` | chromux |
12
+ |---|---|---|---|
13
+ | Browser | Bundled Chromium | Real Chrome | Real Chrome |
14
+ | Bot detection | Often caught | Avoided | Avoided |
15
+ | Tab isolation | Yes | **No** (sessions share tab) | **Yes** |
16
+ | Parallel agents | Yes | **Broken** | **Yes** |
17
+ | Dependencies | 100s of MB | playwright-core | **None** |
18
+ | Profile management | No | No | **Yes** |
19
+
20
+ ## Prerequisites
21
+
22
+ - **Node.js >= 22** (for built-in `WebSocket`)
23
+ - **Google Chrome** installed
24
+
25
+ ## Quick Start
26
+
27
+ ```bash
28
+ # Launch Chrome with an isolated profile (auto-finds Chrome, auto-assigns port)
29
+ chromux launch
30
+
31
+ # Open tabs for two agents
32
+ chromux open agent-a https://news.ycombinator.com
33
+ chromux open agent-b https://reddit.com/r/programming
34
+
35
+ # Each operates independently
36
+ chromux snapshot agent-a
37
+ chromux click agent-a @3
38
+ chromux eval agent-b "document.title"
39
+ chromux screenshot agent-a /tmp/hn.png
40
+
41
+ # Clean up
42
+ chromux close agent-a
43
+ chromux close agent-b
44
+ chromux kill default
45
+ ```
46
+
47
+ ## Profile Management
48
+
49
+ Each profile is an isolated Chrome instance with its own user-data-dir, logins, cookies, and extensions.
50
+
51
+ ```bash
52
+ # Launch named profiles
53
+ chromux launch work
54
+ chromux launch personal
55
+
56
+ # See what's running
57
+ chromux ps
58
+ # PROFILE PORT PID STATUS TABS
59
+ # work 9300 12345 running 3
60
+ # personal 9301 12346 running 1
61
+
62
+ # Use a specific profile for tab commands
63
+ chromux --profile work open my-tab https://...
64
+ CHROMUX_PROFILE=personal chromux open other-tab https://...
65
+
66
+ # Default profile is "default" — used when no --profile specified
67
+ chromux open my-tab https://... # → uses "default" profile (auto-launches if needed)
68
+
69
+ # Stop a profile
70
+ chromux kill work
71
+ ```
72
+
73
+ ## Commands
74
+
75
+ ### Profile
76
+
77
+ | Command | Description |
78
+ |---------|-------------|
79
+ | `launch [name]` | Launch Chrome with isolated profile (default: "default") |
80
+ | `launch <name> --port N` | Launch with specific port |
81
+ | `ps` | List running profiles |
82
+ | `kill <name>` | Stop profile (Chrome + daemon) |
83
+
84
+ ### Tab Operations
85
+
86
+ | Command | Description |
87
+ |---------|-------------|
88
+ | `open <session> <url>` | Navigate (auto-creates tab) |
89
+ | `snapshot <session>` | Accessibility tree with `@ref` numbers |
90
+ | `click <session> @<ref>` | Click element by ref |
91
+ | `click <session> "selector"` | Click by CSS selector |
92
+ | `fill <session> @<ref> "text"` | Fill input field |
93
+ | `type <session> "text"` | Keyboard input (Enter, Tab, etc.) |
94
+ | `eval <session> "js"` | Run JavaScript expression |
95
+ | `screenshot <session> [path]` | Take PNG screenshot |
96
+ | `scroll <session> up\|down` | Scroll page |
97
+ | `wait <session> <ms>` | Wait milliseconds |
98
+ | `close <session>` | Close tab |
99
+ | `list` | List active sessions in current profile |
100
+ | `stop` | Stop daemon (keeps Chrome running) |
101
+
102
+ ## Architecture
103
+
104
+ ```
105
+ ~/.chromux/
106
+ config.json Global config (optional)
107
+ profiles/
108
+ default/ Chrome user-data-dir
109
+ .state PID, port, socket path
110
+ work/
111
+ .state
112
+
113
+ Chrome instance A (port 9300, ~/.chromux/profiles/default/)
114
+ ↑ CDP WebSocket per tab
115
+ chromux daemon (Unix socket /tmp/chromux-default.sock)
116
+ ↑ HTTP
117
+ CLI / AI agents
118
+
119
+ Chrome instance B (port 9301, ~/.chromux/profiles/work/)
120
+ ↑ CDP WebSocket per tab
121
+ chromux daemon (Unix socket /tmp/chromux-work.sock)
122
+ ↑ HTTP
123
+ CLI / AI agents
124
+ ```
125
+
126
+ - **No Playwright/Puppeteer** — raw `WebSocket` + `http` from Node.js stdlib
127
+ - **Tab CRUD** via Chrome's `/json/*` HTTP endpoints
128
+ - **Page ops** via CDP WebSocket JSON-RPC
129
+ - **Daemon per profile** keeps WebSocket connections alive across CLI invocations
130
+ - **Auto-launch** — `chromux open` auto-launches default profile if needed
131
+
132
+ ## Configuration
133
+
134
+ Optional `~/.chromux/config.json`:
135
+
136
+ ```json
137
+ {
138
+ "chromePath": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
139
+ "portRangeStart": 9300,
140
+ "portRangeEnd": 9399
141
+ }
142
+ ```
143
+
144
+ ## Environment
145
+
146
+ | Variable | Default | Description |
147
+ |----------|---------|-------------|
148
+ | `CHROMUX_PROFILE` | `default` | Active profile name |
149
+
150
+ ## License
151
+
152
+ MIT
@@ -0,0 +1,501 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * chrome-tabs — Zero-dependency parallel Chrome tab controller via raw CDP.
5
+ *
6
+ * Connects to a real Chrome instance (e.g. chrome-agent) over the Chrome
7
+ * DevTools Protocol. Each "session" is an independent browser tab that can
8
+ * be operated in parallel by different AI agents.
9
+ *
10
+ * Dependencies: NONE — uses only Node.js ≥22 built-ins (http, WebSocket, fs).
11
+ */
12
+
13
+ import http from 'node:http';
14
+ import fs from 'node:fs';
15
+ import { spawn, execSync } from 'node:child_process';
16
+
17
+ const SOCK = '/tmp/chrome-tabs.sock';
18
+ const CDP_URL = process.env.CHROME_CDP || 'http://localhost:9222';
19
+
20
+ // ============================================================
21
+ // CDP Client — thin wrapper over Chrome DevTools Protocol
22
+ // ============================================================
23
+
24
+ class CDPClient {
25
+ #ws;
26
+ #seq = 0;
27
+ #pending = new Map(); // id → { resolve }
28
+ #waiters = []; // [{ method, resolve, timer }]
29
+
30
+ async connect(wsUrl) {
31
+ this.#ws = new WebSocket(wsUrl);
32
+ await new Promise((res, rej) => {
33
+ this.#ws.addEventListener('open', res, { once: true });
34
+ this.#ws.addEventListener('error', rej, { once: true });
35
+ });
36
+ this.#ws.addEventListener('message', (evt) => {
37
+ const msg = JSON.parse(evt.data);
38
+ // command response
39
+ if ('id' in msg) {
40
+ const p = this.#pending.get(msg.id);
41
+ if (p) { this.#pending.delete(msg.id); p.resolve(msg); }
42
+ }
43
+ // domain event
44
+ if ('method' in msg) {
45
+ for (let i = this.#waiters.length - 1; i >= 0; i--) {
46
+ if (this.#waiters[i].method === msg.method) {
47
+ clearTimeout(this.#waiters[i].timer);
48
+ this.#waiters[i].resolve(msg.params);
49
+ this.#waiters.splice(i, 1);
50
+ break;
51
+ }
52
+ }
53
+ }
54
+ });
55
+ }
56
+
57
+ /** Send a CDP command, return result (throws on CDP error). */
58
+ async send(method, params = {}) {
59
+ const id = ++this.#seq;
60
+ const msg = await new Promise((resolve) => {
61
+ this.#pending.set(id, { resolve });
62
+ this.#ws.send(JSON.stringify({ id, method, params }));
63
+ });
64
+ if (msg.error) throw new Error(`CDP ${method}: ${msg.error.message}`);
65
+ return msg.result;
66
+ }
67
+
68
+ /** Wait for a domain event (e.g. Page.loadEventFired). */
69
+ waitForEvent(method, timeout = 30000) {
70
+ return new Promise((resolve, reject) => {
71
+ const timer = setTimeout(() => {
72
+ const i = this.#waiters.findIndex(w => w === entry);
73
+ if (i >= 0) this.#waiters.splice(i, 1);
74
+ reject(new Error(`Timeout waiting for ${method}`));
75
+ }, timeout);
76
+ const entry = { method, resolve, timer };
77
+ this.#waiters.push(entry);
78
+ });
79
+ }
80
+
81
+ close() { this.#ws?.close(); }
82
+ }
83
+
84
+ // ============================================================
85
+ // Chrome HTTP helpers — tab CRUD via /json/* endpoints
86
+ // ============================================================
87
+
88
+ function cdpFetch(path, method = 'GET') {
89
+ const base = new URL(CDP_URL);
90
+ return new Promise((resolve, reject) => {
91
+ const req = http.request({ hostname: base.hostname, port: base.port, path, method }, (res) => {
92
+ let d = '';
93
+ res.on('data', c => d += c);
94
+ res.on('end', () => {
95
+ try { resolve(JSON.parse(d)); }
96
+ catch { resolve(d); }
97
+ });
98
+ });
99
+ req.on('error', reject);
100
+ req.end();
101
+ });
102
+ }
103
+
104
+ async function createTab(url = 'about:blank') {
105
+ return cdpFetch(`/json/new?${url}`, 'PUT');
106
+ }
107
+
108
+ async function closeTab(targetId) {
109
+ return cdpFetch(`/json/close/${targetId}`);
110
+ }
111
+
112
+ async function listTabs() {
113
+ const all = await cdpFetch('/json');
114
+ return all.filter(t => t.type === 'page');
115
+ }
116
+
117
+ // ============================================================
118
+ // Snapshot — accessibility tree with @ref numbers
119
+ // ============================================================
120
+
121
+ const SNAPSHOT_JS = `(() => {
122
+ let refId = 0;
123
+ const ROLES = {
124
+ a:'link', button:'button', input:'textbox', select:'combobox',
125
+ textarea:'textbox', img:'img', nav:'navigation', main:'main',
126
+ header:'banner', footer:'contentinfo', form:'form',
127
+ h1:'heading', h2:'heading', h3:'heading',
128
+ h4:'heading', h5:'heading', h6:'heading',
129
+ ul:'list', ol:'list', li:'listitem',
130
+ table:'table', tr:'row', td:'cell', th:'columnheader',
131
+ dialog:'dialog', section:'region', aside:'complementary',
132
+ };
133
+ const INTERACTIVE = new Set(['a','button','input','select','textarea']);
134
+ function getRole(el) {
135
+ return el.getAttribute('role') || ROLES[el.tagName.toLowerCase()] || null;
136
+ }
137
+ function isInteractive(el) {
138
+ const tag = el.tagName.toLowerCase();
139
+ if (INTERACTIVE.has(tag)) return true;
140
+ const role = el.getAttribute('role');
141
+ if (role === 'button' || role === 'link' || role === 'tab' || role === 'menuitem') return true;
142
+ if (el.getAttribute('tabindex') !== null && el.getAttribute('tabindex') !== '-1') return true;
143
+ return false;
144
+ }
145
+ function getLabel(el) {
146
+ const tag = el.tagName.toLowerCase();
147
+ const aria = el.getAttribute('aria-label');
148
+ if (aria) return aria;
149
+ if (tag === 'input' || tag === 'textarea') return el.value || el.placeholder || '';
150
+ if (tag === 'img') return el.alt || '';
151
+ let text = '';
152
+ for (const n of el.childNodes) { if (n.nodeType === 3) text += n.textContent; }
153
+ return text.trim().substring(0, 100);
154
+ }
155
+ function walk(el, depth) {
156
+ if (!el || el.nodeType !== 1) return '';
157
+ try {
158
+ const s = getComputedStyle(el);
159
+ if (s.display === 'none' || s.visibility === 'hidden' || el.hidden) return '';
160
+ } catch { return ''; }
161
+ if (el.getAttribute('aria-hidden') === 'true') return '';
162
+ const tag = el.tagName.toLowerCase();
163
+ if (['script','style','noscript','br','hr','svg','path'].includes(tag)) return '';
164
+ const role = getRole(el);
165
+ const interactive = isInteractive(el);
166
+ const label = getLabel(el);
167
+ const has = role || interactive || label;
168
+ const cd = has ? depth + 1 : depth;
169
+ let children = '';
170
+ for (const c of el.children) children += walk(c, cd);
171
+ if (!has && !children) return '';
172
+ if (!has) return children;
173
+ const indent = ' '.repeat(depth);
174
+ let line = indent;
175
+ if (interactive) {
176
+ const ref = ++refId;
177
+ el.setAttribute('data-ct-ref', String(ref));
178
+ line += '@' + ref + ' ';
179
+ }
180
+ line += role || tag;
181
+ if (label) line += ' "' + label.replace(/"/g, '\\\\"') + '"';
182
+ if (tag === 'input') line += ' [' + (el.type || 'text') + ']';
183
+ if (tag === 'a' && el.href) {
184
+ const href = el.getAttribute('href');
185
+ if (href && !href.startsWith('javascript:') && !href.startsWith('#'))
186
+ line += ' -> ' + href.substring(0, 80);
187
+ }
188
+ return line + '\\n' + children;
189
+ }
190
+ return '# ' + document.title + '\\n# ' + location.href + '\\n\\n' + walk(document.body, 0);
191
+ })()`;
192
+
193
+ // ============================================================
194
+ // Daemon server
195
+ // ============================================================
196
+
197
+ async function startDaemon() {
198
+ try { fs.unlinkSync(SOCK); } catch {}
199
+
200
+ // Verify Chrome is reachable
201
+ try { await cdpFetch('/json/version'); }
202
+ catch { console.error(`Cannot reach Chrome at ${CDP_URL}`); process.exit(1); }
203
+
204
+ /** @type {Map<string, {targetId: string, cdp: CDPClient}>} */
205
+ const sessions = new Map();
206
+
207
+ const server = http.createServer(async (req, res) => {
208
+ const url = new URL(req.url, 'http://x');
209
+ const body = ['POST', 'PUT'].includes(req.method) ? await readBody(req) : null;
210
+
211
+ try {
212
+ const result = await route(req.method, url.pathname, body, sessions);
213
+ const isText = typeof result === 'string';
214
+ res.writeHead(200, { 'Content-Type': isText ? 'text/plain; charset=utf-8' : 'application/json' });
215
+ res.end(isText ? result : JSON.stringify(result));
216
+ } catch (err) {
217
+ res.writeHead(err.status || 500, { 'Content-Type': 'application/json' });
218
+ res.end(JSON.stringify({ error: err.message }));
219
+ }
220
+ });
221
+
222
+ server.listen(SOCK, () => console.log(`chrome-tabs daemon on ${SOCK}`));
223
+
224
+ const cleanup = () => {
225
+ for (const s of sessions.values()) s.cdp.close();
226
+ try { fs.unlinkSync(SOCK); } catch {}
227
+ };
228
+ process.on('exit', cleanup);
229
+ process.on('SIGTERM', () => process.exit(0));
230
+ process.on('SIGINT', () => process.exit(0));
231
+ }
232
+
233
+ async function route(method, path, body, sessions) {
234
+
235
+ // --- health ---
236
+ if (path === '/health')
237
+ return { ok: true, sessions: sessions.size };
238
+
239
+ // --- list ---
240
+ if (path === '/list') {
241
+ const out = {};
242
+ for (const [id, s] of sessions) {
243
+ try {
244
+ const r = await s.cdp.send('Runtime.evaluate', { expression: 'JSON.stringify({url:location.href,title:document.title})', returnByValue: true });
245
+ out[id] = JSON.parse(r.result.value);
246
+ } catch { out[id] = { url: '(closed)', title: '' }; sessions.delete(id); }
247
+ }
248
+ return out;
249
+ }
250
+
251
+ // --- open ---
252
+ if (path === '/open' && method === 'POST') {
253
+ const { session, url } = body;
254
+ if (!session || !url) throw httpErr(400, 'session and url required');
255
+
256
+ let s = sessions.get(session);
257
+ if (!s) {
258
+ const tab = await createTab('about:blank');
259
+ const cdp = new CDPClient();
260
+ await cdp.connect(tab.webSocketDebuggerUrl);
261
+ await cdp.send('Page.enable');
262
+ await cdp.send('Runtime.enable');
263
+ s = { targetId: tab.id, cdp };
264
+ sessions.set(session, s);
265
+ }
266
+
267
+ const loaded = s.cdp.waitForEvent('Page.loadEventFired', 30000);
268
+ await s.cdp.send('Page.navigate', { url });
269
+ await loaded;
270
+
271
+ const r = await s.cdp.send('Runtime.evaluate', { expression: 'JSON.stringify({url:location.href,title:document.title})', returnByValue: true });
272
+ return { session, ...JSON.parse(r.result.value) };
273
+ }
274
+
275
+ // --- snapshot ---
276
+ if (path.startsWith('/snapshot/')) {
277
+ const session = decodeURIComponent(path.split('/')[2]);
278
+ const s = getSession(sessions, session);
279
+ const r = await s.cdp.send('Runtime.evaluate', { expression: SNAPSHOT_JS, returnByValue: true });
280
+ return r.result.value; // plain text
281
+ }
282
+
283
+ // --- click ---
284
+ if (path === '/click' && method === 'POST') {
285
+ const { session, selector } = body;
286
+ const s = getSession(sessions, session);
287
+ const sel = selector.startsWith('@')
288
+ ? `[data-ct-ref="${selector.slice(1)}"]`
289
+ : selector;
290
+ await s.cdp.send('Runtime.evaluate', {
291
+ expression: `document.querySelector('${sel.replace(/'/g, "\\'")}').click()`,
292
+ returnByValue: true,
293
+ });
294
+ await sleep(500);
295
+ return { clicked: selector };
296
+ }
297
+
298
+ // --- fill ---
299
+ if (path === '/fill' && method === 'POST') {
300
+ const { session, selector, text } = body;
301
+ const s = getSession(sessions, session);
302
+ const sel = selector.startsWith('@')
303
+ ? `[data-ct-ref="${selector.slice(1)}"]`
304
+ : selector;
305
+ const escaped = text.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
306
+ await s.cdp.send('Runtime.evaluate', {
307
+ expression: `(() => { const el = document.querySelector('${sel.replace(/'/g, "\\'")}'); el.focus(); el.value = '${escaped}'; el.dispatchEvent(new Event('input', {bubbles:true})); })()`,
308
+ returnByValue: true,
309
+ });
310
+ return { filled: selector, text };
311
+ }
312
+
313
+ // --- type ---
314
+ if (path === '/type' && method === 'POST') {
315
+ const { session, text } = body;
316
+ const s = getSession(sessions, session);
317
+ const KEY_MAP = { Enter: '\r', Tab: '\t', Escape: '\u001B', Backspace: '\b' };
318
+ if (KEY_MAP[text] || text.length === 1) {
319
+ await s.cdp.send('Input.dispatchKeyEvent', {
320
+ type: 'keyDown', key: text, text: KEY_MAP[text] || text,
321
+ });
322
+ await s.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: text });
323
+ } else {
324
+ for (const ch of text) {
325
+ await s.cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: ch, text: ch });
326
+ await s.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: ch });
327
+ }
328
+ }
329
+ return { typed: text };
330
+ }
331
+
332
+ // --- eval ---
333
+ if (path === '/eval' && method === 'POST') {
334
+ const { session, code } = body;
335
+ const s = getSession(sessions, session);
336
+ const r = await s.cdp.send('Runtime.evaluate', {
337
+ expression: code, returnByValue: true, awaitPromise: true,
338
+ });
339
+ if (r.exceptionDetails) throw httpErr(400, r.exceptionDetails.text || 'eval error');
340
+ return r.result.value;
341
+ }
342
+
343
+ // --- screenshot ---
344
+ if (path === '/screenshot' && method === 'POST') {
345
+ const { session, path: savePath } = body;
346
+ const s = getSession(sessions, session);
347
+ const r = await s.cdp.send('Page.captureScreenshot', { format: 'png' });
348
+ const p = savePath || `/tmp/chrome-tabs-${session}-${Date.now()}.png`;
349
+ fs.writeFileSync(p, Buffer.from(r.data, 'base64'));
350
+ return { path: p };
351
+ }
352
+
353
+ // --- scroll ---
354
+ if (path === '/scroll' && method === 'POST') {
355
+ const { session, direction } = body;
356
+ const s = getSession(sessions, session);
357
+ const delta = direction === 'up' ? -500 : 500;
358
+ await s.cdp.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: 300, y: 300, deltaX: 0, deltaY: delta });
359
+ await sleep(300);
360
+ return { scrolled: direction };
361
+ }
362
+
363
+ // --- wait ---
364
+ if (path === '/wait' && method === 'POST') {
365
+ getSession(sessions, body.session);
366
+ await sleep(body.ms || 1000);
367
+ return { waited: body.ms || 1000 };
368
+ }
369
+
370
+ // --- close session ---
371
+ if (path.startsWith('/session/') && method === 'DELETE') {
372
+ const session = decodeURIComponent(path.split('/')[2]);
373
+ const s = sessions.get(session);
374
+ if (s) {
375
+ s.cdp.close();
376
+ await closeTab(s.targetId).catch(() => {});
377
+ sessions.delete(session);
378
+ }
379
+ return { closed: session };
380
+ }
381
+
382
+ // --- stop ---
383
+ if (path === '/stop') {
384
+ setTimeout(() => process.exit(0), 100);
385
+ return { stopping: true };
386
+ }
387
+
388
+ throw httpErr(404, `Not found: ${method} ${path}`);
389
+ }
390
+
391
+ // ============================================================
392
+ // CLI client
393
+ // ============================================================
394
+
395
+ function cliReq(method, path, body) {
396
+ return new Promise((resolve, reject) => {
397
+ const opts = {
398
+ socketPath: SOCK, path, method,
399
+ headers: body ? { 'Content-Type': 'application/json' } : {},
400
+ };
401
+ const req = http.request(opts, (res) => {
402
+ let d = '';
403
+ res.on('data', c => d += c);
404
+ res.on('end', () => {
405
+ if (res.statusCode >= 400) {
406
+ try { reject(new Error(JSON.parse(d).error)); }
407
+ catch { reject(new Error(d)); }
408
+ return;
409
+ }
410
+ if (res.headers['content-type']?.includes('text/plain')) resolve(d);
411
+ else { try { resolve(JSON.parse(d)); } catch { resolve(d); } }
412
+ });
413
+ });
414
+ req.on('error', reject);
415
+ if (body) req.write(JSON.stringify(body));
416
+ req.end();
417
+ });
418
+ }
419
+
420
+ async function ensureDaemon() {
421
+ try { await cliReq('GET', '/health'); return; } catch {}
422
+ process.stderr.write('Starting chrome-tabs daemon...');
423
+ const child = spawn(process.execPath, [process.argv[1], '--daemon'], {
424
+ detached: true, stdio: 'ignore', env: { ...process.env, CHROME_CDP: CDP_URL },
425
+ });
426
+ child.unref();
427
+ for (let i = 0; i < 50; i++) {
428
+ await sleep(200);
429
+ try { await cliReq('GET', '/health'); process.stderr.write(' ready.\n'); return; } catch {}
430
+ }
431
+ console.error(' failed. Is Chrome running with --remote-debugging-port=9222?');
432
+ process.exit(1);
433
+ }
434
+
435
+ async function runCli(cmd, args) {
436
+ await ensureDaemon();
437
+ const routes = {
438
+ open: () => cliReq('POST', '/open', { session: args[0], url: args[1] }),
439
+ snapshot: () => cliReq('GET', `/snapshot/${args[0]}`),
440
+ click: () => cliReq('POST', '/click', { session: args[0], selector: args[1] }),
441
+ fill: () => cliReq('POST', '/fill', { session: args[0], selector: args[1], text: args[2] }),
442
+ type: () => cliReq('POST', '/type', { session: args[0], text: args[1] }),
443
+ eval: () => cliReq('POST', '/eval', { session: args[0], code: args[1] }),
444
+ screenshot: () => cliReq('POST', '/screenshot', { session: args[0], path: args[1] }),
445
+ scroll: () => cliReq('POST', '/scroll', { session: args[0], direction: args[1] || 'down' }),
446
+ wait: () => cliReq('POST', '/wait', { session: args[0], ms: parseInt(args[1]) || 1000 }),
447
+ close: () => cliReq('DELETE', `/session/${args[0]}`),
448
+ list: () => cliReq('GET', '/list'),
449
+ stop: () => cliReq('POST', '/stop', {}),
450
+ };
451
+ if (!routes[cmd]) { console.error(`Unknown: ${cmd}. Run: chrome-tabs help`); process.exit(1); }
452
+ try {
453
+ const r = await routes[cmd]();
454
+ console.log(typeof r === 'string' ? r : JSON.stringify(r, null, 2));
455
+ } catch (e) { console.error(`Error: ${e.message}`); process.exit(1); }
456
+ }
457
+
458
+ // ============================================================
459
+ // Helpers
460
+ // ============================================================
461
+
462
+ function getSession(sessions, id) {
463
+ const s = sessions.get(id);
464
+ if (!s) throw httpErr(404, `Session "${id}" not found`);
465
+ return s;
466
+ }
467
+ function httpErr(status, message) { const e = new Error(message); e.status = status; return e; }
468
+ function readBody(req) {
469
+ return new Promise(r => { let d = ''; req.on('data', c => d += c); req.on('end', () => { try { r(JSON.parse(d)); } catch { r(d); } }); });
470
+ }
471
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
472
+
473
+ // ============================================================
474
+ // Entry
475
+ // ============================================================
476
+
477
+ const [,, cmd, ...args] = process.argv;
478
+
479
+ if (cmd === '--daemon') {
480
+ await startDaemon();
481
+ } else if (!cmd || cmd === 'help' || cmd === '--help') {
482
+ console.log(`chrome-tabs — Zero-dep parallel Chrome tab controller via raw CDP
483
+
484
+ Usage:
485
+ chrome-tabs open <session> <url> Navigate (auto-creates tab)
486
+ chrome-tabs snapshot <session> Accessibility tree with @ref
487
+ chrome-tabs click <session> @<ref> Click by ref number
488
+ chrome-tabs fill <session> @<ref> "text" Fill input
489
+ chrome-tabs type <session> "text" Keyboard input
490
+ chrome-tabs eval <session> "js" Run JavaScript
491
+ chrome-tabs screenshot <session> [path] Take screenshot
492
+ chrome-tabs scroll <session> up|down Scroll
493
+ chrome-tabs wait <session> <ms> Wait
494
+ chrome-tabs close <session> Close tab
495
+ chrome-tabs list List sessions
496
+ chrome-tabs stop Stop daemon
497
+
498
+ Env: CHROME_CDP (default http://localhost:9222)`);
499
+ } else {
500
+ await runCli(cmd, args);
501
+ }