@houwert/conductor 0.19.1 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -71,6 +71,52 @@ function attachConsoleListeners(page) {
71
71
  source: 'console',
72
72
  });
73
73
  });
74
+ attachNetworkListeners(page);
75
+ }
76
+ const MAX_NETWORK_BUFFER = 500;
77
+ const _networkBuffer = [];
78
+ let _netSeq = 0;
79
+ function pushNetworkEntry(entry) {
80
+ _networkBuffer.push(entry);
81
+ if (_networkBuffer.length > MAX_NETWORK_BUFFER) {
82
+ _networkBuffer.splice(0, _networkBuffer.length - MAX_NETWORK_BUFFER);
83
+ }
84
+ }
85
+ /**
86
+ * Capture all page network traffic via Playwright events — covers fetch/XHR plus
87
+ * document/script/image/media loads (a canvas webtv app's API calls included), without
88
+ * injecting a shim into the page (unlike the React Native Metro path).
89
+ */
90
+ function attachNetworkListeners(page) {
91
+ const inflight = new WeakMap();
92
+ page.on('request', (req) => {
93
+ const entry = {
94
+ id: _netSeq++,
95
+ timestamp: new Date().toISOString(),
96
+ method: req.method(),
97
+ url: req.url(),
98
+ resourceType: req.resourceType(),
99
+ status: null,
100
+ durationMs: null,
101
+ error: null,
102
+ };
103
+ inflight.set(req, { entry, start: Date.now() });
104
+ pushNetworkEntry(entry);
105
+ });
106
+ page.on('response', (res) => {
107
+ const rec = inflight.get(res.request());
108
+ if (rec) {
109
+ rec.entry.status = res.status();
110
+ rec.entry.durationMs = Date.now() - rec.start;
111
+ }
112
+ });
113
+ page.on('requestfailed', (req) => {
114
+ const rec = inflight.get(req);
115
+ if (rec) {
116
+ rec.entry.error = req.failure()?.errorText ?? 'request failed';
117
+ rec.entry.durationMs = Date.now() - rec.start;
118
+ }
119
+ });
74
120
  }
75
121
  // ── ARIA snapshot parser ─────────────────────────────────────────────────────
76
122
  /**
@@ -423,6 +469,113 @@ async function stampFocusFromDocumentActiveElement(page, elements) {
423
469
  rectPick.best.focused = true;
424
470
  }
425
471
  }
472
+ /** IoU + center-inside score of a candidate node's bounds against a mirror rect. */
473
+ function overlapScore(bounds, m) {
474
+ const x1 = Math.max(m.x, bounds.x);
475
+ const y1 = Math.max(m.y, bounds.y);
476
+ const x2 = Math.min(m.x + m.width, bounds.x + bounds.width);
477
+ const y2 = Math.min(m.y + m.height, bounds.y + bounds.height);
478
+ const inter = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
479
+ if (inter <= 0)
480
+ return 0;
481
+ const union = m.width * m.height + bounds.width * bounds.height - inter;
482
+ const iou = union > 0 ? inter / union : 0;
483
+ const mcx = m.x + m.width / 2;
484
+ const mcy = m.y + m.height / 2;
485
+ const centerInside = mcx >= bounds.x &&
486
+ mcx <= bounds.x + bounds.width &&
487
+ mcy >= bounds.y &&
488
+ mcy <= bounds.y + bounds.height;
489
+ return centerInside ? iou + 1 : iou;
490
+ }
491
+ /** Find the existing tree node whose bounds best overlap a mirror rect (>0.5 score). */
492
+ function bestOverlappingNode(elements, m) {
493
+ let best = null;
494
+ let bestScore = 0.5;
495
+ const walk = (els) => {
496
+ for (const el of els) {
497
+ if (el.bounds) {
498
+ const s = overlapScore(el.bounds, m);
499
+ if (s > bestScore) {
500
+ bestScore = s;
501
+ best = el;
502
+ }
503
+ }
504
+ if (el.children)
505
+ walk(el.children);
506
+ }
507
+ };
508
+ walk(elements);
509
+ return best;
510
+ }
511
+ /**
512
+ * Harvest a canvas DOM-inspector mirror into the hierarchy. Canvas webtv frameworks
513
+ * (Lightning/WPE/RDK) render the whole UI into one `<canvas>` and expose the scene graph as
514
+ * off-screen `<div>`s carrying `data-testid` (real identity) and `data-focused="true"` (focus
515
+ * — the canvas owns `document.activeElement`, so the normal focus path can't see it).
516
+ *
517
+ * Each mirror node is matched to an existing ARIA node by bounds overlap and used to enrich it
518
+ * (testId + focus); unmatched mirror nodes are appended as new nodes. Mirror rects come from
519
+ * `getBoundingClientRect`, i.e. the same viewport-CSS-pixel space taps use — drive TV apps at
520
+ * the app's native resolution (e.g. `set-viewport 1920 1080`) so lower nodes aren't off-screen.
521
+ */
522
+ async function harvestDomMirror(page, elements) {
523
+ let mirror = null;
524
+ try {
525
+ mirror = (await page.evaluate(`(() => {
526
+ const nodes = document.querySelectorAll('[data-testid],[data-focused]');
527
+ if (!nodes.length) return null;
528
+ const out = [];
529
+ nodes.forEach((el) => {
530
+ const r = el.getBoundingClientRect();
531
+ out.push({
532
+ testId: el.getAttribute('data-testid') || '',
533
+ focused: el.getAttribute('data-focused') === 'true',
534
+ role: el.getAttribute('role') || 'generic',
535
+ name: (el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 200),
536
+ disabled: el.getAttribute('aria-disabled') === 'true',
537
+ x: r.x, y: r.y, width: r.width, height: r.height,
538
+ });
539
+ });
540
+ return out;
541
+ })()`));
542
+ }
543
+ catch {
544
+ return;
545
+ }
546
+ if (!mirror || mirror.length === 0)
547
+ return;
548
+ // data-focused is authoritative for canvas apps (activeElement is the <canvas>, never the
549
+ // focused scene node), so clear any focus already inferred from the ARIA snapshot.
550
+ if (mirror.some((m) => m.focused))
551
+ clearFocusedFlags(elements);
552
+ for (const m of mirror) {
553
+ const bounds = m.width > 0 && m.height > 0
554
+ ? { x: m.x, y: m.y, width: m.width, height: m.height }
555
+ : undefined;
556
+ const target = bounds ? bestOverlappingNode(elements, m) : null;
557
+ if (target) {
558
+ if (m.testId)
559
+ target.testId = m.testId;
560
+ if (m.focused)
561
+ target.focused = true;
562
+ if (!target.name && m.name)
563
+ target.name = m.name;
564
+ }
565
+ else {
566
+ elements.push({
567
+ role: m.role,
568
+ name: m.name,
569
+ ref: '',
570
+ testId: m.testId || undefined,
571
+ bounds,
572
+ enabled: !m.disabled,
573
+ focused: m.focused,
574
+ children: [],
575
+ });
576
+ }
577
+ }
578
+ }
426
579
  // ── Web server ───────────────────────────────────────────────────────────────
427
580
  const DEFAULT_VIEWPORT = { width: 1280, height: 720 };
428
581
  let _browser = null;
@@ -832,6 +985,9 @@ async function handleRequest(req, res, dlog) {
832
985
  await resolveBoundingBoxesByRole(p, elements);
833
986
  clearFocusedFlags(elements);
834
987
  applyFocusFromAriaSnapshotYaml(ariaSnapshot, elements);
988
+ // Canvas webtv: merge the data-testid/data-focused mirror before falling back to
989
+ // document.activeElement (which is the <canvas>, not the focused scene node).
990
+ await harvestDomMirror(p, elements);
835
991
  await stampFocusFromDocumentActiveElement(p, elements);
836
992
  jsonResponse(res, {
837
993
  url: p.url(),
@@ -937,6 +1093,17 @@ async function handleRequest(req, res, dlog) {
937
1093
  jsonResponse(res, { entries });
938
1094
  return;
939
1095
  }
1096
+ case '/networkLogs': {
1097
+ const since = parsedUrl.query['since'] ?? '';
1098
+ const limit = Number(parsedUrl.query['limit'] ?? 0);
1099
+ let entries = since
1100
+ ? _networkBuffer.filter((e) => e.timestamp > since)
1101
+ : _networkBuffer.slice();
1102
+ if (limit > 0)
1103
+ entries = entries.slice(-limit);
1104
+ jsonResponse(res, { entries });
1105
+ return;
1106
+ }
940
1107
  default: {
941
1108
  res.writeHead(404);
942
1109
  res.end('Not found');
@@ -1005,6 +1172,58 @@ async function handleRequest(req, res, dlog) {
1005
1172
  jsonResponse(res, { ok: true });
1006
1173
  return;
1007
1174
  }
1175
+ case '/evaluate': {
1176
+ const expr = body['expr'];
1177
+ if (!expr) {
1178
+ jsonResponse(res, { error: 'expr is required' }, 400);
1179
+ return;
1180
+ }
1181
+ const p = await getPage(dlog);
1182
+ try {
1183
+ // Wrap so a bare expression (e.g. `1+1`) and a statement block both work.
1184
+ const result = await p.evaluate(`(() => { return (${expr}); })()`).catch(
1185
+ // Fall back to evaluating as-is for IIFEs / `async () => …` expressions.
1186
+ () => p.evaluate(expr));
1187
+ jsonResponse(res, { result });
1188
+ }
1189
+ catch (e) {
1190
+ jsonResponse(res, { error: e instanceof Error ? e.message : String(e) });
1191
+ }
1192
+ return;
1193
+ }
1194
+ case '/networkRequest': {
1195
+ const url = body['url'];
1196
+ if (!url) {
1197
+ jsonResponse(res, { error: 'url is required' }, 400);
1198
+ return;
1199
+ }
1200
+ const reqMethod = (body['method'] ?? 'GET').toUpperCase();
1201
+ const headers = body['headers'] ?? {};
1202
+ const data = body['body'];
1203
+ if (!_context) {
1204
+ jsonResponse(res, { error: 'no browser context' }, 500);
1205
+ return;
1206
+ }
1207
+ try {
1208
+ const apiRes = await _context.request.fetch(url, {
1209
+ method: reqMethod,
1210
+ headers,
1211
+ ...(data !== undefined ? { data } : {}),
1212
+ });
1213
+ const hdrs = apiRes.headers();
1214
+ const text = await apiRes.text();
1215
+ jsonResponse(res, {
1216
+ ok: apiRes.ok(),
1217
+ status: apiRes.status(),
1218
+ headers: hdrs,
1219
+ body: text,
1220
+ });
1221
+ }
1222
+ catch (e) {
1223
+ jsonResponse(res, { ok: false, error: e instanceof Error ? e.message : String(e) });
1224
+ }
1225
+ return;
1226
+ }
1008
1227
  case '/navigate':
1009
1228
  case '/launchApp': {
1010
1229
  const targetUrl = (body['url'] ?? body['bundleId'] ?? body['appId']);
@@ -439,7 +439,7 @@ function collectWebElements(nodes, results) {
439
439
  for (const node of nodes) {
440
440
  const visible = node.bounds && node.bounds.width > 0 && node.bounds.height > 0;
441
441
  if (visible) {
442
- const hasContent = !!(node.name || node.ref);
442
+ const hasContent = !!(node.name || node.ref || node.testId);
443
443
  const isLeaf = !node.children || node.children.length === 0;
444
444
  if (hasContent || isLeaf) {
445
445
  results.push(node);
@@ -450,10 +450,14 @@ function collectWebElements(nodes, results) {
450
450
  }
451
451
  }
452
452
  }
453
+ /** Web id field: prefer the canvas mirror's `data-testid`, else the ARIA `ref`. */
454
+ function webElementId(node) {
455
+ return node.testId || node.ref;
456
+ }
453
457
  function matchesWebElement(node, sel) {
454
458
  if (sel.query) {
455
459
  const textOk = matchPatternAgainstAnyTextField(sel.query, [node.name]);
456
- const idOk = matchPatternAgainstElementId(sel.query, node.ref);
460
+ const idOk = matchPatternAgainstElementId(sel.query, webElementId(node));
457
461
  if (!textOk && !idOk)
458
462
  return false;
459
463
  }
@@ -462,7 +466,7 @@ function matchesWebElement(node, sel) {
462
466
  return false;
463
467
  }
464
468
  if (sel.id) {
465
- if (!matchPatternAgainstElementId(sel.id, node.ref))
469
+ if (!matchPatternAgainstElementId(sel.id, webElementId(node)))
466
470
  return false;
467
471
  }
468
472
  if (sel.enabled !== undefined && node.enabled !== sel.enabled)
@@ -532,7 +536,7 @@ function findWebElement(hierarchy, sel) {
532
536
  matches.forEach((n, i) => {
533
537
  const b = n.bounds;
534
538
  const interactive = WEB_INTERACTIVE_ROLES.has(n.role);
535
- (0, verbose_js_1.log)(` [${i}] text="${n.name}" ref="${n.ref}" role="${n.role}" ` +
539
+ (0, verbose_js_1.log)(` [${i}] text="${n.name}" id="${webElementId(n)}" role="${n.role}" ` +
536
540
  `bounds=[${Math.round(b.x)},${Math.round(b.y)}][${Math.round(b.x + b.width)},${Math.round(b.y + b.height)}]` +
537
541
  `${interactive ? ' (interactive)' : ''}`);
538
542
  });
@@ -554,7 +558,7 @@ function findWebElement(hierarchy, sel) {
554
558
  return null;
555
559
  }
556
560
  const b = node.bounds;
557
- (0, verbose_js_1.log)(`[Web] chose [${idx}] text="${node.name}" ref="${node.ref}" role="${node.role}" ` +
561
+ (0, verbose_js_1.log)(`[Web] chose [${idx}] text="${node.name}" id="${webElementId(node)}" role="${node.role}" ` +
558
562
  `bounds=[${Math.round(b.x)},${Math.round(b.y)}][${Math.round(b.x + b.width)},${Math.round(b.y + b.height)}] ` +
559
563
  `→ tap (${Math.round(b.x + b.width / 2)}, ${Math.round(b.y + b.height / 2)})`);
560
564
  return {
@@ -562,7 +566,7 @@ function findWebElement(hierarchy, sel) {
562
566
  centerY: b.y + b.height / 2,
563
567
  bounds: { x: b.x, y: b.y, width: b.width, height: b.height },
564
568
  text: node.name || undefined,
565
- id: node.ref || undefined,
569
+ id: webElementId(node) || undefined,
566
570
  };
567
571
  }
568
572
  /**
@@ -579,6 +583,8 @@ function visitWeb(nodes, lines, depth) {
579
583
  parts.push(node.role);
580
584
  if (node.name)
581
585
  parts.push(`"${node.name}"`);
586
+ if (node.testId)
587
+ parts.push(`id=${node.testId}`);
582
588
  if (node.ref)
583
589
  parts.push(`ref=${node.ref}`);
584
590
  if (node.bounds) {
@@ -588,7 +594,10 @@ function visitWeb(nodes, lines, depth) {
588
594
  if (!node.enabled)
589
595
  parts.push('disabled');
590
596
  // Only output nodes that have content
591
- if (node.name || node.ref || (node.bounds && (!node.children || node.children.length === 0))) {
597
+ if (node.name ||
598
+ node.ref ||
599
+ node.testId ||
600
+ (node.bounds && (!node.children || node.children.length === 0))) {
592
601
  lines.push(`${' '.repeat(depth)}${parts.join(' ')}`);
593
602
  }
594
603
  if (node.children) {
@@ -60,6 +60,14 @@ class WebDriver {
60
60
  }
61
61
  return JSON.parse(data.toString('utf-8'));
62
62
  }
63
+ /** POST that returns the parsed JSON response body (unlike `post`, which discards it). */
64
+ async postJson(path, body) {
65
+ const { status, data } = await this.request('POST', `/${path}`, body);
66
+ if (status < 200 || status >= 300) {
67
+ throw new Error(`Web driver ${path} failed (HTTP ${status}): ${data.toString('utf-8').slice(0, 200)}`);
68
+ }
69
+ return JSON.parse(data.toString('utf-8'));
70
+ }
63
71
  async isAlive() {
64
72
  try {
65
73
  const { status } = await this.request('GET', '/status');
@@ -154,6 +162,24 @@ class WebDriver {
154
162
  }
155
163
  return data.toString('utf-8');
156
164
  }
165
+ /** Recent page network traffic captured via Playwright request/response events. */
166
+ async networkLogs(opts = {}) {
167
+ const qs = new URLSearchParams();
168
+ if (opts.limit)
169
+ qs.set('limit', String(opts.limit));
170
+ if (opts.since)
171
+ qs.set('since', opts.since);
172
+ const s = qs.toString();
173
+ return this.get(`networkLogs${s ? `?${s}` : ''}`);
174
+ }
175
+ /** Issue an HTTP request from the browser context (shares cookies/session with the page). */
176
+ async networkRequest(url, opts = {}) {
177
+ return this.postJson('networkRequest', { url, ...opts });
178
+ }
179
+ /** Evaluate a JS expression in the page runtime and return its (JSON-serializable) value. */
180
+ async evaluate(expr) {
181
+ return this.postJson('evaluate', { expr });
182
+ }
157
183
  async eraseAllText(count = 50) {
158
184
  await this.post('eraseText', { count });
159
185
  }
@@ -0,0 +1,130 @@
1
+ "use strict";
2
+ // Central registry of commands/parameters that only accept a fixed set of
3
+ // enumerated values. The `list-options` command and the global `--options`
4
+ // flag read from here so agents (and humans) can discover valid values without
5
+ // trial-and-error.
6
+ //
7
+ // Where a value list already exists as the canonical source elsewhere, we
8
+ // import it so this registry can never drift from what the command validates.
9
+ // Small, stable 2–4 value lists are inlined with a pointer to their source.
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.ENUM_PARAMS = void 0;
12
+ exports.commandsWithEnums = commandsWithEnums;
13
+ exports.findEnumParams = findEnumParams;
14
+ const press_key_js_1 = require("./commands/press-key.js");
15
+ const set_viewport_js_1 = require("./commands/set-viewport.js");
16
+ const utils_js_1 = require("./utils.js");
17
+ const types_js_1 = require("./drivers/log-sources/types.js");
18
+ const DIRECTION_VALUES = utils_js_1.DIRECTIONS.map((d) => ({ value: d }));
19
+ // `--level` accepts every key of LEVEL_SEVERITY (includes the `warn` alias).
20
+ const LOG_LEVELS = Object.keys(types_js_1.LEVEL_SEVERITY).map((value) => ({ value }));
21
+ exports.ENUM_PARAMS = [
22
+ {
23
+ command: 'press-key',
24
+ param: '<key>',
25
+ description: 'Key, hardware button, or remote button to press',
26
+ values: press_key_js_1.VALID_KEYS.map((value) => ({ value })),
27
+ note: 'Matched case-insensitively. Availability varies by platform: "Remote …" / "TV …" keys target tvOS and Android TV; hardware buttons (Home, Lock, Power, Volume…) target iOS/Android.',
28
+ },
29
+ {
30
+ command: 'scroll',
31
+ param: '--direction',
32
+ description: 'Scroll direction (default: down)',
33
+ values: DIRECTION_VALUES,
34
+ },
35
+ {
36
+ command: 'swipe',
37
+ param: '--direction',
38
+ description: 'Swipe direction (required unless --start/--end are given)',
39
+ values: DIRECTION_VALUES,
40
+ },
41
+ {
42
+ command: 'scroll-until-visible',
43
+ param: '--direction',
44
+ description: 'Scroll direction while searching (default: down)',
45
+ values: DIRECTION_VALUES,
46
+ },
47
+ {
48
+ command: 'set-orientation',
49
+ param: '<orientation>',
50
+ description: 'Device orientation',
51
+ // Source: VALID in commands/set-orientation.ts
52
+ values: [{ value: 'portrait' }, { value: 'landscape' }],
53
+ },
54
+ {
55
+ command: 'start-device',
56
+ param: '--platform',
57
+ description: 'Platform of the device to start',
58
+ // Source: switch in commands/start-device.ts
59
+ values: [{ value: 'ios' }, { value: 'android' }, { value: 'tvos' }, { value: 'web' }],
60
+ },
61
+ {
62
+ command: 'install-web',
63
+ param: '[browser]',
64
+ description: 'Playwright browser to install (default: chromium)',
65
+ // Source: validBrowsers in commands/install.ts
66
+ values: [{ value: 'chromium' }, { value: 'firefox' }, { value: 'webkit' }],
67
+ },
68
+ {
69
+ command: 'set-viewport',
70
+ param: '--preset',
71
+ description: 'Device size preset instead of explicit width/height (web only)',
72
+ values: Object.entries(set_viewport_js_1.PRESETS).map(([value, p]) => ({
73
+ value,
74
+ description: `${p.width}x${p.height} @${p.deviceScaleFactor}x${p.isMobile ? ', mobile' : ''}`,
75
+ })),
76
+ },
77
+ {
78
+ command: 'set-viewport',
79
+ param: '--color-scheme',
80
+ description: 'Emulate prefers-color-scheme (web only)',
81
+ values: [{ value: 'dark' }, { value: 'light' }],
82
+ },
83
+ {
84
+ command: 'logs',
85
+ param: '--source',
86
+ description: 'Filter logs by source (default: both)',
87
+ // Source: sourceFilter in commands/logs.ts
88
+ values: [{ value: 'metro' }, { value: 'device' }],
89
+ },
90
+ {
91
+ command: 'logs',
92
+ param: '--level',
93
+ description: 'Minimum log level to show',
94
+ values: LOG_LEVELS,
95
+ note: '"warn" is an alias for "warning".',
96
+ },
97
+ {
98
+ command: 'list-devices',
99
+ param: '--platform',
100
+ description: 'Filter listed devices by platform (also a global filter on most commands)',
101
+ values: [{ value: 'ios' }, { value: 'android' }, { value: 'tvos' }, { value: 'web' }],
102
+ },
103
+ ];
104
+ /** All distinct command names that have at least one enumerated parameter. */
105
+ function commandsWithEnums() {
106
+ return [...new Set(exports.ENUM_PARAMS.map((p) => p.command))];
107
+ }
108
+ /**
109
+ * Find enumerated parameters matching a query. Matches a command name (e.g.
110
+ * "press-key"), a bare parameter name (e.g. "direction", "--level"), or a
111
+ * value (e.g. "tvos"). Returns all params when query is empty.
112
+ */
113
+ function findEnumParams(query) {
114
+ if (!query)
115
+ return exports.ENUM_PARAMS;
116
+ const norm = (s) => s
117
+ .toLowerCase()
118
+ .replace(/^-+/, '')
119
+ .replace(/[<>[\]]/g, '');
120
+ const q = norm(query);
121
+ // Exact matches (command, param, or value) take precedence so that an exact
122
+ // command name like "scroll" doesn't also drag in "scroll-until-visible".
123
+ const exact = exports.ENUM_PARAMS.filter((p) => p.command.toLowerCase() === q ||
124
+ norm(p.param) === q ||
125
+ p.values.some((v) => v.value.toLowerCase() === q));
126
+ if (exact.length > 0)
127
+ return exact;
128
+ // Otherwise fall back to substring matching to forgive partial queries.
129
+ return exports.ENUM_PARAMS.filter((p) => p.command.toLowerCase().includes(q) || norm(p.param).includes(q));
130
+ }
package/dist/index.js CHANGED
@@ -28,6 +28,7 @@ const press_key_js_1 = require("./commands/press-key.js");
28
28
  const session_js_1 = require("./commands/session.js");
29
29
  const daemon_js_1 = require("./commands/daemon.js");
30
30
  const install_js_1 = require("./commands/install.js");
31
+ const init_js_1 = require("./commands/init.js");
31
32
  const device_pool_js_1 = require("./commands/device-pool.js");
32
33
  const run_parallel_js_1 = require("./commands/run-parallel.js");
33
34
  const run_sequence_js_1 = require("./commands/run-sequence.js");
@@ -59,6 +60,7 @@ const logs_js_1 = require("./commands/logs.js");
59
60
  const memory_js_1 = require("./commands/memory.js");
60
61
  const metro_js_1 = require("./commands/metro.js");
61
62
  const clipboard_js_1 = require("./commands/clipboard.js");
63
+ const options_js_1 = require("./commands/options.js");
62
64
  const device_picker_js_1 = require("./device-picker.js");
63
65
  const update_check_js_1 = require("./update-check.js");
64
66
  const pkg_root_js_1 = require("./pkg-root.js");
@@ -101,6 +103,7 @@ const COMMAND_HELP = {
101
103
  'run-flow-inline': run_flow_inline_js_1.HELP,
102
104
  session: session_js_1.HELP,
103
105
  'install-web': install_js_1.HELP_INSTALL_WEB,
106
+ init: init_js_1.HELP,
104
107
  'daemon-start': daemon_js_1.HELP_DAEMON_START,
105
108
  'daemon-stop': daemon_js_1.HELP_DAEMON_STOP,
106
109
  'daemon-status': daemon_js_1.HELP_DAEMON_STATUS,
@@ -119,12 +122,14 @@ const COMMAND_HELP = {
119
122
  metro: metro_js_1.HELP,
120
123
  clipboard: clipboard_js_1.HELP,
121
124
  paste: ' paste Trigger OS-level paste (or type clipboard on iOS)',
125
+ 'list-options': options_js_1.HELP,
122
126
  };
123
127
  const OPTIONS_HELP = `Options:
124
128
  --device <id> Target device ID (also keys the session and daemon)
125
129
  --device-name <n> Target a booted device by name (resolved to ID from booted devices)
126
130
  --platform <p> Filter to devices of this platform (ios, android, tvos, web)
127
131
  --json Output as machine-readable JSON
132
+ --options List valid values for a command's enumerated parameters and exit
128
133
  --verbose, -v Log daemon calls, fallbacks, and raw output
129
134
  --version, -V Print version number
130
135
  --help, -h Show this help`;
@@ -141,6 +146,7 @@ async function main() {
141
146
  boolean: [
142
147
  'json',
143
148
  'help',
149
+ 'options',
144
150
  'version',
145
151
  'clear',
146
152
  'list',
@@ -163,6 +169,9 @@ async function main() {
163
169
  'leaks',
164
170
  'snapshots',
165
171
  'growth-only',
172
+ 'global',
173
+ 'force',
174
+ 'yes',
166
175
  ],
167
176
  string: [
168
177
  'device',
@@ -222,7 +231,7 @@ async function main() {
222
231
  'user-agent',
223
232
  'color-scheme',
224
233
  ],
225
- alias: { h: 'help', v: 'verbose', V: 'version', o: 'output' },
234
+ alias: { h: 'help', v: 'verbose', V: 'version', o: 'output', y: 'yes' },
226
235
  });
227
236
  if (argv['verbose'])
228
237
  (0, verbose_js_1.setVerbose)(true);
@@ -234,6 +243,12 @@ async function main() {
234
243
  console.log(pkg.version);
235
244
  process.exit(0);
236
245
  }
246
+ // `<command> --options` lists the valid values for that command's enumerated
247
+ // parameters and exits — no device resolution needed. With no command it
248
+ // lists every enumerated parameter. `--help` still wins if both are passed.
249
+ if (argv['options'] && !argv['help'] && command !== 'list-options') {
250
+ process.exit((0, options_js_1.listOptions)(command, opts));
251
+ }
237
252
  // Handle help and unknown commands before device resolution —
238
253
  // no point prompting for a device if we're just printing help or erroring out.
239
254
  if (!command || argv['help']) {
@@ -252,11 +267,13 @@ async function main() {
252
267
  'stop-device',
253
268
  'delete-device',
254
269
  'install-web',
270
+ 'init',
255
271
  'copy-app',
256
272
  'device-pool',
257
273
  'run-parallel',
258
274
  'metro',
259
275
  'workspace',
276
+ 'list-options',
260
277
  // `logs --list` and `logs --source metro` only query Metro on localhost — no device needed
261
278
  // `logs` always needs a device session — Metro discovery is device-scoped.
262
279
  // `daemon-stop --all` stops every daemon — no device needed
@@ -418,6 +435,10 @@ async function main() {
418
435
  exitCode = await (0, press_key_js_1.pressKey)(key, opts, sessionName);
419
436
  break;
420
437
  }
438
+ case 'list-options': {
439
+ exitCode = (0, options_js_1.listOptions)(rest[0], opts);
440
+ break;
441
+ }
421
442
  case 'scroll': {
422
443
  const dir = (argv['direction'] || 'down').toLowerCase();
423
444
  exitCode = await (0, scroll_js_1.scroll)(dir, opts, sessionName);
@@ -610,6 +631,13 @@ async function main() {
610
631
  case 'session':
611
632
  exitCode = await (0, session_js_1.sessionCmd)(argv['clear'], argv['list'], opts, sessionName);
612
633
  break;
634
+ case 'init':
635
+ exitCode = await (0, init_js_1.init)(opts, rest[0], {
636
+ global: argv['global'],
637
+ force: argv['force'],
638
+ yes: argv['yes'],
639
+ });
640
+ break;
613
641
  case 'install-web':
614
642
  exitCode = await (0, install_js_1.installWebCli)(opts, argv['check'], rest[0]);
615
643
  break;
package/dist/utils.js CHANGED
@@ -1,10 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DIRECTIONS = void 0;
3
4
  exports.sleep = sleep;
4
5
  exports.swipeCoords = swipeCoords;
5
6
  function sleep(ms) {
6
7
  return new Promise((resolve) => setTimeout(resolve, ms));
7
8
  }
9
+ exports.DIRECTIONS = ['down', 'up', 'left', 'right'];
8
10
  function swipeCoords(dir) {
9
11
  switch (dir) {
10
12
  case 'down':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@houwert/conductor",
3
- "version": "0.19.1",
3
+ "version": "0.21.0",
4
4
  "description": "CLI tool for mobile app interactions — optimized for AI agents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -17,7 +17,8 @@
17
17
  "main": "./dist/index.js",
18
18
  "files": [
19
19
  "dist/",
20
- "proto/"
20
+ "proto/",
21
+ "skills/"
21
22
  ],
22
23
  "scripts": {
23
24
  "build": "tsc",