@ulpi/browse 0.7.5 → 0.10.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 CHANGED
@@ -20,8 +20,13 @@ const cliFlags = {
20
20
  contentBoundaries: false,
21
21
  allowedDomains: '' as string,
22
22
  headed: false,
23
+ stateFile: '' as string,
24
+ maxOutput: 0,
23
25
  };
24
26
 
27
+ // Track whether --state has been applied (only sent on first command)
28
+ let stateFileApplied = false;
29
+
25
30
  const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10);
26
31
  // One server per project directory by default. Sessions handle agent isolation.
27
32
  // For multiple servers on the same project: set BROWSE_INSTANCE or BROWSE_PORT.
@@ -376,7 +381,8 @@ export const SAFE_TO_RETRY = new Set([
376
381
  'css', 'attrs', 'element-state', 'dialog',
377
382
  'console', 'network', 'cookies', 'perf', 'value', 'count',
378
383
  // Meta commands that are read-only or idempotent
379
- 'tabs', 'status', 'url', 'snapshot', 'snapshot-diff', 'devices', 'sessions', 'frame', 'find',
384
+ 'tabs', 'status', 'url', 'snapshot', 'snapshot-diff', 'devices', 'sessions', 'frame', 'find', 'record', 'cookie-import',
385
+ 'box', 'errors', 'doctor', 'upgrade',
380
386
  ]);
381
387
 
382
388
  // Commands that return static data independent of page state.
@@ -402,6 +408,13 @@ async function sendCommand(state: ServerState, command: string, args: string[],
402
408
  if (cliFlags.allowedDomains) {
403
409
  headers['X-Browse-Allowed-Domains'] = cliFlags.allowedDomains;
404
410
  }
411
+ if (cliFlags.stateFile && !stateFileApplied) {
412
+ headers['X-Browse-State'] = cliFlags.stateFile;
413
+ stateFileApplied = true;
414
+ }
415
+ if (cliFlags.maxOutput > 0) {
416
+ headers['X-Browse-Max-Output'] = String(cliFlags.maxOutput);
417
+ }
405
418
 
406
419
  try {
407
420
  const resp = await fetch(`http://127.0.0.1:${state.port}/command`, {
@@ -511,7 +524,7 @@ export async function main() {
511
524
  for (let i = 0; i < a.length; i++) {
512
525
  if (!a[i].startsWith('-')) return i;
513
526
  // Skip flag values for known value-flags
514
- if (a[i] === '--session' || a[i] === '--allowed-domains') i++;
527
+ if (a[i] === '--session' || a[i] === '--allowed-domains' || a[i] === '--cdp' || a[i] === '--state') i++;
515
528
  }
516
529
  return a.length;
517
530
  }
@@ -569,11 +582,79 @@ export async function main() {
569
582
  }
570
583
  headed = headed || process.env.BROWSE_HEADED === '1';
571
584
 
585
+ // Extract --connect flag (only before command)
586
+ let connectFlag = false;
587
+ const connectIdx = args.indexOf('--connect');
588
+ if (connectIdx !== -1 && connectIdx < findCommandIndex(args)) {
589
+ connectFlag = true;
590
+ args.splice(connectIdx, 1);
591
+ }
592
+
593
+ // Extract --cdp <port> flag (only before command)
594
+ let cdpPort: number | undefined;
595
+ const cdpIdx = args.indexOf('--cdp');
596
+ if (cdpIdx !== -1 && cdpIdx < findCommandIndex(args)) {
597
+ const portStr = args[cdpIdx + 1];
598
+ if (!portStr || portStr.startsWith('-')) {
599
+ console.error('Usage: browse --cdp <port> <command> [args...]');
600
+ process.exit(1);
601
+ }
602
+ cdpPort = parseInt(portStr, 10);
603
+ if (!Number.isFinite(cdpPort) || cdpPort <= 0) {
604
+ console.error(`Invalid CDP port: ${portStr}`);
605
+ process.exit(1);
606
+ }
607
+ args.splice(cdpIdx, 2);
608
+ }
609
+
610
+ // Extract --state <path> flag (only before command)
611
+ let stateFile = '';
612
+ const stateIdx = args.indexOf('--state');
613
+ if (stateIdx !== -1 && stateIdx < findCommandIndex(args)) {
614
+ stateFile = args[stateIdx + 1] || '';
615
+ if (!stateFile || stateFile.startsWith('-')) {
616
+ console.error('Usage: browse --state <path> <command> [args...]');
617
+ process.exit(1);
618
+ }
619
+ args.splice(stateIdx, 2);
620
+ }
621
+
622
+ // Handle --connect / --cdp: blocked by Bun WebSocket bug (oven-sh/bun#9911)
623
+ // Bun's compiled binary sends "Connection: keep-alive" instead of "Connection: Upgrade",
624
+ // breaking the WebSocket handshake required by Playwright's connectOverCDP().
625
+ // This affects all CDP-based connections (--connect, --cdp, lightpanda).
626
+ // The discovery and flag logic is ready — enable when Bun fixes the bug.
627
+ if (connectFlag || cdpPort) {
628
+ console.error(
629
+ '--connect/--cdp are not yet supported in the compiled binary.\n' +
630
+ 'Bun\'s WebSocket client breaks the CDP handshake (oven-sh/bun#9911).\n' +
631
+ 'Workaround: use cookie-import to borrow auth from your browser instead:\n' +
632
+ ' browse cookie-import chrome --domain <your-domain>'
633
+ );
634
+ process.exit(1);
635
+ }
636
+
637
+ // Extract --max-output <n> flag (only before command)
638
+ let maxOutput = 0;
639
+ const maxOutputIdx = args.indexOf('--max-output');
640
+ if (maxOutputIdx !== -1 && maxOutputIdx < findCommandIndex(args)) {
641
+ const val = args[maxOutputIdx + 1];
642
+ if (!val || val.startsWith('-')) {
643
+ console.error('Usage: browse --max-output <chars> <command> [args...]');
644
+ process.exit(1);
645
+ }
646
+ maxOutput = parseInt(val, 10);
647
+ args.splice(maxOutputIdx, 2);
648
+ }
649
+ maxOutput = maxOutput || parseInt(process.env.BROWSE_MAX_OUTPUT || '0', 10) || 0;
650
+
572
651
  // Set global flags for sendCommand()
573
652
  cliFlags.json = jsonMode;
574
653
  cliFlags.contentBoundaries = contentBoundaries;
575
654
  cliFlags.allowedDomains = allowedDomains || '';
576
655
  cliFlags.headed = headed;
656
+ cliFlags.stateFile = stateFile;
657
+ cliFlags.maxOutput = maxOutput;
577
658
 
578
659
  // ─── Local commands (no server needed) ─────────────────────
579
660
  if (args[0] === 'version' || args[0] === '--version' || args[0] === '-V') {
@@ -600,34 +681,49 @@ Usage: browse [options] <command> [args...]
600
681
 
601
682
  Navigation: goto <url> | back | forward | reload | url
602
683
  Content: text | html [sel] | links | forms | accessibility
603
- Interaction: click <sel> | fill <sel> <val> | select <sel> <val>
604
- hover <sel> | dblclick <sel> | focus <sel>
684
+ Interaction: click <sel> | rightclick <sel> | dblclick <sel>
685
+ fill <sel> <val> | select <sel> <val>
686
+ hover <sel> | focus <sel> | tap <sel>
605
687
  check <sel> | uncheck <sel> | drag <src> <tgt>
606
688
  type <text> | press <key> | keydown <key> | keyup <key>
607
- scroll [sel|up|down] | wait <sel|--url|--network-idle>
689
+ keyboard inserttext <text>
690
+ scroll [sel|up|down] | scrollinto <sel>
691
+ swipe <up|down|left|right> [px]
692
+ wait <sel|ms|--url|--text|--fn|--load|--network-idle>
608
693
  viewport <WxH> | highlight <sel> | download <sel> [path]
694
+ Mouse: mouse move <x> <y> | mouse down [btn] | mouse up [btn]
695
+ mouse wheel <dy> [dx]
696
+ Settings: set geo <lat> <lng> | set media <dark|light|no-preference>
609
697
  Device: emulate <device> | emulate reset | devices [filter]
610
698
  Inspection: js <expr> | eval <file> | css <sel> <prop> | attrs <sel>
611
- element-state <sel> | console [--clear] | network [--clear]
699
+ element-state <sel> | box <sel>
700
+ console [--clear] | errors [--clear] | network [--clear]
612
701
  cookies | storage [set <k> <v>] | perf
613
702
  value <sel> | count <sel> | clipboard [write <text>]
614
- Visual: screenshot [path] | pdf [path] | responsive [prefix]
703
+ Visual: screenshot [sel|@ref] [path] [--full] [--clip x,y,w,h]
704
+ screenshot --annotate | pdf [path] | responsive [prefix]
615
705
  Snapshot: snapshot [-i] [-f] [-V] [-c] [-C] [-d N] [-s sel]
616
- Find: find role|text|label|placeholder|testid <query> [name]
706
+ Find: find role|text|label|placeholder|testid|alt|title <query>
707
+ find first|last <sel> | find nth <n> <sel>
617
708
  Compare: diff <url1> <url2> | screenshot-diff <baseline> [current]
618
709
  Multi-step: chain (reads JSON from stdin)
710
+ Cookies: cookie <n>=<v> | cookie set <n> <v> [--domain --secure]
711
+ cookie clear | cookie export <file> | cookie import <file>
619
712
  Network: offline [on|off] | route <pattern> block|fulfill
713
+ header <n>:<v> | useragent <str>
620
714
  Recording: har start | har stop [path]
621
715
  video start [dir] | video stop | video status
716
+ record start | record stop | record status
717
+ record export browse|replay [path]
622
718
  Tabs: tabs | tab <id> | newtab [url] | closetab [id]
623
719
  Frames: frame <sel> | frame main
624
720
  Sessions: sessions | session-close <id>
625
721
  Auth: auth save <name> <url> <user> <pass|--password-stdin>
626
722
  auth login <name> | auth list | auth delete <name>
723
+ cookie-import --list | cookie-import <browser> [--domain <d>] [--profile <p>]
627
724
  State: state save|load|list|show [name]
628
725
  Debug: inspect (requires BROWSE_DEBUG_PORT)
629
- Server: status | instances | cookie <n>=<v> | header <n>:<v>
630
- useragent <str> | stop | restart
726
+ Server: status | instances | stop | restart | doctor | upgrade
631
727
  Setup: install-skill [path]
632
728
 
633
729
  Options:
@@ -636,6 +732,10 @@ Options:
636
732
  --content-boundaries Wrap page content in nonce-delimited markers
637
733
  --allowed-domains <d,d> Block navigation/resources outside allowlist
638
734
  --headed Run browser in headed (visible) mode
735
+ --max-output <n> Truncate output to N characters
736
+ --state <path> Load state file (cookies/storage) before first command
737
+ --connect Auto-discover and connect to running Chrome
738
+ --cdp <port> Connect to Chrome on specific debugging port
639
739
 
640
740
  Snapshot flags:
641
741
  -i Interactive elements only (terse flat list by default)
@@ -131,8 +131,8 @@ export async function handleMetaCommand(
131
131
  // ─── State Persistence ───────────────────────────────
132
132
  case 'state': {
133
133
  const subcommand = args[0];
134
- if (!subcommand || !['save', 'load', 'list', 'show'].includes(subcommand)) {
135
- throw new Error('Usage: browse state save|load|list|show [name]');
134
+ if (!subcommand || !['save', 'load', 'list', 'show', 'clean'].includes(subcommand)) {
135
+ throw new Error('Usage: browse state save|load|list|show|clean [name] [--older-than N]');
136
136
  }
137
137
  const name = sanitizeName(args[1] || 'default');
138
138
  const statesDir = `${LOCAL_DIR}/states`;
@@ -167,6 +167,21 @@ export async function handleMetaCommand(
167
167
  ].join('\n');
168
168
  }
169
169
 
170
+ if (subcommand === 'clean') {
171
+ const olderThanIdx = args.indexOf('--older-than');
172
+ const maxDays = olderThanIdx !== -1 && args[olderThanIdx + 1]
173
+ ? parseInt(args[olderThanIdx + 1], 10)
174
+ : 7;
175
+ if (isNaN(maxDays) || maxDays < 1) {
176
+ throw new Error('--older-than must be a positive number of days');
177
+ }
178
+ const { cleanOldStates } = await import('../session-persist');
179
+ const { deleted } = cleanOldStates(LOCAL_DIR, maxDays);
180
+ return deleted > 0
181
+ ? `Deleted ${deleted} state file(s) older than ${maxDays} days`
182
+ : `No state files older than ${maxDays} days`;
183
+ }
184
+
170
185
  if (subcommand === 'save') {
171
186
  const context = bm.getContext();
172
187
  if (!context) throw new Error('No browser context');
@@ -219,8 +234,31 @@ export async function handleMetaCommand(
219
234
  const page = bm.getPage();
220
235
  const annotate = args.includes('--annotate');
221
236
  const isFullPage = args.includes('--full');
222
- const filteredArgs = args.filter(a => a !== '--annotate' && a !== '--full');
223
- const screenshotPath = filteredArgs[0] || (currentSession ? `${currentSession.outputDir}/screenshot.png` : `${LOCAL_DIR}/browse-screenshot.png`);
237
+ const clipIdx = args.indexOf('--clip');
238
+ const clipArg = clipIdx >= 0 ? args[clipIdx + 1] : null;
239
+ const filteredArgs = args.filter((a, i) => a !== '--annotate' && a !== '--full' && a !== '--clip' && (clipIdx < 0 || i !== clipIdx + 1));
240
+
241
+ // Parse --clip x,y,w,h
242
+ let clip: { x: number; y: number; width: number; height: number } | undefined;
243
+ if (clipArg) {
244
+ const parts = clipArg.split(',').map(Number);
245
+ if (parts.length !== 4 || parts.some(isNaN)) throw new Error('Usage: browse screenshot --clip x,y,width,height [path]');
246
+ clip = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
247
+ if (isFullPage) throw new Error('Cannot use --clip with --full');
248
+ }
249
+
250
+ // Detect element/ref selector vs output path
251
+ // Selector: starts with @e, ., #, [ — Path: contains / or ends with image extension
252
+ let elementSelector: string | null = null;
253
+ let screenshotPath: string;
254
+ const firstArg = filteredArgs[0];
255
+ if (firstArg && (firstArg.startsWith('@e') || firstArg.startsWith('@c') || /^[.#\[]/.test(firstArg))) {
256
+ if (clip) throw new Error('Cannot use --clip with element selector');
257
+ elementSelector = firstArg;
258
+ screenshotPath = filteredArgs[1] || (currentSession ? `${currentSession.outputDir}/screenshot.png` : `${LOCAL_DIR}/browse-screenshot.png`);
259
+ } else {
260
+ screenshotPath = firstArg || (currentSession ? `${currentSession.outputDir}/screenshot.png` : `${LOCAL_DIR}/browse-screenshot.png`);
261
+ }
224
262
 
225
263
  if (annotate) {
226
264
  const viewport = page.viewportSize() || { width: 1920, height: 1080 };
@@ -289,7 +327,14 @@ export async function handleMetaCommand(
289
327
  return `Screenshot saved: ${screenshotPath}\n\nLegend:\n${legend.join('\n')}`;
290
328
  }
291
329
 
292
- await page.screenshot({ path: screenshotPath, fullPage: isFullPage });
330
+ if (elementSelector) {
331
+ const resolved = bm.resolveRef(elementSelector);
332
+ const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector);
333
+ await locator.screenshot({ path: screenshotPath });
334
+ return `Screenshot saved: ${screenshotPath} (element: ${elementSelector})`;
335
+ }
336
+
337
+ await page.screenshot({ path: screenshotPath, fullPage: isFullPage, clip });
293
338
  return `Screenshot saved: ${screenshotPath}`;
294
339
  }
295
340
 
@@ -346,8 +391,8 @@ export async function handleMetaCommand(
346
391
  const { handleWriteCommand } = await import('./write');
347
392
  const { PolicyChecker } = await import('../policy');
348
393
 
349
- const WRITE_SET = new Set(['goto','back','forward','reload','click','dblclick','fill','select','hover','focus','check','uncheck','type','press','scroll','wait','viewport','cookie','header','useragent','upload','dialog-accept','dialog-dismiss','emulate','drag','keydown','keyup','highlight','download','route','offline']);
350
- const READ_SET = new Set(['text','html','links','forms','accessibility','js','eval','css','attrs','element-state','dialog','console','network','cookies','storage','perf','devices','value','count','clipboard']);
394
+ const WRITE_SET = new Set(['goto','back','forward','reload','click','dblclick','fill','select','hover','focus','check','uncheck','type','press','scroll','wait','viewport','cookie','header','useragent','upload','dialog-accept','dialog-dismiss','emulate','drag','keydown','keyup','highlight','download','route','offline','rightclick','tap','swipe','mouse','keyboard','scrollinto','scrollintoview','set']);
395
+ const READ_SET = new Set(['text','html','links','forms','accessibility','js','eval','css','attrs','element-state','dialog','console','network','cookies','storage','perf','devices','value','count','clipboard','box','errors']);
351
396
 
352
397
  const sessionBuffers = currentSession?.buffers;
353
398
  const policy = new PolicyChecker();
@@ -592,6 +637,70 @@ export async function handleMetaCommand(
592
637
  }
593
638
  }
594
639
 
640
+ // ─── Cookie Import ──────────────────────────────────
641
+ case 'cookie-import': {
642
+ const { findInstalledBrowsers, importCookies, CookieImportError } = await import('../cookie-import');
643
+
644
+ // --list: show installed browsers
645
+ if (args.includes('--list')) {
646
+ const browsers = findInstalledBrowsers();
647
+ if (browsers.length === 0) return 'No supported Chromium browsers found';
648
+ return 'Installed browsers:\n' + browsers.map(b => ` ${b.name}`).join('\n');
649
+ }
650
+
651
+ const browserName = args[0];
652
+ if (!browserName) {
653
+ throw new Error(
654
+ 'Usage: browse cookie-import --list\n' +
655
+ ' browse cookie-import <browser> [--domain <d>] [--profile <p>]\n' +
656
+ 'Supported browsers: chrome, arc, brave, edge'
657
+ );
658
+ }
659
+
660
+ // Parse --domain and --profile flags
661
+ const domains: string[] = [];
662
+ let profile: string | undefined;
663
+ for (let i = 1; i < args.length; i++) {
664
+ if (args[i] === '--domain' && args[i + 1]) { domains.push(args[++i]); }
665
+ else if (args[i] === '--profile' && args[i + 1]) { profile = args[++i]; }
666
+ }
667
+
668
+ try {
669
+ // If no domains specified, import all by listing domains first then importing all
670
+ if (domains.length === 0) {
671
+ const { listDomains } = await import('../cookie-import');
672
+ const { domains: allDomains, browser } = listDomains(browserName, profile);
673
+ if (allDomains.length === 0) return `No cookies found in ${browser}`;
674
+ const allDomainNames = allDomains.map(d => d.domain);
675
+ const result = await importCookies(browserName, allDomainNames, profile);
676
+ const context = bm.getContext();
677
+ if (!context) throw new Error('No browser context');
678
+ if (result.cookies.length > 0) await context.addCookies(result.cookies);
679
+ const domainCount = Object.keys(result.domainCounts).length;
680
+ const failedNote = result.failed > 0 ? ` (${result.failed} failed to decrypt)` : '';
681
+ return `Imported ${result.count} cookies from ${browser} across ${domainCount} domains${failedNote}`;
682
+ }
683
+
684
+ const result = await importCookies(browserName, domains, profile);
685
+ const context = bm.getContext();
686
+ if (!context) throw new Error('No browser context');
687
+ if (result.cookies.length > 0) await context.addCookies(result.cookies);
688
+ const domainLabel = domains.length === 1 ? `for ${domains[0]} ` : '';
689
+ const failedNote = result.failed > 0 ? ` (${result.failed} failed to decrypt)` : '';
690
+ // Resolve display name from the result's domain counts keys or use arg
691
+ const browserDisplay = Object.keys(result.domainCounts).length > 0
692
+ ? browserName.charAt(0).toUpperCase() + browserName.slice(1)
693
+ : browserName;
694
+ return `Imported ${result.count} cookies ${domainLabel}from ${browserDisplay}${failedNote}`;
695
+ } catch (err) {
696
+ if (err instanceof CookieImportError) {
697
+ const hint = err.action === 'retry' ? ' (retry may help)' : '';
698
+ throw new Error(err.message + hint);
699
+ }
700
+ throw err;
701
+ }
702
+ }
703
+
595
704
  // ─── HAR Recording ────────────────────────────────
596
705
  case 'har': {
597
706
  const subcommand = args[0];
@@ -652,11 +761,46 @@ export async function handleMetaCommand(
652
761
  throw new Error('Usage: browse video start [dir] | browse video stop | browse video status');
653
762
  }
654
763
 
764
+ // ─── Doctor ────────────────────────────────────────
765
+ case 'doctor': {
766
+ const lines: string[] = [];
767
+ lines.push(`Bun: ${typeof Bun !== 'undefined' ? Bun.version : 'not available'}`);
768
+ try {
769
+ const pw = await import('playwright');
770
+ lines.push(`Playwright: installed`);
771
+ try {
772
+ const chromium = pw.chromium;
773
+ lines.push(`Chromium: ${chromium.executablePath()}`);
774
+ } catch {
775
+ lines.push(`Chromium: NOT FOUND — run "bunx playwright install chromium"`);
776
+ }
777
+ } catch {
778
+ lines.push(`Playwright: NOT INSTALLED — run "bun install playwright"`);
779
+ }
780
+ lines.push(`Server: running (you're connected)`);
781
+ return lines.join('\n');
782
+ }
783
+
784
+ // ─── Upgrade ────────────────────────────────────────
785
+ case 'upgrade': {
786
+ const { execSync } = await import('child_process');
787
+ try {
788
+ const output = execSync('npm update -g @ulpi/browse 2>&1', { encoding: 'utf-8', timeout: 30000 });
789
+ return `Upgrade complete.\n${output.trim()}`;
790
+ } catch (err: any) {
791
+ if (err.message?.includes('EACCES') || err.message?.includes('permission')) {
792
+ return `Permission denied. Try: sudo npm update -g @ulpi/browse`;
793
+ }
794
+ return `Upgrade failed: ${err.message}\nManual: npm install -g @ulpi/browse`;
795
+ }
796
+ }
797
+
655
798
  // ─── Semantic Locator ──────────────────────────────
656
799
  case 'find': {
657
800
  const root = bm.getLocatorRoot();
801
+ const page = bm.getPage();
658
802
  const sub = args[0];
659
- if (!sub) throw new Error('Usage: browse find role|text|label|placeholder|testid <query> [name]');
803
+ if (!sub) throw new Error('Usage: browse find role|text|label|placeholder|testid|alt|title|first|last|nth <query>');
660
804
  const query = args[1];
661
805
  if (!query) throw new Error(`Usage: browse find ${sub} <query>`);
662
806
 
@@ -679,8 +823,35 @@ export async function handleMetaCommand(
679
823
  case 'testid':
680
824
  locator = root.getByTestId(query);
681
825
  break;
826
+ case 'alt':
827
+ locator = root.getByAltText(query);
828
+ break;
829
+ case 'title':
830
+ locator = root.getByTitle(query);
831
+ break;
832
+ case 'first': {
833
+ locator = page.locator(query).first();
834
+ const text = await locator.textContent({ timeout: 2000 }).catch(() => '') || '';
835
+ const total = await page.locator(query).count();
836
+ return `Found ${total} match(es), first: "${text.trim().slice(0, 100)}"`;
837
+ }
838
+ case 'last': {
839
+ locator = page.locator(query).last();
840
+ const text = await locator.textContent({ timeout: 2000 }).catch(() => '') || '';
841
+ const total = await page.locator(query).count();
842
+ return `Found ${total} match(es), last: "${text.trim().slice(0, 100)}"`;
843
+ }
844
+ case 'nth': {
845
+ const n = parseInt(query, 10);
846
+ const sel = args[2];
847
+ if (isNaN(n) || !sel) throw new Error('Usage: browse find nth <index> <selector>');
848
+ locator = page.locator(sel).nth(n);
849
+ const text = await locator.textContent({ timeout: 2000 }).catch(() => '') || '';
850
+ const total = await page.locator(sel).count();
851
+ return `Found ${total} match(es), nth(${n}): "${text.trim().slice(0, 100)}"`;
852
+ }
682
853
  default:
683
- throw new Error(`Unknown find type: ${sub}. Use role|text|label|placeholder|testid`);
854
+ throw new Error(`Unknown find type: ${sub}. Use role|text|label|placeholder|testid|alt|title|first|last|nth`);
684
855
  }
685
856
 
686
857
  const count = await locator.count();
@@ -742,6 +913,73 @@ export async function handleMetaCommand(
742
913
  }
743
914
  }
744
915
 
916
+ // ─── Record & Export ─────────────────────────────────
917
+ case 'record': {
918
+ const subcommand = args[0];
919
+ if (!subcommand) throw new Error('Usage: browse record start | stop | status | export browse|replay [path]');
920
+
921
+ if (subcommand === 'start') {
922
+ if (!currentSession) throw new Error('Recording requires a session context');
923
+ if (currentSession.recording) throw new Error('Recording already active. Run "browse record stop" first.');
924
+ currentSession.recording = [];
925
+ return 'Recording started';
926
+ }
927
+
928
+ if (subcommand === 'stop') {
929
+ if (!currentSession) throw new Error('Recording requires a session context');
930
+ if (!currentSession.recording) throw new Error('No active recording. Run "browse record start" first.');
931
+ const count = currentSession.recording.length;
932
+ // Store last recording for export after stop
933
+ (currentSession as any)._lastRecording = currentSession.recording;
934
+ currentSession.recording = null;
935
+ return `Recording stopped (${count} steps captured)`;
936
+ }
937
+
938
+ if (subcommand === 'status') {
939
+ if (!currentSession) return 'No session context';
940
+ if (currentSession.recording) {
941
+ return `Recording active — ${currentSession.recording.length} steps captured`;
942
+ }
943
+ const last = (currentSession as any)._lastRecording;
944
+ if (last) return `Recording stopped — ${last.length} steps available for export`;
945
+ return 'No active recording';
946
+ }
947
+
948
+ if (subcommand === 'export') {
949
+ if (!currentSession) throw new Error('Recording requires a session context');
950
+ const format = args[1];
951
+ if (!format) throw new Error('Usage: browse record export browse|replay [path]');
952
+
953
+ // Use active recording or last stopped recording
954
+ const steps = currentSession.recording || (currentSession as any)._lastRecording;
955
+ if (!steps || steps.length === 0) {
956
+ throw new Error('No recording to export. Run "browse record start" first, execute commands, then export.');
957
+ }
958
+
959
+ const { exportBrowse, exportReplay } = await import('../record-export');
960
+
961
+ let output: string;
962
+ if (format === 'browse') {
963
+ output = exportBrowse(steps);
964
+ } else if (format === 'replay') {
965
+ output = exportReplay(steps);
966
+ } else {
967
+ throw new Error(`Unknown format: ${format}. Use "browse" (chain JSON) or "replay" (Playwright/Puppeteer).`);
968
+ }
969
+
970
+ const filePath = args[2];
971
+ if (filePath) {
972
+ fs.writeFileSync(filePath, output);
973
+ return `Exported ${steps.length} steps as ${format}: ${filePath}`;
974
+ }
975
+
976
+ // No path — return the script to stdout
977
+ return output;
978
+ }
979
+
980
+ throw new Error('Usage: browse record start | stop | status | export browse|replay [path]');
981
+ }
982
+
745
983
  default:
746
984
  throw new Error(`Unknown meta command: ${command}`);
747
985
  }
@@ -8,6 +8,7 @@
8
8
  import type { BrowserManager } from '../browser-manager';
9
9
  import { listDevices } from '../browser-manager';
10
10
  import type { SessionBuffers } from '../buffers';
11
+ import { DEFAULTS } from '../constants';
11
12
  import * as fs from 'fs';
12
13
 
13
14
  export async function handleReadCommand(
@@ -331,6 +332,33 @@ export async function handleReadCommand(
331
332
  }
332
333
  }
333
334
 
335
+ case 'box': {
336
+ const selector = args[0];
337
+ if (!selector) throw new Error('Usage: browse box <selector>');
338
+ const resolved = bm.resolveRef(selector);
339
+ const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector);
340
+ const box = await locator.boundingBox({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
341
+ if (!box) throw new Error(`Element ${selector} is not visible or has no bounding box`);
342
+ return JSON.stringify({ x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height) });
343
+ }
344
+
345
+ case 'errors': {
346
+ const cb = (buffers || bm.getBuffers()).consoleBuffer;
347
+ if (args[0] === '--clear') {
348
+ const before = cb.length;
349
+ // Remove error entries in-place
350
+ for (let i = cb.length - 1; i >= 0; i--) {
351
+ if (cb[i].level === 'error') cb.splice(i, 1);
352
+ }
353
+ return `Cleared ${before - cb.length} error(s).`;
354
+ }
355
+ const errors = cb.filter(e => e.level === 'error');
356
+ if (errors.length === 0) return '(no errors)';
357
+ return errors.map(e =>
358
+ `[${new Date(e.timestamp).toISOString()}] ${e.text}`
359
+ ).join('\n');
360
+ }
361
+
334
362
  case 'devices': {
335
363
  const filter = args.join(' ').toLowerCase();
336
364
  const all = listDevices();