@houwert/conductor 0.19.1 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -1
- package/dist/commands/capture-ui.js +14 -1
- package/dist/commands/debug.js +33 -1
- package/dist/commands/init.js +240 -0
- package/dist/commands/logs.js +17 -2
- package/dist/commands/network.js +67 -2
- package/dist/commands/options.js +44 -0
- package/dist/commands/press-key.js +12 -5
- package/dist/commands/set-viewport.js +4 -4
- package/dist/daemon/web-server.js +219 -0
- package/dist/drivers/element-resolver.js +16 -7
- package/dist/drivers/web.js +26 -0
- package/dist/enum-options.js +130 -0
- package/dist/index.js +29 -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 +73 -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
|
|
|
@@ -5,7 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.HELP = void 0;
|
|
7
7
|
exports.captureUI = captureUI;
|
|
8
|
-
exports.HELP = ` capture-ui [--output <path>]
|
|
8
|
+
exports.HELP = ` capture-ui [--output <path.json>] Capture screenshot + hierarchy + a11y snapshot as a JSON bundle (for Argus UI panel)`;
|
|
9
9
|
const promises_1 = __importDefault(require("fs/promises"));
|
|
10
10
|
const path_1 = __importDefault(require("path"));
|
|
11
11
|
const runner_js_1 = require("../runner.js");
|
|
@@ -17,6 +17,19 @@ const a11y_js_1 = require("../drivers/a11y.js");
|
|
|
17
17
|
const snapshot_store_js_1 = require("../snapshot-store.js");
|
|
18
18
|
async function captureUI(outputPath, opts = {}, sessionName = 'default') {
|
|
19
19
|
try {
|
|
20
|
+
// capture-ui always emits a JSON bundle (screenshot is embedded as base64).
|
|
21
|
+
// Reject non-JSON output paths up front so a `.png`/`.jpg` mistake doesn't
|
|
22
|
+
// silently produce an image-named file full of JSON. Use take-screenshot
|
|
23
|
+
// for an actual image.
|
|
24
|
+
if (outputPath) {
|
|
25
|
+
const ext = path_1.default.extname(outputPath).toLowerCase();
|
|
26
|
+
if (ext && ext !== '.json') {
|
|
27
|
+
(0, output_js_1.printError)(`capture-ui — \`--output\` must be a .json path (got "${ext}"). ` +
|
|
28
|
+
`capture-ui writes a JSON bundle (screenshot + hierarchy + a11y snapshot), not an image. ` +
|
|
29
|
+
`Use \`take-screenshot\` to save an image file.`, opts);
|
|
30
|
+
return 1;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
20
33
|
const driver = await (0, runner_js_1.getDriver)(sessionName);
|
|
21
34
|
const capturedAt = new Date().toISOString();
|
|
22
35
|
let platform;
|
package/dist/commands/debug.js
CHANGED
|
@@ -11,7 +11,7 @@ exports.debugInspectElement = debugInspectElement;
|
|
|
11
11
|
exports.debugLogRegistry = debugLogRegistry;
|
|
12
12
|
exports.debugReload = debugReload;
|
|
13
13
|
exports.HELP = ` debug status [--port N] Show RN debugger connection info
|
|
14
|
-
debug evaluate <expr> [--port N] Run JS in the app runtime (Hermes/Fusebox)
|
|
14
|
+
debug evaluate <expr> [--port N] Run JS in the app runtime (RN: Hermes/Fusebox; web: page context)
|
|
15
15
|
debug component-tree [--port N] Print the React component tree (on-screen)
|
|
16
16
|
debug inspect-element <x,y> Print the React component at a screen point
|
|
17
17
|
debug log-registry [--source metro] Summarize recent Metro/Hermes console logs`;
|
|
@@ -22,6 +22,18 @@ const bootstrap_js_1 = require("../drivers/bootstrap.js");
|
|
|
22
22
|
const metro_js_1 = require("../drivers/log-sources/metro.js");
|
|
23
23
|
const metro_scripts_js_1 = require("../drivers/metro-scripts.js");
|
|
24
24
|
const logs_js_1 = require("./logs.js");
|
|
25
|
+
const runner_js_1 = require("../runner.js");
|
|
26
|
+
const web_js_1 = require("../drivers/web.js");
|
|
27
|
+
/** Resolve the web driver when this session targets a web device, else null (→ Metro path). */
|
|
28
|
+
async function webDriverFor(sessionName) {
|
|
29
|
+
if (!sessionName || sessionName === 'default')
|
|
30
|
+
return null;
|
|
31
|
+
const platform = await (0, bootstrap_js_1.detectPlatform)(sessionName).catch(() => undefined);
|
|
32
|
+
if (platform !== 'web')
|
|
33
|
+
return null;
|
|
34
|
+
const driver = await (0, runner_js_1.getDriver)(sessionName);
|
|
35
|
+
return driver instanceof web_js_1.WebDriver ? driver : null;
|
|
36
|
+
}
|
|
25
37
|
function newRequestId() {
|
|
26
38
|
return crypto_1.default.randomBytes(6).toString('hex');
|
|
27
39
|
}
|
|
@@ -88,6 +100,26 @@ async function debugEvaluate(expr, opts, sessionName, debugOpts) {
|
|
|
88
100
|
(0, output_js_1.printError)('debug evaluate requires a JS expression', opts);
|
|
89
101
|
return 1;
|
|
90
102
|
}
|
|
103
|
+
// Web: evaluate in the page runtime via Playwright (no Metro/Hermes).
|
|
104
|
+
const web = await webDriverFor(sessionName);
|
|
105
|
+
if (web) {
|
|
106
|
+
try {
|
|
107
|
+
const { result, error } = await web.evaluate(expr);
|
|
108
|
+
if (error) {
|
|
109
|
+
(0, output_js_1.printError)(`debug evaluate — ${error}`, opts);
|
|
110
|
+
return 1;
|
|
111
|
+
}
|
|
112
|
+
if (opts.json)
|
|
113
|
+
(0, output_js_1.printData)({ result }, opts);
|
|
114
|
+
else
|
|
115
|
+
console.log(typeof result === 'string' ? result : JSON.stringify(result, null, 2));
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
(0, output_js_1.printError)(`debug evaluate — ${err instanceof Error ? err.message : String(err)}`, opts);
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
91
123
|
const port = debugOpts.port ?? 8081;
|
|
92
124
|
const { deviceId, platformPromise } = resolveSession(sessionName);
|
|
93
125
|
try {
|
|
@@ -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
|
+
}
|
package/dist/commands/logs.js
CHANGED
|
@@ -42,6 +42,21 @@ function formatEntry(entry, opts) {
|
|
|
42
42
|
}
|
|
43
43
|
return line;
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Ensure both the device driver and its daemon are up before reading logs.
|
|
47
|
+
*
|
|
48
|
+
* getDriver() starts the daemon when the driver *port* is closed, but skips it
|
|
49
|
+
* when the port is already open — which happens after the daemon idle-times-out
|
|
50
|
+
* while leaving the driver alive (e.g. tvOS deliberately keeps its runner up
|
|
51
|
+
* across daemon restarts). The log collector lives inside the daemon, so a live
|
|
52
|
+
* driver port is not enough: we must guarantee the daemon socket itself is up,
|
|
53
|
+
* otherwise the log source connects to a dead socket. startDaemon() is
|
|
54
|
+
* idempotent — it returns immediately when the daemon already answers /status.
|
|
55
|
+
*/
|
|
56
|
+
async function ensureLogDaemon(sessionName) {
|
|
57
|
+
await (0, runner_js_1.getDriver)(sessionName);
|
|
58
|
+
await (0, client_js_1.startDaemon)(sessionName);
|
|
59
|
+
}
|
|
45
60
|
async function resolvePlatformAndDevice(sessionName) {
|
|
46
61
|
try {
|
|
47
62
|
const driver = await (0, runner_js_1.getDriver)(sessionName);
|
|
@@ -130,7 +145,7 @@ async function logs(opts = {}, sessionName = 'default', { source, level, list, r
|
|
|
130
145
|
try {
|
|
131
146
|
// ── Snapshot mode (--recent N) ──────────────────────────────────────────
|
|
132
147
|
if (recent !== undefined) {
|
|
133
|
-
await (
|
|
148
|
+
await ensureLogDaemon(sessionName);
|
|
134
149
|
const minSeverity = level ? (types_js_1.LEVEL_SEVERITY[level] ?? 0) : 0;
|
|
135
150
|
const entries = await (0, client_js_1.fetchDaemonLogs)(sessionName, { limit: recent, level });
|
|
136
151
|
for (const entry of entries) {
|
|
@@ -145,7 +160,7 @@ async function logs(opts = {}, sessionName = 'default', { source, level, list, r
|
|
|
145
160
|
}
|
|
146
161
|
// ── Streaming modes ─────────────────────────────────────────────────────
|
|
147
162
|
// Ensure daemon is running; its log collector auto-discovers Metro.
|
|
148
|
-
await (
|
|
163
|
+
await ensureLogDaemon(sessionName);
|
|
149
164
|
const minSeverity = level ? (types_js_1.LEVEL_SEVERITY[level] ?? 0) : 0;
|
|
150
165
|
const logSource = new daemon_js_1.DaemonLogSource(sessionName);
|
|
151
166
|
await logSource.connect();
|
package/dist/commands/network.js
CHANGED
|
@@ -3,12 +3,30 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.HELP = void 0;
|
|
4
4
|
exports.networkLogs = networkLogs;
|
|
5
5
|
exports.networkRequest = networkRequest;
|
|
6
|
-
exports.HELP = ` network logs [--port N] [--limit N] Read recent HTTP traffic (
|
|
6
|
+
exports.HELP = ` network logs [--port N] [--limit N] Read recent HTTP traffic (RN: fetch/XHR shim; web: all traffic via Playwright)
|
|
7
7
|
network request <url> [--method M] [--body STR] [--header K=V] [--port N]
|
|
8
8
|
Issue an HTTP request from the app's context`;
|
|
9
9
|
const output_js_1 = require("../output.js");
|
|
10
10
|
const metro_cdp_js_1 = require("../drivers/metro-cdp.js");
|
|
11
11
|
const bootstrap_js_1 = require("../drivers/bootstrap.js");
|
|
12
|
+
const runner_js_1 = require("../runner.js");
|
|
13
|
+
const web_js_1 = require("../drivers/web.js");
|
|
14
|
+
/** Resolve the web driver when this session targets a web device, else null (→ Metro path). */
|
|
15
|
+
async function webDriverFor(sessionName) {
|
|
16
|
+
if (!sessionName || sessionName === 'default')
|
|
17
|
+
return null;
|
|
18
|
+
const platform = await (0, bootstrap_js_1.detectPlatform)(sessionName).catch(() => undefined);
|
|
19
|
+
if (platform !== 'web')
|
|
20
|
+
return null;
|
|
21
|
+
const driver = await (0, runner_js_1.getDriver)(sessionName);
|
|
22
|
+
return driver instanceof web_js_1.WebDriver ? driver : null;
|
|
23
|
+
}
|
|
24
|
+
function formatNetEntry(e) {
|
|
25
|
+
const ts = e.timestamp.slice(11, 23);
|
|
26
|
+
const status = e.error ? `ERR ${e.error}` : e.status !== null ? String(e.status) : '...';
|
|
27
|
+
const dur = e.durationMs !== null ? `${e.durationMs}ms` : '-';
|
|
28
|
+
return `${ts} ${status.padEnd(6)} ${e.method.padEnd(6)} ${e.url} (${dur}, ${e.resourceType ?? e.kind ?? ''})`;
|
|
29
|
+
}
|
|
12
30
|
const INSTALL_SHIM_SCRIPT = `
|
|
13
31
|
(() => {
|
|
14
32
|
if (globalThis.__CONDUCTOR_NET__ && globalThis.__CONDUCTOR_NET__.installed) {
|
|
@@ -99,8 +117,30 @@ function resolveSession(sessionName) {
|
|
|
99
117
|
};
|
|
100
118
|
}
|
|
101
119
|
async function networkLogs(opts, sessionName, netOpts) {
|
|
102
|
-
const port = netOpts.port ?? 8081;
|
|
103
120
|
const limit = netOpts.limit ?? 50;
|
|
121
|
+
// Web: Playwright captures all traffic natively — no Metro, no injected shim.
|
|
122
|
+
const web = await webDriverFor(sessionName);
|
|
123
|
+
if (web) {
|
|
124
|
+
try {
|
|
125
|
+
const { entries } = await web.networkLogs({ limit });
|
|
126
|
+
if (opts.json) {
|
|
127
|
+
(0, output_js_1.printData)({ installed: true, count: entries.length, entries }, opts);
|
|
128
|
+
}
|
|
129
|
+
else if (entries.length === 0) {
|
|
130
|
+
console.log('No network entries captured yet. Reload the app and try again.');
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
for (const e of entries)
|
|
134
|
+
console.log(formatNetEntry(e));
|
|
135
|
+
}
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
(0, output_js_1.printError)(`network logs — ${err instanceof Error ? err.message : String(err)}`, opts);
|
|
140
|
+
return 1;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const port = netOpts.port ?? 8081;
|
|
104
144
|
const { deviceId, platformPromise } = resolveSession(sessionName);
|
|
105
145
|
try {
|
|
106
146
|
const platform = await platformPromise;
|
|
@@ -144,6 +184,31 @@ async function networkRequest(url, opts, sessionName, reqOpts) {
|
|
|
144
184
|
headers[h.slice(0, idx)] = h.slice(idx + 1);
|
|
145
185
|
}
|
|
146
186
|
const body = reqOpts.body;
|
|
187
|
+
// Web: issue via the browser context (shares the page's cookies/session).
|
|
188
|
+
const web = await webDriverFor(sessionName);
|
|
189
|
+
if (web) {
|
|
190
|
+
try {
|
|
191
|
+
const result = await web.networkRequest(url, { method, headers, body });
|
|
192
|
+
if (opts.json)
|
|
193
|
+
(0, output_js_1.printData)(result, opts);
|
|
194
|
+
else {
|
|
195
|
+
if (result.error)
|
|
196
|
+
console.error(`error: ${result.error}`);
|
|
197
|
+
console.log(`status: ${result.status ?? 'n/a'}`);
|
|
198
|
+
for (const [k, v] of Object.entries(result.headers ?? {}))
|
|
199
|
+
console.log(`${k}: ${v}`);
|
|
200
|
+
if (result.body !== undefined) {
|
|
201
|
+
console.log('');
|
|
202
|
+
console.log(result.body);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return result.ok ? 0 : 1;
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
(0, output_js_1.printError)(`network request — ${err instanceof Error ? err.message : String(err)}`, opts);
|
|
209
|
+
return 1;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
147
212
|
const init = JSON.stringify({
|
|
148
213
|
method,
|
|
149
214
|
headers,
|
|
@@ -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) => {
|
|
@@ -149,6 +149,13 @@ async function pressKey(key, opts = {}, sessionName = 'default') {
|
|
|
149
149
|
Escape: 'Escape',
|
|
150
150
|
Home: 'Home',
|
|
151
151
|
End: 'End',
|
|
152
|
+
// Canvas webtv apps (Lightning/WPE) navigate focus via the D-pad, which they listen
|
|
153
|
+
// for as arrow keys (and Enter for select). Maps the TV remote onto web keyboard.
|
|
154
|
+
'Remote Dpad Up': 'ArrowUp',
|
|
155
|
+
'Remote Dpad Down': 'ArrowDown',
|
|
156
|
+
'Remote Dpad Left': 'ArrowLeft',
|
|
157
|
+
'Remote Dpad Right': 'ArrowRight',
|
|
158
|
+
'Remote Dpad Center': 'Enter',
|
|
152
159
|
};
|
|
153
160
|
const webKey = WEB_KEY_MAP[matched];
|
|
154
161
|
if (webKey) {
|
|
@@ -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);
|