@houwert/conductor 0.28.0 → 0.29.1

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.
package/README.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  <img src="assets/banner.png" alt="Conductor" width="800" />
4
4
 
5
- # 🎼 Conductor
5
+ # Conductor
6
6
 
7
- **Give Claude hands. Let it drive your app.**
7
+ **Mobile and web UI automation for AI agents.**
8
8
 
9
9
  [![CI](https://github.com/DouweBos/conductor/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/DouweBos/conductor/actions/workflows/ci.yml)
10
10
  [![npm version](https://img.shields.io/npm/v/@houwert/conductor)](https://www.npmjs.com/package/@houwert/conductor)
@@ -14,13 +14,9 @@
14
14
 
15
15
  ---
16
16
 
17
- Conductor is a token-efficient CLI for mobile UI interactions, built for AI agents. It's a TypeScript reimplementation and partial fork of [Maestro](https://maestro.mobile.dev) that bundles its own native drivers no external CLI, no setup friction, no nonsense.
17
+ Conductor is a token-efficient CLI for driving and inspecting running apps, built for AI agents. It is a TypeScript reimplementation and partial fork of [Maestro](https://maestro.mobile.dev) that ships its own native drivers, so there is no external CLI to install and no JVM to configure.
18
18
 
19
- It started as an experiment: what if Claude could tap through your app while it's writing the code? Turns out that's extremely useful. Now it's a proper tool.
20
-
21
- ## ✨ What it does
22
-
23
- Conductor gives Claude Code the ability to interact with iOS simulators and Android emulators directly from a coding session. It can navigate UI, inspect the live hierarchy, take screenshots, run flows, and manage multiple devices in parallel across concurrent agents.
19
+ It gives a coding agent the ability to operate an app while writing the code for it: navigate the UI, read the live view hierarchy, take screenshots, run flows, and drive several devices in parallel across concurrent agents.
24
20
 
25
21
  ```bash
26
22
  conductor launch-app com.example.myapp
@@ -30,47 +26,85 @@ conductor assert-visible "Dashboard"
30
26
  conductor take-screenshot --output /tmp/screen.png
31
27
  ```
32
28
 
33
- One agent writes the feature. Another taps through the app. They talk. It works. 🤝
29
+ Targets iOS and tvOS simulators, physical iOS/tvOS devices, Android emulators and devices, Amazon Fire TV, and web via Playwright.
34
30
 
35
- ## 🚀 Quick start
31
+ ## Quick start
36
32
 
37
33
  ```bash
38
34
  npm install -g @houwert/conductor
39
35
  ```
40
36
 
41
- That's it. Conductor is a pure CLI. To teach an AI agent how to use it, set up the bundled skills in your repo:
37
+ Conductor is a pure CLI. To teach an AI agent how to use it, install the bundled skills into your repository:
42
38
 
43
39
  ```bash
44
- conductor init # interactive: pick scope + skills, writes them into .claude/skills/
40
+ conductor init # interactive: pick scope + skills, writes to .claude/skills/
45
41
  conductor init --yes # non-interactive: install all skills into ./.claude/skills/
46
42
  conductor init --global # install into ~/.claude/skills/ for all repos
47
- conductor init --force # re-sync skills you've already installed
43
+ conductor init --force # re-sync skills you have already installed
48
44
  ```
49
45
 
50
- `init` is the one manual setup step — run it once per repo. In a terminal it walks you through which skills and where; piped or headless (CI/agent) it installs everything non-interactively. It drops a set of capability-scoped Claude Code skills — `conductor-device-interact`, `conductor-inspect`, `conductor-create-flow`, `conductor-metro-debugger`, `conductor-profiler`, and `conductor-device-setup` — that document every command and the act → observe → act workflow.
46
+ `init` is the one manual setup step — run it once per repository. In a terminal it prompts for which skills and where; piped or headless (CI, agent) it installs everything non-interactively. It writes a set of capability-scoped Claude Code skills — `conductor-device-interact`, `conductor-inspect`, `conductor-create-flow`, `conductor-native`, `conductor-metro-debugger`, `conductor-profiler` and `conductor-device-setup` — documenting every command and the act → observe → act workflow.
47
+
48
+ After upgrading conductor, re-run `conductor init --force` to re-sync the installed skills. `init` stamps the version it installed, so it can tell you when they are stale and prune skills no longer shipped. Integrating another way (a custom `CLAUDE.md`, a slash command) works equally well.
51
49
 
52
- When you upgrade conductor, re-run `conductor init --force` to re-sync the installed skills (it stamps the installed version, so `init` tells you when they're out of date, and prunes any skills no longer shipped). Or wire it in however you like (a custom `CLAUDE.md`, a slash command — it's up to you). Run `conductor --help` for the full command reference, or `conductor <command> --help` for per-command flags.
50
+ Run `conductor --help` for the full command reference, or `conductor <command> --help` for per-command flags.
53
51
 
54
- ### 📱 What the CLI can do
52
+ ### What the CLI can do
55
53
 
56
54
  | Capability | Commands |
57
55
  |---|---|
58
- | App lifecycle | `launch-app`, `stop-app`, `clear-state`, `uninstall-app`, `install-app`, `foreground-app`, `copy-app` |
59
- | Interaction | `tap-on`, `input-text`, `scroll`, `scroll-until-visible`, `swipe`, `press-key`, `erase-text`, `hide-keyboard` |
60
- | Inspection | `inspect`, `focused`, `take-screenshot`, `capture-ui`, `list-apps` |
61
- | Assertions | `assert-visible`, `assert-not-visible` |
56
+ | App lifecycle | `launch-app`, `stop-app`, `clear-state`, `install-app`, `uninstall-app`, `foreground-app`, `copy-app`, `download-app` |
57
+ | Interaction | `tap-on`, `input-text`, `scroll`, `scroll-until-visible`, `swipe`, `gesture`, `pinch`, `press-key`, `erase-text`, `hide-keyboard` |
58
+ | Inspection | `inspect`, `focused`, `capture-ui`, `take-screenshot`, `list-apps` |
59
+ | Assertions | `assert-visible`, `assert-not-visible`, `assert-true`, `assert-screenshot` |
62
60
  | Navigation | `open-link`, `back` |
63
- | Flows | `run-flow`, `run-flow-inline`, `run-parallel` |
64
- | Devices | `start-device`, `list-devices`, `set-location`, `set-orientation` |
61
+ | Flows | `run-flow`, `run-flow-inline`, `run-parallel`, `run-sequence`, `flow` |
62
+ | Devices | `start-device`, `stop-device`, `list-devices`, `device-pool`, `set-location`, `set-orientation`, `set-permissions` |
63
+ | Debugging | `logs`, `crashes`, `network`, `memory`, `profile`, `metro`, `record-video`, `stream-server` |
64
+ | In-process (iOS/tvOS) | `native-inspect`, `native-find`, `native-set`, `native-eval`, `native-heap`, and more |
65
+ | Test cases | `cases` |
65
66
  | Web setup | `install-web [browser]` (installs a Playwright browser; `--check` prints status) |
66
- | Discovery | `list-options [command]` / `<command> --options` (valid values for enumerated params) |
67
+ | Discovery | `list-options [command]` / `<command> --options`, `workspace` |
68
+
69
+ ## Conductor Studio
70
+
71
+ **[Conductor Studio](apps/studio)** is a desktop app (Electron + React) built on this CLI. It does three jobs: writing and managing Maestro tests, writing them with an agent, and tracking them as test cases.
72
+
73
+ It provides a flow editor with autocomplete and linting, a live device stream with element picking and a record mode that turns your interactions into flow steps, an agentic test writer that verifies described behaviour on a device and files a visual report, and local test case management. Light and dark, signed and notarized, auto-updating.
74
+
75
+ Studio bundles its own copy of the conductor CLI, so it needs nothing installed globally. The version it uses can be pinned from Settings.
76
+
77
+ **Download:** [Releases](https://github.com/DouweBos/conductor/releases) — macOS (Apple silicon). Studio releases are tagged `studio-v*`; the CLI's are tagged `cli-v*`.
78
+
79
+ See the [Studio README](apps/studio/README.md) for the full feature tour, architecture and release process.
80
+
81
+ ```bash
82
+ pnpm dev:studio # run it from source
83
+ ```
67
84
 
68
- ## 🔨 Building locally
85
+ ## Repository structure
86
+
87
+ ```
88
+ conductor/
89
+ ├── packages/
90
+ │ ├── cli/ # TypeScript CLI (@houwert/conductor)
91
+ │ ├── android-driver/ # Kotlin/Gradle instrumentation driver
92
+ │ ├── ios-driver/ # Swift/Xcode XCTest driver
93
+ │ ├── ios-inproc/ # Library injected into the app for a second inspection plane
94
+ │ ├── ios-hid/ # Host binary injecting HID below the XCTest layer
95
+ │ ├── ios-capture/ # Host binary capturing the Simulator framebuffer
96
+ │ └── studio-ui/ # Design system for Conductor Studio
97
+ ├── apps/
98
+ │ └── studio/ # Conductor Studio — the desktop app
99
+ └── Makefile
100
+ ```
101
+
102
+ ## Building locally
69
103
 
70
104
  ### Prerequisites
71
105
 
72
- - Node.js + pnpm v9
73
- - **iOS:** Xcode with command-line tools
106
+ - Node.js and pnpm 10
107
+ - **iOS/tvOS:** Xcode with command-line tools
74
108
  - **Android:** Android SDK with `adb` on `PATH`
75
109
 
76
110
  ### Full build
@@ -79,7 +113,7 @@ When you upgrade conductor, re-run `conductor init --force` to re-sync the insta
79
113
  make build
80
114
  ```
81
115
 
82
- Builds the iOS driver (xcodebuild), the Android driver (Gradle), packages both into the CLI, and compiles TypeScript. Then link it globally:
116
+ Builds the iOS and tvOS XCTest drivers, the in-process library and capture binary (xcodebuild), and the Android driver (Gradle); packages them all into the CLI and compiles TypeScript. Then link it globally:
83
117
 
84
118
  ```bash
85
119
  cd packages/cli && pnpm link --global
@@ -97,48 +131,23 @@ pnpm install && pnpm build
97
131
  ### Individual targets
98
132
 
99
133
  ```bash
100
- make build-cli # CLI TypeScript only
101
- make build-ios-driver # iOS XCTest driver
134
+ make build-cli # CLI TypeScript only
135
+ make build-ios-driver # iOS XCTest driver
102
136
  make build-android-driver # Android instrumentation driver
103
- make package-cli # Bundle drivers into CLI package
104
- ```
105
-
106
- ## 🗂️ Repository structure
107
-
108
- ```
109
- conductor/
110
- ├── packages/
111
- │ ├── cli/ # TypeScript CLI (@houwert/conductor)
112
- │ ├── android-driver/ # Kotlin/Gradle instrumentation driver
113
- │ ├── ios-driver/ # Swift/Xcode XCTest driver
114
- │ └── studio-ui/ # Design system for Conductor Studio
115
- ├── apps/
116
- │ └── studio/ # Conductor Studio — the desktop app
117
- └── Makefile
118
- ```
119
-
120
- ## 🖥️ Conductor Studio
121
-
122
- There's also a desktop app: **Conductor Studio**, a Maestro test workbench built
123
- on this CLI — flow editor with autocomplete, live device stream with element
124
- picking, an agentic test writer, and test case management. It lives in
125
- [`apps/studio`](apps/studio); see its
126
- [README](apps/studio/README.md) for what it does and how to run it.
127
-
128
- ```bash
129
- pnpm dev:studio
137
+ make package-cli # Bundle drivers into CLI package
130
138
  ```
131
139
 
132
- ## 🛠️ Development
140
+ ## Development
133
141
 
134
142
  ```bash
135
- pnpm dev # TypeScript watch mode
136
- pnpm lint # ESLint + Prettier check
143
+ pnpm dev # TypeScript watch mode
144
+ pnpm lint # ESLint + Prettier check
137
145
  pnpm lint:fix # Auto-fix formatting
138
- pnpm test # Run test suite
146
+ pnpm test # Run test suite
139
147
  ```
140
148
 
141
- ## 📋 Requirements
149
+ ## Requirements
142
150
 
143
- - **iOS:** Xcode with a booted simulator
151
+ - **iOS/tvOS:** Xcode with a booted simulator, or a paired physical device
144
152
  - **Android:** `adb` on `PATH` with a running emulator or connected device
153
+ - **Web:** a Playwright browser (`conductor install-web`)
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.adb = adb;
4
+ exports.adbShell = adbShell;
5
+ exports.parseResumedPackage = parseResumedPackage;
6
+ exports.resolveAndroidForegroundApp = resolveAndroidForegroundApp;
7
+ exports.shellBracketed = shellBracketed;
8
+ exports.buildClockAnchor = buildClockAnchor;
9
+ exports.parseProcUptimeMs = parseProcUptimeMs;
10
+ exports.toDeviceRealtimeMs = toDeviceRealtimeMs;
11
+ /**
12
+ * Small adb helpers for read-only device queries.
13
+ *
14
+ * Deliberately spawns adb directly rather than going through `runner.ts` so the
15
+ * profiling commands can query a device without booting the gRPC driver daemon —
16
+ * `dumpsys` needs neither, and starting the daemon would perturb what we measure.
17
+ */
18
+ const child_process_1 = require("child_process");
19
+ const sdk_js_1 = require("./sdk.js");
20
+ function adb(deviceId, args) {
21
+ return new Promise((resolve) => {
22
+ const proc = (0, child_process_1.spawn)((0, sdk_js_1.resolveAndroidTool)('adb'), ['-s', deviceId, ...args], {
23
+ stdio: ['ignore', 'pipe', 'pipe'],
24
+ env: (0, sdk_js_1.androidSpawnEnv)(),
25
+ });
26
+ let stdout = '';
27
+ let stderr = '';
28
+ proc.stdout.on('data', (c) => (stdout += c.toString()));
29
+ proc.stderr.on('data', (c) => (stderr += c.toString()));
30
+ proc.on('close', (code) => resolve({ success: code === 0, stdout, stderr }));
31
+ proc.on('error', (err) => resolve({ success: false, stdout: '', stderr: err.message }));
32
+ });
33
+ }
34
+ function adbShell(deviceId, args) {
35
+ return adb(deviceId, ['shell', ...args]);
36
+ }
37
+ /**
38
+ * Package name of the resumed activity, or undefined. Android spells this
39
+ * differently across API levels, hence the alternation.
40
+ */
41
+ function parseResumedPackage(dumpsysActivities) {
42
+ const m = dumpsysActivities.match(/(?:m|top)?ResumedActivity[=:].*?([a-zA-Z][a-zA-Z0-9_]*(?:\.[a-zA-Z][a-zA-Z0-9_]*)+)\//);
43
+ return m ? m[1] : undefined;
44
+ }
45
+ async function resolveAndroidForegroundApp(deviceId) {
46
+ const dump = await adbShell(deviceId, ['dumpsys', 'activity', 'activities']);
47
+ return dump.success ? parseResumedPackage(dump.stdout) : undefined;
48
+ }
49
+ /**
50
+ * Run `command` on the device bracketed by two CLOCK_REALTIME reads, in one
51
+ * adb invocation. The caller supplies the monotonic reading by parsing it out
52
+ * of `stdout` (for gfxinfo, its own `Uptime:` header line).
53
+ */
54
+ async function shellBracketed(deviceId, command) {
55
+ const hostStart = Date.now();
56
+ // One argument, not argv: adb joins everything after `shell` with spaces and
57
+ // the device's own shell parses it, so the semicolons survive.
58
+ const res = await adbShell(deviceId, [`date +%s%N; ${command}; date +%s%N`]);
59
+ const hostElapsedMs = Date.now() - hostStart;
60
+ if (!res.success)
61
+ return undefined;
62
+ const stamps = [...res.stdout.matchAll(/^\s*(\d{16,})\s*$/gm)].map((m) => Number(m[1]) / 1e6);
63
+ if (stamps.length < 2)
64
+ return undefined;
65
+ const realtimeStartMs = stamps[0];
66
+ const realtimeEndMs = stamps[stamps.length - 1];
67
+ // Strip the two stamp lines so callers parse only the command's own output.
68
+ const stdout = res.stdout
69
+ .split(/\r?\n/)
70
+ .filter((l) => !/^\s*\d{16,}\s*$/.test(l))
71
+ .join('\n');
72
+ return { stdout, realtimeStartMs, realtimeEndMs, hostElapsedMs };
73
+ }
74
+ function buildClockAnchor(bracket, deviceMonotonicMs) {
75
+ const spread = bracket.realtimeEndMs - bracket.realtimeStartMs;
76
+ return {
77
+ deviceMonotonicMs,
78
+ deviceRealtimeMs: (bracket.realtimeStartMs + bracket.realtimeEndMs) / 2,
79
+ anchorErrorMs: Math.round((spread / 2) * 100) / 100,
80
+ hostEpochMs: Date.now(),
81
+ // The device cannot have spent more wall time on this than the host did
82
+ // waiting for it; if it claims to, the realtime clock was stepped.
83
+ clockStepped: spread > bracket.hostElapsedMs + 50 ? true : undefined,
84
+ };
85
+ }
86
+ /** Device CLOCK_MONOTONIC ms from /proc/uptime. Cheap (~1ms) but 10ms-grained. */
87
+ function parseProcUptimeMs(out) {
88
+ const m = out.match(/^\s*(\d+\.\d+)\s+\d+\.\d+\s*$/m);
89
+ return m ? Number(m[1]) * 1000 : undefined;
90
+ }
91
+ /** Convert a framestats vsync timestamp (ns, monotonic) to the app's Date.now() domain. */
92
+ function toDeviceRealtimeMs(anchor, monotonicNs) {
93
+ return monotonicNs / 1e6 - anchor.deviceMonotonicMs + anchor.deviceRealtimeMs;
94
+ }
@@ -47,11 +47,11 @@ function resolveSession(sessionName) {
47
47
  };
48
48
  }
49
49
  async function debugStatus(opts, sessionName, debugOpts) {
50
- const port = debugOpts.port ?? 8081;
51
50
  try {
52
- const targets = await (0, metro_js_1.fetchTargets)(port, 'localhost');
53
51
  const { deviceId, platformPromise } = resolveSession(sessionName);
54
52
  const platform = await platformPromise;
53
+ const port = await (0, metro_cdp_js_1.resolveMetroPort)({ port: debugOpts.port, deviceId, platform });
54
+ const targets = await (0, metro_js_1.fetchTargets)(port, 'localhost');
55
55
  const client = new metro_cdp_js_1.MetroCdpClient();
56
56
  await client.connect({ port, deviceId, platform, targetIndex: debugOpts.targetIndex });
57
57
  await client.enableDomain('Runtime');
@@ -120,10 +120,10 @@ async function debugEvaluate(expr, opts, sessionName, debugOpts) {
120
120
  return 1;
121
121
  }
122
122
  }
123
- const port = debugOpts.port ?? 8081;
124
123
  const { deviceId, platformPromise } = resolveSession(sessionName);
125
124
  try {
126
125
  const platform = await platformPromise;
126
+ const port = await (0, metro_cdp_js_1.resolveMetroPort)({ port: debugOpts.port, deviceId, platform });
127
127
  const client = new metro_cdp_js_1.MetroCdpClient();
128
128
  await client.connect({ port, deviceId, platform, targetIndex: debugOpts.targetIndex });
129
129
  const value = await client.evaluate(expr);
@@ -140,10 +140,10 @@ async function debugEvaluate(expr, opts, sessionName, debugOpts) {
140
140
  }
141
141
  }
142
142
  async function debugComponentTree(opts, sessionName, debugOpts) {
143
- const port = debugOpts.port ?? 8081;
144
143
  const { deviceId, platformPromise } = resolveSession(sessionName);
145
144
  try {
146
145
  const platform = await platformPromise;
146
+ const port = await (0, metro_cdp_js_1.resolveMetroPort)({ port: debugOpts.port, deviceId, platform });
147
147
  const client = new metro_cdp_js_1.MetroCdpClient();
148
148
  await client.connect({ port, deviceId, platform, targetIndex: debugOpts.targetIndex });
149
149
  const awaitCallback = await client.installCallbackBinding();
@@ -214,10 +214,10 @@ async function debugInspectElement(at, opts, sessionName, debugOpts) {
214
214
  }
215
215
  const x = Number(m[1]);
216
216
  const y = Number(m[2]);
217
- const port = debugOpts.port ?? 8081;
218
217
  const { deviceId, platformPromise } = resolveSession(sessionName);
219
218
  try {
220
219
  const platform = await platformPromise;
220
+ const port = await (0, metro_cdp_js_1.resolveMetroPort)({ port: debugOpts.port, deviceId, platform });
221
221
  const client = new metro_cdp_js_1.MetroCdpClient();
222
222
  await client.connect({ port, deviceId, platform, targetIndex: debugOpts.targetIndex });
223
223
  const awaitCallback = await client.installCallbackBinding();
@@ -253,10 +253,10 @@ async function debugLogRegistry(opts, sessionName) {
253
253
  return (0, logs_js_1.logs)(opts, sessionName, { source: 'metro', list: true });
254
254
  }
255
255
  async function debugReload(opts, sessionName, debugOpts) {
256
- const port = debugOpts.port ?? 8081;
257
256
  const { deviceId, platformPromise } = resolveSession(sessionName);
258
257
  try {
259
258
  const platform = await platformPromise;
259
+ const port = await (0, metro_cdp_js_1.resolveMetroPort)({ port: debugOpts.port, deviceId, platform });
260
260
  await (0, metro_cdp_js_1.cdpCall)('Page.reload', undefined, {
261
261
  port,
262
262
  deviceId,
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.HELP = void 0;
4
+ exports.queryFocused = queryFocused;
5
+ exports.focusKey = focusKey;
4
6
  exports.focused = focused;
5
7
  exports.HELP = ` focused [--poll [interval_ms]] Print metadata of the currently focused element
6
8
  --poll continuously watches for focus changes (default 500ms)`;
@@ -216,6 +218,31 @@ async function queryFocused(driver) {
216
218
  }
217
219
  throw new Error('Unknown driver type');
218
220
  }
221
+ /**
222
+ * Stable identity for a focused element, for detecting *that focus moved*.
223
+ *
224
+ * Deliberately excludes the visible label: TV rows repeat titles, so two
225
+ * neighbouring cards can print identically. Bounds plus whichever id the
226
+ * platform exposes distinguishes them.
227
+ */
228
+ function focusKey(data) {
229
+ if (!data)
230
+ return '(none)';
231
+ const bounds = data.bounds;
232
+ const boundsKey = bounds
233
+ ? Object.entries(bounds)
234
+ .sort(([a], [b]) => a.localeCompare(b))
235
+ .map(([k, v]) => `${k}=${v}`)
236
+ .join(',')
237
+ : '';
238
+ const id = data.identifier ||
239
+ data.resourceId ||
240
+ data.ref ||
241
+ data.contentDesc ||
242
+ '';
243
+ const type = data.elementTypeName || data.className || data.role || '';
244
+ return `${id}|${type}|${boundsKey}`;
245
+ }
219
246
  async function focused(opts = {}, sessionName = 'default', { poll = false, interval = 500 } = {}) {
220
247
  try {
221
248
  const driver = await (0, runner_js_1.getDriver)(sessionName);