@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 +1 -1
- package/dist/auth.d.ts +23 -4
- package/dist/auth.js +165 -39
- package/dist/commands/iteration.js +105 -6
- package/dist/commands/source.js +24 -2
- package/dist/commands/study.js +2 -2
- package/dist/config.d.ts +4 -0
- package/dist/connect.js +13 -2
- package/dist/index.js +3 -3
- package/dist/lib/auth.js +7 -1
- package/dist/lib/command-helpers.js +83 -50
- package/dist/lib/docs.js +120 -17
- package/dist/lib/skill-content.js +1 -1
- package/package.json +3 -3
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
|
|
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,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(
|
|
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
|
|
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,35 +124,141 @@ 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
|
-
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
|
-
|
|
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
|
-
|
|
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.");
|
|
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
|
|
302
|
+
const project = options.supabaseUrl && options.anonKey
|
|
181
303
|
? { url: options.supabaseUrl, anonKey: options.anonKey }
|
|
182
|
-
: resolveSupabaseProjectFromToken(options
|
|
183
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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)
|
|
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.");
|
package/dist/commands/source.js
CHANGED
|
@@ -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
|
}
|
package/dist/commands/study.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
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, {
|
|
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
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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)
|
|
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)
|
|
204
|
-
while the study carries the persistent
|
|
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
|
|
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.
|
|
4
|
-
"description": "The command-line interface for
|
|
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
|
+
}
|