@ishlabs/cli 0.27.0 → 0.28.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/README.md +4 -0
  2. package/dist/commands/doctor.js +21 -11
  3. package/dist/commands/feedback.d.ts +22 -0
  4. package/dist/commands/feedback.js +259 -0
  5. package/dist/commands/iteration.js +13 -4
  6. package/dist/commands/study-run.js +12 -12
  7. package/dist/commands/study-screenshots.js +15 -12
  8. package/dist/commands/study.js +22 -3
  9. package/dist/commands/workspace.js +1 -1
  10. package/dist/index.js +2 -0
  11. package/dist/lib/command-helpers.js +7 -3
  12. package/dist/lib/docs.js +238 -19
  13. package/dist/lib/local-sim/actions.d.ts +18 -2
  14. package/dist/lib/local-sim/actions.js +24 -1
  15. package/dist/lib/local-sim/adb.d.ts +19 -2
  16. package/dist/lib/local-sim/adb.js +71 -23
  17. package/dist/lib/local-sim/android.js +7 -2
  18. package/dist/lib/local-sim/device-pool.d.ts +85 -0
  19. package/dist/lib/local-sim/device-pool.js +316 -0
  20. package/dist/lib/local-sim/device.d.ts +4 -0
  21. package/dist/lib/local-sim/device.js +19 -1
  22. package/dist/lib/local-sim/emulator.d.ts +50 -0
  23. package/dist/lib/local-sim/emulator.js +189 -0
  24. package/dist/lib/local-sim/install.js +23 -3
  25. package/dist/lib/local-sim/ios.d.ts +26 -1
  26. package/dist/lib/local-sim/ios.js +61 -17
  27. package/dist/lib/local-sim/loop.js +117 -11
  28. package/dist/lib/local-sim/screen-signature.js +4 -0
  29. package/dist/lib/local-sim/simctl-provision.d.ts +49 -0
  30. package/dist/lib/local-sim/simctl-provision.js +89 -0
  31. package/dist/lib/local-sim/simctl.d.ts +6 -4
  32. package/dist/lib/local-sim/simctl.js +18 -5
  33. package/dist/lib/local-sim/types.d.ts +27 -1
  34. package/dist/lib/local-sim/xcuitest.d.ts +39 -1
  35. package/dist/lib/local-sim/xcuitest.js +70 -6
  36. package/dist/lib/output.d.ts +11 -1
  37. package/dist/lib/output.js +56 -2
  38. package/dist/lib/paths.d.ts +1 -0
  39. package/dist/lib/paths.js +3 -0
  40. package/dist/lib/skill-content.js +14 -2
  41. package/dist/lib/upload.d.ts +27 -0
  42. package/dist/lib/upload.js +108 -11
  43. package/package.json +2 -2
package/README.md CHANGED
@@ -45,6 +45,10 @@ The CLI resolves your auth token in this order:
45
45
 
46
46
  Test plan is available at `/Users/felixweiland/ish-cli-test-plan.md`.
47
47
 
48
+ ## Experiments
49
+
50
+ Durable records of engineering experiments (including reverted ones, so we don't re-run them) live in [`docs/experiments/`](docs/experiments/README.md).
51
+
48
52
  ---
49
53
 
50
54
  ## Concepts
@@ -125,13 +125,16 @@ async function checkSimulator() {
125
125
  catch {
126
126
  return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "fail", message: "could not parse simctl output" };
127
127
  }
128
- if (booted.length === 1) {
129
- return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "pass", message: `${booted[0].name} (booted)` };
128
+ if (booted.length >= 1) {
129
+ // >1 booted is fine now: a parallel run (`--parallel N`) reuses booted
130
+ // simulators and clones the shortfall, so extras are a head start, not a
131
+ // problem. A single-device (non-parallel) run still needs exactly one.
132
+ const extra = booted.length > 1
133
+ ? ` (+${booted.length - 1} more — parallel runs pool them; a single-device run needs exactly one)`
134
+ : "";
135
+ return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "pass", message: `${booted[0].name} (booted)${extra}` };
130
136
  }
131
- if (booted.length === 0) {
132
- 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>`" };
133
- }
134
- 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" };
137
+ 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>`" };
135
138
  }
136
139
  /** True when the prebuilt XCUITest runner (WebDriverAgent `.app`) is present. */
137
140
  function wdaBundlePresent() {
@@ -172,11 +175,18 @@ async function checkAdb() {
172
175
  .slice(1)
173
176
  .map((l) => l.trim())
174
177
  .filter((l) => l.endsWith("\tdevice"));
175
- const emulator = devices.length === 1
176
- ? { key: "android_emulator", name: "Android emulator", group: "Android", status: "pass", message: devices[0].split("\t")[0] }
177
- : devices.length === 0
178
- ? { 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>`)" }
179
- : { key: "android_emulator", name: "Android emulator", group: "Android", status: "warn", message: `${devices.length} online — native runs drive exactly one`, fix: "Stop the extras" };
178
+ // >1 online is fine now: `--parallel` pools emulators (and auto-launches more
179
+ // from your AVDs). A single-device (non-parallel) run still uses exactly one.
180
+ const emulator = devices.length >= 1
181
+ ? {
182
+ key: "android_emulator",
183
+ name: "Android emulator",
184
+ group: "Android",
185
+ status: "pass",
186
+ message: devices[0].split("\t")[0] +
187
+ (devices.length > 1 ? ` (+${devices.length - 1} more — parallel runs pool them)` : ""),
188
+ }
189
+ : { key: "android_emulator", name: "Android emulator", group: "Android", status: "warn", message: "none online (parallel runs auto-launch your AVDs)", fix: "Create an AVD in Android Studio > Device Manager (or `emulator -avd <name>`)" };
180
190
  return { adb, emulator };
181
191
  }
182
192
  async function checkChromium() {
@@ -0,0 +1,22 @@
1
+ /**
2
+ * ish feedback — report a bug, request a feature, or send any other feedback
3
+ * to the ish team straight from the terminal.
4
+ *
5
+ * Transport: posts to the ish backend (`POST /api/v1/product-feedback` via
6
+ * ApiClient) with the user's bearer token. The backend inserts into the
7
+ * `product_feedback` table — the same table the web app's "Send feedback"
8
+ * dialog writes to — using its privileged connection, so CLI reports show up
9
+ * alongside web feedback. Per ADR-0029 (ish-frontend), clients submit through
10
+ * api.ishlabs.io, NEVER via Supabase PostgREST directly: the `sb_publishable_*`
11
+ * key shipped in this binary is deliberately powerless against the database.
12
+ * (The web flow additionally mirrors to Notion + captures PostHog server-side;
13
+ * those are deferred for the CLI.)
14
+ *
15
+ * Diagnostics: to help whoever triages the report, the user's message is
16
+ * augmented with an environment + active-config block (opt out with
17
+ * --no-diagnostics), and optionally (--health) the `ish check` setup checklist
18
+ * plus the tail of ~/.ish/local-sim.log. The reporter's identity (user_id /
19
+ * user_email) is attached server-side from the JWT, not here.
20
+ */
21
+ import type { Command } from "commander";
22
+ export declare function registerFeedbackCommands(program: Command): void;
@@ -0,0 +1,259 @@
1
+ /**
2
+ * ish feedback — report a bug, request a feature, or send any other feedback
3
+ * to the ish team straight from the terminal.
4
+ *
5
+ * Transport: posts to the ish backend (`POST /api/v1/product-feedback` via
6
+ * ApiClient) with the user's bearer token. The backend inserts into the
7
+ * `product_feedback` table — the same table the web app's "Send feedback"
8
+ * dialog writes to — using its privileged connection, so CLI reports show up
9
+ * alongside web feedback. Per ADR-0029 (ish-frontend), clients submit through
10
+ * api.ishlabs.io, NEVER via Supabase PostgREST directly: the `sb_publishable_*`
11
+ * key shipped in this binary is deliberately powerless against the database.
12
+ * (The web flow additionally mirrors to Notion + captures PostHog server-side;
13
+ * those are deferred for the CLI.)
14
+ *
15
+ * Diagnostics: to help whoever triages the report, the user's message is
16
+ * augmented with an environment + active-config block (opt out with
17
+ * --no-diagnostics), and optionally (--health) the `ish check` setup checklist
18
+ * plus the tail of ~/.ish/local-sim.log. The reporter's identity (user_id /
19
+ * user_email) is attached server-side from the JWT, not here.
20
+ */
21
+ import { existsSync, readFileSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+ import pkg from "../../package.json" with { type: "json" };
25
+ import { loadConfig } from "../config.js";
26
+ import { withClient } from "../lib/command-helpers.js";
27
+ import { resolveApiUrl } from "../lib/auth.js";
28
+ import { output } from "../lib/output.js";
29
+ import { c } from "../lib/colors.js";
30
+ const { version: CLI_VERSION } = pkg;
31
+ const FEEDBACK_TYPES = ["bug", "feature", "other"];
32
+ const DESCRIPTION_MIN = 10;
33
+ const DESCRIPTION_MAX = 4000;
34
+ const TITLE_MIN = 3;
35
+ const TITLE_MAX = 120;
36
+ const TYPE_NOUN = {
37
+ bug: "Bug report",
38
+ feature: "Feature request",
39
+ other: "Feedback",
40
+ };
41
+ function validationError(message) {
42
+ // `name === "ValidationError"` maps to exit code 2 in exitCodeFromError.
43
+ const err = new Error(message);
44
+ err.name = "ValidationError";
45
+ return err;
46
+ }
47
+ function readStdin() {
48
+ return new Promise((resolve, reject) => {
49
+ let data = "";
50
+ process.stdin.setEncoding("utf-8");
51
+ process.stdin.on("data", (chunk) => {
52
+ data += chunk;
53
+ });
54
+ process.stdin.on("end", () => resolve(data));
55
+ process.stdin.on("error", reject);
56
+ });
57
+ }
58
+ /**
59
+ * Resolve the feedback body from the positional argument, an `@file`
60
+ * reference, or piped stdin — the same input idioms the rest of the CLI uses
61
+ * for text fields (no interactive prompt; the CLI is for autonomous agents).
62
+ */
63
+ async function resolveDescription(message) {
64
+ if (message && message.startsWith("@")) {
65
+ const path = message.slice(1);
66
+ try {
67
+ return readFileSync(path, "utf-8");
68
+ }
69
+ catch {
70
+ throw new Error(`Cannot read file: ${path}`);
71
+ }
72
+ }
73
+ // Explicit stdin sentinel, or no argument with something piped in.
74
+ if (message === "-" || (message === undefined && !process.stdin.isTTY)) {
75
+ return readStdin();
76
+ }
77
+ if (message === undefined) {
78
+ throw validationError('No feedback message. Pass it as an argument, pipe it on stdin, or use @file:\n' +
79
+ ' ish feedback "the save button on the profile page does nothing"\n' +
80
+ ' ish feedback --type feature "let me export interactions as CSV"\n' +
81
+ " ish feedback @bug-report.md\n" +
82
+ ' echo "..." | ish feedback -');
83
+ }
84
+ return message;
85
+ }
86
+ function deriveTitle(explicit, description, type) {
87
+ if (explicit !== undefined) {
88
+ const t = explicit.trim();
89
+ if (t.length < TITLE_MIN) {
90
+ throw validationError(`--title must be at least ${TITLE_MIN} characters.`);
91
+ }
92
+ return t.slice(0, TITLE_MAX);
93
+ }
94
+ // Mirror the web dialog: first non-empty line of the message becomes the
95
+ // title, capped to TITLE_MAX. Fall back to a generic title so the table's
96
+ // NOT NULL + min-length contract is always satisfied even for terse bodies.
97
+ const firstLine = description
98
+ .split("\n")
99
+ .map((l) => l.trim())
100
+ .find((l) => l.length > 0) ?? "";
101
+ const candidate = firstLine.slice(0, TITLE_MAX).trim();
102
+ return candidate.length >= TITLE_MIN ? candidate : `${TYPE_NOUN[type]} from CLI`;
103
+ }
104
+ // ── Diagnostics ────────────────────────────────────────────────────────────
105
+ //
106
+ // Appended to the message so whoever debugs the report has grounded context.
107
+ // None of this needs the JWT — the backend attaches the reporter's identity
108
+ // from the token. The aim is maximal useful signal without a schema change.
109
+ /** Always-on, instant one-liner: the bare minimum a debugger needs. */
110
+ function platformLine(dev) {
111
+ return `Sent via ish CLI v${CLI_VERSION} · ${process.platform} ${process.arch} · node ${process.versions.node}${dev ? " · dev" : ""}`;
112
+ }
113
+ /** Active-config context (instant; no subprocesses). Default-on. */
114
+ function diagnosticsBlock(apiUrl) {
115
+ const cfg = loadConfig();
116
+ const lines = [
117
+ "Diagnostics (auto-attached; pass --no-diagnostics to omit)",
118
+ `- Active workspace: ${cfg.workspace ?? "none"}`,
119
+ `- Active study: ${cfg.study ?? "none"}`,
120
+ `- Active ask: ${cfg.ask ?? "none"}`,
121
+ `- API: ${apiUrl}`,
122
+ ];
123
+ return lines.join("\n");
124
+ }
125
+ /** Setup/health checklist, reusing `ish check`'s probes. Heavier (subprocess
126
+ * probes for xcrun/adb), so gated behind --health. */
127
+ async function healthBlock() {
128
+ try {
129
+ const { runChecks } = await import("./doctor.js");
130
+ const checks = await runChecks();
131
+ const MARK = {
132
+ pass: "OK",
133
+ warn: "WARN",
134
+ fail: "FAIL",
135
+ skip: "--",
136
+ };
137
+ const lines = checks.map((ch) => {
138
+ const fix = (ch.status === "warn" || ch.status === "fail") && ch.fix
139
+ ? ` (fix: ${ch.fix})`
140
+ : "";
141
+ return `- [${MARK[ch.status] ?? ch.status}] ${ch.name}: ${ch.message ?? ""}${fix}`.trimEnd();
142
+ });
143
+ return ["Setup checks (ish check):", ...lines].join("\n");
144
+ }
145
+ catch (e) {
146
+ return `Setup checks: could not run (${e instanceof Error ? e.message : String(e)}).`;
147
+ }
148
+ }
149
+ /** Tail of the local-simulation debug log, when present. Written to
150
+ * ~/.ish/local-sim.log by `study run --local --debug`. */
151
+ function localSimLogTail(maxLines = 80) {
152
+ // Match the literal path debug.ts writes to (it uses homedir()/.ish, not the
153
+ // ISH_HOME-aware paths helper), so we read exactly where the log lands.
154
+ const logFile = join(homedir(), ".ish", "local-sim.log");
155
+ if (!existsSync(logFile))
156
+ return undefined;
157
+ try {
158
+ const tail = readFileSync(logFile, "utf-8").split("\n").slice(-maxLines).join("\n").trim();
159
+ if (!tail)
160
+ return undefined;
161
+ return [
162
+ `Recent local-sim log (last ${maxLines} lines of ~/.ish/local-sim.log):`,
163
+ "```",
164
+ tail,
165
+ "```",
166
+ ].join("\n");
167
+ }
168
+ catch {
169
+ return undefined;
170
+ }
171
+ }
172
+ /** Assemble everything appended to the user's message. */
173
+ async function buildAppendix(opts) {
174
+ const sections = [platformLine(opts.dev)];
175
+ if (opts.diagnostics)
176
+ sections.push(diagnosticsBlock(opts.apiUrl));
177
+ if (opts.health) {
178
+ sections.push(await healthBlock());
179
+ const log = localSimLogTail();
180
+ if (log)
181
+ sections.push(log);
182
+ }
183
+ return "\n\n—\n" + sections.join("\n\n");
184
+ }
185
+ export function registerFeedbackCommands(program) {
186
+ program
187
+ .command("feedback")
188
+ .description("Report a bug or send feedback to the ish team")
189
+ .argument("[message]", "Feedback text. Use @path to read from a file, or - to read from stdin.")
190
+ .option("--type <type>", "Feedback type: bug | feature | other", "bug")
191
+ .option("--title <title>", "Short title (default: first line of the message)")
192
+ .option("--no-diagnostics", "Don't attach environment/account diagnostics to the report")
193
+ .option("--health", "Also run setup checks (ish check) and attach recent local-sim logs — useful for simulation/setup bugs")
194
+ .option("--dry-run", "Print exactly what would be sent (including diagnostics) without submitting")
195
+ .addHelpText("after", `\nExamples:
196
+ $ ish feedback "the save button on the profile page does nothing"
197
+ $ ish feedback --type feature "let me export interactions as CSV"
198
+ $ ish feedback --type other "the guided setup was great"
199
+ $ ish feedback @bug-report.md
200
+ $ ish study run … --local --debug 2>err.txt; ish feedback @err.txt --health
201
+ $ git log -1 --oneline | ish feedback -
202
+
203
+ Reports go to the ish team and show up alongside web feedback. Sends as the
204
+ logged-in user (run \`ish login\` first). bug is the default type. To give
205
+ whoever debugs it the most signal, attach the failing command's output via
206
+ @file or stdin; environment + active-config diagnostics are attached
207
+ automatically (opt out with --no-diagnostics), and --health adds setup
208
+ checks + local-sim logs.`)
209
+ .action(async (message, opts, cmd) => {
210
+ await withClient(cmd, async (client, globals) => {
211
+ const type = opts.type;
212
+ if (!FEEDBACK_TYPES.includes(type)) {
213
+ throw validationError(`Invalid --type "${opts.type}". Must be one of: ${FEEDBACK_TYPES.join(", ")}.`);
214
+ }
215
+ const rawDescription = await resolveDescription(message);
216
+ const description = rawDescription.trim();
217
+ if (description.length === 0) {
218
+ throw validationError('No feedback message. Pass it as an argument, pipe it on stdin, or use @file:\n' +
219
+ ' ish feedback "the save button on the profile page does nothing"\n' +
220
+ ' ish feedback --type feature "let me export interactions as CSV"\n' +
221
+ " ish feedback @bug-report.md");
222
+ }
223
+ if (description.length < DESCRIPTION_MIN) {
224
+ throw validationError(`Please add a bit more detail (at least ${DESCRIPTION_MIN} characters).`);
225
+ }
226
+ if (description.length > DESCRIPTION_MAX) {
227
+ throw validationError(`Feedback is too long (${description.length} chars; max ${DESCRIPTION_MAX}).`);
228
+ }
229
+ const title = deriveTitle(opts.title, description, type);
230
+ const apiUrl = resolveApiUrl(globals.apiUrl, globals.dev);
231
+ const appendix = await buildAppendix({
232
+ apiUrl,
233
+ dev: globals.dev,
234
+ diagnostics: opts.diagnostics,
235
+ health: opts.health ?? false,
236
+ });
237
+ const body = {
238
+ type,
239
+ title,
240
+ description: description + appendix,
241
+ };
242
+ if (opts.dryRun) {
243
+ if (!globals.quiet) {
244
+ console.error(`${c.dim}Dry run — nothing sent. This is what would be submitted:${c.reset}`);
245
+ }
246
+ output(body, globals.json);
247
+ return;
248
+ }
249
+ if (!globals.quiet) {
250
+ console.error(`Sending ${type} report…`);
251
+ }
252
+ const data = await client.post("/product-feedback", body);
253
+ if (!globals.quiet) {
254
+ console.error(`${c.green}✓${c.reset} Thanks! Your ${type} report was sent to the ish team.`);
255
+ }
256
+ output(data, globals.json, { writePath: true });
257
+ });
258
+ });
259
+ }
@@ -9,7 +9,7 @@ import { readFileSync } from "node:fs";
9
9
  import { withClient, resolveStudy, resolveWorkspace, readFileOrStdin, collectIds } from "../lib/command-helpers.js";
10
10
  import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
11
11
  import { output, formatIterationList, ValidationError } from "../lib/output.js";
12
- import { resolveContentUrl, resolveContentUrls, resolveTextContent } from "../lib/upload.js";
12
+ import { resolveContentUrl, resolveContentUrls, resolveTextContent, archiveHtmlImages } from "../lib/upload.js";
13
13
  import { isMediaModality, validateIterationDetails, normalizeChatMode, validateRoleCriteria } from "../lib/modality.js";
14
14
  import { validateSegmentation, warnIfOverSegmented } from "../lib/segmentation.js";
15
15
  import { normalizeEnumValue, SCREEN_FORMATS } from "../lib/enums.js";
@@ -297,7 +297,9 @@ function buildIterationDetails(modality, opts) {
297
297
  if (opts.platform === "figma" && (!opts.fileKey || !opts.startNodeId)) {
298
298
  throw new Error("Figma interactive iterations require both --file-key and --start-node-id.");
299
299
  }
300
- let screenFormat = "desktop";
300
+ // Native (ios/android) targets are phones — default to mobile_portrait
301
+ // rather than desktop. An explicit --screen-format still wins below.
302
+ let screenFormat = isNativePlatform(opts.platform) ? "mobile_portrait" : "desktop";
301
303
  if (opts.screenFormat !== undefined) {
302
304
  const normalized = normalizeEnumValue(opts.screenFormat, SCREEN_FORMATS);
303
305
  if (normalized === null) {
@@ -385,7 +387,7 @@ Concept pages: ish docs get-page concepts/iteration
385
387
  .option("--platform <platform>", "Platform (browser, android, ios, figma, code) — interactive only")
386
388
  .option("--url <url>", "URL to test — interactive only (optional for ios/android native apps)")
387
389
  .option("--app <id>", "Native app bundle id (or .app/.apk path) — ios/android; supplies the iteration target so --url isn't required")
388
- .option("--screen-format <format>", "Screen format (mobile_portrait, desktop) — interactive only; hyphen/underscore variants accepted")
390
+ .option("--screen-format <format>", "Screen format (mobile_portrait, desktop) — interactive only; hyphen/underscore variants accepted. Default: desktop, or mobile_portrait for native ios/android")
389
391
  .option("--locale <locale>", "Locale code (e.g. en-US) — interactive only")
390
392
  .option("--file-key <key>", "Figma file key — required when --platform=figma")
391
393
  .option("--start-node-id <id>", "Figma start node id — required when --platform=figma")
@@ -622,8 +624,15 @@ Next: \`ish study run\` to dispatch simulations against this iteration.`)
622
624
  if (isMedia) {
623
625
  if (resolved.contentText)
624
626
  resolved.contentText = resolveTextContent(resolved.contentText);
625
- if (resolved.contentHtml)
627
+ if (resolved.contentHtml) {
626
628
  resolved.contentHtml = resolveTextContent(resolved.contentHtml);
629
+ // Archive external <img> images onto workspace storage so the
630
+ // render-to-image worker (egress-denied to other origins) can
631
+ // fetch them. Mirrors the FE paste pipeline; text modality only.
632
+ if (modality === "text") {
633
+ resolved.contentHtml = await archiveHtmlImages(client, studyId, resolved.contentHtml, { quiet: globals.quiet });
634
+ }
635
+ }
627
636
  if (resolved.copyText)
628
637
  resolved.copyText = resolveTextContent(resolved.copyText);
629
638
  if (resolved.copyHtml)
@@ -16,13 +16,10 @@ import { fetchStudyParticipants } from "../lib/study-participants.js";
16
16
  import { streamStudyEvents } from "../lib/study-events.js";
17
17
  import { isMediaModality, isChatModality, iterationHasContent, describeRequiredContentFlag, readChatMode, readParticipantPairConfig, summarizeRoleCriteria, toModality, } from "../lib/modality.js";
18
18
  // NOTE: local-sim modules are loaded via dynamic import at the `--local`
19
- // branch below, NOT statically here. `local-sim/install.ts` deep-imports
20
- // `playwright-core/lib/server/registry/index`, which is not exposed by
21
- // playwright-core's `exports` map Node refuses to resolve it during
22
- // module load (ERR_PACKAGE_PATH_NOT_EXPORTED), so a static import here
23
- // would crash *every* `ish` invocation on the npm-installed CLI, not
24
- // just `study run --local`. The bun-compiled binary bundles the deep
25
- // path so it doesn't hit Node's resolver; only the npm path is sensitive.
19
+ // branch below, NOT statically here, so that plain API commands never pay
20
+ // for (or crash on) playwright-core. The registry deep import inside
21
+ // `local-sim/install.ts` is itself lazy for the same reason — see the
22
+ // comment in `installBrowser()`.
26
23
  import { estimateChatPair, estimateChatSolo, estimateMediaRun } from "../lib/billing.js";
27
24
  import { reportReadiness } from "../lib/report-readiness.js";
28
25
  import { runChecks, scopeChecks, overall } from "./doctor.js";
@@ -324,7 +321,7 @@ export function attachStudyRunCommands(study) {
324
321
  .option("--slow-mo <ms>", "Slow down actions by ms (local mode only)")
325
322
  .option("--devtools", "Open Chrome DevTools (local mode only)")
326
323
  .option("--debug", "Enable detailed debug logging to stderr and ~/.ish/local-sim.log")
327
- .option("--parallel <n>", "Run N participants in parallel (local mode only, default: all)")
324
+ .option("--parallel <n>", "Run N participants in parallel (local mode only). Browser: default all. Native iOS/Android: pools N auto-provisioned devices — simulators (iOS) / headless emulators from your AVDs (Android) — default 1, capped at 5, auto-sized to host RAM (and AVD count).")
328
325
  .option("--platform <platform>", "Local target platform: 'web' (Playwright), 'android' (adb emulator), or 'ios' (simctl+idb simulator). Defaults to the iteration's platform.")
329
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.")
330
327
  .addHelpText("after", `
@@ -737,10 +734,6 @@ Examples:
737
734
  }
738
735
  log("");
739
736
  }
740
- if (opts.local) {
741
- const { ensureBrowser } = await import("../lib/local-sim/install.js");
742
- await ensureBrowser({ quiet: globals.quiet, skipPrompt: globals.json });
743
- }
744
737
  // Step 5: Either reuse the iteration's participants or batch-create new ones
745
738
  let createdParticipants;
746
739
  // Pair-mode bookkeeping: the dispatch endpoint takes
@@ -763,6 +756,13 @@ Examples:
763
756
  ?? platformFromApp
764
757
  ?? detailsView.platform
765
758
  ?? "browser";
759
+ // Chromium is only needed for the browser local path. iOS/Android
760
+ // local runs drive a simulator/emulator and must not block on (or
761
+ // prompt for) a browser download.
762
+ if (opts.local && normalizePlatform(resolvedPlatform) === "browser") {
763
+ const { ensureBrowser } = await import("../lib/local-sim/install.js");
764
+ await ensureBrowser({ quiet: globals.quiet, skipPrompt: globals.json });
765
+ }
766
766
  // Best-effort native-readiness report. When this is a LOCAL native run
767
767
  // (iOS/Android driven on this developer's machine), fire-and-forget a
768
768
  // fresh, platform-scoped `runChecks()` to the backend so the web app
@@ -23,14 +23,16 @@ import { resolveId } from "../lib/alias-store.js";
23
23
  import { output, printTable } from "../lib/output.js";
24
24
  import { ApiError } from "../lib/api-client.js";
25
25
  /**
26
- * Server-side screenshots are produced by remote interactive runs only. A
27
- * study whose only runs were local (`ish study run --local`) has none — and the
28
- * grouped endpoint currently 500s instead of returning an empty index. Tag this
29
- * hint onto the error so the bare 500 points the user at the local debug report.
26
+ * The frame-grouped screenshot INDEX (`/screenshots/grouped`) is a remote-run
27
+ * artifact it groups by frame_version_id, which local runs don't create — and
28
+ * the endpoint currently 500s for a local-only study instead of returning an
29
+ * empty index. Local (`--local`) runs DO still capture per-interaction
30
+ * screenshots; they just live on the participant rows, not in this index. Tag
31
+ * this hint onto the error so the bare 500 points the user at where they ARE.
30
32
  */
31
33
  const LOCAL_RUN_SCREENSHOT_HINT = [
32
- "Screenshots are produced by remote runs only.",
33
- "Ran this study locally (--local)? The per-step screenshots are in the HTML debug report under ~/.ish/debug/ (path printed at the end of each local run).",
34
+ "The frame-grouped screenshot index is a remote-run artifact (this endpoint may 500 for local-only studies).",
35
+ "Ran this study locally (--local)? Per-interaction screenshots ARE captured — read them via `ish study get <id>` (each interaction carries a screenshot_url), or open the per-step HTML debug report under ~/.ish/debug/ (path printed at the end of each local run).",
34
36
  ];
35
37
  /**
36
38
  * GET the frame-grouped screenshot index, tagging the local-run hint onto any
@@ -136,12 +138,13 @@ Examples:
136
138
  $ ish study screenshots download <study-id> --id <scid> --out shot.png
137
139
  $ ish study screenshots download <study-id> --all --out ./shots/
138
140
 
139
- Screenshots are produced server-side by remote interactive runs only — chat /
140
- video / text studies don't have them, and neither do local runs
141
- (\`ish study run --local\`), which instead write a per-step HTML debug report to
142
- ~/.ish/debug/ (the path is printed at the end of each local run). Each row's
143
- storage URL is self-credentialed, so the CLI fetches bytes without forwarding
144
- your bearer.`);
141
+ This frame-grouped index is built by remote interactive runs — chat / video /
142
+ text studies don't populate it, and neither do local runs (\`ish study run
143
+ --local\`). Local runs still CAPTURE per-interaction screenshots: read them via
144
+ \`ish study get <id>\` (each interaction carries a screenshot_url) or the per-step
145
+ HTML debug report under ~/.ish/debug/ (path printed at the end of each local
146
+ run). Each row's storage URL is self-credentialed, so the CLI fetches bytes
147
+ without forwarding your bearer.`);
145
148
  screenshots
146
149
  .command("list", { isDefault: true })
147
150
  .description("List screenshots for a study (frame-grouped).")
@@ -350,8 +350,20 @@ Next: configure a run with \`ish iteration create --study <id>\`,
350
350
  validateSegmentation(inlineMediaExtras.segmentation);
351
351
  warnIfOverSegmented(inlineMediaExtras.segmentation, { quiet: globals.quietExplicit });
352
352
  }
353
+ let inlineContentHtml;
354
+ if (opts.contentHtml) {
355
+ inlineContentHtml = opts.contentHtml.startsWith("@")
356
+ ? readFileSync(opts.contentHtml.slice(1), "utf8")
357
+ : opts.contentHtml;
358
+ // The study does not exist yet here, so we cannot archive remote
359
+ // images onto workspace storage (the render worker egress-denies
360
+ // other origins). Point the operator at the archive-capable flow.
361
+ if (/<img\b[^>]*\bsrc\s*=\s*["']https?:\/\//i.test(inlineContentHtml) && !globals.quietExplicit) {
362
+ process.stderr.write("Note: --content-html has remote <img> images, which `study create` cannot archive (the study does not exist yet) — they will not render. To archive them, run `ish study create` without content, then `ish iteration create --content-html ...`.\n");
363
+ }
364
+ }
353
365
  const inlineEmailExtras = {
354
- ...(opts.contentHtml && { content_html: opts.contentHtml.startsWith("@") ? readFileSync(opts.contentHtml.slice(1), "utf8") : opts.contentHtml }),
366
+ ...(inlineContentHtml !== undefined && { content_html: inlineContentHtml }),
355
367
  ...(opts.senderName && { sender_name: opts.senderName }),
356
368
  ...(opts.senderEmail && { sender_email: opts.senderEmail }),
357
369
  ...(opts.featuredImageUrl && { featured_image_url: opts.featuredImageUrl }),
@@ -1244,13 +1256,20 @@ checklists ("steps") ride along when present in the JSON forms
1244
1256
  if (!id) {
1245
1257
  throw new Error("Provide a study alias or UUID, or use --clear.");
1246
1258
  }
1247
- await withClient(cmd, async (client) => {
1259
+ await withClient(cmd, async (client, globals) => {
1248
1260
  const rid = resolveId(id);
1249
1261
  const data = await client.get(`/studies/${rid}`);
1250
1262
  const config = loadConfig();
1251
1263
  config.study = rid;
1252
1264
  saveConfig(config);
1253
- console.error(`Active study set to "${data.name || rid}".`);
1265
+ // stdout = data: emit a JSON object so `study use --json` is capturable
1266
+ // (e.g. `--get alias`); the human confirmation stays on stderr.
1267
+ if (globals.json) {
1268
+ output({ id: rid, alias: tagAlias(ALIAS_PREFIX.study, rid), name: data.name ?? null, active: true }, true, { writePath: true });
1269
+ }
1270
+ else {
1271
+ console.error(`Active study set to "${data.name || rid}".`);
1272
+ }
1254
1273
  });
1255
1274
  });
1256
1275
  attachStudyRunCommands(study);
@@ -296,7 +296,7 @@ async function collectWorkspaceUsage(client, workspaceId) {
296
296
  people_used: typeof participants.total === "number" ? participants.total : 0,
297
297
  people_max: lookupLimit("maxCustomPersons"),
298
298
  concurrent_participants_max: lookupLimit("maxConcurrentParticipants"),
299
- workspace_members_max: lookupLimit("maxWorkspaceMembers"),
299
+ workspace_members_max: lookupLimit("maxSeats"),
300
300
  };
301
301
  }
302
302
  // ---------------------------------------------------------------------------
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import { registerInitCommands } from "./commands/init.js";
20
20
  import { registerMcpCommands } from "./commands/mcp.js";
21
21
  import { registerSecretCommands } from "./commands/secret.js";
22
22
  import { registerDoctorCommands } from "./commands/doctor.js";
23
+ import { registerFeedbackCommands } from "./commands/feedback.js";
23
24
  import { AGENT_HELP_FOOTER } from "./lib/docs.js";
24
25
  import { runInline, EXIT_USAGE, injectGlobalWorkspaceOption } from "./lib/command-helpers.js";
25
26
  import { resolveApiUrl, resolveToken, verifyToken } from "./lib/auth.js";
@@ -512,6 +513,7 @@ registerInitCommands(program);
512
513
  registerMcpCommands(program);
513
514
  registerSecretCommands(program);
514
515
  registerDoctorCommands(program);
516
+ registerFeedbackCommands(program);
515
517
  program
516
518
  .command("upgrade")
517
519
  .description("Update ish to the latest version")
@@ -6,7 +6,7 @@ import * as fs from "node:fs";
6
6
  import { resolveApiUrl, resolveToken } from "./auth.js";
7
7
  import { getAppUrl } from "../auth.js";
8
8
  import { ApiClient, ApiError } from "./api-client.js";
9
- import { outputError, setVerbose, setFields, setGetField } from "./output.js";
9
+ import { outputError, shouldSuggestReport, setVerbose, setFields, setGetField } from "./output.js";
10
10
  import { setColorsEnabled, colorsEnabled } from "./colors.js";
11
11
  import { loadConfig } from "../config.js";
12
12
  import { resolveId } from "./alias-store.js";
@@ -513,7 +513,10 @@ export async function withClient(cmd, fn) {
513
513
  await fn(client, globals);
514
514
  }
515
515
  catch (err) {
516
- outputError(err, globals.json);
516
+ // Nudge to report genuine faults — but never when the failing command
517
+ // IS `ish feedback` (don't suggest reporting via the thing that broke).
518
+ const suggestReport = shouldSuggestReport(err) && commandPath(cmd).split(".")[0] !== "feedback";
519
+ outputError(err, globals.json, { suggestReport });
517
520
  await exitWithFlush(exitCodeFromError(err));
518
521
  }
519
522
  });
@@ -530,7 +533,8 @@ export async function runInline(cmd, fn) {
530
533
  await fn(globals);
531
534
  }
532
535
  catch (err) {
533
- outputError(err, globals.json);
536
+ const suggestReport = shouldSuggestReport(err) && commandPath(cmd).split(".")[0] !== "feedback";
537
+ outputError(err, globals.json, { suggestReport });
534
538
  await exitWithFlush(exitCodeFromError(err));
535
539
  }
536
540
  });