@jackwener/opencli 1.7.5 → 1.7.6

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 (69) hide show
  1. package/README.md +5 -2
  2. package/README.zh-CN.md +5 -2
  3. package/cli-manifest.json +77 -1
  4. package/clis/bilibili/video.js +61 -0
  5. package/clis/bilibili/video.test.js +81 -0
  6. package/clis/deepseek/ask.js +21 -1
  7. package/clis/deepseek/ask.test.js +73 -0
  8. package/clis/deepseek/utils.js +84 -1
  9. package/clis/deepseek/utils.test.js +37 -0
  10. package/clis/jianyu/search.js +139 -3
  11. package/clis/jianyu/search.test.js +25 -0
  12. package/clis/jianyu/shared/procurement-detail.js +15 -0
  13. package/clis/jianyu/shared/procurement-detail.test.js +12 -0
  14. package/clis/twitter/shared.js +7 -2
  15. package/clis/twitter/tweets.js +218 -0
  16. package/clis/twitter/tweets.test.js +125 -0
  17. package/clis/youtube/channel.js +35 -0
  18. package/dist/src/browser/base-page.d.ts +13 -3
  19. package/dist/src/browser/base-page.js +35 -25
  20. package/dist/src/browser/cdp.d.ts +1 -0
  21. package/dist/src/browser/cdp.js +12 -3
  22. package/dist/src/browser/compound.d.ts +59 -0
  23. package/dist/src/browser/compound.js +112 -0
  24. package/dist/src/browser/compound.test.d.ts +1 -0
  25. package/dist/src/browser/compound.test.js +175 -0
  26. package/dist/src/browser/dom-snapshot.d.ts +7 -0
  27. package/dist/src/browser/dom-snapshot.js +76 -3
  28. package/dist/src/browser/dom-snapshot.test.js +65 -0
  29. package/dist/src/browser/extract.d.ts +69 -0
  30. package/dist/src/browser/extract.js +132 -0
  31. package/dist/src/browser/extract.test.d.ts +1 -0
  32. package/dist/src/browser/extract.test.js +129 -0
  33. package/dist/src/browser/find.d.ts +76 -0
  34. package/dist/src/browser/find.js +179 -0
  35. package/dist/src/browser/find.test.d.ts +1 -0
  36. package/dist/src/browser/find.test.js +120 -0
  37. package/dist/src/browser/html-tree.d.ts +75 -0
  38. package/dist/src/browser/html-tree.js +112 -0
  39. package/dist/src/browser/html-tree.test.d.ts +1 -0
  40. package/dist/src/browser/html-tree.test.js +181 -0
  41. package/dist/src/browser/network-cache.d.ts +48 -0
  42. package/dist/src/browser/network-cache.js +66 -0
  43. package/dist/src/browser/network-cache.test.d.ts +1 -0
  44. package/dist/src/browser/network-cache.test.js +58 -0
  45. package/dist/src/browser/network-key.d.ts +22 -0
  46. package/dist/src/browser/network-key.js +66 -0
  47. package/dist/src/browser/network-key.test.d.ts +1 -0
  48. package/dist/src/browser/network-key.test.js +49 -0
  49. package/dist/src/browser/shape-filter.d.ts +52 -0
  50. package/dist/src/browser/shape-filter.js +101 -0
  51. package/dist/src/browser/shape-filter.test.d.ts +1 -0
  52. package/dist/src/browser/shape-filter.test.js +101 -0
  53. package/dist/src/browser/shape.d.ts +23 -0
  54. package/dist/src/browser/shape.js +95 -0
  55. package/dist/src/browser/shape.test.d.ts +1 -0
  56. package/dist/src/browser/shape.test.js +82 -0
  57. package/dist/src/browser/target-errors.d.ts +14 -1
  58. package/dist/src/browser/target-errors.js +13 -0
  59. package/dist/src/browser/target-errors.test.js +39 -6
  60. package/dist/src/browser/target-resolver.d.ts +57 -10
  61. package/dist/src/browser/target-resolver.js +195 -75
  62. package/dist/src/browser/target-resolver.test.js +80 -5
  63. package/dist/src/cli.js +630 -125
  64. package/dist/src/cli.test.js +794 -0
  65. package/dist/src/execution.js +7 -2
  66. package/dist/src/execution.test.js +54 -0
  67. package/dist/src/main.js +16 -0
  68. package/dist/src/types.d.ts +18 -3
  69. package/package.json +1 -1
package/dist/src/cli.js CHANGED
@@ -21,11 +21,74 @@ import { registerAllCommands } from './commanderAdapter.js';
21
21
  import { EXIT_CODES, getErrorMessage, BrowserConnectError } from './errors.js';
22
22
  import { TargetError } from './browser/target-errors.js';
23
23
  import { resolveTargetJs, getTextResolvedJs, getValueResolvedJs, getAttributesResolvedJs, selectResolvedJs, isAutocompleteResolvedJs } from './browser/target-resolver.js';
24
+ import { buildFindJs, isFindError } from './browser/find.js';
25
+ import { inferShape } from './browser/shape.js';
26
+ import { assignKeys } from './browser/network-key.js';
27
+ import { DEFAULT_TTL_MS, findEntry, loadNetworkCache, saveNetworkCache } from './browser/network-cache.js';
28
+ import { parseFilter, shapeMatchesFilter } from './browser/shape-filter.js';
29
+ import { buildHtmlTreeJs } from './browser/html-tree.js';
30
+ import { buildExtractHtmlJs, runExtractFromHtml } from './browser/extract.js';
24
31
  import { daemonStatus, daemonStop } from './commands/daemon.js';
25
32
  import { log } from './logger.js';
26
33
  const CLI_FILE = fileURLToPath(import.meta.url);
27
34
  const DEFAULT_BROWSER_WORKSPACE = 'browser:default';
28
35
  const BROWSER_TAB_OPTION_DESCRIPTION = 'Target tab/page identity returned by "browser open", "browser tab new", or "browser tab list"';
36
+ /**
37
+ * Normalize raw capture entries (from daemon/CDP `readNetworkCapture` or
38
+ * the JS interceptor's `window.__opencli_net`) into a consistent shape.
39
+ * Response preview is parsed as JSON when possible, otherwise kept as string.
40
+ * `bodyFullSize` / `bodyTruncated` surface capture-layer truncation so the
41
+ * agent-facing envelope can warn when the body isn't whole.
42
+ */
43
+ async function captureNetworkItems(page) {
44
+ if (page.readNetworkCapture) {
45
+ const raw = await page.readNetworkCapture();
46
+ return raw.map((e) => {
47
+ const preview = e.responsePreview ?? null;
48
+ let body = null;
49
+ if (preview) {
50
+ try {
51
+ body = JSON.parse(preview);
52
+ }
53
+ catch {
54
+ body = preview;
55
+ }
56
+ }
57
+ const fullSize = typeof e.responseBodyFullSize === 'number'
58
+ ? e.responseBodyFullSize
59
+ : (preview ? preview.length : 0);
60
+ const truncated = e.responseBodyTruncated === true;
61
+ return {
62
+ url: e.url || '',
63
+ method: e.method || 'GET',
64
+ status: e.responseStatus || 0,
65
+ size: fullSize,
66
+ ct: e.responseContentType || '',
67
+ body,
68
+ bodyFullSize: fullSize,
69
+ bodyTruncated: truncated,
70
+ };
71
+ });
72
+ }
73
+ const raw = await page.evaluate(`(function(){ return JSON.stringify(window.__opencli_net || []); })()`);
74
+ try {
75
+ return JSON.parse(raw);
76
+ }
77
+ catch {
78
+ return [];
79
+ }
80
+ }
81
+ /** Drop static-resource / telemetry noise so agents see only API-shaped traffic. */
82
+ function filterNetworkItems(items) {
83
+ return items.filter((r) => (r.ct?.includes('json') || r.ct?.includes('xml') || r.ct?.includes('text/plain')) &&
84
+ !/\.(js|css|png|jpg|gif|svg|woff|ico|map)(\?|$)/i.test(r.url) &&
85
+ !/analytics|tracking|telemetry|beacon|pixel|gtag|fbevents/i.test(r.url));
86
+ }
87
+ /** Emit a structured error JSON so agents can branch on `error.code` without regex. */
88
+ function emitNetworkError(code, message, extra = {}) {
89
+ console.log(JSON.stringify({ error: { code, message, ...extra } }, null, 2));
90
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
91
+ }
29
92
  function getBrowserCacheDir() {
30
93
  return process.env.OPENCLI_CACHE_DIR || path.join(os.homedir(), '.opencli', 'cache');
31
94
  }
@@ -249,12 +312,50 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
249
312
  const browser = program
250
313
  .command('browser')
251
314
  .description('Browser control — navigate, click, type, extract, wait (no LLM needed)');
252
- /** Resolve a ref/CSS target via the unified resolver, throwing TargetError on failure. */
253
- async function resolveRef(page, ref) {
254
- const resolution = await page.evaluate(resolveTargetJs(ref));
315
+ /**
316
+ * Resolve a `<target>` (numeric ref or CSS selector) via the unified resolver.
317
+ * Returns the CSS match count so callers can propagate `matches_n` into the
318
+ * JSON envelope printed back to the agent.
319
+ */
320
+ async function resolveRef(page, ref, opts = {}) {
321
+ const resolution = await page.evaluate(resolveTargetJs(ref, opts));
255
322
  if (!resolution.ok) {
256
- throw new TargetError(resolution);
323
+ throw new TargetError({
324
+ code: resolution.code,
325
+ message: resolution.message,
326
+ hint: resolution.hint,
327
+ candidates: resolution.candidates,
328
+ matches_n: resolution.matches_n,
329
+ });
257
330
  }
331
+ return { matches_n: resolution.matches_n, match_level: resolution.match_level };
332
+ }
333
+ /**
334
+ * Parse `--nth <n>` flag, returning the parsed 0-based index or a usage error.
335
+ * The surface mirrors `--depth` etc. in `browser get html --as json`: the flag
336
+ * is optional, must be a non-negative integer when present, and on failure we
337
+ * emit the structured error envelope rather than throwing past the command.
338
+ */
339
+ function parseNthFlag(raw) {
340
+ if (raw === undefined || raw === null || raw === '')
341
+ return null;
342
+ const str = String(raw);
343
+ if (!/^\d+$/.test(str)) {
344
+ return { error: `--nth must be a non-negative integer, got "${str}"` };
345
+ }
346
+ return Number.parseInt(str, 10);
347
+ }
348
+ /** Emit the `{ error: { code, message, hint?, candidates?, matches_n? } }` envelope used by the selector-first commands. */
349
+ function emitTargetError(err) {
350
+ console.log(JSON.stringify({
351
+ error: {
352
+ code: err.code,
353
+ message: err.message,
354
+ hint: err.hint,
355
+ ...(err.candidates && { candidates: err.candidates }),
356
+ ...(err.matches_n !== undefined && { matches_n: err.matches_n }),
357
+ },
358
+ }, null, 2));
258
359
  }
259
360
  /** Wrap browser actions with error handling and optional --json output */
260
361
  function browserAction(fn) {
@@ -272,13 +373,11 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
272
373
  log.error(`Hint: ${err.hint}`);
273
374
  }
274
375
  else if (err instanceof TargetError) {
376
+ // Agent-facing structured envelope on stdout + short human line on stderr.
377
+ emitTargetError(err);
275
378
  log.error(`[${err.code}] ${err.message}`);
276
379
  if (err.hint)
277
380
  log.error(`Hint: ${err.hint}`);
278
- if (err.candidates?.length) {
279
- log.error('Candidates:');
280
- err.candidates.forEach((c, i) => log.error(` ${i + 1}. ${c}`));
281
- }
282
381
  }
283
382
  else {
284
383
  const msg = getErrorMessage(err);
@@ -352,8 +451,17 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
352
451
  console.log(JSON.stringify({ closed: validatedTarget }, null, 2));
353
452
  }));
354
453
  // ── Navigation ──
355
- /** Network interceptor JS — injected on every open/navigate to capture fetch/XHR */
356
- const NETWORK_INTERCEPTOR_JS = `(function(){if(window.__opencli_net)return;window.__opencli_net=[];var M=200,B=50000,F=window.fetch;window.fetch=async function(){var r=await F.apply(this,arguments);try{var ct=r.headers.get('content-type')||'';if(ct.includes('json')||ct.includes('text')){var c=r.clone(),t=await c.text();if(window.__opencli_net.length<M){var b=null;if(t.length<=B)try{b=JSON.parse(t)}catch(e){b=t}window.__opencli_net.push({url:r.url||(arguments[0]&&arguments[0].url)||String(arguments[0]),method:(arguments[1]&&arguments[1].method)||'GET',status:r.status,size:t.length,ct:ct,body:b})}}}catch(e){}return r};var X=XMLHttpRequest.prototype,O=X.open,S=X.send;X.open=function(m,u){this._om=m;this._ou=u;return O.apply(this,arguments)};X.send=function(){var x=this;x.addEventListener('load',function(){try{var ct=x.getResponseHeader('content-type')||'';if((ct.includes('json')||ct.includes('text'))&&window.__opencli_net.length<M){var t=x.responseText,b=null;if(t&&t.length<=B)try{b=JSON.parse(t)}catch(e){b=t}window.__opencli_net.push({url:x._ou,method:x._om||'GET',status:x.status,size:t?t.length:0,ct:ct,body:b})}}catch(e){}});return S.apply(this,arguments)}})()`;
454
+ /**
455
+ * Network interceptor JS injected on every open/navigate to capture
456
+ * fetch/XHR bodies when the session-level capture channel (CDP/extension)
457
+ * isn't available. Keeps parity with the CDP path's truncation contract:
458
+ * when a body exceeds the per-entry cap, we keep a string prefix and set
459
+ * `bodyTruncated: true` + `bodyFullSize: <original length>` so `browser
460
+ * network` can propagate a visible signal to the agent instead of
461
+ * silently dropping the body. Per-entry cap is 1 MiB and the ring is
462
+ * capped at 200 entries, bounding worst-case in-page memory.
463
+ */
464
+ const NETWORK_INTERCEPTOR_JS = `(function(){if(window.__opencli_net)return;window.__opencli_net=[];var M=200,B=1048576,F=window.fetch;function capture(url,method,status,text,ct){if(window.__opencli_net.length>=M)return;var full=text?text.length:0,trunc=full>B,stored=trunc?text.slice(0,B):text,body=null;if(stored){if(trunc){body=stored}else{try{body=JSON.parse(stored)}catch(e){body=stored}}}var e={url:url,method:method||'GET',status:status,size:full,ct:ct,body:body};if(trunc){e.bodyTruncated=true;e.bodyFullSize=full}window.__opencli_net.push(e)}window.fetch=async function(){var r=await F.apply(this,arguments);try{var ct=r.headers.get('content-type')||'';if(ct.includes('json')||ct.includes('text')){var c=r.clone(),t=await c.text();capture(r.url||(arguments[0]&&arguments[0].url)||String(arguments[0]),(arguments[1]&&arguments[1].method)||'GET',r.status,t,ct)}}catch(e){}return r};var X=XMLHttpRequest.prototype,O=X.open,S=X.send;X.open=function(m,u){this._om=m;this._ou=u;return O.apply(this,arguments)};X.send=function(){var x=this;x.addEventListener('load',function(){try{var ct=x.getResponseHeader('content-type')||'';if(ct.includes('json')||ct.includes('text')){capture(x._ou,x._om||'GET',x.status,x.responseText||'',ct)}}catch(e){}});return S.apply(this,arguments)}})()`;
357
465
  addBrowserTabOption(browser.command('open').argument('<url>').description('Open URL in automation window'))
358
466
  .action(browserAction(async (page, url) => {
359
467
  // Start session-level capture before navigation (catches initial requests)
@@ -413,6 +521,51 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
413
521
  console.log(await page.screenshot({ format: 'png' }));
414
522
  }
415
523
  }));
524
+ // ── Find (structured CSS query, agent-native) ──
525
+ //
526
+ // `browser find --css <sel>` lets agents jump straight from a semantic
527
+ // selector to a JSON list of matching elements, without having to parse
528
+ // the free-text state snapshot to recover indices.
529
+ addBrowserTabOption(browser.command('find')
530
+ .option('--css <selector>', 'CSS selector (required)')
531
+ .option('--limit <n>', 'Max entries returned', '50')
532
+ .option('--text-max <n>', 'Max chars of trimmed text per entry', '120')
533
+ .description('Find DOM elements by CSS selector — returns JSON {matches_n, entries[]}'))
534
+ .action(browserAction(async (page, opts) => {
535
+ if (!opts.css || typeof opts.css !== 'string') {
536
+ console.log(JSON.stringify({
537
+ error: {
538
+ code: 'usage_error',
539
+ message: '--css <selector> is required',
540
+ hint: 'Example: opencli browser find --css ".btn.primary"',
541
+ },
542
+ }, null, 2));
543
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
544
+ return;
545
+ }
546
+ const limit = parseNthFlag(opts.limit);
547
+ if (limit && typeof limit === 'object' && 'error' in limit) {
548
+ console.log(JSON.stringify({ error: { code: 'usage_error', message: limit.error.replace('--nth', '--limit') } }, null, 2));
549
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
550
+ return;
551
+ }
552
+ const textMax = parseNthFlag(opts.textMax);
553
+ if (textMax && typeof textMax === 'object' && 'error' in textMax) {
554
+ console.log(JSON.stringify({ error: { code: 'usage_error', message: textMax.error.replace('--nth', '--text-max') } }, null, 2));
555
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
556
+ return;
557
+ }
558
+ const result = await page.evaluate(buildFindJs(opts.css, {
559
+ limit: limit ?? undefined,
560
+ textMax: textMax ?? undefined,
561
+ }));
562
+ if (isFindError(result)) {
563
+ console.log(JSON.stringify(result, null, 2));
564
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
565
+ return;
566
+ }
567
+ console.log(JSON.stringify(result, null, 2));
568
+ }));
416
569
  // ── Get commands (structured data extraction) ──
417
570
  const get = browser.command('get').description('Get page properties');
418
571
  addBrowserTabOption(get.command('title').description('Page title'))
@@ -423,65 +576,271 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
423
576
  .action(browserAction(async (page) => {
424
577
  console.log(await page.getCurrentUrl?.() ?? await page.evaluate('location.href'));
425
578
  }));
426
- addBrowserTabOption(get.command('text').argument('<index>', 'Element index').description('Element text content'))
427
- .action(browserAction(async (page, index) => {
428
- await resolveRef(page, String(index));
429
- const text = await page.evaluate(getTextResolvedJs());
430
- console.log(text ?? '(empty)');
431
- }));
432
- addBrowserTabOption(get.command('value').argument('<index>', 'Element index').description('Input/textarea value'))
433
- .action(browserAction(async (page, index) => {
434
- await resolveRef(page, String(index));
435
- const val = await page.evaluate(getValueResolvedJs());
436
- console.log(val ?? '(empty)');
437
- }));
438
- addBrowserTabOption(get.command('html').option('--selector <css>', 'CSS selector scope').description('Page HTML (or scoped)'))
579
+ // Read commands (`get text/value/attributes`) always emit a JSON envelope:
580
+ //
581
+ // { value, matches_n } — success
582
+ // { error: { code, message, hint, matches_n? } } — structured failure
583
+ //
584
+ // `<target>` accepts either a numeric ref (from `browser state`/`browser find`)
585
+ // or a CSS selector. On multi-match CSS, the first element wins and the real
586
+ // match count is exposed via `matches_n`; `--nth <n>` picks a specific one.
587
+ const runGetCommand = async (page, target, opts, evalJs, field) => {
588
+ const nth = parseNthFlag(opts.nth);
589
+ if (nth && typeof nth === 'object' && 'error' in nth) {
590
+ console.log(JSON.stringify({ error: { code: 'usage_error', message: nth.error } }, null, 2));
591
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
592
+ return;
593
+ }
594
+ const { matches_n, match_level } = await resolveRef(page, String(target), {
595
+ firstOnMulti: nth === null,
596
+ ...(typeof nth === 'number' ? { nth } : {}),
597
+ });
598
+ const raw = await page.evaluate(evalJs);
599
+ let value;
600
+ if (field === 'attributes') {
601
+ // getAttributesResolvedJs stringifies the attribute record — parse it back so
602
+ // the JSON envelope contains a real object rather than a nested JSON string.
603
+ try {
604
+ value = raw == null ? {} : JSON.parse(String(raw));
605
+ }
606
+ catch {
607
+ value = raw;
608
+ }
609
+ }
610
+ else {
611
+ value = raw ?? null;
612
+ }
613
+ console.log(JSON.stringify({ value, matches_n, match_level }, null, 2));
614
+ };
615
+ addBrowserTabOption(get.command('text')
616
+ .argument('<target>', 'Numeric ref (from browser state / find) or CSS selector')
617
+ .option('--nth <n>', 'Pick the nth match (0-based) when <target> is a multi-match CSS selector')
618
+ .description('Element text content — JSON envelope {value, matches_n}'))
619
+ .action(browserAction(async (page, target, opts) => runGetCommand(page, String(target), opts ?? {}, getTextResolvedJs(), 'text')));
620
+ addBrowserTabOption(get.command('value')
621
+ .argument('<target>', 'Numeric ref (from browser state / find) or CSS selector')
622
+ .option('--nth <n>', 'Pick the nth match (0-based) when <target> is a multi-match CSS selector')
623
+ .description('Input/textarea value — JSON envelope {value, matches_n}'))
624
+ .action(browserAction(async (page, target, opts) => runGetCommand(page, String(target), opts ?? {}, getValueResolvedJs(), 'value')));
625
+ addBrowserTabOption(get.command('html')
626
+ .option('--selector <css>', 'CSS selector scope (first match)')
627
+ .option('--as <format>', 'Output format: "html" (default) or "json" for structured tree', 'html')
628
+ .option('--max <n>', 'Max characters of raw HTML to return (0 = unlimited)', '0')
629
+ .option('--depth <n>', '(--as json) Max tree depth below root (0 = root only, 0 disables = unlimited via empty)', '')
630
+ .option('--children-max <n>', '(--as json) Max element children kept per node (empty = unlimited)', '')
631
+ .option('--text-max <n>', '(--as json) Max chars of direct text kept per node (empty = unlimited)', '')
632
+ .description('Page HTML (or scoped); use --as json for a {tag, attrs, text, children} tree'))
439
633
  .action(browserAction(async (page, opts) => {
634
+ const format = String(opts.as || 'html').toLowerCase();
635
+ if (format !== 'html' && format !== 'json') {
636
+ console.log(JSON.stringify({ error: { code: 'invalid_format', message: `--as must be "html" or "json", got "${opts.as}"` } }, null, 2));
637
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
638
+ return;
639
+ }
640
+ // `--max` is validated up-front (before touching the page) so a bad value
641
+ // gets the same structured error regardless of selector/format path.
642
+ const rawMax = String(opts.max ?? '0');
643
+ if (!/^\d+$/.test(rawMax)) {
644
+ console.log(JSON.stringify({ error: { code: 'invalid_max', message: `--max must be a non-negative integer, got "${opts.max}"` } }, null, 2));
645
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
646
+ return;
647
+ }
648
+ const max = Number.parseInt(rawMax, 10);
649
+ if (format === 'json') {
650
+ const parseBudget = (flag, value) => {
651
+ const raw = value === undefined || value === null ? '' : String(value);
652
+ if (raw === '')
653
+ return null;
654
+ if (!/^\d+$/.test(raw))
655
+ return { error: `${flag} must be a non-negative integer, got "${raw}"` };
656
+ return Number.parseInt(raw, 10);
657
+ };
658
+ const depth = parseBudget('--depth', opts.depth);
659
+ const childrenMax = parseBudget('--children-max', opts.childrenMax);
660
+ const textMax = parseBudget('--text-max', opts.textMax);
661
+ for (const budget of [depth, childrenMax, textMax]) {
662
+ if (budget && typeof budget === 'object' && 'error' in budget) {
663
+ console.log(JSON.stringify({ error: { code: 'invalid_budget', message: budget.error } }, null, 2));
664
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
665
+ return;
666
+ }
667
+ }
668
+ const js = buildHtmlTreeJs({
669
+ selector: opts.selector ?? null,
670
+ depth: depth,
671
+ childrenMax: childrenMax,
672
+ textMax: textMax,
673
+ });
674
+ const result = await page.evaluate(js);
675
+ if (result && typeof result === 'object' && 'invalidSelector' in result && result.invalidSelector) {
676
+ console.log(JSON.stringify({
677
+ error: { code: 'invalid_selector', message: `Selector "${opts.selector}" is not a valid CSS selector: ${result.reason}` },
678
+ }, null, 2));
679
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
680
+ return;
681
+ }
682
+ const ok = result;
683
+ if (!ok || ok.matched === 0) {
684
+ console.log(JSON.stringify({
685
+ error: {
686
+ code: 'selector_not_found',
687
+ message: opts.selector
688
+ ? `Selector "${opts.selector}" matched 0 elements.`
689
+ : 'Page has no documentElement.',
690
+ },
691
+ }, null, 2));
692
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
693
+ return;
694
+ }
695
+ console.log(JSON.stringify(ok, null, 2));
696
+ return;
697
+ }
698
+ // Raw HTML path — unbounded by default; --max optionally caps with a visible marker.
699
+ // Selector lookup is wrapped in try/catch inside page context so an invalid
700
+ // selector returns a structured signal instead of throwing through page.evaluate.
440
701
  const sel = opts.selector ? JSON.stringify(opts.selector) : 'null';
441
- const html = await page.evaluate(`(${sel} ? document.querySelector(${sel})?.outerHTML : document.documentElement.outerHTML)?.slice(0, 50000)`);
442
- console.log(html ?? '(empty)');
443
- }));
444
- addBrowserTabOption(get.command('attributes').argument('<index>', 'Element index').description('Element attributes'))
445
- .action(browserAction(async (page, index) => {
446
- await resolveRef(page, String(index));
447
- const attrs = await page.evaluate(getAttributesResolvedJs());
448
- console.log(attrs ?? '{}');
702
+ const rawResult = await page.evaluate(`(() => {
703
+ const s = ${sel};
704
+ if (s) {
705
+ try {
706
+ const el = document.querySelector(s);
707
+ return { kind: 'ok', html: el ? el.outerHTML : null };
708
+ } catch (e) {
709
+ return { kind: 'invalid_selector', reason: (e && e.message) || String(e) };
710
+ }
711
+ }
712
+ return { kind: 'ok', html: document.documentElement ? document.documentElement.outerHTML : null };
713
+ })()`);
714
+ if (rawResult.kind === 'invalid_selector') {
715
+ console.log(JSON.stringify({
716
+ error: { code: 'invalid_selector', message: `Selector "${opts.selector}" is not a valid CSS selector: ${rawResult.reason}` },
717
+ }, null, 2));
718
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
719
+ return;
720
+ }
721
+ const html = rawResult.html;
722
+ if (html === null) {
723
+ if (opts.selector) {
724
+ console.log(JSON.stringify({
725
+ error: { code: 'selector_not_found', message: `Selector "${opts.selector}" matched 0 elements.` },
726
+ }, null, 2));
727
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
728
+ return;
729
+ }
730
+ console.log('(empty)');
731
+ return;
732
+ }
733
+ if (max > 0 && html.length > max) {
734
+ console.log(`<!-- opencli: truncated ${max} of ${html.length} chars; re-run without --max (or --max 0) for full -->\n${html.slice(0, max)}`);
735
+ return;
736
+ }
737
+ console.log(html);
449
738
  }));
739
+ addBrowserTabOption(get.command('attributes')
740
+ .argument('<target>', 'Numeric ref (from browser state / find) or CSS selector')
741
+ .option('--nth <n>', 'Pick the nth match (0-based) when <target> is a multi-match CSS selector')
742
+ .description('Element attributes — JSON envelope {value, matches_n}'))
743
+ .action(browserAction(async (page, target, opts) => runGetCommand(page, String(target), opts ?? {}, getAttributesResolvedJs(), 'attributes')));
450
744
  // ── Interact ──
451
- addBrowserTabOption(browser.command('click').argument('<index>', 'Element index from state').description('Click element by index'))
452
- .action(browserAction(async (page, index) => {
453
- await page.click(index);
454
- console.log(`Clicked element [${index}]`);
745
+ //
746
+ // Write commands (`click/type/select`) share the same `<target>` contract
747
+ // as the read commands but *reject* multi-match CSS as `selector_ambiguous`
748
+ // unless the caller passes `--nth <n>`. That asymmetry is intentional:
749
+ // clicking "one of three buttons" at random is almost never what the agent
750
+ // meant. Every branch emits a JSON envelope on stdout; error envelopes go
751
+ // through the unified TargetError handler in browserAction.
752
+ /**
753
+ * Parse the `--nth` flag and convert it to `ResolveOptions`.
754
+ * Returns `{ error }` when the flag was malformed (so the command can
755
+ * print the structured usage error and exit) or `{ opts }` to feed
756
+ * into resolveRef / page.click / page.typeText.
757
+ */
758
+ function nthToResolveOpts(raw) {
759
+ const parsed = parseNthFlag(raw);
760
+ if (parsed && typeof parsed === 'object' && 'error' in parsed)
761
+ return parsed;
762
+ if (typeof parsed === 'number')
763
+ return { opts: { nth: parsed } };
764
+ return { opts: {} };
765
+ }
766
+ addBrowserTabOption(browser.command('click')
767
+ .argument('<target>', 'Numeric ref (from browser state / find) or CSS selector')
768
+ .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
769
+ .description('Click element — JSON envelope {clicked, target, matches_n}'))
770
+ .action(browserAction(async (page, target, opts) => {
771
+ const parsed = nthToResolveOpts(opts?.nth);
772
+ if ('error' in parsed) {
773
+ console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
774
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
775
+ return;
776
+ }
777
+ const { matches_n, match_level } = await page.click(String(target), parsed.opts);
778
+ console.log(JSON.stringify({ clicked: true, target: String(target), matches_n, match_level }, null, 2));
455
779
  }));
456
- addBrowserTabOption(browser.command('type').argument('<index>', 'Element index').argument('<text>', 'Text to type'))
457
- .description('Click element, then type text')
458
- .action(browserAction(async (page, index, text) => {
459
- await page.click(index);
780
+ addBrowserTabOption(browser.command('type')
781
+ .argument('<target>', 'Numeric ref (from browser state / find) or CSS selector')
782
+ .argument('<text>', 'Text to type')
783
+ .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
784
+ .description('Click element, then type text — JSON envelope {typed, text, target, matches_n, autocomplete}'))
785
+ .action(browserAction(async (page, target, text, opts) => {
786
+ const parsed = nthToResolveOpts(opts?.nth);
787
+ if ('error' in parsed) {
788
+ console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
789
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
790
+ return;
791
+ }
792
+ // Click first (focuses the field), wait briefly, then type.
793
+ await page.click(String(target), parsed.opts);
460
794
  await page.wait(0.3);
461
- await page.typeText(index, text);
462
- // Detect autocomplete/combobox fields and wait for dropdown suggestions
463
- // __resolved is already set by typeText's resolver call
795
+ const { matches_n, match_level } = await page.typeText(String(target), String(text), parsed.opts);
796
+ // __resolved is already set by the resolver call inside page.typeText
464
797
  const isAutocomplete = await page.evaluate(isAutocompleteResolvedJs());
465
- if (isAutocomplete) {
798
+ if (isAutocomplete)
466
799
  await page.wait(0.4);
467
- console.log(`Typed "${text}" into autocomplete [${index}] — use state to see suggestions`);
468
- }
469
- else {
470
- console.log(`Typed "${text}" into element [${index}]`);
471
- }
800
+ console.log(JSON.stringify({
801
+ typed: true,
802
+ text: String(text),
803
+ target: String(target),
804
+ matches_n,
805
+ match_level,
806
+ autocomplete: !!isAutocomplete,
807
+ }, null, 2));
472
808
  }));
473
- addBrowserTabOption(browser.command('select').argument('<index>', 'Element index of <select>').argument('<option>', 'Option text'))
474
- .description('Select dropdown option')
475
- .action(browserAction(async (page, index, option) => {
476
- await resolveRef(page, String(index));
477
- const result = await page.evaluate(selectResolvedJs(option));
809
+ addBrowserTabOption(browser.command('select')
810
+ .argument('<target>', 'Numeric ref (from browser state / find) or CSS selector of a <select> element')
811
+ .argument('<option>', 'Option text (or value) to select')
812
+ .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
813
+ .description('Select dropdown option JSON envelope {selected, target, matches_n}'))
814
+ .action(browserAction(async (page, target, option, opts) => {
815
+ const parsed = nthToResolveOpts(opts?.nth);
816
+ if ('error' in parsed) {
817
+ console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
818
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
819
+ return;
820
+ }
821
+ const { matches_n, match_level } = await resolveRef(page, String(target), parsed.opts);
822
+ const result = await page.evaluate(selectResolvedJs(String(option)));
478
823
  if (result?.error) {
479
- console.error(`Error: ${result.error}${result.available ? ` Available: ${result.available.join(', ')}` : ''}`);
824
+ // The select-specific "Not a <select>" / "Option not found" errors
825
+ // are domain-level failures — emit a structured envelope so agents
826
+ // can branch on code rather than scrape a log line.
827
+ console.log(JSON.stringify({
828
+ error: {
829
+ code: result.error === 'Not a <select>' ? 'not_a_select' : 'option_not_found',
830
+ message: result.error,
831
+ ...(result.available && { available: result.available }),
832
+ matches_n,
833
+ },
834
+ }, null, 2));
480
835
  process.exitCode = EXIT_CODES.GENERIC_ERROR;
836
+ return;
481
837
  }
482
- else {
483
- console.log(`Selected "${result?.selected}" in element [${index}]`);
484
- }
838
+ console.log(JSON.stringify({
839
+ selected: result?.selected ?? String(option),
840
+ target: String(target),
841
+ matches_n,
842
+ match_level,
843
+ }, null, 2));
485
844
  }));
486
845
  addBrowserTabOption(browser.command('keys').argument('<key>', 'Key to press (Enter, Escape, Tab, Control+a)'))
487
846
  .description('Press keyboard key')
@@ -552,86 +911,232 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
552
911
  else
553
912
  console.log(JSON.stringify(result, null, 2));
554
913
  }));
914
+ // ── Extract (content reading) ──
915
+ //
916
+ // `extract` answers the "read this page" question that `get html` / `get text`
917
+ // can't: denoise → markdown → paragraph-aware chunking. Agents walk long pages
918
+ // by passing back the `next_start_char` cursor instead of juggling selectors.
919
+ addBrowserTabOption(browser.command('extract')
920
+ .option('--selector <css>', 'CSS selector scope; defaults to <main>/<article>/<body>')
921
+ .option('--chunk-size <chars>', 'Target chunk size in chars', '20000')
922
+ .option('--start <char>', 'Start offset (use next_start_char from a previous extract)', '0')
923
+ .description('Extract page content as markdown, paragraph-aware chunks for long pages'))
924
+ .action(browserAction(async (page, opts) => {
925
+ const rawChunk = String(opts.chunkSize ?? '20000');
926
+ if (!/^\d+$/.test(rawChunk) || Number.parseInt(rawChunk, 10) <= 0) {
927
+ console.log(JSON.stringify({ error: { code: 'invalid_chunk_size', message: `--chunk-size must be a positive integer, got "${opts.chunkSize}"` } }, null, 2));
928
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
929
+ return;
930
+ }
931
+ const rawStart = String(opts.start ?? '0');
932
+ if (!/^\d+$/.test(rawStart)) {
933
+ console.log(JSON.stringify({ error: { code: 'invalid_start', message: `--start must be a non-negative integer, got "${opts.start}"` } }, null, 2));
934
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
935
+ return;
936
+ }
937
+ const chunkSize = Number.parseInt(rawChunk, 10);
938
+ const start = Number.parseInt(rawStart, 10);
939
+ const selector = typeof opts.selector === 'string' && opts.selector.length > 0 ? opts.selector : null;
940
+ const js = buildExtractHtmlJs(selector);
941
+ const res = await page.evaluate(js);
942
+ if (!res) {
943
+ console.log(JSON.stringify({ error: { code: 'extract_failed', message: 'Page returned no root element.' } }, null, 2));
944
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
945
+ return;
946
+ }
947
+ if ('invalidSelector' in res) {
948
+ console.log(JSON.stringify({ error: { code: 'invalid_selector', message: `Selector "${selector}" is not a valid CSS selector: ${res.reason}` } }, null, 2));
949
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
950
+ return;
951
+ }
952
+ if ('notFound' in res) {
953
+ console.log(JSON.stringify({ error: { code: 'selector_not_found', message: selector ? `Selector "${selector}" matched 0 elements.` : 'Page has no body/main/article element.' } }, null, 2));
954
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
955
+ return;
956
+ }
957
+ const envelope = runExtractFromHtml({
958
+ html: res.html,
959
+ url: res.url,
960
+ title: res.title,
961
+ selector,
962
+ start,
963
+ chunkSize,
964
+ });
965
+ console.log(JSON.stringify(envelope, null, 2));
966
+ }));
555
967
  // ── Network (API discovery) ──
968
+ //
969
+ // Default output is JSON (agent-native). Each entry carries a stable `key`
970
+ // (GraphQL operationName or `METHOD host+pathname`) so agents can fetch
971
+ // full bodies with `--detail <key>` even after subsequent commands.
972
+ // Captures are persisted per workspace under ~/.opencli/cache/browser-network/.
556
973
  addBrowserTabOption(browser.command('network'))
557
- .option('--detail <index>', 'Show full response body of request at index')
558
- .option('--all', 'Show all requests including static resources')
559
- .description('Show captured network requests (auto-captured since last open)')
974
+ .option('--detail <key>', 'Emit full body for the entry with this key')
975
+ .option('--all', 'Include static resources (js/css/images/telemetry)')
976
+ .option('--raw', 'Emit full bodies for every entry (skip shape preview)')
977
+ .option('--filter <fields>', 'Comma-separated field names; keep only entries whose body shape has ALL names as path segments')
978
+ .option('--max-body <chars>', 'With --detail: cap the emitted body at N chars (0 = unlimited, default)', '0')
979
+ .option('--ttl <ms>', 'Cache TTL in ms for --detail lookups', String(DEFAULT_TTL_MS))
980
+ .description('Capture network requests as shape previews; retrieve full bodies by key')
560
981
  .action(browserAction(async (page, opts) => {
561
- let items = [];
562
- if (page.readNetworkCapture) {
563
- const raw = await page.readNetworkCapture();
564
- // Normalize daemon/CDP capture entries to __opencli_net shape.
565
- // Daemon returns: responseStatus, responseContentType, responsePreview
566
- // CDP returns the same shape after PR A fix.
567
- items = raw.map(e => {
568
- const preview = e.responsePreview ?? null;
569
- let body = null;
570
- if (preview) {
571
- try {
572
- body = JSON.parse(preview);
573
- }
574
- catch {
575
- body = preview;
576
- }
577
- }
578
- return {
579
- url: e.url || '',
580
- method: e.method || 'GET',
581
- status: e.responseStatus || 0,
582
- size: preview ? preview.length : 0,
583
- ct: e.responseContentType || '',
584
- body,
585
- };
586
- });
982
+ const ttlMs = parsePositiveIntOption(opts.ttl, 'ttl', DEFAULT_TTL_MS);
983
+ const workspace = DEFAULT_BROWSER_WORKSPACE;
984
+ const hasDetail = typeof opts.detail === 'string' && opts.detail.length > 0;
985
+ const hasFilter = typeof opts.filter === 'string';
986
+ // --detail and --filter do different things (one request by key vs. narrow
987
+ // the list by shape), don't compose, and combining them has no sensible
988
+ // semantic. Reject up front with a structured error instead of silently
989
+ // dropping one.
990
+ if (hasDetail && hasFilter) {
991
+ emitNetworkError('invalid_args', '--filter and --detail cannot be used together (one narrows a list, the other fetches a specific entry).');
992
+ return;
587
993
  }
588
- else {
589
- // Fallback to JS interceptor data
590
- const requests = await page.evaluate(`(function(){
591
- var reqs = window.__opencli_net || [];
592
- return JSON.stringify(reqs);
593
- })()`);
594
- try {
595
- items = JSON.parse(requests);
994
+ let filterFields = null;
995
+ if (hasFilter) {
996
+ const parsed = parseFilter(opts.filter);
997
+ if ('reason' in parsed) {
998
+ emitNetworkError('invalid_filter', parsed.reason);
999
+ return;
596
1000
  }
597
- catch {
598
- console.log('No network data captured. Run "browser open <url>" first.');
1001
+ filterFields = parsed.fields;
1002
+ }
1003
+ // --detail short-circuits: read from cache only, no live capture needed.
1004
+ if (hasDetail) {
1005
+ const res = loadNetworkCache(workspace, { ttlMs });
1006
+ if (res.status === 'missing') {
1007
+ emitNetworkError('cache_missing', `No cached capture. Run "browser network" first (in workspace "${workspace}").`);
1008
+ return;
1009
+ }
1010
+ if (res.status === 'expired') {
1011
+ emitNetworkError('cache_expired', `Cache is stale (age ${res.ageMs}ms > ttl ${ttlMs}ms). Re-run "browser network" to refresh.`);
1012
+ return;
1013
+ }
1014
+ if (res.status === 'corrupt' || !res.file) {
1015
+ emitNetworkError('cache_corrupt', 'Cache file is malformed; re-run "browser network" to regenerate.');
1016
+ return;
1017
+ }
1018
+ const entry = findEntry(res.file, opts.detail);
1019
+ if (!entry) {
1020
+ emitNetworkError('key_not_found', `Key "${opts.detail}" not in cache.`, {
1021
+ available_keys: res.file.entries.map((e) => e.key),
1022
+ });
599
1023
  return;
600
1024
  }
1025
+ const rawMaxBody = String(opts.maxBody ?? '0');
1026
+ if (!/^\d+$/.test(rawMaxBody)) {
1027
+ emitNetworkError('invalid_max_body', `--max-body must be a non-negative integer, got "${opts.maxBody}"`);
1028
+ return;
1029
+ }
1030
+ const maxBody = Number.parseInt(rawMaxBody, 10);
1031
+ // Body shape/source:
1032
+ // - If capture already truncated it (entry.body_truncated), the body is a string.
1033
+ // - If the adapter stored a JSON value, it parsed cleanly at capture time; leave it.
1034
+ // - --max-body applies a transport-level cap when the caller wants to keep output small.
1035
+ let outputBody = entry.body;
1036
+ let transportTruncated = false;
1037
+ if (maxBody > 0 && typeof entry.body === 'string' && entry.body.length > maxBody) {
1038
+ outputBody = entry.body.slice(0, maxBody);
1039
+ transportTruncated = true;
1040
+ }
1041
+ const captureTruncated = entry.body_truncated === true;
1042
+ const detailEnvelope = {
1043
+ key: entry.key,
1044
+ url: entry.url,
1045
+ method: entry.method,
1046
+ status: entry.status,
1047
+ ct: entry.ct,
1048
+ size: entry.size,
1049
+ shape: inferShape(entry.body),
1050
+ body: outputBody,
1051
+ };
1052
+ if (captureTruncated || transportTruncated) {
1053
+ detailEnvelope.body_truncated = true;
1054
+ detailEnvelope.body_full_size = entry.body_full_size ?? entry.size;
1055
+ detailEnvelope.body_truncation_reason = captureTruncated
1056
+ ? 'capture-limit'
1057
+ : 'max-body';
1058
+ }
1059
+ console.log(JSON.stringify(detailEnvelope, null, 2));
1060
+ return;
1061
+ }
1062
+ // Fresh capture path.
1063
+ let rawItems;
1064
+ try {
1065
+ rawItems = await captureNetworkItems(page);
601
1066
  }
602
- if (items.length === 0) {
603
- console.log('No requests captured.');
1067
+ catch (err) {
1068
+ emitNetworkError('capture_failed', `Could not read network capture: ${err.message}`);
604
1069
  return;
605
1070
  }
606
- // Filter out static resources unless --all
607
- if (!opts.all) {
608
- items = items.filter(r => (r.ct?.includes('json') || r.ct?.includes('xml') || r.ct?.includes('text/plain')) &&
609
- !/\.(js|css|png|jpg|gif|svg|woff|ico|map)(\?|$)/i.test(r.url) &&
610
- !/analytics|tracking|telemetry|beacon|pixel|gtag|fbevents/i.test(r.url));
611
- }
612
- if (opts.detail !== undefined) {
613
- const idx = parseInt(opts.detail, 10);
614
- const req = items[idx];
615
- if (!req) {
616
- console.error(`Request #${idx} not found. ${items.length} requests available.`);
617
- process.exitCode = EXIT_CODES.USAGE_ERROR;
618
- return;
619
- }
620
- console.log(`${req.method} ${req.url}`);
621
- console.log(`Status: ${req.status} | Size: ${req.size} | Type: ${req.ct}`);
622
- console.log('---');
623
- console.log(typeof req.body === 'string' ? req.body : JSON.stringify(req.body, null, 2));
1071
+ const items = opts.all ? rawItems : filterNetworkItems(rawItems);
1072
+ const filteredOut = rawItems.length - items.length;
1073
+ const keyed = assignKeys(items);
1074
+ const cacheEntries = keyed.map((it) => ({
1075
+ key: it.key,
1076
+ url: it.url,
1077
+ method: it.method,
1078
+ status: it.status,
1079
+ size: it.size,
1080
+ ct: it.ct,
1081
+ body: it.body,
1082
+ ...(it.bodyTruncated ? { body_truncated: true } : {}),
1083
+ ...(it.bodyTruncated && typeof it.bodyFullSize === 'number'
1084
+ ? { body_full_size: it.bodyFullSize }
1085
+ : {}),
1086
+ }));
1087
+ // Soft failure: the caller already has the data, so surface a warning
1088
+ // via the output envelope rather than erroring out the whole command.
1089
+ let cacheWarning = null;
1090
+ try {
1091
+ saveNetworkCache(workspace, cacheEntries);
624
1092
  }
625
- else {
626
- console.log(`Captured ${items.length} API requests:\n`);
627
- items.forEach((r, i) => {
628
- const bodyPreview = r.body ? (typeof r.body === 'string' ? r.body.slice(0, 60) : JSON.stringify(r.body).slice(0, 60)) : '';
629
- console.log(` [${i}] ${r.method} ${r.status} ${r.url.slice(0, 80)}`);
630
- if (bodyPreview)
631
- console.log(` ${bodyPreview}...`);
632
- });
633
- console.log(`\nUse --detail <index> to see full response body.`);
1093
+ catch (err) {
1094
+ cacheWarning = `Could not persist capture cache: ${err.message}. --detail lookups may miss this capture.`;
1095
+ }
1096
+ // Pair each cache entry with its shape up front so --filter can read
1097
+ // segments without recomputing, and the --raw view can keep the full
1098
+ // body. Cache persistence above stored the unfiltered set on purpose:
1099
+ // later `--detail <key>` lookups must still see requests that the
1100
+ // current --filter narrowed out.
1101
+ const shaped = cacheEntries.map((e) => ({ entry: e, shape: inferShape(e.body) }));
1102
+ const visible = filterFields
1103
+ ? shaped.filter((s) => shapeMatchesFilter(s.shape, filterFields))
1104
+ : shaped;
1105
+ const filterDropped = filterFields ? shaped.length - visible.length : 0;
1106
+ const envelope = {
1107
+ workspace,
1108
+ captured_at: new Date().toISOString(),
1109
+ count: visible.length,
1110
+ filtered_out: filteredOut,
1111
+ };
1112
+ if (filterFields) {
1113
+ envelope.filter = filterFields;
1114
+ envelope.filter_dropped = filterDropped;
1115
+ }
1116
+ if (cacheWarning)
1117
+ envelope.cache_warning = cacheWarning;
1118
+ const truncatedCount = visible.filter((s) => s.entry.body_truncated).length;
1119
+ if (truncatedCount > 0) {
1120
+ envelope.body_truncated_count = truncatedCount;
1121
+ envelope.body_truncated_hint = 'Some bodies exceeded the capture limit; their `shape` reflects only the captured prefix.';
634
1122
  }
1123
+ if (opts.raw) {
1124
+ envelope.entries = visible.map((s) => s.entry);
1125
+ }
1126
+ else {
1127
+ envelope.entries = visible.map((s) => ({
1128
+ key: s.entry.key,
1129
+ method: s.entry.method,
1130
+ status: s.entry.status,
1131
+ url: s.entry.url,
1132
+ ct: s.entry.ct,
1133
+ size: s.entry.size,
1134
+ shape: s.shape,
1135
+ ...(s.entry.body_truncated ? { body_truncated: true } : {}),
1136
+ }));
1137
+ envelope.detail_hint = 'Run "browser network --detail <key>" for full body.';
1138
+ }
1139
+ console.log(JSON.stringify(envelope, null, 2));
635
1140
  }));
636
1141
  // ── Init (adapter scaffolding) ──
637
1142
  browser.command('init')