@ishlabs/cli 0.8.5 → 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
@@ -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,9 +32,10 @@ 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
40
  /**
25
41
  * Thrown by `refreshTokens` when the refresh attempt failed permanently and
@@ -36,9 +52,12 @@ export declare class AuthRefreshPermanentError extends Error {
36
52
  readonly body: string;
37
53
  constructor(httpStatus: number, body: string, errorCode: string | undefined);
38
54
  }
39
- export declare function refreshTokens(refreshToken: string, options?: {
55
+ export declare function refreshTokens(refreshToken: string, options: {
40
56
  /** The (possibly expired) access token. Used to pick the correct Supabase project. */
41
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;
42
61
  /** Force a specific Supabase project URL (e.g. for tests). */
43
62
  supabaseUrl?: string;
44
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,35 +124,141 @@ 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;
132
139
  }
133
- // 202 = pending, keep polling
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;
162
+ }
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.");
137
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");
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 ---
142
264
  /**
@@ -177,16 +299,20 @@ function parseRefreshErrorCode(body) {
177
299
  return undefined;
178
300
  }
179
301
  export async function refreshTokens(refreshToken, options) {
180
- const project = options?.supabaseUrl && options?.anonKey
302
+ const project = options.supabaseUrl && options.anonKey
181
303
  ? { url: options.supabaseUrl, anonKey: options.anonKey }
182
- : resolveSupabaseProjectFromToken(options?.accessToken);
183
- 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`, {
184
309
  method: "POST",
185
- headers: {
186
- apikey: project.anonKey,
187
- "Content-Type": "application/json",
188
- },
189
- 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(),
190
316
  signal: AbortSignal.timeout(10_000),
191
317
  });
192
318
  if (!resp.ok) {
@@ -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.");
@@ -7,8 +7,8 @@
7
7
  * generation runs, or to inspect processing status.
8
8
  */
9
9
  import { withClient, resolveWorkspace } from "../lib/command-helpers.js";
10
- import { resolveId } from "../lib/alias-store.js";
11
- import { formatAudienceSource } from "../lib/output.js";
10
+ import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
11
+ import { formatAudienceSource, output } from "../lib/output.js";
12
12
  import { inferSourceKind, uploadAndProcessSource, } from "../lib/profile-sources.js";
13
13
  const VALID_KINDS = ["text_file", "audio", "image"];
14
14
  export function registerSourceCommands(program) {
@@ -75,4 +75,26 @@ Examples:
75
75
  formatAudienceSource(src, globals.json);
76
76
  });
77
77
  });
78
+ source
79
+ .command("delete")
80
+ .description("Delete an audience source plus its uploaded file")
81
+ .argument("<id>", "Source ID or alias")
82
+ .addHelpText("after", `
83
+ Removes both the database row and the underlying uploaded file. Profiles
84
+ already generated from this source remain in place — they don't reference
85
+ the source after generation.
86
+
87
+ Examples:
88
+ $ ish source delete tps-3a4`)
89
+ .action(async (id, _opts, cmd) => {
90
+ await withClient(cmd, async (client, globals) => {
91
+ const rid = resolveId(id);
92
+ await client.del(`/tester-profiles/sources/${rid}`);
93
+ output({
94
+ id: rid,
95
+ alias: tagAlias(ALIAS_PREFIX.testerProfileSource, rid),
96
+ message: "Source deleted",
97
+ }, globals.json, { writePath: true });
98
+ });
99
+ });
78
100
  }
@@ -81,7 +81,7 @@ Concept pages: ish docs get-page concepts/study
81
81
  .option("--workspace <id>", "Workspace ID")
82
82
  .requiredOption("--name <name>", "Study name")
83
83
  .option("--description <description>", "Study description")
84
- .option("--modality <modality>", "Study modality (interactive, video, audio, text, image, document)")
84
+ .option("--modality <modality>", "Study modality (interactive, video, audio, text, image, document, chat)")
85
85
  .option("--content-type <type>", "Content type (varies by modality — see examples below)")
86
86
  .option("--assignment <name:instructions>", "Assignment as 'Name:Instructions' (repeatable)", collectRepeatable, [])
87
87
  .option("--assignments-file <path>", "JSON file with assignments array")
@@ -317,7 +317,7 @@ When no runs have completed, the same envelope is returned with zero counts and
317
317
  .option("--name <name>", "Study name")
318
318
  .option("--description <description>", "Study description")
319
319
  .option("--status <status>", "Study status (draft, running, completed)")
320
- .option("--modality <modality>", "Study modality (interactive, video, audio, text, image, document)")
320
+ .option("--modality <modality>", "Study modality (interactive, video, audio, text, image, document, chat)")
321
321
  .option("--content-type <type>", "Content type")
322
322
  .option("--assignment <name:instructions>", "Replace all assignments with these (repeatable)", collectRepeatable, [])
323
323
  .option("--assignments-file <path>", "JSON file with assignments array")
package/dist/config.d.ts CHANGED
@@ -5,6 +5,10 @@
5
5
  export interface IshConfig {
6
6
  access_token?: string;
7
7
  refresh_token?: string;
8
+ /** OAuth client_id minted by Supabase DCR at last login. Required to refresh
9
+ * tokens issued by Supabase OAuth Server (public client → must include
10
+ * client_id on refresh). Absent for legacy/non-OAuth tokens. */
11
+ oauth_client_id?: string;
8
12
  token?: string;
9
13
  workspace?: string;
10
14
  study?: string;
package/dist/connect.js CHANGED
@@ -233,8 +233,14 @@ async function resolveToken(tokenArg, apiUrl, tokenFileArg) {
233
233
  let accessToken = config.access_token;
234
234
  // Refresh if expired or close to expiry
235
235
  if (isTokenExpired(accessToken)) {
236
+ if (!config.oauth_client_id) {
237
+ throw new Error('Saved tokens are missing oauth_client_id. Run "ish login" to re-authenticate.');
238
+ }
236
239
  try {
237
- const tokens = await refreshTokens(config.refresh_token);
240
+ const tokens = await refreshTokens(config.refresh_token, {
241
+ accessToken,
242
+ clientId: config.oauth_client_id,
243
+ });
238
244
  accessToken = tokens.accessToken;
239
245
  config.access_token = tokens.accessToken;
240
246
  config.refresh_token = tokens.refreshToken;
@@ -250,7 +256,12 @@ async function resolveToken(tokenArg, apiUrl, tokenFileArg) {
250
256
  const cfg = loadConfig();
251
257
  if (!cfg.refresh_token)
252
258
  throw new Error("No refresh token");
253
- const tokens = await refreshTokens(cfg.refresh_token);
259
+ if (!cfg.oauth_client_id)
260
+ throw new Error('Missing oauth_client_id; run "ish login" again.');
261
+ const tokens = await refreshTokens(cfg.refresh_token, {
262
+ accessToken: cfg.access_token,
263
+ clientId: cfg.oauth_client_id,
264
+ });
254
265
  cfg.access_token = tokens.accessToken;
255
266
  cfg.refresh_token = tokens.refreshToken;
256
267
  saveConfig(cfg);
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { program, Option } from "commander";
3
3
  import { runTunnel } from "./connect.js";
4
- import { login, getAppUrl, decodeJwtClaims } from "./auth.js";
4
+ import { login, decodeJwtClaims } from "./auth.js";
5
5
  import { loadConfig, saveConfig } from "./config.js";
6
6
  import { upgrade } from "./upgrade.js";
7
7
  import { registerWorkspaceCommands } from "./commands/workspace.js";
@@ -83,11 +83,11 @@ program
83
83
  .description("Authenticate with Ish via your browser")
84
84
  .action(async (_opts, cmd) => {
85
85
  await runInline(cmd, async (globals) => {
86
- const appUrl = globals.dev ? "http://localhost:3000" : getAppUrl();
87
- const tokens = await login(appUrl);
86
+ const tokens = await login();
88
87
  const config = loadConfig();
89
88
  config.access_token = tokens.accessToken;
90
89
  config.refresh_token = tokens.refreshToken;
90
+ config.oauth_client_id = tokens.clientId;
91
91
  saveConfig(config);
92
92
  output({ message: "Login successful" }, globals.json);
93
93
  });
package/dist/lib/auth.js CHANGED
@@ -57,8 +57,14 @@ export async function resolveToken(tokenArg, apiUrl, tokenFileArg) {
57
57
  let accessToken = config.access_token;
58
58
  // Refresh if expired or close to expiry
59
59
  if (isTokenExpired(accessToken)) {
60
+ if (!config.oauth_client_id) {
61
+ throw new Error('Saved tokens are missing oauth_client_id. Run "ish login" to re-authenticate.');
62
+ }
60
63
  try {
61
- const tokens = await refreshTokens(config.refresh_token, { accessToken });
64
+ const tokens = await refreshTokens(config.refresh_token, {
65
+ accessToken,
66
+ clientId: config.oauth_client_id,
67
+ });
62
68
  accessToken = tokens.accessToken;
63
69
  config.access_token = tokens.accessToken;
64
70
  config.refresh_token = tokens.refreshToken;
@@ -45,6 +45,77 @@ function describeFilters(flags) {
45
45
  parts.push(`--visibility ${flags.visibility}`);
46
46
  return parts.join(" ");
47
47
  }
48
+ /**
49
+ * Best-effort: surface the top-3 populated countries so an empty-audience
50
+ * error doesn't strand the agent without a pivot. Two tiers — tier 1 keeps
51
+ * non-country filters and drops `--country` (used when `--country` was the
52
+ * binding constraint); tier 2 fires when tier 1 returns empty (or when
53
+ * `--country` wasn't set at all) and drops every filter so the workspace's
54
+ * overall country distribution is the fallback. Returns the empty string on
55
+ * any failure — never replaces the primary error with a secondary one.
56
+ */
57
+ async function suggestCountries(client, workspace, flags, opts) {
58
+ const countOf = (items) => {
59
+ const counts = new Map();
60
+ for (const p of items) {
61
+ const c = typeof p.country === "string" ? p.country : null;
62
+ if (c)
63
+ counts.set(c, (counts.get(c) ?? 0) + 1);
64
+ }
65
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3);
66
+ };
67
+ const fetchFacet = async (keepOtherFilters) => {
68
+ try {
69
+ const broader = {
70
+ product_id: workspace,
71
+ type: "ai",
72
+ limit: "500",
73
+ offset: "0",
74
+ };
75
+ if (keepOtherFilters) {
76
+ if (flags.search)
77
+ broader.search = flags.search;
78
+ if (flags.gender && flags.gender.length > 0)
79
+ broader.gender = flags.gender;
80
+ if (flags.minAge)
81
+ broader.min_age = flags.minAge;
82
+ if (flags.maxAge)
83
+ broader.max_age = flags.maxAge;
84
+ if (flags.visibility)
85
+ broader.visibility = flags.visibility;
86
+ }
87
+ const data = await client.get("/tester-profiles", broader);
88
+ const items = Array.isArray(data)
89
+ ? data
90
+ : Array.isArray(data?.items)
91
+ ? data.items
92
+ : [];
93
+ const pool = opts.requireSimulatable ? items.filter(isSimulatable) : items;
94
+ return countOf(pool);
95
+ }
96
+ catch {
97
+ return [];
98
+ }
99
+ };
100
+ const countrySet = !!(flags.country && flags.country.length > 0);
101
+ // Tier 1: when --country is set, drop just country and keep other filters.
102
+ // When --country isn't set, skip straight to the unfiltered fallback —
103
+ // tier 1 with `keepOtherFilters=false` would re-issue the same query that
104
+ // already returned 0.
105
+ let top = [];
106
+ let label = "Populated countries";
107
+ if (countrySet) {
108
+ top = await fetchFacet(true);
109
+ label = "Populated countries with these other filters";
110
+ }
111
+ if (top.length === 0) {
112
+ top = await fetchFacet(false);
113
+ label = "Populated countries";
114
+ }
115
+ if (top.length === 0)
116
+ return "";
117
+ return ` ${label}: ${top.map(([c, n]) => `${c} (${n})`).join(", ")}.`;
118
+ }
48
119
  function hasFilterFlag(flags) {
49
120
  return Boolean(flags.search
50
121
  || (flags.gender && flags.gender.length > 0)
@@ -129,56 +200,18 @@ export async function resolveAudienceProfileIds(client, workspace, flags, opts =
129
200
  if (opts.excludeProfileIds && opts.excludeProfileIds.size > 0 && !filterDesc) {
130
201
  throw new Error("All matching profiles are already in this audience.");
131
202
  }
132
- // When --country was the binding constraint, query the broader pool (drop
133
- // country filter, keep the rest) and surface the top populated countries
134
- // so the agent doesn't have to round-trip through `ish profile list` to
135
- // find one that matches. Pure best-effort any failure falls back to the
136
- // original error.
137
- let suggestion = "";
138
- if (flags.country && flags.country.length > 0) {
139
- try {
140
- const broader = {
141
- product_id: workspace,
142
- type: "ai",
143
- limit: "500",
144
- offset: "0",
145
- };
146
- if (flags.search)
147
- broader.search = flags.search;
148
- if (flags.gender && flags.gender.length > 0)
149
- broader.gender = flags.gender;
150
- if (flags.minAge)
151
- broader.min_age = flags.minAge;
152
- if (flags.maxAge)
153
- broader.max_age = flags.maxAge;
154
- if (flags.visibility)
155
- broader.visibility = flags.visibility;
156
- const broaderData = await client.get("/tester-profiles", broader);
157
- const broaderItems = Array.isArray(broaderData)
158
- ? broaderData
159
- : Array.isArray(broaderData?.items)
160
- ? broaderData.items
161
- : [];
162
- const broaderPool = opts.requireSimulatable
163
- ? broaderItems.filter(isSimulatable)
164
- : broaderItems;
165
- const counts = new Map();
166
- for (const p of broaderPool) {
167
- const c = typeof p.country === "string" ? p.country : null;
168
- if (c)
169
- counts.set(c, (counts.get(c) ?? 0) + 1);
170
- }
171
- const top = [...counts.entries()]
172
- .sort((a, b) => b[1] - a[1])
173
- .slice(0, 3);
174
- if (top.length > 0) {
175
- suggestion = ` Populated countries with these other filters: ${top.map(([c, n]) => `${c} (${n})`).join(", ")}.`;
176
- }
177
- }
178
- catch {
179
- // Swallow — never replace the user's error with a secondary failure.
180
- }
181
- }
203
+ // Country suggestion: surface the top populated countries so the agent
204
+ // doesn't have to round-trip through `ish profile list` to find one that
205
+ // matches. Two tiers tier 1 drops `--country` only and retains other
206
+ // filters when `--country` was set (so the hint reflects "of countries
207
+ // that match your other filters, here are the populated ones"). Tier 2
208
+ // fires when tier 1 returns nothing *and* tier 1 was scoped, or when
209
+ // `--country` wasn't the constraint to begin with: drop every filter and
210
+ // surface the workspace's overall country distribution. Pure best-effort
211
+ // any failure falls back to the original error.
212
+ const suggestion = await suggestCountries(client, workspace, flags, {
213
+ requireSimulatable: !!opts.requireSimulatable,
214
+ });
182
215
  if (filterDesc) {
183
216
  throw new Error(`No ${sim}tester profiles in workspace ${workspace} match: ${filterDesc}.${suggestion} Broaden your filters or run \`ish profile list\` to inspect the pool.`);
184
217
  }
package/dist/lib/docs.js CHANGED
@@ -18,7 +18,7 @@ Workspace (= product)
18
18
  ├── Tester Profiles ────── reusable audience personas (alias: tp-…)
19
19
  │ └── Sources ──────── transcripts/audio/images that seed generation
20
20
  ├── Study ──────────────── persistent research artifact (alias: s-…)
21
- │ ├── modality ──────── interactive | text | video | audio | image | document
21
+ │ ├── modality ──────── interactive | text | video | audio | image | document | chat
22
22
  │ ├── assignments ───── tasks the tester does
23
23
  │ ├── questionnaire ─── questions the tester answers
24
24
  │ └── Iterations ────── one configured run (URL or content) (alias: i-…)
@@ -108,8 +108,9 @@ ish workspace site-access status
108
108
  const CONCEPT_STUDY = `# concept: study
109
109
 
110
110
  A **study** is the persistent research artifact. It defines:
111
- - \`modality\`: \`interactive\` (the tester drives a real browser) or one of
112
- \`text | video | audio | image | document\` (media reaction studies).
111
+ - \`modality\`: \`interactive\` (the tester drives a real browser), one of
112
+ \`text | video | audio | image | document\` (media reaction studies),
113
+ or \`chat\` (multi-turn probe against an external chatbot endpoint).
113
114
  - \`content_type\` (media studies only): \`email | social_post | ad | …\` —
114
115
  controls the framing the tester is given.
115
116
  - \`assignments\`: the tasks the tester performs. See \`concepts/assignment\`.
@@ -200,9 +201,9 @@ pick was wrong.
200
201
  const CONCEPT_ITERATION = `# concept: iteration
201
202
 
202
203
  An **iteration** is one configured run of a study. It carries the
203
- volatile bits — the URL (interactive) or the media (video/text/etc.)
204
- while the study carries the persistent shape (assignments, questionnaire,
205
- modality).
204
+ volatile bits — the URL (interactive), the media (video/text/etc.), or
205
+ the chatbot endpoint (chat) — while the study carries the persistent
206
+ shape (assignments, questionnaire, modality).
206
207
 
207
208
  - Alias prefix: \`i-\`
208
209
  - A study has 1..N iterations. \`ish study run\` defaults to the latest.
@@ -224,9 +225,19 @@ ish iteration create --study s-b2c --url https://example.com
224
225
  # Interactive on mobile screen format:
225
226
  ish iteration create --url https://example.com --screen-format mobile_portrait
226
227
 
228
+ # Figma interactive (file_key + start_node_id required):
229
+ ish iteration create --platform figma --url https://figma.com/proto \\
230
+ --screen-format mobile_portrait --file-key abc123 --start-node-id 0:1 \\
231
+ --flow-name "Onboarding A"
232
+
227
233
  # Text/email content from a file:
228
234
  ish iteration create --content-text @./email.html --title "Newsletter"
229
235
 
236
+ # Email iteration with sender + featured hero image:
237
+ ish iteration create --content-text @./email.txt --content-html @./email.html \\
238
+ --sender-name "Marketing" --sender-email "marketing@example.com" \\
239
+ --featured-image-url https://cdn.example.com/hero.png
240
+
230
241
  # Video (URL or local file):
231
242
  ish iteration create --content-url ./video.mp4
232
243
 
@@ -236,11 +247,113 @@ ish iteration create --image-urls "./a.png,./b.png"
236
247
  # Document (PDF):
237
248
  ish iteration create --content-url ./report.pdf
238
249
 
250
+ # Chat — probe a saved chatbot endpoint:
251
+ ish iteration create --chat-endpoint-id ce-... --max-turns 10 --early-termination
252
+
239
253
  # Inspect:
240
254
  ish iteration list --study s-b2c
241
255
  ish iteration get i-d4e
242
256
  \`\`\`
243
257
 
258
+ ## Segments and segment labels
259
+
260
+ For media iterations (video, audio, text, image, document), reactions
261
+ can be collected per **segment** instead of over the whole asset. A
262
+ segment is a contiguous slice of the iteration's content — a 30-second
263
+ window of a video, a paragraph range of an email, a section of a PDF.
264
+ Each segment can carry a human-readable **label** ("Intro", "Pricing
265
+ section", "Call to action") that surfaces in the tester UI and in
266
+ results.
267
+
268
+ Segments live inside the iteration's \`segmentation\` field — there is
269
+ no separate segments resource. Three discriminated shapes:
270
+
271
+ - **time_based** (video, audio): boundaries in seconds. Segment 0 runs
272
+ from \`intervals_seconds[0]\` to \`intervals_seconds[1]\`, etc.
273
+ Optional \`labels[]\` names each segment.
274
+
275
+ \`\`\`json
276
+ {
277
+ "type": "time_based",
278
+ "intervals_seconds": [0, 30, 60, 90],
279
+ "labels": ["Hook", "Feature 1", "Feature 2", "CTA"]
280
+ }
281
+ \`\`\`
282
+
283
+ - **section_based** (text, document, image copy): explicit list of
284
+ named sections, either marker-bounded or paragraph-bounded.
285
+
286
+ \`\`\`json
287
+ {
288
+ "type": "section_based",
289
+ "sections": [
290
+ { "name": "intro", "label": "Intro", "paragraph_start": 0, "paragraph_end": 1 },
291
+ { "name": "body", "label": "Body", "paragraph_start": 1, "paragraph_end": 4 },
292
+ { "name": "cta", "label": "Call to action", "paragraph_start": 4, "paragraph_end": 5 }
293
+ ]
294
+ }
295
+ \`\`\`
296
+
297
+ - **page_based** (document): pages are auto-derived from the document.
298
+ No additional fields.
299
+
300
+ Pass via \`--segmentation-json '<json>'\` on \`iteration create\`.
301
+
302
+ ### Default segmentation for text/image iterations
303
+
304
+ For text- and image-modality iterations created without
305
+ \`--segmentation-json\`, the worker synthesises a single whole-content
306
+ section so a minimal \`ish iteration create --content-text "..."\` runs
307
+ end-to-end. Author your own segmentation when you want section-level
308
+ reactions; otherwise the default just works.
309
+
310
+ ### content_config — early termination + selected segments
311
+
312
+ A sibling of \`segmentation\` that controls how the tester progresses
313
+ through segments:
314
+
315
+ - \`early_termination: true\` — stop the session once every selected
316
+ segment has been seen.
317
+ - \`selected_segment_indices: [0, 2]\` — only show these segment
318
+ indices; \`null\` (default) means all segments are active.
319
+
320
+ Pass via \`--content-config-json '<json>'\`.
321
+
322
+ ## HTML content (text + media captions)
323
+
324
+ - **Text modality**: pair plain \`--content-text\` with rich
325
+ \`--content-html\` to render emails / articles with formatting. The
326
+ plain text is what testers reason over; the HTML is what they see.
327
+ - **Media captions** (video, audio, image): \`--copy-text\` and
328
+ \`--copy-html\` attach a caption to the media — the social-post
329
+ pattern. Add \`--social-platform\` (instagram/tiktok/facebook/linkedin/x)
330
+ for platform-specific framing, and \`--copy-position before|after\`
331
+ for ordering relative to the media.
332
+
333
+ Captions can carry their own segmentation when you want
334
+ paragraph-by-paragraph reactions to a long caption. Use the
335
+ \`--details-json\` escape hatch to pass a nested
336
+ \`copy_content.segmentation\`.
337
+
338
+ ## Chat modality
339
+
340
+ Chat iterations probe an external chatbot endpoint by having a tester
341
+ hold a multi-turn conversation against it. Two ways to wire the
342
+ endpoint:
343
+
344
+ \`\`\`
345
+ # Reference a saved endpoint row (recommended — reproducible):
346
+ ish iteration create --chat-endpoint-id ce-...
347
+
348
+ # Inline endpoint config (one-off):
349
+ ish iteration create --chat-endpoint-json '{"url":"https://...","headers":{...}}'
350
+ \`\`\`
351
+
352
+ Tunables:
353
+ - \`--max-turns N\` — cap the conversation length (default 12, max 50).
354
+ - \`--early-termination\` — let the worker end the session early when
355
+ the tester signals the conversation is over.
356
+
244
357
  ## No more auto-empty iteration A
245
358
 
246
359
  \`ish study create\` and \`ish study generate\` **do not auto-create
@@ -261,16 +374,6 @@ then retry.
261
374
 
262
375
  Treat this as actionable, not transient — re-running won't change anything.
263
376
 
264
- ## Default segmentation for text/image iterations
265
-
266
- For text-modality iterations created with just \`--content-text\` (and
267
- similarly \`--image-urls\` for image), the worker now synthesises a
268
- single whole-content section if no \`segmentation\` was supplied. This
269
- means a minimal \`ish iteration create --study s-XYZ --content-text
270
- "..."\` actually runs end-to-end without you needing to author a
271
- SegmentationConfig manually. Author your own segmentation when you
272
- want section-level reactions; otherwise the default just works.
273
-
274
377
  ## Related
275
378
 
276
379
  - \`concepts/study\` — the parent artifact.
@@ -1375,7 +1478,7 @@ const PAGES = [
1375
1478
  {
1376
1479
  slug: "concepts/iteration",
1377
1480
  title: "concept: iteration",
1378
- description: "One configured run of a study (URL or media).",
1481
+ description: "One configured run of a study (URL, media, or chat). Covers segments, segment labels, and HTML content.",
1379
1482
  body: CONCEPT_ITERATION,
1380
1483
  },
1381
1484
  {
@@ -77,7 +77,7 @@ Workspace (= product)
77
77
  ├── Tester Profiles (tp-…) reusable audience personas
78
78
  │ └── Sources (tps-…) transcripts/audio/images that seed generation
79
79
  ├── Study (s-…) persistent research artifact
80
- │ ├── modality interactive | text | video | audio | image | document
80
+ │ ├── modality interactive | text | video | audio | image | document | chat
81
81
  │ ├── assignments tasks the tester does
82
82
  │ ├── questionnaire questions the tester answers
83
83
  │ └── Iterations (i-…) one configured run; carries the URL or media
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ishlabs/cli",
3
- "version": "0.8.5",
4
- "description": "The command-line interface for Ish",
3
+ "version": "0.9.0",
4
+ "description": "The command-line interface for ish",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "ish": "./dist/index.js"
@@ -41,4 +41,4 @@
41
41
  "@types/node": "^22.0.0",
42
42
  "typescript": "^5.7.0"
43
43
  }
44
- }
44
+ }