@runablehq/mini-browser 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 (3) hide show
  1. package/README.md +165 -0
  2. package/dist/mb.js +914 -0
  3. package/package.json +38 -0
package/README.md ADDED
@@ -0,0 +1,165 @@
1
+ ```
2
+ ┌─────────────────────────────────────────┐
3
+ │ ███╗ ███╗██████╗ │
4
+ │ ████╗ ████║██╔══██╗ │
5
+ │ ██╔████╔██║██████╔╝ │
6
+ │ ██║╚██╔╝██║██╔══██╗ │
7
+ │ ██║ ╚═╝ ██║██████╔╝ │
8
+ │ ╚═╝ ╚═╝╚═════╝ │
9
+ │ │
10
+ │ mini-browser for agents │
11
+ └─────────────────────────────────────────┘
12
+ ```
13
+
14
+ A lightweight browser CLI for AI agents. Navigate, observe, interact — all from the command line.
15
+
16
+ ---
17
+
18
+ ## Quick Start
19
+
20
+ ```bash
21
+ # 1. Install dependencies
22
+ bun install
23
+
24
+ # 2. Build the CLI
25
+ bun run build
26
+
27
+ # 3. Start Chrome with remote debugging
28
+ ./start-chrome.sh
29
+
30
+ # 4. You're ready!
31
+ node dist/mb.js --help
32
+ ```
33
+
34
+ ---
35
+
36
+ ## Demo
37
+
38
+ ```bash
39
+ # Navigate to a page
40
+ $ mb go "https://example.com"
41
+ https://example.com/
42
+
43
+ # Get page text
44
+ $ mb text
45
+ Example Domain
46
+
47
+ This domain is for use in documentation examples without needing permission.
48
+
49
+ Learn more
50
+
51
+ # Find interactive elements with coordinates
52
+ $ mb snap
53
+ [0] link "Learn more" (246, 223)
54
+
55
+ # Click by coordinates
56
+ $ mb click 246 223
57
+
58
+ # Check where we ended up
59
+ $ mb url
60
+ https://www.iana.org/help/example-domains
61
+
62
+ # Go back
63
+ $ mb back
64
+
65
+ # Take a screenshot
66
+ $ mb shot page.png
67
+ page.png
68
+ ```
69
+
70
+ ---
71
+
72
+ ## Commands
73
+
74
+ ```
75
+ ┌─────────────────────────────────────────────────────────────────────┐
76
+ │ NAVIGATION │
77
+ ├─────────────────────────────────────────────────────────────────────┤
78
+ │ go <url> Navigate to URL │
79
+ │ url Print current URL │
80
+ │ back Go back │
81
+ │ forward Go forward │
82
+ ├─────────────────────────────────────────────────────────────────────┤
83
+ │ OBSERVE │
84
+ ├─────────────────────────────────────────────────────────────────────┤
85
+ │ shot [file] Screenshot (default: ./shot.png) │
86
+ │ snap Interactive elements with (x, y) coords │
87
+ │ text [selector] Visible text content │
88
+ ├─────────────────────────────────────────────────────────────────────┤
89
+ │ INTERACT │
90
+ ├─────────────────────────────────────────────────────────────────────┤
91
+ │ click <x> <y> Click at coordinates │
92
+ │ type [x y] <text> Type text (triple-clicks to select first) │
93
+ │ fill <k=v...> Fill form: Email=a@b.com "Name=Jo Do" │
94
+ │ key <key...> Press keys (Enter, Tab, Meta+a) │
95
+ │ move <x> <y> Move mouse / hover │
96
+ │ drag <x1> <y1> <x2> <y2> Drag from point to point │
97
+ │ scroll <dir> [px] Scroll up/down/left/right │
98
+ ├─────────────────────────────────────────────────────────────────────┤
99
+ │ TABS │
100
+ ├─────────────────────────────────────────────────────────────────────┤
101
+ │ tab list List open tabs │
102
+ │ tab new [url] Open new tab, print index │
103
+ │ tab close [n] Close tab (default: last) │
104
+ ├─────────────────────────────────────────────────────────────────────┤
105
+ │ OTHER │
106
+ ├─────────────────────────────────────────────────────────────────────┤
107
+ │ js <code> Run JavaScript in page context │
108
+ │ wait <target> Wait for ms/selector/networkidle/url:... │
109
+ │ audit Design audit (colors, fonts, contrast) │
110
+ │ logs Stream console logs (Ctrl+C to stop) │
111
+ └─────────────────────────────────────────────────────────────────────┘
112
+ ```
113
+
114
+ ### Flags
115
+
116
+ | Flag | Description |
117
+ |------|-------------|
118
+ | `--timeout <ms>` | Command timeout (default: 30000) |
119
+ | `--tab <n>` | Target tab index (default: 0) |
120
+ | `--json` | JSON output |
121
+ | `--right` | Right-click |
122
+ | `--double` | Double-click |
123
+
124
+ ---
125
+
126
+ ## How It Works
127
+
128
+ ```
129
+ ┌──────────┐ CDP ┌─────────────────┐
130
+ │ mb │ ◄──────────► │ Chrome/Chromium │
131
+ │ CLI │ (9222) │ (debug mode) │
132
+ └──────────┘ └─────────────────┘
133
+ ```
134
+
135
+ 1. **Start Chrome** with `--remote-debugging-port=9222`
136
+ 2. **Run commands** — `mb` connects via Chrome DevTools Protocol
137
+ 3. **Observe + Interact** — screenshots, text, clicks, forms, etc.
138
+
139
+ ---
140
+
141
+ ## For Agents
142
+
143
+ The `snap` command is your best friend:
144
+
145
+ ```bash
146
+ $ mb snap
147
+ [0] button "Sign In" (845, 32)
148
+ [1] input[type=email] (512, 245)
149
+ [2] input[type=password] (512, 312)
150
+ [3] button "Submit" (512, 380)
151
+ [4] link "Forgot password?" (512, 420)
152
+ ```
153
+
154
+ Each element has **coordinates** — use them with `click`, `type`, or `fill`.
155
+
156
+ ```bash
157
+ # Click the Sign In button
158
+ mb click 845 32
159
+
160
+ # Or fill a form by label
161
+ mb fill Email=user@example.com Password=secret123
162
+ mb click 512 380
163
+ ```
164
+
165
+ ---
package/dist/mb.js ADDED
@@ -0,0 +1,914 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/lib/flags.ts
4
+ import { parseArgs } from "node:util";
5
+
6
+ // src/lib/config.ts
7
+ var CDP_URL = "http://127.0.0.1:9222";
8
+ var VIEWPORT = { width: 1024, height: 768 };
9
+ var DEFAULT_TIMEOUT = 3e4;
10
+ var DEFAULT_TAB = 0;
11
+
12
+ // src/lib/flags.ts
13
+ var parseNumericFlag = ({ name, value }) => {
14
+ if (typeof value !== "string") {
15
+ throw new Error(`Invalid value for --${name}: "${String(value)}". Expected a number.`);
16
+ }
17
+ const numeric = Number(value);
18
+ if (!Number.isFinite(numeric)) {
19
+ throw new Error(`Invalid value for --${name}: "${value}". Expected a number.`);
20
+ }
21
+ return numeric;
22
+ };
23
+ var formatParseArgsError = ({ error }) => {
24
+ const message = error instanceof Error ? error.message : String(error);
25
+ const unknownFlag = message.match(/Unknown option '([^']+)'/);
26
+ if (unknownFlag?.[1]) {
27
+ return `Unknown flag ${unknownFlag[1]}. Run 'mb --help' for usage.`;
28
+ }
29
+ return `Invalid flags: ${message}`;
30
+ };
31
+ var parse = (argv) => {
32
+ const parsed = (() => {
33
+ try {
34
+ return parseArgs({
35
+ args: argv,
36
+ allowPositionals: true,
37
+ options: {
38
+ timeout: { type: "string", default: String(DEFAULT_TIMEOUT) },
39
+ tab: { type: "string", default: String(DEFAULT_TAB) },
40
+ json: { type: "boolean", default: false },
41
+ right: { type: "boolean", default: false },
42
+ double: { type: "boolean", default: false },
43
+ help: { type: "boolean", short: "h", default: false }
44
+ }
45
+ });
46
+ } catch (error) {
47
+ throw new Error(formatParseArgsError({ error }));
48
+ }
49
+ })();
50
+ const { values, positionals } = parsed;
51
+ return {
52
+ args: positionals,
53
+ flags: {
54
+ timeout: parseNumericFlag({ name: "timeout", value: values.timeout }),
55
+ tab: parseNumericFlag({ name: "tab", value: values.tab }),
56
+ json: values.json,
57
+ right: values.right,
58
+ double: values.double,
59
+ help: values.help
60
+ }
61
+ };
62
+ };
63
+
64
+ // src/lib/browser.ts
65
+ import puppeteer from "puppeteer-core";
66
+ var connect = async (tab2 = 0) => {
67
+ const browser = await puppeteer.connect({
68
+ browserURL: CDP_URL,
69
+ defaultViewport: VIEWPORT
70
+ });
71
+ const pages = await browser.pages();
72
+ if (tab2 < 0 || tab2 >= pages.length) {
73
+ await browser.disconnect();
74
+ throw new Error(`Invalid tab index: ${tab2}. Open tabs: 0-${pages.length - 1}`);
75
+ }
76
+ const page = pages[tab2];
77
+ if (!page) throw new Error("No pages found");
78
+ return { browser, page, close: () => browser.disconnect() };
79
+ };
80
+
81
+ // src/commands/go.ts
82
+ var go = async (args, flags) => {
83
+ const url2 = args[0];
84
+ if (!url2) {
85
+ console.error("Usage: mb go <url>");
86
+ process.exit(1);
87
+ }
88
+ const { page, close: close2 } = await connect(flags.tab);
89
+ await page.goto(url2, { waitUntil: "domcontentloaded", timeout: flags.timeout });
90
+ console.log(page.url());
91
+ await close2();
92
+ };
93
+
94
+ // src/commands/url.ts
95
+ var url = async (args, flags) => {
96
+ const { page, close: close2 } = await connect(flags.tab);
97
+ console.log(page.url());
98
+ await close2();
99
+ };
100
+
101
+ // src/commands/back.ts
102
+ var back = async (args, flags) => {
103
+ const { page, close: close2 } = await connect(flags.tab);
104
+ await page.goBack({ waitUntil: "domcontentloaded", timeout: flags.timeout });
105
+ console.log(page.url());
106
+ await close2();
107
+ };
108
+
109
+ // src/commands/forward.ts
110
+ var forward = async (args, flags) => {
111
+ const { page, close: close2 } = await connect(flags.tab);
112
+ await page.goForward({ waitUntil: "domcontentloaded", timeout: flags.timeout });
113
+ console.log(page.url());
114
+ await close2();
115
+ };
116
+
117
+ // src/commands/shot.ts
118
+ var shot = async (args, flags) => {
119
+ const path = args[0] || "./shot.png";
120
+ const { page, close: close2 } = await connect(flags.tab);
121
+ await page.screenshot({ path, type: "png" });
122
+ console.log(path);
123
+ await close2();
124
+ };
125
+
126
+ // src/commands/snap.ts
127
+ var INTERACTIVE_ROLES = /* @__PURE__ */ new Set([
128
+ "link",
129
+ "button",
130
+ "textbox",
131
+ "combobox",
132
+ "searchbox",
133
+ "checkbox",
134
+ "radio",
135
+ "switch",
136
+ "slider",
137
+ "spinbutton",
138
+ "tab",
139
+ "menuitem",
140
+ "menuitemcheckbox",
141
+ "menuitemradio",
142
+ "option",
143
+ "treeitem",
144
+ "select"
145
+ ]);
146
+ var STATE_PROPS = /* @__PURE__ */ new Set([
147
+ "haspopup",
148
+ "expanded",
149
+ "checked",
150
+ "selected",
151
+ "disabled",
152
+ "pressed"
153
+ ]);
154
+ var VIEWPORT_MARGIN = 5;
155
+ var getBox = async (client, backendNodeId) => {
156
+ const { model } = await client.send("DOM.getBoxModel", { backendNodeId });
157
+ if (model.width === 0 || model.height === 0) return null;
158
+ const [x1, y1, x2, , , y3] = model.border;
159
+ return {
160
+ x: Math.round((x1 + x2) / 2),
161
+ y: Math.round((y1 + y3) / 2)
162
+ };
163
+ };
164
+ var inViewport = ({ x, y }, viewport) => x >= 0 && x <= viewport.width && y >= 0 && y <= viewport.height + VIEWPORT_MARGIN;
165
+ var extractState = (properties) => (properties ?? []).reduce((acc, p) => {
166
+ if (STATE_PROPS.has(p.name)) acc[p.name] = p.value.value;
167
+ return acc;
168
+ }, {});
169
+ var nameFallback = (name, role, properties) => {
170
+ if (name) return name;
171
+ const href = properties?.find((p) => p.name === "url")?.value?.value;
172
+ if (href && role === "link") {
173
+ try {
174
+ return new URL(href).pathname.replace(/\/$/, "") || href;
175
+ } catch {
176
+ }
177
+ }
178
+ return "";
179
+ };
180
+ var formatElement = (el, index) => {
181
+ const stateStr = Object.entries(el.state).map(([k, v]) => `[${k}=${v}]`).join(" ");
182
+ return `[${index}] ${el.role} "${el.name}" (${el.x}, ${el.y})${stateStr ? " " + stateStr : ""}`;
183
+ };
184
+ var snap = async (args, flags) => {
185
+ const { page, close: close2 } = await connect(flags.tab);
186
+ const client = await page.createCDPSession();
187
+ const viewport = await page.evaluate(() => ({
188
+ width: window.innerWidth,
189
+ height: window.innerHeight
190
+ }));
191
+ const { nodes } = await client.send("Accessibility.getFullAXTree");
192
+ const interesting = nodes.filter(
193
+ (n) => !n.ignored && n.backendDOMNodeId && INTERACTIVE_ROLES.has(n.role?.value)
194
+ );
195
+ const elements = (await Promise.all(
196
+ interesting.map(async (n) => {
197
+ try {
198
+ const box = await getBox(client, n.backendDOMNodeId);
199
+ if (!box || !inViewport(box, viewport)) return null;
200
+ return {
201
+ role: n.role.value,
202
+ name: nameFallback(n.name?.value || "", n.role.value, n.properties),
203
+ ...box,
204
+ state: extractState(n.properties)
205
+ };
206
+ } catch {
207
+ return null;
208
+ }
209
+ })
210
+ )).filter((el) => el !== null).sort((a, b) => a.y - b.y || a.x - b.x);
211
+ await client.detach();
212
+ await close2();
213
+ if (flags.json) {
214
+ console.log(JSON.stringify(elements, null, 2));
215
+ return;
216
+ }
217
+ elements.forEach((el, i) => console.log(formatElement(el, i)));
218
+ };
219
+
220
+ // src/commands/text.ts
221
+ var text = async (args, flags) => {
222
+ const selector = args[0] || "body";
223
+ const { page, close: close2 } = await connect(flags.tab);
224
+ const content = await page.evaluate((sel) => {
225
+ const el = document.querySelector(sel);
226
+ return el?.innerText?.trim() ?? null;
227
+ }, selector);
228
+ await close2();
229
+ if (content === null) {
230
+ console.error(`Selector not found: ${selector}`);
231
+ process.exit(1);
232
+ }
233
+ console.log(content);
234
+ };
235
+
236
+ // src/commands/click.ts
237
+ var click = async (args, flags) => {
238
+ const [xStr, yStr] = args;
239
+ if (!xStr || !yStr) {
240
+ console.error("Usage: mb click <x> <y>");
241
+ process.exit(1);
242
+ }
243
+ const x = +xStr;
244
+ const y = +yStr;
245
+ if (isNaN(x) || isNaN(y)) {
246
+ console.error("x and y must be numbers");
247
+ process.exit(1);
248
+ }
249
+ const { page, close: close2 } = await connect(flags.tab);
250
+ await page.mouse.click(x, y, {
251
+ button: flags.right ? "right" : "left",
252
+ clickCount: flags.double ? 2 : 1
253
+ });
254
+ await close2();
255
+ };
256
+
257
+ // src/commands/type.ts
258
+ var type = async (args, flags) => {
259
+ if (args.length === 0) {
260
+ console.error("Usage: mb type <text> OR mb type <x> <y> <text>");
261
+ process.exit(1);
262
+ }
263
+ const maybeX = +args[0];
264
+ const maybeY = +args[1];
265
+ const hasCoords = args.length >= 3 && !isNaN(maybeX) && !isNaN(maybeY);
266
+ const coords = hasCoords ? { x: maybeX, y: maybeY } : null;
267
+ const text2 = hasCoords ? args.slice(2).join(" ") : args.join(" ");
268
+ if (!text2) {
269
+ console.error("Text is required");
270
+ process.exit(1);
271
+ }
272
+ const { page, close: close2 } = await connect(flags.tab);
273
+ if (coords) {
274
+ await page.mouse.click(coords.x, coords.y, { clickCount: 3 });
275
+ await new Promise((r) => setTimeout(r, 50));
276
+ }
277
+ await page.keyboard.type(text2);
278
+ await close2();
279
+ };
280
+
281
+ // src/commands/fill.ts
282
+ var parseKeyValuePairs = ({ args }) => {
283
+ const fields = {};
284
+ for (const arg of args) {
285
+ const eqIndex = arg.indexOf("=");
286
+ if (eqIndex === -1) {
287
+ throw new Error(`Invalid field "${arg}". Use key=value format, e.g.: mb fill Email=test@example.com`);
288
+ }
289
+ const key2 = arg.slice(0, eqIndex);
290
+ const value = arg.slice(eqIndex + 1);
291
+ if (!key2) {
292
+ throw new Error(`Empty field name in "${arg}"`);
293
+ }
294
+ fields[key2] = value;
295
+ }
296
+ return fields;
297
+ };
298
+ var parseFields = ({ args }) => {
299
+ return parseKeyValuePairs({ args });
300
+ };
301
+ var fill = async (args, flags) => {
302
+ if (args.length === 0) {
303
+ throw new Error('Usage: mb fill "Field Name=value" ...');
304
+ }
305
+ const fields = parseFields({ args });
306
+ const { page, close: close2 } = await connect(flags.tab);
307
+ const results = await page.evaluate((fields2) => {
308
+ const filled = [];
309
+ const failed = [];
310
+ const query = (selector, key2) => {
311
+ try {
312
+ return document.querySelector(selector);
313
+ } catch {
314
+ throw new Error(`Invalid selector for field "${key2}": ${selector}`);
315
+ }
316
+ };
317
+ const escapeSelectorValue = (value) => {
318
+ if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
319
+ return CSS.escape(value);
320
+ }
321
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
322
+ };
323
+ const findInput = (key2) => {
324
+ if (key2.startsWith("#") || key2.startsWith(".") || key2.startsWith("[")) {
325
+ return query(key2, key2);
326
+ }
327
+ const escapedKey = escapeSelectorValue(key2);
328
+ const byAria = query(`[aria-label="${escapedKey}"]`, key2);
329
+ if (byAria) return byAria;
330
+ const byPlaceholder = query(`[placeholder="${escapedKey}"]`, key2);
331
+ if (byPlaceholder) return byPlaceholder;
332
+ const byName = query(`[name="${escapedKey}"]`, key2);
333
+ if (byName) return byName;
334
+ const byId = document.getElementById(key2);
335
+ if (byId) return byId;
336
+ const labels = document.querySelectorAll("label");
337
+ for (const label of labels) {
338
+ if (label.textContent?.trim().toLowerCase().includes(key2.toLowerCase())) {
339
+ const forId = label.getAttribute("for");
340
+ if (forId) {
341
+ const input2 = document.getElementById(forId);
342
+ if (input2) return input2;
343
+ }
344
+ const input = label.querySelector("input, textarea, select");
345
+ if (input) return input;
346
+ }
347
+ }
348
+ return null;
349
+ };
350
+ for (const [key2, value] of Object.entries(fields2)) {
351
+ const el = findInput(key2);
352
+ if (!el) {
353
+ failed.push(key2);
354
+ continue;
355
+ }
356
+ if (el.tagName === "SELECT") {
357
+ el.value = value;
358
+ } else {
359
+ el.value = value;
360
+ }
361
+ el.dispatchEvent(new Event("input", { bubbles: true }));
362
+ el.dispatchEvent(new Event("change", { bubbles: true }));
363
+ filled.push(key2);
364
+ }
365
+ return { filled, failed };
366
+ }, fields);
367
+ await close2();
368
+ if (results.filled.length > 0) {
369
+ console.log(`Filled: ${results.filled.join(", ")}`);
370
+ }
371
+ if (results.failed.length > 0) {
372
+ throw new Error(`Not found: ${results.failed.join(", ")}`);
373
+ }
374
+ };
375
+
376
+ // src/commands/key.ts
377
+ var key = async (args, flags) => {
378
+ if (args.length === 0) {
379
+ console.error("Usage: mb key <key...>");
380
+ process.exit(1);
381
+ }
382
+ const { page, close: close2 } = await connect(flags.tab);
383
+ for (const k of args) {
384
+ if (!k.includes("+")) {
385
+ await page.keyboard.press(k);
386
+ continue;
387
+ }
388
+ const parts = k.split("+");
389
+ const modifiers = parts.slice(0, -1);
390
+ const finalKey = parts.at(-1);
391
+ for (const m of modifiers) await page.keyboard.down(m);
392
+ await page.keyboard.press(finalKey);
393
+ for (const m of [...modifiers].reverse()) await page.keyboard.up(m);
394
+ }
395
+ await close2();
396
+ };
397
+
398
+ // src/commands/move.ts
399
+ var move = async (args, flags) => {
400
+ if (args.length < 2) {
401
+ console.error("Usage: mb move <x> <y>");
402
+ process.exit(1);
403
+ }
404
+ const x = +args[0];
405
+ const y = +args[1];
406
+ if (isNaN(x) || isNaN(y)) {
407
+ console.error("x and y must be numbers");
408
+ process.exit(1);
409
+ }
410
+ const { page, close: close2 } = await connect(flags.tab);
411
+ await page.mouse.move(x, y);
412
+ await close2();
413
+ };
414
+
415
+ // src/commands/drag.ts
416
+ var drag = async (args, flags) => {
417
+ if (args.length < 4) {
418
+ console.error("Usage: mb drag <x1> <y1> <x2> <y2>");
419
+ process.exit(1);
420
+ }
421
+ const coords = args.map(Number);
422
+ const [x1, y1, x2, y2] = coords;
423
+ if (coords.some(isNaN)) {
424
+ console.error("All coordinates must be numbers");
425
+ process.exit(1);
426
+ }
427
+ const { page, close: close2 } = await connect(flags.tab);
428
+ await page.mouse.move(x1, y1);
429
+ await page.mouse.down();
430
+ await page.mouse.move(x2, y2, { steps: 10 });
431
+ await page.mouse.up();
432
+ await close2();
433
+ };
434
+
435
+ // src/commands/scroll.ts
436
+ var deltas = {
437
+ up: { x: 0, y: -1 },
438
+ down: { x: 0, y: 1 },
439
+ left: { x: -1, y: 0 },
440
+ right: { x: 1, y: 0 }
441
+ };
442
+ var scroll = async (args, flags) => {
443
+ const dir = args[0] || "down";
444
+ const px = Number(args[1] || 500);
445
+ const delta = deltas[dir];
446
+ if (!delta) {
447
+ console.error("Direction must be: up, down, left, right");
448
+ process.exit(1);
449
+ }
450
+ if (!Number.isFinite(px) || px <= 0) {
451
+ console.error("Pixels must be a positive number");
452
+ process.exit(1);
453
+ }
454
+ const { page, close: close2 } = await connect(flags.tab);
455
+ await page.evaluate(({ x, y }) => window.scrollBy(x, y), { x: delta.x * px, y: delta.y * px });
456
+ await close2();
457
+ };
458
+
459
+ // src/commands/js.ts
460
+ import { text as readStdin } from "node:stream/consumers";
461
+ var js = async (args, flags) => {
462
+ const code = args[0] === "-" ? await readStdin(process.stdin) : args.join(" ");
463
+ if (!code.trim()) {
464
+ console.error("Usage: mb js <code> OR echo 'code' | mb js -");
465
+ process.exit(1);
466
+ }
467
+ const { page, close: close2 } = await connect(flags.tab);
468
+ const result = await page.evaluate(code);
469
+ await close2();
470
+ if (result === void 0 || result === null) return;
471
+ console.log(typeof result === "string" ? result : JSON.stringify(result, null, 2));
472
+ };
473
+
474
+ // src/commands/wait.ts
475
+ var wait = async (args, flags) => {
476
+ const target = args[0];
477
+ if (!target) {
478
+ console.error("Usage: mb wait <ms | selector | networkidle | url:...>");
479
+ process.exit(1);
480
+ }
481
+ const { page, close: close2 } = await connect(flags.tab);
482
+ const ms = +target;
483
+ if (!isNaN(ms) && target === String(ms)) {
484
+ await new Promise((r) => setTimeout(r, ms));
485
+ await close2();
486
+ return;
487
+ }
488
+ if (target === "networkidle") {
489
+ await page.waitForNetworkIdle({ timeout: flags.timeout });
490
+ await close2();
491
+ return;
492
+ }
493
+ if (target.startsWith("url:")) {
494
+ await page.waitForFunction(
495
+ (p) => location.href.includes(p),
496
+ { timeout: flags.timeout },
497
+ target.slice(4)
498
+ );
499
+ await close2();
500
+ return;
501
+ }
502
+ await page.waitForSelector(target, { timeout: flags.timeout });
503
+ await close2();
504
+ };
505
+
506
+ // src/commands/audit.ts
507
+ var findHeadingSkips = (headings) => headings.reduce((skips, level, i) => {
508
+ const prev = headings[i - 1];
509
+ if (i > 0 && prev !== void 0 && level > prev + 1)
510
+ return [...skips, `h${prev}\u2192h${level}`];
511
+ return skips;
512
+ }, []);
513
+ var findDesignIssues = ({ colors, families, sizes, radii }) => {
514
+ const checks = [
515
+ { count: families.length, max: 3, msg: "font families \u2014 limit to 2-3" },
516
+ { count: sizes.length, max: 8, msg: "font sizes \u2014 consolidate to 5-7 step type scale" },
517
+ { count: colors.length, max: 15, msg: "colors \u2014 tighten palette to 8-12" },
518
+ { count: radii.length, max: 4, msg: "border-radius values \u2014 standardize to 2-3" }
519
+ ];
520
+ return checks.filter(({ count, max }) => count > max).map(({ count, msg }) => `${count} ${msg}`);
521
+ };
522
+ var findA11yIssues = (a11y, headingSkips) => [
523
+ a11y.imagesNoAlt && `${a11y.imagesNoAlt} images missing alt`,
524
+ a11y.inputsNoLabel && `${a11y.inputsNoLabel} inputs missing label`,
525
+ a11y.linksNoText && `${a11y.linksNoText} links without text`,
526
+ a11y.buttonsNoText && `${a11y.buttonsNoText} buttons without text`,
527
+ headingSkips.length && `heading skip: ${headingSkips.join(", ")}`
528
+ ].filter(Boolean);
529
+ var check = (ok, yes, no) => ok ? ` \u2713 ${yes}` : ` \u2717 ${no}`;
530
+ var formatText = ({ data, contrast, browser, designIssues, a11yIssues, headingSkips }) => {
531
+ const { colors, families, sizes, weights, margins, paddings, radii, a11y, seo } = data;
532
+ const lines = [];
533
+ const out = (s) => lines.push(s);
534
+ out(`PALETTE ${colors.length} colors`);
535
+ colors.slice(0, 12).forEach((c) => out(` ${c.hex} ${String(c.count).padStart(3)}x ${c.uses.join(",")}`));
536
+ if (colors.length > 12) out(` ... +${colors.length - 12} more`);
537
+ out(`
538
+ TYPOGRAPHY ${families.length} families, ${sizes.length} sizes`);
539
+ families.forEach((f) => out(` ${f.name} (${f.count})`));
540
+ out(` sizes: ${sizes.map((s) => `${s.value}(${s.count})`).join(" ")}`);
541
+ out(` weights: ${weights.map((w) => `${w.value}(${w.count})`).join(" ")}`);
542
+ out(`
543
+ SPACING`);
544
+ if (margins.length) out(` margins: ${margins.map((m) => `${m.value}(${m.count})`).join(" ")}`);
545
+ if (paddings.length) out(` paddings: ${paddings.map((p) => `${p.value}(${p.count})`).join(" ")}`);
546
+ if (radii.length) out(` radii: ${radii.map((r) => `${r.value}(${r.count})`).join(" ")}`);
547
+ if (contrast.length) {
548
+ out(`
549
+ CONTRAST ${contrast.length} issues (Chrome DevTools)`);
550
+ contrast.slice(0, 10).forEach((c) => out(` ${c.selector} ${c.ratio}:1 (need ${c.threshold}:1) ${c.fontSize} w${c.fontWeight}`));
551
+ if (contrast.length > 10) out(` ... +${contrast.length - 10} more`);
552
+ }
553
+ out(`
554
+ ACCESSIBILITY`);
555
+ out(check(!!a11y.lang, `lang="${a11y.lang}"`, "missing lang attribute"));
556
+ out(check(!!a11y.title, "page has title", "missing page title"));
557
+ a11yIssues.forEach((i) => out(` \u2717 ${i}`));
558
+ if (!a11yIssues.length && a11y.lang && a11y.title) out(" \u2713 no issues");
559
+ out(`
560
+ SEO`);
561
+ out(check(!!seo.title, `title: "${seo.title}"`, "missing title"));
562
+ out(check(!!seo.metaDescription, `meta description (${seo.metaDescription.length} chars)`, "missing meta description"));
563
+ out(check(!!seo.canonical, `canonical: ${seo.canonical}`, "no canonical link"));
564
+ out(check(seo.hasViewport, "viewport meta", "missing viewport meta"));
565
+ out(seo.h1Count === 1 ? ` \u2713 1 h1: "${seo.h1Text}"` : seo.h1Count === 0 ? " \u2717 no h1" : ` \u26A0 ${seo.h1Count} h1 tags (should be 1)`);
566
+ out(` ${seo.og.title ? "\u2713" : "\u2717"} og:title ${seo.og.description ? "\u2713" : "\u2717"} og:description ${seo.og.image ? "\u2713" : "\u2717"} og:image`);
567
+ if (browser.length) {
568
+ const grouped = {};
569
+ for (const i of browser) {
570
+ if (!grouped[i.code]) grouped[i.code] = [];
571
+ grouped[i.code].push(i);
572
+ }
573
+ out(`
574
+ BROWSER ${browser.length} issues`);
575
+ for (const [code, items] of Object.entries(grouped)) {
576
+ const details = items.map((i) => i.detail).filter(Boolean);
577
+ out(` ${code} (${details.length})`);
578
+ details.slice(0, 3).forEach((d) => out(` ${d}`));
579
+ if (details.length > 3) out(` ... +${details.length - 3} more`);
580
+ }
581
+ }
582
+ if (designIssues.length) {
583
+ out(`
584
+ DESIGN`);
585
+ designIssues.forEach((i) => out(` \u2717 ${i}`));
586
+ }
587
+ const total = designIssues.length + contrast.length + a11yIssues.length;
588
+ out(`
589
+ ${total} issues total`);
590
+ return lines.join("\n");
591
+ };
592
+ var collectCdpIssues = async (page) => {
593
+ const contrast = [];
594
+ const browser = [];
595
+ try {
596
+ const client = await page.createCDPSession();
597
+ client.on("Audits.issueAdded", ({ issue }) => {
598
+ if (issue.code === "LowTextContrastIssue") {
599
+ const d = issue.details.lowTextContrastIssueDetails;
600
+ contrast.push({
601
+ selector: d.violatingNodeSelector,
602
+ ratio: Math.round(d.contrastRatio * 100) / 100,
603
+ threshold: d.thresholdAA,
604
+ fontSize: d.fontSize,
605
+ fontWeight: d.fontWeight
606
+ });
607
+ } else {
608
+ const detail = issue.details?.mixedContentIssueDetails?.insecureURL ?? issue.details?.deprecationIssueDetails?.type ?? issue.details?.contentSecurityPolicyIssueDetails?.violatedDirective ?? issue.details?.cookieIssueDetails?.cookie?.name ?? "";
609
+ browser.push({ code: issue.code, detail });
610
+ }
611
+ });
612
+ await client.send("Audits.enable");
613
+ await client.send("Audits.checkContrast", { reportAAA: false });
614
+ return { contrast, browser, cleanup: async () => {
615
+ try {
616
+ await client.send("Audits.disable");
617
+ await client.detach();
618
+ } catch {
619
+ }
620
+ } };
621
+ } catch {
622
+ return { contrast, browser, cleanup: async () => {
623
+ } };
624
+ }
625
+ };
626
+ var extractDesignData = (page) => page.evaluate(() => {
627
+ const parseRgb = (s) => {
628
+ const m = s.match(/rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)/);
629
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
630
+ };
631
+ const toHex = (rgb) => "#" + rgb.map((c) => c.toString(16).padStart(2, "0")).join("");
632
+ const transparent = (s) => s === "rgba(0, 0, 0, 0)" || s === "transparent";
633
+ const tally = (map, key2) => {
634
+ map[key2] = (map[key2] || 0) + 1;
635
+ };
636
+ const colors = {};
637
+ const families = {};
638
+ const sizes = {};
639
+ const weights = {};
640
+ const marginVals = {};
641
+ const paddingVals = {};
642
+ const radii = {};
643
+ for (const el of document.querySelectorAll("body *")) {
644
+ const rect = el.getBoundingClientRect();
645
+ if (rect.width === 0 || rect.height === 0) continue;
646
+ const cs = getComputedStyle(el);
647
+ if (cs.display === "none" || cs.visibility === "hidden" || cs.opacity === "0") continue;
648
+ for (const [val, use] of [[cs.color, "text"], [cs.backgroundColor, "bg"]]) {
649
+ if (transparent(val)) continue;
650
+ const rgb = parseRgb(val);
651
+ if (!rgb) continue;
652
+ const h = toHex(rgb);
653
+ const entry = colors[h] ?? (colors[h] = { count: 0, uses: /* @__PURE__ */ new Set() });
654
+ entry.count++;
655
+ entry.uses.add(use);
656
+ }
657
+ const fam = cs.fontFamily.split(",")[0]?.trim().replace(/['"]/g, "") ?? "";
658
+ tally(families, fam);
659
+ tally(sizes, cs.fontSize);
660
+ tally(weights, cs.fontWeight);
661
+ for (const dir of ["Top", "Right", "Bottom", "Left"]) {
662
+ const m = cs[`margin${dir}`], p = cs[`padding${dir}`];
663
+ if (m && m !== "0px") tally(marginVals, m);
664
+ if (p && p !== "0px") tally(paddingVals, p);
665
+ }
666
+ if (cs.borderRadius && cs.borderRadius !== "0px") tally(radii, cs.borderRadius);
667
+ }
668
+ const countMatching = (sel, predicate) => [...document.querySelectorAll(sel)].filter(predicate).length;
669
+ const hasAccessibleName = (el) => {
670
+ const text2 = el.innerText?.trim() || el.getAttribute("aria-label") || "";
671
+ return !!text2 || !!el.querySelector("img[alt]");
672
+ };
673
+ const headings = [...document.querySelectorAll("h1,h2,h3,h4,h5,h6")].map((h) => parseInt(h.tagName.charAt(1)));
674
+ const a11y = {
675
+ imagesNoAlt: countMatching("img", (el) => !el.hasAttribute("alt")),
676
+ inputsNoLabel: countMatching("input:not([type=hidden]), textarea, select", (el) => {
677
+ const inp = el;
678
+ const id = inp.id;
679
+ return !(inp.hasAttribute("aria-label") || inp.hasAttribute("aria-labelledby") || inp.hasAttribute("title") || inp.hasAttribute("placeholder") || id && document.querySelector(`label[for="${id}"]`) || inp.closest("label"));
680
+ }),
681
+ linksNoText: countMatching("a[href]", (el) => !hasAccessibleName(el)),
682
+ buttonsNoText: countMatching("button, [role=button]", (el) => !hasAccessibleName(el)),
683
+ headings,
684
+ lang: document.documentElement.getAttribute("lang") || "",
685
+ title: document.title?.trim().slice(0, 80) || ""
686
+ };
687
+ const meta = (sel, attr = "content") => document.querySelector(sel)?.getAttribute(attr) || "";
688
+ const h1 = document.querySelector("h1");
689
+ const seo = {
690
+ title: a11y.title,
691
+ metaDescription: meta('meta[name="description"]').slice(0, 120),
692
+ canonical: meta('link[rel="canonical"]', "href"),
693
+ h1Count: document.querySelectorAll("h1").length,
694
+ h1Text: h1?.innerText?.trim().slice(0, 80) || "",
695
+ hasViewport: !!document.querySelector('meta[name="viewport"]'),
696
+ og: {
697
+ title: !!document.querySelector('meta[property="og:title"]'),
698
+ description: !!document.querySelector('meta[property="og:description"]'),
699
+ image: !!document.querySelector('meta[property="og:image"]')
700
+ }
701
+ };
702
+ const descByCount = (obj) => Object.entries(obj).sort((a, b) => b[1] - a[1]);
703
+ const ascByPx = (obj) => Object.entries(obj).sort((a, b) => parseFloat(a[0]) - parseFloat(b[0]));
704
+ return {
705
+ colors: Object.entries(colors).sort((a, b) => b[1].count - a[1].count).map(([h, v]) => ({ hex: h, count: v.count, uses: [...v.uses] })),
706
+ families: descByCount(families).map(([name, count]) => ({ name, count })),
707
+ sizes: ascByPx(sizes).map(([value, count]) => ({ value, count })),
708
+ weights: ascByPx(weights).map(([value, count]) => ({ value, count })),
709
+ margins: ascByPx(marginVals).map(([value, count]) => ({ value, count })),
710
+ paddings: ascByPx(paddingVals).map(([value, count]) => ({ value, count })),
711
+ radii: ascByPx(radii).map(([value, count]) => ({ value, count })),
712
+ a11y,
713
+ seo
714
+ };
715
+ });
716
+ var audit = async (args, flags) => {
717
+ const { page, close: close2 } = await connect(flags.tab);
718
+ const cdp = await collectCdpIssues(page);
719
+ const data = await extractDesignData(page);
720
+ await new Promise((r) => setTimeout(r, 300));
721
+ await cdp.cleanup();
722
+ await close2();
723
+ const { contrast, browser } = cdp;
724
+ const headingSkips = findHeadingSkips(data.a11y.headings);
725
+ const designIssues = findDesignIssues(data);
726
+ const a11yIssues = findA11yIssues(data.a11y, headingSkips);
727
+ if (flags.json) {
728
+ console.log(JSON.stringify({ ...data, contrast, browser, designIssues, a11yIssues, headingSkips }, null, 2));
729
+ return;
730
+ }
731
+ console.log(formatText({ data, contrast, browser, designIssues, a11yIssues, headingSkips }));
732
+ };
733
+
734
+ // src/commands/tab.ts
735
+ import puppeteer2 from "puppeteer-core";
736
+ var subcommands = { list, new: newTab, close };
737
+ var help = `Usage: mb tab <list|new|close> [args]
738
+
739
+ list List open tabs
740
+ new [url] Open new tab, print index
741
+ close [n] Close tab (default: last)`;
742
+ var tab = async (args, flags) => {
743
+ const [sub, ...rest] = args;
744
+ if (!sub || !(sub in subcommands)) {
745
+ console.error(help);
746
+ process.exit(1);
747
+ }
748
+ const handler = subcommands[sub];
749
+ await handler(rest, flags);
750
+ };
751
+ async function list(_args, flags) {
752
+ const browser = await puppeteer2.connect({ browserURL: CDP_URL, defaultViewport: VIEWPORT });
753
+ const pages = await browser.pages();
754
+ const entries = await Promise.all(
755
+ pages.map(async (page, i) => ({
756
+ index: i,
757
+ url: page.url(),
758
+ title: await page.title()
759
+ }))
760
+ );
761
+ await browser.disconnect();
762
+ if (flags.json) {
763
+ console.log(JSON.stringify(entries, null, 2));
764
+ return;
765
+ }
766
+ for (const { index, url: url2, title } of entries) {
767
+ console.log(`${index} ${url2} ${title}`);
768
+ }
769
+ }
770
+ async function newTab(args, flags) {
771
+ const url2 = args[0];
772
+ const browser = await puppeteer2.connect({ browserURL: CDP_URL, defaultViewport: VIEWPORT });
773
+ const page = await browser.newPage();
774
+ if (url2) {
775
+ try {
776
+ await page.goto(url2, { waitUntil: "domcontentloaded", timeout: flags.timeout });
777
+ } catch (e) {
778
+ await page.close();
779
+ await browser.disconnect();
780
+ throw e;
781
+ }
782
+ }
783
+ const pages = await browser.pages();
784
+ const index = pages.indexOf(page);
785
+ await browser.disconnect();
786
+ console.log(index);
787
+ }
788
+ async function close(args, _flags) {
789
+ const browser = await puppeteer2.connect({ browserURL: CDP_URL, defaultViewport: VIEWPORT });
790
+ const pages = await browser.pages();
791
+ if (pages.length <= 1) {
792
+ await browser.disconnect();
793
+ throw new Error("Cannot close the last tab");
794
+ }
795
+ const index = args[0] !== void 0 ? Number(args[0]) : pages.length - 1;
796
+ if (!Number.isInteger(index) || index < 0 || index >= pages.length) {
797
+ await browser.disconnect();
798
+ throw new Error(`Invalid tab index: ${args[0]}. Open tabs: 0-${pages.length - 1}`);
799
+ }
800
+ const target = pages[index];
801
+ const url2 = target.url();
802
+ await target.close();
803
+ await browser.disconnect();
804
+ console.log(`Closed tab ${index} ${url2}`);
805
+ }
806
+
807
+ // src/commands/logs.ts
808
+ import puppeteer3 from "puppeteer-core";
809
+ var logs = async (_args, flags) => {
810
+ const browser = await puppeteer3.connect({
811
+ browserURL: CDP_URL,
812
+ defaultViewport: VIEWPORT
813
+ });
814
+ const setupPage = async (page, index) => {
815
+ const client = await page.createCDPSession();
816
+ await client.send("Runtime.enable");
817
+ client.on("Runtime.consoleAPICalled", (event) => {
818
+ const args = event.args.map((a) => a.value !== void 0 ? String(a.value) : a.description ?? "").join(" ");
819
+ const time = (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false });
820
+ const type2 = event.type === "warning" ? "warn" : event.type;
821
+ if (flags.json) {
822
+ console.log(JSON.stringify({ tab: index, type: type2, time, message: args }));
823
+ } else {
824
+ const prefix = index > 0 ? `[${index}] ` : "";
825
+ console.log(`${prefix}[${time}] ${type2}: ${args}`);
826
+ }
827
+ });
828
+ };
829
+ const pages = await browser.pages();
830
+ for (let i = 0; i < pages.length; i++) {
831
+ await setupPage(pages[i], i);
832
+ }
833
+ browser.on("targetcreated", async (target) => {
834
+ if (target.type() === "page") {
835
+ const page = await target.page();
836
+ if (page) {
837
+ const allPages = await browser.pages();
838
+ const index = allPages.indexOf(page);
839
+ await setupPage(page, index);
840
+ }
841
+ }
842
+ });
843
+ console.error("Watching console logs... (Ctrl+C to stop)\n");
844
+ await new Promise((resolve) => {
845
+ process.on("SIGINT", () => {
846
+ console.error("\nStopped");
847
+ resolve();
848
+ });
849
+ });
850
+ await browser.disconnect();
851
+ };
852
+
853
+ // src/index.ts
854
+ var commands = { go, url, back, forward, shot, snap, text, click, type, fill, key, move, drag, scroll, js, wait, audit, tab, logs };
855
+ var help2 = `mb \u2014 Browser CLI for Agents
856
+
857
+ Usage: mb <command> [args] [flags]
858
+
859
+ Navigation:
860
+ go <url> Navigate to URL
861
+ url Print current URL
862
+ back Go back
863
+ forward Go forward
864
+
865
+ Observe:
866
+ shot [file] Screenshot (default: ./shot.png)
867
+ snap Interactive elements with (x, y)
868
+ text [selector] Visible text content
869
+
870
+ Interact:
871
+ click <x> <y> Click at coordinates
872
+ type [x y] <text> Type text (triple-clicks to select first)
873
+ fill <k=v...> Fill form: Email=a@b.com "Name=Jo Do"
874
+ key <key...> Press keys (Enter, Tab, Meta+a)
875
+ move <x> <y> Move mouse / hover
876
+ drag <x1> <y1> <x2> <y2> Drag
877
+ scroll <dir> [px] Scroll up/down/left/right
878
+
879
+ Other:
880
+ js <code> Run JavaScript
881
+ wait <target> Wait for ms/selector/networkidle/url:...
882
+ audit Design audit (colors, fonts, spacing, contrast)
883
+ logs Stream console logs (Ctrl+C to stop)
884
+
885
+ Tabs:
886
+ tab list List open tabs
887
+ tab new [url] Open new tab, print index
888
+ tab close [n] Close tab (default: last)
889
+
890
+ Flags:
891
+ --timeout <ms> Timeout (default: 30000)
892
+ --tab <n> Tab index (default: 0)
893
+ --json JSON output
894
+ --right/--double Right/double click`;
895
+ var main = async () => {
896
+ try {
897
+ const { args, flags } = parse(process.argv.slice(2));
898
+ const [cmd, ...rest] = args;
899
+ if (!cmd || flags.help) {
900
+ console.log(help2);
901
+ process.exit(0);
902
+ }
903
+ const handler = commands[cmd];
904
+ if (!handler) {
905
+ throw new Error(`Unknown command: ${cmd}
906
+ Run 'mb --help' for usage`);
907
+ }
908
+ await handler(rest, flags);
909
+ } catch (e) {
910
+ console.error(`Error: ${e instanceof Error ? e.message : e}`);
911
+ process.exit(1);
912
+ }
913
+ };
914
+ main();
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@runablehq/mini-browser",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "A browser CLI for agents, built with Puppeteer",
6
+ "keywords": [
7
+ "browser",
8
+ "cli",
9
+ "puppeteer",
10
+ "automation",
11
+ "agent",
12
+ "screenshot",
13
+ "headless"
14
+ ],
15
+ "bin": {
16
+ "mb": "./dist/mb.js"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "README.md"
21
+ ],
22
+ "scripts": {
23
+ "build": "node scripts/build.js",
24
+ "build:binary": "bun build src/index.ts --compile --outfile dist/mb",
25
+ "prepublishOnly": "npm run build"
26
+ },
27
+ "dependencies": {
28
+ "puppeteer-core": "^24.37.3"
29
+ },
30
+ "devDependencies": {
31
+ "@types/bun": "latest",
32
+ "esbuild": "^0.25.12",
33
+ "typescript": "^5.9.3"
34
+ },
35
+ "engines": {
36
+ "node": ">=18"
37
+ }
38
+ }