@ulpi/browse 0.7.5 → 1.0.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/src/cli.ts DELETED
@@ -1,683 +0,0 @@
1
- /**
2
- * browse CLI — thin wrapper that talks to the persistent server
3
- *
4
- * Flow:
5
- * 1. Read /tmp/browse-server.json for port + token
6
- * 2. If missing or stale PID → start server in background
7
- * 3. Health check
8
- * 4. Send command via HTTP POST
9
- * 5. Print response to stdout (or stderr for errors)
10
- */
11
-
12
- import * as fs from 'fs';
13
- import * as path from 'path';
14
- import { DEFAULTS } from './constants';
15
- import { loadConfig } from './config';
16
-
17
- // Global CLI flags — set in main(), used by sendCommand()
18
- const cliFlags = {
19
- json: false,
20
- contentBoundaries: false,
21
- allowedDomains: '' as string,
22
- headed: false,
23
- };
24
-
25
- const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10);
26
- // One server per project directory by default. Sessions handle agent isolation.
27
- // For multiple servers on the same project: set BROWSE_INSTANCE or BROWSE_PORT.
28
- const BROWSE_INSTANCE = process.env.BROWSE_INSTANCE || '';
29
- const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : (BROWSE_INSTANCE ? `-${BROWSE_INSTANCE}` : '');
30
-
31
- /**
32
- * Resolve the project-local .browse/ directory for state files, logs, screenshots.
33
- * Walks up from cwd looking for .git/ or .claude/ (project root markers).
34
- * Creates <root>/.browse/ with a self-contained .gitignore.
35
- * Falls back to /tmp/ if not found (e.g. running outside a project).
36
- */
37
- function resolveLocalDir(): string {
38
- if (process.env.BROWSE_LOCAL_DIR) {
39
- try { fs.mkdirSync(process.env.BROWSE_LOCAL_DIR, { recursive: true }); } catch {}
40
- return process.env.BROWSE_LOCAL_DIR;
41
- }
42
-
43
- let dir = process.cwd();
44
- for (let i = 0; i < 20; i++) {
45
- if (fs.existsSync(path.join(dir, '.git')) || fs.existsSync(path.join(dir, '.claude'))) {
46
- const browseDir = path.join(dir, '.browse');
47
- try {
48
- fs.mkdirSync(browseDir, { recursive: true });
49
- // Self-contained .gitignore — users don't need to add .browse/ to their .gitignore
50
- const gi = path.join(browseDir, '.gitignore');
51
- if (!fs.existsSync(gi)) {
52
- fs.writeFileSync(gi, '*\n');
53
- }
54
- } catch {}
55
- return browseDir;
56
- }
57
- const parent = path.dirname(dir);
58
- if (parent === dir) break;
59
- dir = parent;
60
- }
61
- return '/tmp';
62
- }
63
-
64
- const LOCAL_DIR = resolveLocalDir();
65
- const STATE_FILE = process.env.BROWSE_STATE_FILE || path.join(LOCAL_DIR, `browse-server${INSTANCE_SUFFIX}.json`);
66
- const MAX_START_WAIT = 8000; // 8 seconds to start
67
- const LOCK_FILE = STATE_FILE + '.lock';
68
- const LOCK_STALE_MS = DEFAULTS.LOCK_STALE_THRESHOLD_MS;
69
-
70
- export function resolveServerScript(
71
- env: Record<string, string | undefined> = process.env,
72
- metaDir: string = import.meta.dir,
73
- ): string {
74
- // 1. Explicit env var override
75
- if (env.BROWSE_SERVER_SCRIPT) {
76
- return env.BROWSE_SERVER_SCRIPT;
77
- }
78
-
79
- // 2. server.ts adjacent to cli.ts (dev mode or installed)
80
- if (metaDir.startsWith('/') && !metaDir.includes('$bunfs')) {
81
- const direct = path.resolve(metaDir, 'server.ts');
82
- if (fs.existsSync(direct)) {
83
- return direct;
84
- }
85
- }
86
-
87
- // Compiled binary ($bunfs): server is bundled, no external file needed
88
- if (metaDir.includes('$bunfs')) {
89
- return '__compiled__';
90
- }
91
-
92
- throw new Error(
93
- '[browse] Cannot find server.ts. Set BROWSE_SERVER_SCRIPT env var to the path of server.ts.'
94
- );
95
- }
96
-
97
- const SERVER_SCRIPT = resolveServerScript();
98
-
99
- interface ServerState {
100
- pid: number;
101
- port: number;
102
- token: string;
103
- startedAt: string;
104
- serverPath: string;
105
- }
106
-
107
- // ─── State File ────────────────────────────────────────────────
108
- function readState(): ServerState | null {
109
- try {
110
- const data = fs.readFileSync(STATE_FILE, 'utf-8');
111
- return JSON.parse(data);
112
- } catch {
113
- return null;
114
- }
115
- }
116
-
117
- function isProcessAlive(pid: number): boolean {
118
- try {
119
- process.kill(pid, 0);
120
- return true;
121
- } catch {
122
- return false;
123
- }
124
- }
125
-
126
- async function listInstances(): Promise<void> {
127
- try {
128
- const files = fs.readdirSync(LOCAL_DIR).filter(
129
- f => f.startsWith('browse-server') && f.endsWith('.json') && !f.endsWith('.lock')
130
- );
131
- if (files.length === 0) { console.log('(no running instances)'); return; }
132
-
133
- let found = false;
134
- for (const file of files) {
135
- try {
136
- const data = JSON.parse(fs.readFileSync(path.join(LOCAL_DIR, file), 'utf-8'));
137
- if (!data.pid || !data.port) continue;
138
-
139
- const alive = isProcessAlive(data.pid);
140
- let status = 'dead';
141
- let sessions = 0;
142
- if (alive) {
143
- try {
144
- const resp = await fetch(`http://127.0.0.1:${data.port}/health`, { signal: AbortSignal.timeout(1000) });
145
- if (resp.ok) {
146
- const health = await resp.json() as any;
147
- status = health.status === 'healthy' ? 'healthy' : 'unhealthy';
148
- sessions = health.sessions || 0;
149
- }
150
- } catch { status = 'unreachable'; }
151
- }
152
-
153
- // Derive instance name from filename
154
- const match = file.match(/^browse-server-?(.*)\.json$/);
155
- const instance = match?.[1] || 'default';
156
-
157
- console.log(` ${instance.padEnd(15)} PID ${String(data.pid).padEnd(8)} port ${data.port} ${status}${sessions ? ` ${sessions} session(s)` : ''}`);
158
- found = true;
159
-
160
- // Clean up dead entries
161
- if (!alive) {
162
- try { fs.unlinkSync(path.join(LOCAL_DIR, file)); } catch {}
163
- }
164
- } catch {}
165
- }
166
- if (!found) console.log('(no running instances)');
167
- } catch { console.log('(no running instances)'); }
168
- }
169
-
170
- function isBrowseProcess(pid: number): boolean {
171
- try {
172
- const { execSync } = require('child_process');
173
- const cmd = execSync(`ps -p ${pid} -o command=`, { encoding: 'utf-8' }).trim();
174
- return cmd.includes('browse') || cmd.includes('__BROWSE_SERVER_MODE');
175
- } catch {
176
- return false;
177
- }
178
- }
179
-
180
- // ─── Server Lifecycle ──────────────────────────────────────────
181
-
182
- /**
183
- * Acquire a lock file to prevent concurrent server spawns.
184
- * Uses O_EXCL (wx flag) for atomic creation.
185
- * Returns true if lock acquired, false if another process holds it.
186
- */
187
- function acquireLock(): boolean {
188
- try {
189
- fs.writeFileSync(LOCK_FILE, String(process.pid), { flag: 'wx' });
190
- return true;
191
- } catch (err: any) {
192
- if (err.code === 'EEXIST') {
193
- // Check if lock is stale
194
- try {
195
- const stat = fs.statSync(LOCK_FILE);
196
- if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
197
- // Lock is stale — remove and retry
198
- try { fs.unlinkSync(LOCK_FILE); } catch {}
199
- return acquireLock();
200
- }
201
- } catch {}
202
- return false;
203
- }
204
- throw err;
205
- }
206
- }
207
-
208
- function releaseLock() {
209
- try { fs.unlinkSync(LOCK_FILE); } catch {}
210
- }
211
-
212
- async function startServer(): Promise<ServerState> {
213
- // Acquire lock to prevent concurrent spawns
214
- if (!acquireLock()) {
215
- // Another process is starting the server — wait for state file or lock release
216
- const start = Date.now();
217
- while (Date.now() - start < MAX_START_WAIT) {
218
- const state = readState();
219
- if (state && isProcessAlive(state.pid)) {
220
- return state;
221
- }
222
- // If the lock was released (first starter failed), retry acquiring it
223
- // instead of waiting forever for a state file that will never appear.
224
- if (acquireLock()) {
225
- // We now hold the lock — fall through to the spawn logic below
226
- break;
227
- }
228
- await Bun.sleep(100);
229
- }
230
- // If we still don't hold the lock and no state file appeared, give up
231
- if (!fs.existsSync(LOCK_FILE) || fs.readFileSync(LOCK_FILE, 'utf-8').trim() !== String(process.pid)) {
232
- const state = readState();
233
- if (state && isProcessAlive(state.pid)) return state;
234
- throw new Error('Server failed to start (another process is starting it)');
235
- }
236
- }
237
-
238
- try {
239
- // Only remove state file if it belongs to a dead process
240
- try {
241
- const oldState = readState();
242
- if (oldState && !isProcessAlive(oldState.pid)) {
243
- fs.unlinkSync(STATE_FILE);
244
- }
245
- } catch {}
246
-
247
- // Start server as detached background process.
248
- // Compiled binary: self-spawn with __BROWSE_SERVER_MODE=1
249
- // Dev mode: spawn bun with server.ts
250
- const spawnCmd = SERVER_SCRIPT === '__compiled__'
251
- ? [process.execPath]
252
- : ['bun', 'run', SERVER_SCRIPT];
253
- const proc = Bun.spawn(spawnCmd, {
254
- stdio: ['ignore', 'pipe', 'pipe'],
255
- env: { ...process.env, __BROWSE_SERVER_MODE: '1', BROWSE_LOCAL_DIR: LOCAL_DIR, BROWSE_INSTANCE, ...(cliFlags.headed ? { BROWSE_HEADED: '1' } : {}) },
256
- });
257
-
258
- // Don't hold the CLI open
259
- proc.unref();
260
-
261
- // Wait for state file to appear
262
- const start = Date.now();
263
- while (Date.now() - start < MAX_START_WAIT) {
264
- const state = readState();
265
- if (state && isProcessAlive(state.pid)) {
266
- return state;
267
- }
268
- await Bun.sleep(100);
269
- }
270
-
271
- // If we get here, server didn't start in time
272
- // Try to read stderr for error message
273
- const stderr = proc.stderr;
274
- if (stderr) {
275
- const reader = stderr.getReader();
276
- const { value } = await reader.read();
277
- if (value) {
278
- const errText = new TextDecoder().decode(value);
279
- throw new Error(`Server failed to start:\n${errText}`);
280
- }
281
- }
282
- throw new Error(`Server failed to start within ${MAX_START_WAIT / 1000}s`);
283
- } finally {
284
- releaseLock();
285
- }
286
- }
287
-
288
- async function ensureServer(): Promise<ServerState> {
289
- const state = readState();
290
-
291
- if (state && isProcessAlive(state.pid)) {
292
- // Server appears alive — do a health check
293
- try {
294
- const resp = await fetch(`http://127.0.0.1:${state.port}/health`, {
295
- signal: AbortSignal.timeout(DEFAULTS.HEALTH_CHECK_TIMEOUT_MS),
296
- });
297
- if (resp.ok) {
298
- const health = await resp.json() as any;
299
- if (health.status === 'healthy') {
300
- return state;
301
- }
302
- }
303
- } catch {
304
- // Health check failed — server is dead or unhealthy
305
- }
306
-
307
- // Server is alive but unhealthy (shutting down, browser crashed).
308
- // Kill it so we can start fresh — but only if it's actually a browse process.
309
- if (isBrowseProcess(state.pid)) {
310
- try { process.kill(state.pid, 'SIGTERM'); } catch {}
311
- // Brief wait for graceful exit
312
- const deadline = Date.now() + 3000;
313
- while (Date.now() < deadline && isProcessAlive(state.pid)) {
314
- await Bun.sleep(100);
315
- }
316
- if (isProcessAlive(state.pid)) {
317
- try { process.kill(state.pid, 'SIGKILL'); } catch {}
318
- await Bun.sleep(200);
319
- }
320
- }
321
- }
322
-
323
- // Clean up stale state file
324
- if (state) {
325
- try { fs.unlinkSync(STATE_FILE); } catch {}
326
- }
327
-
328
- // Clean up orphaned state files from other instances (e.g., old PPID-suffixed files)
329
- cleanOrphanedServers();
330
-
331
- // Need to (re)start
332
- console.error('[browse] Starting server...');
333
- return startServer();
334
- }
335
-
336
- /**
337
- * Clean up orphaned browse server state files.
338
- * Removes any browse-server*.json whose PID is dead.
339
- * Kills live orphans (legacy PPID-suffixed files from pre-v0.2.4) if they're browse processes.
340
- * Preserves intentional BROWSE_PORT instances (suffix matches port inside the file).
341
- */
342
- function cleanOrphanedServers(): void {
343
- try {
344
- const files = fs.readdirSync(LOCAL_DIR);
345
- for (const file of files) {
346
- if (!file.startsWith('browse-server') || !file.endsWith('.json') || file.endsWith('.lock')) continue;
347
- const filePath = path.join(LOCAL_DIR, file);
348
- if (filePath === STATE_FILE) continue;
349
- try {
350
- const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
351
- if (!data.pid) { fs.unlinkSync(filePath); continue; }
352
- // Preserve intentional BROWSE_PORT instances (suffix = port number)
353
- const suffixMatch = file.match(/browse-server-(\d+)\.json$/);
354
- if (suffixMatch && data.port === parseInt(suffixMatch[1], 10) && isProcessAlive(data.pid)) continue;
355
- // Dead process → remove state file
356
- if (!isProcessAlive(data.pid)) { fs.unlinkSync(filePath); continue; }
357
- // Live orphan (legacy PPID file) → kill if it's a browse process
358
- if (isBrowseProcess(data.pid)) {
359
- try { process.kill(data.pid, 'SIGTERM'); } catch {}
360
- }
361
- } catch { try { fs.unlinkSync(filePath); } catch {} }
362
- }
363
- } catch {}
364
- }
365
-
366
- // ─── Command Dispatch ──────────────────────────────────────────
367
-
368
- // Commands that are safe to retry after a transport failure.
369
- // Write commands (click, fill, goto, etc.) may have already executed
370
- // before the connection dropped — retrying them could duplicate side effects.
371
- // NOTE: 'js' and 'eval' excluded — page.evaluate() can run arbitrary side effects
372
- // NOTE: 'storage' excluded — 'storage set' mutates localStorage
373
- export const SAFE_TO_RETRY = new Set([
374
- // Read commands — no side effects
375
- 'text', 'html', 'links', 'forms', 'accessibility',
376
- 'css', 'attrs', 'element-state', 'dialog',
377
- 'console', 'network', 'cookies', 'perf', 'value', 'count',
378
- // Meta commands that are read-only or idempotent
379
- 'tabs', 'status', 'url', 'snapshot', 'snapshot-diff', 'devices', 'sessions', 'frame', 'find',
380
- ]);
381
-
382
- // Commands that return static data independent of page state.
383
- // Safe to retry even after a server restart (no "blank page" issue).
384
- const PAGE_INDEPENDENT = new Set(['devices', 'status']);
385
-
386
- async function sendCommand(state: ServerState, command: string, args: string[], retries = 0, sessionId?: string): Promise<void> {
387
- const body = JSON.stringify({ command, args });
388
-
389
- const headers: Record<string, string> = {
390
- 'Content-Type': 'application/json',
391
- 'Authorization': `Bearer ${state.token}`,
392
- };
393
- if (sessionId) {
394
- headers['X-Browse-Session'] = sessionId;
395
- }
396
- if (cliFlags.json) {
397
- headers['X-Browse-Json'] = '1';
398
- }
399
- if (cliFlags.contentBoundaries) {
400
- headers['X-Browse-Boundaries'] = '1';
401
- }
402
- if (cliFlags.allowedDomains) {
403
- headers['X-Browse-Allowed-Domains'] = cliFlags.allowedDomains;
404
- }
405
-
406
- try {
407
- const resp = await fetch(`http://127.0.0.1:${state.port}/command`, {
408
- method: 'POST',
409
- headers,
410
- body,
411
- signal: AbortSignal.timeout(30000),
412
- });
413
-
414
- if (resp.status === 401) {
415
- // Token mismatch — server may have restarted
416
- console.error('[browse] Auth failed — server may have restarted. Retrying...');
417
- const newState = readState();
418
- if (newState && newState.token !== state.token) {
419
- return sendCommand(newState, command, args, 0, sessionId);
420
- }
421
- throw new Error('Authentication failed');
422
- }
423
-
424
- const text = await resp.text();
425
-
426
- if (resp.ok) {
427
- process.stdout.write(text);
428
- if (!text.endsWith('\n')) process.stdout.write('\n');
429
-
430
- // After stop/restart, wait for old server to actually die
431
- if (command === 'stop' || command === 'restart') {
432
- const oldPid = state.pid;
433
- const deadline = Date.now() + 5000;
434
- while (Date.now() < deadline && isProcessAlive(oldPid)) {
435
- await Bun.sleep(100);
436
- }
437
- if (isProcessAlive(oldPid)) {
438
- try { process.kill(oldPid, 'SIGKILL'); } catch {}
439
- await Bun.sleep(300);
440
- }
441
- // Clean up state file
442
- try { fs.unlinkSync(STATE_FILE); } catch {}
443
-
444
- if (command === 'restart') {
445
- const newState = await startServer();
446
- console.error(`[browse] Server restarted (PID: ${newState.pid})`);
447
- }
448
- }
449
- } else {
450
- // Try to parse as JSON error
451
- try {
452
- const err = JSON.parse(text);
453
- console.error(err.error || text);
454
- if (err.hint) console.error(err.hint);
455
- } catch {
456
- console.error(text);
457
- }
458
- process.exit(1);
459
- }
460
- } catch (err: any) {
461
- if (err.name === 'AbortError') {
462
- console.error('[browse] Command timed out after 30s');
463
- process.exit(1);
464
- }
465
- // Connection error — server may have crashed
466
- if (err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.message?.includes('fetch failed')) {
467
- if (retries >= 1) throw new Error('[browse] Server crashed twice in a row — aborting');
468
-
469
- // ECONNREFUSED = server not listening, command never executed → safe to retry anything.
470
- // ECONNRESET/fetch failed = connection dropped mid-request, command may have executed.
471
- // Only retry read-only commands to avoid duplicating side effects (e.g., form submits).
472
- if (err.code !== 'ECONNREFUSED' && !SAFE_TO_RETRY.has(command)) {
473
- throw new Error(
474
- `[browse] Connection lost during '${command}'. ` +
475
- `The action may have already executed — not retrying to avoid duplicating side effects.`
476
- );
477
- }
478
-
479
- console.error('[browse] Server connection lost. Restarting...');
480
- const newState = await startServer();
481
-
482
- // After a restart the new server has a fresh browser (blank page).
483
- // Read-only commands would silently return data from that blank page,
484
- // which is worse than an error. Only retry navigation commands that
485
- // will establish session state on the new server.
486
- // Exception: page-independent commands (devices, status) return static
487
- // data that doesn't depend on page state — safe to retry on blank page.
488
- if (SAFE_TO_RETRY.has(command) && !PAGE_INDEPENDENT.has(command)) {
489
- throw new Error(
490
- `[browse] Server restarted but '${command}' would return data from a blank page. ` +
491
- `Re-navigate with 'goto' first, then retry.`
492
- );
493
- }
494
-
495
- return sendCommand(newState, command, args, retries + 1, sessionId);
496
- }
497
- throw err;
498
- }
499
- }
500
-
501
- // ─── Main ──────────────────────────────────────────────────────
502
- export async function main() {
503
- const args = process.argv.slice(2);
504
-
505
- // Load project config (browse.json) — values serve as defaults
506
- const config = loadConfig();
507
-
508
- // Find the first non-flag arg (the command) to limit global flag scanning.
509
- // Only extract global flags from args BEFORE the command position.
510
- function findCommandIndex(a: string[]): number {
511
- for (let i = 0; i < a.length; i++) {
512
- if (!a[i].startsWith('-')) return i;
513
- // Skip flag values for known value-flags
514
- if (a[i] === '--session' || a[i] === '--allowed-domains') i++;
515
- }
516
- return a.length;
517
- }
518
-
519
- // Extract --session flag (only before command)
520
- let sessionId: string | undefined;
521
- const sessionIdx = args.indexOf('--session');
522
- if (sessionIdx !== -1 && sessionIdx < findCommandIndex(args)) {
523
- sessionId = args[sessionIdx + 1];
524
- if (!sessionId || sessionId.startsWith('-')) {
525
- console.error('Usage: browse --session <id> <command> [args...]');
526
- process.exit(1);
527
- }
528
- args.splice(sessionIdx, 2);
529
- }
530
- sessionId = sessionId || process.env.BROWSE_SESSION || config.session || undefined;
531
-
532
- // Extract --json flag (only before command)
533
- let jsonMode = false;
534
- const jsonIdx = args.indexOf('--json');
535
- if (jsonIdx !== -1 && jsonIdx < findCommandIndex(args)) {
536
- jsonMode = true;
537
- args.splice(jsonIdx, 1);
538
- }
539
- jsonMode = jsonMode || process.env.BROWSE_JSON === '1' || config.json === true;
540
-
541
- // Extract --content-boundaries flag (only before command)
542
- let contentBoundaries = false;
543
- const boundIdx = args.indexOf('--content-boundaries');
544
- if (boundIdx !== -1 && boundIdx < findCommandIndex(args)) {
545
- contentBoundaries = true;
546
- args.splice(boundIdx, 1);
547
- }
548
- contentBoundaries = contentBoundaries || process.env.BROWSE_CONTENT_BOUNDARIES === '1' || config.contentBoundaries === true;
549
-
550
- // Extract --allowed-domains flag (only before command)
551
- let allowedDomains: string | undefined;
552
- const domIdx = args.indexOf('--allowed-domains');
553
- if (domIdx !== -1 && domIdx < findCommandIndex(args)) {
554
- allowedDomains = args[domIdx + 1];
555
- if (!allowedDomains || allowedDomains.startsWith('-')) {
556
- console.error('Usage: browse --allowed-domains domain1,domain2 <command> [args...]');
557
- process.exit(1);
558
- }
559
- args.splice(domIdx, 2);
560
- }
561
- allowedDomains = allowedDomains || process.env.BROWSE_ALLOWED_DOMAINS || (config.allowedDomains ? config.allowedDomains.join(',') : undefined);
562
-
563
- // Extract --headed flag (only before command)
564
- let headed = false;
565
- const headedIdx = args.indexOf('--headed');
566
- if (headedIdx !== -1 && headedIdx < findCommandIndex(args)) {
567
- headed = true;
568
- args.splice(headedIdx, 1);
569
- }
570
- headed = headed || process.env.BROWSE_HEADED === '1';
571
-
572
- // Set global flags for sendCommand()
573
- cliFlags.json = jsonMode;
574
- cliFlags.contentBoundaries = contentBoundaries;
575
- cliFlags.allowedDomains = allowedDomains || '';
576
- cliFlags.headed = headed;
577
-
578
- // ─── Local commands (no server needed) ─────────────────────
579
- if (args[0] === 'version' || args[0] === '--version' || args[0] === '-V') {
580
- const pkg = await import('../package.json');
581
- console.log(pkg.version);
582
- return;
583
- }
584
-
585
- if (args[0] === 'instances') {
586
- await listInstances();
587
- return;
588
- }
589
-
590
- if (args[0] === 'install-skill') {
591
- const { installSkill } = await import('./install-skill');
592
- installSkill(args[1]);
593
- return;
594
- }
595
-
596
- if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
597
- console.log(`browse — Fast headless browser for AI coding agents
598
-
599
- Usage: browse [options] <command> [args...]
600
-
601
- Navigation: goto <url> | back | forward | reload | url
602
- Content: text | html [sel] | links | forms | accessibility
603
- Interaction: click <sel> | fill <sel> <val> | select <sel> <val>
604
- hover <sel> | dblclick <sel> | focus <sel>
605
- check <sel> | uncheck <sel> | drag <src> <tgt>
606
- type <text> | press <key> | keydown <key> | keyup <key>
607
- scroll [sel|up|down] | wait <sel|--url|--network-idle>
608
- viewport <WxH> | highlight <sel> | download <sel> [path]
609
- Device: emulate <device> | emulate reset | devices [filter]
610
- Inspection: js <expr> | eval <file> | css <sel> <prop> | attrs <sel>
611
- element-state <sel> | console [--clear] | network [--clear]
612
- cookies | storage [set <k> <v>] | perf
613
- value <sel> | count <sel> | clipboard [write <text>]
614
- Visual: screenshot [path] | pdf [path] | responsive [prefix]
615
- Snapshot: snapshot [-i] [-f] [-V] [-c] [-C] [-d N] [-s sel]
616
- Find: find role|text|label|placeholder|testid <query> [name]
617
- Compare: diff <url1> <url2> | screenshot-diff <baseline> [current]
618
- Multi-step: chain (reads JSON from stdin)
619
- Network: offline [on|off] | route <pattern> block|fulfill
620
- Recording: har start | har stop [path]
621
- video start [dir] | video stop | video status
622
- Tabs: tabs | tab <id> | newtab [url] | closetab [id]
623
- Frames: frame <sel> | frame main
624
- Sessions: sessions | session-close <id>
625
- Auth: auth save <name> <url> <user> <pass|--password-stdin>
626
- auth login <name> | auth list | auth delete <name>
627
- State: state save|load|list|show [name]
628
- Debug: inspect (requires BROWSE_DEBUG_PORT)
629
- Server: status | instances | cookie <n>=<v> | header <n>:<v>
630
- useragent <str> | stop | restart
631
- Setup: install-skill [path]
632
-
633
- Options:
634
- --session <id> Named session (isolates tabs, refs, cookies)
635
- --json Wrap output as {success, data, command}
636
- --content-boundaries Wrap page content in nonce-delimited markers
637
- --allowed-domains <d,d> Block navigation/resources outside allowlist
638
- --headed Run browser in headed (visible) mode
639
-
640
- Snapshot flags:
641
- -i Interactive elements only (terse flat list by default)
642
- -f Full — indented tree with props and children (use with -i)
643
- -V Viewport — only elements visible in current viewport
644
- -c Compact — remove empty structural elements
645
- -C Cursor-interactive — detect divs with cursor:pointer,
646
- onclick, tabindex, data-action (missed by ARIA tree)
647
- -d N Limit tree depth to N levels
648
- -s <sel> Scope to CSS selector
649
-
650
- Refs: After 'snapshot', use @e1, @e2... as selectors:
651
- click @e3 | fill @e4 "value" | hover @e1`);
652
- process.exit(0);
653
- }
654
-
655
- const command = args[0];
656
- const commandArgs = args.slice(1);
657
-
658
- // Special case: chain reads from stdin
659
- if (command === 'chain' && commandArgs.length === 0) {
660
- const stdin = await Bun.stdin.text();
661
- commandArgs.push(stdin.trim());
662
- }
663
-
664
- // Special case: auth --password-stdin reads in CLI before sending to server
665
- if (command === 'auth' && commandArgs.includes('--password-stdin')) {
666
- const stdinIdx = commandArgs.indexOf('--password-stdin');
667
- const password = (await Bun.stdin.text()).trim();
668
- commandArgs.splice(stdinIdx, 1, password);
669
- }
670
-
671
- const state = await ensureServer();
672
- await sendCommand(state, command, commandArgs, 0, sessionId);
673
- }
674
-
675
- if (process.env.__BROWSE_SERVER_MODE === '1') {
676
- import('./server');
677
- } else if (import.meta.main) {
678
- // Direct execution: bun run src/cli.ts <command>
679
- main().catch((err) => {
680
- console.error(`[browse] ${err.message}`);
681
- process.exit(1);
682
- });
683
- }