@bubstack/moe-glass 0.1.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.
Files changed (62) hide show
  1. package/README.md +29 -0
  2. package/agents/browser-user.md +105 -0
  3. package/dist/LICENSE +25 -0
  4. package/dist/index.d.ts +9 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +22517 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/payload.d.ts +214 -0
  9. package/dist/payload.d.ts.map +1 -0
  10. package/dist/payload.js +325 -0
  11. package/dist/payload.js.map +1 -0
  12. package/package.json +59 -0
  13. package/skills/browsing/COMMANDLINE-USAGE.md +595 -0
  14. package/skills/browsing/EXAMPLES.md +717 -0
  15. package/skills/browsing/README.md +55 -0
  16. package/skills/browsing/SKILL.md +478 -0
  17. package/skills/browsing/chrome-ws +1021 -0
  18. package/skills/browsing/chrome-ws-lib.js +461 -0
  19. package/skills/browsing/host-override.js +98 -0
  20. package/skills/browsing/lib/browser-bridge.js +175 -0
  21. package/skills/browsing/lib/browser-session.js +137 -0
  22. package/skills/browsing/lib/capture.js +499 -0
  23. package/skills/browsing/lib/cdp-router.js +72 -0
  24. package/skills/browsing/lib/cdp-utils.js +18 -0
  25. package/skills/browsing/lib/chrome-launcher-helpers.js +374 -0
  26. package/skills/browsing/lib/chrome-process.js +464 -0
  27. package/skills/browsing/lib/console-logging.js +70 -0
  28. package/skills/browsing/lib/cookies.js +17 -0
  29. package/skills/browsing/lib/dialogs-render.js +154 -0
  30. package/skills/browsing/lib/dialogs-router.js +117 -0
  31. package/skills/browsing/lib/dialogs.js +254 -0
  32. package/skills/browsing/lib/element-selector.js +91 -0
  33. package/skills/browsing/lib/evaluation.js +85 -0
  34. package/skills/browsing/lib/extraction.js +55 -0
  35. package/skills/browsing/lib/file-upload.js +56 -0
  36. package/skills/browsing/lib/html-diff.js +122 -0
  37. package/skills/browsing/lib/key-definitions.js +149 -0
  38. package/skills/browsing/lib/keyboard-input.js +288 -0
  39. package/skills/browsing/lib/mouse.js +423 -0
  40. package/skills/browsing/lib/navigation.js +272 -0
  41. package/skills/browsing/lib/page-scripts/dom-summary.js +31 -0
  42. package/skills/browsing/lib/page-scripts/markdown.js +85 -0
  43. package/skills/browsing/lib/page-scripts/permission-shim.js +80 -0
  44. package/skills/browsing/lib/page-session.js +106 -0
  45. package/skills/browsing/lib/profile-lock.js +179 -0
  46. package/skills/browsing/lib/screenshot.js +171 -0
  47. package/skills/browsing/lib/select-option.js +99 -0
  48. package/skills/browsing/lib/session-state.js +66 -0
  49. package/skills/browsing/lib/tabs.js +144 -0
  50. package/skills/browsing/lib/viewport.js +103 -0
  51. package/skills/browsing/lib/websocket-client.js +162 -0
  52. package/skills/browsing/package.json +11 -0
  53. package/skills/browsing/test-chrome-args.js +81 -0
  54. package/skills/browsing/test-cookies.js +21 -0
  55. package/skills/browsing/test-e2e.sh +51 -0
  56. package/skills/browsing/test-extract.sh +17 -0
  57. package/skills/browsing/test-interact.sh +11 -0
  58. package/skills/browsing/test-navigate.sh +9 -0
  59. package/skills/browsing/test-raw.sh +8 -0
  60. package/skills/browsing/test-tabs.sh +15 -0
  61. package/skills/browsing/test-viewport.js +27 -0
  62. package/skills/browsing/test-wait.sh +9 -0
@@ -0,0 +1,1021 @@
1
+ #!/usr/bin/env node
2
+
3
+ const process = require('process');
4
+
5
+ // Parse --port=N flag from anywhere in argv (filter it out of positional args)
6
+ const allArgs = process.argv.slice(2);
7
+ const portArg = allArgs.find(a => a.startsWith('--port='));
8
+ const positionalArgs = allArgs.filter(a => !a.startsWith('--port='));
9
+ const [command, wsUrlOrIndex, ...args] = positionalArgs;
10
+
11
+ // Handle --help and --version before any other processing
12
+ if (command === '--help' || command === '-h' || !command) {
13
+ console.log(`Usage: chrome-ws <command> [args]
14
+
15
+ Commands:
16
+ start [port] Start Chrome with remote debugging
17
+ stop Kill Chrome
18
+ pid Print Chrome PID
19
+ info Print Chrome info (JSON)
20
+ tabs List open tabs
21
+ new <url> Open a new tab
22
+ close <tab> Close a tab
23
+ navigate <tab> <url> Navigate tab to URL
24
+ extract <tab> <selector> Extract element text content
25
+ attr <tab> <selector> <attribute> Get element attribute
26
+ html <tab> [selector] Get HTML content
27
+ click <tab> <selector> Click an element
28
+ fill <tab> <selector> <text> Fill an input field
29
+ select <tab> <selector> <value> Select a dropdown option
30
+ eval <tab> <js> Evaluate JavaScript
31
+ wait-for <tab> <selector> [timeout-ms] Wait for element to appear
32
+ wait-text <tab> <text> [timeout-ms] Wait for text to appear
33
+ screenshot <tab> <filename.png> [--fullpage] Take a screenshot
34
+ markdown <tab> <filename.md> Save page as markdown
35
+ har <tab> <filename.har> Export HAR (after har-start)
36
+ raw <ws-url> <json-rpc-payload> Send raw CDP command
37
+
38
+ --help, -h Show this help
39
+ --version, -v Show version
40
+ --port=N Override CHROME_WS_PORT env var
41
+
42
+ Tab arg: numeric index (0, 1, 2...) or full ws:// URL.
43
+ `);
44
+ process.exit(0);
45
+ }
46
+ if (command === '--version' || command === '-v') {
47
+ const pkg = require('../../package.json');
48
+ console.log(pkg.version);
49
+ process.exit(0);
50
+ }
51
+
52
+ const hostOverride = require('./host-override').createOverride();
53
+ const { createSession } = require('./chrome-ws-lib');
54
+ const { buildChromeArgs } = require('./lib/chrome-launcher-helpers');
55
+ const CHROME_DEBUG_HOST = hostOverride.getHost();
56
+ const CHROME_DEBUG_PORT = hostOverride.getPort();
57
+ const WS_OVERRIDE_ENABLED = hostOverride.isOverrideEnabled();
58
+ const rewriteWsUrl = hostOverride.rewriteWsUrl;
59
+
60
+ // Effective port: --port=N flag overrides CHROME_WS_PORT env / default 9222
61
+ const effectivePort = portArg ? parseInt(portArg.split('=')[1], 10) : CHROME_DEBUG_PORT;
62
+
63
+ // Session pointed at the effective port. Built after effectivePort is known
64
+ // so the lib's pooled connections target the right Chrome instance.
65
+ const session = createSession({ host: CHROME_DEBUG_HOST, port: effectivePort });
66
+
67
+ // Minimal WebSocket client implementation (dependency-free)
68
+ class WebSocketClient {
69
+ constructor(url) {
70
+ this.url = new URL(url);
71
+ this.callbacks = {};
72
+ this.socket = null;
73
+ this.buffer = Buffer.alloc(0);
74
+ }
75
+
76
+ on(event, callback) {
77
+ this.callbacks[event] = callback;
78
+ }
79
+
80
+ connect() {
81
+ return new Promise((resolve, reject) => {
82
+ const http = require('http');
83
+ const crypto = require('crypto');
84
+
85
+ const key = crypto.randomBytes(16).toString('base64');
86
+
87
+ const options = {
88
+ hostname: this.url.hostname,
89
+ port: this.url.port || 80,
90
+ path: this.url.pathname + this.url.search,
91
+ headers: {
92
+ 'Upgrade': 'websocket',
93
+ 'Connection': 'Upgrade',
94
+ 'Sec-WebSocket-Key': key,
95
+ 'Sec-WebSocket-Version': '13'
96
+ }
97
+ };
98
+
99
+ const req = http.request(options);
100
+
101
+ req.on('upgrade', (res, socket) => {
102
+ this.socket = socket;
103
+
104
+ socket.on('data', (data) => {
105
+ this.buffer = Buffer.concat([this.buffer, data]);
106
+ this.processFrames();
107
+ });
108
+
109
+ socket.on('error', (err) => {
110
+ if (this.callbacks.error) this.callbacks.error(err);
111
+ });
112
+
113
+ if (this.callbacks.open) this.callbacks.open();
114
+ resolve();
115
+ });
116
+
117
+ req.on('error', reject);
118
+ req.end();
119
+ });
120
+ }
121
+
122
+ processFrames() {
123
+ while (this.buffer.length >= 2) {
124
+ const firstByte = this.buffer[0];
125
+ const secondByte = this.buffer[1];
126
+
127
+ const fin = (firstByte & 0x80) !== 0;
128
+ const opcode = firstByte & 0x0F;
129
+ const masked = (secondByte & 0x80) !== 0;
130
+ let payloadLen = secondByte & 0x7F;
131
+
132
+ let offset = 2;
133
+
134
+ if (payloadLen === 126) {
135
+ if (this.buffer.length < 4) return;
136
+ payloadLen = this.buffer.readUInt16BE(2);
137
+ offset = 4;
138
+ } else if (payloadLen === 127) {
139
+ if (this.buffer.length < 10) return;
140
+ payloadLen = Number(this.buffer.readBigUInt64BE(2));
141
+ offset = 10;
142
+ }
143
+
144
+ if (this.buffer.length < offset + payloadLen) return;
145
+
146
+ let payload = this.buffer.slice(offset, offset + payloadLen);
147
+ this.buffer = this.buffer.slice(offset + payloadLen);
148
+
149
+ if (opcode === 0x1 && this.callbacks.message) {
150
+ this.callbacks.message(payload.toString('utf8'));
151
+ }
152
+ }
153
+ }
154
+
155
+ send(data) {
156
+ const payload = Buffer.from(data, 'utf8');
157
+ const payloadLen = payload.length;
158
+
159
+ let frame;
160
+ let offset = 2;
161
+
162
+ if (payloadLen < 126) {
163
+ frame = Buffer.alloc(payloadLen + 6);
164
+ frame[1] = payloadLen | 0x80;
165
+ } else if (payloadLen < 65536) {
166
+ frame = Buffer.alloc(payloadLen + 8);
167
+ frame[1] = 126 | 0x80;
168
+ frame.writeUInt16BE(payloadLen, 2);
169
+ offset = 4;
170
+ } else {
171
+ frame = Buffer.alloc(payloadLen + 14);
172
+ frame[1] = 127 | 0x80;
173
+ frame.writeBigUInt64BE(BigInt(payloadLen), 2);
174
+ offset = 10;
175
+ }
176
+
177
+ frame[0] = 0x81; // FIN + text frame
178
+
179
+ const mask = Buffer.alloc(4);
180
+ require('crypto').randomFillSync(mask);
181
+ mask.copy(frame, offset);
182
+ offset += 4;
183
+
184
+ for (let i = 0; i < payloadLen; i++) {
185
+ frame[offset + i] = payload[i] ^ mask[i % 4];
186
+ }
187
+
188
+ this.socket.write(frame);
189
+ }
190
+
191
+ close() {
192
+ if (this.socket) {
193
+ this.socket.end();
194
+ this.socket = null;
195
+ }
196
+ }
197
+ }
198
+
199
+ // Helper to convert string tab specifier to the type expected by session methods.
200
+ // session.fill/evaluate/etc. use getPageSession which accepts a number (index) or
201
+ // a ws:// string — but NOT a numeric string like "0".
202
+ function resolveTabArg(wsUrlOrIndex) {
203
+ if (wsUrlOrIndex && wsUrlOrIndex.startsWith('ws://')) {
204
+ return wsUrlOrIndex; // Already a ws URL string
205
+ }
206
+ const index = parseInt(wsUrlOrIndex, 10);
207
+ if (!isNaN(index)) {
208
+ return index; // Numeric tab index as a number
209
+ }
210
+ throw new Error(`Invalid tab specifier: ${wsUrlOrIndex}`);
211
+ }
212
+
213
+ // Helper to resolve tab index or ws URL to actual ws URL
214
+ async function resolveWsUrl(wsUrlOrIndex) {
215
+ // If it's already a WebSocket URL, return it
216
+ if (wsUrlOrIndex && wsUrlOrIndex.startsWith('ws://')) {
217
+ return wsUrlOrIndex;
218
+ }
219
+
220
+ // If it's a number (tab index), resolve it
221
+ const index = parseInt(wsUrlOrIndex);
222
+ if (!isNaN(index)) {
223
+ const tabs = await chromeHttp('/json');
224
+ const pageTabs = Array.isArray(tabs)
225
+ ? tabs
226
+ .filter(t => t.type === 'page')
227
+ .map(tab => WS_OVERRIDE_ENABLED
228
+ ? { ...tab, webSocketDebuggerUrl: rewriteWsUrl(tab.webSocketDebuggerUrl) }
229
+ : tab
230
+ )
231
+ : [];
232
+
233
+ // Auto-create tab if none exist (similar to auto-start Chrome behavior)
234
+ if (pageTabs.length === 0) {
235
+ const newTabInfo = await chromeHttp('/json/new?about:blank', 'PUT');
236
+ return WS_OVERRIDE_ENABLED ? rewriteWsUrl(newTabInfo.webSocketDebuggerUrl) : newTabInfo.webSocketDebuggerUrl;
237
+ }
238
+
239
+ if (index < 0 || index >= pageTabs.length) {
240
+ throw new Error(`Tab index ${index} out of range (0-${pageTabs.length - 1})`);
241
+ }
242
+ return WS_OVERRIDE_ENABLED ? rewriteWsUrl(pageTabs[index].webSocketDebuggerUrl) : pageTabs[index].webSocketDebuggerUrl;
243
+ }
244
+
245
+ throw new Error(`Invalid tab specifier: ${wsUrlOrIndex}`);
246
+ }
247
+
248
+ // Helper to make HTTP requests to Chrome on the effective port
249
+ async function chromeHttp(path, method = 'GET') {
250
+ const http = require('http');
251
+
252
+ return new Promise((resolve, reject) => {
253
+ const options = {
254
+ hostname: CHROME_DEBUG_HOST,
255
+ port: effectivePort,
256
+ path,
257
+ method: method
258
+ };
259
+
260
+ const req = http.request(options, (res) => {
261
+ let data = '';
262
+ res.on('data', chunk => data += chunk);
263
+ res.on('end', () => {
264
+ if (!data) {
265
+ resolve({});
266
+ return;
267
+ }
268
+ try {
269
+ resolve(JSON.parse(data));
270
+ } catch (e) {
271
+ // Some endpoints return plain text (e.g., "Target is closing")
272
+ resolve({ message: data });
273
+ }
274
+ });
275
+ });
276
+
277
+ req.on('error', reject);
278
+ req.end();
279
+ });
280
+ }
281
+
282
+ // Command: start - launch Chrome with remote debugging
283
+ if (command === 'start') {
284
+ const { spawn } = require('child_process');
285
+ const { existsSync } = require('fs');
286
+ const os = require('os');
287
+
288
+ // Platform-specific Chrome paths
289
+ const chromePaths = {
290
+ darwin: [
291
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
292
+ '/Applications/Chromium.app/Contents/MacOS/Chromium'
293
+ ],
294
+ linux: [
295
+ '/usr/bin/google-chrome',
296
+ '/usr/bin/google-chrome-stable',
297
+ '/usr/bin/chromium',
298
+ '/usr/bin/chromium-browser'
299
+ ],
300
+ win32: [
301
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
302
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
303
+ 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
304
+ 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe'
305
+ ]
306
+ };
307
+
308
+ const platform = os.platform();
309
+ const paths = chromePaths[platform];
310
+
311
+ if (!paths) {
312
+ console.error(`Unsupported platform: ${platform}`);
313
+ process.exit(1);
314
+ }
315
+
316
+ // Find Chrome executable (CHROME_WS_BROWSER env var overrides auto-detection)
317
+ const chromePath = process.env.CHROME_WS_BROWSER || paths.find(p => existsSync(p));
318
+
319
+ if (!chromePath) {
320
+ console.error('Chrome not found. Searched:');
321
+ paths.forEach(p => console.error(` ${p}`));
322
+ process.exit(1);
323
+ }
324
+
325
+ // Launch Chrome
326
+ const userDataDir = platform === 'win32'
327
+ ? 'C:\\temp\\chrome-debug'
328
+ : '/tmp/chrome-debug';
329
+
330
+ const chromeArgs = buildChromeArgs({
331
+ chosenPort: effectivePort,
332
+ chromeUserDataDir: userDataDir,
333
+ chromeHeadless: false,
334
+ });
335
+
336
+ console.log(`Starting Chrome: ${chromePath}`);
337
+
338
+ // Capture Chrome's stderr to a tempfile (issue #35): with stdio 'ignore',
339
+ // Chrome's own complaint (sandbox, missing libs, profile lock) vanished and
340
+ // every launch failure looked like an ambiguous port timeout.
341
+ const { openSync, closeSync, readFileSync: readFile, unlinkSync } = require('fs');
342
+ const pathMod = require('path');
343
+ const stderrLogPath = pathMod.join(os.tmpdir(), `chrome-ws-start-${process.pid}.log`);
344
+ const stderrFd = openSync(stderrLogPath, 'w');
345
+
346
+ const chrome = spawn(chromePath, chromeArgs, {
347
+ detached: true,
348
+ stdio: ['ignore', 'ignore', stderrFd]
349
+ });
350
+
351
+ // Race Chrome's exit against the port poll so "Chrome died at launch" is
352
+ // reported as that, not as "debug port not accessible".
353
+ let exited = null;
354
+ chrome.on('exit', (code, signal) => { exited = { code, signal }; });
355
+ chrome.on('error', (err) => {
356
+ console.error(`Failed to launch Chrome (${chromePath}): ${err.message}`);
357
+ process.exit(1);
358
+ });
359
+ chrome.unref();
360
+ closeSync(stderrFd); // the child keeps its own descriptor
361
+
362
+ const stderrTail = () => {
363
+ try {
364
+ const lines = readFile(stderrLogPath, 'utf8').trim().split('\n');
365
+ return lines.slice(-20).join('\n').trim();
366
+ } catch (_e) {
367
+ return '';
368
+ }
369
+ };
370
+
371
+ const debugBase = `http://${CHROME_DEBUG_HOST}:${effectivePort}`;
372
+
373
+ // Wait and verify
374
+ setTimeout(async () => {
375
+ try {
376
+ const version = await chromeHttp('/json/version');
377
+ console.log(`Chrome started: ${version.Browser}`);
378
+ console.log(`Remote debugging: ${debugBase}`);
379
+ try { unlinkSync(stderrLogPath); } catch (_e) { /* best-effort */ }
380
+ } catch (e) {
381
+ if (exited) {
382
+ const how = exited.signal ? `signal ${exited.signal}` : `code ${exited.code}`;
383
+ console.error(`Chrome exited with ${how} before opening the debug port`);
384
+ } else {
385
+ console.error(`Chrome is running but the debug port at ${debugBase} is not responding`);
386
+ console.error(`Try: curl ${debugBase}/json/version`);
387
+ }
388
+ const tail = stderrTail();
389
+ if (tail) {
390
+ console.error(`Chrome stderr (full log: ${stderrLogPath}):`);
391
+ console.error(tail);
392
+ }
393
+ process.exit(1);
394
+ }
395
+ }, 2000);
396
+
397
+ return;
398
+ }
399
+
400
+ // Command: stop - kill the Chrome process this session manages
401
+ if (command === 'stop') {
402
+ (async () => {
403
+ try {
404
+ await session.killChrome();
405
+ console.log('Chrome stopped');
406
+ } catch (e) {
407
+ console.error('Failed to stop Chrome:', e.message);
408
+ process.exit(1);
409
+ }
410
+ })();
411
+ return;
412
+ }
413
+
414
+ // Command: pid - print Chrome PID (for X11 window management, etc.)
415
+ if (command === 'pid') {
416
+ const pid = session.getChromePid();
417
+ if (pid === null) {
418
+ console.error('Chrome is not running (started via MCP). PID is only available when Chrome was started in this process.');
419
+ process.exit(1);
420
+ }
421
+ console.log(pid);
422
+ return;
423
+ }
424
+
425
+ // Command: info - print Chrome info as JSON (pid, mode, profile, port)
426
+ if (command === 'info') {
427
+ (async () => {
428
+ try {
429
+ const mode = await session.getBrowserMode();
430
+ // Also try to get PID from meta.json if available
431
+ const meta = session.readProfileMeta ? session.readProfileMeta(mode.profile) : null;
432
+ const info = {
433
+ pid: meta ? meta.pid : mode.pid,
434
+ port: meta ? meta.port : mode.port,
435
+ mode: meta ? (meta.headless ? 'headless' : 'headed') : mode.mode,
436
+ profile: mode.profile,
437
+ profileDir: mode.profileDir,
438
+ running: meta !== null
439
+ };
440
+ console.log(JSON.stringify(info, null, 2));
441
+ } catch (e) {
442
+ console.error('Failed to get Chrome info:', e.message);
443
+ process.exit(1);
444
+ }
445
+ })();
446
+ return;
447
+ }
448
+
449
+ // Command: tabs - list all tabs
450
+ if (command === 'tabs') {
451
+ (async () => {
452
+ try {
453
+ const tabs = await chromeHttp('/json');
454
+ tabs.forEach(tab => {
455
+ if (tab.type === 'page') {
456
+ console.log(`${tab.id}\t${tab.url}\t${tab.title}`);
457
+ }
458
+ });
459
+ } catch (e) {
460
+ console.error('Failed to list tabs:', e.message);
461
+ process.exit(1);
462
+ }
463
+ })();
464
+ return;
465
+ }
466
+
467
+ // Command: new - create new tab
468
+ if (command === 'new') {
469
+ // For this command, wsUrlOrIndex variable contains the URL parameter
470
+ if (!wsUrlOrIndex) {
471
+ console.error('Usage: chrome-ws new <url>');
472
+ process.exit(1);
473
+ }
474
+ const url = wsUrlOrIndex;
475
+ (async () => {
476
+ try {
477
+ const encoded = encodeURIComponent(url);
478
+ const tab = await chromeHttp(`/json/new?${encoded}`, 'PUT');
479
+ const wsUrl = WS_OVERRIDE_ENABLED ? rewriteWsUrl(tab.webSocketDebuggerUrl) : tab.webSocketDebuggerUrl;
480
+ console.log(wsUrl);
481
+ } catch (e) {
482
+ console.error('Failed to create tab:', e.message);
483
+ process.exit(1);
484
+ }
485
+ })();
486
+ return;
487
+ }
488
+
489
+ // Command: close - close tab by ws URL or numeric index
490
+ if (command === 'close') {
491
+ if (!wsUrlOrIndex) {
492
+ console.error('Usage: chrome-ws close <tab>');
493
+ process.exit(1);
494
+ }
495
+ (async () => {
496
+ try {
497
+ const tabWsUrl = await resolveWsUrl(wsUrlOrIndex);
498
+ // Extract tab ID from ws URL
499
+ const match = tabWsUrl.match(/\/devtools\/page\/([A-F0-9-]+)/i);
500
+ if (!match) {
501
+ console.error('Invalid WebSocket URL');
502
+ process.exit(1);
503
+ }
504
+ await chromeHttp(`/json/close/${match[1]}`);
505
+ console.log('Tab closed');
506
+ } catch (e) {
507
+ console.error('Failed to close tab:', e.message);
508
+ process.exit(1);
509
+ }
510
+ })();
511
+ return;
512
+ }
513
+
514
+ // Helper to send CDP command via WebSocket
515
+ async function sendCdpCommand(wsUrl, method, params = {}) {
516
+ return new Promise(async (resolve, reject) => {
517
+ const ws = new WebSocketClient(wsUrl);
518
+ const id = Math.floor(Math.random() * 1000000);
519
+
520
+ const timeout = setTimeout(() => {
521
+ ws.close();
522
+ reject(new Error('Timeout after 30s'));
523
+ }, 30000);
524
+
525
+ ws.on('message', (data) => {
526
+ const response = JSON.parse(data);
527
+ if (response.id === id) {
528
+ clearTimeout(timeout);
529
+ if (response.error) {
530
+ ws.close();
531
+ reject(new Error(response.error.message));
532
+ } else {
533
+ ws.close();
534
+ resolve(response.result);
535
+ }
536
+ }
537
+ });
538
+
539
+ ws.on('error', (err) => {
540
+ clearTimeout(timeout);
541
+ reject(err);
542
+ });
543
+
544
+ try {
545
+ await ws.connect();
546
+ ws.send(JSON.stringify({ id, method, params }));
547
+ } catch (err) {
548
+ clearTimeout(timeout);
549
+ reject(err);
550
+ }
551
+ });
552
+ }
553
+
554
+ // Command: navigate
555
+ if (command === 'navigate') {
556
+ const [url] = args;
557
+ if (!wsUrlOrIndex || !url) {
558
+ console.error('Usage: chrome-ws navigate <tab-index-or-ws-url> <url>');
559
+ process.exit(1);
560
+ }
561
+ (async () => {
562
+ try {
563
+ const wsUrl = await resolveWsUrl(wsUrlOrIndex);
564
+ await sendCdpCommand(wsUrl, 'Page.navigate', { url });
565
+ console.log(`Navigated to ${url}`);
566
+ } catch (e) {
567
+ console.error('Navigation failed:', e.message);
568
+ process.exit(1);
569
+ }
570
+ })();
571
+ return;
572
+ }
573
+
574
+ // Command: wait-for - wait for selector to appear
575
+ if (command === 'wait-for') {
576
+ const [selector, timeoutArg] = args;
577
+ if (!wsUrlOrIndex || !selector) {
578
+ console.error('Usage: chrome-ws wait-for <tab-index-or-ws-url> <selector> [timeout-ms]');
579
+ process.exit(1);
580
+ }
581
+ const timeout = timeoutArg ? parseInt(timeoutArg, 10) : 5000;
582
+ if (Number.isNaN(timeout) || timeout < 0) {
583
+ console.error(`Invalid timeout: ${timeoutArg}`);
584
+ process.exit(1);
585
+ }
586
+ (async () => {
587
+ try {
588
+ await session.waitForElement(resolveTabArg(wsUrlOrIndex), selector, timeout);
589
+ console.log(`Element found: ${selector}`);
590
+ process.exit(0);
591
+ } catch (e) {
592
+ console.error('Wait failed:', e.message);
593
+ process.exit(1);
594
+ }
595
+ })();
596
+ return;
597
+ }
598
+
599
+ // Command: click
600
+ if (command === 'click') {
601
+ const [selector] = args;
602
+ if (!wsUrlOrIndex || !selector) {
603
+ console.error('Usage: chrome-ws click <tab-index-or-ws-url> <selector>');
604
+ process.exit(1);
605
+ }
606
+ (async () => {
607
+ try {
608
+ const wsUrl = await resolveWsUrl(wsUrlOrIndex);
609
+ const js = `document.querySelector(${JSON.stringify(selector)}).click()`;
610
+ await sendCdpCommand(wsUrl, 'Runtime.evaluate', { expression: js });
611
+ console.log(`Clicked: ${selector}`);
612
+ } catch (e) {
613
+ console.error('Click failed:', e.message);
614
+ process.exit(1);
615
+ }
616
+ })();
617
+ return;
618
+ }
619
+
620
+ // Command: fill
621
+ if (command === 'fill') {
622
+ const [selector, value] = args;
623
+ if (!wsUrlOrIndex || !selector || value === undefined) {
624
+ console.error('Usage: chrome-ws fill <tab-index-or-ws-url> <selector> <value>');
625
+ process.exit(1);
626
+ }
627
+ (async () => {
628
+ try {
629
+ await session.fill(resolveTabArg(wsUrlOrIndex), selector, value);
630
+ console.log(`Filled: ${selector}`);
631
+ process.exit(0);
632
+ } catch (e) {
633
+ console.error('Fill failed:', e.message);
634
+ process.exit(1);
635
+ }
636
+ })();
637
+ return;
638
+ }
639
+
640
+ // Command: select - select dropdown option
641
+ if (command === 'select') {
642
+ const [selector, value] = args;
643
+ if (!wsUrlOrIndex || !selector || value === undefined) {
644
+ console.error('Usage: chrome-ws select <tab-index-or-ws-url> <selector> <value-or-label-or-json-array>');
645
+ process.exit(1);
646
+ }
647
+ (async () => {
648
+ try {
649
+ // Accept JSON array (multi-select) or plain string (value or label).
650
+ let selectValue = value;
651
+ if (typeof value === 'string' && value.trim().startsWith('[')) {
652
+ try {
653
+ const parsed = JSON.parse(value);
654
+ if (Array.isArray(parsed) && parsed.every(v => typeof v === 'string')) {
655
+ selectValue = parsed;
656
+ }
657
+ } catch (_e) { /* not JSON, treat as plain string */ }
658
+ }
659
+ const result = await session.selectOption(resolveTabArg(wsUrlOrIndex), selector, selectValue);
660
+ console.log(JSON.stringify(result.matched.map(o => o.value)));
661
+ process.exit(0);
662
+ } catch (e) {
663
+ console.error('Select failed:', e.message);
664
+ process.exit(1);
665
+ }
666
+ })();
667
+ return;
668
+ }
669
+
670
+ // Command: eval - evaluate JavaScript
671
+ if (command === 'eval') {
672
+ const expression = args.join(' ');
673
+ if (!wsUrlOrIndex || !expression) {
674
+ console.error('Usage: chrome-ws eval <tab-index-or-ws-url> <js-expression>');
675
+ process.exit(1);
676
+ }
677
+ (async () => {
678
+ try {
679
+ const value = await session.evaluate(resolveTabArg(wsUrlOrIndex), expression);
680
+ console.log(JSON.stringify(value, null, 2));
681
+ process.exit(0);
682
+ } catch (e) {
683
+ console.error('Eval failed:', e.message);
684
+ process.exit(1);
685
+ }
686
+ })();
687
+ return;
688
+ }
689
+
690
+ // Command: extract - get element text content
691
+ if (command === 'extract') {
692
+ const [selector] = args;
693
+ if (!wsUrlOrIndex || !selector) {
694
+ console.error('Usage: chrome-ws extract <tab-index-or-ws-url> <selector>');
695
+ process.exit(1);
696
+ }
697
+ (async () => {
698
+ try {
699
+ const wsUrl = await resolveWsUrl(wsUrlOrIndex);
700
+ const js = `document.querySelector(${JSON.stringify(selector)})?.textContent`;
701
+ const result = await sendCdpCommand(wsUrl, 'Runtime.evaluate', {
702
+ expression: js,
703
+ returnByValue: true
704
+ });
705
+ console.log(result.result.value);
706
+ } catch (e) {
707
+ console.error('Extract failed:', e.message);
708
+ process.exit(1);
709
+ }
710
+ })();
711
+ return;
712
+ }
713
+
714
+ // Command: attr - get element attribute
715
+ if (command === 'attr') {
716
+ const [selector, attrName] = args;
717
+ if (!wsUrlOrIndex || !selector || !attrName) {
718
+ console.error('Usage: chrome-ws attr <tab-index-or-ws-url> <selector> <attribute>');
719
+ process.exit(1);
720
+ }
721
+ (async () => {
722
+ try {
723
+ const wsUrl = await resolveWsUrl(wsUrlOrIndex);
724
+ const js = `document.querySelector(${JSON.stringify(selector)})?.getAttribute(${JSON.stringify(attrName)})`;
725
+ const result = await sendCdpCommand(wsUrl, 'Runtime.evaluate', {
726
+ expression: js,
727
+ returnByValue: true
728
+ });
729
+ console.log(result.result.value);
730
+ } catch (e) {
731
+ console.error('Attr failed:', e.message);
732
+ process.exit(1);
733
+ }
734
+ })();
735
+ return;
736
+ }
737
+
738
+ // Command: html - get HTML content
739
+ if (command === 'html') {
740
+ const [selector] = args;
741
+ if (!wsUrlOrIndex) {
742
+ console.error('Usage: chrome-ws html <tab-index-or-ws-url> [selector]');
743
+ process.exit(1);
744
+ }
745
+ (async () => {
746
+ try {
747
+ const wsUrl = await resolveWsUrl(wsUrlOrIndex);
748
+ const js = selector
749
+ ? `document.querySelector(${JSON.stringify(selector)})?.innerHTML`
750
+ : 'document.documentElement.outerHTML';
751
+ const result = await sendCdpCommand(wsUrl, 'Runtime.evaluate', {
752
+ expression: js,
753
+ returnByValue: true
754
+ });
755
+ console.log(result.result.value);
756
+ } catch (e) {
757
+ console.error('HTML failed:', e.message);
758
+ process.exit(1);
759
+ }
760
+ })();
761
+ return;
762
+ }
763
+
764
+ // Command: wait-text - wait for text to appear
765
+ if (command === 'wait-text') {
766
+ // Last positional arg is treated as timeout if it parses as a non-negative
767
+ // integer; otherwise everything is text. This handles both:
768
+ // wait-text 0 "the text" 3000
769
+ // wait-text 0 "text without timeout"
770
+ if (!wsUrlOrIndex || args.length === 0) {
771
+ console.error('Usage: chrome-ws wait-text <tab-index-or-ws-url> <text> [timeout-ms]');
772
+ process.exit(1);
773
+ }
774
+ let textArgs = args;
775
+ let timeout = 5000;
776
+ const last = args[args.length - 1];
777
+ const parsedLast = parseInt(last, 10);
778
+ if (args.length >= 2 && Number.isFinite(parsedLast) && parsedLast >= 0 && String(parsedLast) === last.trim()) {
779
+ timeout = parsedLast;
780
+ textArgs = args.slice(0, -1);
781
+ }
782
+ const text = textArgs.join(' ');
783
+ if (!text) {
784
+ console.error('Usage: chrome-ws wait-text <tab-index-or-ws-url> <text> [timeout-ms]');
785
+ process.exit(1);
786
+ }
787
+ (async () => {
788
+ try {
789
+ await session.waitForText(resolveTabArg(wsUrlOrIndex), text, timeout);
790
+ console.log(`Text found: ${text}`);
791
+ process.exit(0);
792
+ } catch (e) {
793
+ console.error('Wait failed:', e.message);
794
+ process.exit(1);
795
+ }
796
+ })();
797
+ return;
798
+ }
799
+
800
+ // Command: screenshot - capture screenshot
801
+ if (command === 'screenshot') {
802
+ const fullPage = args.includes('--fullpage');
803
+ const cleanArgs = args.filter(a => a !== '--fullpage');
804
+ const [filename] = cleanArgs;
805
+ if (!wsUrlOrIndex || !filename) {
806
+ console.error('Usage: chrome-ws screenshot <tab-index-or-ws-url> <filename.png> [--fullpage]');
807
+ process.exit(1);
808
+ }
809
+ (async () => {
810
+ try {
811
+ const savedPath = await session.screenshot(resolveTabArg(wsUrlOrIndex), filename, null, fullPage);
812
+ console.log(`Screenshot saved to ${savedPath}`);
813
+ process.exit(0);
814
+ } catch (e) {
815
+ console.error('Screenshot failed:', e.message);
816
+ process.exit(1);
817
+ }
818
+ })();
819
+ return;
820
+ }
821
+
822
+ // Command: markdown - save page as markdown
823
+ if (command === 'markdown') {
824
+ const [filename] = args;
825
+ if (!wsUrlOrIndex || !filename) {
826
+ console.error('Usage: chrome-ws markdown <tab-index-or-ws-url> <filename.md>');
827
+ process.exit(1);
828
+ }
829
+ (async () => {
830
+ try {
831
+ const wsUrl = await resolveWsUrl(wsUrlOrIndex);
832
+
833
+ // Extract page content intelligently
834
+ const js = `
835
+ (() => {
836
+ const title = document.title;
837
+ const url = window.location.href;
838
+
839
+ // Get main content (try article, main, or body)
840
+ let content = document.querySelector('article') ||
841
+ document.querySelector('main') ||
842
+ document.body;
843
+
844
+ // Convert to markdown-ish text
845
+ function nodeToMarkdown(node, level = 0) {
846
+ let md = '';
847
+
848
+ if (node.nodeType === Node.TEXT_NODE) {
849
+ const text = node.textContent.trim();
850
+ return text ? text + ' ' : '';
851
+ }
852
+
853
+ if (node.nodeType !== Node.ELEMENT_NODE) return '';
854
+
855
+ const tag = node.tagName.toLowerCase();
856
+
857
+ // Headers
858
+ if (/^h[1-6]$/.test(tag)) {
859
+ const hLevel = parseInt(tag[1]);
860
+ md += '\\n' + '#'.repeat(hLevel) + ' ' + node.textContent.trim() + '\\n\\n';
861
+ return md;
862
+ }
863
+
864
+ // Paragraphs
865
+ if (tag === 'p') {
866
+ md += node.textContent.trim() + '\\n\\n';
867
+ return md;
868
+ }
869
+
870
+ // Links
871
+ if (tag === 'a') {
872
+ const href = node.getAttribute('href') || '';
873
+ const text = node.textContent.trim();
874
+ return \`[\${text}](\${href}) \`;
875
+ }
876
+
877
+ // Lists
878
+ if (tag === 'li') {
879
+ return '- ' + node.textContent.trim() + '\\n';
880
+ }
881
+
882
+ // Code
883
+ if (tag === 'code' || tag === 'pre') {
884
+ return '\`' + node.textContent.trim() + '\` ';
885
+ }
886
+
887
+ // Recurse for other elements
888
+ for (const child of node.childNodes) {
889
+ md += nodeToMarkdown(child, level + 1);
890
+ }
891
+
892
+ if (tag === 'div' || tag === 'section') md += '\\n';
893
+
894
+ return md;
895
+ }
896
+
897
+ const markdown = nodeToMarkdown(content);
898
+
899
+ return \`# \${title}\\n\\nSource: \${url}\\n\\n\${markdown}\`;
900
+ })()
901
+ `;
902
+
903
+ const result = await sendCdpCommand(wsUrl, 'Runtime.evaluate', {
904
+ expression: js,
905
+ returnByValue: true
906
+ });
907
+
908
+ const fs = require('fs');
909
+ fs.writeFileSync(filename, result.result.value);
910
+ console.log(`Markdown saved to ${filename}`);
911
+ } catch (e) {
912
+ console.error('Markdown conversion failed:', e.message);
913
+ process.exit(1);
914
+ }
915
+ })();
916
+ return;
917
+ }
918
+
919
+ // Command: har - save network traffic as HAR
920
+ if (command === 'har') {
921
+ const [filename] = args;
922
+ if (!wsUrlOrIndex || !filename) {
923
+ console.error('Usage: chrome-ws har <tab-index-or-ws-url> <filename.har>');
924
+ console.error('Note: Start recording with "chrome-ws har-start <tab>" first');
925
+ process.exit(1);
926
+ }
927
+ (async () => {
928
+ try {
929
+ const wsUrl = await resolveWsUrl(wsUrlOrIndex);
930
+
931
+ // Get HAR data
932
+ const js = `window.__chrome_ws_har__ || []`;
933
+ const result = await sendCdpCommand(wsUrl, 'Runtime.evaluate', {
934
+ expression: js,
935
+ returnByValue: true
936
+ });
937
+
938
+ const har = {
939
+ log: {
940
+ version: '1.2',
941
+ creator: { name: 'chrome-ws', version: '1.0.0' },
942
+ entries: result.result.value || []
943
+ }
944
+ };
945
+
946
+ const fs = require('fs');
947
+ fs.writeFileSync(filename, JSON.stringify(har, null, 2));
948
+ console.log(`HAR saved to ${filename} (${har.log.entries.length} entries)`);
949
+ } catch (e) {
950
+ console.error('HAR export failed:', e.message);
951
+ process.exit(1);
952
+ }
953
+ })();
954
+ return;
955
+ }
956
+
957
+ // Past all the named-command dispatches without a return → either it's the
958
+ // raw escape hatch or it's a typo. Separate those cases so users get an
959
+ // actionable error instead of a confusing "Usage: chrome-ws raw ..." banner.
960
+ if (command !== 'raw') {
961
+ console.error(`Unknown command: ${command}`);
962
+ console.error(`Run 'chrome-ws --help' for the list of commands.`);
963
+ process.exit(1);
964
+ }
965
+
966
+ if (!wsUrlOrIndex || args.length === 0) {
967
+ console.error('Usage: chrome-ws raw <tab-index-or-ws-url> <json-rpc-payload>');
968
+ process.exit(1);
969
+ }
970
+
971
+ const payload = args.join(' ');
972
+ let message;
973
+ try {
974
+ message = JSON.parse(payload);
975
+ } catch (e) {
976
+ console.error('Invalid JSON payload:', e.message);
977
+ process.exit(1);
978
+ }
979
+
980
+ // For raw command, wsUrlOrIndex must be a full WebSocket URL (not an index)
981
+ // since this is the low-level escape hatch
982
+ if (!wsUrlOrIndex.startsWith('ws://')) {
983
+ console.error('raw command requires full WebSocket URL, not tab index');
984
+ console.error('Use: chrome-ws tabs # to get WebSocket URLs');
985
+ process.exit(1);
986
+ }
987
+
988
+ (async () => {
989
+ const ws = new WebSocketClient(wsUrlOrIndex);
990
+
991
+ const timeout = setTimeout(() => {
992
+ console.error('Timeout after 30s');
993
+ ws.close();
994
+ process.exit(1);
995
+ }, 30000);
996
+
997
+ ws.on('message', (data) => {
998
+ const response = JSON.parse(data);
999
+ if (response.id === message.id) {
1000
+ clearTimeout(timeout);
1001
+ console.log(JSON.stringify(response, null, 2));
1002
+ ws.close();
1003
+ process.exit(0);
1004
+ }
1005
+ });
1006
+
1007
+ ws.on('error', (error) => {
1008
+ clearTimeout(timeout);
1009
+ console.error('WebSocket error:', error.message);
1010
+ process.exit(1);
1011
+ });
1012
+
1013
+ try {
1014
+ await ws.connect();
1015
+ ws.send(JSON.stringify(message));
1016
+ } catch (err) {
1017
+ clearTimeout(timeout);
1018
+ console.error('Connection failed:', err.message);
1019
+ process.exit(1);
1020
+ }
1021
+ })();