@ishlabs/cli 0.8.4 → 0.9.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 CHANGED
@@ -55,7 +55,7 @@ Two top-level research primitives, both consume reusable tester profiles:
55
55
  Workspace (= product, top-level container)
56
56
 
57
57
  ├── Tester Profiles ← reusable audience personas
58
- │ └── Audience Sources (transcripts / audio / images that seed generation)
58
+ │ └── Audience Sources (images/PDFs/audio/video/text transcripts that seed generation)
59
59
 
60
60
  ├── Study ─────────────── "structured research artifact"
61
61
  │ ├── modality (interactive | text | video | audio | image | document)
@@ -103,7 +103,7 @@ ish workspace site-access status | basic-auth | cookie | login | affirm-public
103
103
  ish study list | create | generate | get | results | update | delete | use
104
104
  ish iteration list | create | get | update | delete
105
105
  ish profile list | create | generate | get | update | delete
106
- ish source upload | get
106
+ ish source upload | get | delete
107
107
  ish config list | create | get | schema | update | delete
108
108
  ```
109
109
 
package/dist/auth.d.ts CHANGED
@@ -1,6 +1,21 @@
1
1
  /**
2
- * Browser-based authentication via the Ish frontend plugin auth flow.
3
- * Uses the existing /auth/plugin + /api/plugin/auth/poll infrastructure.
2
+ * Browser-based authentication via Supabase OAuth Server (RFC 6749 / OAuth 2.1).
3
+ *
4
+ * Flow:
5
+ * 1. DCR (RFC 7591) — register a one-shot public OAuth client at the Supabase
6
+ * OAuth Server with the loopback redirect URI we just opened a port for.
7
+ * 2. PKCE (RFC 7636 S256) — generate verifier + challenge.
8
+ * 3. Loopback redirect (RFC 8252) — listen on 127.0.0.1:<random> and open the
9
+ * browser at the Supabase /oauth/authorize URL.
10
+ * 4. After the user signs in + consents at <ish-frontend>/oauth/consent,
11
+ * Supabase redirects back to our loopback with ?code=&state=.
12
+ * 5. Exchange code → tokens at /oauth/token, return access + refresh.
13
+ *
14
+ * Refresh tokens minted here flow back through /oauth/token (not the legacy
15
+ * /auth/v1/token endpoint), so refreshTokens() targets the same URL.
16
+ *
17
+ * Tokens land in ~/.ish/config.json with the same shape as before; the MCP
18
+ * server's `local` mode keeps reading them without changes.
4
19
  */
5
20
  export declare function getAppUrl(): string;
6
21
  export declare function getSupabaseUrl(): string;
@@ -17,13 +32,32 @@ export declare function resolveSupabaseProjectFromToken(accessToken: string | un
17
32
  export declare function decodeJwtExp(token: string): number;
18
33
  export declare function decodeJwtClaims(token: string): Record<string, unknown> | undefined;
19
34
  export declare function isTokenExpired(token: string, bufferSeconds?: number): boolean;
20
- export declare function login(appUrl?: string): Promise<{
35
+ export declare function login(): Promise<{
21
36
  accessToken: string;
22
37
  refreshToken: string;
38
+ clientId: string;
23
39
  }>;
24
- export declare function refreshTokens(refreshToken: string, options?: {
40
+ /**
41
+ * Thrown by `refreshTokens` when the refresh attempt failed permanently and
42
+ * the local config can't recover (e.g. Supabase `refresh_token_not_found`,
43
+ * `invalid_grant`, or any 400-class response). Callers should treat this as
44
+ * "user must run `ish login` again" — retrying won't help.
45
+ *
46
+ * Transient failures (network errors, 5xx, timeouts) are NOT this error and
47
+ * may be worth retrying.
48
+ */
49
+ export declare class AuthRefreshPermanentError extends Error {
50
+ readonly httpStatus: number;
51
+ readonly errorCode: string | undefined;
52
+ readonly body: string;
53
+ constructor(httpStatus: number, body: string, errorCode: string | undefined);
54
+ }
55
+ export declare function refreshTokens(refreshToken: string, options: {
25
56
  /** The (possibly expired) access token. Used to pick the correct Supabase project. */
26
57
  accessToken?: string;
58
+ /** OAuth client_id from DCR at login time. Supabase requires it on
59
+ * refresh because the issued client is public (no client secret). */
60
+ clientId: string;
27
61
  /** Force a specific Supabase project URL (e.g. for tests). */
28
62
  supabaseUrl?: string;
29
63
  /** Force a specific anon/publishable key. */
package/dist/auth.js CHANGED
@@ -1,11 +1,27 @@
1
1
  /**
2
- * Browser-based authentication via the Ish frontend plugin auth flow.
3
- * Uses the existing /auth/plugin + /api/plugin/auth/poll infrastructure.
2
+ * Browser-based authentication via Supabase OAuth Server (RFC 6749 / OAuth 2.1).
3
+ *
4
+ * Flow:
5
+ * 1. DCR (RFC 7591) — register a one-shot public OAuth client at the Supabase
6
+ * OAuth Server with the loopback redirect URI we just opened a port for.
7
+ * 2. PKCE (RFC 7636 S256) — generate verifier + challenge.
8
+ * 3. Loopback redirect (RFC 8252) — listen on 127.0.0.1:<random> and open the
9
+ * browser at the Supabase /oauth/authorize URL.
10
+ * 4. After the user signs in + consents at <ish-frontend>/oauth/consent,
11
+ * Supabase redirects back to our loopback with ?code=&state=.
12
+ * 5. Exchange code → tokens at /oauth/token, return access + refresh.
13
+ *
14
+ * Refresh tokens minted here flow back through /oauth/token (not the legacy
15
+ * /auth/v1/token endpoint), so refreshTokens() targets the same URL.
16
+ *
17
+ * Tokens land in ~/.ish/config.json with the same shape as before; the MCP
18
+ * server's `local` mode keeps reading them without changes.
4
19
  */
20
+ import * as http from "node:http";
5
21
  import * as crypto from "node:crypto";
6
22
  import { execFile } from "node:child_process";
7
- const POLL_INTERVAL = 2_000;
8
- const LOGIN_TIMEOUT = 5 * 60 * 1000; // 5 minutes (matches server-side token TTL)
23
+ const LOGIN_TIMEOUT = 5 * 60 * 1000; // 5 minutes
24
+ const CLIENT_NAME = "ish CLI";
9
25
  const DEFAULT_APP_URL = "https://app.ishlabs.io";
10
26
  // Known Supabase projects, keyed by hostname. The CLI may be talking to either
11
27
  // production or development depending on how the user logged in — the access
@@ -13,12 +29,12 @@ const DEFAULT_APP_URL = "https://app.ishlabs.io";
13
29
  // must send refresh requests back to that same project (with its matching
14
30
  // publishable/anon key) or Supabase will reject them.
15
31
  const SUPABASE_PROJECTS = {
16
- // Production
32
+ // Development (default — local dev work hits this project)
17
33
  "muqvgnqyubmqnfnqwxuk.supabase.co": {
18
34
  url: "https://muqvgnqyubmqnfnqwxuk.supabase.co",
19
35
  anonKey: "sb_publishable_pxXwY9EaWFwkR7h728NWvQ_NFqGfh8K",
20
36
  },
21
- // Development
37
+ // Production
22
38
  "hngymyxdyamokpbeakps.supabase.co": {
23
39
  url: "https://hngymyxdyamokpbeakps.supabase.co",
24
40
  anonKey: "sb_publishable_JlS-HfwNyDqLNbrfbrkUlw_PSdZJdo2",
@@ -108,52 +124,202 @@ export function isTokenExpired(token, bufferSeconds = 300) {
108
124
  return true;
109
125
  return Date.now() / 1000 >= exp - bufferSeconds;
110
126
  }
111
- // --- Login via browser polling ---
112
- export async function login(appUrl) {
113
- const url = appUrl ?? getAppUrl();
114
- const state = crypto.randomBytes(32).toString("hex");
115
- const loginUrl = `${url}/auth/plugin?state=${state}`;
116
- console.error("Opening browser to sign in...");
117
- console.error(`If the browser doesn't open, visit:\n ${loginUrl}\n`);
118
- openBrowser(loginUrl);
119
- console.error("Waiting for authentication...");
120
- const deadline = Date.now() + LOGIN_TIMEOUT;
121
- while (Date.now() < deadline) {
122
- await new Promise((r) => setTimeout(r, POLL_INTERVAL));
123
- try {
124
- const resp = await fetch(`${url}/api/plugin/auth/poll?state=${state}`, {
125
- signal: AbortSignal.timeout(10_000),
126
- });
127
- if (resp.status === 200) {
128
- const data = await resp.json();
129
- if (data.status === "complete" && data.access_token && data.refresh_token) {
130
- return { accessToken: data.access_token, refreshToken: data.refresh_token };
131
- }
127
+ function startCallbackServer() {
128
+ return new Promise((resolve, reject) => {
129
+ let resolveCallback = null;
130
+ const callbackPromise = new Promise((res) => {
131
+ resolveCallback = res;
132
+ });
133
+ const server = http.createServer((req, res) => {
134
+ const reqUrl = new URL(req.url ?? "/", "http://127.0.0.1");
135
+ if (reqUrl.pathname !== "/callback") {
136
+ res.writeHead(404);
137
+ res.end();
138
+ return;
139
+ }
140
+ const cb = {
141
+ code: reqUrl.searchParams.get("code") ?? undefined,
142
+ state: reqUrl.searchParams.get("state") ?? undefined,
143
+ error: reqUrl.searchParams.get("error") ?? undefined,
144
+ errorDescription: reqUrl.searchParams.get("error_description") ?? undefined,
145
+ };
146
+ const headline = cb.error ? "Sign-in failed" : "Signed in to Ish";
147
+ const subline = cb.error
148
+ ? `${cb.error}${cb.errorDescription ? `: ${cb.errorDescription}` : ""}`
149
+ : "You can close this window and return to your terminal.";
150
+ const html = `<!doctype html><html><head><meta charset="utf-8"><title>${headline}</title></head><body style="font-family:system-ui,-apple-system,sans-serif;padding:3em;text-align:center;color:#1f2937"><h1 style="font-weight:600;margin-bottom:1em">${headline}</h1><p style="color:#6b7280">${subline}</p></body></html>`;
151
+ res.writeHead(cb.error ? 400 : 200, { "Content-Type": "text/html; charset=utf-8" });
152
+ res.end(html);
153
+ if (resolveCallback)
154
+ resolveCallback(cb);
155
+ });
156
+ server.on("error", reject);
157
+ server.listen(0, "127.0.0.1", () => {
158
+ const addr = server.address();
159
+ if (!addr || typeof addr === "string") {
160
+ reject(new Error("Failed to start callback server"));
161
+ return;
132
162
  }
133
- // 202 = pending, keep polling
163
+ resolve({
164
+ port: addr.port,
165
+ waitForCallback: () => callbackPromise,
166
+ close: () => {
167
+ server.close();
168
+ },
169
+ });
170
+ });
171
+ });
172
+ }
173
+ async function registerOAuthClient(supabaseUrl, redirectUri) {
174
+ const resp = await fetch(`${supabaseUrl}/auth/v1/oauth/clients/register`, {
175
+ method: "POST",
176
+ headers: { "Content-Type": "application/json" },
177
+ body: JSON.stringify({
178
+ client_name: CLIENT_NAME,
179
+ redirect_uris: [redirectUri],
180
+ application_type: "native",
181
+ grant_types: ["authorization_code", "refresh_token"],
182
+ response_types: ["code"],
183
+ token_endpoint_auth_method: "none",
184
+ }),
185
+ signal: AbortSignal.timeout(15_000),
186
+ });
187
+ if (!resp.ok) {
188
+ throw new Error(`Dynamic client registration failed (HTTP ${resp.status}): ${await resp.text().catch(() => "")}`);
189
+ }
190
+ const data = await resp.json();
191
+ if (typeof data.client_id !== "string") {
192
+ throw new Error("Dynamic client registration response missing client_id");
193
+ }
194
+ return data.client_id;
195
+ }
196
+ function generatePkcePair() {
197
+ const verifier = crypto.randomBytes(64).toString("base64url");
198
+ const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
199
+ return { verifier, challenge };
200
+ }
201
+ export async function login() {
202
+ const supabaseUrl = getSupabaseUrl();
203
+ const server = await startCallbackServer();
204
+ const redirectUri = `http://127.0.0.1:${server.port}/callback`;
205
+ try {
206
+ const clientId = await registerOAuthClient(supabaseUrl, redirectUri);
207
+ const { verifier, challenge } = generatePkcePair();
208
+ const state = crypto.randomBytes(24).toString("base64url");
209
+ const authorizeUrl = new URL(`${supabaseUrl}/auth/v1/oauth/authorize`);
210
+ authorizeUrl.searchParams.set("response_type", "code");
211
+ authorizeUrl.searchParams.set("client_id", clientId);
212
+ authorizeUrl.searchParams.set("redirect_uri", redirectUri);
213
+ authorizeUrl.searchParams.set("state", state);
214
+ authorizeUrl.searchParams.set("scope", "openid email profile");
215
+ authorizeUrl.searchParams.set("code_challenge", challenge);
216
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
217
+ console.error("Opening browser to sign in...");
218
+ console.error(`If the browser doesn't open, visit:\n ${authorizeUrl.toString()}\n`);
219
+ openBrowser(authorizeUrl.toString());
220
+ console.error("Waiting for authentication...");
221
+ const timeoutPromise = new Promise((_, reject) => {
222
+ setTimeout(() => reject(new Error("Login timed out. Please try again.")), LOGIN_TIMEOUT);
223
+ });
224
+ const cb = await Promise.race([server.waitForCallback(), timeoutPromise]);
225
+ if (cb.error) {
226
+ throw new Error(`OAuth error: ${cb.error}${cb.errorDescription ? ` — ${cb.errorDescription}` : ""}`);
134
227
  }
135
- catch {
136
- // Network error, keep polling
228
+ if (cb.state !== state) {
229
+ throw new Error("OAuth state mismatch — possible CSRF attempt. Please try again.");
230
+ }
231
+ if (!cb.code) {
232
+ throw new Error("No authorization code received from Supabase.");
233
+ }
234
+ const tokenResp = await fetch(`${supabaseUrl}/auth/v1/oauth/token`, {
235
+ method: "POST",
236
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
237
+ body: new URLSearchParams({
238
+ grant_type: "authorization_code",
239
+ code: cb.code,
240
+ redirect_uri: redirectUri,
241
+ client_id: clientId,
242
+ code_verifier: verifier,
243
+ }).toString(),
244
+ signal: AbortSignal.timeout(15_000),
245
+ });
246
+ if (!tokenResp.ok) {
247
+ throw new Error(`Token exchange failed (HTTP ${tokenResp.status}): ${await tokenResp.text().catch(() => "")}`);
248
+ }
249
+ const tokenData = await tokenResp.json();
250
+ if (typeof tokenData.access_token !== "string" || typeof tokenData.refresh_token !== "string") {
251
+ throw new Error("Token exchange response missing access_token or refresh_token");
137
252
  }
253
+ return {
254
+ accessToken: tokenData.access_token,
255
+ refreshToken: tokenData.refresh_token,
256
+ clientId,
257
+ };
258
+ }
259
+ finally {
260
+ server.close();
138
261
  }
139
- throw new Error("Login timed out. Please try again.");
140
262
  }
141
263
  // --- Token refresh ---
264
+ /**
265
+ * Thrown by `refreshTokens` when the refresh attempt failed permanently and
266
+ * the local config can't recover (e.g. Supabase `refresh_token_not_found`,
267
+ * `invalid_grant`, or any 400-class response). Callers should treat this as
268
+ * "user must run `ish login` again" — retrying won't help.
269
+ *
270
+ * Transient failures (network errors, 5xx, timeouts) are NOT this error and
271
+ * may be worth retrying.
272
+ */
273
+ export class AuthRefreshPermanentError extends Error {
274
+ httpStatus;
275
+ errorCode;
276
+ body;
277
+ constructor(httpStatus, body, errorCode) {
278
+ const detail = errorCode ? ` ${errorCode}` : "";
279
+ super(`Token refresh failed permanently (HTTP ${httpStatus}${detail}): ${body}`);
280
+ this.name = "AuthRefreshPermanentError";
281
+ this.httpStatus = httpStatus;
282
+ this.errorCode = errorCode;
283
+ this.body = body;
284
+ }
285
+ }
286
+ function parseRefreshErrorCode(body) {
287
+ try {
288
+ const parsed = JSON.parse(body);
289
+ if (parsed && typeof parsed === "object") {
290
+ if (typeof parsed.error_code === "string")
291
+ return parsed.error_code;
292
+ if (typeof parsed.error === "string")
293
+ return parsed.error;
294
+ }
295
+ }
296
+ catch {
297
+ // not JSON — fall through
298
+ }
299
+ return undefined;
300
+ }
142
301
  export async function refreshTokens(refreshToken, options) {
143
- const project = options?.supabaseUrl && options?.anonKey
302
+ const project = options.supabaseUrl && options.anonKey
144
303
  ? { url: options.supabaseUrl, anonKey: options.anonKey }
145
- : resolveSupabaseProjectFromToken(options?.accessToken);
146
- const resp = await fetch(`${project.url}/auth/v1/token?grant_type=refresh_token`, {
304
+ : resolveSupabaseProjectFromToken(options.accessToken);
305
+ // OAuth-server-minted tokens refresh through /oauth/token. The legacy
306
+ // /auth/v1/token endpoint is for password/magic-link flows and won't
307
+ // accept refresh tokens issued under the OAuth grant.
308
+ const resp = await fetch(`${project.url}/auth/v1/oauth/token`, {
147
309
  method: "POST",
148
- headers: {
149
- apikey: project.anonKey,
150
- "Content-Type": "application/json",
151
- },
152
- body: JSON.stringify({ refresh_token: refreshToken }),
310
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
311
+ body: new URLSearchParams({
312
+ grant_type: "refresh_token",
313
+ refresh_token: refreshToken,
314
+ client_id: options.clientId,
315
+ }).toString(),
153
316
  signal: AbortSignal.timeout(10_000),
154
317
  });
155
318
  if (!resp.ok) {
156
319
  const body = await resp.text().catch(() => "");
320
+ if (resp.status >= 400 && resp.status < 500) {
321
+ throw new AuthRefreshPermanentError(resp.status, body, parseRefreshErrorCode(body));
322
+ }
157
323
  throw new Error(`Token refresh failed (HTTP ${resp.status}): ${body}`);
158
324
  }
159
325
  const data = await resp.json();
@@ -326,6 +326,10 @@ Minimal --questions JSON (server keys: "question" + "type"):
326
326
  { "question": "What stood out?", "type": "text" },
327
327
  { "question": "Rate it 1-5", "type": "slider" }
328
328
  ]
329
+
330
+ Picks come back with a \`pick_confidence\` (0..1) score per tester when
331
+ \`--wants-pick\` is set — read it off \`pick.confidence\` in the response. See
332
+ \`ish docs get-page concepts/ask\` for interpretation.
329
333
  `)
330
334
  .action(async (opts, cmd) => {
331
335
  await withClient(cmd, async (client, globals) => {
@@ -430,7 +434,14 @@ lookups.`)
430
434
  .argument("[id]", "Ask alias or UUID (defaults to active ask)")
431
435
  .option("--ask <id>", "Ask ID; alternative to positional argument")
432
436
  .option("--round <n>", "Show only round N (1-indexed; default: all rounds)")
433
- .addHelpText("after", "\nExamples:\n $ ish ask results a-6ec\n $ ish ask results a-6ec --round 1 --json")
437
+ .addHelpText("after", `
438
+ Examples:
439
+ $ ish ask results a-6ec
440
+ $ ish ask results a-6ec --round 1 --json
441
+
442
+ Each pick has a \`pick_confidence\` field (0..1, when --wants-pick was set) —
443
+ the model's self-reported confidence in its variant choice. See
444
+ \`ish docs get-page concepts/ask\` for how to use it for ranking ties.`)
434
445
  .action(async (id, opts, cmd) => {
435
446
  await withClient(cmd, async (client, globals) => {
436
447
  const aid = resolveAsk(pickAskRef(id, opts.ask));
@@ -539,13 +550,18 @@ lookups.`)
539
550
  .requiredOption("--round <n|round-id>", "Round number (1-indexed) or round id/alias")
540
551
  .requiredOption("--questions <file.json>", `Questions JSON file: [{"question":"...","type":"text"|"slider"|"likert"|"single-choice"|"multiple-choice"|"number"}]`)
541
552
  .option("--redispatch-all", "Clear prior phase-1 outputs (comment, pick, ratings) and re-run the entire round from scratch (legacy behavior). Default is additive — only the new questions are answered.", false)
553
+ .option("--wait", "Wait until the round completes (or errors)")
554
+ .option("--timeout <s>", "Wait timeout in seconds (default 300)")
542
555
  .addHelpText("after", `
543
556
  Examples:
544
557
  # Additive (default): preserves prior picks/ratings/comments.
545
558
  $ ish ask add-questions a-6ec --round 1 --questions ./qs.json
546
559
 
560
+ # Wait for the round to finish before returning.
561
+ $ ish ask add-questions a-6ec --round 1 --questions ./qs.json --wait
562
+
547
563
  # Legacy reset: re-runs the whole round; prior picks may shift.
548
- $ ish ask add-questions a-6ec --round 1 --questions ./qs.json --redispatch-all
564
+ $ ish ask add-questions a-6ec --round 1 --questions ./qs.json --redispatch-all --wait
549
565
 
550
566
  Minimal valid --questions JSON:
551
567
  [
@@ -566,6 +582,16 @@ text, slider, likert, single-choice, multiple-choice, number.`)
566
582
  ...(opts.redispatchAll && { redispatch_all: true }),
567
583
  };
568
584
  const updated = await client.post(`/asks/${aid}/rounds/${round.id}/questions`, body);
585
+ if (opts.wait) {
586
+ const timeoutMs = parseWaitTimeout(opts.timeout);
587
+ await pollUntilRoundDone(client, aid, updated.order_index, timeoutMs, !!globals.quiet);
588
+ const refreshed = await client.get(`/asks/${aid}`);
589
+ const target = refreshed.rounds.find((r) => r.id === updated.id);
590
+ if (target) {
591
+ formatRoundDetail(target, globals.json);
592
+ return;
593
+ }
594
+ }
569
595
  if (!globals.json || globals.verbose) {
570
596
  formatRoundDetail(updated, globals.json);
571
597
  return;
@@ -23,6 +23,30 @@ function buildCopyContent(opts) {
23
23
  ...(opts.copyPosition && { position: opts.copyPosition }),
24
24
  };
25
25
  }
26
+ function parseJsonFlag(raw, flagName) {
27
+ if (!raw)
28
+ return undefined;
29
+ try {
30
+ const parsed = JSON.parse(raw);
31
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
32
+ throw new Error(`Invalid ${flagName}: expected a JSON object`);
33
+ }
34
+ return parsed;
35
+ }
36
+ catch (err) {
37
+ if (err instanceof Error && err.message.startsWith("Invalid "))
38
+ throw err;
39
+ throw new Error(`Invalid ${flagName}: expected valid JSON object`);
40
+ }
41
+ }
42
+ function mediaExtras(opts) {
43
+ const segmentation = parseJsonFlag(opts.segmentationJson, "--segmentation-json");
44
+ const contentConfig = parseJsonFlag(opts.contentConfigJson, "--content-config-json");
45
+ return {
46
+ ...(segmentation && { segmentation }),
47
+ ...(contentConfig && { content_config: contentConfig }),
48
+ };
49
+ }
26
50
  function buildIterationDetails(modality, opts) {
27
51
  switch (modality) {
28
52
  case "text":
@@ -35,6 +59,10 @@ function buildIterationDetails(modality, opts) {
35
59
  ...(opts.contentHtml && { content_html: opts.contentHtml }),
36
60
  ...(opts.title && { title: opts.title }),
37
61
  ...(opts.mimeType && { mime_type: opts.mimeType }),
62
+ ...(opts.senderName && { sender_name: opts.senderName }),
63
+ ...(opts.senderEmail && { sender_email: opts.senderEmail }),
64
+ ...(opts.featuredImageUrl && { featured_image_url: opts.featuredImageUrl }),
65
+ ...mediaExtras(opts),
38
66
  };
39
67
  case "video":
40
68
  case "audio": {
@@ -48,6 +76,7 @@ function buildIterationDetails(modality, opts) {
48
76
  ...(opts.title && { title: opts.title }),
49
77
  ...(opts.mimeType && { mime_type: opts.mimeType }),
50
78
  ...(copy && { copy_content: copy }),
79
+ ...mediaExtras(opts),
51
80
  };
52
81
  }
53
82
  case "image": {
@@ -61,6 +90,7 @@ function buildIterationDetails(modality, opts) {
61
90
  ...(opts.title && { title: opts.title }),
62
91
  ...(opts.mimeType && { mime_type: opts.mimeType }),
63
92
  ...(copy && { copy_content: copy }),
93
+ ...mediaExtras(opts),
64
94
  };
65
95
  }
66
96
  case "document":
@@ -72,17 +102,45 @@ function buildIterationDetails(modality, opts) {
72
102
  content_url: opts.contentUrl,
73
103
  ...(opts.title && { title: opts.title }),
74
104
  ...(opts.mimeType && { mime_type: opts.mimeType }),
105
+ ...mediaExtras(opts),
75
106
  };
107
+ case "chat": {
108
+ const endpoint = parseJsonFlag(opts.chatEndpointJson, "--chat-endpoint-json");
109
+ if (!endpoint && !opts.chatEndpointId) {
110
+ throw new Error("Chat iterations require either --chat-endpoint-id (saved endpoint) or --chat-endpoint-json (inline config).");
111
+ }
112
+ let maxTurns;
113
+ if (opts.maxTurns !== undefined) {
114
+ const parsed = Number.parseInt(opts.maxTurns, 10);
115
+ if (!Number.isFinite(parsed) || parsed < 1) {
116
+ throw new Error("Invalid --max-turns: expected a positive integer");
117
+ }
118
+ maxTurns = parsed;
119
+ }
120
+ return {
121
+ type: "chat",
122
+ ...(endpoint && { endpoint }),
123
+ ...(opts.chatEndpointId && { chatbot_endpoint_id: opts.chatEndpointId }),
124
+ ...(maxTurns !== undefined && { max_turns: maxTurns }),
125
+ ...(opts.earlyTermination !== undefined && { early_termination: opts.earlyTermination }),
126
+ };
127
+ }
76
128
  default:
77
129
  if (!opts.url) {
78
130
  throw new Error("Interactive iterations require --url. Provide the URL to test.");
79
131
  }
132
+ if (opts.platform === "figma" && (!opts.fileKey || !opts.startNodeId)) {
133
+ throw new Error("Figma interactive iterations require both --file-key and --start-node-id.");
134
+ }
80
135
  return {
81
136
  type: "interactive",
82
137
  platform: opts.platform || "browser",
83
138
  url: opts.url,
84
139
  screen_format: opts.screenFormat || "desktop",
85
140
  ...(opts.locale && { locale: opts.locale }),
141
+ ...(opts.fileKey && { file_key: opts.fileKey }),
142
+ ...(opts.startNodeId && { start_node_id: opts.startNodeId }),
143
+ ...(opts.flowName && { flow_name: opts.flowName }),
86
144
  };
87
145
  }
88
146
  }
@@ -91,9 +149,11 @@ export function registerIterationCommands(program) {
91
149
  .command("iteration")
92
150
  .description("Manage iterations of a study (a study's run-time configuration)")
93
151
  .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.
152
+ An iteration is one configured run of a study — it carries the URL (interactive),
153
+ media content (text/video/image/document), or chatbot endpoint (chat). A study has
154
+ 1..N iterations; \`ish study run\` defaults to the latest. Local file paths in
155
+ --content-url / --image-urls are auto-uploaded. Segments and per-segment labels
156
+ live inside --segmentation-json.
97
157
 
98
158
  Concept pages: ish docs get-page concepts/iteration
99
159
  ish docs get-page concepts/study`);
@@ -145,9 +205,15 @@ Concept pages: ish docs get-page concepts/iteration
145
205
  .option("--url <url>", "URL to test — interactive only")
146
206
  .option("--screen-format <format>", "Screen format (mobile_portrait, desktop) — interactive only")
147
207
  .option("--locale <locale>", "Locale code (e.g. en-US) — interactive only")
208
+ .option("--file-key <key>", "Figma file key — required when --platform=figma")
209
+ .option("--start-node-id <id>", "Figma start node id — required when --platform=figma")
210
+ .option("--flow-name <name>", "Figma flow name — interactive only")
148
211
  // Media text
149
212
  .option("--content-text <text>", "Text content to evaluate, or @filepath to read from file — text modality")
150
213
  .option("--content-html <html>", "HTML version of the text, or @filepath to read from file — text modality")
214
+ .option("--sender-name <name>", "Email 'From' display name — text modality (email rendering)")
215
+ .option("--sender-email <email>", "Email sender address — text modality (email rendering)")
216
+ .option("--featured-image-url <url>", "Hero image URL — text modality (email rendering)")
151
217
  // Media video/audio/document
152
218
  .option("--content-url <url>", "URL or local file path to media file — video, audio, document modalities")
153
219
  // Media image
@@ -160,6 +226,14 @@ Concept pages: ish docs get-page concepts/iteration
160
226
  .option("--copy-html <html>", "HTML version of copy text (or @filepath)")
161
227
  .option("--social-platform <platform>", "Social platform (instagram, tiktok, facebook, linkedin, x)")
162
228
  .option("--copy-position <pos>", "Copy position relative to media (before, after)", "after")
229
+ // Segmentation / per-iteration evaluation config (media modalities)
230
+ .option("--segmentation-json <json>", "Segmentation JSON — time_based {intervals_seconds, labels?}, section_based {sections[{name,label,...}]}, or page_based {} — media modalities")
231
+ .option("--content-config-json <json>", "Content config JSON — {early_termination, selected_segment_indices?} — media modalities")
232
+ // 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")
236
+ .option("--early-termination", "End the chat session early when the tester signals stop — chat modality")
163
237
  // Escape hatch
164
238
  .option("--details-json <json>", "Raw iteration details JSON (overrides individual flags)")
165
239
  .addHelpText("after", `
@@ -194,6 +268,25 @@ Examples:
194
268
  $ ish iteration create --image-urls ./post.png \\
195
269
  --copy-text @./caption.txt --social-platform instagram
196
270
 
271
+ # Email with sender + featured image + section-based segmentation:
272
+ $ ish iteration create --content-text @./email.txt --content-html @./email.html \\
273
+ --sender-name "Marketing" --sender-email "marketing@example.com" \\
274
+ --featured-image-url https://cdn.example.com/hero.png \\
275
+ --segmentation-json '{"type":"section_based","sections":[{"name":"intro","label":"Intro","paragraph_start":0,"paragraph_end":1}]}'
276
+
277
+ # Video with time-based segmentation + labels and early termination:
278
+ $ ish iteration create --content-url ./promo.mp4 \\
279
+ --segmentation-json '{"type":"time_based","intervals_seconds":[0,30,60],"labels":["Hook","Body","CTA"]}' \\
280
+ --content-config-json '{"early_termination":true,"selected_segment_indices":[0,2]}'
281
+
282
+ # Figma interactive (file_key + start_node_id required):
283
+ $ ish iteration create --platform figma --url https://figma.com/proto \\
284
+ --screen-format mobile_portrait --file-key abc123 --start-node-id 0:1 \\
285
+ --flow-name "Onboarding A"
286
+
287
+ # Chat (probe a saved chatbot endpoint):
288
+ $ ish iteration create --chat-endpoint-id ce-... --max-turns 10 --early-termination
289
+
197
290
  # Raw JSON escape hatch (overrides individual flags):
198
291
  $ ish iteration create --study s-b2c --details-json \\
199
292
  '{"type":"interactive","platform":"browser","url":"https://example.com","screen_format":"desktop"}'
@@ -221,10 +314,11 @@ Next: \`ish study run\` to dispatch simulations against this iteration.`)
221
314
  const study = await client.get(`/studies/${studyId}`);
222
315
  const modality = study.modality || "interactive";
223
316
  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.`);
317
+ const isChat = modality === "chat";
318
+ if ((isMedia || isChat) && opts.url) {
319
+ throw new Error(`This study uses "${modality}" modality — --url is for interactive studies. Use the modality-specific flags instead.`);
226
320
  }
227
- if (!isMedia && (opts.contentText || opts.contentUrl || opts.imageUrls)) {
321
+ if (!isMedia && !isChat && (opts.contentText || opts.contentUrl || opts.imageUrls)) {
228
322
  throw new Error(`This study uses "interactive" modality — --content-text, --content-url, and --image-urls are for media studies. Use --url instead.`);
229
323
  }
230
324
  // Validate per-modality required flags BEFORE any upload so we don't
@@ -252,6 +346,11 @@ Next: \`ish study run\` to dispatch simulations against this iteration.`)
252
346
  throw new Error("Document iterations require --content-url. Provide the URL or local file path.");
253
347
  }
254
348
  break;
349
+ 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).");
352
+ }
353
+ break;
255
354
  default:
256
355
  if (!opts.url) {
257
356
  throw new Error("Interactive iterations require --url. Provide the URL to test.");