@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.
- package/dist/commands/chat-config.d.ts +23 -0
- package/dist/commands/chat-config.js +289 -0
- package/dist/commands/chat.js +26 -37
- 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-analyze.d.ts +41 -0
- package/dist/commands/study-analyze.js +187 -0
- package/dist/commands/study-run.js +359 -30
- package/dist/commands/study-screenshots.d.ts +20 -0
- package/dist/commands/study-screenshots.js +216 -0
- package/dist/commands/study.js +174 -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/alias-store.d.ts +1 -0
- package/dist/lib/alias-store.js +1 -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/command-helpers.d.ts +6 -0
- package/dist/lib/command-helpers.js +12 -0
- package/dist/lib/docs.js +1181 -38
- 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 +397 -19
- package/dist/lib/types.d.ts +6 -1
- package/dist/lib/types.js +1 -1
- 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
|
+
}
|
package/dist/commands/study.js
CHANGED
|
@@ -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
|
|
90
|
-
|
|
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
|
|
113
|
-
.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)")
|
|
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:
|
|
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
|
-
|
|
357
|
-
|
|
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
|
|
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
|
-
.
|
|
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>;
|