@jackwener/opencli 1.7.14 → 1.7.15

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 (94) hide show
  1. package/cli-manifest.json +215 -45
  2. package/clis/bilibili/subtitle.js +1 -1
  3. package/clis/dianping/cityResolver.js +185 -0
  4. package/clis/dianping/dianping.test.js +154 -0
  5. package/clis/dianping/search.js +6 -3
  6. package/clis/douyin/_shared/browser-fetch.js +14 -2
  7. package/clis/douyin/_shared/browser-fetch.test.js +13 -0
  8. package/clis/douyin/stats.js +1 -1
  9. package/clis/douyin/update.js +1 -1
  10. package/clis/jike/search.js +1 -1
  11. package/clis/reddit/search.js +1 -1
  12. package/clis/reddit/subreddit.js +1 -1
  13. package/clis/reddit/user-comments.js +1 -1
  14. package/clis/reddit/user-posts.js +1 -1
  15. package/clis/reddit/user.js +1 -1
  16. package/clis/twitter/article.js +2 -1
  17. package/clis/twitter/bookmark-folder.js +189 -0
  18. package/clis/twitter/bookmark-folder.test.js +334 -0
  19. package/clis/twitter/bookmark-folders.js +117 -0
  20. package/clis/twitter/bookmark-folders.test.js +150 -0
  21. package/clis/twitter/bookmark.js +15 -6
  22. package/clis/twitter/bookmark.test.js +74 -0
  23. package/clis/twitter/bookmarks.js +7 -5
  24. package/clis/twitter/delete.js +11 -35
  25. package/clis/twitter/delete.test.js +21 -9
  26. package/clis/twitter/download.js +5 -5
  27. package/clis/twitter/followers.js +9 -3
  28. package/clis/twitter/following.js +11 -5
  29. package/clis/twitter/hide-reply.js +24 -5
  30. package/clis/twitter/hide-reply.test.js +76 -0
  31. package/clis/twitter/like.js +21 -11
  32. package/clis/twitter/like.test.js +73 -0
  33. package/clis/twitter/likes.js +8 -6
  34. package/clis/twitter/list-add.js +4 -4
  35. package/clis/twitter/list-remove.js +4 -4
  36. package/clis/twitter/list-tweets.js +6 -4
  37. package/clis/twitter/lists.js +3 -3
  38. package/clis/twitter/notifications.js +2 -2
  39. package/clis/twitter/profile.js +4 -3
  40. package/clis/twitter/quote.js +60 -32
  41. package/clis/twitter/quote.test.js +96 -8
  42. package/clis/twitter/reply.js +24 -178
  43. package/clis/twitter/reply.test.js +29 -11
  44. package/clis/twitter/retweet.js +9 -14
  45. package/clis/twitter/retweet.test.js +5 -1
  46. package/clis/twitter/search.js +175 -23
  47. package/clis/twitter/search.test.js +266 -1
  48. package/clis/twitter/shared.js +43 -0
  49. package/clis/twitter/shared.test.js +107 -1
  50. package/clis/twitter/thread.js +6 -4
  51. package/clis/twitter/timeline.js +8 -6
  52. package/clis/twitter/tweets.js +5 -3
  53. package/clis/twitter/unbookmark.js +13 -6
  54. package/clis/twitter/unbookmark.test.js +73 -0
  55. package/clis/twitter/unlike.js +6 -13
  56. package/clis/twitter/unlike.test.js +5 -2
  57. package/clis/twitter/unretweet.js +9 -14
  58. package/clis/twitter/unretweet.test.js +5 -1
  59. package/clis/twitter/utils.js +286 -0
  60. package/clis/twitter/utils.test.js +169 -0
  61. package/dist/src/browser/ax-snapshot.d.ts +37 -0
  62. package/dist/src/browser/ax-snapshot.js +217 -0
  63. package/dist/src/browser/ax-snapshot.test.d.ts +1 -0
  64. package/dist/src/browser/ax-snapshot.test.js +91 -0
  65. package/dist/src/browser/base-page.d.ts +51 -0
  66. package/dist/src/browser/base-page.js +545 -2
  67. package/dist/src/browser/base-page.test.js +520 -4
  68. package/dist/src/browser/cdp-click-fixture.test.d.ts +1 -0
  69. package/dist/src/browser/cdp-click-fixture.test.js +87 -0
  70. package/dist/src/browser/cdp.js +5 -0
  71. package/dist/src/browser/cdp.test.js +1 -0
  72. package/dist/src/browser/daemon-client.d.ts +3 -1
  73. package/dist/src/browser/find.d.ts +9 -1
  74. package/dist/src/browser/find.js +219 -0
  75. package/dist/src/browser/find.test.js +61 -1
  76. package/dist/src/browser/page.d.ts +2 -1
  77. package/dist/src/browser/page.js +13 -0
  78. package/dist/src/browser/page.test.js +28 -0
  79. package/dist/src/browser/target-errors.d.ts +3 -1
  80. package/dist/src/browser/target-errors.js +2 -0
  81. package/dist/src/browser/target-resolver.d.ts +14 -0
  82. package/dist/src/browser/target-resolver.js +28 -0
  83. package/dist/src/browser/visual-refs.d.ts +11 -0
  84. package/dist/src/browser/visual-refs.js +108 -0
  85. package/dist/src/build-manifest.d.ts +23 -0
  86. package/dist/src/build-manifest.js +34 -0
  87. package/dist/src/build-manifest.test.js +108 -1
  88. package/dist/src/cli.js +560 -58
  89. package/dist/src/cli.test.js +598 -0
  90. package/dist/src/help.d.ts +32 -0
  91. package/dist/src/help.js +145 -0
  92. package/dist/src/types.d.ts +82 -0
  93. package/package.json +1 -1
  94. 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';
@@ -373,6 +373,41 @@ function getPageScope(page) {
373
373
  const contextId = page.contextId;
374
374
  return getBrowserScope(getPageWorkspace(page), typeof contextId === 'string' && contextId.trim() ? contextId.trim() : undefined);
375
375
  }
376
+ function snapshotMetricText(snapshot) {
377
+ return typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot, null, 2);
378
+ }
379
+ function snapshotMetrics(snapshot, elapsedMs) {
380
+ const text = snapshotMetricText(snapshot);
381
+ const interactiveMatch = text.match(/^interactive:\s*(\d+)\s*$/m);
382
+ return {
383
+ ok: true,
384
+ chars: text.length,
385
+ bytes: Buffer.byteLength(text, 'utf8'),
386
+ lines: text ? text.split(/\r?\n/).length : 0,
387
+ approx_tokens: Math.ceil(text.length / 4),
388
+ refs: (text.match(/(^|\n)\s*\[\d+\]/g) ?? []).length,
389
+ frame_sections: (text.match(/(^|\n)frame /g) ?? []).length,
390
+ ...(interactiveMatch ? { interactive: Number(interactiveMatch[1]) } : {}),
391
+ elapsed_ms: elapsedMs,
392
+ };
393
+ }
394
+ async function snapshotSourceMetrics(page, source) {
395
+ const started = Date.now();
396
+ try {
397
+ const snapshot = await page.snapshot({ viewportExpand: 2000, source });
398
+ return snapshotMetrics(snapshot, Date.now() - started);
399
+ }
400
+ catch (err) {
401
+ return {
402
+ ok: false,
403
+ elapsed_ms: Date.now() - started,
404
+ error: {
405
+ ...(err instanceof Error && 'code' in err ? { code: String(err.code) } : {}),
406
+ message: err instanceof Error ? err.message : String(err),
407
+ },
408
+ };
409
+ }
410
+ }
376
411
  function resolveBrowserTabTarget(targetId, opts) {
377
412
  if (typeof targetId === 'string' && targetId.trim())
378
413
  return targetId.trim();
@@ -549,6 +584,7 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
549
584
  .command('browser')
550
585
  .option('--workspace <name>', 'Browser workspace to use (default: browser:default; bound tabs use bound:<name>)')
551
586
  .description('Browser control — navigate, click, type, extract, wait (no LLM needed)');
587
+ const originalBrowserDescription = browser.description();
552
588
  /**
553
589
  * Resolve a `<target>` (numeric ref or CSS selector) via the unified resolver.
554
590
  * Returns the CSS match count so callers can propagate `matches_n` into the
@@ -887,9 +923,33 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
887
923
  console.log(`Scrolled ${direction}`);
888
924
  }));
889
925
  // ── 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 });
926
+ addBrowserTabOption(browser.command('state').description('Page state: URL, title, interactive elements with [N] indices')
927
+ .option('--source <source>', 'Snapshot backend: dom (default) or ax prototype', 'dom')
928
+ .option('--compare-sources', 'Print DOM vs AX snapshot metrics for observation promotion decisions', false))
929
+ .action(browserAction(async (page, opts) => {
930
+ if (opts.compareSources === true) {
931
+ const [dom, ax] = await Promise.all([
932
+ snapshotSourceMetrics(page, 'dom'),
933
+ snapshotSourceMetrics(page, 'ax'),
934
+ ]);
935
+ console.log(JSON.stringify({
936
+ url: await page.getCurrentUrl?.() ?? '',
937
+ sources: { dom, ax },
938
+ }, null, 2));
939
+ return;
940
+ }
941
+ const source = String(opts.source ?? 'dom').toLowerCase();
942
+ if (source !== 'dom' && source !== 'ax') {
943
+ console.log(JSON.stringify({
944
+ error: {
945
+ code: 'invalid_source',
946
+ message: `--source must be "dom" or "ax", got "${opts.source}"`,
947
+ },
948
+ }, null, 2));
949
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
950
+ return;
951
+ }
952
+ const snapshot = await page.snapshot({ viewportExpand: 2000, source: source });
893
953
  const url = await page.getCurrentUrl?.() ?? '';
894
954
  console.log(`URL: ${url}\n`);
895
955
  console.log(typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot, null, 2));
@@ -901,21 +961,26 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
901
961
  }));
902
962
  addBrowserTabOption(browser.command('screenshot').argument('[path]', 'Save to file (base64 if omitted)'))
903
963
  .option('--full-page', 'Capture the full scrollable page, not just the viewport', false)
964
+ .option('--annotate', 'Overlay visible browser state ref labels on the screenshot', false)
904
965
  .option('--width <n>', 'Override viewport width in CSS pixels for this screenshot only', (v) => parseScreenshotDim(v, 'width'))
905
966
  .option('--height <n>', 'Override viewport height in CSS pixels for this screenshot only (ignored with --full-page)', (v) => parseScreenshotDim(v, 'height'))
906
967
  .description('Take screenshot')
907
968
  .action(browserAction(async (page, path, opts) => {
908
969
  const shotOpts = {
909
970
  fullPage: opts.fullPage === true,
971
+ annotate: opts.annotate === true,
910
972
  width: opts.width,
911
973
  height: opts.height,
912
974
  };
975
+ const capture = opts.annotate === true
976
+ ? (page.annotatedScreenshot ?? page.screenshot).bind(page)
977
+ : page.screenshot.bind(page);
913
978
  if (path) {
914
- await page.screenshot({ ...shotOpts, path });
979
+ await capture({ ...shotOpts, path });
915
980
  console.log(`Screenshot saved to: ${path}`);
916
981
  }
917
982
  else {
918
- console.log(await page.screenshot({ ...shotOpts, format: 'png' }));
983
+ console.log(await capture({ ...shotOpts, format: 'png' }));
919
984
  }
920
985
  }));
921
986
  addBrowserTabOption(browser.command('console'))
@@ -1053,18 +1118,200 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1053
1118
  // `browser find --css <sel>` lets agents jump straight from a semantic
1054
1119
  // selector to a JSON list of matching elements, without having to parse
1055
1120
  // the free-text state snapshot to recover indices.
1056
- addBrowserTabOption(browser.command('find')
1121
+ const addSemanticLocatorOptions = (cmd) => cmd
1122
+ .option('--role <role>', 'Semantic role (button, link, textbox, option, etc.)')
1123
+ .option('--name <text>', 'Accessible name contains text (aria-label, label, title, placeholder, or visible text)')
1124
+ .option('--label <text>', 'Associated label contains text')
1125
+ .option('--text <text>', 'Visible text contains text')
1126
+ .option('--testid <id>', 'data-testid / data-test / test-id contains id');
1127
+ const addPrefixedSemanticLocatorOptions = (cmd, prefix) => cmd
1128
+ .option(`--${prefix}-role <role>`, `${prefix} semantic role`)
1129
+ .option(`--${prefix}-name <text>`, `${prefix} accessible name contains text`)
1130
+ .option(`--${prefix}-label <text>`, `${prefix} associated label contains text`)
1131
+ .option(`--${prefix}-text <text>`, `${prefix} visible text contains text`)
1132
+ .option(`--${prefix}-testid <id>`, `${prefix} data-testid / data-test / test-id contains id`);
1133
+ const semanticLocatorFromOptions = (opts) => {
1134
+ const locator = {};
1135
+ for (const key of ['role', 'name', 'label', 'text', 'testid']) {
1136
+ const value = opts[key];
1137
+ if (typeof value === 'string' && value.trim())
1138
+ locator[key] = value.trim();
1139
+ }
1140
+ return Object.keys(locator).length > 0 ? locator : null;
1141
+ };
1142
+ const prefixedSemanticLocatorFromOptions = (opts, prefix) => {
1143
+ const locator = {};
1144
+ const map = {
1145
+ role: `${prefix}Role`,
1146
+ name: `${prefix}Name`,
1147
+ label: `${prefix}Label`,
1148
+ text: `${prefix}Text`,
1149
+ testid: `${prefix}Testid`,
1150
+ };
1151
+ for (const key of ['role', 'name', 'label', 'text', 'testid']) {
1152
+ const value = opts[map[key]];
1153
+ if (typeof value === 'string' && value.trim())
1154
+ locator[key] = value.trim();
1155
+ }
1156
+ return Object.keys(locator).length > 0 ? locator : null;
1157
+ };
1158
+ const semanticTargetFromLocator = async (page, locator, mode) => {
1159
+ const result = await page.evaluate(buildSemanticFindJs({ ...locator, limit: 6 }));
1160
+ if (isFindError(result))
1161
+ return result;
1162
+ if (mode === 'write' && result.matches_n !== 1) {
1163
+ return {
1164
+ error: {
1165
+ code: 'semantic_ambiguous',
1166
+ message: `Semantic locator matched ${result.matches_n} elements; write actions require a unique target.`,
1167
+ hint: 'Add --name/--label/--text/--testid or use browser find with a narrower locator.',
1168
+ matches_n: result.matches_n,
1169
+ entries: result.entries,
1170
+ },
1171
+ };
1172
+ }
1173
+ const first = result.entries[0];
1174
+ if (!first) {
1175
+ return {
1176
+ error: {
1177
+ code: 'semantic_not_found',
1178
+ message: 'Semantic locator matched 0 elements',
1179
+ hint: 'Try browser state, --source ax, or relax the semantic locator.',
1180
+ },
1181
+ };
1182
+ }
1183
+ const target = String(first.ref);
1184
+ if (mode === 'read') {
1185
+ return {
1186
+ target,
1187
+ ...(result.matches_n > 1 ? { total_matches: result.matches_n } : {}),
1188
+ };
1189
+ }
1190
+ return target;
1191
+ };
1192
+ const semanticTargetFromOptions = async (page, opts, mode) => {
1193
+ const locator = semanticLocatorFromOptions(opts);
1194
+ if (!locator)
1195
+ return null;
1196
+ return semanticTargetFromLocator(page, locator, mode);
1197
+ };
1198
+ const resolveExplicitOrSemanticTarget = async (page, target, opts, mode) => {
1199
+ const explicit = typeof target === 'string' && target.trim() ? target.trim() : '';
1200
+ const hasSemantic = !!semanticLocatorFromOptions(opts);
1201
+ if (explicit && hasSemantic) {
1202
+ return {
1203
+ error: {
1204
+ code: 'usage_error',
1205
+ message: 'Pass either <target> or semantic locator flags, not both.',
1206
+ },
1207
+ };
1208
+ }
1209
+ if (explicit)
1210
+ return explicit;
1211
+ const semantic = await semanticTargetFromOptions(page, opts, mode);
1212
+ if (semantic)
1213
+ return semantic;
1214
+ return {
1215
+ error: {
1216
+ code: 'usage_error',
1217
+ message: 'Missing target. Pass a numeric ref/CSS selector, or semantic flags like --role button --name Submit.',
1218
+ },
1219
+ };
1220
+ };
1221
+ const printTargetResolutionError = (resolved) => {
1222
+ console.log(JSON.stringify(resolved, null, 2));
1223
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
1224
+ };
1225
+ const resolveWriteTargetOrPrint = async (page, target, opts) => {
1226
+ const resolvedTarget = await resolveExplicitOrSemanticTarget(page, target, opts, 'write');
1227
+ if (typeof resolvedTarget === 'string')
1228
+ return resolvedTarget;
1229
+ if ('error' in resolvedTarget)
1230
+ printTargetResolutionError(resolvedTarget);
1231
+ return null;
1232
+ };
1233
+ const resolveWriteTargetAndValueOrPrint = async (page, targetOrValue, value, opts, valueLabel) => {
1234
+ const hasSemantic = !!semanticLocatorFromOptions(opts);
1235
+ if (hasSemantic && value !== undefined) {
1236
+ printTargetResolutionError({
1237
+ error: {
1238
+ code: 'usage_error',
1239
+ message: `When using semantic locator flags, pass only <${valueLabel}> as the positional argument.`,
1240
+ },
1241
+ });
1242
+ return null;
1243
+ }
1244
+ const resolvedValue = hasSemantic ? targetOrValue : value;
1245
+ if (resolvedValue === undefined) {
1246
+ printTargetResolutionError({
1247
+ error: {
1248
+ code: 'usage_error',
1249
+ message: `Missing ${valueLabel}.`,
1250
+ hint: hasSemantic
1251
+ ? `With semantic locator flags, pass the ${valueLabel} as the only positional argument.`
1252
+ : `Pass both a target and ${valueLabel}.`,
1253
+ },
1254
+ });
1255
+ return null;
1256
+ }
1257
+ const resolvedTarget = await resolveWriteTargetOrPrint(page, hasSemantic ? undefined : targetOrValue, opts);
1258
+ if (!resolvedTarget)
1259
+ return null;
1260
+ return { target: resolvedTarget, value: String(resolvedValue) };
1261
+ };
1262
+ const resolvePrefixedWriteTargetOrPrint = async (page, target, opts, prefix, label) => {
1263
+ const explicit = typeof target === 'string' && target.trim() ? target.trim() : '';
1264
+ const locator = prefixedSemanticLocatorFromOptions(opts, prefix);
1265
+ if (explicit && locator) {
1266
+ printTargetResolutionError({
1267
+ error: {
1268
+ code: 'usage_error',
1269
+ message: `Pass either <${label}> or --${prefix}-* semantic locator flags, not both.`,
1270
+ },
1271
+ });
1272
+ return null;
1273
+ }
1274
+ if (explicit)
1275
+ return explicit;
1276
+ if (locator) {
1277
+ const resolved = await semanticTargetFromLocator(page, locator, 'write');
1278
+ if (typeof resolved === 'string')
1279
+ return resolved;
1280
+ if ('error' in resolved)
1281
+ printTargetResolutionError(resolved);
1282
+ return null;
1283
+ }
1284
+ printTargetResolutionError({
1285
+ error: {
1286
+ code: 'usage_error',
1287
+ message: `Missing ${label}. Pass a numeric ref/CSS selector, or --${prefix}-role/--${prefix}-name semantic flags.`,
1288
+ },
1289
+ });
1290
+ return null;
1291
+ };
1292
+ addBrowserTabOption(addSemanticLocatorOptions(browser.command('find'))
1057
1293
  .option('--css <selector>', 'CSS selector (required)')
1058
1294
  .option('--limit <n>', 'Max entries returned', '50')
1059
1295
  .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[]}'))
1296
+ .description('Find DOM elements by CSS or semantic locator — returns JSON {matches_n, entries[]}'))
1061
1297
  .action(browserAction(async (page, opts) => {
1062
- if (!opts.css || typeof opts.css !== 'string') {
1298
+ const locator = semanticLocatorFromOptions(opts);
1299
+ if ((!opts.css || typeof opts.css !== 'string') && !locator) {
1300
+ console.log(JSON.stringify({
1301
+ error: {
1302
+ code: 'usage_error',
1303
+ message: '--css <selector> or a semantic locator flag is required',
1304
+ hint: 'Examples: opencli browser find --css ".btn.primary"; opencli browser find --role button --name Save',
1305
+ },
1306
+ }, null, 2));
1307
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
1308
+ return;
1309
+ }
1310
+ if (opts.css && locator) {
1063
1311
  console.log(JSON.stringify({
1064
1312
  error: {
1065
1313
  code: 'usage_error',
1066
- message: '--css <selector> is required',
1067
- hint: 'Example: opencli browser find --css ".btn.primary"',
1314
+ message: 'Pass either --css or semantic locator flags, not both.',
1068
1315
  },
1069
1316
  }, null, 2));
1070
1317
  process.exitCode = EXIT_CODES.USAGE_ERROR;
@@ -1082,10 +1329,16 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1082
1329
  process.exitCode = EXIT_CODES.USAGE_ERROR;
1083
1330
  return;
1084
1331
  }
1085
- const result = await page.evaluate(buildFindJs(opts.css, {
1086
- limit: limit ?? undefined,
1087
- textMax: textMax ?? undefined,
1088
- }));
1332
+ const result = await page.evaluate(locator
1333
+ ? buildSemanticFindJs({
1334
+ ...locator,
1335
+ limit: limit ?? undefined,
1336
+ textMax: textMax ?? undefined,
1337
+ })
1338
+ : buildFindJs(opts.css, {
1339
+ limit: limit ?? undefined,
1340
+ textMax: textMax ?? undefined,
1341
+ }));
1089
1342
  if (isFindError(result)) {
1090
1343
  console.log(JSON.stringify(result, null, 2));
1091
1344
  process.exitCode = EXIT_CODES.GENERIC_ERROR;
@@ -1112,13 +1365,21 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1112
1365
  // or a CSS selector. On multi-match CSS, the first element wins and the real
1113
1366
  // match count is exposed via `matches_n`; `--nth <n>` picks a specific one.
1114
1367
  const runGetCommand = async (page, target, opts, evalJs, field) => {
1368
+ const resolvedTarget = await resolveExplicitOrSemanticTarget(page, target, opts, 'read');
1369
+ if (typeof resolvedTarget !== 'string' && 'error' in resolvedTarget) {
1370
+ console.log(JSON.stringify(resolvedTarget, null, 2));
1371
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
1372
+ return;
1373
+ }
1374
+ const targetRef = typeof resolvedTarget === 'string' ? resolvedTarget : resolvedTarget.target;
1375
+ const totalMatches = typeof resolvedTarget === 'string' ? undefined : resolvedTarget.total_matches;
1115
1376
  const nth = parseNthFlag(opts.nth);
1116
1377
  if (nth && typeof nth === 'object' && 'error' in nth) {
1117
1378
  console.log(JSON.stringify({ error: { code: 'usage_error', message: nth.error } }, null, 2));
1118
1379
  process.exitCode = EXIT_CODES.USAGE_ERROR;
1119
1380
  return;
1120
1381
  }
1121
- const { matches_n, match_level } = await resolveRef(page, String(target), {
1382
+ const { matches_n, match_level } = await resolveRef(page, targetRef, {
1122
1383
  firstOnMulti: nth === null,
1123
1384
  ...(typeof nth === 'number' ? { nth } : {}),
1124
1385
  });
@@ -1137,18 +1398,23 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1137
1398
  else {
1138
1399
  value = raw ?? null;
1139
1400
  }
1140
- console.log(JSON.stringify({ value, matches_n, match_level }, null, 2));
1401
+ console.log(JSON.stringify({
1402
+ value,
1403
+ matches_n,
1404
+ match_level,
1405
+ ...(totalMatches && totalMatches > 1 ? { total_matches: totalMatches } : {}),
1406
+ }, null, 2));
1141
1407
  };
1142
- addBrowserTabOption(get.command('text')
1143
- .argument('<target>', 'Numeric ref (from browser state / find) or CSS selector')
1408
+ addBrowserTabOption(addSemanticLocatorOptions(get.command('text'))
1409
+ .argument('[target]', 'Numeric ref (from browser state / find), CSS selector, or omit when using --role/--name/etc.')
1144
1410
  .option('--nth <n>', 'Pick the nth match (0-based) when <target> is a multi-match CSS selector')
1145
1411
  .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')
1412
+ .action(browserAction(async (page, target, opts) => runGetCommand(page, target, opts ?? {}, getTextResolvedJs(), 'text')));
1413
+ addBrowserTabOption(addSemanticLocatorOptions(get.command('value'))
1414
+ .argument('[target]', 'Numeric ref (from browser state / find), CSS selector, or omit when using --role/--name/etc.')
1149
1415
  .option('--nth <n>', 'Pick the nth match (0-based) when <target> is a multi-match CSS selector')
1150
1416
  .description('Input/textarea value — JSON envelope {value, matches_n}'))
1151
- .action(browserAction(async (page, target, opts) => runGetCommand(page, String(target), opts ?? {}, getValueResolvedJs(), 'value')));
1417
+ .action(browserAction(async (page, target, opts) => runGetCommand(page, target, opts ?? {}, getValueResolvedJs(), 'value')));
1152
1418
  addBrowserTabOption(get.command('html')
1153
1419
  .option('--selector <css>', 'CSS selector scope (first match)')
1154
1420
  .option('--as <format>', 'Output format: "html" (default) or "json" for structured tree', 'html')
@@ -1263,11 +1529,11 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1263
1529
  }
1264
1530
  console.log(html);
1265
1531
  }));
1266
- addBrowserTabOption(get.command('attributes')
1267
- .argument('<target>', 'Numeric ref (from browser state / find) or CSS selector')
1532
+ addBrowserTabOption(addSemanticLocatorOptions(get.command('attributes'))
1533
+ .argument('[target]', 'Numeric ref (from browser state / find), CSS selector, or omit when using --role/--name/etc.')
1268
1534
  .option('--nth <n>', 'Pick the nth match (0-based) when <target> is a multi-match CSS selector')
1269
1535
  .description('Element attributes — JSON envelope {value, matches_n}'))
1270
- .action(browserAction(async (page, target, opts) => runGetCommand(page, String(target), opts ?? {}, getAttributesResolvedJs(), 'attributes')));
1536
+ .action(browserAction(async (page, target, opts) => runGetCommand(page, target, opts ?? {}, getAttributesResolvedJs(), 'attributes')));
1271
1537
  // ── Interact ──
1272
1538
  //
1273
1539
  // Write commands (`click/type/select`) share the same `<target>` contract
@@ -1290,26 +1556,73 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1290
1556
  return { opts: { nth: parsed } };
1291
1557
  return { opts: {} };
1292
1558
  }
1293
- addBrowserTabOption(browser.command('click')
1294
- .argument('<target>', 'Numeric ref (from browser state / find) or CSS selector')
1559
+ function resolveUploadFilePaths(rawFiles) {
1560
+ const inputs = Array.isArray(rawFiles) ? rawFiles : [];
1561
+ if (inputs.length === 0) {
1562
+ return {
1563
+ error: {
1564
+ code: 'usage_error',
1565
+ message: 'At least one file path is required.',
1566
+ hint: 'Example: opencli browser upload "input[type=file]" ./receipt.pdf',
1567
+ },
1568
+ };
1569
+ }
1570
+ const files = [];
1571
+ for (const input of inputs) {
1572
+ const raw = String(input);
1573
+ const expanded = raw === '~' || raw.startsWith(`~${path.sep}`)
1574
+ ? path.join(os.homedir(), raw.slice(2))
1575
+ : raw;
1576
+ const resolved = path.resolve(expanded);
1577
+ if (!fs.existsSync(resolved)) {
1578
+ return { error: { code: 'file_not_found', message: `File not found: ${resolved}` } };
1579
+ }
1580
+ const stat = fs.statSync(resolved);
1581
+ if (!stat.isFile()) {
1582
+ return { error: { code: 'not_a_file', message: `Not a regular file: ${resolved}` } };
1583
+ }
1584
+ files.push(resolved);
1585
+ }
1586
+ return { files };
1587
+ }
1588
+ function parseResolveFlag(raw, flag) {
1589
+ const parsed = parseNthFlag(raw);
1590
+ if (parsed && typeof parsed === 'object' && 'error' in parsed) {
1591
+ return { error: parsed.error.replace('--nth', flag) };
1592
+ }
1593
+ if (typeof parsed === 'number')
1594
+ return { opts: { nth: parsed } };
1595
+ return { opts: {} };
1596
+ }
1597
+ addBrowserTabOption(addSemanticLocatorOptions(browser.command('click'))
1598
+ .argument('[target]', 'Numeric ref (from browser state / find), CSS selector, or omit when using --role/--name/etc.')
1295
1599
  .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
1296
1600
  .description('Click element — JSON envelope {clicked, target, matches_n}'))
1297
1601
  .action(browserAction(async (page, target, opts) => {
1602
+ const resolvedTarget = await resolveExplicitOrSemanticTarget(page, target, opts ?? {}, 'write');
1603
+ if (typeof resolvedTarget !== 'string') {
1604
+ console.log(JSON.stringify(resolvedTarget, null, 2));
1605
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
1606
+ return;
1607
+ }
1298
1608
  const parsed = nthToResolveOpts(opts?.nth);
1299
1609
  if ('error' in parsed) {
1300
1610
  console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
1301
1611
  process.exitCode = EXIT_CODES.USAGE_ERROR;
1302
1612
  return;
1303
1613
  }
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));
1614
+ const { matches_n, match_level } = await page.click(resolvedTarget, parsed.opts);
1615
+ console.log(JSON.stringify({ clicked: true, target: resolvedTarget, matches_n, match_level }, null, 2));
1306
1616
  }));
1307
- addBrowserTabOption(browser.command('type')
1308
- .argument('<target>', 'Numeric ref (from browser state / find) or CSS selector')
1309
- .argument('<text>', 'Text to type')
1617
+ addBrowserTabOption(addSemanticLocatorOptions(browser.command('type'))
1618
+ .argument('[targetOrText]', 'Numeric ref/CSS target, or text when using --role/--name/etc.')
1619
+ .argument('[text]', 'Text to type')
1310
1620
  .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
1311
1621
  .description('Click element, then type text — JSON envelope {typed, text, target, matches_n, autocomplete}'))
1312
- .action(browserAction(async (page, target, text, opts) => {
1622
+ .action(browserAction(async (page, targetOrText, text, opts) => {
1623
+ const resolved = await resolveWriteTargetAndValueOrPrint(page, targetOrText, text, opts ?? {}, 'text');
1624
+ if (!resolved)
1625
+ return;
1313
1626
  const parsed = nthToResolveOpts(opts?.nth);
1314
1627
  if ('error' in parsed) {
1315
1628
  console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
@@ -1317,42 +1630,199 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1317
1630
  return;
1318
1631
  }
1319
1632
  // Click first (focuses the field), wait briefly, then type.
1320
- await page.click(String(target), parsed.opts);
1633
+ await page.click(resolved.target, parsed.opts);
1321
1634
  await page.wait(0.3);
1322
- const { matches_n, match_level } = await page.typeText(String(target), String(text), parsed.opts);
1635
+ const { matches_n, match_level } = await page.typeText(resolved.target, resolved.value, parsed.opts);
1323
1636
  // __resolved is already set by the resolver call inside page.typeText
1324
1637
  const isAutocomplete = await page.evaluate(isAutocompleteResolvedJs());
1325
1638
  if (isAutocomplete)
1326
1639
  await page.wait(0.4);
1327
1640
  console.log(JSON.stringify({
1328
1641
  typed: true,
1329
- text: String(text),
1330
- target: String(target),
1642
+ text: resolved.value,
1643
+ target: resolved.target,
1331
1644
  matches_n,
1332
1645
  match_level,
1333
1646
  autocomplete: !!isAutocomplete,
1334
1647
  }, null, 2));
1335
1648
  }));
1336
- addBrowserTabOption(browser.command('fill')
1337
- .argument('<target>', 'Numeric ref (from browser state / find) or CSS selector')
1338
- .argument('<text>', 'Text to set exactly')
1649
+ addBrowserTabOption(addSemanticLocatorOptions(browser.command('hover'))
1650
+ .argument('[target]', 'Numeric ref (from browser state / find), CSS selector, or omit when using --role/--name/etc.')
1651
+ .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
1652
+ .description('Move the mouse over an element — JSON envelope {hovered, target, matches_n}'))
1653
+ .action(browserAction(async (page, target, opts) => {
1654
+ if (typeof page.hover !== 'function')
1655
+ throw new Error('browser hover is not supported by this browser backend');
1656
+ const resolvedTarget = await resolveWriteTargetOrPrint(page, target, opts ?? {});
1657
+ if (!resolvedTarget)
1658
+ return;
1659
+ const parsed = nthToResolveOpts(opts?.nth);
1660
+ if ('error' in parsed) {
1661
+ console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
1662
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
1663
+ return;
1664
+ }
1665
+ const { matches_n, match_level } = await page.hover(resolvedTarget, parsed.opts);
1666
+ console.log(JSON.stringify({ hovered: true, target: resolvedTarget, matches_n, match_level }, null, 2));
1667
+ }));
1668
+ addBrowserTabOption(addSemanticLocatorOptions(browser.command('focus'))
1669
+ .argument('[target]', 'Numeric ref (from browser state / find), CSS selector, or omit when using --role/--name/etc.')
1670
+ .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
1671
+ .description('Focus an element — JSON envelope {focused, target, matches_n}'))
1672
+ .action(browserAction(async (page, target, opts) => {
1673
+ if (typeof page.focus !== 'function')
1674
+ throw new Error('browser focus is not supported by this browser backend');
1675
+ const resolvedTarget = await resolveWriteTargetOrPrint(page, target, opts ?? {});
1676
+ if (!resolvedTarget)
1677
+ return;
1678
+ const parsed = nthToResolveOpts(opts?.nth);
1679
+ if ('error' in parsed) {
1680
+ console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
1681
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
1682
+ return;
1683
+ }
1684
+ const { focused, matches_n, match_level } = await page.focus(resolvedTarget, parsed.opts);
1685
+ console.log(JSON.stringify({ focused, target: resolvedTarget, matches_n, match_level }, null, 2));
1686
+ }));
1687
+ addBrowserTabOption(addSemanticLocatorOptions(browser.command('dblclick'))
1688
+ .argument('[target]', 'Numeric ref (from browser state / find), CSS selector, or omit when using --role/--name/etc.')
1689
+ .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
1690
+ .description('Double-click element — JSON envelope {dblclicked, target, matches_n}'))
1691
+ .action(browserAction(async (page, target, opts) => {
1692
+ if (typeof page.dblClick !== 'function')
1693
+ throw new Error('browser dblclick is not supported by this browser backend');
1694
+ const resolvedTarget = await resolveWriteTargetOrPrint(page, target, opts ?? {});
1695
+ if (!resolvedTarget)
1696
+ return;
1697
+ const parsed = nthToResolveOpts(opts?.nth);
1698
+ if ('error' in parsed) {
1699
+ console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
1700
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
1701
+ return;
1702
+ }
1703
+ const { matches_n, match_level } = await page.dblClick(resolvedTarget, parsed.opts);
1704
+ console.log(JSON.stringify({ dblclicked: true, target: resolvedTarget, matches_n, match_level }, null, 2));
1705
+ }));
1706
+ const runCheckCommand = async (page, target, opts, checked) => {
1707
+ if (typeof page.setChecked !== 'function')
1708
+ throw new Error(`browser ${checked ? 'check' : 'uncheck'} is not supported by this browser backend`);
1709
+ const resolvedTarget = await resolveWriteTargetOrPrint(page, target, opts);
1710
+ if (!resolvedTarget)
1711
+ return;
1712
+ const parsed = nthToResolveOpts(opts?.nth);
1713
+ if ('error' in parsed) {
1714
+ console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
1715
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
1716
+ return;
1717
+ }
1718
+ const result = await page.setChecked(resolvedTarget, checked, parsed.opts);
1719
+ console.log(JSON.stringify({
1720
+ checked: result.checked,
1721
+ changed: result.changed,
1722
+ target: resolvedTarget,
1723
+ matches_n: result.matches_n,
1724
+ match_level: result.match_level,
1725
+ ...(result.kind ? { kind: result.kind } : {}),
1726
+ }, null, 2));
1727
+ };
1728
+ addBrowserTabOption(addSemanticLocatorOptions(browser.command('check'))
1729
+ .argument('[target]', 'Numeric ref (from browser state / find), CSS selector, or omit when using --role/--name/etc.')
1730
+ .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
1731
+ .description('Ensure a checkbox/radio/aria-checked control is checked — JSON envelope {checked, changed, target, matches_n}'))
1732
+ .action(browserAction(async (page, target, opts) => {
1733
+ await runCheckCommand(page, target, opts ?? {}, true);
1734
+ }));
1735
+ addBrowserTabOption(addSemanticLocatorOptions(browser.command('uncheck'))
1736
+ .argument('[target]', 'Numeric ref (from browser state / find), CSS selector, or omit when using --role/--name/etc.')
1737
+ .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
1738
+ .description('Ensure a checkbox/aria-checked control is unchecked — JSON envelope {checked, changed, target, matches_n}'))
1739
+ .action(browserAction(async (page, target, opts) => {
1740
+ await runCheckCommand(page, target, opts ?? {}, false);
1741
+ }));
1742
+ addBrowserTabOption(addSemanticLocatorOptions(browser.command('upload'))
1743
+ .argument('[targetOrFile]', 'Numeric ref/CSS target, or first file when using --role/--name/etc.')
1744
+ .argument('[files...]', 'Local file path(s) to attach')
1745
+ .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
1746
+ .description('Attach local files to a file input — JSON envelope {uploaded, files, file_names, target, matches_n}'))
1747
+ .action(browserAction(async (page, targetOrFile, files, opts) => {
1748
+ if (typeof page.uploadFiles !== 'function')
1749
+ throw new Error('browser upload is not supported by this browser backend');
1750
+ const hasSemantic = !!semanticLocatorFromOptions(opts ?? {});
1751
+ const target = hasSemantic ? undefined : targetOrFile;
1752
+ const resolvedTarget = await resolveWriteTargetOrPrint(page, target, opts ?? {});
1753
+ if (!resolvedTarget)
1754
+ return;
1755
+ const parsed = nthToResolveOpts(opts?.nth);
1756
+ if ('error' in parsed) {
1757
+ console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
1758
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
1759
+ return;
1760
+ }
1761
+ const rawFiles = hasSemantic
1762
+ ? [targetOrFile, ...(Array.isArray(files) ? files : [])].filter((value) => value !== undefined)
1763
+ : files;
1764
+ const resolvedFiles = resolveUploadFilePaths(rawFiles);
1765
+ if ('error' in resolvedFiles) {
1766
+ console.log(JSON.stringify({ error: resolvedFiles.error }, null, 2));
1767
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
1768
+ return;
1769
+ }
1770
+ const result = await page.uploadFiles(resolvedTarget, resolvedFiles.files, parsed.opts);
1771
+ console.log(JSON.stringify(result, null, 2));
1772
+ }));
1773
+ addBrowserTabOption(addPrefixedSemanticLocatorOptions(addPrefixedSemanticLocatorOptions(browser.command('drag'), 'from'), 'to')
1774
+ .argument('[source]', 'Numeric ref/CSS selector to drag from, or omit with --from-role/--from-name/etc.')
1775
+ .argument('[target]', 'Numeric ref/CSS selector to drop onto, or omit with --to-role/--to-name/etc.')
1776
+ .option('--from-nth <n>', 'When <source> is a multi-match CSS selector, pick the nth match (0-based)')
1777
+ .option('--to-nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
1778
+ .description('Drag one element to another — JSON envelope {dragged, source, target, source_matches_n, target_matches_n}'))
1779
+ .action(browserAction(async (page, source, target, opts) => {
1780
+ if (typeof page.drag !== 'function')
1781
+ throw new Error('browser drag is not supported by this browser backend');
1782
+ const resolvedSource = await resolvePrefixedWriteTargetOrPrint(page, source, opts ?? {}, 'from', 'source');
1783
+ if (!resolvedSource)
1784
+ return;
1785
+ const resolvedTarget = await resolvePrefixedWriteTargetOrPrint(page, target, opts ?? {}, 'to', 'target');
1786
+ if (!resolvedTarget)
1787
+ return;
1788
+ const from = parseResolveFlag(opts?.fromNth, '--from-nth');
1789
+ if ('error' in from) {
1790
+ console.log(JSON.stringify({ error: { code: 'usage_error', message: from.error } }, null, 2));
1791
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
1792
+ return;
1793
+ }
1794
+ const to = parseResolveFlag(opts?.toNth, '--to-nth');
1795
+ if ('error' in to) {
1796
+ console.log(JSON.stringify({ error: { code: 'usage_error', message: to.error } }, null, 2));
1797
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
1798
+ return;
1799
+ }
1800
+ const result = await page.drag(resolvedSource, resolvedTarget, { from: from.opts, to: to.opts });
1801
+ console.log(JSON.stringify(result, null, 2));
1802
+ }));
1803
+ addBrowserTabOption(addSemanticLocatorOptions(browser.command('fill'))
1804
+ .argument('[targetOrText]', 'Numeric ref/CSS target, or text when using --role/--name/etc.')
1805
+ .argument('[text]', 'Text to set exactly')
1339
1806
  .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
1340
1807
  .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) => {
1808
+ .action(browserAction(async (page, targetOrText, text, opts) => {
1809
+ const resolved = await resolveWriteTargetAndValueOrPrint(page, targetOrText, text, opts ?? {}, 'text');
1810
+ if (!resolved)
1811
+ return;
1342
1812
  const parsed = nthToResolveOpts(opts?.nth);
1343
1813
  if ('error' in parsed) {
1344
1814
  console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
1345
1815
  process.exitCode = EXIT_CODES.USAGE_ERROR;
1346
1816
  return;
1347
1817
  }
1348
- const result = await page.fillText(String(target), String(text), parsed.opts);
1818
+ const result = await page.fillText(resolved.target, resolved.value, parsed.opts);
1349
1819
  if (!result.verified)
1350
1820
  process.exitCode = EXIT_CODES.GENERIC_ERROR;
1351
1821
  console.log(JSON.stringify({
1352
1822
  filled: result.filled,
1353
1823
  verified: result.verified,
1354
- target: String(target),
1355
- text: String(text),
1824
+ target: resolved.target,
1825
+ text: resolved.value,
1356
1826
  actual: result.actual,
1357
1827
  length: result.length,
1358
1828
  matches_n: result.matches_n,
@@ -1360,20 +1830,23 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1360
1830
  ...(result.mode ? { mode: result.mode } : {}),
1361
1831
  }, null, 2));
1362
1832
  }));
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')
1833
+ addBrowserTabOption(addSemanticLocatorOptions(browser.command('select'))
1834
+ .argument('[targetOrOption]', 'Numeric ref/CSS target, or option text when using --role/--name/etc.')
1835
+ .argument('[option]', 'Option text (or value) to select')
1366
1836
  .option('--nth <n>', 'When <target> is a multi-match CSS selector, pick the nth match (0-based)')
1367
1837
  .description('Select dropdown option — JSON envelope {selected, target, matches_n}'))
1368
- .action(browserAction(async (page, target, option, opts) => {
1838
+ .action(browserAction(async (page, targetOrOption, option, opts) => {
1839
+ const resolved = await resolveWriteTargetAndValueOrPrint(page, targetOrOption, option, opts ?? {}, 'option');
1840
+ if (!resolved)
1841
+ return;
1369
1842
  const parsed = nthToResolveOpts(opts?.nth);
1370
1843
  if ('error' in parsed) {
1371
1844
  console.log(JSON.stringify({ error: { code: 'usage_error', message: parsed.error } }, null, 2));
1372
1845
  process.exitCode = EXIT_CODES.USAGE_ERROR;
1373
1846
  return;
1374
1847
  }
1375
- const { matches_n, match_level } = await resolveRef(page, String(target), parsed.opts);
1376
- const result = await page.evaluate(selectResolvedJs(String(option)));
1848
+ const { matches_n, match_level } = await resolveRef(page, resolved.target, parsed.opts);
1849
+ const result = await page.evaluate(selectResolvedJs(resolved.value));
1377
1850
  if (result?.error) {
1378
1851
  // The select-specific "Not a <select>" / "Option not found" errors
1379
1852
  // are domain-level failures — emit a structured envelope so agents
@@ -1390,8 +1863,8 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1390
1863
  return;
1391
1864
  }
1392
1865
  console.log(JSON.stringify({
1393
- selected: result?.selected ?? String(option),
1394
- target: String(target),
1866
+ selected: result?.selected ?? resolved.value,
1867
+ target: resolved.target,
1395
1868
  matches_n,
1396
1869
  match_level,
1397
1870
  }, null, 2));
@@ -1458,10 +1931,10 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1458
1931
  }));
1459
1932
  // ── Wait commands ──
1460
1933
  addBrowserTabOption(browser.command('wait'))
1461
- .argument('<type>', 'selector, text, time, or xhr')
1462
- .argument('[value]', 'CSS selector, text string, seconds, or XHR URL regex')
1934
+ .argument('<type>', 'selector, text, time, xhr, or download')
1935
+ .argument('[value]', 'CSS selector, text string, seconds, XHR URL regex, or download filename/URL pattern')
1463
1936
  .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")')
1937
+ .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
1938
  .action(browserAction(async (page, type, value, opts) => {
1466
1939
  const timeout = parseInt(opts.timeout, 10);
1467
1940
  if (type === 'time') {
@@ -1538,8 +2011,36 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1538
2011
  matched: { url: matched.url, status: matched.status, contentType: matched.ct },
1539
2012
  }, null, 2));
1540
2013
  }
2014
+ else if (type === 'download') {
2015
+ if (typeof page.waitForDownload !== 'function') {
2016
+ console.log(JSON.stringify({
2017
+ error: {
2018
+ code: 'download_wait_unavailable',
2019
+ message: 'The active browser backend does not support download lifecycle waits.',
2020
+ hint: 'Use the Browser Bridge extension version 1.0.8 or newer, then retry the command.',
2021
+ },
2022
+ }, null, 2));
2023
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
2024
+ return;
2025
+ }
2026
+ const result = await page.waitForDownload(String(value ?? ''), timeout);
2027
+ if (!result.downloaded) {
2028
+ const code = result.state === 'interrupted' && result.id !== undefined ? 'download_failed' : 'download_not_seen';
2029
+ console.log(JSON.stringify({
2030
+ error: {
2031
+ code,
2032
+ message: result.error ?? `No download matched "${value ?? '*'}" within ${timeout}ms`,
2033
+ hint: 'Check the pattern against the expected filename or URL; use a longer --timeout if the download starts slowly.',
2034
+ },
2035
+ download: result,
2036
+ }, null, 2));
2037
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
2038
+ return;
2039
+ }
2040
+ console.log(JSON.stringify(result, null, 2));
2041
+ }
1541
2042
  else {
1542
- console.error(`Unknown wait type "${type}". Use: selector, text, time, or xhr`);
2043
+ console.error(`Unknown wait type "${type}". Use: selector, text, time, xhr, or download`);
1543
2044
  process.exitCode = EXIT_CODES.USAGE_ERROR;
1544
2045
  }
1545
2046
  }));
@@ -2605,6 +3106,7 @@ cli({
2605
3106
  }
2606
3107
  const adapterGroups = { external: externalNames, apps, sites };
2607
3108
  const adapterNameSet = new Set([...externalNames, ...siteNames]);
3109
+ installCommanderNamespaceStructuredHelp(browser, { globalCommand: program, description: originalBrowserDescription });
2608
3110
  program.configureHelp({
2609
3111
  visibleCommands: (command) => command.commands.filter(child => command !== program || !adapterNameSet.has(child.name())),
2610
3112
  });