@ishlabs/cli 0.9.0 → 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.
@@ -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 { MEDIA_MODALITIES } from "../lib/types.js";
13
- function isMediaModality(modality) {
14
- return !!modality && MEDIA_MODALITIES.includes(modality);
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)
@@ -230,10 +252,17 @@ Concept pages: ish docs get-page concepts/iteration
230
252
  .option("--segmentation-json <json>", "Segmentation JSON — time_based {intervals_seconds, labels?}, section_based {sections[{name,label,...}]}, or page_based {} — media modalities")
231
253
  .option("--content-config-json <json>", "Content config JSON — {early_termination, selected_segment_indices?} — media modalities")
232
254
  // Chat modality
233
- .option("--chat-endpoint-id <id>", "Saved chatbot endpoint id — chat modality")
234
- .option("--chat-endpoint-json <json>", "Inline chatbot endpoint config JSON — chat modality")
235
- .option("--max-turns <n>", "Max tester turns (1-50) — 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)")
236
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")
237
266
  // Escape hatch
238
267
  .option("--details-json <json>", "Raw iteration details JSON (overrides individual flags)")
239
268
  .addHelpText("after", `
@@ -284,8 +313,15 @@ Examples:
284
313
  --screen-format mobile_portrait --file-key abc123 --start-node-id 0:1 \\
285
314
  --flow-name "Onboarding A"
286
315
 
287
- # Chat (probe a saved chatbot endpoint):
288
- $ ish iteration create --chat-endpoint-id ce-... --max-turns 10 --early-termination
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
289
325
 
290
326
  # Raw JSON escape hatch (overrides individual flags):
291
327
  $ ish iteration create --study s-b2c --details-json \\
@@ -297,9 +333,13 @@ workspace's public storage bucket. Validation now happens before upload.
297
333
  Next: \`ish study run\` to dispatch simulations against this iteration.`)
298
334
  .action(async (opts, cmd) => {
299
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
+ }
300
339
  if (opts.workspace)
301
340
  resolveWorkspace(opts.workspace);
302
341
  const studyId = resolveStudy(opts.study);
342
+ let chatbotEndpointIdForOutput = null;
303
343
  let details;
304
344
  if (opts.detailsJson) {
305
345
  try {
@@ -321,6 +361,9 @@ Next: \`ish study run\` to dispatch simulations against this iteration.`)
321
361
  if (!isMedia && !isChat && (opts.contentText || opts.contentUrl || opts.imageUrls)) {
322
362
  throw new Error(`This study uses "interactive" modality — --content-text, --content-url, and --image-urls are for media studies. Use --url instead.`);
323
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
+ }
324
367
  // Validate per-modality required flags BEFORE any upload so we don't
325
368
  // orphan blobs in storage when the wrong flag is passed (e.g.
326
369
  // --content-url to an image-modality study).
@@ -347,8 +390,15 @@ Next: \`ish study run\` to dispatch simulations against this iteration.`)
347
390
  }
348
391
  break;
349
392
  case "chat":
350
- if (!opts.chatEndpointId && !opts.chatEndpointJson) {
351
- throw new Error("Chat iterations require either --chat-endpoint-id (saved endpoint) or --chat-endpoint-json (inline config).");
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"]);
352
402
  }
353
403
  break;
354
404
  default:
@@ -374,7 +424,55 @@ Next: \`ish study run\` to dispatch simulations against this iteration.`)
374
424
  resolved.imageUrls = urls.join(",");
375
425
  }
376
426
  }
377
- details = buildIterationDetails(modality, resolved);
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
+ }
378
476
  }
379
477
  const body = {
380
478
  name: opts.name || `CLI ${new Date().toISOString().slice(0, 16)}`,
@@ -385,6 +483,9 @@ Next: \`ish study run\` to dispatch simulations against this iteration.`)
385
483
  const result = data;
386
484
  if (result.id)
387
485
  result.alias = tagAlias(ALIAS_PREFIX.iteration, String(result.id));
486
+ if (chatbotEndpointIdForOutput !== null) {
487
+ result.chatbot_endpoint_id = chatbotEndpointIdForOutput;
488
+ }
388
489
  output(result, globals.json);
389
490
  });
390
491
  });
@@ -410,6 +511,7 @@ With multiple IDs, returns a {items:[...], total:N} envelope.`)
410
511
  const result = data;
411
512
  if (result.id)
412
513
  result.alias = tagAlias(ALIAS_PREFIX.iteration, String(result.id));
514
+ enrichIterationTesters(result);
413
515
  output(result, globals.json);
414
516
  return;
415
517
  }
@@ -418,6 +520,7 @@ With multiple IDs, returns a {items:[...], total:N} envelope.`)
418
520
  const r = data;
419
521
  if (r.id)
420
522
  r.alias = tagAlias(ALIAS_PREFIX.iteration, String(r.id));
523
+ enrichIterationTesters(r);
421
524
  return r;
422
525
  }));
423
526
  if (globals.json) {
@@ -458,7 +561,24 @@ With multiple IDs, returns a {items:[...], total:N} envelope.`)
458
561
  console.error("No update flags provided. Run `ish iteration update --help` for options.");
459
562
  return;
460
563
  }
461
- const data = await client.put(`/iterations/${resolveId(id)}`, body);
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);
462
582
  const result = data;
463
583
  if (result.id)
464
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
+ }
@@ -8,4 +8,42 @@
8
8
  * Lower-level: `study poll`, `study cancel`.
9
9
  */
10
10
  import type { Command } from "commander";
11
+ /**
12
+ * M8 / M9 / Pattern G: typed wait-timeout error so `--wait` paths can
13
+ * produce a structured envelope (`error_code: "wait_timeout"`, exit 5
14
+ * transient) distinct from the generic timeout/network/server errors
15
+ * that the api-client wrapper produces. Carries the in-flight progress
16
+ * (testers done / total) so `study wait` always emits final state JSON
17
+ * even when it bails on the timer.
18
+ */
19
+ export declare class WaitTimeoutError extends Error {
20
+ readonly progress: {
21
+ study_id: string;
22
+ iteration_id?: string;
23
+ timeout_seconds: number;
24
+ done: number;
25
+ total: number;
26
+ pending: number;
27
+ rows: TesterStatusRow[];
28
+ };
29
+ readonly error_code = "wait_timeout";
30
+ readonly retryable = true;
31
+ constructor(message: string, progress: {
32
+ study_id: string;
33
+ iteration_id?: string;
34
+ timeout_seconds: number;
35
+ done: number;
36
+ total: number;
37
+ pending: number;
38
+ rows: TesterStatusRow[];
39
+ });
40
+ }
41
+ interface TesterStatusRow {
42
+ id: string;
43
+ status: string;
44
+ tester_name: string;
45
+ interaction_count: number;
46
+ error_message?: string;
47
+ }
11
48
  export declare function attachStudyRunCommands(study: Command): void;
49
+ export {};