@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.
Files changed (39) hide show
  1. package/README.md +55 -6
  2. package/dist/auth.d.ts +23 -4
  3. package/dist/auth.js +165 -39
  4. package/dist/commands/ask.d.ts +12 -0
  5. package/dist/commands/ask.js +127 -2
  6. package/dist/commands/chat.d.ts +17 -0
  7. package/dist/commands/chat.js +589 -0
  8. package/dist/commands/iteration.js +232 -13
  9. package/dist/commands/secret.d.ts +20 -0
  10. package/dist/commands/secret.js +246 -0
  11. package/dist/commands/source.js +24 -2
  12. package/dist/commands/study-run.d.ts +38 -0
  13. package/dist/commands/study-run.js +199 -80
  14. package/dist/commands/study-tester.js +17 -2
  15. package/dist/commands/study.js +311 -39
  16. package/dist/commands/workspace.js +81 -0
  17. package/dist/config.d.ts +7 -0
  18. package/dist/connect.d.ts +3 -0
  19. package/dist/connect.js +359 -24
  20. package/dist/index.js +67 -9
  21. package/dist/lib/alias-hydrate.d.ts +42 -0
  22. package/dist/lib/alias-hydrate.js +175 -0
  23. package/dist/lib/alias-store.d.ts +1 -0
  24. package/dist/lib/alias-store.js +28 -1
  25. package/dist/lib/auth.js +11 -3
  26. package/dist/lib/chat-endpoint-formatters.d.ts +39 -0
  27. package/dist/lib/chat-endpoint-formatters.js +104 -0
  28. package/dist/lib/command-helpers.d.ts +18 -0
  29. package/dist/lib/command-helpers.js +188 -53
  30. package/dist/lib/docs.js +662 -34
  31. package/dist/lib/modality.d.ts +42 -0
  32. package/dist/lib/modality.js +192 -0
  33. package/dist/lib/output.d.ts +41 -0
  34. package/dist/lib/output.js +453 -19
  35. package/dist/lib/paths.d.ts +1 -0
  36. package/dist/lib/paths.js +3 -0
  37. package/dist/lib/skill-content.js +183 -13
  38. package/dist/lib/types.d.ts +15 -0
  39. 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 { 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)
@@ -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) or
95
- media content (text/video/image/document). A study has 1..N iterations; \`ish study run\`
96
- defaults to the latest. Local file paths in --content-url / --image-urls are auto-uploaded.
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
- if (isMedia && opts.url) {
225
- throw new Error(`This study uses "${modality}" modality — --url is for interactive studies. Use --content-text, --content-url, or --image-urls instead.`);
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
- 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
+ }
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 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);
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
+ }