@ishlabs/cli 0.13.0 → 0.14.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.
@@ -11,6 +11,8 @@ 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";
16
18
  import { attachStudyAnalyzeCommands } from "./study-analyze.js";
@@ -88,8 +90,13 @@ Concept pages: ish docs get-page concepts/study
88
90
  .addHelpText("after", "\nExamples:\n $ ish study list --workspace <id>\n $ ish study list --workspace <id> --json")
89
91
  .action(async (opts, cmd) => {
90
92
  await withClient(cmd, async (client, globals) => {
91
- const data = await client.get(`/products/${resolveWorkspace(opts.workspace)}/studies`);
92
- 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);
93
100
  });
94
101
  });
95
102
  study
@@ -107,13 +114,21 @@ Concept pages: ish docs get-page concepts/study
107
114
  .option("--questionnaire <path>", "JSON file defining the questionnaire (supports text, slider, likert, single-choice, multiple-choice, number; timing=before|after)")
108
115
  .option("--content-text <text>", "Text content to evaluate, or @filepath to read from file. Creates iteration A inline (text modality only)")
109
116
  .option("--url <url>", "URL to test. Creates iteration A inline (interactive modality only)")
110
- .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)")
111
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.")
112
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.")
113
120
  .option("--title <title>", "Content title (text + media modalities — image, video, audio, document; optional). Not used for interactive / chat.")
114
- .option("--endpoint <id>", "Saved chatbot endpoint id or alias. Creates iteration A inline (chat modality only)")
115
- .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)")
116
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.")
117
132
  .addHelpText("after", `
118
133
  Note: --workspace is optional if set via \`ish workspace use <alias>\`.
119
134
 
@@ -229,12 +244,25 @@ Next: configure a run with \`ish iteration create --study <id>\`,
229
244
  // exist until after `studies` POST. For local files, agents fall
230
245
  // back to the existing 2-step `iteration create` path which uploads
231
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";
232
259
  const inlineMediaFlagsSet = [
233
260
  opts.contentText !== undefined ? "--content-text" : null,
234
261
  opts.url !== undefined ? "--url" : null,
235
262
  opts.contentUrl !== undefined ? "--content-url" : null,
236
263
  opts.imageUrls !== undefined ? "--image-urls" : null,
237
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,
238
266
  ].filter((f) => f !== null);
239
267
  if (inlineMediaFlagsSet.length > 1) {
240
268
  throw new ValidationError(`Pass only one inline-iteration flag: ${inlineMediaFlagsSet.join(", ")}.`, inlineMediaFlagsSet);
@@ -242,6 +270,14 @@ Next: configure a run with \`ish iteration create --study <id>\`,
242
270
  if (opts.screenFormat !== undefined && opts.url === undefined) {
243
271
  throw new Error("--screen-format only applies when --url is set (interactive modality).");
244
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
+ }
245
281
  // Pattern G.2: --title is metadata, not content. The backend
246
282
  // accepts it on text + media modalities (see
247
283
  // `buildIterationDetails` in iteration.ts). Reject it only on
@@ -281,7 +317,7 @@ Next: configure a run with \`ish iteration create --study <id>\`,
281
317
  type: "interactive",
282
318
  url: opts.url,
283
319
  platform: "browser",
284
- screen_format: opts.screenFormat || "desktop",
320
+ screen_format: normalizedScreenFormat || "desktop",
285
321
  },
286
322
  };
287
323
  }
@@ -330,6 +366,9 @@ Next: configure a run with \`ish iteration create --study <id>\`,
330
366
  if (opts.modality && opts.modality !== "chat") {
331
367
  throw new ValidationError(`--endpoint / --endpoint-config require --modality chat (got "${opts.modality}").`, ["chat"]);
332
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
+ }
333
372
  let endpointConfig;
334
373
  if (opts.endpoint !== undefined) {
335
374
  const epId = resolveId(opts.endpoint);
@@ -355,8 +394,117 @@ Next: configure a run with \`ish iteration create --study <id>\`,
355
394
  name: "A",
356
395
  details: {
357
396
  type: "chat",
358
- endpoint: endpointConfig,
359
- 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
+ },
360
508
  max_turns: maxTurns,
361
509
  early_termination: true,
362
510
  },
@@ -391,6 +539,9 @@ Next: configure a run with \`ish iteration create --study <id>\`,
391
539
  if (opts.modality === "chat" && inlineIteration) {
392
540
  result.chatbot_endpoint_id = chatbotEndpointId;
393
541
  }
542
+ if (data.id) {
543
+ result.url = getWebUrl(globals, `/${resolvedWs}/${data.id}/overview`);
544
+ }
394
545
  formatStudyDetail(result, globals.json, { writePath: true });
395
546
  if (!globals.json && data.id) {
396
547
  const url = getWebUrl(globals, `/${resolvedWs}/${data.id}/overview`);
@@ -416,6 +567,9 @@ Next: configure a run with \`ish iteration create --study <id>\`,
416
567
  const result = data;
417
568
  if (result.id)
418
569
  result.alias = tagAlias(ALIAS_PREFIX.study, String(result.id));
570
+ if (data.id) {
571
+ result.url = getWebUrl(globals, `/${resolvedWs}/${data.id}/overview`);
572
+ }
419
573
  formatStudyDetail(result, globals.json, { writePath: true });
420
574
  if (!globals.json && data.id) {
421
575
  const url = getWebUrl(globals, `/${resolvedWs}/${data.id}/overview`);
@@ -447,6 +601,9 @@ list table layout in human mode.`)
447
601
  const result = data;
448
602
  if (result.id)
449
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
+ }
450
607
  formatStudyDetail(result, globals.json);
451
608
  if (!globals.json && data.product_id) {
452
609
  const url = getWebUrl(globals, `/${data.product_id}/${rid}/overview`);
@@ -455,10 +612,14 @@ list table layout in human mode.`)
455
612
  return;
456
613
  }
457
614
  const results = await Promise.all(flat.map(async (raw) => {
458
- const data = await client.get(`/studies/${resolveId(raw)}`);
615
+ const rid = resolveId(raw);
616
+ const data = await client.get(`/studies/${rid}`);
459
617
  const r = data;
460
618
  if (r.id)
461
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
+ }
462
623
  return r;
463
624
  }));
464
625
  if (globals.json) {
@@ -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>;
@@ -0,0 +1,136 @@
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
+ const SPEC_URL = "https://ishlabs.io/spec/accessibility-profile-schema.v1.json";
13
+ const TEXT_SIZE = new Set(["default", "large", "xl", "xxl"]);
14
+ const CONTRAST_PREFERENCE = new Set(["default", "more", "less"]);
15
+ const COLOR_SCHEME = new Set(["no_preference", "light", "dark"]);
16
+ const COLOR_FILTER = new Set([
17
+ "none",
18
+ "deuteranopia",
19
+ "protanopia",
20
+ "tritanopia",
21
+ "grayscale",
22
+ ]);
23
+ const VISUAL_BOOLEANS = new Set([
24
+ "reduce_transparency",
25
+ "forced_colors",
26
+ "inverted_colors",
27
+ "uses_screen_reader",
28
+ "uses_magnifier",
29
+ "dyslexia_friendly_font",
30
+ ]);
31
+ const VISUAL_STRINGS = {
32
+ text_size: TEXT_SIZE,
33
+ contrast_preference: CONTRAST_PREFERENCE,
34
+ color_scheme: COLOR_SCHEME,
35
+ color_filter: COLOR_FILTER,
36
+ };
37
+ const AUDITORY_BOOLEANS = new Set([
38
+ "captions_required",
39
+ "audio_descriptions_required",
40
+ "mono_audio",
41
+ "visual_alerts_required",
42
+ "uses_hearing_aid",
43
+ ]);
44
+ const MOTOR_BOOLEANS = new Set([
45
+ "uses_switch_control",
46
+ "uses_voice_control",
47
+ "uses_eye_tracking",
48
+ "needs_larger_tap_targets",
49
+ "extended_interaction_timeouts",
50
+ "avoid_hover_interactions",
51
+ "sticky_keys",
52
+ ]);
53
+ const COGNITIVE_BOOLEANS = new Set([
54
+ "reduce_motion",
55
+ "simple_language_preferred",
56
+ "extra_reading_time",
57
+ "avoid_flashing",
58
+ "predictable_navigation",
59
+ ]);
60
+ const DATA_BOOLEANS = new Set(["reduce_data"]);
61
+ const TOP_LEVEL_KEYS = new Set([
62
+ "version",
63
+ "visual",
64
+ "auditory",
65
+ "motor",
66
+ "cognitive",
67
+ "data",
68
+ "assistive_tech",
69
+ "notes",
70
+ "extensions",
71
+ ]);
72
+ function err(path, msg) {
73
+ return new Error(`Invalid --accessibility-profile at ${path}: ${msg}. See ${SPEC_URL}.`);
74
+ }
75
+ function checkGroup(group, path, bools, strs = {}) {
76
+ if (group === undefined || group === null)
77
+ return;
78
+ if (typeof group !== "object" || Array.isArray(group)) {
79
+ throw err(path, "must be an object");
80
+ }
81
+ for (const [key, value] of Object.entries(group)) {
82
+ if (bools.has(key)) {
83
+ if (typeof value !== "boolean")
84
+ throw err(`${path}.${key}`, "must be a boolean");
85
+ continue;
86
+ }
87
+ if (key in strs) {
88
+ const allowed = strs[key];
89
+ if (typeof value !== "string" || !allowed.has(value)) {
90
+ throw err(`${path}.${key}`, `must be one of ${[...allowed].join(", ")}`);
91
+ }
92
+ continue;
93
+ }
94
+ throw err(`${path}.${key}`, "unknown property");
95
+ }
96
+ }
97
+ export function validateAccessibilityProfile(raw) {
98
+ if (raw === undefined || raw === null) {
99
+ throw err("$", "must be a JSON object");
100
+ }
101
+ if (typeof raw !== "object" || Array.isArray(raw)) {
102
+ throw err("$", "must be a JSON object");
103
+ }
104
+ const obj = raw;
105
+ const keys = Object.keys(obj);
106
+ if (keys.length === 0)
107
+ return {};
108
+ for (const key of keys) {
109
+ if (!TOP_LEVEL_KEYS.has(key)) {
110
+ throw err(`$.${key}`, "unknown property");
111
+ }
112
+ }
113
+ if (obj.version !== "1.0") {
114
+ throw err("$.version", 'is required and must be "1.0" when the object is non-empty');
115
+ }
116
+ checkGroup(obj.visual, "$.visual", VISUAL_BOOLEANS, VISUAL_STRINGS);
117
+ checkGroup(obj.auditory, "$.auditory", AUDITORY_BOOLEANS);
118
+ checkGroup(obj.motor, "$.motor", MOTOR_BOOLEANS);
119
+ checkGroup(obj.cognitive, "$.cognitive", COGNITIVE_BOOLEANS);
120
+ checkGroup(obj.data, "$.data", DATA_BOOLEANS);
121
+ if (obj.assistive_tech !== undefined) {
122
+ if (!Array.isArray(obj.assistive_tech)
123
+ || !obj.assistive_tech.every((v) => typeof v === "string")) {
124
+ throw err("$.assistive_tech", "must be an array of strings");
125
+ }
126
+ }
127
+ if (obj.notes !== undefined && typeof obj.notes !== "string") {
128
+ throw err("$.notes", "must be a string");
129
+ }
130
+ if (obj.extensions !== undefined) {
131
+ if (typeof obj.extensions !== "object" || obj.extensions === null || Array.isArray(obj.extensions)) {
132
+ throw err("$.extensions", "must be an object");
133
+ }
134
+ }
135
+ return obj;
136
+ }
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { readFileSync } from "node:fs";
9
9
  import { resolve as resolvePath } from "node:path";
10
+ import { normalizeEnumValue, QUESTION_TYPES } from "./enums.js";
10
11
  export function loadQuestionsManifest(filePath) {
11
12
  let raw;
12
13
  try {
@@ -30,6 +31,14 @@ export function loadQuestionsManifest(filePath) {
30
31
  if (!q || typeof q !== "object" || typeof q.question !== "string" || !q.question.trim()) {
31
32
  throw new Error(`questions[${i}].question must be a non-empty string.`);
32
33
  }
34
+ // Fold underscored variants (`single_choice`) back to the canonical
35
+ // hyphenated form (`single-choice`). Unknown types pass through untouched
36
+ // so the backend remains the source of truth for shape validation.
37
+ if (typeof q.type === "string") {
38
+ const canonical = normalizeEnumValue(q.type, QUESTION_TYPES);
39
+ if (canonical !== null)
40
+ q.type = canonical;
41
+ }
33
42
  }
34
43
  return parsed;
35
44
  }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Credit cost estimators — mirror the backend's billing formulas so the CLI
3
+ * can surface a pre-dispatch estimate without a network round-trip.
4
+ *
5
+ * The numbers here MUST match `ish-backend/app/{media,chat}/billing.py`
6
+ * and `app/billing/service.py`. If the backend formula changes, update
7
+ * this file in the same commit, otherwise the CLI will mislead agents.
8
+ *
9
+ * Today every modality uses the same shape: `max(1, round(steps / 10))`
10
+ * per principal (per tester for media/interactive, per conversation for
11
+ * chat, ×2 for tester-pair). Asks bill flat 1 credit per successful
12
+ * tester response. These are intentionally per-run estimates; long-term
13
+ * we'll fetch `GET /billing/rates` and parameterise modalities — see
14
+ * `reference/credits` docs page.
15
+ */
16
+ export interface CreditEstimate {
17
+ /** Upper bound (no early termination). Never claims exactness. */
18
+ upper_bound: number;
19
+ /** Stable identifier so agents can branch on shape if the formula evolves. */
20
+ formula: "media_per_tester" | "chat_solo" | "chat_pair" | "ask_per_response";
21
+ /** Human-readable breakdown so agents can explain the number to users. */
22
+ breakdown: string;
23
+ /** Always "credits" today; reserved for future-proofing (e.g. millicredits). */
24
+ unit: "credits";
25
+ }
26
+ /** Mirror of `app/media/billing.py::media_credit_cost`. */
27
+ export declare function mediaCreditCost(steps: number): number;
28
+ /** Mirror of `app/chat/billing.py::chat_credit_cost`. */
29
+ export declare function chatCreditCost(turns: number): number;
30
+ /**
31
+ * Media/interactive run: 1 credit-cost-per-tester × tester count. Modality
32
+ * doesn't currently affect the rate (interactive == text == video at the
33
+ * billing layer) — kept as a parameter for forward compatibility.
34
+ */
35
+ export declare function estimateMediaRun(args: {
36
+ testerCount: number;
37
+ maxInteractions: number;
38
+ }): CreditEstimate;
39
+ /** Solo chat (single tester, external chatbot). */
40
+ export declare function estimateChatSolo(args: {
41
+ testerCount: number;
42
+ maxTurns: number;
43
+ }): CreditEstimate;
44
+ /** Tester-pair chat: each turn bills both sides, so cost doubles. */
45
+ export declare function estimateChatPair(args: {
46
+ conversationCount: number;
47
+ maxTurns: number;
48
+ }): CreditEstimate;
49
+ /**
50
+ * Ask round: flat 1 credit per successful tester response (charged only
51
+ * for completed responses; the upper bound assumes everyone completes).
52
+ */
53
+ export declare function estimateAskRound(args: {
54
+ testerCount: number;
55
+ }): CreditEstimate;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Credit cost estimators — mirror the backend's billing formulas so the CLI
3
+ * can surface a pre-dispatch estimate without a network round-trip.
4
+ *
5
+ * The numbers here MUST match `ish-backend/app/{media,chat}/billing.py`
6
+ * and `app/billing/service.py`. If the backend formula changes, update
7
+ * this file in the same commit, otherwise the CLI will mislead agents.
8
+ *
9
+ * Today every modality uses the same shape: `max(1, round(steps / 10))`
10
+ * per principal (per tester for media/interactive, per conversation for
11
+ * chat, ×2 for tester-pair). Asks bill flat 1 credit per successful
12
+ * tester response. These are intentionally per-run estimates; long-term
13
+ * we'll fetch `GET /billing/rates` and parameterise modalities — see
14
+ * `reference/credits` docs page.
15
+ */
16
+ /** Mirror of `app/media/billing.py::media_credit_cost`. */
17
+ export function mediaCreditCost(steps) {
18
+ if (!Number.isFinite(steps) || steps <= 0)
19
+ return 1;
20
+ return Math.max(1, Math.round(steps / 10));
21
+ }
22
+ /** Mirror of `app/chat/billing.py::chat_credit_cost`. */
23
+ export function chatCreditCost(turns) {
24
+ if (!Number.isFinite(turns) || turns <= 0)
25
+ return 1;
26
+ return Math.max(1, Math.round(turns / 10));
27
+ }
28
+ /**
29
+ * Media/interactive run: 1 credit-cost-per-tester × tester count. Modality
30
+ * doesn't currently affect the rate (interactive == text == video at the
31
+ * billing layer) — kept as a parameter for forward compatibility.
32
+ */
33
+ export function estimateMediaRun(args) {
34
+ const perTester = mediaCreditCost(args.maxInteractions);
35
+ const total = Math.max(0, args.testerCount) * perTester;
36
+ return {
37
+ upper_bound: total,
38
+ formula: "media_per_tester",
39
+ breakdown: `${args.testerCount} tester(s) × max(1, round(${args.maxInteractions} steps / 10)) = ${args.testerCount} × ${perTester} = ${total}`,
40
+ unit: "credits",
41
+ };
42
+ }
43
+ /** Solo chat (single tester, external chatbot). */
44
+ export function estimateChatSolo(args) {
45
+ const perTester = chatCreditCost(args.maxTurns);
46
+ const total = Math.max(0, args.testerCount) * perTester;
47
+ return {
48
+ upper_bound: total,
49
+ formula: "chat_solo",
50
+ breakdown: `${args.testerCount} tester(s) × max(1, round(${args.maxTurns} turns / 10)) = ${args.testerCount} × ${perTester} = ${total}`,
51
+ unit: "credits",
52
+ };
53
+ }
54
+ /** Tester-pair chat: each turn bills both sides, so cost doubles. */
55
+ export function estimateChatPair(args) {
56
+ const perSide = chatCreditCost(args.maxTurns);
57
+ const total = Math.max(0, args.conversationCount) * perSide * 2;
58
+ return {
59
+ upper_bound: total,
60
+ formula: "chat_pair",
61
+ breakdown: `${args.conversationCount} conv × max(1, round(${args.maxTurns} turns / 10)) × 2 sides = ${args.conversationCount} × ${perSide} × 2 = ${total}`,
62
+ unit: "credits",
63
+ };
64
+ }
65
+ /**
66
+ * Ask round: flat 1 credit per successful tester response (charged only
67
+ * for completed responses; the upper bound assumes everyone completes).
68
+ */
69
+ export function estimateAskRound(args) {
70
+ const total = Math.max(0, args.testerCount);
71
+ return {
72
+ upper_bound: total,
73
+ formula: "ask_per_response",
74
+ breakdown: `${args.testerCount} tester(s) × 1 credit/response = ${total} (upper bound; only successful responses bill)`,
75
+ unit: "credits",
76
+ };
77
+ }