@ishlabs/cli 0.8.1

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 (57) hide show
  1. package/LICENSE +6 -0
  2. package/README.md +69 -0
  3. package/dist/auth.d.ts +17 -0
  4. package/dist/auth.js +102 -0
  5. package/dist/commands/config.d.ts +5 -0
  6. package/dist/commands/config.js +82 -0
  7. package/dist/commands/iteration.d.ts +5 -0
  8. package/dist/commands/iteration.js +134 -0
  9. package/dist/commands/simulation.d.ts +10 -0
  10. package/dist/commands/simulation.js +647 -0
  11. package/dist/commands/study.d.ts +5 -0
  12. package/dist/commands/study.js +283 -0
  13. package/dist/commands/tester-profile.d.ts +5 -0
  14. package/dist/commands/tester-profile.js +109 -0
  15. package/dist/commands/tester.d.ts +5 -0
  16. package/dist/commands/tester.js +73 -0
  17. package/dist/commands/workspace.d.ts +5 -0
  18. package/dist/commands/workspace.js +133 -0
  19. package/dist/config.d.ts +13 -0
  20. package/dist/config.js +25 -0
  21. package/dist/connect.d.ts +4 -0
  22. package/dist/connect.js +573 -0
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.js +89 -0
  25. package/dist/lib/alias-store.d.ts +49 -0
  26. package/dist/lib/alias-store.js +138 -0
  27. package/dist/lib/api-client.d.ts +58 -0
  28. package/dist/lib/api-client.js +177 -0
  29. package/dist/lib/auth.d.ts +8 -0
  30. package/dist/lib/auth.js +73 -0
  31. package/dist/lib/command-helpers.d.ts +28 -0
  32. package/dist/lib/command-helpers.js +131 -0
  33. package/dist/lib/local-sim/actions.d.ts +22 -0
  34. package/dist/lib/local-sim/actions.js +379 -0
  35. package/dist/lib/local-sim/browser.d.ts +63 -0
  36. package/dist/lib/local-sim/browser.js +332 -0
  37. package/dist/lib/local-sim/debug-report.d.ts +21 -0
  38. package/dist/lib/local-sim/debug-report.js +186 -0
  39. package/dist/lib/local-sim/debug.d.ts +44 -0
  40. package/dist/lib/local-sim/debug.js +103 -0
  41. package/dist/lib/local-sim/install.d.ts +25 -0
  42. package/dist/lib/local-sim/install.js +72 -0
  43. package/dist/lib/local-sim/loop.d.ts +60 -0
  44. package/dist/lib/local-sim/loop.js +526 -0
  45. package/dist/lib/local-sim/types.d.ts +232 -0
  46. package/dist/lib/local-sim/types.js +8 -0
  47. package/dist/lib/local-sim/upload.d.ts +6 -0
  48. package/dist/lib/local-sim/upload.js +24 -0
  49. package/dist/lib/output.d.ts +34 -0
  50. package/dist/lib/output.js +675 -0
  51. package/dist/lib/types.d.ts +179 -0
  52. package/dist/lib/types.js +12 -0
  53. package/dist/lib/upload.d.ts +47 -0
  54. package/dist/lib/upload.js +178 -0
  55. package/dist/upgrade.d.ts +1 -0
  56. package/dist/upgrade.js +94 -0
  57. package/package.json +43 -0
@@ -0,0 +1,647 @@
1
+ /**
2
+ * ish simulation — Run, monitor, and cancel simulations.
3
+ *
4
+ * Primary command: `ish simulation run` — orchestrates the full flow:
5
+ * 1. Creates iteration (if not provided)
6
+ * 2. Creates testers from profiles
7
+ * 3. Starts simulations (interactive or media, based on study modality)
8
+ */
9
+ import * as readline from "node:readline/promises";
10
+ import { withClient, getWebUrl, terminalLink, resolveWorkspace, resolveStudy } from "../lib/command-helpers.js";
11
+ import { resolveId } from "../lib/alias-store.js";
12
+ import { output, formatSimulationPoll } from "../lib/output.js";
13
+ function parseMaxInteractions(value) {
14
+ const n = parseInt(value, 10);
15
+ if (isNaN(n) || n < 1)
16
+ throw new Error(`Invalid --max-interactions value: ${value}`);
17
+ return n;
18
+ }
19
+ function parseSlowMo(value) {
20
+ const n = parseInt(value, 10);
21
+ if (isNaN(n) || n < 0)
22
+ throw new Error(`Invalid --slow-mo value: ${value}`);
23
+ return n;
24
+ }
25
+ import { MEDIA_MODALITIES } from "../lib/types.js";
26
+ import { resolveContentUrl, resolveContentUrls, resolveTextContent } from "../lib/upload.js";
27
+ import { runLocalSimulations } from "../lib/local-sim/loop.js";
28
+ import { ensureBrowser } from "../lib/local-sim/install.js";
29
+ function isMediaModality(modality) {
30
+ return !!modality && MEDIA_MODALITIES.includes(modality);
31
+ }
32
+ /**
33
+ * Build iteration details based on study modality and CLI options.
34
+ */
35
+ function buildCopyContent(opts) {
36
+ if (!opts.copyText)
37
+ return undefined;
38
+ return {
39
+ text: opts.copyText,
40
+ ...(opts.copyHtml && { html: opts.copyHtml }),
41
+ ...(opts.socialPlatform && { social_platform: opts.socialPlatform }),
42
+ ...(opts.copyPosition && { position: opts.copyPosition }),
43
+ };
44
+ }
45
+ function buildIterationDetails(modality, opts) {
46
+ switch (modality) {
47
+ case "text":
48
+ if (!opts.contentText) {
49
+ throw new Error("Text studies require --content-text. Provide the text content to evaluate.");
50
+ }
51
+ return {
52
+ type: "text",
53
+ content_text: opts.contentText,
54
+ ...(opts.contentHtml && { content_html: opts.contentHtml }),
55
+ ...(opts.title && { title: opts.title }),
56
+ ...(opts.mimeType && { mime_type: opts.mimeType }),
57
+ };
58
+ case "video":
59
+ case "audio": {
60
+ if (!opts.contentUrl) {
61
+ throw new Error(`${modality} studies require --content-url. Provide the URL to the ${modality} file.`);
62
+ }
63
+ const copy = buildCopyContent(opts);
64
+ return {
65
+ type: modality,
66
+ content_url: opts.contentUrl,
67
+ ...(opts.title && { title: opts.title }),
68
+ ...(opts.mimeType && { mime_type: opts.mimeType }),
69
+ ...(copy && { copy_content: copy }),
70
+ };
71
+ }
72
+ case "image": {
73
+ if (!opts.imageUrls) {
74
+ throw new Error("Image studies require --image-urls. Provide comma-separated image URLs.");
75
+ }
76
+ const copy = buildCopyContent(opts);
77
+ return {
78
+ type: "image",
79
+ image_urls: opts.imageUrls.split(",").map((s) => s.trim()).filter(Boolean),
80
+ ...(opts.title && { title: opts.title }),
81
+ ...(opts.mimeType && { mime_type: opts.mimeType }),
82
+ ...(copy && { copy_content: copy }),
83
+ };
84
+ }
85
+ case "document":
86
+ if (!opts.contentUrl) {
87
+ throw new Error("Document studies require --content-url. Provide the URL to the document.");
88
+ }
89
+ return {
90
+ type: "document",
91
+ content_url: opts.contentUrl,
92
+ ...(opts.title && { title: opts.title }),
93
+ ...(opts.mimeType && { mime_type: opts.mimeType }),
94
+ };
95
+ default:
96
+ // Interactive
97
+ if (!opts.url) {
98
+ throw new Error("Interactive studies require --url. Provide the URL to test.");
99
+ }
100
+ return {
101
+ type: "interactive",
102
+ platform: opts.platform || "browser",
103
+ url: opts.url,
104
+ screen_format: opts.screenFormat || "desktop",
105
+ ...(opts.locale && { locale: opts.locale }),
106
+ };
107
+ }
108
+ }
109
+ /**
110
+ * Copy relevant fields from a previous iteration's details for reuse.
111
+ */
112
+ function copyDetailsFromPrevious(modality, details) {
113
+ if (isMediaModality(modality)) {
114
+ const copy = details.copy_content;
115
+ return {
116
+ ...(typeof details.content_text === "string" && { contentText: details.content_text }),
117
+ ...(typeof details.content_html === "string" && { contentHtml: details.content_html }),
118
+ ...(typeof details.content_url === "string" && { contentUrl: details.content_url }),
119
+ ...(Array.isArray(details.image_urls) && { imageUrls: details.image_urls.join(",") }),
120
+ ...(typeof details.title === "string" && { title: details.title }),
121
+ ...(typeof details.mime_type === "string" && { mimeType: details.mime_type }),
122
+ ...(copy && typeof copy.text === "string" && { copyText: copy.text }),
123
+ ...(copy && typeof copy.html === "string" && { copyHtml: copy.html }),
124
+ ...(copy && typeof copy.social_platform === "string" && { socialPlatform: copy.social_platform }),
125
+ ...(copy && typeof copy.position === "string" && { copyPosition: copy.position }),
126
+ };
127
+ }
128
+ const screenFormat = typeof details.screen_format === "string"
129
+ ? details.screen_format
130
+ : typeof details.screenFormat === "string"
131
+ ? details.screenFormat
132
+ : undefined;
133
+ return {
134
+ ...(typeof details.platform === "string" && { platform: details.platform }),
135
+ ...(typeof details.url === "string" && { url: details.url }),
136
+ ...(screenFormat && { screenFormat }),
137
+ };
138
+ }
139
+ export function registerSimulationCommands(program) {
140
+ const sim = program
141
+ .command("simulation")
142
+ .alias("sim")
143
+ .description("Run and monitor simulations");
144
+ // --- Primary: `simulation run` ---
145
+ sim
146
+ .command("run")
147
+ .description("Run simulations (creates iteration + testers + starts simulations)")
148
+ .option("--workspace <id>", "Workspace ID")
149
+ .option("--study <id>", "Study ID")
150
+ .option("--profiles <ids>", "Comma-separated tester profile IDs (auto-selected from last iteration if omitted)")
151
+ .option("--iteration <id>", "Use existing iteration (skip creation)")
152
+ .option("--iteration-name <name>", "Name for new iteration (forces creating a new iteration)")
153
+ // Interactive options
154
+ .option("--platform <platform>", "Platform (browser, android, figma, code) — interactive only")
155
+ .option("--url <url>", "URL to test — interactive only")
156
+ .option("--screen-format <format>", "Screen format (mobile_portrait, desktop) — interactive only")
157
+ // Media options
158
+ .option("--content-text <text>", "Text content to evaluate, or @filepath to read from file — text modality")
159
+ .option("--content-html <html>", "HTML version of the text, or @filepath to read from file — text modality")
160
+ .option("--content-url <url>", "URL or local file path to media file — video, audio, document modalities")
161
+ .option("--image-urls <urls>", "Comma-separated image URLs or local file paths — image modality")
162
+ .option("--title <title>", "Content title — media modalities")
163
+ .option("--mime-type <type>", "MIME type (e.g. video/mp4) — media modalities")
164
+ // Copy/caption options (ads & social posts)
165
+ .option("--copy-text <text>", "Ad copy or social post caption (or @filepath) — ads & social posts")
166
+ .option("--copy-html <html>", "HTML version of copy text (or @filepath)")
167
+ .option("--social-platform <platform>", "Social platform (instagram, tiktok, facebook, linkedin, x)")
168
+ .option("--copy-position <pos>", "Copy position relative to media (before, after)", "after")
169
+ .option("--config <id>", "Simulation config ID (required for media, auto-resolved for interactive)")
170
+ // Shared options
171
+ .option("--max-interactions <n>", "Max interactions per tester")
172
+ .option("--language <lang>", "Language code (e.g. en, sv)")
173
+ .option("--locale <locale>", "Locale code (e.g. en-US)")
174
+ .option("-y, --yes", "Skip confirmation prompt")
175
+ // Local simulation options
176
+ .option("--local", "Run simulation with local browser (Playwright) instead of remote")
177
+ .option("--headed", "Show browser window (local mode only)")
178
+ .option("--slow-mo <ms>", "Slow down actions by ms (local mode only)")
179
+ .option("--devtools", "Open Chrome DevTools (local mode only)")
180
+ .option("--debug", "Enable detailed debug logging to stderr and ~/.ish/local-sim.log")
181
+ .option("--parallel <n>", "Run N testers in parallel (local mode only, default: all)")
182
+ .addHelpText("after", `
183
+ Note: --workspace and --study are optional if you have set active context
184
+ via \`ish workspace use <alias>\` and \`ish study use <alias>\`.
185
+ Profiles and iteration settings are auto-reused from the latest iteration.
186
+
187
+ Examples:
188
+ # Re-run with same settings (after setting context):
189
+ $ ish sim run -y
190
+
191
+ # Interactive — first run:
192
+ $ ish sim run --workspace w-6ec --study s-b2c --profiles tp-795,tp-af2 --url https://example.com
193
+
194
+ # Text/email (inline or from file):
195
+ $ ish sim run --content-text "Your email content..." --config c-c3c
196
+ $ ish sim run --content-text @./email.html --config c-c3c
197
+
198
+ # Video (URL or local file):
199
+ $ ish sim run --content-url ./video.mp4 --config c-c3c
200
+
201
+ # Image (local files):
202
+ $ ish sim run --image-urls "./a.png,./b.png" --config c-c3c
203
+
204
+ # Document:
205
+ $ ish sim run --content-url ./report.pdf --config c-c3c
206
+
207
+ # Video ad with copy text:
208
+ $ ish sim run --content-url ./ad.mp4 --copy-text "Buy now — 50% off!" --config c-c3c
209
+
210
+ # Social post with caption:
211
+ $ ish sim run --image-urls ./post.png --copy-text @./caption.txt --social-platform instagram --config c-c3c
212
+
213
+ # Re-run existing iteration:
214
+ $ ish sim run --iteration i-d4e
215
+
216
+ # Local browser simulation (no remote Browserbase):
217
+ $ ish sim run --local --url http://localhost:3000
218
+ $ ish sim run --local --url http://localhost:3000 --headed --slow-mo 500`)
219
+ .action(async (opts, cmd) => {
220
+ await withClient(cmd, async (client, globals) => {
221
+ const log = (msg) => { if (!globals.quiet)
222
+ console.error(msg); };
223
+ const resolvedWorkspace = resolveWorkspace(opts.workspace);
224
+ const resolvedStudy = resolveStudy(opts.study);
225
+ if (opts.iteration && opts.iterationName) {
226
+ throw new Error("Cannot use both --iteration and --iteration-name. Use --iteration to reuse an existing iteration, or --iteration-name to create a new one.");
227
+ }
228
+ // Step 0: Fetch study to determine modality and resolve defaults
229
+ const study = await client.get(`/studies/${resolvedStudy}`);
230
+ const modality = study.modality || "interactive";
231
+ const isMedia = isMediaModality(modality);
232
+ if (!study.assignments || study.assignments.length === 0) {
233
+ throw new Error("Study has no assignments. Add tasks with --assignments when creating the study, or use `ish study generate`.");
234
+ }
235
+ // Validate conflicting options
236
+ if (isMedia && opts.url) {
237
+ throw new Error(`This study uses "${modality}" modality — --url is for interactive studies. Use --content-text, --content-url, or --image-urls instead.`);
238
+ }
239
+ if (!isMedia && (opts.contentText || opts.contentUrl || opts.imageUrls)) {
240
+ throw new Error(`This study uses "interactive" modality — --content-text, --content-url, and --image-urls are for media studies. Use --url instead.`);
241
+ }
242
+ // Resolve defaults from latest iteration
243
+ let profileIds = opts.profiles
244
+ ? opts.profiles.split(",").map((s) => s.trim()).filter(Boolean).map(resolveId)
245
+ : [];
246
+ const profileNames = new Map();
247
+ let iterationId = opts.iteration ? resolveId(opts.iteration) : undefined;
248
+ // Mutable copies for resolving from previous iteration
249
+ let resolvedOpts = { ...opts };
250
+ const iterations = study.iterations || [];
251
+ const latest = iterations.length > 0 ? iterations[iterations.length - 1] : null;
252
+ if (latest) {
253
+ const iterLabel = latest.label || latest.name || latest.id.slice(0, 8);
254
+ log(`Using iteration "${iterLabel}" as baseline`);
255
+ // Reuse iteration if not creating a new one
256
+ if (!iterationId && !opts.iterationName) {
257
+ iterationId = latest.id;
258
+ }
259
+ // Fill defaults from previous iteration details
260
+ const details = latest.details;
261
+ if (details) {
262
+ const prev = copyDetailsFromPrevious(modality, details);
263
+ if (!resolvedOpts.platform && prev.platform)
264
+ resolvedOpts.platform = prev.platform;
265
+ if (!resolvedOpts.url && prev.url)
266
+ resolvedOpts.url = prev.url;
267
+ if (!resolvedOpts.screenFormat && prev.screenFormat)
268
+ resolvedOpts.screenFormat = prev.screenFormat;
269
+ if (!resolvedOpts.contentText && prev.contentText)
270
+ resolvedOpts.contentText = prev.contentText;
271
+ if (!resolvedOpts.contentHtml && prev.contentHtml)
272
+ resolvedOpts.contentHtml = prev.contentHtml;
273
+ if (!resolvedOpts.contentUrl && prev.contentUrl)
274
+ resolvedOpts.contentUrl = prev.contentUrl;
275
+ if (!resolvedOpts.imageUrls && prev.imageUrls)
276
+ resolvedOpts.imageUrls = prev.imageUrls;
277
+ if (!resolvedOpts.title && prev.title)
278
+ resolvedOpts.title = prev.title;
279
+ if (!resolvedOpts.mimeType && prev.mimeType)
280
+ resolvedOpts.mimeType = prev.mimeType;
281
+ if (!resolvedOpts.copyText && prev.copyText)
282
+ resolvedOpts.copyText = prev.copyText;
283
+ if (!resolvedOpts.copyHtml && prev.copyHtml)
284
+ resolvedOpts.copyHtml = prev.copyHtml;
285
+ if (!resolvedOpts.socialPlatform && prev.socialPlatform)
286
+ resolvedOpts.socialPlatform = prev.socialPlatform;
287
+ if (!resolvedOpts.copyPosition && prev.copyPosition)
288
+ resolvedOpts.copyPosition = prev.copyPosition;
289
+ }
290
+ // Auto-select profiles from latest iteration's testers
291
+ if (profileIds.length === 0 && latest.testers && latest.testers.length > 0) {
292
+ const seen = new Set();
293
+ for (const t of latest.testers) {
294
+ const pid = t.tester_profile_id || t.tester_profile?.id;
295
+ if (pid && !seen.has(pid)) {
296
+ seen.add(pid);
297
+ profileIds.push(pid);
298
+ const name = t.tester_profile?.name;
299
+ if (name)
300
+ profileNames.set(pid, name);
301
+ }
302
+ }
303
+ }
304
+ }
305
+ if (profileIds.length === 0) {
306
+ throw new Error("No profiles specified and no previous iteration to copy from. Use --profiles <ids>.");
307
+ }
308
+ // Resolve local file paths → upload to storage and get URLs
309
+ if (isMedia) {
310
+ if (resolvedOpts.contentText) {
311
+ resolvedOpts.contentText = resolveTextContent(resolvedOpts.contentText);
312
+ }
313
+ if (resolvedOpts.contentHtml) {
314
+ resolvedOpts.contentHtml = resolveTextContent(resolvedOpts.contentHtml);
315
+ }
316
+ if (resolvedOpts.copyText) {
317
+ resolvedOpts.copyText = resolveTextContent(resolvedOpts.copyText);
318
+ }
319
+ if (resolvedOpts.copyHtml) {
320
+ resolvedOpts.copyHtml = resolveTextContent(resolvedOpts.copyHtml);
321
+ }
322
+ if (resolvedOpts.contentUrl) {
323
+ resolvedOpts.contentUrl = await resolveContentUrl(client, resolvedStudy, resolvedOpts.contentUrl, { mimeTypeOverride: resolvedOpts.mimeType, quiet: globals.quiet });
324
+ }
325
+ if (resolvedOpts.imageUrls) {
326
+ const urls = await resolveContentUrls(client, resolvedStudy, resolvedOpts.imageUrls, { mimeTypeOverride: resolvedOpts.mimeType, quiet: globals.quiet });
327
+ resolvedOpts.imageUrls = urls.join(",");
328
+ }
329
+ }
330
+ // Resolve config_id for media simulations
331
+ const resolvedConfigOverride = opts.config ? resolveId(opts.config) : undefined;
332
+ const profileConfigMap = new Map();
333
+ if (isMedia && !resolvedConfigOverride) {
334
+ // Resolve config from each profile's simulation_config_id
335
+ for (const pid of profileIds) {
336
+ const profile = await client.get(`/tester-profiles/${pid}`);
337
+ if (profile.simulation_config_id) {
338
+ profileConfigMap.set(pid, profile.simulation_config_id);
339
+ }
340
+ else {
341
+ throw new Error(`Profile ${profileNames.get(pid) || pid} has no simulation config assigned.\n` +
342
+ "Use --config <id> to specify one, or assign a config to the profile.\n" +
343
+ "List configs with: ish config list");
344
+ }
345
+ }
346
+ }
347
+ // Confirmation step
348
+ if (!globals.json && !opts.yes) {
349
+ log("");
350
+ log(" Simulation settings:");
351
+ log(` Modality: ${modality}`);
352
+ if (study.content_type)
353
+ log(` Content type: ${study.content_type}`);
354
+ if (isMedia) {
355
+ if (resolvedOpts.title)
356
+ log(` Title: ${resolvedOpts.title}`);
357
+ if (resolvedOpts.contentText)
358
+ log(` Content: ${resolvedOpts.contentText.slice(0, 80)}${resolvedOpts.contentText.length > 80 ? "..." : ""}`);
359
+ if (resolvedOpts.contentUrl)
360
+ log(` Content URL: ${resolvedOpts.contentUrl}`);
361
+ if (resolvedOpts.imageUrls)
362
+ log(` Image URLs: ${resolvedOpts.imageUrls}`);
363
+ if (resolvedOpts.mimeType)
364
+ log(` MIME type: ${resolvedOpts.mimeType}`);
365
+ if (resolvedOpts.copyText)
366
+ log(` Copy text: ${resolvedOpts.copyText.slice(0, 80)}${resolvedOpts.copyText.length > 80 ? "..." : ""}`);
367
+ if (resolvedOpts.socialPlatform)
368
+ log(` Platform: ${resolvedOpts.socialPlatform}`);
369
+ if (resolvedConfigOverride)
370
+ log(` Config: ${resolvedConfigOverride}`);
371
+ }
372
+ else {
373
+ log(` Platform: ${resolvedOpts.platform || "browser"}`);
374
+ log(` Screen format: ${resolvedOpts.screenFormat || "desktop"}`);
375
+ if (resolvedOpts.url)
376
+ log(` URL: ${resolvedOpts.url}`);
377
+ }
378
+ if (opts.language)
379
+ log(` Language: ${opts.language}`);
380
+ log(` Profiles (${profileIds.length}):`);
381
+ for (const pid of profileIds) {
382
+ const name = profileNames.get(pid);
383
+ log(` - ${name ? `${name} (${pid})` : pid}`);
384
+ }
385
+ log("");
386
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
387
+ const answer = await rl.question(" Proceed? [Y/n] ");
388
+ rl.close();
389
+ if (answer && !["y", "yes", ""].includes(answer.toLowerCase().trim())) {
390
+ log("Aborted.");
391
+ process.exit(0);
392
+ }
393
+ log("");
394
+ }
395
+ // Ensure browser is ready before creating server-side state
396
+ if (opts.local) {
397
+ await ensureBrowser({ quiet: globals.quiet, skipPrompt: globals.json });
398
+ }
399
+ // Step 1: Create or use existing iteration
400
+ if (!iterationId) {
401
+ const iterName = resolvedOpts.iterationName || `CLI ${new Date().toISOString().slice(0, 16)}`;
402
+ const iterBody = {
403
+ name: iterName,
404
+ details: buildIterationDetails(modality, resolvedOpts),
405
+ };
406
+ log(`Creating iteration "${iterName}"...`);
407
+ const iter = await client.post(`/studies/${resolvedStudy}/iterations`, iterBody);
408
+ iterationId = iter.id;
409
+ log(`Created iteration "${iterName}"`);
410
+ }
411
+ else if (!opts.iteration) {
412
+ // Auto-reused iteration — update its details to reflect current run
413
+ const newDetails = buildIterationDetails(modality, resolvedOpts);
414
+ await client.put(`/iterations/${iterationId}`, { details: newDetails });
415
+ }
416
+ // Step 2: Create testers from profiles (or reuse from explicit iteration)
417
+ let createdTesters;
418
+ if (opts.iteration && !opts.profiles) {
419
+ // Reuse existing testers from the explicitly provided iteration
420
+ const existingIter = await client.get(`/iterations/${iterationId}`);
421
+ if (existingIter.testers && existingIter.testers.length > 0) {
422
+ createdTesters = existingIter.testers;
423
+ log(`Reusing ${createdTesters.length} existing tester${createdTesters.length > 1 ? "s" : ""} from iteration`);
424
+ }
425
+ else {
426
+ throw new Error("Iteration has no existing testers. Use --profiles to create new testers.");
427
+ }
428
+ }
429
+ else {
430
+ const testerInputs = profileIds.map((profileId) => ({
431
+ tester_profile_id: profileId,
432
+ tester_type: "ai",
433
+ status: "draft",
434
+ ...(opts.language && { language: opts.language }),
435
+ ...(!isMedia && { platform: resolvedOpts.platform || "browser" }),
436
+ }));
437
+ log(`Creating ${testerInputs.length} tester${testerInputs.length > 1 ? "s" : ""}...`);
438
+ const batchResult = await client.post(`/iterations/${iterationId}/testers/batch`, { testers: testerInputs });
439
+ createdTesters = batchResult.testers;
440
+ log(`Created ${createdTesters.length} tester${createdTesters.length > 1 ? "s" : ""}`);
441
+ }
442
+ // Step 3: Start simulations
443
+ // Local mode: run simulations with local browser
444
+ if (opts.local) {
445
+ if (isMedia) {
446
+ throw new Error("Local mode is only supported for interactive simulations.");
447
+ }
448
+ const testerNameMap = new Map();
449
+ for (const t of createdTesters) {
450
+ testerNameMap.set(t.id, t.tester_profile?.name ?? "Unknown");
451
+ }
452
+ await runLocalSimulations(client, {
453
+ workspaceId: resolvedWorkspace,
454
+ studyId: resolvedStudy,
455
+ iterationId: iterationId,
456
+ testerIds: createdTesters.map((t) => t.id),
457
+ testerNames: testerNameMap,
458
+ url: resolvedOpts.url,
459
+ screenFormat: resolvedOpts.screenFormat,
460
+ locale: opts.locale,
461
+ maxInteractions: opts.maxInteractions ? parseMaxInteractions(opts.maxInteractions) : undefined,
462
+ headed: !!opts.headed,
463
+ slowMo: opts.slowMo ? parseSlowMo(opts.slowMo) : undefined,
464
+ devtools: opts.devtools,
465
+ debug: opts.debug,
466
+ parallel: opts.parallel ? parseInt(opts.parallel, 10) : undefined,
467
+ quiet: globals.quiet,
468
+ json: globals.json,
469
+ });
470
+ if (globals.json) {
471
+ output({
472
+ iteration_id: iterationId,
473
+ testers: createdTesters.map((t) => ({ id: t.id, profile_name: t.tester_profile?.name })),
474
+ mode: "local",
475
+ }, true);
476
+ }
477
+ return;
478
+ }
479
+ // Remote mode: delegate to backend
480
+ log(`Starting ${createdTesters.length} simulation${createdTesters.length > 1 ? "s" : ""}...`);
481
+ let simResults;
482
+ if (isMedia) {
483
+ // Media batch endpoint — resolve config per tester from override or profile
484
+ const mediaBatchItems = createdTesters.map((t, i) => ({
485
+ study_id: resolvedStudy,
486
+ tester_id: t.id,
487
+ config_id: resolvedConfigOverride || profileConfigMap.get(profileIds[i]),
488
+ ...(opts.language && { language: opts.language }),
489
+ }));
490
+ const simResult = await client.post("/simulation/media/start/batch", {
491
+ product_id: resolvedWorkspace,
492
+ simulations: mediaBatchItems,
493
+ ...(opts.maxInteractions && { max_interactions: parseMaxInteractions(opts.maxInteractions) }),
494
+ }, { timeout: 60_000 });
495
+ simResults = simResult.results;
496
+ }
497
+ else {
498
+ // Interactive batch endpoint
499
+ const simItems = createdTesters.map((t) => ({
500
+ study_id: resolvedStudy,
501
+ tester_id: t.id,
502
+ ...(opts.config && { config_id: resolveId(opts.config) }),
503
+ ...(opts.language && { language: opts.language }),
504
+ ...(opts.locale && { locale: opts.locale }),
505
+ }));
506
+ const simResult = await client.post("/simulation/interactive/start/batch", {
507
+ product_id: resolvedWorkspace,
508
+ simulations: simItems,
509
+ platform: resolvedOpts.platform || "browser",
510
+ ...(resolvedOpts.url && { url: resolvedOpts.url }),
511
+ screen_format: resolvedOpts.screenFormat || "desktop",
512
+ ...(opts.maxInteractions && { max_interactions: parseMaxInteractions(opts.maxInteractions) }),
513
+ }, { timeout: 60_000 });
514
+ simResults = simResult.results;
515
+ }
516
+ if (globals.json) {
517
+ output({
518
+ iteration_id: iterationId,
519
+ testers: createdTesters.map((t) => ({ id: t.id, profile_name: t.tester_profile?.name })),
520
+ simulations: simResults,
521
+ }, true);
522
+ }
523
+ else {
524
+ for (let i = 0; i < simResults.length; i++) {
525
+ const tester = createdTesters[i];
526
+ const profileName = tester?.tester_profile?.name || "Unknown";
527
+ log(` ${profileName.padEnd(24)} QUEUED`);
528
+ }
529
+ const url = getWebUrl(globals, `/${resolvedWorkspace}/${resolvedStudy}/timeline`);
530
+ log(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
531
+ log(`Run \`ish simulation poll --study ${resolvedStudy}\` to check progress.`);
532
+ }
533
+ });
534
+ });
535
+ // --- Poll: check simulation progress ---
536
+ sim
537
+ .command("poll")
538
+ .description("Check simulation progress")
539
+ .argument("[job_id]", "Job ID (for single simulation)")
540
+ .option("--study <id>", "Study ID (poll all simulations for study)")
541
+ .addHelpText("after", "\nExamples:\n $ ish simulation poll --study <study_id>\n $ ish simulation poll <job_id> --json")
542
+ .action(async (jobId, opts, cmd) => {
543
+ await withClient(cmd, async (client, globals) => {
544
+ if (jobId) {
545
+ // Single job status
546
+ const data = await client.get(`/simulation/status/${resolveId(jobId)}`);
547
+ output(data, globals.json);
548
+ }
549
+ else if (opts.study) {
550
+ const rid = resolveId(opts.study);
551
+ const study = await client.get(`/studies/${rid}`);
552
+ const isMedia = isMediaModality(study.modality);
553
+ // Collect all testers across iterations
554
+ const allTesters = [];
555
+ for (const iteration of study.iterations || []) {
556
+ for (const tester of iteration.testers || []) {
557
+ allTesters.push({
558
+ id: tester.id,
559
+ status: tester.status,
560
+ tester_name: tester.tester_profile?.name || "Unknown",
561
+ interaction_count: Array.isArray(tester.interactions) ? tester.interactions.length : 0,
562
+ ...(tester.error && { error: tester.error }),
563
+ ...(tester.failure_reason && { error: tester.failure_reason }),
564
+ });
565
+ }
566
+ }
567
+ formatSimulationPoll(allTesters, globals.json, isMedia);
568
+ if (!globals.json && study.product_id) {
569
+ const url = getWebUrl(globals, `/${study.product_id}/${rid}/timeline`);
570
+ console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
571
+ }
572
+ }
573
+ else {
574
+ throw new Error("Provide a job_id argument or --study flag");
575
+ }
576
+ });
577
+ });
578
+ // --- Lower-level commands ---
579
+ sim
580
+ .command("start")
581
+ .description("Start a single interactive simulation (low-level)")
582
+ .option("--workspace <id>", "Workspace ID")
583
+ .option("--study <id>", "Study ID")
584
+ .requiredOption("--tester <id>", "Tester ID")
585
+ .option("--config <id>", "Simulation config ID (resolved from profile if omitted)")
586
+ .option("--platform <platform>", "Platform (browser, android, figma, code)", "browser")
587
+ .option("--url <url>", "URL to test")
588
+ .option("--screen-format <format>", "Screen format (mobile_portrait, desktop)")
589
+ .option("--max-interactions <n>", "Max interactions")
590
+ .option("--language <lang>", "Language code")
591
+ .option("--locale <locale>", "Locale code")
592
+ .action(async (opts, cmd) => {
593
+ await withClient(cmd, async (client, globals) => {
594
+ const body = {
595
+ product_id: resolveWorkspace(opts.workspace),
596
+ study_id: resolveStudy(opts.study),
597
+ tester_id: resolveId(opts.tester),
598
+ ...(opts.config && { config_id: resolveId(opts.config) }),
599
+ platform: opts.platform,
600
+ ...(opts.url && { url: opts.url }),
601
+ ...(opts.screenFormat && { screen_format: opts.screenFormat }),
602
+ ...(opts.maxInteractions && { max_interactions: parseMaxInteractions(opts.maxInteractions) }),
603
+ ...(opts.language && { language: opts.language }),
604
+ ...(opts.locale && { locale: opts.locale }),
605
+ };
606
+ const data = await client.post("/simulation/interactive/start", body);
607
+ output(data, globals.json);
608
+ });
609
+ });
610
+ sim
611
+ .command("start-media")
612
+ .description("Start a media simulation (low-level)")
613
+ .option("--workspace <id>", "Workspace ID")
614
+ .option("--study <id>", "Study ID")
615
+ .requiredOption("--tester <id>", "Tester ID")
616
+ .requiredOption("--config <id>", "Simulation config ID")
617
+ .option("--max-interactions <n>", "Max interactions")
618
+ .option("--language <lang>", "Language code")
619
+ .addHelpText("after", `
620
+ Examples:
621
+ $ ish sim start-media --workspace W --study S --tester T --config C
622
+ $ ish sim start-media --workspace W --study S --tester T --config C --max-interactions 10`)
623
+ .action(async (opts, cmd) => {
624
+ await withClient(cmd, async (client, globals) => {
625
+ const body = {
626
+ product_id: resolveWorkspace(opts.workspace),
627
+ study_id: resolveStudy(opts.study),
628
+ tester_id: resolveId(opts.tester),
629
+ config_id: resolveId(opts.config),
630
+ ...(opts.maxInteractions && { max_interactions: parseMaxInteractions(opts.maxInteractions) }),
631
+ ...(opts.language && { language: opts.language }),
632
+ };
633
+ const data = await client.post("/simulation/media/start", body);
634
+ output(data, globals.json);
635
+ });
636
+ });
637
+ sim
638
+ .command("cancel")
639
+ .description("Cancel a simulation")
640
+ .argument("<job_id>", "Job ID")
641
+ .action(async (jobId, _opts, cmd) => {
642
+ await withClient(cmd, async (client, globals) => {
643
+ const data = await client.post(`/simulation/cancel/${resolveId(jobId)}`);
644
+ output(data, globals.json);
645
+ });
646
+ });
647
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * ish study — Manage studies.
3
+ */
4
+ import type { Command } from "commander";
5
+ export declare function registerStudyCommands(program: Command): void;