@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.
- package/dist/commands/iteration.js +219 -22
- package/dist/commands/profile.js +75 -9
- package/dist/commands/source.js +6 -4
- package/dist/commands/study-run.js +382 -34
- package/dist/commands/study.js +170 -9
- package/dist/commands/workspace.js +35 -2
- package/dist/lib/accessibility-profile.d.ts +12 -0
- package/dist/lib/accessibility-profile.js +136 -0
- package/dist/lib/ask-questions.js +9 -0
- package/dist/lib/billing.d.ts +55 -0
- package/dist/lib/billing.js +77 -0
- package/dist/lib/docs.js +1106 -36
- package/dist/lib/enums.d.ts +54 -0
- package/dist/lib/enums.js +100 -0
- package/dist/lib/local-sim/actions.d.ts +2 -1
- package/dist/lib/local-sim/actions.js +88 -13
- package/dist/lib/local-sim/loop.js +49 -19
- package/dist/lib/local-sim/tabs.d.ts +27 -0
- package/dist/lib/local-sim/tabs.js +157 -0
- package/dist/lib/local-sim/types.d.ts +15 -0
- package/dist/lib/modality.d.ts +70 -1
- package/dist/lib/modality.js +323 -17
- package/dist/lib/output.js +61 -4
- package/dist/lib/skill-content.js +382 -19
- package/dist/lib/types.d.ts +6 -1
- package/package.json +1 -1
package/dist/commands/study.js
CHANGED
|
@@ -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
|
|
92
|
-
|
|
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
|
|
115
|
-
.option("--endpoint-config <file>", "ChatbotEndpointConfig JSON file (or `-` for stdin); embedded directly. Mutually exclusive with --endpoint (chat modality
|
|
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:
|
|
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
|
-
|
|
359
|
-
|
|
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
|
|
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
|
-
.
|
|
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
|
+
}
|