@houwert/conductor 0.20.0 → 0.22.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.
package/README.md CHANGED
@@ -38,7 +38,18 @@ One agent writes the feature. Another taps through the app. They talk. It works.
38
38
  npm install -g @houwert/conductor
39
39
  ```
40
40
 
41
- That's it. Conductor is a pure CLI — no Claude Code plugin or skill is registered. Wire it into your agent however you like (a custom `CLAUDE.md`, a project skill, a slash command — it's up to you). Run `conductor --help` for the full command reference, or `conductor <command> --help` for per-command flags.
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:
42
+
43
+ ```bash
44
+ conductor init # interactive: pick scope + skills, writes them into .claude/skills/
45
+ conductor init --yes # non-interactive: install all skills into ./.claude/skills/
46
+ conductor init --global # install into ~/.claude/skills/ for all repos
47
+ conductor init --force # re-sync skills you've already installed
48
+ ```
49
+
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.
51
+
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.
42
53
 
43
54
  ### 📱 What the CLI can do
44
55
 
@@ -52,6 +63,7 @@ That's it. Conductor is a pure CLI — no Claude Code plugin or skill is registe
52
63
  | Flows | `run-flow`, `run-flow-inline`, `run-parallel` |
53
64
  | Devices | `start-device`, `list-devices`, `set-location`, `set-orientation` |
54
65
  | Web setup | `install-web [browser]` (installs a Playwright browser; `--check` prints status) |
66
+ | Discovery | `list-options [command]` / `<command> --options` (valid values for enumerated params) |
55
67
 
56
68
  ## 🔨 Building locally
57
69
 
@@ -0,0 +1,240 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.HELP = void 0;
7
+ exports.init = init;
8
+ exports.HELP = ` init [target-dir] Set up conductor in a repo: install the agent skills into .claude/skills/
9
+ Interactive when run in a terminal; non-interactive otherwise.
10
+ --global Install into ~/.claude/skills/ instead of the current repo
11
+ --force Re-sync skills that are already installed (overwrite)
12
+ --yes, -y Skip prompts and accept defaults (install all skills)`;
13
+ const fs_1 = __importDefault(require("fs"));
14
+ const os_1 = __importDefault(require("os"));
15
+ const path_1 = __importDefault(require("path"));
16
+ const promises_1 = __importDefault(require("readline/promises"));
17
+ const output_js_1 = require("../output.js");
18
+ const pkg_root_js_1 = require("../pkg-root.js");
19
+ /** Skills conductor installs are namespaced with this prefix; prune is bounded to it. */
20
+ const SKILL_PREFIX = 'conductor-';
21
+ /** Records what conductor installed into a skills dir, so we can detect staleness and prune. */
22
+ const MANIFEST_FILE = '.conductor-skills.json';
23
+ /**
24
+ * The skill templates ship inside the published package under `skills/`
25
+ * (declared in package.json `files`), one directory per `conductor-<capability>`
26
+ * skill, each containing a SKILL.md. `findPkgRoot` resolves the package root for
27
+ * both the production build (dist/) and the test build (which has an extra `src/`
28
+ * path level), mirroring how the bundled drivers are located.
29
+ */
30
+ function bundledSkillsRoot() {
31
+ return path_1.default.join((0, pkg_root_js_1.findPkgRoot)(__dirname), 'skills');
32
+ }
33
+ function packageVersion() {
34
+ try {
35
+ const pkg = JSON.parse(fs_1.default.readFileSync(path_1.default.join((0, pkg_root_js_1.findPkgRoot)(__dirname), 'package.json'), 'utf-8'));
36
+ return typeof pkg.version === 'string' ? pkg.version : 'unknown';
37
+ }
38
+ catch {
39
+ return 'unknown';
40
+ }
41
+ }
42
+ /** Enumerate bundled skill directories (those containing a SKILL.md). */
43
+ function listBundledSkills(skillsRoot) {
44
+ if (!fs_1.default.existsSync(skillsRoot))
45
+ return [];
46
+ return fs_1.default
47
+ .readdirSync(skillsRoot)
48
+ .filter((name) => fs_1.default.existsSync(path_1.default.join(skillsRoot, name, 'SKILL.md')))
49
+ .sort();
50
+ }
51
+ function readManifest(destRoot) {
52
+ try {
53
+ const raw = JSON.parse(fs_1.default.readFileSync(path_1.default.join(destRoot, MANIFEST_FILE), 'utf-8'));
54
+ if (raw && Array.isArray(raw.skills)) {
55
+ return { version: String(raw.version ?? 'unknown'), skills: raw.skills.map(String) };
56
+ }
57
+ }
58
+ catch {
59
+ /* missing or corrupt → treat as no prior install */
60
+ }
61
+ return null;
62
+ }
63
+ function writeManifest(destRoot, manifest) {
64
+ fs_1.default.mkdirSync(destRoot, { recursive: true });
65
+ fs_1.default.writeFileSync(path_1.default.join(destRoot, MANIFEST_FILE), JSON.stringify(manifest, null, 2) + '\n');
66
+ }
67
+ function copySkill(srcDir, destDir) {
68
+ fs_1.default.mkdirSync(destDir, { recursive: true });
69
+ for (const entry of fs_1.default.readdirSync(srcDir)) {
70
+ const from = path_1.default.join(srcDir, entry);
71
+ if (!fs_1.default.statSync(from).isFile())
72
+ continue;
73
+ fs_1.default.copyFileSync(from, path_1.default.join(destDir, entry));
74
+ }
75
+ }
76
+ /**
77
+ * Remove skills conductor previously installed that are no longer bundled (renamed
78
+ * or dropped). Bounded to skills recorded in our manifest and the `conductor-`
79
+ * prefix, so it never touches user-authored or third-party skills.
80
+ */
81
+ function pruneOrphans(destRoot, prev, bundled) {
82
+ if (!prev)
83
+ return [];
84
+ const pruned = [];
85
+ for (const name of prev.skills) {
86
+ if (!name.startsWith(SKILL_PREFIX) || bundled.has(name))
87
+ continue;
88
+ const dir = path_1.default.join(destRoot, name);
89
+ if (fs_1.default.existsSync(dir)) {
90
+ fs_1.default.rmSync(dir, { recursive: true, force: true });
91
+ pruned.push(name);
92
+ }
93
+ }
94
+ return pruned;
95
+ }
96
+ function resolveDestRoot(global, targetDir) {
97
+ return global
98
+ ? path_1.default.join(os_1.default.homedir(), '.claude', 'skills')
99
+ : path_1.default.join(path_1.default.resolve(targetDir ?? process.cwd()), '.claude', 'skills');
100
+ }
101
+ /**
102
+ * Setting up conductor is the one manual, human-driven step — so when `init` runs
103
+ * in a real terminal we walk the dev through scope and skill selection, the way
104
+ * argent's wizard does. Headless/agent/CI runs (no TTY, --json, or --yes) take the
105
+ * non-interactive path with sensible defaults: all skills, project scope.
106
+ */
107
+ async function promptPlan(skills, version, targetDir, flags) {
108
+ const rl = promises_1.default.createInterface({ input: process.stdin, output: process.stdout });
109
+ try {
110
+ // Scope — skip the prompt if the flags already decided it.
111
+ let global = flags.global;
112
+ if (!flags.global && targetDir === undefined) {
113
+ const ans = (await rl.question('Where should the skills be installed?\n' +
114
+ ' 1) This project (./.claude/skills) [default]\n' +
115
+ ' 2) Globally (~/.claude/skills)\n' +
116
+ '> ')).trim();
117
+ global = ans === '2';
118
+ }
119
+ const destRoot = resolveDestRoot(global, targetDir);
120
+ // Skill selection.
121
+ let selected = skills;
122
+ const sel = (await rl.question(`\nInstall all ${skills.length} skills, or choose a subset?\n` +
123
+ ' 1) All [default]\n' +
124
+ ' 2) Select\n' +
125
+ '> ')).trim();
126
+ if (sel === '2') {
127
+ skills.forEach((name, i) => console.log(` ${i + 1}) ${name}`));
128
+ const picks = (await rl.question('Enter numbers (comma-separated): ')).trim();
129
+ const chosen = picks
130
+ .split(',')
131
+ .map((s) => parseInt(s.trim(), 10) - 1)
132
+ .filter((i) => i >= 0 && i < skills.length)
133
+ .map((i) => skills[i]);
134
+ if (chosen.length > 0)
135
+ selected = [...new Set(chosen)];
136
+ }
137
+ // Offer to re-sync already-installed skills. If they're from an older
138
+ // conductor, say so and default to yes; otherwise default to no.
139
+ let force = flags.force;
140
+ if (!force) {
141
+ const existing = selected.filter((name) => fs_1.default.existsSync(path_1.default.join(destRoot, name)));
142
+ if (existing.length > 0) {
143
+ const prev = readManifest(destRoot);
144
+ const stale = prev !== null && prev.version !== version;
145
+ const prompt = stale
146
+ ? `\n${existing.length} installed skill(s) are from conductor v${prev?.version} (this is v${version}). Re-sync (overwrite) them? [Y/n] `
147
+ : `\n${existing.length} of these are already installed. Re-sync (overwrite) them? [y/N] `;
148
+ const ans = (await rl.question(prompt)).trim().toLowerCase();
149
+ force = stale ? ans !== 'n' && ans !== 'no' : ans === 'y' || ans === 'yes';
150
+ }
151
+ }
152
+ return { destRoot, selected, force };
153
+ }
154
+ finally {
155
+ rl.close();
156
+ }
157
+ }
158
+ async function init(opts, targetDir, flags) {
159
+ try {
160
+ if (targetDir !== undefined && flags.global) {
161
+ (0, output_js_1.printError)('init: pass a target directory or --global, not both.', opts);
162
+ return 1;
163
+ }
164
+ const srcRoot = bundledSkillsRoot();
165
+ const skills = listBundledSkills(srcRoot);
166
+ if (skills.length === 0) {
167
+ (0, output_js_1.printError)(`No bundled skill templates found at ${srcRoot}`, opts);
168
+ return 1;
169
+ }
170
+ const version = packageVersion();
171
+ const bundledSet = new Set(skills);
172
+ const interactive = !opts.json && !flags.yes && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
173
+ const plan = interactive
174
+ ? await promptPlan(skills, version, targetDir, flags)
175
+ : {
176
+ destRoot: resolveDestRoot(flags.global, targetDir),
177
+ selected: skills,
178
+ force: flags.force,
179
+ };
180
+ const prev = readManifest(plan.destRoot);
181
+ const installed = [];
182
+ const skipped = [];
183
+ for (const name of plan.selected) {
184
+ const destDir = path_1.default.join(plan.destRoot, name);
185
+ if (fs_1.default.existsSync(destDir) && !plan.force) {
186
+ skipped.push(name);
187
+ continue;
188
+ }
189
+ copySkill(path_1.default.join(srcRoot, name), destDir);
190
+ installed.push(name);
191
+ }
192
+ // Remove skills we previously installed that are no longer bundled.
193
+ const pruned = pruneOrphans(plan.destRoot, prev, bundledSet);
194
+ // Update the manifest. Only claim the current version when we fully re-synced
195
+ // (force); otherwise existing skills may still be stale, so keep the old stamp.
196
+ const present = skills.filter((name) => fs_1.default.existsSync(path_1.default.join(plan.destRoot, name)));
197
+ if (present.length > 0) {
198
+ const stampVersion = !prev || plan.force ? version : prev.version;
199
+ writeManifest(plan.destRoot, { version: stampVersion, skills: present });
200
+ }
201
+ const stale = !plan.force && prev !== null && prev.version !== version && skipped.length > 0;
202
+ if (opts.json) {
203
+ (0, output_js_1.printData)({ status: 'ok', dir: plan.destRoot, version, installed, skipped, pruned, stale }, opts);
204
+ return 0;
205
+ }
206
+ // argent-style messaging.
207
+ if (installed.length > 0) {
208
+ console.log(`\nInstalling skills…`);
209
+ for (const name of installed)
210
+ console.log(` + ${name}`);
211
+ console.log(`Skills installed → ${plan.destRoot}`);
212
+ }
213
+ if (pruned.length > 0) {
214
+ console.log(`Pruned skills no longer shipped: ${pruned.join(', ')}`);
215
+ }
216
+ if (skipped.length > 0) {
217
+ console.log(`Already installed (skipped): ${skipped.join(', ')}. Re-run with --force to re-sync.`);
218
+ }
219
+ if (stale) {
220
+ console.log(`Note: skipped skills are from conductor v${prev?.version} (this is v${version}). Re-run \`conductor init --force\` to update them.`);
221
+ }
222
+ if (installed.length === 0 && pruned.length === 0 && skipped.length > 0) {
223
+ console.log('Nothing to do — all selected skills already installed.');
224
+ }
225
+ else {
226
+ console.log('Conductor is ready. Restart your agent / Claude Code session to pick up the skills.');
227
+ }
228
+ return 0;
229
+ }
230
+ catch (err) {
231
+ const message = err instanceof Error ? err.message : String(err);
232
+ (0, output_js_1.printError)(`init failed: ${message}`, opts);
233
+ // Manual fallback, in the spirit of argent's "install manually" note.
234
+ if (!opts.json) {
235
+ console.error('To install manually, copy the bundled skills into your skills directory:');
236
+ console.error(` cp -r "${bundledSkillsRoot()}"/* ./.claude/skills/`);
237
+ }
238
+ return 1;
239
+ }
240
+ }
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HELP = void 0;
4
+ exports.listOptions = listOptions;
5
+ exports.HELP = ` list-options [command|param] List valid values for enumerated parameters
6
+ (e.g. \`list-options press-key\`, \`list-options direction\`, or no arg for all)`;
7
+ const output_js_1 = require("../output.js");
8
+ const enum_options_js_1 = require("../enum-options.js");
9
+ function renderParam(p) {
10
+ const lines = [];
11
+ lines.push(`${p.command} ${p.param}`);
12
+ lines.push(` ${p.description}`);
13
+ for (const v of p.values) {
14
+ lines.push(v.description ? ` ${v.value} — ${v.description}` : ` ${v.value}`);
15
+ }
16
+ if (p.note)
17
+ lines.push(` note: ${p.note}`);
18
+ return lines.join('\n');
19
+ }
20
+ /**
21
+ * List valid values for enumerated parameters. With no query, lists every
22
+ * enumerated parameter. With a query, filters by command name, parameter name,
23
+ * or value.
24
+ */
25
+ function listOptions(query, opts = {}) {
26
+ const matches = (0, enum_options_js_1.findEnumParams)(query);
27
+ if (matches.length === 0) {
28
+ const available = (0, enum_options_js_1.commandsWithEnums)().join(', ');
29
+ (0, output_js_1.printError)(`No enumerated parameters match "${query}". Commands with options: ${available}`, opts);
30
+ return 1;
31
+ }
32
+ if (opts.json) {
33
+ (0, output_js_1.printData)(matches.map((p) => ({
34
+ command: p.command,
35
+ param: p.param,
36
+ description: p.description,
37
+ values: p.values.map((v) => v.value),
38
+ note: p.note ?? null,
39
+ })), opts);
40
+ return 0;
41
+ }
42
+ console.log(matches.map(renderParam).join('\n\n'));
43
+ return 0;
44
+ }
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.HELP = void 0;
3
+ exports.VALID_KEYS = exports.HELP = void 0;
4
4
  exports.pressKey = pressKey;
5
5
  exports.HELP = ` press-key <key> Press a key (Enter, Backspace, Home, ...)`;
6
6
  const runner_js_1 = require("../runner.js");
@@ -8,7 +8,7 @@ const output_js_1 = require("../output.js");
8
8
  const ios_js_1 = require("../drivers/ios.js");
9
9
  const android_js_1 = require("../drivers/android.js");
10
10
  const web_js_1 = require("../drivers/web.js");
11
- const VALID_KEYS = [
11
+ exports.VALID_KEYS = [
12
12
  'Enter',
13
13
  'Backspace',
14
14
  'Home',
@@ -107,12 +107,12 @@ const ANDROID_KEYCODE = {
107
107
  };
108
108
  async function pressKey(key, opts = {}, sessionName = 'default') {
109
109
  if (!key) {
110
- (0, output_js_1.printError)(`press-key requires <key>. Valid keys: ${VALID_KEYS.join(', ')}`, opts);
110
+ (0, output_js_1.printError)(`press-key requires <key>. Valid keys: ${exports.VALID_KEYS.join(', ')}`, opts);
111
111
  return 1;
112
112
  }
113
- const matched = VALID_KEYS.find((k) => k.toLowerCase() === key.toLowerCase());
113
+ const matched = exports.VALID_KEYS.find((k) => k.toLowerCase() === key.toLowerCase());
114
114
  if (!matched) {
115
- (0, output_js_1.printError)(`Unknown key "${key}". Valid keys: ${VALID_KEYS.join(', ')}`, opts);
115
+ (0, output_js_1.printError)(`Unknown key "${key}". Valid keys: ${exports.VALID_KEYS.join(', ')}`, opts);
116
116
  return 1;
117
117
  }
118
118
  const result = await (0, runner_js_1.runDirect)(async (driver) => {
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.HELP = void 0;
3
+ exports.PRESETS = exports.HELP = void 0;
4
4
  exports.setViewport = setViewport;
5
5
  exports.HELP = ` set-viewport [<width> <height>] Resize the web browser viewport (web only)
6
6
  --preset <mobile|tablet|desktop> Use a device preset instead of explicit width/height
@@ -12,7 +12,7 @@ exports.HELP = ` set-viewport [<width> <height>] Resize the web browser vi
12
12
  const runner_js_1 = require("../runner.js");
13
13
  const web_js_1 = require("../drivers/web.js");
14
14
  const output_js_1 = require("../output.js");
15
- const PRESETS = {
15
+ exports.PRESETS = {
16
16
  mobile: { width: 390, height: 844, deviceScaleFactor: 3, isMobile: true },
17
17
  tablet: { width: 820, height: 1180, deviceScaleFactor: 2, isMobile: true },
18
18
  desktop: { width: 1280, height: 800, deviceScaleFactor: 1, isMobile: false },
@@ -23,9 +23,9 @@ async function setViewport(flags, opts = {}, sessionName = 'default') {
23
23
  let isMobile = flags.mobile;
24
24
  let scale = flags.scale;
25
25
  if (flags.preset !== undefined) {
26
- const preset = PRESETS[flags.preset.toLowerCase()];
26
+ const preset = exports.PRESETS[flags.preset.toLowerCase()];
27
27
  if (!preset) {
28
- (0, output_js_1.printError)(`--preset must be one of: ${Object.keys(PRESETS).join(', ')}`, opts);
28
+ (0, output_js_1.printError)(`--preset must be one of: ${Object.keys(exports.PRESETS).join(', ')}`, opts);
29
29
  return 1;
30
30
  }
31
31
  width ?? (width = preset.width);
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.HELP = void 0;
7
+ exports.webTargets = webTargets;
8
+ /**
9
+ * List the CDP page targets exposed by an external browser (e.g. an Electron app
10
+ * launched with `--remote-debugging-port`). Each target is a controllable page —
11
+ * for the Lightning emulator, one per tile plus its control/remote chrome.
12
+ *
13
+ * Reads the DevTools HTTP endpoint (`/json/list`) directly, so it needs no
14
+ * Playwright browser and works before any daemon session exists. Use the printed
15
+ * target IDs with `--cdp-url` / `--cdp-target` to bind a session to a tile.
16
+ */
17
+ const http_1 = __importDefault(require("http"));
18
+ const output_js_1 = require("../output.js");
19
+ /** Derive the `http://host:port` base from a CDP URL (which may be ws:// or include a path). */
20
+ function httpBase(cdpUrl) {
21
+ const u = new URL(cdpUrl);
22
+ const proto = u.protocol === 'https:' || u.protocol === 'wss:' ? 'https:' : 'http:';
23
+ return `${proto}//${u.host}`;
24
+ }
25
+ function fetchTargets(cdpUrl) {
26
+ const url = `${httpBase(cdpUrl)}/json/list`;
27
+ return new Promise((resolve, reject) => {
28
+ const req = http_1.default.get(url, (res) => {
29
+ const chunks = [];
30
+ res.on('data', (c) => chunks.push(c));
31
+ res.on('end', () => {
32
+ if ((res.statusCode ?? 0) >= 300) {
33
+ reject(new Error(`HTTP ${res.statusCode} from ${url}`));
34
+ return;
35
+ }
36
+ try {
37
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8')));
38
+ }
39
+ catch (err) {
40
+ reject(err);
41
+ }
42
+ });
43
+ });
44
+ req.setTimeout(5000, () => req.destroy(new Error(`Timed out fetching ${url}`)));
45
+ req.on('error', reject);
46
+ });
47
+ }
48
+ async function webTargets(cdpUrl, opts) {
49
+ if (!cdpUrl) {
50
+ console.error('web-targets requires --cdp-url <url> (e.g. --cdp-url http://127.0.0.1:9222).\n' +
51
+ 'Launch the browser/Electron app with --remote-debugging-port to expose it.');
52
+ return 1;
53
+ }
54
+ let targets;
55
+ try {
56
+ targets = await fetchTargets(cdpUrl);
57
+ }
58
+ catch (err) {
59
+ console.error(`Could not reach CDP endpoint at ${cdpUrl}: ${err instanceof Error ? err.message : String(err)}`);
60
+ return 1;
61
+ }
62
+ // Only type="page" targets are controllable as Playwright Pages.
63
+ const pages = targets.filter((t) => t.type === 'page');
64
+ if (opts.json) {
65
+ (0, output_js_1.printData)(pages.map((t) => ({ id: t.id, title: t.title, url: t.url })), opts);
66
+ return 0;
67
+ }
68
+ if (pages.length === 0) {
69
+ console.log('No page targets found. Is the app loaded and started with --remote-debugging-port?');
70
+ return 0;
71
+ }
72
+ console.log(`Found ${pages.length} page target(s) at ${cdpUrl}:\n`);
73
+ pages.forEach((t, i) => {
74
+ console.log(` [${i}] ${t.title || '(untitled)'}`);
75
+ console.log(` url: ${t.url}`);
76
+ console.log(` target: ${t.id}`);
77
+ console.log(` bind: conductor --device web:chromium:t${i} --cdp-url ${cdpUrl} --cdp-target ${t.id} inspect`);
78
+ console.log('');
79
+ });
80
+ console.log('Bind a session to a target once (any command), then drop the --cdp-* flags on later\n' +
81
+ 'commands for that --device — the attachment is remembered per session.');
82
+ return 0;
83
+ }
84
+ exports.HELP = ' web-targets --cdp-url <url> List controllable CDP page targets (one per Electron webview/tile)';
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.isMetroPort = isMetroPort;
4
4
  exports.discoverMetroPortForDevice = discoverMetroPortForDevice;
5
5
  exports.getDeviceDisplayName = getDeviceDisplayName;
6
+ exports.deviceNameMatches = deviceNameMatches;
6
7
  exports.selectTargetForDevice = selectTargetForDevice;
7
8
  exports.targetsForDevice = targetsForDevice;
8
9
  /**
@@ -200,6 +201,16 @@ async function getDeviceDisplayName(platform, deviceId) {
200
201
  }
201
202
  return null;
202
203
  }
204
+ /**
205
+ * Whether a Metro target's `deviceName` refers to the same device as `displayName`.
206
+ * Tolerant of the suffixes Metro appends to the bare model name — e.g. Android's
207
+ * `ro.product.model` is `Chromecast` while Metro reports `Chromecast - 14 - API 34`.
208
+ */
209
+ function deviceNameMatches(targetDeviceName, displayName) {
210
+ if (!targetDeviceName)
211
+ return false;
212
+ return targetDeviceName === displayName || targetDeviceName.startsWith(`${displayName} `);
213
+ }
203
214
  /**
204
215
  * Filter Metro /json targets to those belonging to this device (by display
205
216
  * name), preferring the fusebox runtime if present. Returns undefined when
@@ -208,7 +219,7 @@ async function getDeviceDisplayName(platform, deviceId) {
208
219
  */
209
220
  function selectTargetForDevice(targets, displayName) {
210
221
  const withWs = targets.filter((t) => t.webSocketDebuggerUrl);
211
- const matches = withWs.filter((t) => t.deviceName === displayName);
222
+ const matches = withWs.filter((t) => deviceNameMatches(t.deviceName, displayName));
212
223
  if (matches.length === 0)
213
224
  return undefined;
214
225
  const fusebox = matches.find((t) => t.reactNative?.capabilities?.prefersFuseboxFrontend);
@@ -216,5 +227,5 @@ function selectTargetForDevice(targets, displayName) {
216
227
  }
217
228
  /** Convenience: all /json targets belonging to this device. */
218
229
  function targetsForDevice(targets, displayName) {
219
- return targets.filter((t) => t.webSocketDebuggerUrl && t.deviceName === displayName);
230
+ return targets.filter((t) => t.webSocketDebuggerUrl && deviceNameMatches(t.deviceName, displayName));
220
231
  }
@@ -40,12 +40,20 @@ function selectDebuggerUrl(targets, opts, displayName) {
40
40
  }
41
41
  return withWs[opts.targetIndex].webSocketDebuggerUrl;
42
42
  }
43
- if (displayName) {
44
- const target = (0, metro_discovery_js_1.selectTargetForDevice)(withWs, displayName);
43
+ // Device-scoped: must resolve to that device's own target. Never silently
44
+ // fall back to another device — that reloads the wrong app and reports success.
45
+ if (opts.deviceId) {
46
+ const target = displayName ? (0, metro_discovery_js_1.selectTargetForDevice)(withWs, displayName) : undefined;
45
47
  if (target)
46
48
  return target.webSocketDebuggerUrl;
49
+ const available = withWs
50
+ .map((t, i) => ` [${i}] ${t.deviceName ?? t.title ?? '(unnamed)'}`)
51
+ .join('\n');
52
+ throw new Error(`No Metro debugger target for device ${opts.deviceId}` +
53
+ (displayName ? ` (${displayName})` : '') +
54
+ `.\nAvailable targets:\n${available}\nPass --target <index> to pick one explicitly.`);
47
55
  }
48
- // Prefer the Hermes/React target by title, otherwise first.
56
+ // No device requested: prefer the Hermes/React target by title, otherwise first.
49
57
  const target = withWs.find((t) => t.title && /hermes|react/i.test(t.title)) ?? withWs[0];
50
58
  return target.webSocketDebuggerUrl;
51
59
  }
@@ -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,9 @@ 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");
64
+ const web_targets_js_1 = require("./commands/web-targets.js");
65
+ const session_js_2 = require("./session.js");
62
66
  const device_picker_js_1 = require("./device-picker.js");
63
67
  const update_check_js_1 = require("./update-check.js");
64
68
  const pkg_root_js_1 = require("./pkg-root.js");
@@ -101,6 +105,7 @@ const COMMAND_HELP = {
101
105
  'run-flow-inline': run_flow_inline_js_1.HELP,
102
106
  session: session_js_1.HELP,
103
107
  'install-web': install_js_1.HELP_INSTALL_WEB,
108
+ init: init_js_1.HELP,
104
109
  'daemon-start': daemon_js_1.HELP_DAEMON_START,
105
110
  'daemon-stop': daemon_js_1.HELP_DAEMON_STOP,
106
111
  'daemon-status': daemon_js_1.HELP_DAEMON_STATUS,
@@ -119,12 +124,18 @@ const COMMAND_HELP = {
119
124
  metro: metro_js_1.HELP,
120
125
  clipboard: clipboard_js_1.HELP,
121
126
  paste: ' paste Trigger OS-level paste (or type clipboard on iOS)',
127
+ 'list-options': options_js_1.HELP,
128
+ 'web-targets': web_targets_js_1.HELP,
122
129
  };
123
130
  const OPTIONS_HELP = `Options:
124
131
  --device <id> Target device ID (also keys the session and daemon)
125
132
  --device-name <n> Target a booted device by name (resolved to ID from booted devices)
126
133
  --platform <p> Filter to devices of this platform (ios, android, tvos, web)
134
+ --cdp-url <url> Attach the web driver to an existing browser over CDP (e.g. an
135
+ Electron app started with --remote-debugging-port). Remembered per session.
136
+ --cdp-target <id> Pick which CDP page target to control (see \`conductor web-targets\`)
127
137
  --json Output as machine-readable JSON
138
+ --options List valid values for a command's enumerated parameters and exit
128
139
  --verbose, -v Log daemon calls, fallbacks, and raw output
129
140
  --version, -V Print version number
130
141
  --help, -h Show this help`;
@@ -141,6 +152,7 @@ async function main() {
141
152
  boolean: [
142
153
  'json',
143
154
  'help',
155
+ 'options',
144
156
  'version',
145
157
  'clear',
146
158
  'list',
@@ -163,6 +175,9 @@ async function main() {
163
175
  'leaks',
164
176
  'snapshots',
165
177
  'growth-only',
178
+ 'global',
179
+ 'force',
180
+ 'yes',
166
181
  ],
167
182
  string: [
168
183
  'device',
@@ -221,8 +236,10 @@ async function main() {
221
236
  'height',
222
237
  'user-agent',
223
238
  'color-scheme',
239
+ 'cdp-url',
240
+ 'cdp-target',
224
241
  ],
225
- alias: { h: 'help', v: 'verbose', V: 'version', o: 'output' },
242
+ alias: { h: 'help', v: 'verbose', V: 'version', o: 'output', y: 'yes' },
226
243
  });
227
244
  if (argv['verbose'])
228
245
  (0, verbose_js_1.setVerbose)(true);
@@ -234,6 +251,12 @@ async function main() {
234
251
  console.log(pkg.version);
235
252
  process.exit(0);
236
253
  }
254
+ // `<command> --options` lists the valid values for that command's enumerated
255
+ // parameters and exits — no device resolution needed. With no command it
256
+ // lists every enumerated parameter. `--help` still wins if both are passed.
257
+ if (argv['options'] && !argv['help'] && command !== 'list-options') {
258
+ process.exit((0, options_js_1.listOptions)(command, opts));
259
+ }
237
260
  // Handle help and unknown commands before device resolution —
238
261
  // no point prompting for a device if we're just printing help or erroring out.
239
262
  if (!command || argv['help']) {
@@ -252,11 +275,14 @@ async function main() {
252
275
  'stop-device',
253
276
  'delete-device',
254
277
  'install-web',
278
+ 'init',
255
279
  'copy-app',
256
280
  'device-pool',
257
281
  'run-parallel',
258
282
  'metro',
259
283
  'workspace',
284
+ 'list-options',
285
+ 'web-targets',
260
286
  // `logs --list` and `logs --source metro` only query Metro on localhost — no device needed
261
287
  // `logs` always needs a device session — Metro discovery is device-scoped.
262
288
  // `daemon-stop --all` stops every daemon — no device needed
@@ -292,8 +318,38 @@ async function main() {
292
318
  explicitDevice ?? (await (0, device_picker_js_1.pickDevice)(argv['platform'])) ?? 'default';
293
319
  }
294
320
  }
321
+ // CDP attach settings (web only): --cdp-url/--cdp-target map to the env the daemon
322
+ // reads. Passing them once persists them to the session so later commands for the
323
+ // same --device don't need the flags; absent flags hydrate from the saved session.
324
+ const isWebSession = sessionName === 'web' || sessionName.startsWith('web:');
325
+ if (isWebSession && !NO_DEVICE_COMMANDS.has(command)) {
326
+ const cdpUrlFlag = argv['cdp-url'];
327
+ const cdpTargetFlag = argv['cdp-target'];
328
+ if (cdpUrlFlag || cdpTargetFlag) {
329
+ if (cdpUrlFlag)
330
+ process.env.CONDUCTOR_CDP_URL = cdpUrlFlag;
331
+ if (cdpTargetFlag)
332
+ process.env.CONDUCTOR_CDP_TARGET_ID = cdpTargetFlag;
333
+ await (0, session_js_2.updateSession)({
334
+ cdpUrl: process.env.CONDUCTOR_CDP_URL,
335
+ cdpTargetId: process.env.CONDUCTOR_CDP_TARGET_ID,
336
+ }, sessionName);
337
+ }
338
+ else {
339
+ const saved = await (0, session_js_2.getSession)(sessionName);
340
+ if (saved.cdpUrl && !process.env.CONDUCTOR_CDP_URL) {
341
+ process.env.CONDUCTOR_CDP_URL = saved.cdpUrl;
342
+ }
343
+ if (saved.cdpTargetId && !process.env.CONDUCTOR_CDP_TARGET_ID) {
344
+ process.env.CONDUCTOR_CDP_TARGET_ID = saved.cdpTargetId;
345
+ }
346
+ }
347
+ }
295
348
  let exitCode = 0;
296
349
  switch (command) {
350
+ case 'web-targets':
351
+ exitCode = await (0, web_targets_js_1.webTargets)(argv['cdp-url'], opts);
352
+ break;
297
353
  case 'start-device':
298
354
  exitCode = await (0, start_device_js_1.startDevice)(argv['platform'], opts, {
299
355
  osVersion: argv['os-version'],
@@ -418,6 +474,10 @@ async function main() {
418
474
  exitCode = await (0, press_key_js_1.pressKey)(key, opts, sessionName);
419
475
  break;
420
476
  }
477
+ case 'list-options': {
478
+ exitCode = (0, options_js_1.listOptions)(rest[0], opts);
479
+ break;
480
+ }
421
481
  case 'scroll': {
422
482
  const dir = (argv['direction'] || 'down').toLowerCase();
423
483
  exitCode = await (0, scroll_js_1.scroll)(dir, opts, sessionName);
@@ -610,6 +670,13 @@ async function main() {
610
670
  case 'session':
611
671
  exitCode = await (0, session_js_1.sessionCmd)(argv['clear'], argv['list'], opts, sessionName);
612
672
  break;
673
+ case 'init':
674
+ exitCode = await (0, init_js_1.init)(opts, rest[0], {
675
+ global: argv['global'],
676
+ force: argv['force'],
677
+ yes: argv['yes'],
678
+ });
679
+ break;
613
680
  case 'install-web':
614
681
  exitCode = await (0, install_js_1.installWebCli)(opts, argv['check'], rest[0]);
615
682
  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.20.0",
3
+ "version": "0.22.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",
@@ -0,0 +1,56 @@
1
+ ---
2
+ name: conductor-create-flow
3
+ description: Author and run Maestro-compatible YAML flows and command sequences with the conductor CLI, including recording flows from live interactions and sharding them across devices. Use when scripting a repeatable multi-step app journey, running an existing Maestro flow, recording a flow by interacting with the app, or running flows in parallel across booted devices.
4
+ ---
5
+
6
+ # Conductor — flows
7
+
8
+ A **flow** is a YAML file describing a sequence of conductor commands.
9
+ Conductor's format is a subset of [Maestro](https://maestro.mobile.dev)'s — most
10
+ existing Maestro flows run unchanged. Use flows for repeatable journeys; use
11
+ `conductor-device-interact` for one-off ad-hoc steps.
12
+
13
+ ## Run flows
14
+
15
+ | Command | Purpose |
16
+ |---|---|
17
+ | `conductor run-flow <file> [--env K=V] [--benchmark]` | Run a Maestro YAML flow file |
18
+ | `conductor run-flow-inline '<yaml>' [--benchmark]` | Run inline YAML from the command line |
19
+ | `conductor run-sequence [--file path.json]` | Run a JSON sequence of conductor commands serially; reads stdin if no `--file` |
20
+ | `conductor run-parallel --flows-dir <path>` | Shard a directory of flows across all booted devices |
21
+
22
+ `--benchmark` prints elapsed time per command and total flow time.
23
+
24
+ ## Flow YAML
25
+
26
+ ```yaml
27
+ appId: com.example.myapp
28
+ ---
29
+ - launchApp
30
+ - tapOn: "Sign In"
31
+ - inputText: "user@example.com"
32
+ - assertVisible: "Dashboard"
33
+ ```
34
+
35
+ `run-sequence` JSON shape (stops on first non-zero exit):
36
+
37
+ ```json
38
+ { "steps": [ { "cmd": "tap-on", "args": ["Login"] }, { "cmd": "input-text", "args": ["user@example.com"] } ] }
39
+ ```
40
+
41
+ ## Record a flow from your interactions
42
+
43
+ | Command | Purpose |
44
+ |---|---|
45
+ | `conductor flow record start [--out path]` | Start recording this session's interactions to a YAML file |
46
+ | `conductor flow record echo <text>` | Insert a `console.log` step |
47
+ | `conductor flow record status` | Show the active recording path |
48
+ | `conductor flow record finish` | Close the recording, print the file path |
49
+
50
+ Record, then interact via `conductor-device-interact`; each action is appended
51
+ to the flow. `finish` gives you a runnable `.yaml`.
52
+
53
+ ## Tips
54
+
55
+ - Add `--json` for machine-readable output; a failed step exits non-zero.
56
+ - `conductor run-flow --help` (and friends) for exact flags.
@@ -0,0 +1,96 @@
1
+ ---
2
+ name: conductor-device-interact
3
+ description: Drive a running iOS simulator, Android emulator, tvOS simulator, or Playwright web app with the conductor CLI. Use when launching apps, tapping UI elements, typing text, scrolling/swiping, performing gestures, pressing hardware/keyboard keys, opening URLs or deep links, navigating back, or verifying an app change in the real running app.
4
+ ---
5
+
6
+ # Conductor — device interaction
7
+
8
+ `conductor` drives a real running app the way a user would: launch it, tap,
9
+ type, scroll, and assert. It bundles its own native drivers — no second CLI to
10
+ install. Use it to verify a change in the actual app, not just in tests.
11
+
12
+ To **observe** the screen (inspect the hierarchy, screenshot, read element
13
+ state), use the `conductor-inspect` skill — it pairs with this one.
14
+
15
+ ## The core loop: act → observe → act
16
+
17
+ Never tap blind, never assume the result. After every action, observe before
18
+ the next one.
19
+
20
+ 1. **Observe** with `conductor capture-ui` (see `conductor-inspect`) to see the
21
+ screen and get short element refs (`@e1`, `@e2`, …).
22
+ 2. **Act** — `tap-on`, `input-text`, `scroll`, etc.
23
+ 3. **Confirm** with `assert-visible` / another `capture-ui`.
24
+
25
+ ```bash
26
+ conductor launch-app com.example.myapp
27
+ conductor capture-ui # observe; get @eN refs
28
+ conductor tap-on "Sign In" # or: conductor tap-on @e3
29
+ conductor input-text "user@example.com"
30
+ conductor assert-visible "Dashboard"
31
+ ```
32
+
33
+ ## Interaction commands
34
+
35
+ | Command | Purpose |
36
+ |---|---|
37
+ | `conductor launch-app <appId>` | Launch app (saved to session). `--no-stop-app` resumes; `--argument key=value` passes launch args |
38
+ | `conductor stop-app [<appId>]` | Stop the app |
39
+ | `conductor tap-on <element>` | Tap by text, id, or `@eN`. `--long-press`, `--double-tap`, `--optional`, `--index <n>` |
40
+ | `conductor input-text <text>` | Type into the focused field |
41
+ | `conductor erase-text [n]` | Erase n characters (default 50) |
42
+ | `conductor press-key <key>` | Press a key (Enter, Backspace, Home, …) |
43
+ | `conductor hide-keyboard` | Dismiss the on-screen keyboard |
44
+ | `conductor back` | Press back |
45
+ | `conductor scroll [--direction down\|up\|left\|right]` | Scroll |
46
+ | `conductor scroll-until-visible <element> [--direction] [--timeout ms]` | Scroll until element appears |
47
+ | `conductor swipe --direction <dir>` / `--start <x,y> --end <x,y> [--duration ms]` | Swipe |
48
+ | `conductor open-link <url>` | Open a URL / deep link |
49
+ | `conductor pinch [--scale N] [--center x,y]` | Two-finger pinch (scale<1 out, >1 in) |
50
+ | `conductor rotate-gesture [--degrees N] [--center x,y]` | Two-finger rotate |
51
+ | `conductor gesture <json\|--file path>` | Play a multi-touch path |
52
+ | `conductor clipboard read` / `clipboard write <text>` / `paste` | Clipboard (iOS) |
53
+ | `conductor list-options [command]` | List valid values for enumerated params |
54
+
55
+ ## Discovering valid values
56
+
57
+ Several commands only accept a fixed set of values (`press-key <key>`,
58
+ `--direction`, `set-orientation`, `set-viewport --preset`/`--color-scheme`,
59
+ `logs --level`/`--source`, `--platform`). Don't guess — list them:
60
+
61
+ - `conductor <command> --options` — valid values for that command, e.g.
62
+ `conductor press-key --options`, `conductor swipe --options`.
63
+ - `conductor list-options [command|param]` — same data; with no argument it
64
+ lists every enumerated parameter, or filter by name (`list-options direction`).
65
+ - Add `--json` for machine-readable output.
66
+
67
+ ## Selecting elements
68
+
69
+ Positional `<element>` matches **accessibility id first, then visible text**.
70
+ Disambiguate when multiple match:
71
+
72
+ - `--id <id>` / `--text <text>` — id-only / text-only matching
73
+ - `--index <n>` — nth match (0-based)
74
+ - `--below` / `--above` / `--left-of` / `--right-of <text>` — relative position
75
+ - `--focused`, `--enabled`, `--checked`, `--selected` — state filters
76
+ - `--timeout <ms>` — wait for the element to appear
77
+ - `--optional` — missing element is a no-op, not an error
78
+ - `@eN` — exact element from the **last `capture-ui`** (cached coords, ephemeral
79
+ ~60s; re-capture after navigating)
80
+
81
+ If you can't find an element, run `conductor inspect` or `capture-ui` to see the
82
+ real ids and texts rather than guessing.
83
+
84
+ ## ⚠️ Don't reset state to "fix" navigation
85
+
86
+ Never use `launch-app --clear-state`, `clear-state`, or `--clear-keychain` to
87
+ clear focus or navigation — they **wipe user data and sign the user out**, and
88
+ can't be undone without their credentials. Navigate out with `back` / Menu, or
89
+ relaunch without the flag. (See `conductor-device-setup`.)
90
+
91
+ ## Tips
92
+
93
+ - `--device <id>` / `--device-name <name>` targets a device; `--platform <ios|android|tvos|web>` scopes by platform.
94
+ - Add `--json` for machine-readable output; failed assertions exit non-zero.
95
+ - Run a per-session daemon for many commands (see `conductor-device-setup`).
96
+ - `conductor <command> --help` for exact flags.
@@ -0,0 +1,90 @@
1
+ ---
2
+ name: conductor-device-setup
3
+ description: Boot, list, and manage devices and app installs for the conductor CLI — iOS simulators, Android emulators, tvOS simulators, and Playwright web browsers — plus sessions, the warm-driver daemon, and the parallel device pool. Use when starting or stopping a simulator/emulator/browser, installing or launching an app, setting up the web driver, attaching to an already-running browser over CDP (e.g. an Electron app / its webviews), keeping the driver warm, or coordinating multiple devices for parallel agents.
4
+ ---
5
+
6
+ # Conductor — device & app setup
7
+
8
+ Get a device running and an app installed before you drive it. Start here when
9
+ nothing is booted yet.
10
+
11
+ ## Orient first
12
+
13
+ ```bash
14
+ conductor workspace info # detected project type, bundle IDs, devices, Metro port — best first call
15
+ conductor list-devices # booted + available devices
16
+ conductor foreground-app # bundle id of the app currently in front
17
+ conductor list-apps # installed app ids / package names
18
+ ```
19
+
20
+ ## Devices
21
+
22
+ | Command | Purpose |
23
+ | --------------------------------------------------------------------- | -------------------------------------------------------------------------- |
24
+ | `conductor start-device --platform <ios\|android\|tvos\|web>` | Boot a simulator/emulator or start the web driver |
25
+ | `conductor start-device --os-version <n> --device-type <name>` | Pick OS version + device type (creates if needed) |
26
+ | `conductor stop-device [<name-or-id>] [--all]` | Shut down device(s) |
27
+ | `conductor delete-device <name-or-id> [--all]` | Delete simulator(s)/AVD(s)/web session(s) |
28
+ | `conductor set-location --lat <n> --lng <n>` | Set GPS coordinates |
29
+ | `conductor set-orientation <portrait\|landscape>` | Set orientation |
30
+ | `conductor set-viewport [<w> <h>] [--preset mobile\|tablet\|desktop]` | Resize web viewport (web only) |
31
+ | `conductor install-web [--check] [browser]` | Install a Playwright browser (chromium/firefox/webkit); `--check` = status |
32
+
33
+ ### Attach to an existing browser (CDP)
34
+
35
+ Instead of launching its own browser, the web driver can attach to one that's
36
+ already running and exposes CDP over a remote-debugging port — e.g. an Electron
37
+ app started with `--remote-debugging-port`, where each window / webview is a
38
+ separate page target you can drive independently.
39
+
40
+ | Command | Purpose |
41
+ | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
42
+ | `conductor web-targets --cdp-url <url>` | List the controllable page targets the browser exposes (id, url, title) + a paste-ready bind command for each |
43
+ | `conductor --device web:<browser>:<label> --cdp-url <url> --cdp-target <id> <cmd>` | Bind a session to one target; the attachment persists so later commands for that `--device` don't need the flags |
44
+
45
+ Use a distinct fully-qualified `--device web:chromium:<label>` per target (a bare
46
+ `web` gets an auto-generated sub-id instead). Each target is its own session, so
47
+ several webviews can be driven concurrently. Only `type=page` targets are
48
+ controllable. See [Web testing → Attaching to an existing browser](../../../docs/web.md).
49
+
50
+ ## App lifecycle
51
+
52
+ | Command | Purpose |
53
+ | ----------------------------------------------------- | ---------------------------------------------------------------------- |
54
+ | `conductor install-app <path>` | Install .app / .ipa / .apk |
55
+ | `conductor launch-app <appId>` | Launch app (saved to session); `--no-stop-app`, `--argument key=value` |
56
+ | `conductor stop-app [<appId>]` | Stop app |
57
+ | `conductor uninstall-app <appId>` | Uninstall app |
58
+ | `conductor copy-app <bundleId> --from <id> --to <id>` | Copy an installed app between iOS simulators |
59
+ | `conductor download-app <appId> --output <path>` | Download installed app binary |
60
+
61
+ ### ⚠️ Destructive flags — ask the user first
62
+
63
+ `conductor clear-state [<appId>]`, `launch-app --clear-state`, and
64
+ `launch-app --clear-keychain` **wipe app data and sign the user out**, and can't
65
+ be undone without their credentials. Never use them to "reset focus" or clear
66
+ navigation — relaunch without the flag, or navigate out with `back` / Menu. If
67
+ you genuinely need one, ask the human first.
68
+
69
+ ## Sessions, daemon & device pool
70
+
71
+ A **session** remembers the last device + app so you don't re-specify them.
72
+ Parallel agents each get their own `--session <name>` so they don't collide.
73
+
74
+ | Command | Purpose |
75
+ | -------------------------------------- | ---------------------------------------------------------------------------------------------------- |
76
+ | `conductor session [--clear] [--list]` | Show, clear, or list sessions |
77
+ | `conductor daemon-start` | Start the per-session background daemon (keeps the driver warm — do this for any multi-step session) |
78
+ | `conductor daemon-status` | Show daemon status |
79
+ | `conductor daemon-stop [--all]` | Stop this session's daemon (`--all` = every session) |
80
+ | `conductor device-pool --list` | List devices + pool status |
81
+ | `conductor device-pool --acquire` | Claim a free device (prints id) |
82
+ | `conductor device-pool --release <id>` | Release a device back to the pool |
83
+
84
+ Don't leave a daemon running when you're done — `daemon-stop` it.
85
+
86
+ ## Tips
87
+
88
+ - `--device <id>` / `--device-name <name>` targets a device; `--platform` scopes by platform.
89
+ - Add `--json` for machine-readable output.
90
+ - `conductor <command> --help` for exact flags.
@@ -0,0 +1,48 @@
1
+ ---
2
+ name: conductor-inspect
3
+ description: Read the live UI state of a running app with the conductor CLI — view hierarchy, accessibility snapshot, screenshots, focused element, and element refs. Use when you need to see what's on screen, find an element's id/text/coordinates, take a screenshot, check focus, or assert that something is (or isn't) visible before or after acting.
4
+ ---
5
+
6
+ # Conductor — inspection & assertions
7
+
8
+ These commands let you **observe** a running app's screen so you know what to do
9
+ next. Pair them with `conductor-device-interact`, which acts on what you find
10
+ here. Always observe before you act, and confirm after.
11
+
12
+ ## Observe the screen
13
+
14
+ | Command | Purpose |
15
+ |---|---|
16
+ | `conductor capture-ui [--output <path.json>]` | Screenshot + hierarchy + a11y snapshot in one JSON bundle; assigns short `@eN` refs. **Preferred way to observe.** |
17
+ | `conductor inspect [--dump]` | Print the UI hierarchy (`--dump` = raw driver output) |
18
+ | `conductor inspect --at <x,y> [--tappable]` | Topmost view at a screen point |
19
+ | `conductor focused [--poll [ms]]` | Metadata of the focused element. `--poll` watches changes — only with a bounded use, then stop it |
20
+ | `conductor take-screenshot [<element>] [--output <path>] [--full-page]` | Screenshot; crop to a matched element; `--full-page` (web) |
21
+
22
+ `capture-ui` is the workhorse: it returns the screen as structured data **and**
23
+ gives each element a ref like `@e3` that `conductor tap-on @e3` taps by cached
24
+ coordinates. Refs are ephemeral (~60s) — re-capture after navigating or waiting.
25
+
26
+ ```bash
27
+ conductor capture-ui --output /tmp/screen.json
28
+ # read it: element texts, ids, frames, and @eN refs
29
+ conductor tap-on @e5
30
+ ```
31
+
32
+ ## Assertions
33
+
34
+ | Command | Purpose |
35
+ |---|---|
36
+ | `conductor assert-visible <element> [--timeout ms]` | Assert element is visible (non-zero exit on failure) |
37
+ | `conductor assert-not-visible <element> [--timeout ms]` | Assert element is absent |
38
+
39
+ Both take the same selectors as `tap-on`: `--id`, `--text`, `--index`,
40
+ `--below` / `--above` / `--left-of` / `--right-of`, `--focused`, `--enabled`,
41
+ `--checked`, `--selected`, `--optional`.
42
+
43
+ ## Tips
44
+
45
+ - Add `--json` to parse output programmatically (pipe through `jq`).
46
+ - When an interaction can't find an element, `inspect` / `capture-ui` shows the
47
+ real ids and texts on screen — don't guess selectors.
48
+ - `conductor <command> --help` for exact flags.
@@ -0,0 +1,51 @@
1
+ ---
2
+ name: conductor-metro-debugger
3
+ description: Inspect a running app's JS runtime, logs, and network with the conductor CLI — evaluate JS in React Native (Hermes) or the web page, dump the React component tree, read console/Metro/device logs, and inspect or issue HTTP requests. Use when debugging app behavior, reading logs, evaluating expressions in the live runtime, inspecting React components, or examining network traffic.
4
+ ---
5
+
6
+ # Conductor — runtime debugging, logs & network
7
+
8
+ Inspect what a running app is doing under the UI — its JS runtime, console
9
+ output, and HTTP traffic. Works against React Native (Hermes/Fusebox) and
10
+ Playwright web.
11
+
12
+ ## Runtime (React Native / web)
13
+
14
+ | Command | Purpose |
15
+ |---|---|
16
+ | `conductor debug status [--port N]` | RN debugger connection info |
17
+ | `conductor debug evaluate <expr> [--port N]` | Run JS in the app runtime (Hermes or web page) |
18
+ | `conductor debug component-tree [--port N]` | On-screen React component tree |
19
+ | `conductor debug inspect-element <x,y>` | React component at a screen point |
20
+ | `conductor debug log-registry [--source metro]` | Summarize recent Metro/Hermes console logs |
21
+
22
+ ## Logs
23
+
24
+ | Command | Purpose |
25
+ |---|---|
26
+ | `conductor logs --recent <n>` | Last N buffered log lines — **agent-friendly, exits immediately** |
27
+ | `conductor logs [--source metro\|device] [--level …] [--json] [--duration s]` | Stream logs (bound it with `--duration`) |
28
+ | `conductor logs --list` | List Metro debugger targets for this device |
29
+
30
+ Prefer `logs --recent N` over a bare `logs` stream — don't leave a stream
31
+ running indefinitely.
32
+
33
+ ## Network
34
+
35
+ | Command | Purpose |
36
+ |---|---|
37
+ | `conductor network logs [--limit N]` | Recent HTTP traffic (RN fetch/XHR shim; web via Playwright) |
38
+ | `conductor network request <url> [--method M] [--body STR] [--header K=V]` | Issue an HTTP request from the app's context |
39
+
40
+ ## Metro bundler
41
+
42
+ | Command | Purpose |
43
+ |---|---|
44
+ | `conductor metro reload [--port N] [--target N]` | Reload the JS bundle without restarting native |
45
+ | `conductor metro stop [--port N]` | Stop the Metro bundler on a port (default 8081) |
46
+
47
+ ## Tips
48
+
49
+ - `--port N` targets a specific Metro/debugger port when auto-detection isn't enough.
50
+ - Add `--json` for machine-readable output.
51
+ - For crashes and performance, see `conductor-profiler`.
@@ -0,0 +1,42 @@
1
+ ---
2
+ name: conductor-profiler
3
+ description: Profile a running app's CPU, memory, and React render performance with the conductor CLI, and read crash reports. Use when investigating slowness, jank, memory growth or leaks, excessive React re-renders, or when an app has crashed and you need the crash report.
4
+ ---
5
+
6
+ # Conductor — profiling & crashes
7
+
8
+ Measure a running app's performance and inspect crashes.
9
+
10
+ ## Profiling
11
+
12
+ | Command | Purpose |
13
+ |---|---|
14
+ | `conductor profile cpu --duration <s> [--out <path>]` | Record a CPU trace (iOS: xctrace, Android: simpleperf) |
15
+ | `conductor profile memory --track <s> [--interval ms] [<appId>]` | Sample memory for N seconds, report deltas |
16
+ | `conductor profile react start` / `profile react stop [--top N]` | Install a React commit-profiler hook, then summarize captured commits |
17
+
18
+ ## Memory
19
+
20
+ | Command | Purpose |
21
+ |---|---|
22
+ | `conductor memory [<appId>]` | Device + app memory usage |
23
+ | `conductor memory --objects` | Include per-class object counts (iOS heap; slower) |
24
+ | `conductor memory --leaks` | Run leak detection (iOS only; slow, can pause the app) |
25
+ | `conductor memory --save <name>` / `--diff <name>` / `--diff <name> --vs <other>` | Snapshot and diff memory reports |
26
+ | `conductor memory --filter <regex>` / `--growth-only` / `--top <n>` | Narrow object/class tables (great for leak-hunting) |
27
+
28
+ Typical leak hunt: `memory --save before`, exercise the screen, then
29
+ `memory --diff before --growth-only`.
30
+
31
+ ## Crashes
32
+
33
+ | Command | Purpose |
34
+ |---|---|
35
+ | `conductor crashes list [--app <bundleId>] [--since <duration>]` | List recent crash reports (iOS host + Android logcat) |
36
+ | `conductor crashes show <id>` | Print a specific crash report |
37
+ | `conductor crashes tail` | Stream new crash reports as they appear |
38
+
39
+ ## Tips
40
+
41
+ - Add `--json` to parse reports programmatically.
42
+ - These commands can be slow or pause the app — scope them with `--duration` / `--track` and avoid leaving `crashes tail` running.