@ishlabs/cli 0.24.1 → 0.26.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/dist/commands/ask.js +3 -3
- package/dist/commands/doctor.d.ts +26 -0
- package/dist/commands/doctor.js +334 -0
- package/dist/commands/iteration.js +1 -1
- package/dist/commands/study-analyze.js +1 -1
- package/dist/commands/study-run.js +80 -12
- package/dist/commands/study.js +11 -7
- package/dist/index.js +2 -0
- package/dist/lib/alias-store.js +1 -1
- package/dist/lib/api-client.d.ts +2 -0
- package/dist/lib/docs.js +57 -42
- package/dist/lib/local-sim/actions.d.ts +10 -2
- package/dist/lib/local-sim/actions.js +18 -11
- package/dist/lib/local-sim/adb.d.ts +113 -0
- package/dist/lib/local-sim/adb.js +366 -0
- package/dist/lib/local-sim/android.d.ts +111 -0
- package/dist/lib/local-sim/android.js +504 -0
- package/dist/lib/local-sim/apk-manifest.d.ts +22 -0
- package/dist/lib/local-sim/apk-manifest.js +210 -0
- package/dist/lib/local-sim/browser.d.ts +22 -0
- package/dist/lib/local-sim/browser.js +65 -0
- package/dist/lib/local-sim/coordinates.d.ts +69 -0
- package/dist/lib/local-sim/coordinates.js +59 -0
- package/dist/lib/local-sim/device.d.ts +143 -0
- package/dist/lib/local-sim/device.js +152 -0
- package/dist/lib/local-sim/ios.d.ts +185 -0
- package/dist/lib/local-sim/ios.js +599 -0
- package/dist/lib/local-sim/loop.d.ts +14 -2
- package/dist/lib/local-sim/loop.js +168 -73
- package/dist/lib/local-sim/native-a11y.d.ts +111 -0
- package/dist/lib/local-sim/native-a11y.js +419 -0
- package/dist/lib/local-sim/simctl.d.ts +55 -0
- package/dist/lib/local-sim/simctl.js +144 -0
- package/dist/lib/local-sim/types.d.ts +39 -2
- package/dist/lib/local-sim/upload.d.ts +1 -1
- package/dist/lib/local-sim/upload.js +9 -6
- package/dist/lib/local-sim/xcuitest.d.ts +60 -0
- package/dist/lib/local-sim/xcuitest.js +303 -0
- package/dist/lib/output.js +58 -12
- package/dist/lib/paths.d.ts +8 -0
- package/dist/lib/paths.js +12 -0
- package/dist/lib/skill-content.js +10 -9
- package/package.json +2 -1
package/dist/commands/ask.js
CHANGED
|
@@ -436,15 +436,15 @@ Picks come back with a \`pick_confidence\` (0..1) score per participant when
|
|
|
436
436
|
// error rather than a transient failure.
|
|
437
437
|
ask
|
|
438
438
|
.command("dispatch")
|
|
439
|
-
.description("Dispatch a draft ask —
|
|
439
|
+
.description("Dispatch a draft ask — draws credits and starts the round")
|
|
440
440
|
.argument("[id]", "Ask alias or UUID (defaults to active ask)")
|
|
441
441
|
.option("--ask <id>", "Ask ID; alternative to positional argument")
|
|
442
442
|
.option("--wait", "Wait until the first round completes (or errors)")
|
|
443
443
|
.option("--timeout <s>", "Wait timeout in seconds (default 300)")
|
|
444
444
|
.addHelpText("after", `
|
|
445
445
|
Use after \`ish ask create --no-dispatch\` to start a draft once the user has
|
|
446
|
-
reviewed it.
|
|
447
|
-
|
|
446
|
+
reviewed it. Dispatch draws credits as responses land, the same as a normal
|
|
447
|
+
create. This is the expected way to run an ask — go ahead and dispatch.
|
|
448
448
|
|
|
449
449
|
Examples:
|
|
450
450
|
# Dispatch the active draft and wait for results:
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ish check / ish setup — environment diagnostics + dependency setup for
|
|
3
|
+
* local web and native (iOS/Android) simulations. (`doctor` is a kept alias.)
|
|
4
|
+
*
|
|
5
|
+
* `check [platform]` runs read-only checks and prints a flutter-doctor-style
|
|
6
|
+
* checklist (or `--json`), scoped to one platform's group + Account when a
|
|
7
|
+
* platform is given (ios|android|web), else everything. `setup`
|
|
8
|
+
* fetches/installs what it can (Chromium today; the iOS XCUITest runner once
|
|
9
|
+
* the artifact pipeline lands) and prints guidance for the rest, then re-runs
|
|
10
|
+
* the checks.
|
|
11
|
+
*
|
|
12
|
+
* Design notes:
|
|
13
|
+
* - Device probes are light, NON-throwing `execFile` calls reimplemented here
|
|
14
|
+
* (not imported from the local-sim drivers): doctor must never throw on a
|
|
15
|
+
* missing tool, and must not pull the playwright/local-sim module graph.
|
|
16
|
+
* - The Chromium check/setup is a DYNAMIC import of `install.ts` — that module
|
|
17
|
+
* deep-imports a non-exported playwright path that crashes at module load on
|
|
18
|
+
* the npm-installed CLI, so it can only be loaded lazily (same reason
|
|
19
|
+
* `study run --local` imports it dynamically).
|
|
20
|
+
* - iOS UI automation is migrating from idb to Apple's XCUITest (a prebuilt
|
|
21
|
+
* WebDriverAgent runner fetched into `wdaDir()`); the runner row reflects
|
|
22
|
+
* that target. Until the artifact ships, point `ISH_WDA_PATH` at a local
|
|
23
|
+
* build.
|
|
24
|
+
*/
|
|
25
|
+
import type { Command } from "commander";
|
|
26
|
+
export declare function registerDoctorCommands(program: Command): void;
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ish check / ish setup — environment diagnostics + dependency setup for
|
|
3
|
+
* local web and native (iOS/Android) simulations. (`doctor` is a kept alias.)
|
|
4
|
+
*
|
|
5
|
+
* `check [platform]` runs read-only checks and prints a flutter-doctor-style
|
|
6
|
+
* checklist (or `--json`), scoped to one platform's group + Account when a
|
|
7
|
+
* platform is given (ios|android|web), else everything. `setup`
|
|
8
|
+
* fetches/installs what it can (Chromium today; the iOS XCUITest runner once
|
|
9
|
+
* the artifact pipeline lands) and prints guidance for the rest, then re-runs
|
|
10
|
+
* the checks.
|
|
11
|
+
*
|
|
12
|
+
* Design notes:
|
|
13
|
+
* - Device probes are light, NON-throwing `execFile` calls reimplemented here
|
|
14
|
+
* (not imported from the local-sim drivers): doctor must never throw on a
|
|
15
|
+
* missing tool, and must not pull the playwright/local-sim module graph.
|
|
16
|
+
* - The Chromium check/setup is a DYNAMIC import of `install.ts` — that module
|
|
17
|
+
* deep-imports a non-exported playwright path that crashes at module load on
|
|
18
|
+
* the npm-installed CLI, so it can only be loaded lazily (same reason
|
|
19
|
+
* `study run --local` imports it dynamically).
|
|
20
|
+
* - iOS UI automation is migrating from idb to Apple's XCUITest (a prebuilt
|
|
21
|
+
* WebDriverAgent runner fetched into `wdaDir()`); the runner row reflects
|
|
22
|
+
* that target. Until the artifact ships, point `ISH_WDA_PATH` at a local
|
|
23
|
+
* build.
|
|
24
|
+
*/
|
|
25
|
+
import { execFile } from "node:child_process";
|
|
26
|
+
import { promisify } from "node:util";
|
|
27
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
28
|
+
import * as path from "node:path";
|
|
29
|
+
import { runInline } from "../lib/command-helpers.js";
|
|
30
|
+
import { output } from "../lib/output.js";
|
|
31
|
+
import { loadConfig } from "../config.js";
|
|
32
|
+
import { decodeJwtClaims, isTokenExpired } from "../auth.js";
|
|
33
|
+
import { wdaDir } from "../lib/paths.js";
|
|
34
|
+
import pkg from "../../package.json" with { type: "json" };
|
|
35
|
+
const execFileAsync = promisify(execFile);
|
|
36
|
+
const XCRUN = "/usr/bin/xcrun";
|
|
37
|
+
const isMac = process.platform === "darwin";
|
|
38
|
+
async function tryExec(cmd, args, timeoutMs = 8000) {
|
|
39
|
+
try {
|
|
40
|
+
const { stdout, stderr } = await execFileAsync(cmd, args, {
|
|
41
|
+
timeout: timeoutMs,
|
|
42
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
43
|
+
});
|
|
44
|
+
return { ok: true, stdout, stderr };
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
const err = e;
|
|
48
|
+
return {
|
|
49
|
+
ok: false,
|
|
50
|
+
stdout: err.stdout ?? "",
|
|
51
|
+
stderr: err.stderr ?? err.message ?? String(e),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** adb path, mirroring the device layer's resolution (ISH_ADB → ANDROID_HOME → PATH). */
|
|
56
|
+
function resolveAdb() {
|
|
57
|
+
for (const v of [process.env.ISH_ADB, process.env.ADB]) {
|
|
58
|
+
if (v && existsSync(v))
|
|
59
|
+
return v;
|
|
60
|
+
}
|
|
61
|
+
for (const home of [process.env.ANDROID_HOME, process.env.ANDROID_SDK_ROOT]) {
|
|
62
|
+
if (home) {
|
|
63
|
+
const p = path.join(home, "platform-tools", "adb");
|
|
64
|
+
if (existsSync(p))
|
|
65
|
+
return p;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return "adb";
|
|
69
|
+
}
|
|
70
|
+
// ── Individual checks ──────────────────────────────────────────────────────
|
|
71
|
+
function checkAuth() {
|
|
72
|
+
const cfg = loadConfig();
|
|
73
|
+
const token = cfg.access_token || cfg.token;
|
|
74
|
+
if (!token) {
|
|
75
|
+
return { key: "auth", name: "Account", group: "Account", status: "fail", message: "not logged in", fix: "ish login" };
|
|
76
|
+
}
|
|
77
|
+
const claims = decodeJwtClaims(token);
|
|
78
|
+
const email = claims?.email ?? "authenticated";
|
|
79
|
+
if (isTokenExpired(token)) {
|
|
80
|
+
// Expired access tokens auto-refresh via the refresh_token on the next API
|
|
81
|
+
// call, so this is a warn, not a hard fail.
|
|
82
|
+
return { key: "auth", name: "Account", group: "Account", status: "warn", message: `${email} (token expired — refreshes on next call)`, fix: "ish login (if refresh fails)" };
|
|
83
|
+
}
|
|
84
|
+
return { key: "auth", name: "Account", group: "Account", status: "pass", message: email };
|
|
85
|
+
}
|
|
86
|
+
function checkWorkspace() {
|
|
87
|
+
const cfg = loadConfig();
|
|
88
|
+
if (cfg.workspace) {
|
|
89
|
+
return { key: "workspace", name: "Active workspace", group: "Account", status: "pass", message: cfg.workspace };
|
|
90
|
+
}
|
|
91
|
+
return { key: "workspace", name: "Active workspace", group: "Account", status: "warn", message: "none set", fix: "ish workspace use <id>" };
|
|
92
|
+
}
|
|
93
|
+
async function checkXcode() {
|
|
94
|
+
if (!isMac) {
|
|
95
|
+
return { key: "xcode", name: "Xcode / xcrun", group: "iOS", status: "skip", message: "iOS simulations require macOS" };
|
|
96
|
+
}
|
|
97
|
+
const r = await tryExec(XCRUN, ["--version"]);
|
|
98
|
+
if (r.ok) {
|
|
99
|
+
const ver = r.stdout.trim().split("\n")[0] || "xcrun present";
|
|
100
|
+
return { key: "xcode", name: "Xcode / xcrun", group: "iOS", status: "pass", message: ver };
|
|
101
|
+
}
|
|
102
|
+
return { key: "xcode", name: "Xcode / xcrun", group: "iOS", status: "fail", message: "xcrun not found", fix: "Install Xcode from the App Store, then `xcodebuild -runFirstLaunch`" };
|
|
103
|
+
}
|
|
104
|
+
async function checkSimulator() {
|
|
105
|
+
if (!isMac) {
|
|
106
|
+
return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "skip" };
|
|
107
|
+
}
|
|
108
|
+
const r = await tryExec(XCRUN, ["simctl", "list", "devices", "booted", "-j"]);
|
|
109
|
+
if (!r.ok) {
|
|
110
|
+
return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "fail", message: "could not query simctl", fix: "Install Xcode" };
|
|
111
|
+
}
|
|
112
|
+
let booted = [];
|
|
113
|
+
try {
|
|
114
|
+
const parsed = JSON.parse(r.stdout);
|
|
115
|
+
booted = Object.values(parsed.devices ?? {})
|
|
116
|
+
.flat()
|
|
117
|
+
.filter((d) => d.state === "Booted")
|
|
118
|
+
.map((d) => ({ name: d.name, udid: d.udid }));
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "fail", message: "could not parse simctl output" };
|
|
122
|
+
}
|
|
123
|
+
if (booted.length === 1) {
|
|
124
|
+
return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "pass", message: `${booted[0].name} (booted)` };
|
|
125
|
+
}
|
|
126
|
+
if (booted.length === 0) {
|
|
127
|
+
return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "warn", message: "none booted", fix: "Open Simulator.app (Xcode ships simulators) or `xcrun simctl boot <udid>`" };
|
|
128
|
+
}
|
|
129
|
+
return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "warn", message: `${booted.length} booted — native runs drive exactly one`, fix: "Shut down the extras" };
|
|
130
|
+
}
|
|
131
|
+
/** True when the prebuilt XCUITest runner (WebDriverAgent `.app`) is present. */
|
|
132
|
+
function wdaBundlePresent() {
|
|
133
|
+
const override = process.env.ISH_WDA_PATH;
|
|
134
|
+
if (override)
|
|
135
|
+
return existsSync(override);
|
|
136
|
+
const dir = wdaDir();
|
|
137
|
+
if (!existsSync(dir))
|
|
138
|
+
return false;
|
|
139
|
+
try {
|
|
140
|
+
return readdirSync(dir).some((e) => e.endsWith(".app"));
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function checkXcuitestRunner() {
|
|
147
|
+
if (!isMac) {
|
|
148
|
+
return { key: "xcuitest_runner", name: "XCUITest runner", group: "iOS", status: "skip" };
|
|
149
|
+
}
|
|
150
|
+
if (wdaBundlePresent()) {
|
|
151
|
+
return { key: "xcuitest_runner", name: "XCUITest runner", group: "iOS", status: "pass", message: process.env.ISH_WDA_PATH ? "ISH_WDA_PATH" : wdaDir() };
|
|
152
|
+
}
|
|
153
|
+
return { key: "xcuitest_runner", name: "XCUITest runner", group: "iOS", status: "warn", message: "not installed", fix: "ish setup (or set ISH_WDA_PATH to a local WebDriverAgent build)" };
|
|
154
|
+
}
|
|
155
|
+
async function checkAdb() {
|
|
156
|
+
const adbPath = resolveAdb();
|
|
157
|
+
const r = await tryExec(adbPath, ["devices"]);
|
|
158
|
+
if (!r.ok) {
|
|
159
|
+
const adb = { key: "adb", name: "Android adb", group: "Android", status: "warn", message: "not found (only needed for Android)", fix: "Install Android platform-tools" };
|
|
160
|
+
const emulator = { key: "android_emulator", name: "Android emulator", group: "Android", status: "skip" };
|
|
161
|
+
return { adb, emulator };
|
|
162
|
+
}
|
|
163
|
+
const adb = { key: "adb", name: "Android adb", group: "Android", status: "pass", message: adbPath };
|
|
164
|
+
// Lines after the "List of devices attached" header, in "device" state.
|
|
165
|
+
const devices = r.stdout
|
|
166
|
+
.split("\n")
|
|
167
|
+
.slice(1)
|
|
168
|
+
.map((l) => l.trim())
|
|
169
|
+
.filter((l) => l.endsWith("\tdevice"));
|
|
170
|
+
const emulator = devices.length === 1
|
|
171
|
+
? { key: "android_emulator", name: "Android emulator", group: "Android", status: "pass", message: devices[0].split("\t")[0] }
|
|
172
|
+
: devices.length === 0
|
|
173
|
+
? { key: "android_emulator", name: "Android emulator", group: "Android", status: "warn", message: "none online", fix: "Create + boot an AVD in Android Studio > Device Manager (or `emulator -avd <name>`)" }
|
|
174
|
+
: { key: "android_emulator", name: "Android emulator", group: "Android", status: "warn", message: `${devices.length} online — native runs drive exactly one`, fix: "Stop the extras" };
|
|
175
|
+
return { adb, emulator };
|
|
176
|
+
}
|
|
177
|
+
async function checkChromium() {
|
|
178
|
+
try {
|
|
179
|
+
const { isBrowserInstalled } = await import("../lib/local-sim/install.js");
|
|
180
|
+
return isBrowserInstalled()
|
|
181
|
+
? { key: "chromium", name: "Chromium (web local)", group: "Web", status: "pass", message: "installed" }
|
|
182
|
+
: { key: "chromium", name: "Chromium (web local)", group: "Web", status: "warn", message: "not installed", fix: "ish setup" };
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return { key: "chromium", name: "Chromium (web local)", group: "Web", status: "skip", message: "unavailable in this build" };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function checkCli() {
|
|
189
|
+
return { key: "cli", name: "ish CLI", group: "Account", status: "pass", message: `v${pkg.version}` };
|
|
190
|
+
}
|
|
191
|
+
async function runChecks() {
|
|
192
|
+
const { adb, emulator } = await checkAdb();
|
|
193
|
+
return [
|
|
194
|
+
await checkXcode(),
|
|
195
|
+
await checkSimulator(),
|
|
196
|
+
checkXcuitestRunner(),
|
|
197
|
+
adb,
|
|
198
|
+
emulator,
|
|
199
|
+
await checkChromium(),
|
|
200
|
+
checkAuth(),
|
|
201
|
+
checkWorkspace(),
|
|
202
|
+
checkCli(),
|
|
203
|
+
];
|
|
204
|
+
}
|
|
205
|
+
// ── Rendering ───────────────────────────────────────────────────────────────
|
|
206
|
+
const MARK = { pass: "[ OK ]", warn: "[WARN]", fail: "[FAIL]", skip: "[ -- ]" };
|
|
207
|
+
const GROUP_ORDER = ["iOS", "Android", "Web", "Account"];
|
|
208
|
+
const GROUP_LABEL = {
|
|
209
|
+
iOS: "iOS (native simulations)",
|
|
210
|
+
Android: "Android (native simulations)",
|
|
211
|
+
Web: "Web (local browser simulations)",
|
|
212
|
+
Account: "Account",
|
|
213
|
+
};
|
|
214
|
+
// `ish check <platform>` scopes the report to one platform's group + Account,
|
|
215
|
+
// so an Android study never nags about Xcode and an iOS study never about adb.
|
|
216
|
+
const PLATFORM_GROUPS = {
|
|
217
|
+
ios: ["iOS", "Account"],
|
|
218
|
+
android: ["Android", "Account"],
|
|
219
|
+
web: ["Web", "Account"],
|
|
220
|
+
};
|
|
221
|
+
function scopeChecks(checks, platform) {
|
|
222
|
+
if (!platform)
|
|
223
|
+
return checks;
|
|
224
|
+
const groups = PLATFORM_GROUPS[platform];
|
|
225
|
+
return groups ? checks.filter((c) => groups.includes(c.group)) : checks;
|
|
226
|
+
}
|
|
227
|
+
function overall(checks) {
|
|
228
|
+
if (checks.some((c) => c.status === "fail"))
|
|
229
|
+
return "fail";
|
|
230
|
+
if (checks.some((c) => c.status === "warn"))
|
|
231
|
+
return "warn";
|
|
232
|
+
return "pass";
|
|
233
|
+
}
|
|
234
|
+
function renderHuman(checks) {
|
|
235
|
+
const lines = ["", "ish check — local & native simulation environment", ""];
|
|
236
|
+
const width = Math.max(...checks.map((c) => c.name.length)) + 2;
|
|
237
|
+
for (const group of GROUP_ORDER) {
|
|
238
|
+
const inGroup = checks.filter((c) => c.group === group);
|
|
239
|
+
if (inGroup.length === 0)
|
|
240
|
+
continue;
|
|
241
|
+
lines.push(` ${GROUP_LABEL[group]}`);
|
|
242
|
+
for (const c of inGroup) {
|
|
243
|
+
const label = c.name.padEnd(width, " ");
|
|
244
|
+
let line = ` ${MARK[c.status]} ${label}${c.message ?? ""}`;
|
|
245
|
+
lines.push(line.trimEnd());
|
|
246
|
+
if ((c.status === "warn" || c.status === "fail") && c.fix) {
|
|
247
|
+
lines.push(` ↳ ${c.fix}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
lines.push("");
|
|
251
|
+
}
|
|
252
|
+
const o = overall(checks);
|
|
253
|
+
const fails = checks.filter((c) => c.status === "fail").length;
|
|
254
|
+
const warns = checks.filter((c) => c.status === "warn").length;
|
|
255
|
+
if (o === "pass") {
|
|
256
|
+
lines.push("Everything checks out.");
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
const parts = [fails ? `${fails} blocking` : "", warns ? `${warns} optional` : ""].filter(Boolean);
|
|
260
|
+
lines.push(`${parts.join(", ")} issue(s). Run \`ish setup\` to fetch what's fetchable.`);
|
|
261
|
+
}
|
|
262
|
+
console.log(lines.join("\n"));
|
|
263
|
+
}
|
|
264
|
+
// ── Commands ──────────────────────────────────────────────────────────────
|
|
265
|
+
export function registerDoctorCommands(program) {
|
|
266
|
+
program
|
|
267
|
+
.command("check [platform]")
|
|
268
|
+
.alias("doctor")
|
|
269
|
+
.description("Check your local simulation setup for a platform (ios | android | web), or everything")
|
|
270
|
+
.action(async (platform, _opts, cmd) => {
|
|
271
|
+
await runInline(cmd, async (globals) => {
|
|
272
|
+
const scope = platform?.toLowerCase();
|
|
273
|
+
if (scope && !PLATFORM_GROUPS[scope]) {
|
|
274
|
+
throw new Error(`Unknown platform "${platform}". Use one of: ios, android, web.`);
|
|
275
|
+
}
|
|
276
|
+
const checks = scopeChecks(await runChecks(), scope);
|
|
277
|
+
if (globals.json) {
|
|
278
|
+
output({ checks, overall: overall(checks) }, true);
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
renderHuman(checks);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
program
|
|
286
|
+
.command("setup")
|
|
287
|
+
.description("Fetch/install local-simulation dependencies (Chromium, iOS runner) and show what's left to do")
|
|
288
|
+
.action(async (_opts, cmd) => {
|
|
289
|
+
await runInline(cmd, async (globals) => {
|
|
290
|
+
const quiet = globals.json;
|
|
291
|
+
const log = (m) => { if (!quiet)
|
|
292
|
+
console.error(m); };
|
|
293
|
+
// 1. Chromium (web local) — auto-fetchable.
|
|
294
|
+
try {
|
|
295
|
+
const { isBrowserInstalled, ensureBrowser } = await import("../lib/local-sim/install.js");
|
|
296
|
+
if (isBrowserInstalled()) {
|
|
297
|
+
log("Chromium: already installed.");
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
log("Chromium: fetching for local web simulations...");
|
|
301
|
+
await ensureBrowser({ quiet, skipPrompt: true });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
catch (e) {
|
|
305
|
+
log(`Chromium: could not install (${e instanceof Error ? e.message : String(e)}).`);
|
|
306
|
+
}
|
|
307
|
+
// 2. iOS XCUITest runner (WebDriverAgent) — auto-fetchable on macOS.
|
|
308
|
+
if (isMac) {
|
|
309
|
+
try {
|
|
310
|
+
if (wdaBundlePresent()) {
|
|
311
|
+
log("iOS runner: already installed.");
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
log("iOS runner: fetching WebDriverAgent...");
|
|
315
|
+
const { resolveWdaBundle } = await import("../lib/local-sim/xcuitest.js");
|
|
316
|
+
await resolveWdaBundle();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
catch (e) {
|
|
320
|
+
log(`iOS runner: could not fetch (${e instanceof Error ? e.message : String(e)}).`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// 3. Re-run checks so the user sees the resulting state, and hand back
|
|
324
|
+
// the structured result on --json.
|
|
325
|
+
const checks = await runChecks();
|
|
326
|
+
if (globals.json) {
|
|
327
|
+
output({ checks, overall: overall(checks) }, true);
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
renderHuman(checks);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
}
|
|
@@ -383,7 +383,7 @@ Concept pages: ish docs get-page concepts/iteration
|
|
|
383
383
|
// Media image
|
|
384
384
|
.option("--image-urls <urls>", "Comma-separated image URLs or local file paths — image modality")
|
|
385
385
|
// Shared media
|
|
386
|
-
.option("--title <title>", "Content title — media modalities")
|
|
386
|
+
.option("--title <title>", "Content title shown to participants (the leading headline they read) — media modalities")
|
|
387
387
|
.option("--mime-type <type>", "MIME type (e.g. video/mp4) — media modalities")
|
|
388
388
|
// Copy/caption
|
|
389
389
|
.option("--copy-text <text>", "Ad copy or social post caption (or @filepath) — ads & social posts")
|
|
@@ -81,7 +81,7 @@ export function attachStudyAnalyzeCommands(study) {
|
|
|
81
81
|
study
|
|
82
82
|
.command("analyze")
|
|
83
83
|
.description("Trigger an AI summary + key-insights analysis for a study. " +
|
|
84
|
-
"First analysis per study is
|
|
84
|
+
"First analysis per study is included; subsequent runs draw 10 credits.")
|
|
85
85
|
.argument("[id]", "Study ID (defaults to active study)")
|
|
86
86
|
.option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the study)")
|
|
87
87
|
.option("--wait", "Poll until the run reaches completed or failed")
|
|
@@ -33,8 +33,8 @@ function parseMaxInteractions(value) {
|
|
|
33
33
|
/**
|
|
34
34
|
* Default cap the CLI sends when neither `--max-interactions` nor the
|
|
35
35
|
* iteration carries its own value. Picked to match the frontend's
|
|
36
|
-
* conservative interactive launchers and to prevent runaway
|
|
37
|
-
* iteration runs against a broken or non-responsive surface — without a
|
|
36
|
+
* conservative interactive launchers and to prevent runaway credit draw when
|
|
37
|
+
* an iteration runs against a broken or non-responsive surface — without a
|
|
38
38
|
* cap, a stuck participant can rack up hundreds of steps before the SDK gives
|
|
39
39
|
* up.
|
|
40
40
|
*/
|
|
@@ -264,6 +264,34 @@ function readIterationDetails(details) {
|
|
|
264
264
|
...(typeof details.title === "string" && { title: details.title }),
|
|
265
265
|
};
|
|
266
266
|
}
|
|
267
|
+
/**
|
|
268
|
+
* Normalize a platform string for matching. "web", "browser", and unset all
|
|
269
|
+
* mean the default browser path; "android"/"ios" are native. Lets `--platform
|
|
270
|
+
* web` match a "browser" iteration (and vice-versa) without false mismatches.
|
|
271
|
+
*/
|
|
272
|
+
function normalizePlatform(platform) {
|
|
273
|
+
const p = (platform ?? "").toLowerCase();
|
|
274
|
+
if (p === "" || p === "web" || p === "browser")
|
|
275
|
+
return "browser";
|
|
276
|
+
return p;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* The local platform the user explicitly requested via flags, before any
|
|
280
|
+
* iteration is picked: --platform, or inferred from --app's extension
|
|
281
|
+
* (.apk → android, .app → ios). Undefined when neither is set (no preference →
|
|
282
|
+
* don't filter iterations by platform). The iteration's stored platform is
|
|
283
|
+
* deliberately NOT consulted here — it's what we're selecting against.
|
|
284
|
+
*/
|
|
285
|
+
function requestedLocalPlatform(opts) {
|
|
286
|
+
if (opts.platform)
|
|
287
|
+
return opts.platform;
|
|
288
|
+
const app = opts.app?.toLowerCase();
|
|
289
|
+
if (app?.endsWith(".apk"))
|
|
290
|
+
return "android";
|
|
291
|
+
if (app?.endsWith(".app"))
|
|
292
|
+
return "ios";
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
267
295
|
export function attachStudyRunCommands(study) {
|
|
268
296
|
// --- Primary: `study run` ---
|
|
269
297
|
const studyRun = study
|
|
@@ -294,6 +322,8 @@ export function attachStudyRunCommands(study) {
|
|
|
294
322
|
.option("--devtools", "Open Chrome DevTools (local mode only)")
|
|
295
323
|
.option("--debug", "Enable detailed debug logging to stderr and ~/.ish/local-sim.log")
|
|
296
324
|
.option("--parallel <n>", "Run N participants in parallel (local mode only, default: all)")
|
|
325
|
+
.option("--platform <platform>", "Local target platform: 'web' (Playwright), 'android' (adb emulator), or 'ios' (simctl+idb simulator). Defaults to the iteration's platform.")
|
|
326
|
+
.option("--app <path>", "Native local mode: path to an .apk (android) / .app (ios) to install, or an installed package/bundle id to launch. The extension implies --platform.")
|
|
297
327
|
.addHelpText("after", `
|
|
298
328
|
Note: --workspace and --study are optional if you have set active context
|
|
299
329
|
via \`ish workspace use <alias>\` and \`ish study use <alias>\`.
|
|
@@ -326,7 +356,7 @@ Examples:
|
|
|
326
356
|
$ ish study run --config c-c3c
|
|
327
357
|
|
|
328
358
|
# Cap interactions per participant (default 20 — pass higher to allow deeper
|
|
329
|
-
# exploration, lower to cap
|
|
359
|
+
# exploration, lower to cap credit draw on a known-broken surface):
|
|
330
360
|
$ ish study run --max-interactions 30
|
|
331
361
|
|
|
332
362
|
# Block until all simulations finish (or timeout):
|
|
@@ -412,8 +442,13 @@ Examples:
|
|
|
412
442
|
if (!study.assignments || study.assignments.length === 0) {
|
|
413
443
|
throw new Error("Study has no assignments. Add tasks with --assignments when creating the study, or use `ish study generate`.");
|
|
414
444
|
}
|
|
415
|
-
// Step 1: Pick iteration (explicit --iteration, or latest on study)
|
|
445
|
+
// Step 1: Pick iteration (explicit --iteration, or latest on study).
|
|
446
|
+
// When --platform / --app requests a local platform, select the
|
|
447
|
+
// iteration whose details.platform matches — otherwise a multi-platform
|
|
448
|
+
// study (e.g. an android AND an ios iteration) would silently run the
|
|
449
|
+
// latest, which may be the wrong platform.
|
|
416
450
|
const iterations = study.iterations || [];
|
|
451
|
+
const wantPlatform = opts.local ? requestedLocalPlatform(opts) : undefined;
|
|
417
452
|
let iteration;
|
|
418
453
|
if (opts.iteration) {
|
|
419
454
|
const wantedId = resolveId(opts.iteration);
|
|
@@ -421,6 +456,25 @@ Examples:
|
|
|
421
456
|
if (!iteration) {
|
|
422
457
|
throw new Error(`Iteration ${opts.iteration} not found on this study.`);
|
|
423
458
|
}
|
|
459
|
+
// An explicit --iteration whose platform contradicts --platform is a
|
|
460
|
+
// footgun (you'd drive the wrong device); refuse rather than guess.
|
|
461
|
+
if (wantPlatform) {
|
|
462
|
+
const itPlatform = readIterationDetails(iteration.details).platform;
|
|
463
|
+
if (normalizePlatform(itPlatform) !== normalizePlatform(wantPlatform)) {
|
|
464
|
+
throw new Error(`--platform ${wantPlatform} but iteration ${opts.iteration} is platform ` +
|
|
465
|
+
`'${itPlatform ?? "browser"}'. Pass the matching iteration or drop --platform.`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
else if (wantPlatform) {
|
|
470
|
+
// Latest iteration whose platform matches the requested one.
|
|
471
|
+
const matching = iterations.filter((it) => normalizePlatform(readIterationDetails(it.details).platform) === normalizePlatform(wantPlatform));
|
|
472
|
+
if (matching.length === 0) {
|
|
473
|
+
const available = [...new Set(iterations.map((it) => normalizePlatform(readIterationDetails(it.details).platform)))].join(", ") || "none";
|
|
474
|
+
throw new Error(`No ${wantPlatform} iteration on this study (platforms present: ${available}). ` +
|
|
475
|
+
`Create one with \`ish iteration create --study ${resolvedStudy} ...\`, or pass --iteration.`);
|
|
476
|
+
}
|
|
477
|
+
iteration = matching[matching.length - 1];
|
|
424
478
|
}
|
|
425
479
|
else if (iterations.length > 0) {
|
|
426
480
|
iteration = iterations[iterations.length - 1];
|
|
@@ -691,6 +745,21 @@ Examples:
|
|
|
691
745
|
// by reusing the iteration's existing Conversation rows or by
|
|
692
746
|
// calling pair-batch.
|
|
693
747
|
let pairConversationIds = [];
|
|
748
|
+
// Resolve the local target platform ONCE so participant-create and the
|
|
749
|
+
// local-sim dispatch agree. Precedence: --platform flag > --app
|
|
750
|
+
// extension (.apk → android, .app → ios) > iteration's stored platform
|
|
751
|
+
// > browser. Used to set participant.platform (so the backend's native
|
|
752
|
+
// trigger is driven primarily by the participant, not just the
|
|
753
|
+
// empty-tree fallback) and to pick the device in the local loop.
|
|
754
|
+
const platformFromApp = opts.app?.toLowerCase().endsWith(".apk")
|
|
755
|
+
? "android"
|
|
756
|
+
: opts.app?.toLowerCase().endsWith(".app")
|
|
757
|
+
? "ios"
|
|
758
|
+
: undefined;
|
|
759
|
+
const resolvedPlatform = opts.platform
|
|
760
|
+
?? platformFromApp
|
|
761
|
+
?? detailsView.platform
|
|
762
|
+
?? "browser";
|
|
694
763
|
if (isPair && pairConfig) {
|
|
695
764
|
// Pair-mode flow mirrors the MCP (`ish-mcp` `_run_pair_mode`):
|
|
696
765
|
// 1. If the iteration already carries `conversations[]` from a
|
|
@@ -782,7 +851,7 @@ Examples:
|
|
|
782
851
|
participant_type: "ai",
|
|
783
852
|
status: "draft",
|
|
784
853
|
...(opts.language && { language: opts.language }),
|
|
785
|
-
...(!isMedia && !isChat && { platform:
|
|
854
|
+
...(!isMedia && !isChat && { platform: resolvedPlatform }),
|
|
786
855
|
}));
|
|
787
856
|
log(`Creating ${participantInputs.length} participant${participantInputs.length > 1 ? "s" : ""}...`);
|
|
788
857
|
const batchResult = await client.post(`/iterations/${iterationId}/participants/batch`, { participants: participantInputs }, { timeout: dispatchTimeoutMs });
|
|
@@ -814,6 +883,8 @@ Examples:
|
|
|
814
883
|
devtools: opts.devtools,
|
|
815
884
|
debug: opts.debug,
|
|
816
885
|
parallel: opts.parallel ? parseInt(opts.parallel, 10) : undefined,
|
|
886
|
+
platform: resolvedPlatform,
|
|
887
|
+
...(opts.app && { appPath: opts.app }),
|
|
817
888
|
quiet: globals.quiet,
|
|
818
889
|
json: globals.json,
|
|
819
890
|
});
|
|
@@ -1042,14 +1113,11 @@ Examples:
|
|
|
1042
1113
|
}, true);
|
|
1043
1114
|
}
|
|
1044
1115
|
else {
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
log(` ${personName.padEnd(24)} QUEUED`);
|
|
1049
|
-
}
|
|
1116
|
+
const studyAlias = tagAlias(ALIAS_PREFIX.study, resolvedStudy);
|
|
1117
|
+
const n = createdParticipants.length;
|
|
1118
|
+
log(`Dispatched ${n} participant${n > 1 ? "s" : ""} — run \`ish study results ${studyAlias}\` for results (or \`ish study poll --study ${studyAlias}\` / --wait to track progress).`);
|
|
1050
1119
|
const url = getWebUrl(globals, `/${resolvedWorkspace}/${resolvedStudy}/timeline`);
|
|
1051
|
-
log(
|
|
1052
|
-
log(`Run \`ish study poll --study ${resolvedStudy}\` (or --wait next time) to check progress.`);
|
|
1120
|
+
log(` ${terminalLink(url, "Open in browser ↗")}`);
|
|
1053
1121
|
}
|
|
1054
1122
|
return;
|
|
1055
1123
|
}
|
package/dist/commands/study.js
CHANGED
|
@@ -113,7 +113,7 @@ Concept pages: ish docs get-page concepts/study
|
|
|
113
113
|
.requiredOption("--name <name>", "Study name")
|
|
114
114
|
.option("--description <description>", "Study description")
|
|
115
115
|
.option("--modality <modality>", "Study modality (interactive, video, audio, text, image, document, chat)")
|
|
116
|
-
.option("--content-type <type>", "Content type (per-modality enum — see 'Content types by modality' below). Not used for interactive / chat.")
|
|
116
|
+
.option("--content-type <type>", "Content type (per-modality enum — see 'Content types by modality' below). Changes how --title is presented to participants (e.g. content-type email renders --title as the Subject: line). Not used for interactive / chat.")
|
|
117
117
|
.option("--assignment <name:instructions>", "Assignment as 'Name:Instructions' (repeatable)", collectRepeatable, [])
|
|
118
118
|
.option("--assignments-file <path>", "JSON file with assignments array")
|
|
119
119
|
.option("--assignments <json>", "Inline JSON array of assignments (escape hatch)")
|
|
@@ -124,7 +124,7 @@ Concept pages: ish docs get-page concepts/study
|
|
|
124
124
|
.option("--screen-format <format>", "Screen format for interactive iterations: desktop (default) or mobile_portrait (hyphen/underscore variants accepted)")
|
|
125
125
|
.option("--content-url <url>", "Public URL of the media file. Creates iteration A inline (video, audio, document modalities). For local files, use the 2-step `iteration create` flow.")
|
|
126
126
|
.option("--image-urls <urls>", "Comma-separated public image URLs. Creates iteration A inline (image modality). For local files, use the 2-step `iteration create` flow.")
|
|
127
|
-
.option("--title <title>", "
|
|
127
|
+
.option("--title <title>", "Participant-facing content title — the headline participants read before the body (text + media modalities — image, video, audio, document; optional). With --content-type email it becomes the email Subject: line. Not an internal label. Not used for interactive / chat.")
|
|
128
128
|
.option("--segmentation-json <json>", "Segmentation JSON for the inline iteration A — time_based {intervals_seconds, labels?}, section_based {sections[{name,label,...}]}, or page_based {} (text + media). section_based sections are SEMANTIC: group related paragraphs into a few coherent sections (a long article is usually 3-6 sections, not one per paragraph). Lets one `study create` build a complete segmented iteration — no separate `iteration create` needed.")
|
|
129
129
|
.option("--content-config-json <json>", "Content-config JSON for the inline iteration A (early_termination, selected_segment_indices) — text + media.")
|
|
130
130
|
.option("--content-html <html>", "HTML version of the text, or @filepath — text modality (email rendering)")
|
|
@@ -239,6 +239,9 @@ Examples:
|
|
|
239
239
|
|
|
240
240
|
Content types by modality (source: VALID_CONTENT_TYPES in src/lib/types.ts; interactive + chat omitted — they don't take --content-type):
|
|
241
241
|
${describeContentTypes()}
|
|
242
|
+
The content type also shapes how --title is rendered to participants: with
|
|
243
|
+
content-type email, --title becomes the email Subject: line; otherwise it
|
|
244
|
+
reads as the leading headline of the content.
|
|
242
245
|
|
|
243
246
|
Tips:
|
|
244
247
|
Use \`--get <path>\` to capture a single value (e.g. \`--get id\`),
|
|
@@ -310,11 +313,12 @@ Next: configure a run with \`ish iteration create --study <id>\`,
|
|
|
310
313
|
}
|
|
311
314
|
normalizedScreenFormat = normalized;
|
|
312
315
|
}
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
316
|
+
// --title is participant-facing content: the backend renders it as a
|
|
317
|
+
// leading headline participants read before the body (and as the email
|
|
318
|
+
// Subject: line when content-type is email). It only exists on text +
|
|
319
|
+
// media modalities (see `buildIterationDetails` in iteration.ts), so
|
|
320
|
+
// reject it on shapes that have no title field — interactive (URL only)
|
|
321
|
+
// and chat (endpoint config carries its own configuration).
|
|
318
322
|
if (opts.title !== undefined
|
|
319
323
|
&& opts.contentText === undefined
|
|
320
324
|
&& opts.contentUrl === undefined
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { registerDocsCommands } from "./commands/docs.js";
|
|
|
19
19
|
import { registerInitCommands } from "./commands/init.js";
|
|
20
20
|
import { registerMcpCommands } from "./commands/mcp.js";
|
|
21
21
|
import { registerSecretCommands } from "./commands/secret.js";
|
|
22
|
+
import { registerDoctorCommands } from "./commands/doctor.js";
|
|
22
23
|
import { AGENT_HELP_FOOTER } from "./lib/docs.js";
|
|
23
24
|
import { runInline, EXIT_USAGE, injectGlobalWorkspaceOption } from "./lib/command-helpers.js";
|
|
24
25
|
import { resolveApiUrl, resolveToken, verifyToken } from "./lib/auth.js";
|
|
@@ -510,6 +511,7 @@ registerDocsCommands(program);
|
|
|
510
511
|
registerInitCommands(program);
|
|
511
512
|
registerMcpCommands(program);
|
|
512
513
|
registerSecretCommands(program);
|
|
514
|
+
registerDoctorCommands(program);
|
|
513
515
|
program
|
|
514
516
|
.command("upgrade")
|
|
515
517
|
.description("Update ish to the latest version")
|
package/dist/lib/alias-store.js
CHANGED
|
@@ -129,7 +129,7 @@ const HYDRATE_HINT = {
|
|
|
129
129
|
// alias map at ~/.ish/aliases.json carries any sources the CLI has
|
|
130
130
|
// touched in this session.
|
|
131
131
|
ps: "ish source upload <file> # or `cat ~/.ish/aliases.json | grep ^ps-` to recover prior aliases",
|
|
132
|
-
pt: "ish participant
|
|
132
|
+
pt: "ish study participant <participant-id>",
|
|
133
133
|
c: "ish config list",
|
|
134
134
|
a: "ish ask list",
|
|
135
135
|
r: "ish ask get <ask-id>",
|
package/dist/lib/api-client.d.ts
CHANGED