@jackwener/opencli 1.7.14 → 1.7.16

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 (153) hide show
  1. package/README.md +9 -6
  2. package/README.zh-CN.md +9 -6
  3. package/cli-manifest.json +374 -74
  4. package/clis/bilibili/subtitle.js +1 -1
  5. package/clis/chatgpt/ask.js +2 -1
  6. package/clis/chatgpt/detail.js +6 -1
  7. package/clis/chatgpt/read.js +2 -1
  8. package/clis/chatgpt/send.js +2 -1
  9. package/clis/chatgpt/utils.js +54 -12
  10. package/clis/chatgpt/utils.test.js +36 -1
  11. package/clis/claude/ask.js +22 -7
  12. package/clis/claude/detail.js +9 -2
  13. package/clis/claude/new.js +8 -2
  14. package/clis/claude/read.js +2 -1
  15. package/clis/claude/send.js +8 -3
  16. package/clis/claude/utils.js +27 -4
  17. package/clis/deepseek/ask.js +21 -8
  18. package/clis/deepseek/detail.js +9 -1
  19. package/clis/deepseek/new.js +13 -2
  20. package/clis/deepseek/read.js +2 -1
  21. package/clis/deepseek/utils.js +8 -1
  22. package/clis/dianping/cityResolver.js +185 -0
  23. package/clis/dianping/dianping.test.js +154 -0
  24. package/clis/dianping/search.js +6 -3
  25. package/clis/douyin/_shared/browser-fetch.js +14 -2
  26. package/clis/douyin/_shared/browser-fetch.test.js +13 -0
  27. package/clis/douyin/stats.js +1 -1
  28. package/clis/douyin/update.js +1 -1
  29. package/clis/jike/search.js +1 -1
  30. package/clis/linkedin/search.js +8 -11
  31. package/clis/maimai/search-talents.js +10 -6
  32. package/clis/openreview/author.js +58 -0
  33. package/clis/openreview/openreview.test.js +83 -1
  34. package/clis/openreview/utils.js +14 -0
  35. package/clis/reddit/comment.js +1 -0
  36. package/clis/reddit/frontpage.js +1 -0
  37. package/clis/reddit/popular.js +1 -0
  38. package/clis/reddit/read.js +2 -0
  39. package/clis/reddit/read.test.js +4 -0
  40. package/clis/reddit/save.js +1 -0
  41. package/clis/reddit/saved.js +1 -0
  42. package/clis/reddit/search.js +2 -1
  43. package/clis/reddit/subreddit.js +2 -1
  44. package/clis/reddit/subscribe.js +1 -0
  45. package/clis/reddit/upvote.js +1 -0
  46. package/clis/reddit/upvoted.js +1 -0
  47. package/clis/reddit/user-comments.js +2 -1
  48. package/clis/reddit/user-posts.js +2 -1
  49. package/clis/reddit/user.js +2 -1
  50. package/clis/twitter/article.js +9 -5
  51. package/clis/twitter/bookmark-folder.js +187 -0
  52. package/clis/twitter/bookmark-folder.test.js +337 -0
  53. package/clis/twitter/bookmark-folders.js +115 -0
  54. package/clis/twitter/bookmark-folders.test.js +152 -0
  55. package/clis/twitter/bookmark.js +15 -6
  56. package/clis/twitter/bookmark.test.js +74 -0
  57. package/clis/twitter/bookmarks.js +10 -10
  58. package/clis/twitter/delete.js +11 -35
  59. package/clis/twitter/delete.test.js +21 -9
  60. package/clis/twitter/download.js +6 -5
  61. package/clis/twitter/followers.js +10 -3
  62. package/clis/twitter/following.js +14 -11
  63. package/clis/twitter/following.test.js +2 -1
  64. package/clis/twitter/hide-reply.js +24 -5
  65. package/clis/twitter/hide-reply.test.js +76 -0
  66. package/clis/twitter/like.js +21 -11
  67. package/clis/twitter/like.test.js +73 -0
  68. package/clis/twitter/likes.js +11 -11
  69. package/clis/twitter/list-add.js +8 -7
  70. package/clis/twitter/list-add.test.js +23 -1
  71. package/clis/twitter/list-remove.js +8 -7
  72. package/clis/twitter/list-remove.test.js +23 -1
  73. package/clis/twitter/list-tweets.js +9 -9
  74. package/clis/twitter/lists.js +6 -8
  75. package/clis/twitter/notifications.js +3 -2
  76. package/clis/twitter/profile.js +11 -7
  77. package/clis/twitter/quote.js +60 -32
  78. package/clis/twitter/quote.test.js +96 -8
  79. package/clis/twitter/reply.js +24 -178
  80. package/clis/twitter/reply.test.js +29 -11
  81. package/clis/twitter/retweet.js +9 -14
  82. package/clis/twitter/retweet.test.js +5 -1
  83. package/clis/twitter/search.js +176 -23
  84. package/clis/twitter/search.test.js +266 -1
  85. package/clis/twitter/shared.js +43 -0
  86. package/clis/twitter/shared.test.js +107 -1
  87. package/clis/twitter/thread.js +11 -11
  88. package/clis/twitter/timeline.js +13 -13
  89. package/clis/twitter/trending.js +4 -4
  90. package/clis/twitter/tweets.js +8 -9
  91. package/clis/twitter/unbookmark.js +13 -6
  92. package/clis/twitter/unbookmark.test.js +73 -0
  93. package/clis/twitter/unlike.js +6 -13
  94. package/clis/twitter/unlike.test.js +5 -2
  95. package/clis/twitter/unretweet.js +9 -14
  96. package/clis/twitter/unretweet.test.js +5 -1
  97. package/clis/twitter/utils.js +286 -0
  98. package/clis/twitter/utils.test.js +169 -0
  99. package/clis/youtube/like.js +6 -2
  100. package/clis/youtube/subscribe.js +6 -2
  101. package/clis/youtube/unlike.js +6 -2
  102. package/clis/youtube/unsubscribe.js +6 -2
  103. package/clis/youtube/utils.js +19 -13
  104. package/clis/youtube/utils.test.js +17 -1
  105. package/dist/src/browser/ax-snapshot.d.ts +37 -0
  106. package/dist/src/browser/ax-snapshot.js +217 -0
  107. package/dist/src/browser/ax-snapshot.test.d.ts +1 -0
  108. package/dist/src/browser/ax-snapshot.test.js +91 -0
  109. package/dist/src/browser/base-page.d.ts +51 -0
  110. package/dist/src/browser/base-page.js +545 -2
  111. package/dist/src/browser/base-page.test.js +520 -4
  112. package/dist/src/browser/bridge.d.ts +1 -0
  113. package/dist/src/browser/bridge.js +1 -1
  114. package/dist/src/browser/cdp-click-fixture.test.d.ts +1 -0
  115. package/dist/src/browser/cdp-click-fixture.test.js +87 -0
  116. package/dist/src/browser/cdp.d.ts +1 -0
  117. package/dist/src/browser/cdp.js +5 -0
  118. package/dist/src/browser/cdp.test.js +1 -0
  119. package/dist/src/browser/daemon-client.d.ts +5 -3
  120. package/dist/src/browser/daemon-client.js +6 -3
  121. package/dist/src/browser/daemon-client.test.js +10 -0
  122. package/dist/src/browser/find.d.ts +9 -1
  123. package/dist/src/browser/find.js +219 -0
  124. package/dist/src/browser/find.test.js +61 -1
  125. package/dist/src/browser/page.d.ts +4 -2
  126. package/dist/src/browser/page.js +18 -1
  127. package/dist/src/browser/page.test.js +28 -0
  128. package/dist/src/browser/target-errors.d.ts +3 -1
  129. package/dist/src/browser/target-errors.js +2 -0
  130. package/dist/src/browser/target-resolver.d.ts +14 -0
  131. package/dist/src/browser/target-resolver.js +28 -0
  132. package/dist/src/browser/visual-refs.d.ts +11 -0
  133. package/dist/src/browser/visual-refs.js +108 -0
  134. package/dist/src/build-manifest.d.ts +23 -0
  135. package/dist/src/build-manifest.js +34 -0
  136. package/dist/src/build-manifest.test.js +108 -1
  137. package/dist/src/cli.js +630 -60
  138. package/dist/src/cli.test.js +731 -1
  139. package/dist/src/commanderAdapter.js +7 -0
  140. package/dist/src/doctor.js +2 -2
  141. package/dist/src/doctor.test.js +4 -4
  142. package/dist/src/execution.d.ts +2 -0
  143. package/dist/src/execution.js +31 -6
  144. package/dist/src/execution.test.js +43 -16
  145. package/dist/src/external-clis.yaml +24 -0
  146. package/dist/src/help.d.ts +33 -0
  147. package/dist/src/help.js +174 -0
  148. package/dist/src/main.js +4 -14
  149. package/dist/src/runtime.d.ts +3 -0
  150. package/dist/src/runtime.js +1 -0
  151. package/dist/src/types.d.ts +83 -1
  152. package/package.json +1 -1
  153. package/scripts/typed-error-lint-baseline.json +18 -18
package/dist/src/cli.js CHANGED
@@ -18,11 +18,11 @@ import { PKG_VERSION } from './version.js';
18
18
  import { printCompletionScript } from './completion.js';
19
19
  import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js';
20
20
  import { registerAllCommands } from './commanderAdapter.js';
21
- import { classifyAdapter, formatRootAdapterHelpText, installStructuredHelp, rootHelpData } from './help.js';
21
+ import { classifyAdapter, formatRootAdapterHelpText, installCommanderNamespaceStructuredHelp, installStructuredHelp, rootHelpData } from './help.js';
22
22
  import { EXIT_CODES, getErrorMessage, BrowserConnectError } from './errors.js';
23
23
  import { TargetError } from './browser/target-errors.js';
24
24
  import { resolveTargetJs, getTextResolvedJs, getValueResolvedJs, getAttributesResolvedJs, selectResolvedJs, isAutocompleteResolvedJs } from './browser/target-resolver.js';
25
- import { buildFindJs, isFindError } from './browser/find.js';
25
+ import { buildFindJs, buildSemanticFindJs, isFindError } from './browser/find.js';
26
26
  import { inferShape } from './browser/shape.js';
27
27
  import { assignKeys } from './browser/network-key.js';
28
28
  import { DEFAULT_TTL_MS, findEntry, loadNetworkCache, saveNetworkCache } from './browser/network-cache.js';
@@ -313,7 +313,7 @@ async function resolveStoredBrowserTarget(page, scope = DEFAULT_BROWSER_WORKSPAC
313
313
  return resolveBrowserTargetInSession(page, defaultPage, { scope, source: 'saved' });
314
314
  }
315
315
  /** Create a browser page for browser commands. Uses a dedicated browser workspace for session persistence. */
316
- async function getBrowserPage(targetPage, workspace = DEFAULT_BROWSER_WORKSPACE, contextId) {
316
+ async function getBrowserPage(targetPage, workspace = DEFAULT_BROWSER_WORKSPACE, contextId, opts = {}) {
317
317
  const { BrowserBridge } = await import('./browser/index.js');
318
318
  const bridge = new BrowserBridge();
319
319
  // Idle timeout: how long the browser workspace lease stays alive between commands
@@ -325,6 +325,7 @@ async function getBrowserPage(targetPage, workspace = DEFAULT_BROWSER_WORKSPACE,
325
325
  workspace,
326
326
  ...(contextId && { contextId }),
327
327
  ...(idleTimeout && idleTimeout > 0 && { idleTimeout }),
328
+ windowMode: opts.windowMode ?? getBrowserWindowMode(undefined, 'foreground'),
328
329
  });
329
330
  const targetScope = getBrowserScope(workspace, contextId);
330
331
  const resolvedTargetPage = targetPage
@@ -338,6 +339,43 @@ async function getBrowserPage(targetPage, workspace = DEFAULT_BROWSER_WORKSPACE,
338
339
  }
339
340
  return page;
340
341
  }
342
+ function getBrowserWindowMode(command, defaultMode) {
343
+ const optionRaw = getCommandOption(command, 'window');
344
+ if (optionRaw !== undefined && optionRaw !== '') {
345
+ if (optionRaw === 'foreground' || optionRaw === 'background')
346
+ return optionRaw;
347
+ throw new Error(`--window must be one of: foreground, background. Received: "${String(optionRaw)}"`);
348
+ }
349
+ const envRaw = process.env.OPENCLI_WINDOW;
350
+ if (envRaw !== undefined && envRaw !== '') {
351
+ if (envRaw === 'foreground' || envRaw === 'background')
352
+ return envRaw;
353
+ throw new Error(`OPENCLI_WINDOW must be one of: foreground, background. Received: "${envRaw}"`);
354
+ }
355
+ return defaultMode;
356
+ }
357
+ function parseBrowserBoolean(name, raw) {
358
+ if (raw === undefined || raw === '')
359
+ return undefined;
360
+ if (raw === 'true')
361
+ return true;
362
+ if (raw === 'false')
363
+ return false;
364
+ throw new Error(`${name} must be one of: true, false. Received: "${String(raw)}"`);
365
+ }
366
+ function getBrowserKeepTab(command, defaultValue) {
367
+ return parseBrowserBoolean('--keep-tab', getCommandOption(command, 'keepTab'))
368
+ ?? parseBrowserBoolean('OPENCLI_KEEP_TAB', process.env.OPENCLI_KEEP_TAB)
369
+ ?? defaultValue;
370
+ }
371
+ function hasExplicitBrowserWindowOption(command) {
372
+ const raw = getCommandOption(command, 'window');
373
+ return raw !== undefined && raw !== '';
374
+ }
375
+ function hasExplicitBrowserKeepTabOption(command) {
376
+ const raw = getCommandOption(command, 'keepTab');
377
+ return raw !== undefined && raw !== '';
378
+ }
341
379
  function addBrowserTabOption(command) {
342
380
  return command.option('--tab <targetId>', BROWSER_TAB_OPTION_DESCRIPTION);
343
381
  }
@@ -373,6 +411,41 @@ function getPageScope(page) {
373
411
  const contextId = page.contextId;
374
412
  return getBrowserScope(getPageWorkspace(page), typeof contextId === 'string' && contextId.trim() ? contextId.trim() : undefined);
375
413
  }
414
+ function snapshotMetricText(snapshot) {
415
+ return typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot, null, 2);
416
+ }
417
+ function snapshotMetrics(snapshot, elapsedMs) {
418
+ const text = snapshotMetricText(snapshot);
419
+ const interactiveMatch = text.match(/^interactive:\s*(\d+)\s*$/m);
420
+ return {
421
+ ok: true,
422
+ chars: text.length,
423
+ bytes: Buffer.byteLength(text, 'utf8'),
424
+ lines: text ? text.split(/\r?\n/).length : 0,
425
+ approx_tokens: Math.ceil(text.length / 4),
426
+ refs: (text.match(/(^|\n)\s*\[\d+\]/g) ?? []).length,
427
+ frame_sections: (text.match(/(^|\n)frame /g) ?? []).length,
428
+ ...(interactiveMatch ? { interactive: Number(interactiveMatch[1]) } : {}),
429
+ elapsed_ms: elapsedMs,
430
+ };
431
+ }
432
+ async function snapshotSourceMetrics(page, source) {
433
+ const started = Date.now();
434
+ try {
435
+ const snapshot = await page.snapshot({ viewportExpand: 2000, source });
436
+ return snapshotMetrics(snapshot, Date.now() - started);
437
+ }
438
+ catch (err) {
439
+ return {
440
+ ok: false,
441
+ elapsed_ms: Date.now() - started,
442
+ error: {
443
+ ...(err instanceof Error && 'code' in err ? { code: String(err.code) } : {}),
444
+ message: err instanceof Error ? err.message : String(err),
445
+ },
446
+ };
447
+ }
448
+ }
376
449
  function resolveBrowserTabTarget(targetId, opts) {
377
450
  if (typeof targetId === 'string' && targetId.trim())
378
451
  return targetId.trim();
@@ -548,7 +621,10 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
548
621
  const browser = program
549
622
  .command('browser')
550
623
  .option('--workspace <name>', 'Browser workspace to use (default: browser:default; bound tabs use bound:<name>)')
624
+ .option('--window <mode>', 'Browser window mode: foreground or background')
625
+ .option('--keep-tab <bool>', 'Keep the browser tab lease after the command finishes')
551
626
  .description('Browser control — navigate, click, type, extract, wait (no LLM needed)');
627
+ const originalBrowserDescription = browser.description();
552
628
  /**
553
629
  * Resolve a `<target>` (numeric ref or CSS selector) via the unified resolver.
554
630
  * Returns the CSS match count so callers can propagate `matches_n` into the
@@ -610,12 +686,20 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
610
686
  /** Wrap browser actions with error handling and optional --json output */
611
687
  function browserAction(fn) {
612
688
  return async (...args) => {
689
+ let page = null;
690
+ let shouldReleasePage = false;
613
691
  try {
614
692
  const command = args.at(-1) instanceof Command ? args.at(-1) : undefined;
615
693
  const targetPage = getBrowserTargetId(command);
616
694
  const workspace = getBrowserWorkspace(command);
617
695
  const contextId = getBrowserContextId(command);
618
- const page = await getBrowserPage(targetPage, workspace, contextId);
696
+ const windowMode = getBrowserWindowMode(command, 'foreground');
697
+ const keepTab = getBrowserKeepTab(command, true);
698
+ shouldReleasePage = !keepTab && !workspace.startsWith('bound:');
699
+ if (workspace.startsWith('bound:') && (hasExplicitBrowserWindowOption(command) || hasExplicitBrowserKeepTabOption(command))) {
700
+ log.warn('--window/--keep-tab ignored for bound:* workspaces; bound tabs are user-owned.');
701
+ }
702
+ page = await getBrowserPage(targetPage, workspace, contextId, { windowMode });
619
703
  await fn(page, ...args);
620
704
  }
621
705
  catch (err) {
@@ -663,6 +747,14 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
663
747
  }
664
748
  process.exitCode = EXIT_CODES.GENERIC_ERROR;
665
749
  }
750
+ finally {
751
+ if (shouldReleasePage && page?.closeWindow) {
752
+ await page.closeWindow().catch((err) => {
753
+ if (process.env.OPENCLI_VERBOSE)
754
+ log.warn(`[browser] Failed to release tab lease: ${getErrorMessage(err)}`);
755
+ });
756
+ }
757
+ }
666
758
  };
667
759
  }
668
760
  browser.command('bind')
@@ -887,9 +979,33 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
887
979
  console.log(`Scrolled ${direction}`);
888
980
  }));
889
981
  // ── Inspect ──
890
- addBrowserTabOption(browser.command('state').description('Page state: URL, title, interactive elements with [N] indices'))
891
- .action(browserAction(async (page) => {
892
- const snapshot = await page.snapshot({ viewportExpand: 2000 });
982
+ addBrowserTabOption(browser.command('state').description('Page state: URL, title, interactive elements with [N] indices')
983
+ .option('--source <source>', 'Snapshot backend: dom (default) or ax prototype', 'dom')
984
+ .option('--compare-sources', 'Print DOM vs AX snapshot metrics for observation promotion decisions', false))
985
+ .action(browserAction(async (page, opts) => {
986
+ if (opts.compareSources === true) {
987
+ const [dom, ax] = await Promise.all([
988
+ snapshotSourceMetrics(page, 'dom'),
989
+ snapshotSourceMetrics(page, 'ax'),
990
+ ]);
991
+ console.log(JSON.stringify({
992
+ url: await page.getCurrentUrl?.() ?? '',
993
+ sources: { dom, ax },
994
+ }, null, 2));
995
+ return;
996
+ }
997
+ const source = String(opts.source ?? 'dom').toLowerCase();
998
+ if (source !== 'dom' && source !== 'ax') {
999
+ console.log(JSON.stringify({
1000
+ error: {
1001
+ code: 'invalid_source',
1002
+ message: `--source must be "dom" or "ax", got "${opts.source}"`,
1003
+ },
1004
+ }, null, 2));
1005
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
1006
+ return;
1007
+ }
1008
+ const snapshot = await page.snapshot({ viewportExpand: 2000, source: source });
893
1009
  const url = await page.getCurrentUrl?.() ?? '';
894
1010
  console.log(`URL: ${url}\n`);
895
1011
  console.log(typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot, null, 2));
@@ -901,21 +1017,26 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
901
1017
  }));
902
1018
  addBrowserTabOption(browser.command('screenshot').argument('[path]', 'Save to file (base64 if omitted)'))
903
1019
  .option('--full-page', 'Capture the full scrollable page, not just the viewport', false)
1020
+ .option('--annotate', 'Overlay visible browser state ref labels on the screenshot', false)
904
1021
  .option('--width <n>', 'Override viewport width in CSS pixels for this screenshot only', (v) => parseScreenshotDim(v, 'width'))
905
1022
  .option('--height <n>', 'Override viewport height in CSS pixels for this screenshot only (ignored with --full-page)', (v) => parseScreenshotDim(v, 'height'))
906
1023
  .description('Take screenshot')
907
1024
  .action(browserAction(async (page, path, opts) => {
908
1025
  const shotOpts = {
909
1026
  fullPage: opts.fullPage === true,
1027
+ annotate: opts.annotate === true,
910
1028
  width: opts.width,
911
1029
  height: opts.height,
912
1030
  };
1031
+ const capture = opts.annotate === true
1032
+ ? (page.annotatedScreenshot ?? page.screenshot).bind(page)
1033
+ : page.screenshot.bind(page);
913
1034
  if (path) {
914
- await page.screenshot({ ...shotOpts, path });
1035
+ await capture({ ...shotOpts, path });
915
1036
  console.log(`Screenshot saved to: ${path}`);
916
1037
  }
917
1038
  else {
918
- console.log(await page.screenshot({ ...shotOpts, format: 'png' }));
1039
+ console.log(await capture({ ...shotOpts, format: 'png' }));
919
1040
  }
920
1041
  }));
921
1042
  addBrowserTabOption(browser.command('console'))
@@ -1053,18 +1174,200 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1053
1174
  // `browser find --css <sel>` lets agents jump straight from a semantic
1054
1175
  // selector to a JSON list of matching elements, without having to parse
1055
1176
  // the free-text state snapshot to recover indices.
1056
- addBrowserTabOption(browser.command('find')
1177
+ const addSemanticLocatorOptions = (cmd) => cmd
1178
+ .option('--role <role>', 'Semantic role (button, link, textbox, option, etc.)')
1179
+ .option('--name <text>', 'Accessible name contains text (aria-label, label, title, placeholder, or visible text)')
1180
+ .option('--label <text>', 'Associated label contains text')
1181
+ .option('--text <text>', 'Visible text contains text')
1182
+ .option('--testid <id>', 'data-testid / data-test / test-id contains id');
1183
+ const addPrefixedSemanticLocatorOptions = (cmd, prefix) => cmd
1184
+ .option(`--${prefix}-role <role>`, `${prefix} semantic role`)
1185
+ .option(`--${prefix}-name <text>`, `${prefix} accessible name contains text`)
1186
+ .option(`--${prefix}-label <text>`, `${prefix} associated label contains text`)
1187
+ .option(`--${prefix}-text <text>`, `${prefix} visible text contains text`)
1188
+ .option(`--${prefix}-testid <id>`, `${prefix} data-testid / data-test / test-id contains id`);
1189
+ const semanticLocatorFromOptions = (opts) => {
1190
+ const locator = {};
1191
+ for (const key of ['role', 'name', 'label', 'text', 'testid']) {
1192
+ const value = opts[key];
1193
+ if (typeof value === 'string' && value.trim())
1194
+ locator[key] = value.trim();
1195
+ }
1196
+ return Object.keys(locator).length > 0 ? locator : null;
1197
+ };
1198
+ const prefixedSemanticLocatorFromOptions = (opts, prefix) => {
1199
+ const locator = {};
1200
+ const map = {
1201
+ role: `${prefix}Role`,
1202
+ name: `${prefix}Name`,
1203
+ label: `${prefix}Label`,
1204
+ text: `${prefix}Text`,
1205
+ testid: `${prefix}Testid`,
1206
+ };
1207
+ for (const key of ['role', 'name', 'label', 'text', 'testid']) {
1208
+ const value = opts[map[key]];
1209
+ if (typeof value === 'string' && value.trim())
1210
+ locator[key] = value.trim();
1211
+ }
1212
+ return Object.keys(locator).length > 0 ? locator : null;
1213
+ };
1214
+ const semanticTargetFromLocator = async (page, locator, mode) => {
1215
+ const result = await page.evaluate(buildSemanticFindJs({ ...locator, limit: 6 }));
1216
+ if (isFindError(result))
1217
+ return result;
1218
+ if (mode === 'write' && result.matches_n !== 1) {
1219
+ return {
1220
+ error: {
1221
+ code: 'semantic_ambiguous',
1222
+ message: `Semantic locator matched ${result.matches_n} elements; write actions require a unique target.`,
1223
+ hint: 'Add --name/--label/--text/--testid or use browser find with a narrower locator.',
1224
+ matches_n: result.matches_n,
1225
+ entries: result.entries,
1226
+ },
1227
+ };
1228
+ }
1229
+ const first = result.entries[0];
1230
+ if (!first) {
1231
+ return {
1232
+ error: {
1233
+ code: 'semantic_not_found',
1234
+ message: 'Semantic locator matched 0 elements',
1235
+ hint: 'Try browser state, --source ax, or relax the semantic locator.',
1236
+ },
1237
+ };
1238
+ }
1239
+ const target = String(first.ref);
1240
+ if (mode === 'read') {
1241
+ return {
1242
+ target,
1243
+ ...(result.matches_n > 1 ? { total_matches: result.matches_n } : {}),
1244
+ };
1245
+ }
1246
+ return target;
1247
+ };
1248
+ const semanticTargetFromOptions = async (page, opts, mode) => {
1249
+ const locator = semanticLocatorFromOptions(opts);
1250
+ if (!locator)
1251
+ return null;
1252
+ return semanticTargetFromLocator(page, locator, mode);
1253
+ };
1254
+ const resolveExplicitOrSemanticTarget = async (page, target, opts, mode) => {
1255
+ const explicit = typeof target === 'string' && target.trim() ? target.trim() : '';
1256
+ const hasSemantic = !!semanticLocatorFromOptions(opts);
1257
+ if (explicit && hasSemantic) {
1258
+ return {
1259
+ error: {
1260
+ code: 'usage_error',
1261
+ message: 'Pass either <target> or semantic locator flags, not both.',
1262
+ },
1263
+ };
1264
+ }
1265
+ if (explicit)
1266
+ return explicit;
1267
+ const semantic = await semanticTargetFromOptions(page, opts, mode);
1268
+ if (semantic)
1269
+ return semantic;
1270
+ return {
1271
+ error: {
1272
+ code: 'usage_error',
1273
+ message: 'Missing target. Pass a numeric ref/CSS selector, or semantic flags like --role button --name Submit.',
1274
+ },
1275
+ };
1276
+ };
1277
+ const printTargetResolutionError = (resolved) => {
1278
+ console.log(JSON.stringify(resolved, null, 2));
1279
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
1280
+ };
1281
+ const resolveWriteTargetOrPrint = async (page, target, opts) => {
1282
+ const resolvedTarget = await resolveExplicitOrSemanticTarget(page, target, opts, 'write');
1283
+ if (typeof resolvedTarget === 'string')
1284
+ return resolvedTarget;
1285
+ if ('error' in resolvedTarget)
1286
+ printTargetResolutionError(resolvedTarget);
1287
+ return null;
1288
+ };
1289
+ const resolveWriteTargetAndValueOrPrint = async (page, targetOrValue, value, opts, valueLabel) => {
1290
+ const hasSemantic = !!semanticLocatorFromOptions(opts);
1291
+ if (hasSemantic && value !== undefined) {
1292
+ printTargetResolutionError({
1293
+ error: {
1294
+ code: 'usage_error',
1295
+ message: `When using semantic locator flags, pass only <${valueLabel}> as the positional argument.`,
1296
+ },
1297
+ });
1298
+ return null;
1299
+ }
1300
+ const resolvedValue = hasSemantic ? targetOrValue : value;
1301
+ if (resolvedValue === undefined) {
1302
+ printTargetResolutionError({
1303
+ error: {
1304
+ code: 'usage_error',
1305
+ message: `Missing ${valueLabel}.`,
1306
+ hint: hasSemantic
1307
+ ? `With semantic locator flags, pass the ${valueLabel} as the only positional argument.`
1308
+ : `Pass both a target and ${valueLabel}.`,
1309
+ },
1310
+ });
1311
+ return null;
1312
+ }
1313
+ const resolvedTarget = await resolveWriteTargetOrPrint(page, hasSemantic ? undefined : targetOrValue, opts);
1314
+ if (!resolvedTarget)
1315
+ return null;
1316
+ return { target: resolvedTarget, value: String(resolvedValue) };
1317
+ };
1318
+ const resolvePrefixedWriteTargetOrPrint = async (page, target, opts, prefix, label) => {
1319
+ const explicit = typeof target === 'string' && target.trim() ? target.trim() : '';
1320
+ const locator = prefixedSemanticLocatorFromOptions(opts, prefix);
1321
+ if (explicit && locator) {
1322
+ printTargetResolutionError({
1323
+ error: {
1324
+ code: 'usage_error',
1325
+ message: `Pass either <${label}> or --${prefix}-* semantic locator flags, not both.`,
1326
+ },
1327
+ });
1328
+ return null;
1329
+ }
1330
+ if (explicit)
1331
+ return explicit;
1332
+ if (locator) {
1333
+ const resolved = await semanticTargetFromLocator(page, locator, 'write');
1334
+ if (typeof resolved === 'string')
1335
+ return resolved;
1336
+ if ('error' in resolved)
1337
+ printTargetResolutionError(resolved);
1338
+ return null;
1339
+ }
1340
+ printTargetResolutionError({
1341
+ error: {
1342
+ code: 'usage_error',
1343
+ message: `Missing ${label}. Pass a numeric ref/CSS selector, or --${prefix}-role/--${prefix}-name semantic flags.`,
1344
+ },
1345
+ });
1346
+ return null;
1347
+ };
1348
+ addBrowserTabOption(addSemanticLocatorOptions(browser.command('find'))
1057
1349
  .option('--css <selector>', 'CSS selector (required)')
1058
1350
  .option('--limit <n>', 'Max entries returned', '50')
1059
1351
  .option('--text-max <n>', 'Max chars of trimmed text per entry', '120')
1060
- .description('Find DOM elements by CSS selector — returns JSON {matches_n, entries[]}'))
1352
+ .description('Find DOM elements by CSS or semantic locator — returns JSON {matches_n, entries[]}'))
1061
1353
  .action(browserAction(async (page, opts) => {
1062
- if (!opts.css || typeof opts.css !== 'string') {
1354
+ const locator = semanticLocatorFromOptions(opts);
1355
+ if ((!opts.css || typeof opts.css !== 'string') && !locator) {
1356
+ console.log(JSON.stringify({
1357
+ error: {
1358
+ code: 'usage_error',
1359
+ message: '--css <selector> or a semantic locator flag is required',
1360
+ hint: 'Examples: opencli browser find --css ".btn.primary"; opencli browser find --role button --name Save',
1361
+ },
1362
+ }, null, 2));
1363
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
1364
+ return;
1365
+ }
1366
+ if (opts.css && locator) {
1063
1367
  console.log(JSON.stringify({
1064
1368
  error: {
1065
1369
  code: 'usage_error',
1066
- message: '--css <selector> is required',
1067
- hint: 'Example: opencli browser find --css ".btn.primary"',
1370
+ message: 'Pass either --css or semantic locator flags, not both.',
1068
1371
  },
1069
1372
  }, null, 2));
1070
1373
  process.exitCode = EXIT_CODES.USAGE_ERROR;
@@ -1082,10 +1385,16 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1082
1385
  process.exitCode = EXIT_CODES.USAGE_ERROR;
1083
1386
  return;
1084
1387
  }
1085
- const result = await page.evaluate(buildFindJs(opts.css, {
1086
- limit: limit ?? undefined,
1087
- textMax: textMax ?? undefined,
1088
- }));
1388
+ const result = await page.evaluate(locator
1389
+ ? buildSemanticFindJs({
1390
+ ...locator,
1391
+ limit: limit ?? undefined,
1392
+ textMax: textMax ?? undefined,
1393
+ })
1394
+ : buildFindJs(opts.css, {
1395
+ limit: limit ?? undefined,
1396
+ textMax: textMax ?? undefined,
1397
+ }));
1089
1398
  if (isFindError(result)) {
1090
1399
  console.log(JSON.stringify(result, null, 2));
1091
1400
  process.exitCode = EXIT_CODES.GENERIC_ERROR;
@@ -1112,13 +1421,21 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1112
1421
  // or a CSS selector. On multi-match CSS, the first element wins and the real
1113
1422
  // match count is exposed via `matches_n`; `--nth <n>` picks a specific one.
1114
1423
  const runGetCommand = async (page, target, opts, evalJs, field) => {
1424
+ const resolvedTarget = await resolveExplicitOrSemanticTarget(page, target, opts, 'read');
1425
+ if (typeof resolvedTarget !== 'string' && 'error' in resolvedTarget) {
1426
+ console.log(JSON.stringify(resolvedTarget, null, 2));
1427
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
1428
+ return;
1429
+ }
1430
+ const targetRef = typeof resolvedTarget === 'string' ? resolvedTarget : resolvedTarget.target;
1431
+ const totalMatches = typeof resolvedTarget === 'string' ? undefined : resolvedTarget.total_matches;
1115
1432
  const nth = parseNthFlag(opts.nth);
1116
1433
  if (nth && typeof nth === 'object' && 'error' in nth) {
1117
1434
  console.log(JSON.stringify({ error: { code: 'usage_error', message: nth.error } }, null, 2));
1118
1435
  process.exitCode = EXIT_CODES.USAGE_ERROR;
1119
1436
  return;
1120
1437
  }
1121
- const { matches_n, match_level } = await resolveRef(page, String(target), {
1438
+ const { matches_n, match_level } = await resolveRef(page, targetRef, {
1122
1439
  firstOnMulti: nth === null,
1123
1440
  ...(typeof nth === 'number' ? { nth } : {}),
1124
1441
  });
@@ -1137,18 +1454,23 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1137
1454
  else {
1138
1455
  value = raw ?? null;
1139
1456
  }
1140
- console.log(JSON.stringify({ value, matches_n, match_level }, null, 2));
1457
+ console.log(JSON.stringify({
1458
+ value,
1459
+ matches_n,
1460
+ match_level,
1461
+ ...(totalMatches && totalMatches > 1 ? { total_matches: totalMatches } : {}),
1462
+ }, null, 2));
1141
1463
  };
1142
- addBrowserTabOption(get.command('text')
1143
- .argument('<target>', 'Numeric ref (from browser state / find) or CSS selector')
1464
+ addBrowserTabOption(addSemanticLocatorOptions(get.command('text'))
1465
+ .argument('[target]', 'Numeric ref (from browser state / find), CSS selector, or omit when using --role/--name/etc.')
1144
1466
  .option('--nth <n>', 'Pick the nth match (0-based) when <target> is a multi-match CSS selector')
1145
1467
  .description('Element text content — JSON envelope {value, matches_n}'))
1146
- .action(browserAction(async (page, target, opts) => runGetCommand(page, String(target), opts ?? {}, getTextResolvedJs(), 'text')));
1147
- addBrowserTabOption(get.command('value')
1148
- .argument('<target>', 'Numeric ref (from browser state / find) or CSS selector')
1468
+ .action(browserAction(async (page, target, opts) => runGetCommand(page, target, opts ?? {}, getTextResolvedJs(), 'text')));
1469
+ addBrowserTabOption(addSemanticLocatorOptions(get.command('value'))
1470
+ .argument('[target]', 'Numeric ref (from browser state / find), CSS selector, or omit when using --role/--name/etc.')
1149
1471
  .option('--nth <n>', 'Pick the nth match (0-based) when <target> is a multi-match CSS selector')
1150
1472
  .description('Input/textarea value — JSON envelope {value, matches_n}'))
1151
- .action(browserAction(async (page, target, opts) => runGetCommand(page, String(target), opts ?? {}, getValueResolvedJs(), 'value')));
1473
+ .action(browserAction(async (page, target, opts) => runGetCommand(page, target, opts ?? {}, getValueResolvedJs(), 'value')));
1152
1474
  addBrowserTabOption(get.command('html')
1153
1475
  .option('--selector <css>', 'CSS selector scope (first match)')
1154
1476
  .option('--as <format>', 'Output format: "html" (default) or "json" for structured tree', 'html')
@@ -1263,11 +1585,11 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1263
1585
  }
1264
1586
  console.log(html);
1265
1587
  }));
1266
- addBrowserTabOption(get.command('attributes')
1267
- .argument('<target>', 'Numeric ref (from browser state / find) or CSS selector')
1588
+ addBrowserTabOption(addSemanticLocatorOptions(get.command('attributes'))
1589
+ .argument('[target]', 'Numeric ref (from browser state / find), CSS selector, or omit when using --role/--name/etc.')
1268
1590
  .option('--nth <n>', 'Pick the nth match (0-based) when <target> is a multi-match CSS selector')
1269
1591
  .description('Element attributes — JSON envelope {value, matches_n}'))
1270
- .action(browserAction(async (page, target, opts) => runGetCommand(page, String(target), opts ?? {}, getAttributesResolvedJs(), 'attributes')));
1592
+ .action(browserAction(async (page, target, opts) => runGetCommand(page, target, opts ?? {}, getAttributesResolvedJs(), 'attributes')));
1271
1593
  // ── Interact ──
1272
1594
  //
1273
1595
  // Write commands (`click/type/select`) share the same `<target>` contract
@@ -1290,26 +1612,73 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1290
1612
  return { opts: { nth: parsed } };
1291
1613
  return { opts: {} };
1292
1614
  }
1293
- addBrowserTabOption(browser.command('click')
1294
- .argument('<target>', 'Numeric ref (from browser state / find) or CSS selector')
1615
+ function resolveUploadFilePaths(rawFiles) {
1616
+ const inputs = Array.isArray(rawFiles) ? rawFiles : [];
1617
+ if (inputs.length === 0) {
1618
+ return {
1619
+ error: {
1620
+ code: 'usage_error',
1621
+ message: 'At least one file path is required.',
1622
+ hint: 'Example: opencli browser upload "input[type=file]" ./receipt.pdf',
1623
+ },
1624
+ };
1625
+ }
1626
+ const files = [];
1627
+ for (const input of inputs) {
1628
+ const raw = String(input);
1629
+ const expanded = raw === '~' || raw.startsWith(`~${path.sep}`)
1630
+ ? path.join(os.homedir(), raw.slice(2))
1631
+ : raw;
1632
+ const resolved = path.resolve(expanded);
1633
+ if (!fs.existsSync(resolved)) {
1634
+ return { error: { code: 'file_not_found', message: `File not found: ${resolved}` } };
1635
+ }
1636
+ const stat = fs.statSync(resolved);
1637
+ if (!stat.isFile()) {
1638
+ return { error: { code: 'not_a_file', message: `Not a regular file: ${resolved}` } };
1639
+ }
1640
+ files.push(resolved);
1641
+ }
1642
+ return { files };
1643
+ }
1644
+ function parseResolveFlag(raw, flag) {
1645
+ const parsed = parseNthFlag(raw);
1646
+ if (parsed && typeof parsed === 'object' && 'error' in parsed) {
1647
+ return { error: parsed.error.replace('--nth', flag) };
1648
+ }
1649
+ if (typeof parsed === 'number')
1650
+ return { opts: { nth: parsed } };
1651
+ return { opts: {} };
1652
+ }
1653
+ addBrowserTabOption(addSemanticLocatorOptions(browser.command('click'))
1654
+ .argument('[target]', 'Numeric ref (from browser state / find), CSS selector, or omit when using --role/--name/etc.')
1295
1655
  .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
1296
1656
  .description('Click element — JSON envelope {clicked, target, matches_n}'))
1297
1657
  .action(browserAction(async (page, target, opts) => {
1658
+ const resolvedTarget = await resolveExplicitOrSemanticTarget(page, target, opts ?? {}, 'write');
1659
+ if (typeof resolvedTarget !== 'string') {
1660
+ console.log(JSON.stringify(resolvedTarget, null, 2));
1661
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
1662
+ return;
1663
+ }
1298
1664
  const parsed = nthToResolveOpts(opts?.nth);
1299
1665
  if ('error' in parsed) {
1300
1666
  console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
1301
1667
  process.exitCode = EXIT_CODES.USAGE_ERROR;
1302
1668
  return;
1303
1669
  }
1304
- const { matches_n, match_level } = await page.click(String(target), parsed.opts);
1305
- console.log(JSON.stringify({ clicked: true, target: String(target), matches_n, match_level }, null, 2));
1670
+ const { matches_n, match_level } = await page.click(resolvedTarget, parsed.opts);
1671
+ console.log(JSON.stringify({ clicked: true, target: resolvedTarget, matches_n, match_level }, null, 2));
1306
1672
  }));
1307
- addBrowserTabOption(browser.command('type')
1308
- .argument('<target>', 'Numeric ref (from browser state / find) or CSS selector')
1309
- .argument('<text>', 'Text to type')
1673
+ addBrowserTabOption(addSemanticLocatorOptions(browser.command('type'))
1674
+ .argument('[targetOrText]', 'Numeric ref/CSS target, or text when using --role/--name/etc.')
1675
+ .argument('[text]', 'Text to type')
1310
1676
  .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
1311
1677
  .description('Click element, then type text — JSON envelope {typed, text, target, matches_n, autocomplete}'))
1312
- .action(browserAction(async (page, target, text, opts) => {
1678
+ .action(browserAction(async (page, targetOrText, text, opts) => {
1679
+ const resolved = await resolveWriteTargetAndValueOrPrint(page, targetOrText, text, opts ?? {}, 'text');
1680
+ if (!resolved)
1681
+ return;
1313
1682
  const parsed = nthToResolveOpts(opts?.nth);
1314
1683
  if ('error' in parsed) {
1315
1684
  console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
@@ -1317,42 +1686,199 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1317
1686
  return;
1318
1687
  }
1319
1688
  // Click first (focuses the field), wait briefly, then type.
1320
- await page.click(String(target), parsed.opts);
1689
+ await page.click(resolved.target, parsed.opts);
1321
1690
  await page.wait(0.3);
1322
- const { matches_n, match_level } = await page.typeText(String(target), String(text), parsed.opts);
1691
+ const { matches_n, match_level } = await page.typeText(resolved.target, resolved.value, parsed.opts);
1323
1692
  // __resolved is already set by the resolver call inside page.typeText
1324
1693
  const isAutocomplete = await page.evaluate(isAutocompleteResolvedJs());
1325
1694
  if (isAutocomplete)
1326
1695
  await page.wait(0.4);
1327
1696
  console.log(JSON.stringify({
1328
1697
  typed: true,
1329
- text: String(text),
1330
- target: String(target),
1698
+ text: resolved.value,
1699
+ target: resolved.target,
1331
1700
  matches_n,
1332
1701
  match_level,
1333
1702
  autocomplete: !!isAutocomplete,
1334
1703
  }, null, 2));
1335
1704
  }));
1336
- addBrowserTabOption(browser.command('fill')
1337
- .argument('<target>', 'Numeric ref (from browser state / find) or CSS selector')
1338
- .argument('<text>', 'Text to set exactly')
1705
+ addBrowserTabOption(addSemanticLocatorOptions(browser.command('hover'))
1706
+ .argument('[target]', 'Numeric ref (from browser state / find), CSS selector, or omit when using --role/--name/etc.')
1707
+ .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
1708
+ .description('Move the mouse over an element — JSON envelope {hovered, target, matches_n}'))
1709
+ .action(browserAction(async (page, target, opts) => {
1710
+ if (typeof page.hover !== 'function')
1711
+ throw new Error('browser hover is not supported by this browser backend');
1712
+ const resolvedTarget = await resolveWriteTargetOrPrint(page, target, opts ?? {});
1713
+ if (!resolvedTarget)
1714
+ return;
1715
+ const parsed = nthToResolveOpts(opts?.nth);
1716
+ if ('error' in parsed) {
1717
+ console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
1718
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
1719
+ return;
1720
+ }
1721
+ const { matches_n, match_level } = await page.hover(resolvedTarget, parsed.opts);
1722
+ console.log(JSON.stringify({ hovered: true, target: resolvedTarget, matches_n, match_level }, null, 2));
1723
+ }));
1724
+ addBrowserTabOption(addSemanticLocatorOptions(browser.command('focus'))
1725
+ .argument('[target]', 'Numeric ref (from browser state / find), CSS selector, or omit when using --role/--name/etc.')
1726
+ .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
1727
+ .description('Focus an element — JSON envelope {focused, target, matches_n}'))
1728
+ .action(browserAction(async (page, target, opts) => {
1729
+ if (typeof page.focus !== 'function')
1730
+ throw new Error('browser focus is not supported by this browser backend');
1731
+ const resolvedTarget = await resolveWriteTargetOrPrint(page, target, opts ?? {});
1732
+ if (!resolvedTarget)
1733
+ return;
1734
+ const parsed = nthToResolveOpts(opts?.nth);
1735
+ if ('error' in parsed) {
1736
+ console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
1737
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
1738
+ return;
1739
+ }
1740
+ const { focused, matches_n, match_level } = await page.focus(resolvedTarget, parsed.opts);
1741
+ console.log(JSON.stringify({ focused, target: resolvedTarget, matches_n, match_level }, null, 2));
1742
+ }));
1743
+ addBrowserTabOption(addSemanticLocatorOptions(browser.command('dblclick'))
1744
+ .argument('[target]', 'Numeric ref (from browser state / find), CSS selector, or omit when using --role/--name/etc.')
1745
+ .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
1746
+ .description('Double-click element — JSON envelope {dblclicked, target, matches_n}'))
1747
+ .action(browserAction(async (page, target, opts) => {
1748
+ if (typeof page.dblClick !== 'function')
1749
+ throw new Error('browser dblclick is not supported by this browser backend');
1750
+ const resolvedTarget = await resolveWriteTargetOrPrint(page, target, opts ?? {});
1751
+ if (!resolvedTarget)
1752
+ return;
1753
+ const parsed = nthToResolveOpts(opts?.nth);
1754
+ if ('error' in parsed) {
1755
+ console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
1756
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
1757
+ return;
1758
+ }
1759
+ const { matches_n, match_level } = await page.dblClick(resolvedTarget, parsed.opts);
1760
+ console.log(JSON.stringify({ dblclicked: true, target: resolvedTarget, matches_n, match_level }, null, 2));
1761
+ }));
1762
+ const runCheckCommand = async (page, target, opts, checked) => {
1763
+ if (typeof page.setChecked !== 'function')
1764
+ throw new Error(`browser ${checked ? 'check' : 'uncheck'} is not supported by this browser backend`);
1765
+ const resolvedTarget = await resolveWriteTargetOrPrint(page, target, opts);
1766
+ if (!resolvedTarget)
1767
+ return;
1768
+ const parsed = nthToResolveOpts(opts?.nth);
1769
+ if ('error' in parsed) {
1770
+ console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
1771
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
1772
+ return;
1773
+ }
1774
+ const result = await page.setChecked(resolvedTarget, checked, parsed.opts);
1775
+ console.log(JSON.stringify({
1776
+ checked: result.checked,
1777
+ changed: result.changed,
1778
+ target: resolvedTarget,
1779
+ matches_n: result.matches_n,
1780
+ match_level: result.match_level,
1781
+ ...(result.kind ? { kind: result.kind } : {}),
1782
+ }, null, 2));
1783
+ };
1784
+ addBrowserTabOption(addSemanticLocatorOptions(browser.command('check'))
1785
+ .argument('[target]', 'Numeric ref (from browser state / find), CSS selector, or omit when using --role/--name/etc.')
1786
+ .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
1787
+ .description('Ensure a checkbox/radio/aria-checked control is checked — JSON envelope {checked, changed, target, matches_n}'))
1788
+ .action(browserAction(async (page, target, opts) => {
1789
+ await runCheckCommand(page, target, opts ?? {}, true);
1790
+ }));
1791
+ addBrowserTabOption(addSemanticLocatorOptions(browser.command('uncheck'))
1792
+ .argument('[target]', 'Numeric ref (from browser state / find), CSS selector, or omit when using --role/--name/etc.')
1793
+ .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
1794
+ .description('Ensure a checkbox/aria-checked control is unchecked — JSON envelope {checked, changed, target, matches_n}'))
1795
+ .action(browserAction(async (page, target, opts) => {
1796
+ await runCheckCommand(page, target, opts ?? {}, false);
1797
+ }));
1798
+ addBrowserTabOption(addSemanticLocatorOptions(browser.command('upload'))
1799
+ .argument('[targetOrFile]', 'Numeric ref/CSS target, or first file when using --role/--name/etc.')
1800
+ .argument('[files...]', 'Local file path(s) to attach')
1801
+ .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
1802
+ .description('Attach local files to a file input — JSON envelope {uploaded, files, file_names, target, matches_n}'))
1803
+ .action(browserAction(async (page, targetOrFile, files, opts) => {
1804
+ if (typeof page.uploadFiles !== 'function')
1805
+ throw new Error('browser upload is not supported by this browser backend');
1806
+ const hasSemantic = !!semanticLocatorFromOptions(opts ?? {});
1807
+ const target = hasSemantic ? undefined : targetOrFile;
1808
+ const resolvedTarget = await resolveWriteTargetOrPrint(page, target, opts ?? {});
1809
+ if (!resolvedTarget)
1810
+ return;
1811
+ const parsed = nthToResolveOpts(opts?.nth);
1812
+ if ('error' in parsed) {
1813
+ console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
1814
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
1815
+ return;
1816
+ }
1817
+ const rawFiles = hasSemantic
1818
+ ? [targetOrFile, ...(Array.isArray(files) ? files : [])].filter((value) => value !== undefined)
1819
+ : files;
1820
+ const resolvedFiles = resolveUploadFilePaths(rawFiles);
1821
+ if ('error' in resolvedFiles) {
1822
+ console.log(JSON.stringify({ error: resolvedFiles.error }, null, 2));
1823
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
1824
+ return;
1825
+ }
1826
+ const result = await page.uploadFiles(resolvedTarget, resolvedFiles.files, parsed.opts);
1827
+ console.log(JSON.stringify(result, null, 2));
1828
+ }));
1829
+ addBrowserTabOption(addPrefixedSemanticLocatorOptions(addPrefixedSemanticLocatorOptions(browser.command('drag'), 'from'), 'to')
1830
+ .argument('[source]', 'Numeric ref/CSS selector to drag from, or omit with --from-role/--from-name/etc.')
1831
+ .argument('[target]', 'Numeric ref/CSS selector to drop onto, or omit with --to-role/--to-name/etc.')
1832
+ .option('--from-nth <n>', 'When <source> is a multi-match CSS selector, pick the nth match (0-based)')
1833
+ .option('--to-nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
1834
+ .description('Drag one element to another — JSON envelope {dragged, source, target, source_matches_n, target_matches_n}'))
1835
+ .action(browserAction(async (page, source, target, opts) => {
1836
+ if (typeof page.drag !== 'function')
1837
+ throw new Error('browser drag is not supported by this browser backend');
1838
+ const resolvedSource = await resolvePrefixedWriteTargetOrPrint(page, source, opts ?? {}, 'from', 'source');
1839
+ if (!resolvedSource)
1840
+ return;
1841
+ const resolvedTarget = await resolvePrefixedWriteTargetOrPrint(page, target, opts ?? {}, 'to', 'target');
1842
+ if (!resolvedTarget)
1843
+ return;
1844
+ const from = parseResolveFlag(opts?.fromNth, '--from-nth');
1845
+ if ('error' in from) {
1846
+ console.log(JSON.stringify({ error: { code: 'usage_error', message: from.error } }, null, 2));
1847
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
1848
+ return;
1849
+ }
1850
+ const to = parseResolveFlag(opts?.toNth, '--to-nth');
1851
+ if ('error' in to) {
1852
+ console.log(JSON.stringify({ error: { code: 'usage_error', message: to.error } }, null, 2));
1853
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
1854
+ return;
1855
+ }
1856
+ const result = await page.drag(resolvedSource, resolvedTarget, { from: from.opts, to: to.opts });
1857
+ console.log(JSON.stringify(result, null, 2));
1858
+ }));
1859
+ addBrowserTabOption(addSemanticLocatorOptions(browser.command('fill'))
1860
+ .argument('[targetOrText]', 'Numeric ref/CSS target, or text when using --role/--name/etc.')
1861
+ .argument('[text]', 'Text to set exactly')
1339
1862
  .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
1340
1863
  .description('Set input/textarea/contenteditable text exactly and verify the value — JSON envelope {filled, verified, text, actual}'))
1341
- .action(browserAction(async (page, target, text, opts) => {
1864
+ .action(browserAction(async (page, targetOrText, text, opts) => {
1865
+ const resolved = await resolveWriteTargetAndValueOrPrint(page, targetOrText, text, opts ?? {}, 'text');
1866
+ if (!resolved)
1867
+ return;
1342
1868
  const parsed = nthToResolveOpts(opts?.nth);
1343
1869
  if ('error' in parsed) {
1344
1870
  console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
1345
1871
  process.exitCode = EXIT_CODES.USAGE_ERROR;
1346
1872
  return;
1347
1873
  }
1348
- const result = await page.fillText(String(target), String(text), parsed.opts);
1874
+ const result = await page.fillText(resolved.target, resolved.value, parsed.opts);
1349
1875
  if (!result.verified)
1350
1876
  process.exitCode = EXIT_CODES.GENERIC_ERROR;
1351
1877
  console.log(JSON.stringify({
1352
1878
  filled: result.filled,
1353
1879
  verified: result.verified,
1354
- target: String(target),
1355
- text: String(text),
1880
+ target: resolved.target,
1881
+ text: resolved.value,
1356
1882
  actual: result.actual,
1357
1883
  length: result.length,
1358
1884
  matches_n: result.matches_n,
@@ -1360,20 +1886,23 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1360
1886
  ...(result.mode ? { mode: result.mode } : {}),
1361
1887
  }, null, 2));
1362
1888
  }));
1363
- addBrowserTabOption(browser.command('select')
1364
- .argument('<target>', 'Numeric ref (from browser state / find) or CSS selector of a <select> element')
1365
- .argument('<option>', 'Option text (or value) to select')
1889
+ addBrowserTabOption(addSemanticLocatorOptions(browser.command('select'))
1890
+ .argument('[targetOrOption]', 'Numeric ref/CSS target, or option text when using --role/--name/etc.')
1891
+ .argument('[option]', 'Option text (or value) to select')
1366
1892
  .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
1367
1893
  .description('Select dropdown option — JSON envelope {selected, target, matches_n}'))
1368
- .action(browserAction(async (page, target, option, opts) => {
1894
+ .action(browserAction(async (page, targetOrOption, option, opts) => {
1895
+ const resolved = await resolveWriteTargetAndValueOrPrint(page, targetOrOption, option, opts ?? {}, 'option');
1896
+ if (!resolved)
1897
+ return;
1369
1898
  const parsed = nthToResolveOpts(opts?.nth);
1370
1899
  if ('error' in parsed) {
1371
1900
  console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
1372
1901
  process.exitCode = EXIT_CODES.USAGE_ERROR;
1373
1902
  return;
1374
1903
  }
1375
- const { matches_n, match_level } = await resolveRef(page, String(target), parsed.opts);
1376
- const result = await page.evaluate(selectResolvedJs(String(option)));
1904
+ const { matches_n, match_level } = await resolveRef(page, resolved.target, parsed.opts);
1905
+ const result = await page.evaluate(selectResolvedJs(resolved.value));
1377
1906
  if (result?.error) {
1378
1907
  // The select-specific "Not a <select>" / "Option not found" errors
1379
1908
  // are domain-level failures — emit a structured envelope so agents
@@ -1390,8 +1919,8 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1390
1919
  return;
1391
1920
  }
1392
1921
  console.log(JSON.stringify({
1393
- selected: result?.selected ?? String(option),
1394
- target: String(target),
1922
+ selected: result?.selected ?? resolved.value,
1923
+ target: resolved.target,
1395
1924
  matches_n,
1396
1925
  match_level,
1397
1926
  }, null, 2));
@@ -1458,10 +1987,10 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1458
1987
  }));
1459
1988
  // ── Wait commands ──
1460
1989
  addBrowserTabOption(browser.command('wait'))
1461
- .argument('<type>', 'selector, text, time, or xhr')
1462
- .argument('[value]', 'CSS selector, text string, seconds, or XHR URL regex')
1990
+ .argument('<type>', 'selector, text, time, xhr, or download')
1991
+ .argument('[value]', 'CSS selector, text string, seconds, XHR URL regex, or download filename/URL pattern')
1463
1992
  .option('--timeout <ms>', 'Timeout in milliseconds', '10000')
1464
- .description('Wait for selector, text, time, or matching XHR (e.g. wait selector ".loaded", wait text "Success", wait time 3, wait xhr "/api/search")')
1993
+ .description('Wait for selector, text, time, matching XHR, or browser download (e.g. wait selector ".loaded", wait text "Success", wait time 3, wait xhr "/api/search", wait download receipt.pdf)')
1465
1994
  .action(browserAction(async (page, type, value, opts) => {
1466
1995
  const timeout = parseInt(opts.timeout, 10);
1467
1996
  if (type === 'time') {
@@ -1538,8 +2067,36 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1538
2067
  matched: { url: matched.url, status: matched.status, contentType: matched.ct },
1539
2068
  }, null, 2));
1540
2069
  }
2070
+ else if (type === 'download') {
2071
+ if (typeof page.waitForDownload !== 'function') {
2072
+ console.log(JSON.stringify({
2073
+ error: {
2074
+ code: 'download_wait_unavailable',
2075
+ message: 'The active browser backend does not support download lifecycle waits.',
2076
+ hint: 'Use the Browser Bridge extension version 1.0.8 or newer, then retry the command.',
2077
+ },
2078
+ }, null, 2));
2079
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
2080
+ return;
2081
+ }
2082
+ const result = await page.waitForDownload(String(value ?? ''), timeout);
2083
+ if (!result.downloaded) {
2084
+ const code = result.state === 'interrupted' && result.id !== undefined ? 'download_failed' : 'download_not_seen';
2085
+ console.log(JSON.stringify({
2086
+ error: {
2087
+ code,
2088
+ message: result.error ?? `No download matched "${value ?? '*'}" within ${timeout}ms`,
2089
+ hint: 'Check the pattern against the expected filename or URL; use a longer --timeout if the download starts slowly.',
2090
+ },
2091
+ download: result,
2092
+ }, null, 2));
2093
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
2094
+ return;
2095
+ }
2096
+ console.log(JSON.stringify(result, null, 2));
2097
+ }
1541
2098
  else {
1542
- console.error(`Unknown wait type "${type}". Use: selector, text, time, or xhr`);
2099
+ console.error(`Unknown wait type "${type}". Use: selector, text, time, xhr, or download`);
1543
2100
  process.exitCode = EXIT_CODES.USAGE_ERROR;
1544
2101
  }
1545
2102
  }));
@@ -2110,6 +2667,8 @@ cli({
2110
2667
  });
2111
2668
  // ── Plugin management ──────────────────────────────────────────────────────
2112
2669
  const pluginCmd = program.command('plugin').description('Manage opencli plugins');
2670
+ // Snapshot before applyRootSubcommandSummaries() rewrites .description() to a child-name listing.
2671
+ const originalPluginDescription = pluginCmd.description();
2113
2672
  pluginCmd
2114
2673
  .command('install')
2115
2674
  .description('Install a plugin from a git repository')
@@ -2296,6 +2855,8 @@ cli({
2296
2855
  });
2297
2856
  // ── Built-in: adapter management ─────────────────────────────────────────
2298
2857
  const adapterCmd = program.command('adapter').description('Manage CLI adapters');
2858
+ // Snapshot before applyRootSubcommandSummaries() rewrites .description() to a child-name listing.
2859
+ const originalAdapterDescription = adapterCmd.description();
2299
2860
  adapterCmd
2300
2861
  .command('status')
2301
2862
  .description('Show which sites have local overrides vs using official baseline')
@@ -2404,6 +2965,8 @@ cli({
2404
2965
  });
2405
2966
  // ── Built-in: browser profile selection ──────────────────────────────────
2406
2967
  const profileCmd = program.command('profile').description('Manage Browser Bridge Chrome profiles');
2968
+ // Snapshot before applyRootSubcommandSummaries() rewrites .description() to a child-name listing.
2969
+ const originalProfileDescription = profileCmd.description();
2407
2970
  profileCmd
2408
2971
  .command('list')
2409
2972
  .description('List Chrome profiles connected through the Browser Bridge extension')
@@ -2481,6 +3044,8 @@ cli({
2481
3044
  });
2482
3045
  // ── Built-in: daemon ──────────────────────────────────────────────────────
2483
3046
  const daemonCmd = program.command('daemon').description('Manage the opencli daemon');
3047
+ // Snapshot before applyRootSubcommandSummaries() rewrites .description() to a child-name listing.
3048
+ const originalDaemonDescription = daemonCmd.description();
2484
3049
  daemonCmd
2485
3050
  .command('status')
2486
3051
  .description('Show daemon status')
@@ -2605,6 +3170,11 @@ cli({
2605
3170
  }
2606
3171
  const adapterGroups = { external: externalNames, apps, sites };
2607
3172
  const adapterNameSet = new Set([...externalNames, ...siteNames]);
3173
+ installCommanderNamespaceStructuredHelp(browser, { globalCommand: program, description: originalBrowserDescription });
3174
+ installCommanderNamespaceStructuredHelp(daemonCmd, { globalCommand: program, description: originalDaemonDescription });
3175
+ installCommanderNamespaceStructuredHelp(pluginCmd, { globalCommand: program, description: originalPluginDescription });
3176
+ installCommanderNamespaceStructuredHelp(adapterCmd, { globalCommand: program, description: originalAdapterDescription });
3177
+ installCommanderNamespaceStructuredHelp(profileCmd, { globalCommand: program, description: originalProfileDescription });
2608
3178
  program.configureHelp({
2609
3179
  visibleCommands: (command) => command.commands.filter(child => command !== program || !adapterNameSet.has(child.name())),
2610
3180
  });