@ishlabs/cli 0.12.2 → 0.13.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/study-analyze.d.ts +41 -0
- package/dist/commands/study-analyze.js +187 -0
- package/dist/commands/study-screenshots.d.ts +20 -0
- package/dist/commands/study-screenshots.js +216 -0
- package/dist/commands/study.js +4 -0
- package/dist/lib/alias-store.d.ts +1 -0
- package/dist/lib/alias-store.js +1 -0
- package/dist/lib/command-helpers.d.ts +6 -0
- package/dist/lib/command-helpers.js +12 -0
- package/dist/lib/docs.js +75 -2
- package/dist/lib/skill-content.js +15 -0
- package/dist/lib/types.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ish chat config — Manage chatbot configurations (agent shape).
|
|
3
|
+
*
|
|
4
|
+
* A configuration captures *what the chatbot is* (model, system prompt,
|
|
5
|
+
* tools, sub-agents, custom {key, value} fields) — distinct from the
|
|
6
|
+
* *wire envelope* on a chatbot endpoint. Configurations live N-per-
|
|
7
|
+
* endpoint; chat iterations reference one and freeze a snapshot at
|
|
8
|
+
* creation time so historical comparisons stay reproducible.
|
|
9
|
+
*
|
|
10
|
+
* Four-verb surface mirroring `ish secret`: `list / set / get /
|
|
11
|
+
* delete`. `set` is the create-or-update upsert (composite write —
|
|
12
|
+
* mirrors MCP `chatbot_config_set` and the `chatbot_setup` precedent).
|
|
13
|
+
* `get --view iterations` surfaces the cross-study aggregation.
|
|
14
|
+
*/
|
|
15
|
+
import type { Command } from "commander";
|
|
16
|
+
interface CustomField {
|
|
17
|
+
key: string;
|
|
18
|
+
value: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function parseCustomField(raw: string): CustomField;
|
|
21
|
+
export declare function parseCustomFieldsFlag(values: string[] | undefined): CustomField[] | undefined;
|
|
22
|
+
export declare function attachChatConfigCommands(parent: Command): void;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ish chat config — Manage chatbot configurations (agent shape).
|
|
3
|
+
*
|
|
4
|
+
* A configuration captures *what the chatbot is* (model, system prompt,
|
|
5
|
+
* tools, sub-agents, custom {key, value} fields) — distinct from the
|
|
6
|
+
* *wire envelope* on a chatbot endpoint. Configurations live N-per-
|
|
7
|
+
* endpoint; chat iterations reference one and freeze a snapshot at
|
|
8
|
+
* creation time so historical comparisons stay reproducible.
|
|
9
|
+
*
|
|
10
|
+
* Four-verb surface mirroring `ish secret`: `list / set / get /
|
|
11
|
+
* delete`. `set` is the create-or-update upsert (composite write —
|
|
12
|
+
* mirrors MCP `chatbot_config_set` and the `chatbot_setup` precedent).
|
|
13
|
+
* `get --view iterations` surfaces the cross-study aggregation.
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import { ApiError } from "../lib/api-client.js";
|
|
17
|
+
import { ALIAS_PREFIX, tagAlias } from "../lib/alias-store.js";
|
|
18
|
+
import { collectRepeatable, confirmDestructive, resolveChatConfig, resolveChatEndpoint, withClient, } from "../lib/command-helpers.js";
|
|
19
|
+
import { output } from "../lib/output.js";
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Helpers
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
export function parseCustomField(raw) {
|
|
24
|
+
const idx = raw.indexOf("=");
|
|
25
|
+
if (idx <= 0) {
|
|
26
|
+
throw new Error(`--custom-field must be \`key=value\` (got "${raw}").`);
|
|
27
|
+
}
|
|
28
|
+
return { key: raw.slice(0, idx).trim(), value: raw.slice(idx + 1) };
|
|
29
|
+
}
|
|
30
|
+
export function parseCustomFieldsFlag(values) {
|
|
31
|
+
if (values === undefined)
|
|
32
|
+
return undefined;
|
|
33
|
+
const seen = new Set();
|
|
34
|
+
const out = [];
|
|
35
|
+
for (const raw of values) {
|
|
36
|
+
const f = parseCustomField(raw);
|
|
37
|
+
if (!f.key)
|
|
38
|
+
throw new Error("--custom-field key cannot be empty.");
|
|
39
|
+
if (seen.has(f.key)) {
|
|
40
|
+
throw new Error(`--custom-field key "${f.key}" is repeated; keys must be unique.`);
|
|
41
|
+
}
|
|
42
|
+
seen.add(f.key);
|
|
43
|
+
out.push(f);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
function parseJsonListFlag(file, label) {
|
|
48
|
+
if (file === undefined)
|
|
49
|
+
return undefined;
|
|
50
|
+
const content = file === "-" ? fs.readFileSync(0, "utf-8") : fs.readFileSync(file, "utf-8");
|
|
51
|
+
let parsed;
|
|
52
|
+
try {
|
|
53
|
+
parsed = JSON.parse(content);
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
const m = err instanceof Error ? err.message : String(err);
|
|
57
|
+
throw new Error(`${label} is not valid JSON: ${m}`);
|
|
58
|
+
}
|
|
59
|
+
if (!Array.isArray(parsed))
|
|
60
|
+
throw new Error(`${label} must be a JSON array.`);
|
|
61
|
+
return parsed;
|
|
62
|
+
}
|
|
63
|
+
function readSystemPrompt(systemPrompt, systemPromptFile) {
|
|
64
|
+
if (systemPrompt !== undefined && systemPromptFile !== undefined) {
|
|
65
|
+
throw new Error("Pass at most one of --system-prompt or --system-prompt-file.");
|
|
66
|
+
}
|
|
67
|
+
if (systemPrompt !== undefined)
|
|
68
|
+
return systemPrompt;
|
|
69
|
+
if (systemPromptFile !== undefined) {
|
|
70
|
+
return systemPromptFile === "-"
|
|
71
|
+
? fs.readFileSync(0, "utf-8")
|
|
72
|
+
: fs.readFileSync(systemPromptFile, "utf-8");
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
function pruneUndefined(obj) {
|
|
77
|
+
const out = {};
|
|
78
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
79
|
+
if (v !== undefined)
|
|
80
|
+
out[k] = v;
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Commands
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
function attachList(parent) {
|
|
88
|
+
parent
|
|
89
|
+
.command("list")
|
|
90
|
+
.description("List configurations under a chatbot endpoint")
|
|
91
|
+
.option("--endpoint <id>", "Endpoint alias or UUID (defaults to active endpoint)")
|
|
92
|
+
.addHelpText("after", "\nExamples:\n $ ish chat config list\n $ ish chat config list --json | jq '.[] | {alias, name, isDefault, usageCount}'")
|
|
93
|
+
.action(async (opts, cmd) => {
|
|
94
|
+
await withClient(cmd, async (client, globals) => {
|
|
95
|
+
const epId = resolveChatEndpoint(undefined, opts.endpoint);
|
|
96
|
+
const rows = await client.get(`/chatbot-endpoints/${epId}/configurations`);
|
|
97
|
+
for (const row of rows) {
|
|
98
|
+
if (row.id)
|
|
99
|
+
tagAlias(ALIAS_PREFIX.chatConfig, row.id);
|
|
100
|
+
}
|
|
101
|
+
if (globals.json) {
|
|
102
|
+
output(rows, true);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (rows.length === 0) {
|
|
106
|
+
console.log("No configurations yet. Run `ish chat config set --name <name>` to author one.");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
for (const row of rows) {
|
|
110
|
+
const alias = row.id ? tagAlias(ALIAS_PREFIX.chatConfig, row.id) : "?";
|
|
111
|
+
const def = row.isDefault ? " [default]" : "";
|
|
112
|
+
const ver = row.versionLabel ? ` (${row.versionLabel})` : "";
|
|
113
|
+
const model = row.model ? ` model=${row.model}` : "";
|
|
114
|
+
const usage = ` used=${row.usageCount ?? 0}`;
|
|
115
|
+
console.log(`${alias} ${row.name}${ver}${def}${model}${usage}`);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
function attachSet(parent) {
|
|
121
|
+
parent
|
|
122
|
+
.command("set")
|
|
123
|
+
.description("Create or update a chatbot configuration (composite upsert)")
|
|
124
|
+
.requiredOption("--name <name>", "Configuration name (unique per endpoint)")
|
|
125
|
+
.option("--endpoint <id>", "Endpoint alias or UUID (defaults to active endpoint; create only)")
|
|
126
|
+
.option("--config <id>", "Configuration alias or UUID — switches to update mode")
|
|
127
|
+
.option("--version-label <label>", "Optional version tag (e.g. v3, 1.2.0, git sha)")
|
|
128
|
+
.option("--description <text>", "Optional description")
|
|
129
|
+
.option("--model <id>", "Primary model identifier (e.g. claude-sonnet-4-6)")
|
|
130
|
+
.option("--system-prompt <text>", "System prompt text")
|
|
131
|
+
.option("--system-prompt-file <file>", 'Read system prompt from file (or "-" for stdin)')
|
|
132
|
+
.option("--tools-file <file>", 'JSON array of tools (or "-" for stdin)')
|
|
133
|
+
.option("--sub-agents-file <file>", 'JSON array of sub-agents (or "-" for stdin)')
|
|
134
|
+
.option("--custom-field <key=value>", "Custom field; repeat. Replaces all custom fields on update.", collectRepeatable)
|
|
135
|
+
.option("--default", "Mark as the endpoint's default configuration")
|
|
136
|
+
.addHelpText("after", `
|
|
137
|
+
Without --config, POSTs a new configuration under the endpoint. With
|
|
138
|
+
--config, PUTs an update. With --default, fires the set-default call
|
|
139
|
+
after the write so a single command leaves the endpoint with the
|
|
140
|
+
right default. Editing does NOT retroactively change historical
|
|
141
|
+
iterations — each iteration froze a snapshot at creation.
|
|
142
|
+
|
|
143
|
+
Examples:
|
|
144
|
+
$ ish chat config set --name v1-sonnet --model claude-sonnet-4-6 \\
|
|
145
|
+
--system-prompt-file ./prompt.txt --custom-field git_sha=abc --default
|
|
146
|
+
$ ish chat config set --config cc-abc --name v1.1-sonnet
|
|
147
|
+
$ cat tools.json | ish chat config set --name with-search --tools-file -`)
|
|
148
|
+
.action(async (opts, cmd) => {
|
|
149
|
+
await withClient(cmd, async (client, globals) => {
|
|
150
|
+
const cid = opts.config ? resolveChatConfig(undefined, opts.config) : null;
|
|
151
|
+
const body = pruneUndefined({
|
|
152
|
+
name: opts.name,
|
|
153
|
+
versionLabel: opts.versionLabel,
|
|
154
|
+
description: opts.description,
|
|
155
|
+
model: opts.model,
|
|
156
|
+
systemPrompt: readSystemPrompt(opts.systemPrompt, opts.systemPromptFile),
|
|
157
|
+
tools: parseJsonListFlag(opts.toolsFile, "--tools-file"),
|
|
158
|
+
subAgents: parseJsonListFlag(opts.subAgentsFile, "--sub-agents-file"),
|
|
159
|
+
customFields: parseCustomFieldsFlag(opts.customField),
|
|
160
|
+
});
|
|
161
|
+
let saved;
|
|
162
|
+
if (cid === null) {
|
|
163
|
+
const epId = resolveChatEndpoint(undefined, opts.endpoint);
|
|
164
|
+
if (opts.default)
|
|
165
|
+
body.isDefault = true;
|
|
166
|
+
saved = await client.post(`/chatbot-endpoints/${epId}/configurations`, body);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
saved = await client.put(`/chatbot-configurations/${cid}`, body);
|
|
170
|
+
if (opts.default) {
|
|
171
|
+
saved = await client.post(`/chatbot-configurations/${cid}/set-default`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (saved.id)
|
|
175
|
+
tagAlias(ALIAS_PREFIX.chatConfig, saved.id);
|
|
176
|
+
if (!globals.quiet) {
|
|
177
|
+
const action = cid === null ? "Created" : "Updated";
|
|
178
|
+
const tag = saved.isDefault ? " (default)" : "";
|
|
179
|
+
const alias = saved.id ? tagAlias(ALIAS_PREFIX.chatConfig, saved.id) : "?";
|
|
180
|
+
console.error(`${action} configuration ${alias}${tag}`);
|
|
181
|
+
}
|
|
182
|
+
output(saved, globals.json);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
function attachGet(parent) {
|
|
187
|
+
parent
|
|
188
|
+
.command("get")
|
|
189
|
+
.description("Get a chatbot configuration (or its referencing iterations)")
|
|
190
|
+
.argument("[id]", "Configuration alias or UUID")
|
|
191
|
+
.option("--config <id>", "Configuration alias or UUID (alternative to positional)")
|
|
192
|
+
.option("--view <view>", "default | iterations. `iterations` returns the cross-study aggregation list.", "default")
|
|
193
|
+
.addHelpText("after", `
|
|
194
|
+
\`--view iterations\` returns every iteration referencing this
|
|
195
|
+
configuration across studies, joined to study metadata. Mirrors
|
|
196
|
+
\`study_get(view=...)\`.
|
|
197
|
+
|
|
198
|
+
Examples:
|
|
199
|
+
$ ish chat config get cc-abc
|
|
200
|
+
$ ish chat config get cc-abc --view iterations --json | jq 'group_by(.studyName) | length'`)
|
|
201
|
+
.action(async (id, opts, cmd) => {
|
|
202
|
+
await withClient(cmd, async (client, globals) => {
|
|
203
|
+
const cid = resolveChatConfig(id, opts.config);
|
|
204
|
+
if (opts.view === "iterations") {
|
|
205
|
+
const rows = await client.get(`/chatbot-configurations/${cid}/iterations`);
|
|
206
|
+
if (globals.json) {
|
|
207
|
+
output(rows, true);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (rows.length === 0) {
|
|
211
|
+
console.log("No iterations reference this configuration yet.");
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
for (const r of rows) {
|
|
215
|
+
console.log(`${r.iterationLabel} ${r.studyName} — ${r.iterationName} (${r.createdAt})`);
|
|
216
|
+
}
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (opts.view !== "default") {
|
|
220
|
+
throw new Error(`--view must be 'default' or 'iterations' (got "${opts.view}").`);
|
|
221
|
+
}
|
|
222
|
+
const row = await client.get(`/chatbot-configurations/${cid}`);
|
|
223
|
+
if (row.id)
|
|
224
|
+
tagAlias(ALIAS_PREFIX.chatConfig, row.id);
|
|
225
|
+
output(row, globals.json);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
function attachDelete(parent) {
|
|
230
|
+
parent
|
|
231
|
+
.command("delete")
|
|
232
|
+
.description("Delete a chatbot configuration (rejected when iterations reference it)")
|
|
233
|
+
.argument("[id]", "Configuration alias or UUID")
|
|
234
|
+
.option("--config <id>", "Configuration alias or UUID (alternative to positional)")
|
|
235
|
+
.option("-y, --yes", "Skip confirmation prompt")
|
|
236
|
+
.addHelpText("after", `
|
|
237
|
+
Returns HTTP 409 (configuration_in_use) when iterations reference the
|
|
238
|
+
configuration. Snapshots remain readable on those iterations but the
|
|
239
|
+
live cross-study aggregation link breaks. Inspect \`usageCount\`
|
|
240
|
+
via \`ish chat config get <id>\` first.
|
|
241
|
+
|
|
242
|
+
Examples:
|
|
243
|
+
$ ish chat config delete cc-abc --yes`)
|
|
244
|
+
.action(async (id, opts, cmd) => {
|
|
245
|
+
await withClient(cmd, async (client, globals) => {
|
|
246
|
+
const cid = resolveChatConfig(id, opts.config);
|
|
247
|
+
await confirmDestructive(`Delete chat configuration ${tagAlias(ALIAS_PREFIX.chatConfig, cid)}? This cannot be undone.`, { yes: opts.yes, json: globals.json });
|
|
248
|
+
try {
|
|
249
|
+
await client.del(`/chatbot-configurations/${cid}`);
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
if (err instanceof ApiError && err.status === 409) {
|
|
253
|
+
const detail = err.body;
|
|
254
|
+
const ids = detail?.detail?.iteration_ids ?? [];
|
|
255
|
+
const e = new Error(`Cannot delete: configuration is referenced by ${ids.length} iteration(s).`);
|
|
256
|
+
e.error_kind =
|
|
257
|
+
"configuration_in_use";
|
|
258
|
+
e.iteration_ids = ids;
|
|
259
|
+
throw e;
|
|
260
|
+
}
|
|
261
|
+
throw err;
|
|
262
|
+
}
|
|
263
|
+
output({
|
|
264
|
+
success: true,
|
|
265
|
+
deleted: true,
|
|
266
|
+
id: cid,
|
|
267
|
+
alias: tagAlias(ALIAS_PREFIX.chatConfig, cid),
|
|
268
|
+
}, globals.json, { writePath: true });
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// Registration
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
export function attachChatConfigCommands(parent) {
|
|
276
|
+
const config = parent
|
|
277
|
+
.command("config")
|
|
278
|
+
.description("Manage chatbot configurations (agent shape: model, prompt, tools, sub-agents)")
|
|
279
|
+
.addHelpText("after", `
|
|
280
|
+
Configurations live under a chatbot endpoint and are referenced by chat
|
|
281
|
+
iterations. Each iteration freezes a snapshot at creation so editing a
|
|
282
|
+
configuration does NOT retroactively change historical iterations. Use
|
|
283
|
+
\`set --config <id>\` to update in place, or omit \`--config\` to create
|
|
284
|
+
a new configuration. Aliases use the \`cc-\` prefix.`);
|
|
285
|
+
attachList(config);
|
|
286
|
+
attachSet(config);
|
|
287
|
+
attachGet(config);
|
|
288
|
+
attachDelete(config);
|
|
289
|
+
}
|
package/dist/commands/chat.js
CHANGED
|
@@ -18,6 +18,7 @@ import { ApiError } from "../lib/api-client.js";
|
|
|
18
18
|
import { output } from "../lib/output.js";
|
|
19
19
|
import { formatChatEndpointList, formatChatEndpointDetail, envelopeFromRow, } from "../lib/chat-endpoint-formatters.js";
|
|
20
20
|
import { getChatEndpointTemplate, TEMPLATE_NAMES, } from "../lib/chat-endpoint-templates.js";
|
|
21
|
+
import { attachChatConfigCommands } from "./chat-config.js";
|
|
21
22
|
// ---------------------------------------------------------------------------
|
|
22
23
|
// Helpers
|
|
23
24
|
// ---------------------------------------------------------------------------
|
|
@@ -57,25 +58,6 @@ function urlLooksLocal(url) {
|
|
|
57
58
|
return false;
|
|
58
59
|
}
|
|
59
60
|
}
|
|
60
|
-
function inferredToConfig(inferred) {
|
|
61
|
-
const cfg = {
|
|
62
|
-
transport: inferred.transport,
|
|
63
|
-
outgoing: {
|
|
64
|
-
url: inferred.outgoing.url ?? undefined,
|
|
65
|
-
method: inferred.outgoing.method,
|
|
66
|
-
headers: inferred.outgoing.headers ?? {},
|
|
67
|
-
bodyTemplate: inferred.outgoing.bodyTemplate ?? {},
|
|
68
|
-
mode: inferred.outgoing.mode,
|
|
69
|
-
roleAliases: inferred.outgoing.roleAliases ?? {},
|
|
70
|
-
},
|
|
71
|
-
incoming: inferred.incoming,
|
|
72
|
-
asyncPoll: inferred.asyncPoll ?? null,
|
|
73
|
-
};
|
|
74
|
-
if (inferred.streaming) {
|
|
75
|
-
cfg.streaming = inferred.streaming;
|
|
76
|
-
}
|
|
77
|
-
return cfg;
|
|
78
|
-
}
|
|
79
61
|
async function tunnelGuard(client) {
|
|
80
62
|
try {
|
|
81
63
|
await client.get("/connect/active");
|
|
@@ -355,12 +337,12 @@ endpoint, apply the override, and PUT the merged result. Field flags win over
|
|
|
355
337
|
});
|
|
356
338
|
}
|
|
357
339
|
// ---------------------------------------------------------------------------
|
|
358
|
-
// init — auto-detect-
|
|
340
|
+
// init — test-and-map onboarding (auto-detect via /chat/test-and-map)
|
|
359
341
|
// ---------------------------------------------------------------------------
|
|
360
342
|
function attachChatEndpointInit(parent) {
|
|
361
343
|
parent
|
|
362
344
|
.command("init")
|
|
363
|
-
.description("Author an endpoint from a curl/JSON sample via
|
|
345
|
+
.description("Author an endpoint from a curl/JSON sample via test-and-map, or from a known-good template")
|
|
364
346
|
.option("--from-curl <file>", 'Path to a curl example file (or "-" for stdin)')
|
|
365
347
|
.option("--from-json <file>", 'Path to a JSON request/response sample (or "-" for stdin)')
|
|
366
348
|
.option("--template <name>", `Start from a known-good template (one of: ${TEMPLATE_NAMES.join(", ")})`)
|
|
@@ -446,22 +428,27 @@ Examples:
|
|
|
446
428
|
output(result, globals.json, { writePath: true });
|
|
447
429
|
return;
|
|
448
430
|
}
|
|
449
|
-
// Auto-detect path (curl or JSON paste).
|
|
431
|
+
// Auto-detect path (curl or JSON paste). Drives the same
|
|
432
|
+
// /test-and-map endpoint the editor uses, so the CLI and editor
|
|
433
|
+
// stay aligned on inference behaviour and error vocabulary.
|
|
450
434
|
const path = (opts.fromCurl ?? opts.fromJson);
|
|
451
435
|
const paste = await readFileOrStdin(path);
|
|
452
|
-
const
|
|
453
|
-
if (
|
|
436
|
+
const mapRes = await client.post(`/products/${ws}/chat/test-and-map`, { documentation: paste }, { timeout: 120_000 });
|
|
437
|
+
if (mapRes.kind === "failure") {
|
|
454
438
|
// Surface as a structured failure envelope on stdout AND throw so
|
|
455
439
|
// the wrapper sets a non-zero exit. The thrown Error carries the
|
|
456
|
-
//
|
|
457
|
-
const err = new Error(
|
|
458
|
-
err.error_kind =
|
|
440
|
+
// backend's error_kind for the agent to branch on.
|
|
441
|
+
const err = new Error(mapRes.errorMessage ?? "test-and-map failed.");
|
|
442
|
+
err.error_kind = mapRes.errorKind;
|
|
459
443
|
throw err;
|
|
460
444
|
}
|
|
461
|
-
const
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
445
|
+
const config = mapRes.inferredConfig;
|
|
446
|
+
const inferredUrl = (typeof config.outgoing?.url === "string" && config.outgoing.url
|
|
447
|
+
? config.outgoing.url
|
|
448
|
+
: null);
|
|
449
|
+
// Trust the backend's tunnel detection when present; fall back to
|
|
450
|
+
// the local heuristic only if the envelope didn't populate it.
|
|
451
|
+
const detectedTunnel = mapRes.tunnelBackedDetected ?? urlLooksLocal(inferredUrl);
|
|
465
452
|
let tunnelBacked;
|
|
466
453
|
if (opts.tunnelBacked === true)
|
|
467
454
|
tunnelBacked = true;
|
|
@@ -474,8 +461,9 @@ Examples:
|
|
|
474
461
|
if (!inferredUrl) {
|
|
475
462
|
warnings.push("Inferred shape has no URL; set --url before testing.");
|
|
476
463
|
}
|
|
477
|
-
|
|
478
|
-
|
|
464
|
+
const confidence = mapRes.confidence ?? null;
|
|
465
|
+
if (confidence && confidence !== "high") {
|
|
466
|
+
warnings.push(`Auto-detect confidence: ${confidence} — verify the shape before running.`);
|
|
479
467
|
}
|
|
480
468
|
// Decide whether to save. --no-save short-circuits; otherwise save when
|
|
481
469
|
// a name is available (--name wins; else fall back to the inferred
|
|
@@ -508,8 +496,8 @@ Examples:
|
|
|
508
496
|
}
|
|
509
497
|
}
|
|
510
498
|
}
|
|
511
|
-
const missingSignals = Array.isArray(
|
|
512
|
-
?
|
|
499
|
+
const missingSignals = Array.isArray(mapRes.missingSignals)
|
|
500
|
+
? mapRes.missingSignals
|
|
513
501
|
: [];
|
|
514
502
|
const result = {
|
|
515
503
|
success: true,
|
|
@@ -519,8 +507,8 @@ Examples:
|
|
|
519
507
|
config,
|
|
520
508
|
tunnel_backed: tunnelBacked,
|
|
521
509
|
tunnel_backed_detected: detectedTunnel,
|
|
522
|
-
confidence
|
|
523
|
-
explanation:
|
|
510
|
+
confidence,
|
|
511
|
+
explanation: mapRes.explanation ?? "",
|
|
524
512
|
missingSignals,
|
|
525
513
|
warnings,
|
|
526
514
|
};
|
|
@@ -714,6 +702,7 @@ mirrors that editing model.`);
|
|
|
714
702
|
attachChatEndpointInit(endpoint);
|
|
715
703
|
attachChatEndpointTest(endpoint);
|
|
716
704
|
attachChatEndpointMap(endpoint);
|
|
705
|
+
attachChatConfigCommands(chat);
|
|
717
706
|
}
|
|
718
707
|
// Re-exported for tests / external integration if needed.
|
|
719
708
|
export { envelopeFromRow };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ish study analyze / ish study insights — AI summary + key insights for a
|
|
3
|
+
* study.
|
|
4
|
+
*
|
|
5
|
+
* Wraps three backend endpoints already shipped server-side and used by the
|
|
6
|
+
* web overview page:
|
|
7
|
+
*
|
|
8
|
+
* POST /studies/{id}/analysis — kick off an analysis run
|
|
9
|
+
* GET /studies/{id}/results — list runs for a study (newest first)
|
|
10
|
+
* GET /study-results/{id} — fetch one run
|
|
11
|
+
*
|
|
12
|
+
* Status state machine: pending → running → (completed | failed). Polling
|
|
13
|
+
* mirrors the FE behaviour (5s here vs 15s in the FE — agents care more
|
|
14
|
+
* about latency than load).
|
|
15
|
+
*/
|
|
16
|
+
import type { Command } from "commander";
|
|
17
|
+
export interface KeyInsight {
|
|
18
|
+
id: string;
|
|
19
|
+
title: string;
|
|
20
|
+
description: string;
|
|
21
|
+
category: "friction" | "confusion" | "blocker" | "observation" | "positive";
|
|
22
|
+
tester_count: number;
|
|
23
|
+
iteration_labels: string[];
|
|
24
|
+
is_discarded: boolean;
|
|
25
|
+
interaction_ids: string[];
|
|
26
|
+
sequence: number;
|
|
27
|
+
created_at: string;
|
|
28
|
+
}
|
|
29
|
+
export interface StudyResult {
|
|
30
|
+
id: string;
|
|
31
|
+
study_id: string;
|
|
32
|
+
status: "pending" | "running" | "completed" | "failed";
|
|
33
|
+
modality: string;
|
|
34
|
+
summary: string | null;
|
|
35
|
+
error_message: string | null;
|
|
36
|
+
progress_message: string | null;
|
|
37
|
+
key_insights: KeyInsight[];
|
|
38
|
+
created_at: string;
|
|
39
|
+
started_at: string | null;
|
|
40
|
+
}
|
|
41
|
+
export declare function attachStudyAnalyzeCommands(study: Command): void;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ish study analyze / ish study insights — AI summary + key insights for a
|
|
3
|
+
* study.
|
|
4
|
+
*
|
|
5
|
+
* Wraps three backend endpoints already shipped server-side and used by the
|
|
6
|
+
* web overview page:
|
|
7
|
+
*
|
|
8
|
+
* POST /studies/{id}/analysis — kick off an analysis run
|
|
9
|
+
* GET /studies/{id}/results — list runs for a study (newest first)
|
|
10
|
+
* GET /study-results/{id} — fetch one run
|
|
11
|
+
*
|
|
12
|
+
* Status state machine: pending → running → (completed | failed). Polling
|
|
13
|
+
* mirrors the FE behaviour (5s here vs 15s in the FE — agents care more
|
|
14
|
+
* about latency than load).
|
|
15
|
+
*/
|
|
16
|
+
import { withClient, resolveStudy, parseWaitTimeout } from "../lib/command-helpers.js";
|
|
17
|
+
import { resolveId } from "../lib/alias-store.js";
|
|
18
|
+
import { output, printTable } from "../lib/output.js";
|
|
19
|
+
import { WaitTimeoutError } from "./study-run.js";
|
|
20
|
+
const POLL_INTERVAL_MS = 5_000;
|
|
21
|
+
const ANALYSIS_TERMINAL_STATUSES = new Set(["completed", "failed"]);
|
|
22
|
+
async function pollAnalysisUntilDone(client, opts) {
|
|
23
|
+
const start = Date.now();
|
|
24
|
+
let lastReported = "";
|
|
25
|
+
while (true) {
|
|
26
|
+
const result = await client.get(`/study-results/${opts.resultId}`, undefined, { timeout: 60_000 });
|
|
27
|
+
if (!opts.quiet) {
|
|
28
|
+
const line = result.progress_message
|
|
29
|
+
? `${result.status}: ${result.progress_message}`
|
|
30
|
+
: result.status;
|
|
31
|
+
if (line !== lastReported) {
|
|
32
|
+
process.stderr.write(` ${line}\n`);
|
|
33
|
+
lastReported = line;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (ANALYSIS_TERMINAL_STATUSES.has(result.status)) {
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
if (Date.now() - start > opts.timeoutMs) {
|
|
40
|
+
throw new WaitTimeoutError(`Timed out after ${Math.round(opts.timeoutMs / 1000)}s waiting for analysis. ` +
|
|
41
|
+
`Status: ${result.status}. Run \`ish study insights ${opts.studyId}\` to check later.`, {
|
|
42
|
+
study_id: opts.studyId,
|
|
43
|
+
timeout_seconds: Math.round(opts.timeoutMs / 1000),
|
|
44
|
+
done: 0,
|
|
45
|
+
total: 1,
|
|
46
|
+
pending: 1,
|
|
47
|
+
rows: [],
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function formatKeyInsightsTable(insights) {
|
|
54
|
+
printTable(["#", "CATEGORY", "TESTERS", "TITLE"], insights
|
|
55
|
+
.filter((i) => !i.is_discarded)
|
|
56
|
+
.map((i) => [
|
|
57
|
+
String(i.sequence),
|
|
58
|
+
i.category,
|
|
59
|
+
String(i.tester_count),
|
|
60
|
+
i.title,
|
|
61
|
+
]));
|
|
62
|
+
}
|
|
63
|
+
function formatStudyAnalysis(result) {
|
|
64
|
+
const header = `Analysis ${result.id} — ${result.status}`;
|
|
65
|
+
console.log(header);
|
|
66
|
+
if (result.started_at)
|
|
67
|
+
console.log(` started_at: ${result.started_at}`);
|
|
68
|
+
if (result.progress_message)
|
|
69
|
+
console.log(` progress: ${result.progress_message}`);
|
|
70
|
+
if (result.error_message)
|
|
71
|
+
console.log(` error: ${result.error_message}`);
|
|
72
|
+
if (result.summary) {
|
|
73
|
+
console.log(`\nSummary:\n${result.summary}`);
|
|
74
|
+
}
|
|
75
|
+
if (result.key_insights && result.key_insights.length > 0) {
|
|
76
|
+
console.log("\nKey insights:");
|
|
77
|
+
formatKeyInsightsTable(result.key_insights);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export function attachStudyAnalyzeCommands(study) {
|
|
81
|
+
study
|
|
82
|
+
.command("analyze")
|
|
83
|
+
.description("Trigger an AI summary + key-insights analysis for a study. " +
|
|
84
|
+
"First analysis per study is free; subsequent runs cost 10 credits.")
|
|
85
|
+
.argument("[id]", "Study ID (defaults to active study)")
|
|
86
|
+
.option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the study)")
|
|
87
|
+
.option("--wait", "Poll until the run reaches completed or failed")
|
|
88
|
+
.option("--timeout <s>", "Wait timeout in seconds (default 300; only with --wait)")
|
|
89
|
+
.addHelpText("after", `
|
|
90
|
+
Examples:
|
|
91
|
+
$ ish study analyze # uses active study
|
|
92
|
+
$ ish study analyze <study-id>
|
|
93
|
+
$ ish study analyze <study-id> --wait
|
|
94
|
+
$ ish study analyze <study-id> --wait --timeout 600 --json
|
|
95
|
+
|
|
96
|
+
Prerequisites (enforced server-side):
|
|
97
|
+
- Study modality is one of: interactive, video, audio, text, image, document
|
|
98
|
+
- At least 5 testers with completed interactions
|
|
99
|
+
|
|
100
|
+
Read prior runs:
|
|
101
|
+
$ ish study insights <study-id>`)
|
|
102
|
+
.action(async (id, opts, cmd) => {
|
|
103
|
+
await withClient(cmd, async (client, globals) => {
|
|
104
|
+
const studyId = resolveStudy(id);
|
|
105
|
+
const initial = await client.post(`/studies/${studyId}/analysis`);
|
|
106
|
+
if (!opts.wait) {
|
|
107
|
+
if (globals.json) {
|
|
108
|
+
output(initial, true);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
formatStudyAnalysis(initial);
|
|
112
|
+
console.error(`\n Run \`ish study insights ${studyId}\` to read the result once it completes,\n or rerun with --wait to block.`);
|
|
113
|
+
}
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const timeoutMs = parseWaitTimeout(opts.timeout, 300_000);
|
|
117
|
+
const final = await pollAnalysisUntilDone(client, {
|
|
118
|
+
resultId: initial.id,
|
|
119
|
+
studyId,
|
|
120
|
+
timeoutMs,
|
|
121
|
+
quiet: globals.json,
|
|
122
|
+
});
|
|
123
|
+
if (globals.json) {
|
|
124
|
+
output(final, true);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
formatStudyAnalysis(final);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
study
|
|
132
|
+
.command("insights")
|
|
133
|
+
.description("Read AI-generated insights from prior `ish study analyze` runs (newest first).")
|
|
134
|
+
.argument("[id]", "Study ID (defaults to active study)")
|
|
135
|
+
.option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the study)")
|
|
136
|
+
.option("--all", "Show every prior run as a compact table (default: latest only)")
|
|
137
|
+
.option("--result <id>", "Fetch a specific result by id rather than the latest")
|
|
138
|
+
.addHelpText("after", `
|
|
139
|
+
Examples:
|
|
140
|
+
$ ish study insights # latest run for active study
|
|
141
|
+
$ ish study insights <study-id>
|
|
142
|
+
$ ish study insights <study-id> --all
|
|
143
|
+
$ ish study insights <study-id> --json
|
|
144
|
+
$ ish study insights <study-id> --result <result-id>
|
|
145
|
+
|
|
146
|
+
Trigger a new run with \`ish study analyze --wait\`.`)
|
|
147
|
+
.action(async (id, opts, cmd) => {
|
|
148
|
+
await withClient(cmd, async (client, globals) => {
|
|
149
|
+
if (opts.result) {
|
|
150
|
+
const single = await client.get(`/study-results/${resolveId(opts.result)}`);
|
|
151
|
+
if (globals.json) {
|
|
152
|
+
output(single, true);
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
formatStudyAnalysis(single);
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const studyId = resolveStudy(id);
|
|
160
|
+
const history = await client.get(`/studies/${studyId}/results`);
|
|
161
|
+
const latest = history[0] ?? null;
|
|
162
|
+
if (globals.json) {
|
|
163
|
+
output({ latest, history }, true);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (!latest) {
|
|
167
|
+
console.log("No analysis runs yet. Trigger one with `ish study analyze`.");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (opts.all) {
|
|
171
|
+
console.log(`Analysis history for study ${studyId}:`);
|
|
172
|
+
printTable(["#", "ID", "STATUS", "STARTED", "INSIGHTS"], history.map((r, idx) => [
|
|
173
|
+
String(history.length - idx),
|
|
174
|
+
r.id,
|
|
175
|
+
r.status,
|
|
176
|
+
r.started_at ?? r.created_at,
|
|
177
|
+
String((r.key_insights ?? []).length),
|
|
178
|
+
]));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
formatStudyAnalysis(latest);
|
|
182
|
+
if (history.length > 1) {
|
|
183
|
+
console.error(`\n ${history.length - 1} prior run${history.length - 1 === 1 ? "" : "s"} — pass --all to list.`);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
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 type { Command } from "commander";
|
|
20
|
+
export declare function attachStudyScreenshotsCommands(study: Command): void;
|
|
@@ -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
|
@@ -13,6 +13,8 @@ import { loadQuestionsManifest } from "../lib/ask-questions.js";
|
|
|
13
13
|
import { isLocalPath } from "../lib/upload.js";
|
|
14
14
|
import { attachStudyRunCommands } from "./study-run.js";
|
|
15
15
|
import { attachStudyTesterCommands } from "./study-tester.js";
|
|
16
|
+
import { attachStudyAnalyzeCommands } from "./study-analyze.js";
|
|
17
|
+
import { attachStudyScreenshotsCommands } from "./study-screenshots.js";
|
|
16
18
|
function collectRepeatable(value, prev = []) {
|
|
17
19
|
return prev.concat([value]);
|
|
18
20
|
}
|
|
@@ -690,4 +692,6 @@ Examples:
|
|
|
690
692
|
});
|
|
691
693
|
attachStudyRunCommands(study);
|
|
692
694
|
attachStudyTesterCommands(study);
|
|
695
|
+
attachStudyAnalyzeCommands(study);
|
|
696
|
+
attachStudyScreenshotsCommands(study);
|
|
693
697
|
}
|
package/dist/lib/alias-store.js
CHANGED
|
@@ -129,6 +129,12 @@ export declare function resolveAsk(explicit?: string): string;
|
|
|
129
129
|
* endpoint persisted by `ish chat endpoint use`. Throws when none are set.
|
|
130
130
|
*/
|
|
131
131
|
export declare function resolveChatEndpoint(positional?: string, flag?: string): string;
|
|
132
|
+
/**
|
|
133
|
+
* Resolve a chat configuration id from a positional arg or `--config` flag.
|
|
134
|
+
* Configurations don't have an "active" notion (unlike endpoints), so this
|
|
135
|
+
* helper only resolves explicit input — there is no fallback.
|
|
136
|
+
*/
|
|
137
|
+
export declare function resolveChatConfig(positional?: string, flag?: string): string;
|
|
132
138
|
/** Commander option-collector for repeatable flags (e.g. `--variant text:"..."` repeated). */
|
|
133
139
|
export declare function collectRepeatable(value: string, prev?: string[]): string[];
|
|
134
140
|
/**
|
|
@@ -598,6 +598,18 @@ export function resolveChatEndpoint(positional, flag) {
|
|
|
598
598
|
return config.chat_endpoint;
|
|
599
599
|
throw new Error('No chat endpoint set. Use `ish chat endpoint use <id>`, pass the endpoint id, or set --endpoint.');
|
|
600
600
|
}
|
|
601
|
+
/**
|
|
602
|
+
* Resolve a chat configuration id from a positional arg or `--config` flag.
|
|
603
|
+
* Configurations don't have an "active" notion (unlike endpoints), so this
|
|
604
|
+
* helper only resolves explicit input — there is no fallback.
|
|
605
|
+
*/
|
|
606
|
+
export function resolveChatConfig(positional, flag) {
|
|
607
|
+
if (positional)
|
|
608
|
+
return resolveId(positional);
|
|
609
|
+
if (flag)
|
|
610
|
+
return resolveId(flag);
|
|
611
|
+
throw new Error("Pass a chat configuration alias / UUID (positional argument or --config <id>).");
|
|
612
|
+
}
|
|
601
613
|
/** Commander option-collector for repeatable flags (e.g. `--variant text:"..."` repeated). */
|
|
602
614
|
export function collectRepeatable(value, prev = []) {
|
|
603
615
|
return prev.concat([value]);
|
package/dist/lib/docs.js
CHANGED
|
@@ -1116,7 +1116,10 @@ time the CLI sees an entity.
|
|
|
1116
1116
|
- \`tp-\` tester profile
|
|
1117
1117
|
- \`tps-\` tester-profile source
|
|
1118
1118
|
- \`a-\` ask
|
|
1119
|
+
- \`r-\` ask round
|
|
1119
1120
|
- \`c-\` config (simulation config)
|
|
1121
|
+
- \`ep-\` chatbot endpoint
|
|
1122
|
+
- \`cc-\` chatbot configuration
|
|
1120
1123
|
|
|
1121
1124
|
## Examples
|
|
1122
1125
|
|
|
@@ -1130,6 +1133,70 @@ ish profile generate --source tps-3a4 --count 4
|
|
|
1130
1133
|
The full UUID is also always accepted. Add \`--verbose\` to JSON output
|
|
1131
1134
|
to see UUIDs alongside aliases.
|
|
1132
1135
|
`;
|
|
1136
|
+
const REFERENCE_SCREENSHOTS = `# reference: screenshots and iteration media
|
|
1137
|
+
|
|
1138
|
+
Interactive study runs produce per-frame screenshots server-side. They
|
|
1139
|
+
let you (or an agent) see what testers actually saw alongside the
|
|
1140
|
+
sentiment summary.
|
|
1141
|
+
|
|
1142
|
+
## Screenshots — interactive studies only
|
|
1143
|
+
|
|
1144
|
+
Screenshots are produced by interactive runs only — chat / video / text
|
|
1145
|
+
studies don't have them.
|
|
1146
|
+
|
|
1147
|
+
### CLI
|
|
1148
|
+
|
|
1149
|
+
\`\`\`
|
|
1150
|
+
ish study screenshots # active study, frame-grouped
|
|
1151
|
+
ish study screenshots <study-id> --json # machine-readable index
|
|
1152
|
+
ish study screenshots download <study-id> --id <screenshot-id> --out shot.png
|
|
1153
|
+
ish study screenshots download <study-id> --all --out ./shots/
|
|
1154
|
+
\`\`\`
|
|
1155
|
+
|
|
1156
|
+
The list is grouped by frame. Each frame represents a distinct viewport
|
|
1157
|
+
testers landed on (e.g. the hero, the pricing block, a documentation
|
|
1158
|
+
page). Pulling every screenshot can be heavy — start with the listing,
|
|
1159
|
+
then download representative frames.
|
|
1160
|
+
|
|
1161
|
+
### MCP (agent-facing)
|
|
1162
|
+
|
|
1163
|
+
\`ish-mcp\` exposes the same artifacts as MCP Resources so an agent can
|
|
1164
|
+
look at them inline. \`study_get(view='summary' | 'per_tester')\` on an
|
|
1165
|
+
interactive study now carries:
|
|
1166
|
+
|
|
1167
|
+
- \`screenshots_resource: ish://study/<id>/screenshots\` — JSON index
|
|
1168
|
+
with frame groups and per-screenshot \`resource_uri\` strings.
|
|
1169
|
+
|
|
1170
|
+
Read those URIs with \`resources/read\`:
|
|
1171
|
+
|
|
1172
|
+
- \`ish://study/<study-id>/screenshots\` — index (JSON).
|
|
1173
|
+
- \`ish://study/<study-id>/screenshot/<screenshot-id>\` — image bytes.
|
|
1174
|
+
|
|
1175
|
+
## Iteration media — caller-supplied URLs
|
|
1176
|
+
|
|
1177
|
+
For non-interactive studies the iteration carries the artifact directly
|
|
1178
|
+
(\`content_url\` for video / audio / document, \`image_urls\` for image,
|
|
1179
|
+
\`featured_image_url\` for text-as-email). \`ish iteration get <id>\`
|
|
1180
|
+
already shows those fields.
|
|
1181
|
+
|
|
1182
|
+
The MCP surface adds resource URIs alongside them on every \`Iteration\`
|
|
1183
|
+
payload:
|
|
1184
|
+
|
|
1185
|
+
- \`media_resources: [ish://iteration/<id>/media/<kind>/<index>, …]\`
|
|
1186
|
+
where \`kind\` is one of \`content\`, \`image\`, \`featured_image\`.
|
|
1187
|
+
|
|
1188
|
+
Index resource: \`ish://iteration/<iteration-id>/media\` — lists every
|
|
1189
|
+
item (kind, index, source URL, resource URI).
|
|
1190
|
+
Bytes: \`ish://iteration/<iteration-id>/media/<kind>/<index>\`.
|
|
1191
|
+
|
|
1192
|
+
## Auth boundary
|
|
1193
|
+
|
|
1194
|
+
Screenshot URLs and iteration media URLs are *self-credentialed* — public
|
|
1195
|
+
URLs carry no credential, signed URLs embed the credential in the query
|
|
1196
|
+
string. Both the CLI and the MCP server fetch their bytes WITHOUT
|
|
1197
|
+
forwarding your ish bearer token, so the bearer never leaks
|
|
1198
|
+
cross-origin.
|
|
1199
|
+
`;
|
|
1133
1200
|
const REFERENCE_JSON_MODE = `# reference: output modes for agents
|
|
1134
1201
|
|
|
1135
1202
|
\`ish\` distinguishes **three output modes** so agents don't have to
|
|
@@ -1772,7 +1839,7 @@ ish chat endpoint init \\
|
|
|
1772
1839
|
--name my-bot
|
|
1773
1840
|
\`\`\`
|
|
1774
1841
|
|
|
1775
|
-
\`init\` posts the curl to \`/chat/
|
|
1842
|
+
\`init\` posts the curl to \`/chat/test-and-map\`, infers the
|
|
1776
1843
|
config (URL, method, headers, body template, response paths,
|
|
1777
1844
|
mode, async-poll if applicable), and saves it as a chatbot endpoint
|
|
1778
1845
|
resource. Output JSON shape:
|
|
@@ -1893,7 +1960,7 @@ The legacy per-affordance fields (\`optionsPath\`,
|
|
|
1893
1960
|
slot tagged with \`kind\`; anything passive is a reference. New
|
|
1894
1961
|
affordance shapes ship as a new \`kind\` value, no schema migration.
|
|
1895
1962
|
|
|
1896
|
-
\`
|
|
1963
|
+
\`test-and-map\` (the engine behind \`init\`) populates these
|
|
1897
1964
|
lists from the response stub via a shape-based classifier:
|
|
1898
1965
|
\`list[{label, id?}]\` becomes \`alternatives\`,
|
|
1899
1966
|
\`{fields: [...]}\` becomes \`form\`,
|
|
@@ -2214,6 +2281,12 @@ const PAGES = [
|
|
|
2214
2281
|
description: "Display vs capture vs chain: --human, --get, --json, --fields, exit codes, pipe behavior.",
|
|
2215
2282
|
body: REFERENCE_JSON_MODE,
|
|
2216
2283
|
},
|
|
2284
|
+
{
|
|
2285
|
+
slug: "reference/screenshots",
|
|
2286
|
+
title: "reference: screenshots and iteration media",
|
|
2287
|
+
description: "How to list and fetch interactive-run screenshots and iteration media URLs from CLI and MCP, and the cross-origin auth boundary.",
|
|
2288
|
+
body: REFERENCE_SCREENSHOTS,
|
|
2289
|
+
},
|
|
2217
2290
|
{
|
|
2218
2291
|
slug: "reference/billing-limits",
|
|
2219
2292
|
title: "reference: billing tier limits",
|
|
@@ -147,6 +147,21 @@ ish ask dispatch a-6ec --wait
|
|
|
147
147
|
ish study results
|
|
148
148
|
ish ask results a-6ec --round 1
|
|
149
149
|
|
|
150
|
+
# AI summary + key insights (any modality with completed testers)
|
|
151
|
+
ish study analyze --wait # trigger + block
|
|
152
|
+
ish study insights # read latest
|
|
153
|
+
|
|
154
|
+
# Screenshots (interactive studies — see what testers actually saw)
|
|
155
|
+
ish study screenshots # list, frame-grouped
|
|
156
|
+
ish study screenshots download <study-id> --id <scid> --out shot.png
|
|
157
|
+
ish study screenshots download <study-id> --all --out ./shots/
|
|
158
|
+
|
|
159
|
+
# Chat configurations (model + system prompt + tools per chatbot endpoint)
|
|
160
|
+
ish chat config list # active endpoint
|
|
161
|
+
ish chat config set --name v1 --model claude-sonnet-4-6 \\
|
|
162
|
+
--system-prompt-file ./prompt.txt --default
|
|
163
|
+
ish chat config get cc-abc --view iterations # cross-study use
|
|
164
|
+
|
|
150
165
|
# Read offline docs
|
|
151
166
|
ish docs overview
|
|
152
167
|
ish docs get-page <slug>
|
package/dist/lib/types.js
CHANGED
|
@@ -7,7 +7,7 @@ export const VALID_CONTENT_TYPES = {
|
|
|
7
7
|
text: ["narrative", "informational", "commercial", "editorial", "reference", "email", "news"],
|
|
8
8
|
video: ["tutorial", "documentary", "entertainment", "review", "lifestyle", "news", "social_post", "ad"],
|
|
9
9
|
audio: ["music", "narration", "conversation", "speech", "soundscape", "news", "ad"],
|
|
10
|
-
image: ["product", "photography", "infographic", "artwork", "interface", "social_post", "ad"],
|
|
10
|
+
image: ["product", "photography", "infographic", "artwork", "interface", "visual_assets", "social_post", "ad"],
|
|
11
11
|
document: ["deck", "presentation", "report", "brochure", "guide"],
|
|
12
12
|
};
|
|
13
13
|
export const ASK_VARIANT_KINDS = [
|