@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 +13 -1
- package/dist/commands/init.js +240 -0
- package/dist/commands/options.js +44 -0
- package/dist/commands/press-key.js +5 -5
- package/dist/commands/set-viewport.js +4 -4
- package/dist/commands/web-targets.js +84 -0
- package/dist/drivers/log-sources/metro-discovery.js +13 -2
- package/dist/drivers/metro-cdp.js +11 -3
- package/dist/enum-options.js +130 -0
- package/dist/index.js +68 -1
- package/dist/utils.js +2 -0
- package/package.json +3 -2
- package/skills/conductor-create-flow/SKILL.md +56 -0
- package/skills/conductor-device-interact/SKILL.md +96 -0
- package/skills/conductor-device-setup/SKILL.md +90 -0
- package/skills/conductor-inspect/SKILL.md +48 -0
- package/skills/conductor-metro-debugger/SKILL.md +51 -0
- package/skills/conductor-profiler/SKILL.md +42 -0
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
//
|
|
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.
|
|
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.
|