@houwert/conductor 0.2.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.
Files changed (60) hide show
  1. package/.claude-plugin/plugin.json +6 -0
  2. package/README.md +39 -0
  3. package/dist/commands/assert-not-visible.js +47 -0
  4. package/dist/commands/assert-visible.js +58 -0
  5. package/dist/commands/back.js +25 -0
  6. package/dist/commands/cheat-sheet.js +100 -0
  7. package/dist/commands/daemon.js +61 -0
  8. package/dist/commands/device-pool.js +202 -0
  9. package/dist/commands/erase-text.js +26 -0
  10. package/dist/commands/foreground-app.js +50 -0
  11. package/dist/commands/hide-keyboard.js +27 -0
  12. package/dist/commands/inspect.js +37 -0
  13. package/dist/commands/install.js +64 -0
  14. package/dist/commands/launch-app.js +42 -0
  15. package/dist/commands/list-apps.js +60 -0
  16. package/dist/commands/list-devices.js +61 -0
  17. package/dist/commands/open-link.js +22 -0
  18. package/dist/commands/press-key.js +91 -0
  19. package/dist/commands/run-flow-inline.js +25 -0
  20. package/dist/commands/run-flow.js +29 -0
  21. package/dist/commands/run-parallel.js +143 -0
  22. package/dist/commands/screenshot.js +29 -0
  23. package/dist/commands/scroll-until-visible.js +69 -0
  24. package/dist/commands/scroll.js +36 -0
  25. package/dist/commands/session.js +49 -0
  26. package/dist/commands/set-location.js +18 -0
  27. package/dist/commands/set-orientation.js +23 -0
  28. package/dist/commands/start-device.js +178 -0
  29. package/dist/commands/stop-app.js +32 -0
  30. package/dist/commands/swipe.js +72 -0
  31. package/dist/commands/tap.js +69 -0
  32. package/dist/commands/type.js +22 -0
  33. package/dist/daemon/client.js +112 -0
  34. package/dist/daemon/protocol.js +25 -0
  35. package/dist/daemon/server.js +208 -0
  36. package/dist/drivers/android.js +343 -0
  37. package/dist/drivers/bootstrap.js +371 -0
  38. package/dist/drivers/element-resolver.js +371 -0
  39. package/dist/drivers/flow-runner.js +1309 -0
  40. package/dist/drivers/ios.js +328 -0
  41. package/dist/drivers/js-engine.js +150 -0
  42. package/dist/drivers/wait.js +211 -0
  43. package/dist/index.js +426 -0
  44. package/dist/output.js +36 -0
  45. package/dist/pkg-root.js +28 -0
  46. package/dist/postinstall.js +12 -0
  47. package/dist/runner.js +190 -0
  48. package/dist/session.js +66 -0
  49. package/dist/update-check.js +109 -0
  50. package/dist/utils.js +19 -0
  51. package/dist/verbose.js +17 -0
  52. package/drivers/android/conductor-app.apk +0 -0
  53. package/drivers/android/conductor-server.apk +0 -0
  54. package/drivers/ios/conductor-driver-ios-config.xctestrun +126 -0
  55. package/drivers/ios/conductor-driver-ios.zip +0 -0
  56. package/drivers/ios/conductor-driver-iosUITests-Runner.zip +0 -0
  57. package/package.json +52 -0
  58. package/proto/conductor_android.proto +116 -0
  59. package/skills/conductor/SKILL.md +677 -0
  60. package/skills/conductor/references/flow-syntax.md +179 -0
@@ -0,0 +1,371 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.findIOSElement = findIOSElement;
4
+ exports.parseAndroidHierarchy = parseAndroidHierarchy;
5
+ exports.findAndroidElement = findAndroidElement;
6
+ exports.inspectIOSToText = inspectIOSToText;
7
+ exports.inspectAndroidToText = inspectAndroidToText;
8
+ const verbose_js_1 = require("../verbose.js");
9
+ // XCUIElementType rawValues that represent interactive controls.
10
+ // Mirrors Maestro's clickableFirst() behaviour for iOS, where clickable is not
11
+ // exposed in the AXElement — we sort by element type instead.
12
+ const IOS_INTERACTIVE_TYPES = new Set([
13
+ 9, // Button
14
+ 23, // Slider
15
+ 40, // Switch
16
+ 49, // TextField
17
+ 50, // SecureTextField
18
+ 54, // Link
19
+ 73, // Picker
20
+ 74, // PickerWheel
21
+ 75, // Cell
22
+ 90, // Stepper
23
+ 93, // SearchField
24
+ ]);
25
+ // ── iOS: AXElement tree traversal ────────────────────────────────────────────
26
+ /** Collect all visible (non-zero-size) leaf/interactive elements from an AXElement tree. */
27
+ function collectIOSElements(node, results) {
28
+ const { Width, Height } = node.frame;
29
+ const visible = Width > 0 && Height > 0;
30
+ if (visible) {
31
+ const hasContent = !!(node.label ||
32
+ node.identifier ||
33
+ node.title ||
34
+ node.value ||
35
+ node.placeholderValue);
36
+ const isLeaf = !node.children || node.children.length === 0;
37
+ if (hasContent || isLeaf) {
38
+ results.push(node);
39
+ }
40
+ }
41
+ // Always recurse — the root element has a zero-size frame but valid children
42
+ for (const child of node.children ?? []) {
43
+ collectIOSElements(child, results);
44
+ }
45
+ }
46
+ function iosTextOf(node) {
47
+ return node.label || node.title || node.value || node.placeholderValue || '';
48
+ }
49
+ function matchesText(candidate, query) {
50
+ if (!query)
51
+ return false;
52
+ if (candidate === query)
53
+ return true;
54
+ if (candidate.toLowerCase() === query.toLowerCase())
55
+ return true;
56
+ // fuzzy: .*query.* regex
57
+ try {
58
+ const re = new RegExp(`.*${escapeRegex(query)}.*`, 'i');
59
+ return re.test(candidate);
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ }
65
+ function escapeRegex(s) {
66
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
67
+ }
68
+ function matchesIOSElement(node, sel) {
69
+ if (sel.query) {
70
+ const text = iosTextOf(node);
71
+ if (!matchesText(text, sel.query) && !matchesText(node.identifier, sel.query))
72
+ return false;
73
+ }
74
+ if (sel.text) {
75
+ if (!matchesText(iosTextOf(node), sel.text))
76
+ return false;
77
+ }
78
+ if (sel.id) {
79
+ if (!matchesText(node.identifier, sel.id))
80
+ return false;
81
+ }
82
+ // State attributes — only match fields that exist on AXElement
83
+ if (sel.enabled !== undefined && node.enabled !== sel.enabled)
84
+ return false;
85
+ if (sel.selected !== undefined && node.selected !== sel.selected)
86
+ return false;
87
+ // AXElement.hasFocus maps to the focused selector
88
+ if (sel.focused !== undefined && node.hasFocus !== sel.focused)
89
+ return false;
90
+ // AXElement has no checked field — sel.checked is silently ignored for iOS
91
+ return true;
92
+ }
93
+ /**
94
+ * Mirrors Maestro's deepestMatchingElement(): for each branch of the tree,
95
+ * return matching nodes only from the deepest level that has a match.
96
+ * This prevents parent wrapper nodes that inherit their child's accessibility
97
+ * label (common in React Native) from appearing as separate candidates.
98
+ */
99
+ function deepestMatchingIOSElements(node, pred) {
100
+ // Recurse into children first — deepest match wins over ancestor
101
+ const childMatches = (node.children ?? []).flatMap((child) => deepestMatchingIOSElements(child, pred));
102
+ if (childMatches.length > 0)
103
+ return childMatches;
104
+ // No descendant matched — check this node itself
105
+ const { Width, Height } = node.frame;
106
+ if (Width > 0 && Height > 0 && pred(node))
107
+ return [node];
108
+ return [];
109
+ }
110
+ function findIOSElement(root, sel) {
111
+ // Resolve reference frame for relative-position selectors using the full flat list
112
+ let refFrame = null;
113
+ const relSel = sel.below ?? sel.above ?? sel.leftOf ?? sel.rightOf;
114
+ if (relSel) {
115
+ const allNodes = [];
116
+ collectIOSElements(root, allNodes);
117
+ const ref = allNodes.find((n) => matchesIOSElement(n, relSel));
118
+ if (!ref)
119
+ return null;
120
+ refFrame = ref.frame;
121
+ }
122
+ // Find deepest matching nodes (eliminates wrapper duplicates)
123
+ let matches = deepestMatchingIOSElements(root, (n) => matchesIOSElement(n, sel));
124
+ if (refFrame) {
125
+ const refBottom = refFrame.Y + refFrame.Height;
126
+ const refRight = refFrame.X + refFrame.Width;
127
+ if (sel.below) {
128
+ matches = matches.filter((n) => n.frame.Y >= refBottom);
129
+ }
130
+ else if (sel.above) {
131
+ matches = matches.filter((n) => n.frame.Y + n.frame.Height <= refFrame.Y);
132
+ }
133
+ else if (sel.leftOf) {
134
+ matches = matches.filter((n) => n.frame.X + n.frame.Width <= refFrame.X);
135
+ }
136
+ else if (sel.rightOf) {
137
+ matches = matches.filter((n) => n.frame.X >= refRight);
138
+ }
139
+ }
140
+ if (matches.length === 0) {
141
+ (0, verbose_js_1.log)(`[iOS] no candidates matched selector`, sel);
142
+ return null;
143
+ }
144
+ (0, verbose_js_1.log)(`[iOS] ${matches.length} candidate(s):`);
145
+ matches.forEach((n, i) => {
146
+ const { X, Y, Width, Height } = n.frame;
147
+ const interactive = IOS_INTERACTIVE_TYPES.has(n.elementType);
148
+ (0, verbose_js_1.log)(` [${i}] text="${iosTextOf(n)}" id="${n.identifier}" ` +
149
+ `bounds=[${Math.round(X)},${Math.round(Y)}][${Math.round(X + Width)},${Math.round(Y + Height)}] ` +
150
+ `type=${n.elementType}${interactive ? ' (interactive)' : ''}`);
151
+ });
152
+ if (sel.index !== undefined) {
153
+ // Mirror Maestro's INDEX_COMPARATOR: sort top-to-bottom, then left-to-right
154
+ matches = [...matches].sort((a, b) => {
155
+ const dy = a.frame.Y - b.frame.Y;
156
+ return dy !== 0 ? dy : a.frame.X - b.frame.X;
157
+ });
158
+ }
159
+ else {
160
+ // Prefer interactive element types (approximation of Maestro's clickableFirst for iOS)
161
+ matches = [...matches].sort((a, b) => Number(IOS_INTERACTIVE_TYPES.has(b.elementType)) -
162
+ Number(IOS_INTERACTIVE_TYPES.has(a.elementType)));
163
+ }
164
+ const idx = sel.index ?? 0;
165
+ const node = matches[idx < 0 ? matches.length + idx : idx];
166
+ if (!node) {
167
+ (0, verbose_js_1.log)(`[iOS] index ${idx} out of range (${matches.length} candidates)`);
168
+ return null;
169
+ }
170
+ const { X, Y, Width, Height } = node.frame;
171
+ (0, verbose_js_1.log)(`[iOS] chose [${idx}] text="${iosTextOf(node)}" id="${node.identifier}" ` +
172
+ `bounds=[${Math.round(X)},${Math.round(Y)}][${Math.round(X + Width)},${Math.round(Y + Height)}] ` +
173
+ `→ tap (${Math.round(X + Width / 2)}, ${Math.round(Y + Height / 2)})`);
174
+ return {
175
+ centerX: X + Width / 2,
176
+ centerY: Y + Height / 2,
177
+ text: iosTextOf(node) || undefined,
178
+ id: node.identifier || undefined,
179
+ };
180
+ }
181
+ function parseBounds(bounds) {
182
+ const m = bounds.match(/\[(\d+),(\d+)]\[(\d+),(\d+)]/);
183
+ if (!m)
184
+ return null;
185
+ return { x1: +m[1], y1: +m[2], x2: +m[3], y2: +m[4] };
186
+ }
187
+ // Minimal XML attribute parser — just extract attributes from node strings.
188
+ function parseXmlAttributes(str) {
189
+ const attrs = {};
190
+ const re = /(\w[\w-]*)="([^"]*)"/g;
191
+ let m;
192
+ while ((m = re.exec(str)) !== null) {
193
+ attrs[m[1]] = m[2];
194
+ }
195
+ return attrs;
196
+ }
197
+ /** Parse Android view hierarchy XML into a flat list of nodes with bounds. */
198
+ function parseAndroidHierarchy(xml) {
199
+ const nodes = [];
200
+ // Match all <node .../> or <node ...> elements and extract attributes
201
+ const nodeRe = /<node([^>]*?)(?:\/>|>)/g;
202
+ let m;
203
+ while ((m = nodeRe.exec(xml)) !== null) {
204
+ const attrs = parseXmlAttributes(m[1]);
205
+ const bounds = parseBounds(attrs['bounds'] ?? '');
206
+ if (!bounds)
207
+ continue;
208
+ const { x1, y1, x2, y2 } = bounds;
209
+ if (x2 - x1 <= 0 || y2 - y1 <= 0)
210
+ continue; // invisible
211
+ nodes.push({
212
+ text: attrs['text'] ?? '',
213
+ resourceId: attrs['resource-id'] ?? '',
214
+ contentDesc: attrs['content-desc'] ?? '',
215
+ bounds,
216
+ clickable: attrs['clickable'] === 'true',
217
+ enabled: attrs['enabled'] === 'true',
218
+ checked: attrs['checked'] === 'true',
219
+ focused: attrs['focused'] === 'true',
220
+ selected: attrs['selected'] === 'true',
221
+ children: [],
222
+ index: nodes.length,
223
+ });
224
+ }
225
+ return nodes;
226
+ }
227
+ function androidTextOf(n) {
228
+ return n.text || n.contentDesc || '';
229
+ }
230
+ function matchesAndroidNode(n, sel) {
231
+ if (sel.query) {
232
+ if (!matchesText(androidTextOf(n), sel.query) && !matchesText(n.resourceId, sel.query))
233
+ return false;
234
+ }
235
+ if (sel.text) {
236
+ if (!matchesText(androidTextOf(n), sel.text))
237
+ return false;
238
+ }
239
+ if (sel.id) {
240
+ if (!matchesText(n.resourceId, sel.id))
241
+ return false;
242
+ }
243
+ if (sel.enabled !== undefined && n.enabled !== sel.enabled)
244
+ return false;
245
+ if (sel.checked !== undefined && n.checked !== sel.checked)
246
+ return false;
247
+ if (sel.focused !== undefined && n.focused !== sel.focused)
248
+ return false;
249
+ if (sel.selected !== undefined && n.selected !== sel.selected)
250
+ return false;
251
+ return true;
252
+ }
253
+ function findAndroidElement(xml, sel) {
254
+ const nodes = parseAndroidHierarchy(xml);
255
+ // Find reference element for relative selectors
256
+ let refBounds = null;
257
+ const relSel = sel.below ?? sel.above ?? sel.leftOf ?? sel.rightOf;
258
+ if (relSel) {
259
+ const refMatches = nodes.filter((n) => matchesAndroidNode(n, relSel));
260
+ if (refMatches.length === 0)
261
+ return null;
262
+ const ref = refMatches[0];
263
+ refBounds = ref.bounds;
264
+ }
265
+ let matches = nodes.filter((n) => matchesAndroidNode(n, sel));
266
+ // Apply relative position filter
267
+ if (refBounds) {
268
+ if (sel.below) {
269
+ matches = matches.filter((n) => n.bounds.y1 >= refBounds.y2);
270
+ }
271
+ else if (sel.above) {
272
+ matches = matches.filter((n) => n.bounds.y2 <= refBounds.y1);
273
+ }
274
+ else if (sel.leftOf) {
275
+ matches = matches.filter((n) => n.bounds.x2 <= refBounds.x1);
276
+ }
277
+ else if (sel.rightOf) {
278
+ matches = matches.filter((n) => n.bounds.x1 >= refBounds.x2);
279
+ }
280
+ }
281
+ // containsChild: not easily supported in the flat model — no-op for now
282
+ // (sel.containsChild would require tree structure, which is not preserved in the flat list)
283
+ if (matches.length === 0) {
284
+ (0, verbose_js_1.log)(`[Android] no candidates matched selector`, sel);
285
+ return null;
286
+ }
287
+ (0, verbose_js_1.log)(`[Android] ${matches.length} candidate(s) before sort/index:`);
288
+ matches.forEach((n, i) => {
289
+ const { x1, y1, x2, y2 } = n.bounds;
290
+ (0, verbose_js_1.log)(` [${i}] text="${androidTextOf(n)}" id="${n.resourceId}" ` +
291
+ `bounds=[${x1},${y1}][${x2},${y2}]` +
292
+ `${n.clickable ? ' (clickable)' : ''}`);
293
+ });
294
+ // Mirror Maestro's clickableFirst(): when no index is specified, prefer
295
+ // clickable nodes so a text label shared by a Button and a plain TextView
296
+ // resolves to the button, matching Maestro's GraalVM behaviour.
297
+ if (sel.index === undefined) {
298
+ matches = [...matches].sort((a, b) => Number(b.clickable) - Number(a.clickable));
299
+ }
300
+ const idx = sel.index ?? 0;
301
+ const node = matches[idx < 0 ? matches.length + idx : idx];
302
+ if (!node) {
303
+ (0, verbose_js_1.log)(`[Android] index ${idx} out of range (${matches.length} candidates)`);
304
+ return null;
305
+ }
306
+ const { x1, y1, x2, y2 } = node.bounds;
307
+ (0, verbose_js_1.log)(`[Android] chose [${idx}] text="${androidTextOf(node)}" id="${node.resourceId}" ` +
308
+ `bounds=[${x1},${y1}][${x2},${y2}] ` +
309
+ `→ tap (${Math.round((x1 + x2) / 2)}, ${Math.round((y1 + y2) / 2)})`);
310
+ return {
311
+ centerX: (x1 + x2) / 2,
312
+ centerY: (y1 + y2) / 2,
313
+ text: androidTextOf(node) || undefined,
314
+ id: node.resourceId || undefined,
315
+ };
316
+ }
317
+ // ── Inspect: LLM-optimized output ────────────────────────────────────────────
318
+ /**
319
+ * Filter iOS hierarchy: remove zero-size nodes and nodes with no meaningful content.
320
+ * Returns a flattened list of lines suitable for agent consumption.
321
+ */
322
+ function inspectIOSToText(root) {
323
+ const lines = [];
324
+ visitIOS(root, lines, 0);
325
+ return lines.join('\n');
326
+ }
327
+ function visitIOS(node, lines, depth) {
328
+ const { X, Y, Width, Height } = node.frame;
329
+ const visible = Width > 0 && Height > 0;
330
+ if (visible) {
331
+ const text = iosTextOf(node);
332
+ const id = node.identifier;
333
+ const parts = [];
334
+ if (text)
335
+ parts.push(`text="${text}"`);
336
+ if (id)
337
+ parts.push(`id="${id}"`);
338
+ parts.push(`bounds=[${Math.round(X)},${Math.round(Y)}][${Math.round(X + Width)},${Math.round(Y + Height)}]`);
339
+ if (!node.enabled)
340
+ parts.push('disabled');
341
+ if (parts.length > 1 || !node.children?.length) {
342
+ lines.push(`${' '.repeat(depth)}${parts.join(' ')}`);
343
+ }
344
+ }
345
+ // Always recurse — the root element has a zero-size frame but valid children
346
+ for (const child of node.children ?? []) {
347
+ visitIOS(child, lines, depth + 1);
348
+ }
349
+ }
350
+ /**
351
+ * Format Android hierarchy XML into LLM-optimized text.
352
+ */
353
+ function inspectAndroidToText(xml) {
354
+ const nodes = parseAndroidHierarchy(xml);
355
+ return nodes
356
+ .filter((n) => androidTextOf(n) || n.resourceId)
357
+ .map((n) => {
358
+ const { x1, y1, x2, y2 } = n.bounds;
359
+ const parts = [];
360
+ const t = androidTextOf(n);
361
+ if (t)
362
+ parts.push(`text="${t}"`);
363
+ if (n.resourceId)
364
+ parts.push(`id="${n.resourceId}"`);
365
+ parts.push(`bounds=[${x1},${y1}][${x2},${y2}]`);
366
+ if (!n.enabled)
367
+ parts.push('disabled');
368
+ return parts.join(' ');
369
+ })
370
+ .join('\n');
371
+ }