@ishlabs/cli 0.8.1 → 0.8.3

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 (70) hide show
  1. package/README.md +323 -21
  2. package/dist/auth.d.ts +17 -1
  3. package/dist/auth.js +62 -9
  4. package/dist/commands/ask.d.ts +5 -0
  5. package/dist/commands/ask.js +722 -0
  6. package/dist/commands/config.js +25 -1
  7. package/dist/commands/docs.d.ts +17 -0
  8. package/dist/commands/docs.js +147 -0
  9. package/dist/commands/init.d.ts +16 -0
  10. package/dist/commands/init.js +182 -0
  11. package/dist/commands/iteration.d.ts +5 -1
  12. package/dist/commands/iteration.js +243 -31
  13. package/dist/commands/profile.d.ts +5 -0
  14. package/dist/commands/profile.js +313 -0
  15. package/dist/commands/source.d.ts +10 -0
  16. package/dist/commands/source.js +78 -0
  17. package/dist/commands/study-run.d.ts +11 -0
  18. package/dist/commands/study-run.js +552 -0
  19. package/dist/commands/study-tester.d.ts +8 -0
  20. package/dist/commands/study-tester.js +149 -0
  21. package/dist/commands/study.js +145 -70
  22. package/dist/commands/workspace.js +193 -7
  23. package/dist/config.d.ts +3 -1
  24. package/dist/config.js +10 -10
  25. package/dist/connect.d.ts +4 -1
  26. package/dist/connect.js +127 -94
  27. package/dist/index.js +82 -34
  28. package/dist/lib/alias-store.d.ts +3 -0
  29. package/dist/lib/alias-store.js +9 -7
  30. package/dist/lib/api-client.d.ts +9 -6
  31. package/dist/lib/api-client.js +87 -26
  32. package/dist/lib/ask-questions.d.ts +9 -0
  33. package/dist/lib/ask-questions.js +35 -0
  34. package/dist/lib/ask-variants.d.ts +48 -0
  35. package/dist/lib/ask-variants.js +236 -0
  36. package/dist/lib/auth.d.ts +1 -1
  37. package/dist/lib/auth.js +24 -8
  38. package/dist/lib/colors.d.ts +30 -0
  39. package/dist/lib/colors.js +48 -0
  40. package/dist/lib/command-helpers.d.ts +74 -0
  41. package/dist/lib/command-helpers.js +232 -6
  42. package/dist/lib/docs.d.ts +32 -0
  43. package/dist/lib/docs.js +930 -0
  44. package/dist/lib/local-sim/browser.d.ts +0 -1
  45. package/dist/lib/local-sim/browser.js +0 -2
  46. package/dist/lib/local-sim/install.d.ts +2 -12
  47. package/dist/lib/local-sim/install.js +22 -30
  48. package/dist/lib/output.d.ts +25 -3
  49. package/dist/lib/output.js +465 -20
  50. package/dist/lib/paths.d.ts +14 -0
  51. package/dist/lib/paths.js +36 -0
  52. package/dist/lib/profile-sources.d.ts +55 -0
  53. package/dist/lib/profile-sources.js +157 -0
  54. package/dist/lib/site-access.d.ts +80 -0
  55. package/dist/lib/site-access.js +188 -0
  56. package/dist/lib/skill-content.d.ts +31 -0
  57. package/dist/lib/skill-content.js +462 -0
  58. package/dist/lib/study-inputs.d.ts +20 -0
  59. package/dist/lib/study-inputs.js +72 -0
  60. package/dist/lib/types.d.ts +207 -9
  61. package/dist/lib/types.js +7 -0
  62. package/dist/lib/upload.js +2 -2
  63. package/dist/upgrade.js +11 -1
  64. package/package.json +3 -2
  65. package/dist/commands/simulation.d.ts +0 -10
  66. package/dist/commands/simulation.js +0 -647
  67. package/dist/commands/tester-profile.d.ts +0 -5
  68. package/dist/commands/tester-profile.js +0 -109
  69. package/dist/commands/tester.d.ts +0 -5
  70. package/dist/commands/tester.js +0 -73
@@ -0,0 +1,78 @@
1
+ /**
2
+ * ish source — Upload and inspect audience-generation sources.
3
+ *
4
+ * Sources (transcripts, audio, images, PDFs) are inputs to `ish profile
5
+ * generate`. For one-shot generation, `profile generate --source <path>`
6
+ * auto-uploads. Use these commands to upload once and reuse across multiple
7
+ * generation runs, or to inspect processing status.
8
+ */
9
+ import { withClient, resolveWorkspace } from "../lib/command-helpers.js";
10
+ import { resolveId } from "../lib/alias-store.js";
11
+ import { formatAudienceSource } from "../lib/output.js";
12
+ import { inferSourceKind, uploadAndProcessSource, } from "../lib/profile-sources.js";
13
+ const VALID_KINDS = ["text_file", "audio", "image"];
14
+ export function registerSourceCommands(program) {
15
+ const source = program
16
+ .command("source")
17
+ .description("Upload and inspect audience-generation sources (transcripts, audio, images)")
18
+ .addHelpText("after", `
19
+ A source is an input to \`ish profile generate\` — transcript, audio, image, or PDF. Use
20
+ \`source upload\` when you want to reuse the same source across multiple generation runs;
21
+ otherwise pass a local path directly to \`profile generate --source\` and it auto-uploads.
22
+
23
+ Concept pages: ish docs get-page concepts/source
24
+ ish docs get-page concepts/profile`);
25
+ source
26
+ .command("upload")
27
+ .description("Upload a file as an audience source and wait for processing")
28
+ .argument("<file>", "Local file path (transcript, audio, image, PDF, etc.)")
29
+ .option("--workspace <id>", "Workspace (product) ID; falls back to active workspace")
30
+ .option("--kind <kind>", "Source kind: text_file | audio | image (auto-detected if omitted)")
31
+ .option("--description <text>", "Context note attached to the source (max 500 chars)")
32
+ .option("--diarize", "Apply speaker diarization to audio sources (silently ignored for text/image)")
33
+ .option("--no-wait", "Don't poll until terminal status — return after confirm")
34
+ .option("--timeout <seconds>", "Poll timeout in seconds (default 300)", "300")
35
+ .addHelpText("after", `
36
+ Examples:
37
+ $ ish source upload ./transcript.txt
38
+ $ ish source upload ./call.mp3 --diarize --description "Q3 churn interview"
39
+ $ ish source upload ./screenshot.png --kind image --no-wait`)
40
+ .action(async (file, opts, cmd) => {
41
+ await withClient(cmd, async (client, globals) => {
42
+ const productId = resolveWorkspace(opts.workspace);
43
+ let kind;
44
+ if (opts.kind) {
45
+ if (!VALID_KINDS.includes(opts.kind)) {
46
+ throw new Error(`Invalid --kind "${opts.kind}". Valid: ${VALID_KINDS.join(", ")}`);
47
+ }
48
+ kind = opts.kind;
49
+ }
50
+ else {
51
+ kind = inferSourceKind(file);
52
+ }
53
+ const timeoutMs = Math.max(1, parseInt(opts.timeout, 10)) * 1000;
54
+ const src = await uploadAndProcessSource(client, {
55
+ productId,
56
+ filePath: file,
57
+ kind,
58
+ description: opts.description,
59
+ diarize: opts.diarize,
60
+ wait: opts.wait !== false,
61
+ timeoutMs,
62
+ quiet: globals.quiet,
63
+ });
64
+ formatAudienceSource(src, globals.json);
65
+ });
66
+ });
67
+ source
68
+ .command("get")
69
+ .description("Get an audience source's current status")
70
+ .argument("<id>", "Source ID or alias")
71
+ .addHelpText("after", "\nExamples:\n $ ish source get tps-3a4")
72
+ .action(async (id, _opts, cmd) => {
73
+ await withClient(cmd, async (client, globals) => {
74
+ const src = await client.get(`/tester-profiles/sources/${resolveId(id)}`);
75
+ formatAudienceSource(src, globals.json);
76
+ });
77
+ });
78
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * ish study run — Run, monitor, and cancel simulations of a study.
3
+ *
4
+ * `ish study run` creates testers for the latest (or specified) iteration
5
+ * and dispatches simulations. Iterations are created separately via
6
+ * `ish iteration create`, which carries the URL/content details.
7
+ *
8
+ * Lower-level: `study poll`, `study cancel`.
9
+ */
10
+ import type { Command } from "commander";
11
+ export declare function attachStudyRunCommands(study: Command): void;
@@ -0,0 +1,552 @@
1
+ /**
2
+ * ish study run — Run, monitor, and cancel simulations of a study.
3
+ *
4
+ * `ish study run` creates testers for the latest (or specified) iteration
5
+ * and dispatches simulations. Iterations are created separately via
6
+ * `ish iteration create`, which carries the URL/content details.
7
+ *
8
+ * Lower-level: `study poll`, `study cancel`.
9
+ */
10
+ import * as readline from "node:readline/promises";
11
+ import { withClient, getWebUrl, terminalLink, resolveWorkspace, resolveStudy, parseWaitTimeout, resolveAudienceProfileIds, addAudienceFilterFlags, hasAudienceFlags, } from "../lib/command-helpers.js";
12
+ import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
13
+ import { output, formatSimulationPoll } from "../lib/output.js";
14
+ import { MEDIA_MODALITIES } from "../lib/types.js";
15
+ import { runLocalSimulations } from "../lib/local-sim/loop.js";
16
+ import { ensureBrowser } from "../lib/local-sim/install.js";
17
+ function parseMaxInteractions(value) {
18
+ const n = parseInt(value, 10);
19
+ if (isNaN(n) || n < 1)
20
+ throw new Error(`Invalid --max-interactions value: ${value}`);
21
+ return n;
22
+ }
23
+ function parseSlowMo(value) {
24
+ const n = parseInt(value, 10);
25
+ if (isNaN(n) || n < 0)
26
+ throw new Error(`Invalid --slow-mo value: ${value}`);
27
+ return n;
28
+ }
29
+ function isMediaModality(modality) {
30
+ return !!modality && MEDIA_MODALITIES.includes(modality);
31
+ }
32
+ const POLL_INTERVAL_MS = 5_000;
33
+ const TERMINAL_STATUSES = new Set(["completed", "errored", "failed", "cancelled", "canceled"]);
34
+ function flattenTesterStatuses(iterations, only) {
35
+ const rows = [];
36
+ for (const iteration of iterations ?? []) {
37
+ for (const t of iteration.testers ?? []) {
38
+ if (only && !only.has(t.id))
39
+ continue;
40
+ rows.push({
41
+ id: t.id,
42
+ status: t.status,
43
+ tester_name: t.tester_profile?.name || "Unknown",
44
+ interaction_count: Array.isArray(t.interactions) ? t.interactions.length : 0,
45
+ ...(t.error && { error: t.error }),
46
+ ...(t.failure_reason && { error: t.failure_reason }),
47
+ });
48
+ }
49
+ }
50
+ return rows;
51
+ }
52
+ async function pollStudyUntilDone(client, opts) {
53
+ const start = Date.now();
54
+ let lastReported = "";
55
+ while (true) {
56
+ const study = await client.get(`/studies/${opts.studyId}`, undefined, { timeout: 60_000 });
57
+ const isMedia = isMediaModality(study.modality);
58
+ let iterations = study.iterations;
59
+ if (opts.iterationId) {
60
+ iterations = (iterations ?? []).filter((it) => it.id === opts.iterationId);
61
+ }
62
+ const rows = flattenTesterStatuses(iterations, opts.testerIds);
63
+ const total = rows.length;
64
+ const done = rows.filter((r) => TERMINAL_STATUSES.has(r.status)).length;
65
+ const errored = rows.filter((r) => r.status === "errored" || r.status === "failed").length;
66
+ const summary = `${done}/${total} done${errored > 0 ? `, ${errored} errored/failed` : ""}`;
67
+ if (!opts.quiet && summary !== lastReported) {
68
+ process.stderr.write(` ${summary}\n`);
69
+ lastReported = summary;
70
+ }
71
+ if (total > 0 && done === total) {
72
+ return { rows, isMedia };
73
+ }
74
+ if (Date.now() - start > opts.timeoutMs) {
75
+ throw new Error(`Timed out after ${Math.round(opts.timeoutMs / 1000)}s waiting for simulations. ` +
76
+ `Run \`ish study poll --study ${opts.studyId}\` to check status.`);
77
+ }
78
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
79
+ }
80
+ }
81
+ function readIterationDetails(details) {
82
+ if (!details)
83
+ return {};
84
+ const screenFormat = typeof details.screen_format === "string"
85
+ ? details.screen_format
86
+ : typeof details.screenFormat === "string" ? details.screenFormat : undefined;
87
+ return {
88
+ ...(typeof details.platform === "string" && { platform: details.platform }),
89
+ ...(typeof details.url === "string" && { url: details.url }),
90
+ ...(screenFormat && { screenFormat }),
91
+ ...(typeof details.locale === "string" && { locale: details.locale }),
92
+ ...(typeof details.title === "string" && { title: details.title }),
93
+ };
94
+ }
95
+ export function attachStudyRunCommands(study) {
96
+ // --- Primary: `study run` ---
97
+ const studyRun = study
98
+ .command("run")
99
+ .description("Run a study (creates testers for the latest iteration and dispatches simulations)")
100
+ .option("--workspace <id>", "Workspace ID")
101
+ .option("--study <id>", "Study ID")
102
+ .option("--iteration <id>", "Iteration to run (defaults to latest on the study)");
103
+ addAudienceFilterFlags(studyRun, {
104
+ allFlagName: "--all",
105
+ allFlagDescription: "Use every AI profile matching the filters (workspace-wide if no filters set)",
106
+ })
107
+ .option("--config <id>", "Simulation config ID (required for media unless every profile has one)")
108
+ .option("--max-interactions <n>", "Max interactions per tester")
109
+ .option("--language <lang>", "Language code (e.g. en, sv)")
110
+ .option("--wait", "Wait for all simulations to reach a terminal state before returning")
111
+ .option("--timeout <s>", "Wait timeout in seconds (default 300; only with --wait)")
112
+ .option("-y, --yes", "Skip confirmation prompt")
113
+ // Local simulation options
114
+ .option("--local", "Run simulation with local browser (Playwright) instead of remote")
115
+ .option("--headed", "Show browser window (local mode only)")
116
+ .option("--slow-mo <ms>", "Slow down actions by ms (local mode only)")
117
+ .option("--devtools", "Open Chrome DevTools (local mode only)")
118
+ .option("--debug", "Enable detailed debug logging to stderr and ~/.ish/local-sim.log")
119
+ .option("--parallel <n>", "Run N testers in parallel (local mode only, default: all)")
120
+ .addHelpText("after", `
121
+ Note: --workspace and --study are optional if you have set active context
122
+ via \`ish workspace use <alias>\` and \`ish study use <alias>\`.
123
+
124
+ Iterations carry the URL/content. If the study has none, create one
125
+ first with \`ish iteration create\`.
126
+
127
+ Audience: pass nothing to reuse the iteration's existing testers. Pass
128
+ --profile to use specific profiles, or filter flags (--country, --gender,
129
+ --min-age, --max-age, --search, --visibility) with --sample <N> or --all
130
+ to seed a fresh audience from the workspace pool.
131
+
132
+ Examples:
133
+ # Run the latest iteration, reusing its testers:
134
+ $ ish study run -y
135
+
136
+ # Run with an explicit audience:
137
+ $ ish study run --profile tp-795,tp-af2
138
+
139
+ # Run with a demographic-filtered sample (3 Swedish profiles aged 35–50):
140
+ $ ish study run --country SE --min-age 35 --max-age 50 --sample 3
141
+
142
+ # Run with every female profile in the workspace:
143
+ $ ish study run --gender female --all
144
+
145
+ # Run a specific iteration:
146
+ $ ish study run --iteration i-d4e
147
+
148
+ # Override the simulation config (e.g. for a media study):
149
+ $ ish study run --config c-c3c
150
+
151
+ # Block until all simulations finish (or timeout):
152
+ $ ish study run --wait
153
+ $ ish study run --wait --timeout 600
154
+
155
+ # Local browser simulation (no remote Browserbase):
156
+ $ ish study run --local
157
+ $ ish study run --local --headed --slow-mo 500`)
158
+ .action(async (opts, cmd) => {
159
+ await withClient(cmd, async (client, globals) => {
160
+ const log = (msg) => { if (!globals.quiet)
161
+ console.error(msg); };
162
+ const resolvedWorkspace = resolveWorkspace(opts.workspace);
163
+ const resolvedStudy = resolveStudy(opts.study);
164
+ // Step 0: Fetch study (with its iterations + their existing testers)
165
+ const study = await client.get(`/studies/${resolvedStudy}`);
166
+ const modality = study.modality || "interactive";
167
+ const isMedia = isMediaModality(modality);
168
+ if (!study.assignments || study.assignments.length === 0) {
169
+ throw new Error("Study has no assignments. Add tasks with --assignments when creating the study, or use `ish study generate`.");
170
+ }
171
+ // Step 1: Pick iteration (explicit --iteration, or latest on study)
172
+ const iterations = study.iterations || [];
173
+ let iteration;
174
+ if (opts.iteration) {
175
+ const wantedId = resolveId(opts.iteration);
176
+ iteration = iterations.find((it) => it.id === wantedId);
177
+ if (!iteration) {
178
+ throw new Error(`Iteration ${opts.iteration} not found on this study.`);
179
+ }
180
+ }
181
+ else if (iterations.length > 0) {
182
+ iteration = iterations[iterations.length - 1];
183
+ }
184
+ else {
185
+ throw new Error("Study has no iterations. Create one first:\n" +
186
+ ` ish iteration create --study ${resolvedStudy} ${isMedia ? "--content-url <file>" : "--url <url>"}`);
187
+ }
188
+ const iterationId = iteration.id;
189
+ const iterationLabel = iteration.label || iteration.name || iterationId.slice(0, 8);
190
+ const detailsView = readIterationDetails(iteration.details);
191
+ // Step 2: Resolve audience.
192
+ // - If any audience flag is set (--profile / --sample / --all / filter flags),
193
+ // resolve a fresh ID list from the workspace pool via the shared helper.
194
+ // - Otherwise reuse the iteration's existing testers.
195
+ const profileNames = new Map();
196
+ const profileIds = [];
197
+ const existingTesters = [];
198
+ const audienceSet = hasAudienceFlags(opts);
199
+ if (audienceSet) {
200
+ const resolved = await resolveAudienceProfileIds(client, resolvedWorkspace, opts, { requireSimulatable: false, allFlagName: "--all" });
201
+ profileIds.push(...resolved);
202
+ }
203
+ else if (iteration.testers && iteration.testers.length > 0) {
204
+ for (const t of iteration.testers) {
205
+ const pid = t.tester_profile_id || t.tester_profile?.id;
206
+ const name = t.tester_profile?.name;
207
+ if (pid && !profileNames.has(pid)) {
208
+ profileNames.set(pid, name || "");
209
+ profileIds.push(pid);
210
+ }
211
+ if (t.id) {
212
+ existingTesters.push({ id: t.id, tester_profile: { name: name || "Unknown" } });
213
+ }
214
+ }
215
+ }
216
+ const reuseExistingTesters = !audienceSet && existingTesters.length > 0;
217
+ if (profileIds.length === 0) {
218
+ throw new Error(`Iteration "${iterationLabel}" has no testers and no audience flags were given. ` +
219
+ "Pass --profile <ids>, or filter flags (--country, --gender, --min-age, --max-age, --search, --visibility) with --sample <N> or --all.");
220
+ }
221
+ // Step 3: Resolve simulation config (per-profile fallback for media)
222
+ const resolvedConfigOverride = opts.config ? resolveId(opts.config) : undefined;
223
+ const profileConfigMap = new Map();
224
+ if (isMedia && !resolvedConfigOverride) {
225
+ for (const pid of profileIds) {
226
+ const profile = await client.get(`/tester-profiles/${pid}`);
227
+ if (profile.simulation_config_id) {
228
+ profileConfigMap.set(pid, profile.simulation_config_id);
229
+ }
230
+ else {
231
+ throw new Error(`Profile ${profileNames.get(pid) || pid} has no simulation config assigned.\n` +
232
+ "Use --config <id> to specify one, or assign a config to the profile.\n" +
233
+ "List configs with: ish config list");
234
+ }
235
+ }
236
+ }
237
+ // Step 4: Confirmation
238
+ if (!globals.json && !opts.yes) {
239
+ log("");
240
+ log(" Run settings:");
241
+ log(` Iteration: ${iterationLabel}`);
242
+ log(` Modality: ${modality}`);
243
+ if (study.content_type)
244
+ log(` Content type: ${study.content_type}`);
245
+ if (!isMedia) {
246
+ log(` Platform: ${detailsView.platform || "browser"}`);
247
+ log(` Screen format: ${detailsView.screenFormat || "desktop"}`);
248
+ if (detailsView.url)
249
+ log(` URL: ${detailsView.url}`);
250
+ }
251
+ else if (detailsView.title) {
252
+ log(` Title: ${detailsView.title}`);
253
+ }
254
+ if (resolvedConfigOverride)
255
+ log(` Config: ${resolvedConfigOverride}`);
256
+ if (opts.language)
257
+ log(` Language: ${opts.language}`);
258
+ log(` Profiles (${profileIds.length}):`);
259
+ for (const pid of profileIds) {
260
+ const name = profileNames.get(pid);
261
+ log(` - ${name ? `${name} (${pid})` : pid}`);
262
+ }
263
+ log("");
264
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
265
+ const answer = await rl.question(" Proceed? [Y/n] ");
266
+ rl.close();
267
+ if (answer && !["y", "yes", ""].includes(answer.toLowerCase().trim())) {
268
+ log("Aborted.");
269
+ process.exit(0);
270
+ }
271
+ log("");
272
+ }
273
+ if (opts.local) {
274
+ await ensureBrowser({ quiet: globals.quiet, skipPrompt: globals.json });
275
+ }
276
+ // Step 5: Either reuse the iteration's testers or batch-create new ones
277
+ let createdTesters;
278
+ if (reuseExistingTesters && existingTesters.length > 0) {
279
+ createdTesters = existingTesters;
280
+ log(`Reusing ${createdTesters.length} existing tester${createdTesters.length > 1 ? "s" : ""} from iteration "${iterationLabel}"`);
281
+ }
282
+ else {
283
+ const testerInputs = profileIds.map((profileId) => ({
284
+ tester_profile_id: profileId,
285
+ tester_type: "ai",
286
+ status: "draft",
287
+ ...(opts.language && { language: opts.language }),
288
+ ...(!isMedia && { platform: detailsView.platform || "browser" }),
289
+ }));
290
+ log(`Creating ${testerInputs.length} tester${testerInputs.length > 1 ? "s" : ""}...`);
291
+ const batchResult = await client.post(`/iterations/${iterationId}/testers/batch`, { testers: testerInputs });
292
+ createdTesters = batchResult.testers;
293
+ log(`Created ${createdTesters.length} tester${createdTesters.length > 1 ? "s" : ""}`);
294
+ }
295
+ // Step 6: Dispatch
296
+ if (opts.local) {
297
+ if (isMedia) {
298
+ throw new Error("Local mode is only supported for interactive simulations.");
299
+ }
300
+ const testerNameMap = new Map();
301
+ for (const t of createdTesters) {
302
+ testerNameMap.set(t.id, t.tester_profile?.name ?? "Unknown");
303
+ }
304
+ await runLocalSimulations(client, {
305
+ workspaceId: resolvedWorkspace,
306
+ studyId: resolvedStudy,
307
+ iterationId,
308
+ testerIds: createdTesters.map((t) => t.id),
309
+ testerNames: testerNameMap,
310
+ url: detailsView.url,
311
+ screenFormat: detailsView.screenFormat,
312
+ locale: detailsView.locale,
313
+ maxInteractions: opts.maxInteractions ? parseMaxInteractions(opts.maxInteractions) : undefined,
314
+ headed: !!opts.headed,
315
+ slowMo: opts.slowMo ? parseSlowMo(opts.slowMo) : undefined,
316
+ devtools: opts.devtools,
317
+ debug: opts.debug,
318
+ parallel: opts.parallel ? parseInt(opts.parallel, 10) : undefined,
319
+ quiet: globals.quiet,
320
+ json: globals.json,
321
+ });
322
+ if (globals.json) {
323
+ const testersOut = createdTesters.map((t) => ({
324
+ id: t.id,
325
+ alias: tagAlias(ALIAS_PREFIX.tester, String(t.id)),
326
+ profile_name: t.tester_profile?.name,
327
+ }));
328
+ output({
329
+ iteration_id: iterationId,
330
+ testers: testersOut,
331
+ tester_ids: testersOut.map((t) => t.id),
332
+ tester_aliases: testersOut.map((t) => t.alias),
333
+ mode: "local",
334
+ }, true);
335
+ }
336
+ return;
337
+ }
338
+ log(`Starting ${createdTesters.length} simulation${createdTesters.length > 1 ? "s" : ""}...`);
339
+ let simResults;
340
+ if (isMedia) {
341
+ const mediaBatchItems = createdTesters.map((t, i) => ({
342
+ study_id: resolvedStudy,
343
+ tester_id: t.id,
344
+ config_id: resolvedConfigOverride || profileConfigMap.get(profileIds[i]),
345
+ ...(opts.language && { language: opts.language }),
346
+ }));
347
+ const simResult = await client.post("/simulation/media/start/batch", {
348
+ product_id: resolvedWorkspace,
349
+ simulations: mediaBatchItems,
350
+ ...(opts.maxInteractions && { max_interactions: parseMaxInteractions(opts.maxInteractions) }),
351
+ }, { timeout: 60_000 });
352
+ simResults = simResult.results;
353
+ }
354
+ else {
355
+ const simItems = createdTesters.map((t) => ({
356
+ study_id: resolvedStudy,
357
+ tester_id: t.id,
358
+ ...(resolvedConfigOverride && { config_id: resolvedConfigOverride }),
359
+ ...(opts.language && { language: opts.language }),
360
+ ...(detailsView.locale && { locale: detailsView.locale }),
361
+ }));
362
+ const simResult = await client.post("/simulation/interactive/start/batch", {
363
+ product_id: resolvedWorkspace,
364
+ simulations: simItems,
365
+ platform: detailsView.platform || "browser",
366
+ ...(detailsView.url && { url: detailsView.url }),
367
+ screen_format: detailsView.screenFormat || "desktop",
368
+ ...(opts.maxInteractions && { max_interactions: parseMaxInteractions(opts.maxInteractions) }),
369
+ }, { timeout: 60_000 });
370
+ simResults = simResult.results;
371
+ }
372
+ if (!opts.wait) {
373
+ if (globals.json) {
374
+ const testersOut = createdTesters.map((t) => ({
375
+ id: t.id,
376
+ alias: tagAlias(ALIAS_PREFIX.tester, String(t.id)),
377
+ profile_name: t.tester_profile?.name,
378
+ }));
379
+ output({
380
+ iteration_id: iterationId,
381
+ testers: testersOut,
382
+ tester_ids: testersOut.map((t) => t.id),
383
+ tester_aliases: testersOut.map((t) => t.alias),
384
+ simulations: simResults,
385
+ }, true);
386
+ }
387
+ else {
388
+ for (let i = 0; i < simResults.length; i++) {
389
+ const tester = createdTesters[i];
390
+ const profileName = tester?.tester_profile?.name || "Unknown";
391
+ log(` ${profileName.padEnd(24)} QUEUED`);
392
+ }
393
+ const url = getWebUrl(globals, `/${resolvedWorkspace}/${resolvedStudy}/timeline`);
394
+ log(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
395
+ log(`Run \`ish study poll --study ${resolvedStudy}\` (or --wait next time) to check progress.`);
396
+ }
397
+ return;
398
+ }
399
+ // --wait: block until all dispatched testers reach a terminal state
400
+ const timeoutMs = parseWaitTimeout(opts.timeout);
401
+ const dispatchedIds = new Set(createdTesters.map((t) => t.id));
402
+ log(`Waiting for ${dispatchedIds.size} simulation${dispatchedIds.size > 1 ? "s" : ""} to finish...`);
403
+ const { rows, isMedia: pollIsMedia } = await pollStudyUntilDone(client, {
404
+ studyId: resolvedStudy,
405
+ testerIds: dispatchedIds,
406
+ timeoutMs,
407
+ quiet: globals.quiet,
408
+ });
409
+ if (globals.json) {
410
+ const testersOut = createdTesters.map((t) => ({
411
+ id: t.id,
412
+ alias: tagAlias(ALIAS_PREFIX.tester, String(t.id)),
413
+ profile_name: t.tester_profile?.name,
414
+ }));
415
+ output({
416
+ iteration_id: iterationId,
417
+ testers: testersOut,
418
+ tester_ids: testersOut.map((t) => t.id),
419
+ tester_aliases: testersOut.map((t) => t.alias),
420
+ simulations: simResults,
421
+ results: rows,
422
+ }, true);
423
+ }
424
+ else {
425
+ formatSimulationPoll(rows, false, pollIsMedia);
426
+ const url = getWebUrl(globals, `/${resolvedWorkspace}/${resolvedStudy}/timeline`);
427
+ log(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
428
+ }
429
+ });
430
+ });
431
+ // --- Poll: check simulation progress ---
432
+ study
433
+ .command("poll")
434
+ .description("Check simulation progress for a study")
435
+ .argument("[tester_id]", "Tester ID (alias or UUID; from `ish study run --json`.tester_aliases[])")
436
+ .option("--study <id>", "Study ID (poll all simulations for study)")
437
+ .option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from --study/--iteration/<tester_id>)")
438
+ .addHelpText("after", "\nExamples:\n $ ish study poll --study <study_id>\n $ ish study poll <tester_id> --json\n\nGet tester IDs from `ish study run --json` (.tester_aliases[] / .tester_ids[]).")
439
+ .action(async (testerId, opts, cmd) => {
440
+ await withClient(cmd, async (client, globals) => {
441
+ if (testerId) {
442
+ const data = await client.get(`/simulation/status/${resolveId(testerId)}`);
443
+ output(data, globals.json);
444
+ }
445
+ else if (opts.study) {
446
+ const rid = resolveId(opts.study);
447
+ const study = await client.get(`/studies/${rid}`);
448
+ const isMedia = isMediaModality(study.modality);
449
+ const allTesters = [];
450
+ for (const iteration of study.iterations || []) {
451
+ for (const tester of iteration.testers || []) {
452
+ allTesters.push({
453
+ id: tester.id,
454
+ status: tester.status,
455
+ tester_name: tester.tester_profile?.name || "Unknown",
456
+ interaction_count: Array.isArray(tester.interactions) ? tester.interactions.length : 0,
457
+ ...(tester.error && { error: tester.error }),
458
+ ...(tester.failure_reason && { error: tester.failure_reason }),
459
+ });
460
+ }
461
+ }
462
+ formatSimulationPoll(allTesters, globals.json, isMedia);
463
+ if (!globals.json && study.product_id) {
464
+ const url = getWebUrl(globals, `/${study.product_id}/${rid}/timeline`);
465
+ console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
466
+ }
467
+ }
468
+ else {
469
+ throw new Error("Provide a tester_id argument or --study flag");
470
+ }
471
+ });
472
+ });
473
+ // --- Wait: poll until simulations reach a terminal state ---
474
+ study
475
+ .command("wait")
476
+ .description("Poll until simulations reach a terminal state (completed/errored/failed/cancelled)")
477
+ .argument("[tester_id]", "Tester ID (alias or UUID; from `ish study run --json`.tester_aliases[])")
478
+ .option("--study <id>", "Study ID (wait for all testers in the study)")
479
+ .option("--iteration <id>", "Iteration ID (wait for testers in this iteration only)")
480
+ .option("--timeout <s>", "Max seconds to wait (default 300)")
481
+ .option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from --study/--iteration/<tester_id>)")
482
+ .addHelpText("after", "\nExamples:\n $ ish study wait # wait on the active study\n $ ish study wait --iteration i-d4e\n $ ish study wait <tester_id> --timeout 600\n\nGet tester IDs from `ish study run --json` (.tester_aliases[] / .tester_ids[]).")
483
+ .action(async (testerId, opts, cmd) => {
484
+ await withClient(cmd, async (client, globals) => {
485
+ const timeoutMs = parseWaitTimeout(opts.timeout);
486
+ if (testerId) {
487
+ const start = Date.now();
488
+ let lastStatus = "";
489
+ while (true) {
490
+ const data = await client.get(`/simulation/status/${resolveId(testerId)}`, undefined, { timeout: 60_000 });
491
+ const status = String(data.status ?? "unknown");
492
+ if (!globals.quiet && status !== lastStatus) {
493
+ process.stderr.write(` ${status}\n`);
494
+ lastStatus = status;
495
+ }
496
+ if (TERMINAL_STATUSES.has(status)) {
497
+ output(data, globals.json);
498
+ return;
499
+ }
500
+ if (Date.now() - start > timeoutMs) {
501
+ throw new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for tester ${testerId}.`);
502
+ }
503
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
504
+ }
505
+ }
506
+ // When --iteration is given, derive the parent study from the iteration
507
+ // itself rather than falling back to the active study config. An
508
+ // iteration's study_id is authoritative for "what study does this
509
+ // iteration belong to" — using the active config here would happily
510
+ // poll the wrong study when the user is checking on a sibling.
511
+ let studyId;
512
+ let iterationId;
513
+ if (opts.iteration) {
514
+ iterationId = resolveId(opts.iteration);
515
+ const iter = await client.get(`/iterations/${iterationId}`);
516
+ if (!iter.study_id) {
517
+ throw new Error(`Iteration ${opts.iteration} has no study_id.`);
518
+ }
519
+ studyId = iter.study_id;
520
+ if (opts.study) {
521
+ const explicitStudy = resolveId(opts.study);
522
+ if (explicitStudy !== studyId) {
523
+ throw new Error(`--iteration ${opts.iteration} belongs to study ${studyId}, but --study ${opts.study} was passed. Drop --study or pass the matching one.`);
524
+ }
525
+ }
526
+ }
527
+ else {
528
+ studyId = resolveStudy(opts.study);
529
+ }
530
+ const { rows, isMedia } = await pollStudyUntilDone(client, {
531
+ studyId,
532
+ iterationId,
533
+ timeoutMs,
534
+ quiet: globals.quiet,
535
+ });
536
+ formatSimulationPoll(rows, globals.json, isMedia);
537
+ });
538
+ });
539
+ // --- Cancel ---
540
+ study
541
+ .command("cancel")
542
+ .description("Cancel a running simulation")
543
+ .argument("<tester_id>", "Tester ID (alias or UUID; from `ish study run --json`.tester_aliases[])")
544
+ .option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from <tester_id>)")
545
+ .addHelpText("after", "\nExamples:\n $ ish study cancel t-072\n $ ish study cancel <uuid>\n\nGet tester IDs from `ish study run --json` (.tester_aliases[] / .tester_ids[]).")
546
+ .action(async (testerId, _opts, cmd) => {
547
+ await withClient(cmd, async (client, globals) => {
548
+ const data = await client.post(`/simulation/cancel/${resolveId(testerId)}`);
549
+ output(data, globals.json);
550
+ });
551
+ });
552
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * ish study tester — Inspect and manage testers (low-level; usually
3
+ * created via `ish study run`).
4
+ *
5
+ * Default action: `ish study tester <id>` shows tester details and results.
6
+ */
7
+ import type { Command } from "commander";
8
+ export declare function attachStudyTesterCommands(study: Command): void;