@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 +2 -2
- package/dist/auth.d.ts +38 -4
- package/dist/auth.js +205 -39
- package/dist/commands/ask.js +28 -2
- package/dist/commands/iteration.js +105 -6
- package/dist/commands/profile.js +25 -12
- package/dist/commands/source.js +24 -2
- package/dist/commands/study.js +14 -6
- package/dist/config.d.ts +4 -0
- package/dist/connect.js +100 -14
- package/dist/index.js +6 -3
- package/dist/lib/auth.js +7 -1
- package/dist/lib/command-helpers.d.ts +37 -0
- package/dist/lib/command-helpers.js +199 -7
- package/dist/lib/docs.js +316 -39
- package/dist/lib/output.d.ts +6 -0
- package/dist/lib/output.js +133 -2
- package/dist/lib/skill-content.js +120 -6
- package/package.json +3 -3
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 (
|
|
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
|
|
3
|
-
*
|
|
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(
|
|
35
|
+
export declare function login(): Promise<{
|
|
21
36
|
accessToken: string;
|
|
22
37
|
refreshToken: string;
|
|
38
|
+
clientId: string;
|
|
23
39
|
}>;
|
|
24
|
-
|
|
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
|
|
3
|
-
*
|
|
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
|
|
8
|
-
const
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
|
302
|
+
const project = options.supabaseUrl && options.anonKey
|
|
144
303
|
? { url: options.supabaseUrl, anonKey: options.anonKey }
|
|
145
|
-
: resolveSupabaseProjectFromToken(options
|
|
146
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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();
|
package/dist/commands/ask.js
CHANGED
|
@@ -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",
|
|
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)
|
|
95
|
-
media content (text/video/image/document)
|
|
96
|
-
defaults to the latest. Local file paths in
|
|
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
|
-
|
|
225
|
-
|
|
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.");
|