@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.
Files changed (43) hide show
  1. package/dist/commands/ask.js +3 -3
  2. package/dist/commands/doctor.d.ts +26 -0
  3. package/dist/commands/doctor.js +334 -0
  4. package/dist/commands/iteration.js +1 -1
  5. package/dist/commands/study-analyze.js +1 -1
  6. package/dist/commands/study-run.js +80 -12
  7. package/dist/commands/study.js +11 -7
  8. package/dist/index.js +2 -0
  9. package/dist/lib/alias-store.js +1 -1
  10. package/dist/lib/api-client.d.ts +2 -0
  11. package/dist/lib/docs.js +57 -42
  12. package/dist/lib/local-sim/actions.d.ts +10 -2
  13. package/dist/lib/local-sim/actions.js +18 -11
  14. package/dist/lib/local-sim/adb.d.ts +113 -0
  15. package/dist/lib/local-sim/adb.js +366 -0
  16. package/dist/lib/local-sim/android.d.ts +111 -0
  17. package/dist/lib/local-sim/android.js +504 -0
  18. package/dist/lib/local-sim/apk-manifest.d.ts +22 -0
  19. package/dist/lib/local-sim/apk-manifest.js +210 -0
  20. package/dist/lib/local-sim/browser.d.ts +22 -0
  21. package/dist/lib/local-sim/browser.js +65 -0
  22. package/dist/lib/local-sim/coordinates.d.ts +69 -0
  23. package/dist/lib/local-sim/coordinates.js +59 -0
  24. package/dist/lib/local-sim/device.d.ts +143 -0
  25. package/dist/lib/local-sim/device.js +152 -0
  26. package/dist/lib/local-sim/ios.d.ts +185 -0
  27. package/dist/lib/local-sim/ios.js +599 -0
  28. package/dist/lib/local-sim/loop.d.ts +14 -2
  29. package/dist/lib/local-sim/loop.js +168 -73
  30. package/dist/lib/local-sim/native-a11y.d.ts +111 -0
  31. package/dist/lib/local-sim/native-a11y.js +419 -0
  32. package/dist/lib/local-sim/simctl.d.ts +55 -0
  33. package/dist/lib/local-sim/simctl.js +144 -0
  34. package/dist/lib/local-sim/types.d.ts +39 -2
  35. package/dist/lib/local-sim/upload.d.ts +1 -1
  36. package/dist/lib/local-sim/upload.js +9 -6
  37. package/dist/lib/local-sim/xcuitest.d.ts +60 -0
  38. package/dist/lib/local-sim/xcuitest.js +303 -0
  39. package/dist/lib/output.js +58 -12
  40. package/dist/lib/paths.d.ts +8 -0
  41. package/dist/lib/paths.js +12 -0
  42. package/dist/lib/skill-content.js +10 -9
  43. package/package.json +2 -1
@@ -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 — bills credits and starts the round")
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. The dispatch is BILLABLE credits are charged when responses
447
- land, the same as a normal create.
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 free; subsequent runs cost 10 credits.")
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 spend when an
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 spend on a known-broken surface):
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: detailsView.platform || "browser" }),
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
- for (let i = 0; i < simResults.length; i++) {
1046
- const participant = createdParticipants[i];
1047
- const personName = participant?.person?.name || "Unknown";
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(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
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
  }
@@ -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>", "Content title (text + media modalities — image, video, audio, document; optional). Not used for interactive / chat.")
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
- // Pattern G.2: --title is metadata, not content. The backend
314
- // accepts it on text + media modalities (see
315
- // `buildIterationDetails` in iteration.ts). Reject it only on
316
- // shapes that have no title field — interactive (URL only) and
317
- // chat (endpoint config carries its own metadata).
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")
@@ -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 get <participant-id>",
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>",
@@ -48,6 +48,8 @@ export declare class ApiClient {
48
48
  screenshot_url?: string;
49
49
  location_name: string;
50
50
  screen_format?: string;
51
+ full_page_screenshot_base64?: string;
52
+ platform?: string;
51
53
  }): Promise<{
52
54
  frame_version_id: string;
53
55
  }>;