@ishlabs/cli 0.8.5 → 0.10.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/README.md +55 -6
- package/dist/auth.d.ts +23 -4
- package/dist/auth.js +165 -39
- package/dist/commands/ask.d.ts +12 -0
- package/dist/commands/ask.js +127 -2
- package/dist/commands/chat.d.ts +17 -0
- package/dist/commands/chat.js +589 -0
- package/dist/commands/iteration.js +232 -13
- package/dist/commands/secret.d.ts +20 -0
- package/dist/commands/secret.js +246 -0
- package/dist/commands/source.js +24 -2
- package/dist/commands/study-run.d.ts +38 -0
- package/dist/commands/study-run.js +199 -80
- package/dist/commands/study-tester.js +17 -2
- package/dist/commands/study.js +311 -39
- package/dist/commands/workspace.js +81 -0
- package/dist/config.d.ts +7 -0
- package/dist/connect.d.ts +3 -0
- package/dist/connect.js +359 -24
- package/dist/index.js +67 -9
- package/dist/lib/alias-hydrate.d.ts +42 -0
- package/dist/lib/alias-hydrate.js +175 -0
- package/dist/lib/alias-store.d.ts +1 -0
- package/dist/lib/alias-store.js +28 -1
- package/dist/lib/auth.js +11 -3
- package/dist/lib/chat-endpoint-formatters.d.ts +39 -0
- package/dist/lib/chat-endpoint-formatters.js +104 -0
- package/dist/lib/command-helpers.d.ts +18 -0
- package/dist/lib/command-helpers.js +188 -53
- package/dist/lib/docs.js +662 -34
- package/dist/lib/modality.d.ts +42 -0
- package/dist/lib/modality.js +192 -0
- package/dist/lib/output.d.ts +41 -0
- package/dist/lib/output.js +453 -19
- package/dist/lib/paths.d.ts +1 -0
- package/dist/lib/paths.js +3 -0
- package/dist/lib/skill-content.js +183 -13
- package/dist/lib/types.d.ts +15 -0
- package/package.json +3 -3
|
@@ -5,13 +5,35 @@
|
|
|
5
5
|
* content/file for media). Create one before `ish study run` dispatches
|
|
6
6
|
* simulations against it.
|
|
7
7
|
*/
|
|
8
|
-
import { withClient, resolveStudy, resolveWorkspace } from "../lib/command-helpers.js";
|
|
8
|
+
import { withClient, resolveStudy, resolveWorkspace, readFileOrStdin } from "../lib/command-helpers.js";
|
|
9
9
|
import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
|
|
10
|
-
import { output, formatIterationList } from "../lib/output.js";
|
|
10
|
+
import { output, formatIterationList, ValidationError } from "../lib/output.js";
|
|
11
11
|
import { resolveContentUrl, resolveContentUrls, resolveTextContent } from "../lib/upload.js";
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
import { isMediaModality, validateIterationDetails } from "../lib/modality.js";
|
|
13
|
+
/**
|
|
14
|
+
* Pattern C / M12: project each tester row on an iteration response so its
|
|
15
|
+
* `alias` and `name` survive `leanJson` (which strips raw UUID values). The
|
|
16
|
+
* existing `id` and `tester_profile` payload still pass through under
|
|
17
|
+
* `--verbose`, but the agent-default `--json` shape now exposes the same
|
|
18
|
+
* `{alias, name, status}` triple `study results` already does, so an agent
|
|
19
|
+
* can correlate testers across the two verbs.
|
|
20
|
+
*/
|
|
21
|
+
function enrichIterationTesters(iteration) {
|
|
22
|
+
const testers = iteration.testers;
|
|
23
|
+
if (!Array.isArray(testers))
|
|
24
|
+
return;
|
|
25
|
+
for (const t of testers) {
|
|
26
|
+
if (!t || typeof t !== "object")
|
|
27
|
+
continue;
|
|
28
|
+
const tester = t;
|
|
29
|
+
const id = tester.id ? String(tester.id) : "";
|
|
30
|
+
if (id)
|
|
31
|
+
tester.alias = tagAlias(ALIAS_PREFIX.tester, id);
|
|
32
|
+
const profile = tester.tester_profile;
|
|
33
|
+
if (!tester.name) {
|
|
34
|
+
tester.name = profile?.name ?? tester.instance_name ?? null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
15
37
|
}
|
|
16
38
|
function buildCopyContent(opts) {
|
|
17
39
|
if (!opts.copyText)
|
|
@@ -23,6 +45,30 @@ function buildCopyContent(opts) {
|
|
|
23
45
|
...(opts.copyPosition && { position: opts.copyPosition }),
|
|
24
46
|
};
|
|
25
47
|
}
|
|
48
|
+
function parseJsonFlag(raw, flagName) {
|
|
49
|
+
if (!raw)
|
|
50
|
+
return undefined;
|
|
51
|
+
try {
|
|
52
|
+
const parsed = JSON.parse(raw);
|
|
53
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
54
|
+
throw new Error(`Invalid ${flagName}: expected a JSON object`);
|
|
55
|
+
}
|
|
56
|
+
return parsed;
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
if (err instanceof Error && err.message.startsWith("Invalid "))
|
|
60
|
+
throw err;
|
|
61
|
+
throw new Error(`Invalid ${flagName}: expected valid JSON object`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function mediaExtras(opts) {
|
|
65
|
+
const segmentation = parseJsonFlag(opts.segmentationJson, "--segmentation-json");
|
|
66
|
+
const contentConfig = parseJsonFlag(opts.contentConfigJson, "--content-config-json");
|
|
67
|
+
return {
|
|
68
|
+
...(segmentation && { segmentation }),
|
|
69
|
+
...(contentConfig && { content_config: contentConfig }),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
26
72
|
function buildIterationDetails(modality, opts) {
|
|
27
73
|
switch (modality) {
|
|
28
74
|
case "text":
|
|
@@ -35,6 +81,10 @@ function buildIterationDetails(modality, opts) {
|
|
|
35
81
|
...(opts.contentHtml && { content_html: opts.contentHtml }),
|
|
36
82
|
...(opts.title && { title: opts.title }),
|
|
37
83
|
...(opts.mimeType && { mime_type: opts.mimeType }),
|
|
84
|
+
...(opts.senderName && { sender_name: opts.senderName }),
|
|
85
|
+
...(opts.senderEmail && { sender_email: opts.senderEmail }),
|
|
86
|
+
...(opts.featuredImageUrl && { featured_image_url: opts.featuredImageUrl }),
|
|
87
|
+
...mediaExtras(opts),
|
|
38
88
|
};
|
|
39
89
|
case "video":
|
|
40
90
|
case "audio": {
|
|
@@ -48,6 +98,7 @@ function buildIterationDetails(modality, opts) {
|
|
|
48
98
|
...(opts.title && { title: opts.title }),
|
|
49
99
|
...(opts.mimeType && { mime_type: opts.mimeType }),
|
|
50
100
|
...(copy && { copy_content: copy }),
|
|
101
|
+
...mediaExtras(opts),
|
|
51
102
|
};
|
|
52
103
|
}
|
|
53
104
|
case "image": {
|
|
@@ -61,6 +112,7 @@ function buildIterationDetails(modality, opts) {
|
|
|
61
112
|
...(opts.title && { title: opts.title }),
|
|
62
113
|
...(opts.mimeType && { mime_type: opts.mimeType }),
|
|
63
114
|
...(copy && { copy_content: copy }),
|
|
115
|
+
...mediaExtras(opts),
|
|
64
116
|
};
|
|
65
117
|
}
|
|
66
118
|
case "document":
|
|
@@ -72,17 +124,45 @@ function buildIterationDetails(modality, opts) {
|
|
|
72
124
|
content_url: opts.contentUrl,
|
|
73
125
|
...(opts.title && { title: opts.title }),
|
|
74
126
|
...(opts.mimeType && { mime_type: opts.mimeType }),
|
|
127
|
+
...mediaExtras(opts),
|
|
128
|
+
};
|
|
129
|
+
case "chat": {
|
|
130
|
+
const endpoint = parseJsonFlag(opts.chatEndpointJson, "--chat-endpoint-json");
|
|
131
|
+
if (!endpoint && !opts.chatEndpointId) {
|
|
132
|
+
throw new Error("Chat iterations require either --chat-endpoint-id (saved endpoint) or --chat-endpoint-json (inline config).");
|
|
133
|
+
}
|
|
134
|
+
let maxTurns;
|
|
135
|
+
if (opts.maxTurns !== undefined) {
|
|
136
|
+
const parsed = Number.parseInt(opts.maxTurns, 10);
|
|
137
|
+
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
138
|
+
throw new Error("Invalid --max-turns: expected a positive integer");
|
|
139
|
+
}
|
|
140
|
+
maxTurns = parsed;
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
type: "chat",
|
|
144
|
+
...(endpoint && { endpoint }),
|
|
145
|
+
...(opts.chatEndpointId && { chatbot_endpoint_id: opts.chatEndpointId }),
|
|
146
|
+
...(maxTurns !== undefined && { max_turns: maxTurns }),
|
|
147
|
+
...(opts.earlyTermination !== undefined && { early_termination: opts.earlyTermination }),
|
|
75
148
|
};
|
|
149
|
+
}
|
|
76
150
|
default:
|
|
77
151
|
if (!opts.url) {
|
|
78
152
|
throw new Error("Interactive iterations require --url. Provide the URL to test.");
|
|
79
153
|
}
|
|
154
|
+
if (opts.platform === "figma" && (!opts.fileKey || !opts.startNodeId)) {
|
|
155
|
+
throw new Error("Figma interactive iterations require both --file-key and --start-node-id.");
|
|
156
|
+
}
|
|
80
157
|
return {
|
|
81
158
|
type: "interactive",
|
|
82
159
|
platform: opts.platform || "browser",
|
|
83
160
|
url: opts.url,
|
|
84
161
|
screen_format: opts.screenFormat || "desktop",
|
|
85
162
|
...(opts.locale && { locale: opts.locale }),
|
|
163
|
+
...(opts.fileKey && { file_key: opts.fileKey }),
|
|
164
|
+
...(opts.startNodeId && { start_node_id: opts.startNodeId }),
|
|
165
|
+
...(opts.flowName && { flow_name: opts.flowName }),
|
|
86
166
|
};
|
|
87
167
|
}
|
|
88
168
|
}
|
|
@@ -91,9 +171,11 @@ export function registerIterationCommands(program) {
|
|
|
91
171
|
.command("iteration")
|
|
92
172
|
.description("Manage iterations of a study (a study's run-time configuration)")
|
|
93
173
|
.addHelpText("after", `
|
|
94
|
-
An iteration is one configured run of a study — it carries the URL (interactive)
|
|
95
|
-
media content (text/video/image/document)
|
|
96
|
-
defaults to the latest. Local file paths in
|
|
174
|
+
An iteration is one configured run of a study — it carries the URL (interactive),
|
|
175
|
+
media content (text/video/image/document), or chatbot endpoint (chat). A study has
|
|
176
|
+
1..N iterations; \`ish study run\` defaults to the latest. Local file paths in
|
|
177
|
+
--content-url / --image-urls are auto-uploaded. Segments and per-segment labels
|
|
178
|
+
live inside --segmentation-json.
|
|
97
179
|
|
|
98
180
|
Concept pages: ish docs get-page concepts/iteration
|
|
99
181
|
ish docs get-page concepts/study`);
|
|
@@ -145,9 +227,15 @@ Concept pages: ish docs get-page concepts/iteration
|
|
|
145
227
|
.option("--url <url>", "URL to test — interactive only")
|
|
146
228
|
.option("--screen-format <format>", "Screen format (mobile_portrait, desktop) — interactive only")
|
|
147
229
|
.option("--locale <locale>", "Locale code (e.g. en-US) — interactive only")
|
|
230
|
+
.option("--file-key <key>", "Figma file key — required when --platform=figma")
|
|
231
|
+
.option("--start-node-id <id>", "Figma start node id — required when --platform=figma")
|
|
232
|
+
.option("--flow-name <name>", "Figma flow name — interactive only")
|
|
148
233
|
// Media text
|
|
149
234
|
.option("--content-text <text>", "Text content to evaluate, or @filepath to read from file — text modality")
|
|
150
235
|
.option("--content-html <html>", "HTML version of the text, or @filepath to read from file — text modality")
|
|
236
|
+
.option("--sender-name <name>", "Email 'From' display name — text modality (email rendering)")
|
|
237
|
+
.option("--sender-email <email>", "Email sender address — text modality (email rendering)")
|
|
238
|
+
.option("--featured-image-url <url>", "Hero image URL — text modality (email rendering)")
|
|
151
239
|
// Media video/audio/document
|
|
152
240
|
.option("--content-url <url>", "URL or local file path to media file — video, audio, document modalities")
|
|
153
241
|
// Media image
|
|
@@ -160,6 +248,21 @@ Concept pages: ish docs get-page concepts/iteration
|
|
|
160
248
|
.option("--copy-html <html>", "HTML version of copy text (or @filepath)")
|
|
161
249
|
.option("--social-platform <platform>", "Social platform (instagram, tiktok, facebook, linkedin, x)")
|
|
162
250
|
.option("--copy-position <pos>", "Copy position relative to media (before, after)", "after")
|
|
251
|
+
// Segmentation / per-iteration evaluation config (media modalities)
|
|
252
|
+
.option("--segmentation-json <json>", "Segmentation JSON — time_based {intervals_seconds, labels?}, section_based {sections[{name,label,...}]}, or page_based {} — media modalities")
|
|
253
|
+
.option("--content-config-json <json>", "Content config JSON — {early_termination, selected_segment_indices?} — media modalities")
|
|
254
|
+
// Chat modality
|
|
255
|
+
.option("--chat-endpoint-id <id>", "Saved chatbot endpoint id — chat modality (legacy; prefer --endpoint)")
|
|
256
|
+
.option("--chat-endpoint-json <json>", "Inline chatbot endpoint config JSON — chat modality (legacy; prefer --endpoint-config)")
|
|
257
|
+
.option("--max-turns <n>", "Max tester turns (1-50) — chat modality (default 12)")
|
|
258
|
+
.option("--early-termination", "End the chat session early when the tester signals stop — chat modality")
|
|
259
|
+
// Agent-friendly chat shortcuts: --endpoint <id> resolves a saved
|
|
260
|
+
// chatbot endpoint via alias / UUID and fetches its config inline;
|
|
261
|
+
// --endpoint-config <file> takes a raw config file (or `-` for
|
|
262
|
+
// stdin). Mutually exclusive with each other and with the legacy
|
|
263
|
+
// --chat-endpoint-* flags. Only meaningful for chat-modality studies.
|
|
264
|
+
.option("--endpoint <id>", "Saved chatbot endpoint id (alias or UUID) — chat modality")
|
|
265
|
+
.option("--endpoint-config <file>", "Raw ChatbotEndpointConfig JSON file or `-` for stdin — chat modality")
|
|
163
266
|
// Escape hatch
|
|
164
267
|
.option("--details-json <json>", "Raw iteration details JSON (overrides individual flags)")
|
|
165
268
|
.addHelpText("after", `
|
|
@@ -194,6 +297,32 @@ Examples:
|
|
|
194
297
|
$ ish iteration create --image-urls ./post.png \\
|
|
195
298
|
--copy-text @./caption.txt --social-platform instagram
|
|
196
299
|
|
|
300
|
+
# Email with sender + featured image + section-based segmentation:
|
|
301
|
+
$ ish iteration create --content-text @./email.txt --content-html @./email.html \\
|
|
302
|
+
--sender-name "Marketing" --sender-email "marketing@example.com" \\
|
|
303
|
+
--featured-image-url https://cdn.example.com/hero.png \\
|
|
304
|
+
--segmentation-json '{"type":"section_based","sections":[{"name":"intro","label":"Intro","paragraph_start":0,"paragraph_end":1}]}'
|
|
305
|
+
|
|
306
|
+
# Video with time-based segmentation + labels and early termination:
|
|
307
|
+
$ ish iteration create --content-url ./promo.mp4 \\
|
|
308
|
+
--segmentation-json '{"type":"time_based","intervals_seconds":[0,30,60],"labels":["Hook","Body","CTA"]}' \\
|
|
309
|
+
--content-config-json '{"early_termination":true,"selected_segment_indices":[0,2]}'
|
|
310
|
+
|
|
311
|
+
# Figma interactive (file_key + start_node_id required):
|
|
312
|
+
$ ish iteration create --platform figma --url https://figma.com/proto \\
|
|
313
|
+
--screen-format mobile_portrait --file-key abc123 --start-node-id 0:1 \\
|
|
314
|
+
--flow-name "Onboarding A"
|
|
315
|
+
|
|
316
|
+
# Chat — saved endpoint (the parent study's modality determines the type):
|
|
317
|
+
$ ish iteration create --study s-b2c --endpoint ep-abc --max-turns 6
|
|
318
|
+
|
|
319
|
+
# Chat — inline endpoint config (file or stdin):
|
|
320
|
+
$ ish iteration create --study s-b2c --endpoint-config ./bot.json
|
|
321
|
+
$ cat ./bot.json | ish iteration create --study s-b2c --endpoint-config -
|
|
322
|
+
|
|
323
|
+
# Chat — legacy flags still work:
|
|
324
|
+
$ ish iteration create --chat-endpoint-id ce-abc --max-turns 10 --early-termination
|
|
325
|
+
|
|
197
326
|
# Raw JSON escape hatch (overrides individual flags):
|
|
198
327
|
$ ish iteration create --study s-b2c --details-json \\
|
|
199
328
|
'{"type":"interactive","platform":"browser","url":"https://example.com","screen_format":"desktop"}'
|
|
@@ -204,9 +333,13 @@ workspace's public storage bucket. Validation now happens before upload.
|
|
|
204
333
|
Next: \`ish study run\` to dispatch simulations against this iteration.`)
|
|
205
334
|
.action(async (opts, cmd) => {
|
|
206
335
|
await withClient(cmd, async (client, globals) => {
|
|
336
|
+
if (opts.endpoint !== undefined && opts.endpointConfig !== undefined) {
|
|
337
|
+
throw new ValidationError("Pass only one of: --endpoint, --endpoint-config.", ["--endpoint", "--endpoint-config"]);
|
|
338
|
+
}
|
|
207
339
|
if (opts.workspace)
|
|
208
340
|
resolveWorkspace(opts.workspace);
|
|
209
341
|
const studyId = resolveStudy(opts.study);
|
|
342
|
+
let chatbotEndpointIdForOutput = null;
|
|
210
343
|
let details;
|
|
211
344
|
if (opts.detailsJson) {
|
|
212
345
|
try {
|
|
@@ -221,12 +354,16 @@ Next: \`ish study run\` to dispatch simulations against this iteration.`)
|
|
|
221
354
|
const study = await client.get(`/studies/${studyId}`);
|
|
222
355
|
const modality = study.modality || "interactive";
|
|
223
356
|
const isMedia = isMediaModality(modality);
|
|
224
|
-
|
|
225
|
-
|
|
357
|
+
const isChat = modality === "chat";
|
|
358
|
+
if ((isMedia || isChat) && opts.url) {
|
|
359
|
+
throw new Error(`This study uses "${modality}" modality — --url is for interactive studies. Use the modality-specific flags instead.`);
|
|
226
360
|
}
|
|
227
|
-
if (!isMedia && (opts.contentText || opts.contentUrl || opts.imageUrls)) {
|
|
361
|
+
if (!isMedia && !isChat && (opts.contentText || opts.contentUrl || opts.imageUrls)) {
|
|
228
362
|
throw new Error(`This study uses "interactive" modality — --content-text, --content-url, and --image-urls are for media studies. Use --url instead.`);
|
|
229
363
|
}
|
|
364
|
+
if (!isChat && (opts.endpoint !== undefined || opts.endpointConfig !== undefined)) {
|
|
365
|
+
throw new Error(`This study uses "${modality}" modality — --endpoint / --endpoint-config are for chat studies.`);
|
|
366
|
+
}
|
|
230
367
|
// Validate per-modality required flags BEFORE any upload so we don't
|
|
231
368
|
// orphan blobs in storage when the wrong flag is passed (e.g.
|
|
232
369
|
// --content-url to an image-modality study).
|
|
@@ -252,6 +389,18 @@ Next: \`ish study run\` to dispatch simulations against this iteration.`)
|
|
|
252
389
|
throw new Error("Document iterations require --content-url. Provide the URL or local file path.");
|
|
253
390
|
}
|
|
254
391
|
break;
|
|
392
|
+
case "chat":
|
|
393
|
+
if (!opts.chatEndpointId
|
|
394
|
+
&& !opts.chatEndpointJson
|
|
395
|
+
&& !opts.endpoint
|
|
396
|
+
&& !opts.endpointConfig) {
|
|
397
|
+
throw new Error("Chat iterations require one of: --endpoint <id>, --endpoint-config <file>, --chat-endpoint-id, or --chat-endpoint-json.");
|
|
398
|
+
}
|
|
399
|
+
if ((opts.endpoint || opts.endpointConfig)
|
|
400
|
+
&& (opts.chatEndpointId || opts.chatEndpointJson)) {
|
|
401
|
+
throw new ValidationError("Pass only one of: --endpoint / --endpoint-config (preferred) or the legacy --chat-endpoint-id / --chat-endpoint-json.", ["--endpoint", "--endpoint-config"]);
|
|
402
|
+
}
|
|
403
|
+
break;
|
|
255
404
|
default:
|
|
256
405
|
if (!opts.url) {
|
|
257
406
|
throw new Error("Interactive iterations require --url. Provide the URL to test.");
|
|
@@ -275,7 +424,55 @@ Next: \`ish study run\` to dispatch simulations against this iteration.`)
|
|
|
275
424
|
resolved.imageUrls = urls.join(",");
|
|
276
425
|
}
|
|
277
426
|
}
|
|
278
|
-
|
|
427
|
+
if (isChat && (opts.endpoint || opts.endpointConfig)) {
|
|
428
|
+
// Agent-friendly path: fetch a saved endpoint by alias / UUID
|
|
429
|
+
// (or read a raw config from a file / stdin) and embed the full
|
|
430
|
+
// ChatbotEndpointConfig inline at iteration.details.endpoint.
|
|
431
|
+
// Backend's chat iteration shape is wider than what
|
|
432
|
+
// buildIterationDetails covers; we materialise it here.
|
|
433
|
+
let endpointConfig;
|
|
434
|
+
let chatbotEndpointId = null;
|
|
435
|
+
if (opts.endpoint) {
|
|
436
|
+
const id = resolveId(opts.endpoint);
|
|
437
|
+
const ep = await client.get(`/chatbot-endpoints/${id}`);
|
|
438
|
+
endpointConfig = ep.config;
|
|
439
|
+
chatbotEndpointId = id;
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
const raw = await readFileOrStdin(opts.endpointConfig);
|
|
443
|
+
try {
|
|
444
|
+
const parsed = JSON.parse(raw);
|
|
445
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
446
|
+
throw new Error("expected a JSON object");
|
|
447
|
+
}
|
|
448
|
+
endpointConfig = parsed;
|
|
449
|
+
}
|
|
450
|
+
catch (err) {
|
|
451
|
+
const reason = err instanceof Error ? err.message : "invalid JSON";
|
|
452
|
+
throw new Error(`Invalid --endpoint-config: ${reason}`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
let maxTurns = 12;
|
|
456
|
+
if (opts.maxTurns !== undefined) {
|
|
457
|
+
const parsed = Number.parseInt(opts.maxTurns, 10);
|
|
458
|
+
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
459
|
+
throw new Error("Invalid --max-turns: expected a positive integer");
|
|
460
|
+
}
|
|
461
|
+
maxTurns = parsed;
|
|
462
|
+
}
|
|
463
|
+
const earlyTermination = opts.earlyTermination !== undefined ? opts.earlyTermination : true;
|
|
464
|
+
details = {
|
|
465
|
+
type: "chat",
|
|
466
|
+
endpoint: endpointConfig,
|
|
467
|
+
chatbot_endpoint_id: chatbotEndpointId,
|
|
468
|
+
max_turns: maxTurns,
|
|
469
|
+
early_termination: earlyTermination,
|
|
470
|
+
};
|
|
471
|
+
chatbotEndpointIdForOutput = chatbotEndpointId;
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
details = buildIterationDetails(modality, resolved);
|
|
475
|
+
}
|
|
279
476
|
}
|
|
280
477
|
const body = {
|
|
281
478
|
name: opts.name || `CLI ${new Date().toISOString().slice(0, 16)}`,
|
|
@@ -286,6 +483,9 @@ Next: \`ish study run\` to dispatch simulations against this iteration.`)
|
|
|
286
483
|
const result = data;
|
|
287
484
|
if (result.id)
|
|
288
485
|
result.alias = tagAlias(ALIAS_PREFIX.iteration, String(result.id));
|
|
486
|
+
if (chatbotEndpointIdForOutput !== null) {
|
|
487
|
+
result.chatbot_endpoint_id = chatbotEndpointIdForOutput;
|
|
488
|
+
}
|
|
289
489
|
output(result, globals.json);
|
|
290
490
|
});
|
|
291
491
|
});
|
|
@@ -311,6 +511,7 @@ With multiple IDs, returns a {items:[...], total:N} envelope.`)
|
|
|
311
511
|
const result = data;
|
|
312
512
|
if (result.id)
|
|
313
513
|
result.alias = tagAlias(ALIAS_PREFIX.iteration, String(result.id));
|
|
514
|
+
enrichIterationTesters(result);
|
|
314
515
|
output(result, globals.json);
|
|
315
516
|
return;
|
|
316
517
|
}
|
|
@@ -319,6 +520,7 @@ With multiple IDs, returns a {items:[...], total:N} envelope.`)
|
|
|
319
520
|
const r = data;
|
|
320
521
|
if (r.id)
|
|
321
522
|
r.alias = tagAlias(ALIAS_PREFIX.iteration, String(r.id));
|
|
523
|
+
enrichIterationTesters(r);
|
|
322
524
|
return r;
|
|
323
525
|
}));
|
|
324
526
|
if (globals.json) {
|
|
@@ -359,7 +561,24 @@ With multiple IDs, returns a {items:[...], total:N} envelope.`)
|
|
|
359
561
|
console.error("No update flags provided. Run `ish iteration update --help` for options.");
|
|
360
562
|
return;
|
|
361
563
|
}
|
|
362
|
-
const
|
|
564
|
+
const resolvedIterationId = resolveId(id);
|
|
565
|
+
// Pattern C: validate --details-json shape against the parent
|
|
566
|
+
// study's modality before sending. The backend has its own
|
|
567
|
+
// schema, but a bare `{"url":"..."}` blob silently passing
|
|
568
|
+
// through to a chat iteration corrupts it. One GET on the
|
|
569
|
+
// iteration + one on the study is cheap insurance.
|
|
570
|
+
if (body.details !== undefined) {
|
|
571
|
+
const iter = await client.get(`/iterations/${resolvedIterationId}`);
|
|
572
|
+
if (!iter.study_id) {
|
|
573
|
+
throw new Error(`Iteration ${id} has no study_id; cannot validate --details-json against modality.`);
|
|
574
|
+
}
|
|
575
|
+
const parentStudy = await client.get(`/studies/${iter.study_id}`);
|
|
576
|
+
const verdict = validateIterationDetails(parentStudy.modality, body.details);
|
|
577
|
+
if (!verdict.ok) {
|
|
578
|
+
throw new Error(`--details-json rejected: ${verdict.reason} (study modality is "${parentStudy.modality ?? "unknown"}"). ${verdict.suggestion}`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
const data = await client.put(`/iterations/${resolvedIterationId}`, body);
|
|
363
582
|
const result = data;
|
|
364
583
|
if (result.id)
|
|
365
584
|
result.alias = tagAlias(ALIAS_PREFIX.iteration, String(result.id));
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ish secret — Manage workspace secrets used in {{secret:KEY}} placeholders.
|
|
3
|
+
*
|
|
4
|
+
* Workspace secrets back the `{{secret:KEY}}` placeholder in chatbot endpoint
|
|
5
|
+
* config (request body / headers). They live on the same product-secrets
|
|
6
|
+
* surface as site-access (see src/lib/site-access.ts), but with general,
|
|
7
|
+
* user-chosen keys instead of the reserved BASIC_AUTH_/SESSION_COOKIE_/
|
|
8
|
+
* LOGIN_ keys.
|
|
9
|
+
*
|
|
10
|
+
* Security posture:
|
|
11
|
+
* - Values are write-only. The backend's list endpoint never returns them;
|
|
12
|
+
* this command additionally drops any `value`-shaped fields client-side
|
|
13
|
+
* before printing, so an accidental backend regression doesn't leak.
|
|
14
|
+
* - Inputs come from a positional value, --value-file <path>, or stdin via
|
|
15
|
+
* --value-stdin (mutually exclusive). Stdin/file paths keep secrets out
|
|
16
|
+
* of shell history.
|
|
17
|
+
* - No interactive prompts (CLI is for autonomous agents).
|
|
18
|
+
*/
|
|
19
|
+
import type { Command } from "commander";
|
|
20
|
+
export declare function registerSecretCommands(program: Command): void;
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ish secret — Manage workspace secrets used in {{secret:KEY}} placeholders.
|
|
3
|
+
*
|
|
4
|
+
* Workspace secrets back the `{{secret:KEY}}` placeholder in chatbot endpoint
|
|
5
|
+
* config (request body / headers). They live on the same product-secrets
|
|
6
|
+
* surface as site-access (see src/lib/site-access.ts), but with general,
|
|
7
|
+
* user-chosen keys instead of the reserved BASIC_AUTH_/SESSION_COOKIE_/
|
|
8
|
+
* LOGIN_ keys.
|
|
9
|
+
*
|
|
10
|
+
* Security posture:
|
|
11
|
+
* - Values are write-only. The backend's list endpoint never returns them;
|
|
12
|
+
* this command additionally drops any `value`-shaped fields client-side
|
|
13
|
+
* before printing, so an accidental backend regression doesn't leak.
|
|
14
|
+
* - Inputs come from a positional value, --value-file <path>, or stdin via
|
|
15
|
+
* --value-stdin (mutually exclusive). Stdin/file paths keep secrets out
|
|
16
|
+
* of shell history.
|
|
17
|
+
* - No interactive prompts (CLI is for autonomous agents).
|
|
18
|
+
*/
|
|
19
|
+
import { withClient, resolveWorkspace, readFileOrStdin } from "../lib/command-helpers.js";
|
|
20
|
+
import { output } from "../lib/output.js";
|
|
21
|
+
const RESERVED_SITE_ACCESS_KEYS = new Set([
|
|
22
|
+
"BASIC_AUTH_USERNAME",
|
|
23
|
+
"BASIC_AUTH_PASSWORD",
|
|
24
|
+
"BASIC_AUTH_ORIGIN",
|
|
25
|
+
"LOGIN_USERNAME",
|
|
26
|
+
"LOGIN_PASSWORD",
|
|
27
|
+
"SESSION_COOKIE_NAME",
|
|
28
|
+
"SESSION_COOKIE_VALUE",
|
|
29
|
+
"SESSION_COOKIE_ORIGIN",
|
|
30
|
+
"SITE_ACCESS_PUBLIC_AFFIRMED_ORIGIN",
|
|
31
|
+
]);
|
|
32
|
+
const KEY_PATTERN = /^[A-Z][A-Z0-9_]*$/;
|
|
33
|
+
/** Strip any `value`-shaped fields before render — defensive against backend regressions. */
|
|
34
|
+
function sanitize(secret) {
|
|
35
|
+
return {
|
|
36
|
+
id: String(secret.id ?? ""),
|
|
37
|
+
key: String(secret.key ?? ""),
|
|
38
|
+
description: typeof secret.description === "string" ? secret.description : null,
|
|
39
|
+
scope: String(secret.scope ?? ""),
|
|
40
|
+
variable_type: String(secret.variable_type ?? ""),
|
|
41
|
+
created_at: String(secret.created_at ?? ""),
|
|
42
|
+
updated_at: String(secret.updated_at ?? ""),
|
|
43
|
+
expires_at: typeof secret.expires_at === "string" ? secret.expires_at : null,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function validateKey(key) {
|
|
47
|
+
if (!KEY_PATTERN.test(key)) {
|
|
48
|
+
const err = new Error(`Invalid secret key "${key}". Keys must start with an uppercase letter and use only A-Z, 0-9, and underscores (e.g. "GROQ_API_KEY").`);
|
|
49
|
+
err.name = "ValidationError";
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
if (RESERVED_SITE_ACCESS_KEYS.has(key)) {
|
|
53
|
+
const err = new Error(`"${key}" is a reserved site-access key. Use \`ish workspace site-access\` to manage it.`);
|
|
54
|
+
err.name = "ValidationError";
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function readValueFromStdin() {
|
|
59
|
+
if (process.stdin.isTTY) {
|
|
60
|
+
const err = new Error("--value-stdin requires the value to be piped on stdin.");
|
|
61
|
+
err.name = "ValidationError";
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
return await new Promise((resolve, reject) => {
|
|
65
|
+
let data = "";
|
|
66
|
+
process.stdin.setEncoding("utf-8");
|
|
67
|
+
process.stdin.on("data", (chunk) => {
|
|
68
|
+
data += chunk;
|
|
69
|
+
});
|
|
70
|
+
process.stdin.on("end", () => resolve(data.replace(/\r?\n$/, "")));
|
|
71
|
+
process.stdin.on("error", reject);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
export function registerSecretCommands(program) {
|
|
75
|
+
const secret = program
|
|
76
|
+
.command("secret")
|
|
77
|
+
.description("Manage workspace secrets used in {{secret:KEY}} placeholders")
|
|
78
|
+
.addHelpText("after", `
|
|
79
|
+
Workspace secrets back the \`{{secret:KEY}}\` placeholder in chatbot
|
|
80
|
+
endpoint config (request body / headers). Values are write-only — the
|
|
81
|
+
backend never returns them after creation, and \`secret list\` only
|
|
82
|
+
shows keys.
|
|
83
|
+
|
|
84
|
+
Site-access credentials (BASIC_AUTH_*, LOGIN_*, SESSION_COOKIE_*) live
|
|
85
|
+
on the same store but are managed via \`ish workspace site-access\` so
|
|
86
|
+
the paired-key invariant is enforced.
|
|
87
|
+
|
|
88
|
+
Examples:
|
|
89
|
+
$ ish secret set GROQ_API_KEY gsk_live_abc...
|
|
90
|
+
$ ish secret set GROQ_API_KEY --value-file ./key.txt
|
|
91
|
+
$ printf %s "$KEY" | ish secret set GROQ_API_KEY --value-stdin
|
|
92
|
+
$ ish secret list
|
|
93
|
+
$ ish secret delete GROQ_API_KEY`);
|
|
94
|
+
secret
|
|
95
|
+
.command("list")
|
|
96
|
+
.description("List secret keys for the active workspace (values never returned)")
|
|
97
|
+
.option("--workspace <id>", "Workspace ID; defaults to active workspace")
|
|
98
|
+
.addHelpText("after", "\nValues are write-only. This command returns key + metadata only; even if the backend regresses, values are stripped client-side before render.")
|
|
99
|
+
.action(async (opts, cmd) => {
|
|
100
|
+
await withClient(cmd, async (client, globals) => {
|
|
101
|
+
const wid = resolveWorkspace(opts.workspace);
|
|
102
|
+
const data = await client.get(`/products/${wid}/secrets`);
|
|
103
|
+
const sanitized = data.map((s) => sanitize(s));
|
|
104
|
+
if (globals.json) {
|
|
105
|
+
output(sanitized, true);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (sanitized.length === 0) {
|
|
109
|
+
console.log("No secrets configured for this workspace.");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
for (const s of sanitized) {
|
|
113
|
+
const desc = s.description ? ` — ${s.description}` : "";
|
|
114
|
+
console.log(` ${s.key} (${s.variable_type}, scope=${s.scope})${desc}`);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
secret
|
|
119
|
+
.command("set")
|
|
120
|
+
.description("Create or update a workspace secret")
|
|
121
|
+
.argument("<key>", "Secret key (uppercase, e.g. GROQ_API_KEY)")
|
|
122
|
+
.argument("[value]", "Secret value. Omit when using --value-file or --value-stdin.")
|
|
123
|
+
.option("--value-file <path>", "Read the value from a file on disk (use \"-\" for stdin)")
|
|
124
|
+
.option("--value-stdin", "Read the value from stdin (alias for --value-file -)")
|
|
125
|
+
.option("--description <text>", "Optional description (what this secret is used for)")
|
|
126
|
+
.option("--scope <scope>", "Visibility scope: agent | project (default: agent)", "agent")
|
|
127
|
+
.option("--workspace <id>", "Workspace ID; defaults to active workspace")
|
|
128
|
+
.addHelpText("after", `
|
|
129
|
+
Pick exactly one of: positional <value>, --value-file <path>, --value-stdin.
|
|
130
|
+
Stdin and --value-file are preferred for real keys so the value never lands
|
|
131
|
+
in shell history.
|
|
132
|
+
|
|
133
|
+
Examples:
|
|
134
|
+
$ ish secret set GROQ_API_KEY gsk_live_abc...
|
|
135
|
+
$ ish secret set GROQ_API_KEY --value-file ./key.txt
|
|
136
|
+
$ printf %s "$KEY" | ish secret set GROQ_API_KEY --value-stdin
|
|
137
|
+
$ ish secret set GROQ_API_KEY --value-file - < ./key.txt`)
|
|
138
|
+
.action(async (key, value, opts, cmd) => {
|
|
139
|
+
await withClient(cmd, async (client, globals) => {
|
|
140
|
+
validateKey(key);
|
|
141
|
+
// Exactly one input source.
|
|
142
|
+
const sources = [
|
|
143
|
+
value !== undefined,
|
|
144
|
+
opts.valueFile !== undefined,
|
|
145
|
+
opts.valueStdin === true,
|
|
146
|
+
].filter(Boolean).length;
|
|
147
|
+
if (sources === 0) {
|
|
148
|
+
const err = new Error("No value provided. Pass a positional value, --value-file <path>, or --value-stdin.");
|
|
149
|
+
err.name = "ValidationError";
|
|
150
|
+
throw err;
|
|
151
|
+
}
|
|
152
|
+
if (sources > 1) {
|
|
153
|
+
const err = new Error("Pass exactly one of: positional value, --value-file, --value-stdin.");
|
|
154
|
+
err.name = "ValidationError";
|
|
155
|
+
throw err;
|
|
156
|
+
}
|
|
157
|
+
let resolvedValue;
|
|
158
|
+
if (opts.valueStdin) {
|
|
159
|
+
resolvedValue = await readValueFromStdin();
|
|
160
|
+
}
|
|
161
|
+
else if (opts.valueFile !== undefined) {
|
|
162
|
+
resolvedValue = (await readFileOrStdin(opts.valueFile)).replace(/\r?\n$/, "");
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
resolvedValue = value;
|
|
166
|
+
}
|
|
167
|
+
if (resolvedValue.length === 0) {
|
|
168
|
+
const err = new Error("Secret value is empty.");
|
|
169
|
+
err.name = "ValidationError";
|
|
170
|
+
throw err;
|
|
171
|
+
}
|
|
172
|
+
const scope = opts.scope === "project" ? "project" : "agent";
|
|
173
|
+
const wid = resolveWorkspace(opts.workspace);
|
|
174
|
+
// Find an existing entry by key so we PUT instead of POST when the
|
|
175
|
+
// user is updating. The list endpoint never returns the value, so
|
|
176
|
+
// this lookup leaks nothing.
|
|
177
|
+
const existing = await client.get(`/products/${wid}/secrets`);
|
|
178
|
+
const match = existing.find((s) => s.key === key);
|
|
179
|
+
let saved;
|
|
180
|
+
if (match) {
|
|
181
|
+
saved = await client.put(`/products/${wid}/secrets/${match.id}`, {
|
|
182
|
+
value: resolvedValue,
|
|
183
|
+
...(opts.description !== undefined && { description: opts.description }),
|
|
184
|
+
scope,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
const body = {
|
|
189
|
+
key,
|
|
190
|
+
value: resolvedValue,
|
|
191
|
+
scope,
|
|
192
|
+
variable_type: "secret",
|
|
193
|
+
...(opts.description !== undefined && { description: opts.description }),
|
|
194
|
+
};
|
|
195
|
+
saved = await client.post(`/products/${wid}/secrets`, body);
|
|
196
|
+
}
|
|
197
|
+
// Lean envelope. No value echoed.
|
|
198
|
+
const sanitized = sanitize(saved);
|
|
199
|
+
output({
|
|
200
|
+
success: true,
|
|
201
|
+
action: match ? "updated" : "created",
|
|
202
|
+
key: sanitized.key,
|
|
203
|
+
id: sanitized.id,
|
|
204
|
+
scope: sanitized.scope,
|
|
205
|
+
variable_type: sanitized.variable_type,
|
|
206
|
+
workspace_id: wid,
|
|
207
|
+
}, globals.json);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
secret
|
|
211
|
+
.command("delete")
|
|
212
|
+
.description("Delete a workspace secret by key")
|
|
213
|
+
.argument("<key>", "Secret key to delete")
|
|
214
|
+
.option("--workspace <id>", "Workspace ID; defaults to active workspace")
|
|
215
|
+
.addHelpText("after", "\nExamples:\n $ ish secret delete GROQ_API_KEY")
|
|
216
|
+
.action(async (key, opts, cmd) => {
|
|
217
|
+
await withClient(cmd, async (client, globals) => {
|
|
218
|
+
if (!KEY_PATTERN.test(key)) {
|
|
219
|
+
const err = new Error(`Invalid secret key "${key}". Keys must start with an uppercase letter and use only A-Z, 0-9, and underscores.`);
|
|
220
|
+
err.name = "ValidationError";
|
|
221
|
+
throw err;
|
|
222
|
+
}
|
|
223
|
+
const wid = resolveWorkspace(opts.workspace);
|
|
224
|
+
const all = await client.get(`/products/${wid}/secrets`);
|
|
225
|
+
const match = all.find((s) => s.key === key);
|
|
226
|
+
if (!match) {
|
|
227
|
+
// Surface as ApiError so exitCodeFromError maps to 4 (not-found).
|
|
228
|
+
const { ApiError } = await import("../lib/api-client.js");
|
|
229
|
+
throw new ApiError(404, "Not Found", {
|
|
230
|
+
detail: {
|
|
231
|
+
detail: `No secret with key "${key}" in this workspace.`,
|
|
232
|
+
error_code: "secret_not_found",
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
await client.del(`/products/${wid}/secrets/${match.id}`);
|
|
237
|
+
output({
|
|
238
|
+
success: true,
|
|
239
|
+
action: "deleted",
|
|
240
|
+
key,
|
|
241
|
+
id: match.id,
|
|
242
|
+
workspace_id: wid,
|
|
243
|
+
}, globals.json);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
}
|