@ishlabs/cli 0.12.2 → 0.14.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 (38) hide show
  1. package/dist/commands/chat-config.d.ts +23 -0
  2. package/dist/commands/chat-config.js +289 -0
  3. package/dist/commands/chat.js +26 -37
  4. package/dist/commands/iteration.js +219 -22
  5. package/dist/commands/profile.js +75 -9
  6. package/dist/commands/source.js +6 -4
  7. package/dist/commands/study-analyze.d.ts +41 -0
  8. package/dist/commands/study-analyze.js +187 -0
  9. package/dist/commands/study-run.js +359 -30
  10. package/dist/commands/study-screenshots.d.ts +20 -0
  11. package/dist/commands/study-screenshots.js +216 -0
  12. package/dist/commands/study.js +174 -9
  13. package/dist/commands/workspace.js +35 -2
  14. package/dist/lib/accessibility-profile.d.ts +12 -0
  15. package/dist/lib/accessibility-profile.js +136 -0
  16. package/dist/lib/alias-store.d.ts +1 -0
  17. package/dist/lib/alias-store.js +1 -0
  18. package/dist/lib/ask-questions.js +9 -0
  19. package/dist/lib/billing.d.ts +55 -0
  20. package/dist/lib/billing.js +77 -0
  21. package/dist/lib/command-helpers.d.ts +6 -0
  22. package/dist/lib/command-helpers.js +12 -0
  23. package/dist/lib/docs.js +1181 -38
  24. package/dist/lib/enums.d.ts +54 -0
  25. package/dist/lib/enums.js +100 -0
  26. package/dist/lib/local-sim/actions.d.ts +2 -1
  27. package/dist/lib/local-sim/actions.js +88 -13
  28. package/dist/lib/local-sim/loop.js +49 -19
  29. package/dist/lib/local-sim/tabs.d.ts +27 -0
  30. package/dist/lib/local-sim/tabs.js +157 -0
  31. package/dist/lib/local-sim/types.d.ts +15 -0
  32. package/dist/lib/modality.d.ts +70 -1
  33. package/dist/lib/modality.js +323 -17
  34. package/dist/lib/output.js +61 -4
  35. package/dist/lib/skill-content.js +397 -19
  36. package/dist/lib/types.d.ts +6 -1
  37. package/dist/lib/types.js +1 -1
  38. package/package.json +1 -1
@@ -0,0 +1,216 @@
1
+ /**
2
+ * ish study screenshots — list and download screenshots produced by an
3
+ * interactive study run.
4
+ *
5
+ * Wraps two backend endpoints:
6
+ *
7
+ * GET /studies/{id}/screenshots/grouped — frame-grouped index
8
+ * GET /screenshots/{id} — one row carrying screenshot_url
9
+ *
10
+ * The screenshot_url is a Supabase Storage URL (public or signed) — we fetch
11
+ * its bytes with NO Authorization header (the user's ish bearer is never
12
+ * forwarded cross-origin).
13
+ *
14
+ * Mirrors the agent-facing surface ish-mcp exposes via
15
+ * ``ish://study/{id}/screenshots`` and ``ish://study/{id}/screenshot/{scid}``.
16
+ * The CLI is for humans / scripts; the MCP resources are for LLM agents.
17
+ * Both wrap the same backend rows.
18
+ */
19
+ import { writeFile, mkdir } from "node:fs/promises";
20
+ import { dirname, extname, join } from "node:path";
21
+ import { withClient, resolveStudy } from "../lib/command-helpers.js";
22
+ import { resolveId } from "../lib/alias-store.js";
23
+ import { output, printTable } from "../lib/output.js";
24
+ function projectScreenshot(s) {
25
+ return {
26
+ id: s.id,
27
+ label: s.label ?? null,
28
+ description: s.description ?? null,
29
+ };
30
+ }
31
+ function projectListing(studyId, raw) {
32
+ return {
33
+ study_id: studyId,
34
+ total_count: raw.total_count,
35
+ frames: (raw.groups ?? []).map((g) => ({
36
+ frame_id: g.frame?.id ?? null,
37
+ label: g.frame?.label ?? null,
38
+ count: g.count,
39
+ screenshots: (g.screenshots ?? []).map(projectScreenshot),
40
+ })),
41
+ uncategorized: (raw.uncategorized ?? []).map(projectScreenshot),
42
+ };
43
+ }
44
+ function printListingTable(listing) {
45
+ if (listing.total_count === 0) {
46
+ console.log("No screenshots on this study yet. Screenshots are produced by interactive runs (ish study run).");
47
+ return;
48
+ }
49
+ console.log(`Study ${listing.study_id} — ${listing.total_count} screenshot${listing.total_count === 1 ? "" : "s"} across ${listing.frames.length} frame${listing.frames.length === 1 ? "" : "s"}:`);
50
+ const rows = [];
51
+ let frameIdx = 1;
52
+ for (const frame of listing.frames) {
53
+ const tag = `frame ${frameIdx}${frame.label ? ` — ${frame.label}` : ""}`;
54
+ for (const s of frame.screenshots) {
55
+ rows.push([tag, s.id, s.label ?? ""]);
56
+ }
57
+ frameIdx += 1;
58
+ }
59
+ for (const s of listing.uncategorized) {
60
+ rows.push(["(uncategorized)", s.id, s.label ?? ""]);
61
+ }
62
+ printTable(["FRAME", "SCREENSHOT ID", "LABEL"], rows);
63
+ console.error(`\n Download one with \`ish study screenshots download <study-id> --id <screenshot-id> --out <path>\`,\n or pass --all to download every screenshot into a directory.`);
64
+ }
65
+ function mimeToExt(contentType) {
66
+ const t = (contentType ?? "").split(";", 1)[0]?.trim().toLowerCase() ?? "";
67
+ if (t === "image/png")
68
+ return ".png";
69
+ if (t === "image/jpeg" || t === "image/jpg")
70
+ return ".jpg";
71
+ if (t === "image/webp")
72
+ return ".webp";
73
+ if (t === "image/gif")
74
+ return ".gif";
75
+ return ".bin";
76
+ }
77
+ async function fetchScreenshotBytes(url) {
78
+ // No Authorization header — screenshot URLs are self-credentialed (public
79
+ // Supabase Storage URLs, or signed URLs whose token lives in ?token=).
80
+ // Forwarding the ish bearer to a third-party storage host would either
81
+ // leak it or 401 the fetch. Mirrors IshApiClient.get_url_bytes in ish-mcp.
82
+ const res = await fetch(url, {
83
+ signal: AbortSignal.timeout(30_000),
84
+ });
85
+ if (!res.ok) {
86
+ throw new Error(`Failed to fetch screenshot bytes (HTTP ${res.status}). The signed URL may have expired — re-run \`ish study screenshots <id>\` to get a fresh listing.`);
87
+ }
88
+ return {
89
+ body: await res.arrayBuffer(),
90
+ contentType: res.headers.get("content-type") ?? "application/octet-stream",
91
+ };
92
+ }
93
+ async function writeBytes(path, body) {
94
+ await mkdir(dirname(path), { recursive: true });
95
+ await writeFile(path, Buffer.from(body));
96
+ }
97
+ export function attachStudyScreenshotsCommands(study) {
98
+ const screenshots = study
99
+ .command("screenshots")
100
+ .description("List or download screenshots produced by an interactive study run.")
101
+ .addHelpText("after", `
102
+ Examples:
103
+ $ ish study screenshots # list for active study
104
+ $ ish study screenshots <study-id>
105
+ $ ish study screenshots <study-id> --json
106
+ $ ish study screenshots download <study-id> --id <scid> --out shot.png
107
+ $ ish study screenshots download <study-id> --all --out ./shots/
108
+
109
+ Screenshots are produced server-side by interactive runs only — chat / video /
110
+ text studies don't have them. Each row's storage URL is self-credentialed,
111
+ so the CLI fetches bytes without forwarding your bearer.`);
112
+ screenshots
113
+ .command("list", { isDefault: true })
114
+ .description("List screenshots for a study (frame-grouped).")
115
+ .argument("[id]", "Study ID (defaults to active study)")
116
+ .option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the study)")
117
+ .action(async (id, _opts, cmd) => {
118
+ await withClient(cmd, async (client, globals) => {
119
+ const studyId = resolveStudy(id);
120
+ const raw = await client.get(`/studies/${studyId}/screenshots/grouped`);
121
+ const listing = projectListing(studyId, raw);
122
+ if (globals.json) {
123
+ output(listing, true, { preProjected: true });
124
+ return;
125
+ }
126
+ printListingTable(listing);
127
+ });
128
+ });
129
+ screenshots
130
+ .command("download")
131
+ .description("Download screenshot bytes to disk. Pass --id for one, or --all for every screenshot on the study.")
132
+ .argument("[id]", "Study ID (defaults to active study)")
133
+ .option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the study)")
134
+ .option("--id <screenshot-id>", "Single screenshot ID (mutually exclusive with --all).")
135
+ .option("--all", "Download every screenshot on the study into --out (treated as a directory).")
136
+ .option("--out <path>", "Output path. With --id: a file path (defaults to ./<screenshot-id>.<ext>). With --all: a directory (defaults to ./screenshots/).")
137
+ .action(async (id, opts, cmd) => {
138
+ if (opts.id && opts.all) {
139
+ throw new Error("Pass either --id or --all, not both.");
140
+ }
141
+ if (!opts.id && !opts.all) {
142
+ throw new Error("Pass --id <screenshot-id> or --all.");
143
+ }
144
+ await withClient(cmd, async (client, globals) => {
145
+ const studyId = resolveStudy(id);
146
+ if (opts.id) {
147
+ const screenshotId = resolveId(opts.id);
148
+ const row = await client.get(`/screenshots/${screenshotId}`);
149
+ if (!row.screenshot_url) {
150
+ throw new Error(`Screenshot ${screenshotId} has no screenshot_url — the row may be from an aborted upload.`);
151
+ }
152
+ const { body, contentType } = await fetchScreenshotBytes(row.screenshot_url);
153
+ const inferredExt = mimeToExt(contentType);
154
+ const outPath = opts.out ?? `./${screenshotId}${inferredExt}`;
155
+ // Honour an explicit --out even if the extension doesn't match the
156
+ // upstream mime; only auto-pick an extension when --out wasn't set.
157
+ const finalPath = opts.out || extname(outPath) ? outPath : outPath + inferredExt;
158
+ await writeBytes(finalPath, body);
159
+ if (globals.json) {
160
+ output({
161
+ study_id: studyId,
162
+ screenshot_id: screenshotId,
163
+ path: finalPath,
164
+ bytes: body.byteLength,
165
+ content_type: contentType,
166
+ }, true, { preProjected: true });
167
+ }
168
+ else {
169
+ console.log(`Saved ${(body.byteLength / 1024).toFixed(1)} KB → ${finalPath} (${contentType})`);
170
+ }
171
+ return;
172
+ }
173
+ // --all: walk the index, fetch each row, save under --out dir.
174
+ const outDir = opts.out ?? "./screenshots";
175
+ const grouped = await client.get(`/studies/${studyId}/screenshots/grouped`);
176
+ const all = [
177
+ ...(grouped.groups ?? []).flatMap((g) => g.screenshots ?? []),
178
+ ...(grouped.uncategorized ?? []),
179
+ ];
180
+ if (all.length === 0) {
181
+ if (globals.json) {
182
+ output({ study_id: studyId, downloaded: 0, paths: [] }, true, { preProjected: true });
183
+ }
184
+ else {
185
+ console.log("No screenshots to download.");
186
+ }
187
+ return;
188
+ }
189
+ const paths = [];
190
+ let totalBytes = 0;
191
+ for (const s of all) {
192
+ if (!s.screenshot_url)
193
+ continue;
194
+ const { body, contentType } = await fetchScreenshotBytes(s.screenshot_url);
195
+ const path = join(outDir, `${s.id}${mimeToExt(contentType)}`);
196
+ await writeBytes(path, body);
197
+ paths.push(path);
198
+ totalBytes += body.byteLength;
199
+ if (!globals.json) {
200
+ process.stderr.write(` ${path} (${(body.byteLength / 1024).toFixed(1)} KB)\n`);
201
+ }
202
+ }
203
+ if (globals.json) {
204
+ output({
205
+ study_id: studyId,
206
+ downloaded: paths.length,
207
+ total_bytes: totalBytes,
208
+ paths,
209
+ }, true, { preProjected: true });
210
+ }
211
+ else {
212
+ console.log(`\nSaved ${paths.length}/${all.length} screenshots (${(totalBytes / 1024).toFixed(1)} KB total) → ${outDir}/`);
213
+ }
214
+ });
215
+ });
216
+ }
@@ -11,8 +11,12 @@ import { VALID_CONTENT_TYPES } from "../lib/types.js";
11
11
  import { parseAssignment, loadAssignmentsFile, parseQuestion } from "../lib/study-inputs.js";
12
12
  import { loadQuestionsManifest } from "../lib/ask-questions.js";
13
13
  import { isLocalPath } from "../lib/upload.js";
14
+ import { normalizeChatMode, validateRoleCriteria } from "../lib/modality.js";
15
+ import { normalizeEnumValue, SCREEN_FORMATS } from "../lib/enums.js";
14
16
  import { attachStudyRunCommands } from "./study-run.js";
15
17
  import { attachStudyTesterCommands } from "./study-tester.js";
18
+ import { attachStudyAnalyzeCommands } from "./study-analyze.js";
19
+ import { attachStudyScreenshotsCommands } from "./study-screenshots.js";
16
20
  function collectRepeatable(value, prev = []) {
17
21
  return prev.concat([value]);
18
22
  }
@@ -86,8 +90,13 @@ Concept pages: ish docs get-page concepts/study
86
90
  .addHelpText("after", "\nExamples:\n $ ish study list --workspace <id>\n $ ish study list --workspace <id> --json")
87
91
  .action(async (opts, cmd) => {
88
92
  await withClient(cmd, async (client, globals) => {
89
- const data = await client.get(`/products/${resolveWorkspace(opts.workspace)}/studies`);
90
- formatStudyList(data, globals.json);
93
+ const resolvedWs = resolveWorkspace(opts.workspace);
94
+ const data = await client.get(`/products/${resolvedWs}/studies`);
95
+ const withUrls = data.map((s) => ({
96
+ ...s,
97
+ url: getWebUrl(globals, `/${resolvedWs}/${String(s.id ?? "")}/overview`),
98
+ }));
99
+ formatStudyList(withUrls, globals.json);
91
100
  });
92
101
  });
93
102
  study
@@ -105,13 +114,21 @@ Concept pages: ish docs get-page concepts/study
105
114
  .option("--questionnaire <path>", "JSON file defining the questionnaire (supports text, slider, likert, single-choice, multiple-choice, number; timing=before|after)")
106
115
  .option("--content-text <text>", "Text content to evaluate, or @filepath to read from file. Creates iteration A inline (text modality only)")
107
116
  .option("--url <url>", "URL to test. Creates iteration A inline (interactive modality only)")
108
- .option("--screen-format <format>", "Screen format for interactive iterations: desktop (default) or mobile_portrait")
117
+ .option("--screen-format <format>", "Screen format for interactive iterations: desktop (default) or mobile_portrait (hyphen/underscore variants accepted)")
109
118
  .option("--content-url <url>", "Public URL of the media file. Creates iteration A inline (video, audio, document modalities). For local files, use the 2-step `iteration create` flow.")
110
119
  .option("--image-urls <urls>", "Comma-separated public image URLs. Creates iteration A inline (image modality). For local files, use the 2-step `iteration create` flow.")
111
120
  .option("--title <title>", "Content title (text + media modalities — image, video, audio, document; optional). Not used for interactive / chat.")
112
- .option("--endpoint <id>", "Saved chatbot endpoint id or alias. Creates iteration A inline (chat modality only)")
113
- .option("--endpoint-config <file>", "ChatbotEndpointConfig JSON file (or `-` for stdin); embedded directly. Mutually exclusive with --endpoint (chat modality only)")
121
+ .option("--endpoint <id>", "Saved chatbot endpoint id or alias. Creates iteration A inline (chat modality, external_chatbot mode)")
122
+ .option("--endpoint-config <file>", "ChatbotEndpointConfig JSON file (or `-` for stdin); embedded directly. Mutually exclusive with --endpoint (chat modality, external_chatbot mode)")
114
123
  .option("--max-turns <n>", "Maximum conversation turns per tester (chat modality only; default 12)", (v) => Number(v))
124
+ .option("--chat-mode <mode>", "Chat mode: external_chatbot (default) or tester_pair (two AI audiences talk to each other) — chat modality only")
125
+ .option("--audience-a <ids>", "Tester profile IDs/aliases for audience A (comma-separated or repeatable). Pass a single profile and N on --audience-b to broadcast (1×N rehearsal: fix side A, vary side B) — chat tester_pair mode", (value, prev = []) => prev.concat(value.split(",").map((s) => s.trim()).filter(Boolean)), [])
126
+ .option("--audience-b <ids>", "Tester profile IDs/aliases for audience B. When both sides are explicit they must be equal length, BUT if either side is a singleton it's auto-broadcast to match the other (1×N rehearsal) — chat tester_pair mode", (value, prev = []) => prev.concat(value.split(",").map((s) => s.trim()).filter(Boolean)), [])
127
+ .option("--scenario-a <text-or-@file>", "Side-A scenario + goal — chat tester_pair mode")
128
+ .option("--scenario-b <text-or-@file>", "Side-B scenario + goal — chat tester_pair mode")
129
+ .option("--initiator-side <a|b>", "Which side speaks first (default: a) — chat tester_pair mode")
130
+ .option("--role-criteria-a <json-or-@file>", 'RoleCriteria filter for side A (JSON object or @filepath). Keys: occupation[], min_age, max_age, gender[], country[], education_level_in[], household_in[], locale_type_in[], income_level_in[], employment_status_in[], requires_captions, uses_screen_reader, prefers_reduced_motion, prefers_high_contrast, has_any_accessibility_need. The five *_in arrays accept snake_case spec values; the five accessibility filters are booleans. Use INSTEAD of --audience-a or alongside it. chat tester_pair mode.')
131
+ .option("--role-criteria-b <json-or-@file>", "RoleCriteria filter for side B — same shape as --role-criteria-a. chat tester_pair mode.")
115
132
  .addHelpText("after", `
116
133
  Note: --workspace is optional if set via \`ish workspace use <alias>\`.
117
134
 
@@ -227,12 +244,25 @@ Next: configure a run with \`ish iteration create --study <id>\`,
227
244
  // exist until after `studies` POST. For local files, agents fall
228
245
  // back to the existing 2-step `iteration create` path which uploads
229
246
  // against the freshly-created study.
247
+ const normalizedChatMode = normalizeChatMode(opts.chatMode);
248
+ if (opts.chatMode !== undefined && normalizedChatMode === null) {
249
+ throw new ValidationError(`Invalid --chat-mode "${opts.chatMode}". Expected "external_chatbot" or "tester_pair" (hyphenated variants accepted).`, ["external_chatbot", "tester_pair"]);
250
+ }
251
+ const pairFlagsSet = (opts.audienceA && opts.audienceA.length > 0)
252
+ || (opts.audienceB && opts.audienceB.length > 0)
253
+ || opts.scenarioA !== undefined
254
+ || opts.scenarioB !== undefined
255
+ || opts.initiatorSide !== undefined
256
+ || opts.roleCriteriaA !== undefined
257
+ || opts.roleCriteriaB !== undefined
258
+ || normalizedChatMode === "tester_pair";
230
259
  const inlineMediaFlagsSet = [
231
260
  opts.contentText !== undefined ? "--content-text" : null,
232
261
  opts.url !== undefined ? "--url" : null,
233
262
  opts.contentUrl !== undefined ? "--content-url" : null,
234
263
  opts.imageUrls !== undefined ? "--image-urls" : null,
235
264
  (opts.endpoint !== undefined || opts.endpointConfig !== undefined) ? "--endpoint/--endpoint-config" : null,
265
+ pairFlagsSet ? "--chat-mode tester_pair (with --audience-a/-b or --role-criteria-a/-b plus --scenario-a/-b)" : null,
236
266
  ].filter((f) => f !== null);
237
267
  if (inlineMediaFlagsSet.length > 1) {
238
268
  throw new ValidationError(`Pass only one inline-iteration flag: ${inlineMediaFlagsSet.join(", ")}.`, inlineMediaFlagsSet);
@@ -240,6 +270,14 @@ Next: configure a run with \`ish iteration create --study <id>\`,
240
270
  if (opts.screenFormat !== undefined && opts.url === undefined) {
241
271
  throw new Error("--screen-format only applies when --url is set (interactive modality).");
242
272
  }
273
+ let normalizedScreenFormat;
274
+ if (opts.screenFormat !== undefined) {
275
+ const normalized = normalizeEnumValue(opts.screenFormat, SCREEN_FORMATS);
276
+ if (normalized === null) {
277
+ throw new ValidationError(`Invalid --screen-format "${opts.screenFormat}". Expected: ${SCREEN_FORMATS.join(" | ")} (hyphen/underscore variants accepted).`, [...SCREEN_FORMATS]);
278
+ }
279
+ normalizedScreenFormat = normalized;
280
+ }
243
281
  // Pattern G.2: --title is metadata, not content. The backend
244
282
  // accepts it on text + media modalities (see
245
283
  // `buildIterationDetails` in iteration.ts). Reject it only on
@@ -279,7 +317,7 @@ Next: configure a run with \`ish iteration create --study <id>\`,
279
317
  type: "interactive",
280
318
  url: opts.url,
281
319
  platform: "browser",
282
- screen_format: opts.screenFormat || "desktop",
320
+ screen_format: normalizedScreenFormat || "desktop",
283
321
  },
284
322
  };
285
323
  }
@@ -328,6 +366,9 @@ Next: configure a run with \`ish iteration create --study <id>\`,
328
366
  if (opts.modality && opts.modality !== "chat") {
329
367
  throw new ValidationError(`--endpoint / --endpoint-config require --modality chat (got "${opts.modality}").`, ["chat"]);
330
368
  }
369
+ if (normalizedChatMode && normalizedChatMode !== "external_chatbot") {
370
+ throw new ValidationError(`--endpoint / --endpoint-config are only valid with --chat-mode external_chatbot (got "${opts.chatMode}"). For tester_pair use --audience-a/-b and --scenario-a/-b.`, ["external_chatbot"]);
371
+ }
331
372
  let endpointConfig;
332
373
  if (opts.endpoint !== undefined) {
333
374
  const epId = resolveId(opts.endpoint);
@@ -353,8 +394,117 @@ Next: configure a run with \`ish iteration create --study <id>\`,
353
394
  name: "A",
354
395
  details: {
355
396
  type: "chat",
356
- endpoint: endpointConfig,
357
- chatbot_endpoint_id: chatbotEndpointId,
397
+ mode_details: {
398
+ mode: "external_chatbot",
399
+ endpoint: endpointConfig,
400
+ ...(chatbotEndpointId && { chatbot_endpoint_id: chatbotEndpointId }),
401
+ },
402
+ max_turns: maxTurns,
403
+ early_termination: true,
404
+ },
405
+ };
406
+ }
407
+ else if (pairFlagsSet) {
408
+ if (opts.modality && opts.modality !== "chat") {
409
+ throw new ValidationError(`--chat-mode tester_pair (with --audience-a/-b or --role-criteria-a/-b plus --scenario-a/-b) requires --modality chat (got "${opts.modality}").`, ["chat"]);
410
+ }
411
+ if (normalizedChatMode && normalizedChatMode !== "tester_pair") {
412
+ throw new ValidationError(`--audience-a/-b or --role-criteria-a/-b imply --chat-mode tester_pair (got "${opts.chatMode}").`, ["tester_pair"]);
413
+ }
414
+ const audA = (opts.audienceA ?? []).map(resolveId);
415
+ const audB = (opts.audienceB ?? []).map(resolveId);
416
+ // Parse + validate role criteria if supplied (JSON or @filepath).
417
+ const parseCriteria = (raw, flag) => {
418
+ if (raw === undefined)
419
+ return undefined;
420
+ const text = raw.startsWith("@") ? readFileSync(raw.slice(1), "utf8") : raw;
421
+ const trimmed = text.trim();
422
+ if (trimmed.length === 0)
423
+ return undefined;
424
+ let parsed;
425
+ try {
426
+ parsed = JSON.parse(trimmed);
427
+ }
428
+ catch {
429
+ throw new Error(`Invalid ${flag}: expected valid JSON object.`);
430
+ }
431
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
432
+ throw new Error(`Invalid ${flag}: expected a JSON object.`);
433
+ }
434
+ return validateRoleCriteria(parsed, flag);
435
+ };
436
+ let critA;
437
+ let critB;
438
+ try {
439
+ critA = parseCriteria(opts.roleCriteriaA, "--role-criteria-a");
440
+ critB = parseCriteria(opts.roleCriteriaB, "--role-criteria-b");
441
+ }
442
+ catch (err) {
443
+ throw new ValidationError(err instanceof Error ? err.message : "Invalid role criteria.", ["--role-criteria-a", "--role-criteria-b"]);
444
+ }
445
+ const sideAHasInput = audA.length > 0 || !!critA;
446
+ const sideBHasInput = audB.length > 0 || !!critB;
447
+ if (!sideAHasInput || !sideBHasInput) {
448
+ throw new Error("tester_pair chat iterations require, for each side, either an explicit audience (--audience-a / --audience-b) or a role-criteria filter (--role-criteria-a / --role-criteria-b).");
449
+ }
450
+ // 1×N broadcast: canonical "rehearse one side against N
451
+ // variations" shape. See iteration.ts buildIterationDetails
452
+ // tester_pair arm for the rationale.
453
+ let audA_final = audA;
454
+ let audB_final = audB;
455
+ let broadcastMsg;
456
+ if (audA.length === 1 && audB.length > 1 && !critA && !critB) {
457
+ audA_final = Array(audB.length).fill(audA[0]);
458
+ broadcastMsg = `Broadcasting --audience-a (1 profile) to length ${audB.length} to match --audience-b — same side-A profile across all ${audB.length} conversations.`;
459
+ }
460
+ else if (audB.length === 1 && audA.length > 1 && !critA && !critB) {
461
+ audB_final = Array(audA.length).fill(audB[0]);
462
+ broadcastMsg = `Broadcasting --audience-b (1 profile) to length ${audA.length} to match --audience-a — same side-B profile across all ${audA.length} conversations.`;
463
+ }
464
+ if (broadcastMsg) {
465
+ console.error(broadcastMsg);
466
+ }
467
+ const bothExplicit = audA_final.length > 0 && audB_final.length > 0 && !critA && !critB;
468
+ if (bothExplicit && audA_final.length !== audB_final.length) {
469
+ // CLI's 1×N broadcast (above) already cloned the singleton side,
470
+ // so this branch only fires when both sides ship >1 with
471
+ // mismatched counts. Server rejects the same way.
472
+ throw new ValidationError(`--audience-a (${audA_final.length}) and --audience-b (${audB_final.length}) cannot be paired. ` +
473
+ `Pick the same number on each side (1:1 by index), or pass exactly one profile on one side to broadcast ` +
474
+ `(e.g. --audience-a tp-rep --audience-b tp-cto1,tp-cto2,tp-cto3), ` +
475
+ `or use --role-criteria-a/-b on either side to let the backend resolve the pool.`, ["--audience-a", "--audience-b"]);
476
+ }
477
+ if (!opts.scenarioA || !opts.scenarioB) {
478
+ throw new Error("tester_pair chat iterations require --scenario-a <text-or-@file> and --scenario-b <text-or-@file>.");
479
+ }
480
+ const scenarioA = opts.scenarioA.startsWith("@")
481
+ ? readFileSync(opts.scenarioA.slice(1), "utf8")
482
+ : opts.scenarioA;
483
+ const scenarioB = opts.scenarioB.startsWith("@")
484
+ ? readFileSync(opts.scenarioB.slice(1), "utf8")
485
+ : opts.scenarioB;
486
+ if (scenarioA.trim().length === 0 || scenarioB.trim().length === 0) {
487
+ throw new Error("--scenario-a and --scenario-b must be non-empty.");
488
+ }
489
+ const initiator = (opts.initiatorSide ?? "a").toLowerCase();
490
+ if (initiator !== "a" && initiator !== "b") {
491
+ throw new ValidationError(`Invalid --initiator-side "${opts.initiatorSide}". Expected "a" or "b".`, ["a", "b"]);
492
+ }
493
+ const maxTurns = opts.maxTurns ?? 12;
494
+ inlineIteration = {
495
+ name: "A",
496
+ details: {
497
+ type: "chat",
498
+ mode_details: {
499
+ mode: "tester_pair",
500
+ audience_a: audA_final,
501
+ audience_b: audB_final,
502
+ scenario_a: scenarioA,
503
+ scenario_b: scenarioB,
504
+ initiator_side: initiator,
505
+ ...(critA && { role_criteria_a: critA }),
506
+ ...(critB && { role_criteria_b: critB }),
507
+ },
358
508
  max_turns: maxTurns,
359
509
  early_termination: true,
360
510
  },
@@ -389,6 +539,9 @@ Next: configure a run with \`ish iteration create --study <id>\`,
389
539
  if (opts.modality === "chat" && inlineIteration) {
390
540
  result.chatbot_endpoint_id = chatbotEndpointId;
391
541
  }
542
+ if (data.id) {
543
+ result.url = getWebUrl(globals, `/${resolvedWs}/${data.id}/overview`);
544
+ }
392
545
  formatStudyDetail(result, globals.json, { writePath: true });
393
546
  if (!globals.json && data.id) {
394
547
  const url = getWebUrl(globals, `/${resolvedWs}/${data.id}/overview`);
@@ -414,6 +567,9 @@ Next: configure a run with \`ish iteration create --study <id>\`,
414
567
  const result = data;
415
568
  if (result.id)
416
569
  result.alias = tagAlias(ALIAS_PREFIX.study, String(result.id));
570
+ if (data.id) {
571
+ result.url = getWebUrl(globals, `/${resolvedWs}/${data.id}/overview`);
572
+ }
417
573
  formatStudyDetail(result, globals.json, { writePath: true });
418
574
  if (!globals.json && data.id) {
419
575
  const url = getWebUrl(globals, `/${resolvedWs}/${data.id}/overview`);
@@ -445,6 +601,9 @@ list table layout in human mode.`)
445
601
  const result = data;
446
602
  if (result.id)
447
603
  result.alias = tagAlias(ALIAS_PREFIX.study, String(result.id));
604
+ if (data.product_id) {
605
+ result.url = getWebUrl(globals, `/${data.product_id}/${rid}/overview`);
606
+ }
448
607
  formatStudyDetail(result, globals.json);
449
608
  if (!globals.json && data.product_id) {
450
609
  const url = getWebUrl(globals, `/${data.product_id}/${rid}/overview`);
@@ -453,10 +612,14 @@ list table layout in human mode.`)
453
612
  return;
454
613
  }
455
614
  const results = await Promise.all(flat.map(async (raw) => {
456
- const data = await client.get(`/studies/${resolveId(raw)}`);
615
+ const rid = resolveId(raw);
616
+ const data = await client.get(`/studies/${rid}`);
457
617
  const r = data;
458
618
  if (r.id)
459
619
  r.alias = tagAlias(ALIAS_PREFIX.study, String(r.id));
620
+ if (data.product_id) {
621
+ r.url = getWebUrl(globals, `/${data.product_id}/${rid}/overview`);
622
+ }
460
623
  return r;
461
624
  }));
462
625
  if (globals.json) {
@@ -690,4 +853,6 @@ Examples:
690
853
  });
691
854
  attachStudyRunCommands(study);
692
855
  attachStudyTesterCommands(study);
856
+ attachStudyAnalyzeCommands(study);
857
+ attachStudyScreenshotsCommands(study);
693
858
  }
@@ -29,13 +29,44 @@ Concept pages: ish docs get-page concepts/workspace
29
29
  });
30
30
  workspace
31
31
  .command("create")
32
- .description("Create a new workspace")
32
+ .description("Create a new workspace (or reuse an existing one with --ensure)")
33
33
  .requiredOption("--name <name>", "Workspace name")
34
34
  .option("--description <description>", "Workspace description")
35
35
  .option("--base-url <url>", "Default base URL")
36
- .addHelpText("after", "\nExamples:\n $ ish workspace create --name \"My App\" --base-url https://example.com\n $ ish workspace create --name \"My App\" --json")
36
+ .option("--ensure", "Idempotent: if a workspace with this exact name already exists in the caller's account, return it instead of creating. Useful on saturated accounts where create would 402/usage_limit_reached.")
37
+ .addHelpText("after", `
38
+ Examples:
39
+ $ ish workspace create --name "My App" --base-url https://example.com
40
+ $ ish workspace create --name "My App" --json
41
+
42
+ # Idempotent — returns an existing workspace if --name matches one you own:
43
+ $ ish workspace create --name "My App" --ensure
44
+
45
+ With --ensure the response includes a top-level \`reused: true\` flag when an
46
+ existing workspace was returned. On creation, \`reused: false\`.`)
37
47
  .action(async (opts, cmd) => {
38
48
  await withClient(cmd, async (client, globals) => {
49
+ if (opts.ensure) {
50
+ const existing = await client.get("/products");
51
+ const match = Array.isArray(existing)
52
+ ? existing.find((w) => w.name === opts.name)
53
+ : undefined;
54
+ if (match) {
55
+ const result = match;
56
+ if (result.id)
57
+ result.alias = tagAlias(ALIAS_PREFIX.workspace, String(result.id));
58
+ result.reused = true;
59
+ formatWorkspaceDetail(result, globals.json, { writePath: true });
60
+ if (!globals.json) {
61
+ console.error(`Reusing existing workspace "${opts.name}".`);
62
+ if (match.id) {
63
+ const url = getWebUrl(globals, `/${match.id}`);
64
+ console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
65
+ }
66
+ }
67
+ return;
68
+ }
69
+ }
39
70
  const body = {
40
71
  name: opts.name,
41
72
  ...(opts.description !== undefined && { description: opts.description }),
@@ -45,6 +76,8 @@ Concept pages: ish docs get-page concepts/workspace
45
76
  const result = data;
46
77
  if (result.id)
47
78
  result.alias = tagAlias(ALIAS_PREFIX.workspace, String(result.id));
79
+ if (opts.ensure)
80
+ result.reused = false;
48
81
  formatWorkspaceDetail(result, globals.json, { writePath: true });
49
82
  if (!globals.json && data.id) {
50
83
  const url = getWebUrl(globals, `/${data.id}`);
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Client-side validator for the AccessibilityProfile v1.0 JSONB shape on
3
+ * TesterProfile.accessibility_profile. Mirrors
4
+ * `ish-mcp/spec/accessibility-profile-schema.v1.json` (additionalProperties:
5
+ * false at every level except `extensions`). An empty object `{}` is the
6
+ * canonical default. When non-empty, `version` is required and must be
7
+ * `"1.0"`.
8
+ *
9
+ * Surfacing validation here is cheaper than a server round-trip and gives
10
+ * agents the same exit-2 error contract they get for other CLI inputs.
11
+ */
12
+ export declare function validateAccessibilityProfile(raw: unknown): Record<string, unknown>;