@ishlabs/cli 0.8.5 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +55 -6
  2. package/dist/auth.d.ts +23 -4
  3. package/dist/auth.js +165 -39
  4. package/dist/commands/ask.d.ts +12 -0
  5. package/dist/commands/ask.js +127 -2
  6. package/dist/commands/chat.d.ts +17 -0
  7. package/dist/commands/chat.js +589 -0
  8. package/dist/commands/iteration.js +232 -13
  9. package/dist/commands/secret.d.ts +20 -0
  10. package/dist/commands/secret.js +246 -0
  11. package/dist/commands/source.js +24 -2
  12. package/dist/commands/study-run.d.ts +38 -0
  13. package/dist/commands/study-run.js +199 -80
  14. package/dist/commands/study-tester.js +17 -2
  15. package/dist/commands/study.js +311 -39
  16. package/dist/commands/workspace.js +81 -0
  17. package/dist/config.d.ts +7 -0
  18. package/dist/connect.d.ts +3 -0
  19. package/dist/connect.js +359 -24
  20. package/dist/index.js +67 -9
  21. package/dist/lib/alias-hydrate.d.ts +42 -0
  22. package/dist/lib/alias-hydrate.js +175 -0
  23. package/dist/lib/alias-store.d.ts +1 -0
  24. package/dist/lib/alias-store.js +28 -1
  25. package/dist/lib/auth.js +11 -3
  26. package/dist/lib/chat-endpoint-formatters.d.ts +39 -0
  27. package/dist/lib/chat-endpoint-formatters.js +104 -0
  28. package/dist/lib/command-helpers.d.ts +18 -0
  29. package/dist/lib/command-helpers.js +188 -53
  30. package/dist/lib/docs.js +662 -34
  31. package/dist/lib/modality.d.ts +42 -0
  32. package/dist/lib/modality.js +192 -0
  33. package/dist/lib/output.d.ts +41 -0
  34. package/dist/lib/output.js +453 -19
  35. package/dist/lib/paths.d.ts +1 -0
  36. package/dist/lib/paths.js +3 -0
  37. package/dist/lib/skill-content.js +183 -13
  38. package/dist/lib/types.d.ts +15 -0
  39. package/package.json +3 -3
package/README.md CHANGED
@@ -88,7 +88,7 @@ Workspace (= product, top-level container)
88
88
  ```bash
89
89
  ish login # browser auth
90
90
  ish logout
91
- ish connect <port> # Cloudflare tunnel exposing localhost
91
+ ish connect <port> # Cloudflare tunnel exposing localhost (--detach, ish disconnect, ish connect status)
92
92
  ish upgrade # self-update (single-binary installs only)
93
93
  ish upgrade --release 0.8.1 # pin a specific release
94
94
  ```
@@ -98,15 +98,21 @@ ish upgrade --release 0.8.1 # pin a specific release
98
98
  ### Workspaces, studies, iterations, profiles, configs (CRUD groups)
99
99
 
100
100
  ```bash
101
- ish workspace list | create | get | update | delete | use
101
+ ish workspace list | create | get | update | delete | use | info
102
102
  ish workspace site-access status | basic-auth | cookie | login | affirm-public | clear
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
+ ish chat endpoint list | create | get | update | delete | use | init | test
109
+ ish secret list | set | delete
108
110
  ```
109
111
 
112
+ `ish workspace info` reports `studies_used / studies_max / testers_used / testers_max / tier` so an agent can branch on plan caps before a destructive call returns `error_code: usage_limit_reached`.
113
+
114
+ `ish chat endpoint` configures HTTP-bot endpoints for chat-modality studies (auto-detect from a curl example, smoke-test, edit). `ish secret` is the per-workspace KV store referenced from chatbot endpoint headers via `{{secret:KEY}}` placeholders. Run `ish docs get-page guides/chat` for the end-to-end recipe.
115
+
110
116
  Testers live as a nested group on a study (low-level — usually created via `study run`):
111
117
 
112
118
  ```bash
@@ -347,16 +353,59 @@ ish profile generate --source tps-3a4 --propose-count
347
353
  ish profile generate --source tps-3a4 --count 4
348
354
  ```
349
355
 
356
+ ### Chat-modality studies — `ish chat`
357
+
358
+ Configure a customer chatbot endpoint and run chat-modality studies against it.
359
+
360
+ ```bash
361
+ # Author from a curl example (or hand-write the config)
362
+ ish chat endpoint init --from-curl ./bot.curl --name my-bot
363
+ ish chat endpoint create --endpoint-config ./bot-config.json --name "my-bot"
364
+
365
+ # CRUD on saved endpoints (every dialog edit reduces to one of these)
366
+ ish chat endpoint list
367
+ ish chat endpoint get ep-abc --verbose # round-trippable {id, name, isTunnelBacked, config}
368
+ ish chat endpoint update ep-abc --name "Production support bot"
369
+ ish chat endpoint update ep-abc --url https://api.example.com/v2/chat --mode stateless
370
+ ish chat endpoint get ep-abc --verbose | jq '.config.outgoing.headers["X-API-Key"] = "{{secret:KEY}}"' \
371
+ | ish chat endpoint update ep-abc --endpoint-config -
372
+ ish chat endpoint delete ep-abc
373
+ ish chat endpoint use ep-abc # set as the active chat endpoint
374
+
375
+ # Smoke test the connection (single turn; tunnel pre-flight when applicable)
376
+ ish chat endpoint test ep-abc -m "Hello"
377
+ ish chat endpoint test ep-abc -m "Tell me more" --conversation-id "$CID" # stateful threading
378
+
379
+ # Run a chat-modality study using the saved endpoint (existing study verbs).
380
+ # Audience size lives on study run via --sample / --all / --profile.
381
+ ish study create --modality chat --endpoint ep-abc --name "Sign-up Q1" --assignment "Sign up:Try to sign up"
382
+ ish study run --study stu-xyz --sample 5 --wait
383
+ ish study results stu-xyz --json | jq '.testers'
384
+ ```
385
+
386
+ Local bots (`localhost` / `127.0.0.1` / `0.0.0.0`) auto-flag `is_tunnel_backed=true` on `init`; pair with `ish connect <port>` in another shell. Override with `--tunnel-backed` / `--no-tunnel-backed`.
387
+
388
+ `init` returns `confidence` (`high` / `medium` / `low`) and a `missingSignals: [...]` array naming any inputs the inference couldn't observe (e.g. `["response_shape", "message_path"]` when no response sample is provided). When confidence is `low`, verify with `chat endpoint test` before running a study.
389
+
390
+ Failures from `chat endpoint test` carry a structured `error_kind`: `TunnelInactive` (run `ish connect <port>` first), `BotUnreachable` (URL/port wrong or bot down), `BotResponseError` (non-2xx with a status code), `BotEnvelopeError` (200 OK with the bot's own error in the body — see `raw_excerpt`), `BotInvalidResponseError` (response doesn't match the parsing schema), `BotAuthError`, `BotTimeoutError`, `BotRetryExhaustedError`.
391
+
392
+ Full guide: `ish docs get-page guides/chat`.
393
+
350
394
  ### Expose localhost
351
395
 
352
- For interactive studies that need to reach a service running on your machine:
396
+ For interactive studies (and chat endpoints with `is_tunnel_backed=true`) that need to reach a service running on your machine:
353
397
 
354
398
  ```bash
355
- ish connect 3000 # Cloudflare tunnel to localhost:3000
399
+ ish connect 3000 # foreground Cloudflare tunnel to :3000
400
+ ish connect 3000 --detach --json # fork after first heartbeat; prints {pid, tunnel_url, registered}
401
+ ish connect status --json # {active, pid, tunnel_url, registered_at} or {active:false}
402
+ ish disconnect --json # graceful shutdown of an active tunnel
356
403
  ISH_TOKEN=YOUR_TOKEN ish connect 8080
357
404
  ```
358
405
 
359
- `connect` is a long-running command — keep it open while testers run. The Cloudflare tunnel URL prints prominently after "Connected"; pass `--json` for one-line machine-readable output (`{"status":"connected","tunnel_url":"...","local_port":3000,"registered":true}`) suitable for scripts.
406
+ Foreground `connect` is long-running — keep it open while testers run. The tunnel URL prints prominently after "Connected"; pass `--json` for one-line machine-readable output (`{"status":"connected","tunnel_url":"...","local_port":3000,"registered":true}`). The `--detach` form forks after the first successful heartbeat and returns immediately, tracking PID + URL in `~/.ish/connect.lock` so `connect status` and `disconnect` find it later.
407
+
408
+ Destructive verbs in `--json` mode (e.g. `chat endpoint delete`, `study delete`) require an explicit `--yes`; the rejection envelope carries `error_kind: "ConfirmationRequired"` and an `example` field with the same command + `--yes` appended, so an agent can recover without re-reading the help text.
360
409
 
361
410
  ## Global flags
362
411
 
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) {
@@ -2,4 +2,16 @@
2
2
  * ish ask — Create and run asks (multi-round surveys with variants).
3
3
  */
4
4
  import type { Command } from "commander";
5
+ import type { AudienceSubset } from "../lib/types.js";
6
+ /**
7
+ * Parse the `--subset-round <n> --subset-variant <variant_id>` pair into
8
+ * an `AudienceSubset` payload (Pattern B). Both flags must be passed
9
+ * together or neither — half a subset is a misconfiguration the agent
10
+ * should fix before dispatch, not a silent fallthrough to the full
11
+ * audience.
12
+ *
13
+ * Returns `undefined` when neither flag is set; throws when only one is
14
+ * set or when `--subset-round` isn't a positive integer.
15
+ */
16
+ export declare function parseAudienceSubset(subsetRound: string | undefined, subsetVariant: string | undefined): AudienceSubset | undefined;
5
17
  export declare function registerAskCommands(program: Command): void;
@@ -7,6 +7,7 @@ import { loadConfig, saveConfig } from "../config.js";
7
7
  import { formatAskList, formatAskDetail, formatRoundDetail, formatAskResults, output, } from "../lib/output.js";
8
8
  import { parseVariantInputs, uploadAndBuildVariants, } from "../lib/ask-variants.js";
9
9
  import { loadQuestionsManifest } from "../lib/ask-questions.js";
10
+ import { ApiError } from "../lib/api-client.js";
10
11
  const POLL_INTERVAL_MS = 5_000;
11
12
  // ---------------------------------------------------------------------------
12
13
  // Helpers
@@ -108,6 +109,32 @@ async function buildRoundInput(client, productId, opts, quiet) {
108
109
  round.questions = questions;
109
110
  return round;
110
111
  }
112
+ /**
113
+ * Parse the `--subset-round <n> --subset-variant <variant_id>` pair into
114
+ * an `AudienceSubset` payload (Pattern B). Both flags must be passed
115
+ * together or neither — half a subset is a misconfiguration the agent
116
+ * should fix before dispatch, not a silent fallthrough to the full
117
+ * audience.
118
+ *
119
+ * Returns `undefined` when neither flag is set; throws when only one is
120
+ * set or when `--subset-round` isn't a positive integer.
121
+ */
122
+ export function parseAudienceSubset(subsetRound, subsetVariant) {
123
+ if (subsetRound === undefined && subsetVariant === undefined)
124
+ return undefined;
125
+ if (subsetRound === undefined || subsetVariant === undefined) {
126
+ throw new Error("--subset-round and --subset-variant must be passed together (or both omitted).");
127
+ }
128
+ const round = Number.parseInt(subsetRound, 10);
129
+ if (!Number.isFinite(round) || round < 1 || !/^\d+$/.test(subsetRound)) {
130
+ throw new Error(`--subset-round must be a positive integer (got "${subsetRound}").`);
131
+ }
132
+ const trimmedVariant = subsetVariant.trim();
133
+ if (trimmedVariant.length === 0) {
134
+ throw new Error("--subset-variant must be a variant UUID (got empty string).");
135
+ }
136
+ return { round, picked_variant_id: trimmedVariant };
137
+ }
111
138
  // ---------------------------------------------------------------------------
112
139
  // Command registration
113
140
  // ---------------------------------------------------------------------------
@@ -145,6 +172,8 @@ Concept pages: ish docs get-page concepts/ask
145
172
  allFlagName: "--all-simulatable",
146
173
  allFlagDescription: "Use every simulatable AI profile matching the filters (with --new only)",
147
174
  })
175
+ .option("--subset-round <n>", "Drill-in subset (Pattern B) — append-round only. 1-indexed prior round to filter against. Pair with --subset-variant.")
176
+ .option("--subset-variant <variant_id>", "Drill-in subset (Pattern B) — append-round only. Variant id (UUID) on the prior round whose pickers should inherit. Read from `aggregates.pick_buckets` or `variants[*].id` on the prior round's `ask results --json`.")
148
177
  .option("--wait", "Wait until the round completes (or errors)")
149
178
  .option("--timeout <s>", "Wait timeout in seconds (default 300)")
150
179
  .addHelpText("after", `
@@ -169,6 +198,9 @@ Examples:
169
198
  if (pickedId) {
170
199
  throw new Error("Cannot pass an ask id together with --new. Drop the id, or drop --new to append a round.");
171
200
  }
201
+ if (opts.subsetRound !== undefined || opts.subsetVariant !== undefined) {
202
+ throw new Error("--subset-round / --subset-variant are only valid when appending to an existing ask. Drop --new or drop the subset flags.");
203
+ }
172
204
  const wid = resolveWorkspace(opts.workspace);
173
205
  const testerIds = await resolveAudienceProfileIds(client, wid, audienceFlags(opts), { requireSimulatable: true, allFlagName: "--all-simulatable" });
174
206
  const round = await buildRoundInput(client, wid, opts, !!globals.quiet);
@@ -180,7 +212,28 @@ Examples:
180
212
  tester_profile_ids: testerIds,
181
213
  first_round: round,
182
214
  };
183
- let data = await client.post(`/products/${wid}/asks`, body, { timeout: 120_000 });
215
+ // M5 / Pattern G: `ask run --new` POSTs to a non-idempotent
216
+ // create endpoint. If the backend errors after the row is
217
+ // committed (a 500 mid-pipeline, a network timeout after the
218
+ // POST landed), an automatic retry would create a duplicate
219
+ // ask. Override `retryable` to false on any failure here so
220
+ // agents don't auto-retry. The error envelope also reminds
221
+ // the agent to inspect `ish ask list --workspace <id>` before
222
+ // re-running, since the resource may already exist.
223
+ let data;
224
+ try {
225
+ data = await client.post(`/products/${wid}/asks`, body, { timeout: 120_000 });
226
+ }
227
+ catch (err) {
228
+ if (err instanceof ApiError) {
229
+ err.retryable = false;
230
+ const tagged = err;
231
+ tagged.suggestions = [
232
+ `\`ish ask list --workspace ${wid}\` to check whether the ask was created server-side before retrying — \`ask run --new\` is non-idempotent and will duplicate on retry.`,
233
+ ];
234
+ }
235
+ throw err;
236
+ }
184
237
  if (data.id) {
185
238
  const config = loadConfig();
186
239
  config.ask = data.id;
@@ -223,6 +276,9 @@ Examples:
223
276
  }
224
277
  const ask = await client.get(`/asks/${aid}`);
225
278
  const round = await buildRoundInput(client, ask.product_id, opts, !!globals.quiet);
279
+ const subset = parseAudienceSubset(opts.subsetRound, opts.subsetVariant);
280
+ if (subset)
281
+ round.audience_subset = subset;
226
282
  const created = await client.post(`/asks/${aid}/rounds`, round);
227
283
  if (opts.wait) {
228
284
  const timeoutMs = parseWaitTimeout(opts.timeout);
@@ -519,14 +575,32 @@ the model's self-reported confidence in its variant choice. See
519
575
  .option("--wants-pick", "Each tester picks a favourite variant (compatible with --wants-ratings; can be set together).")
520
576
  .option("--wants-ratings", "Each tester rates every variant 1–5 (compatible with --wants-pick; can be set together). If neither is set, testers leave a free-form comment only.")
521
577
  .option("--questions <file.json>", `Questions JSON file: [{"question":"...","type":"text"|"slider"|"likert"|"single-choice"|"multiple-choice"|"number"}]`)
578
+ .option("--subset-round <n>", "Drill-in subset (Pattern B) — 1-indexed prior round to filter against. Pair with --subset-variant. The new round dispatches only to testers who picked --subset-variant on round N.")
579
+ .option("--subset-variant <variant_id>", "Drill-in subset (Pattern B) — variant id (UUID) on the prior round whose pickers should inherit. Pair with --subset-round. Read from `aggregates.pick_buckets` or `variants[*].id` on the prior round.")
522
580
  .option("--wait", "Wait until the new round completes")
523
581
  .option("--timeout <s>", "Wait timeout in seconds (default 300)")
524
- .addHelpText("after", "\nExamples:\n $ ish ask add-round a-6ec --prompt \"And now?\" --variant text:\"Hello\" --variant text:\"Hi\" --wait")
582
+ .addHelpText("after", `
583
+ Examples:
584
+ # Append round 2 to the same audience.
585
+ $ ish ask add-round a-6ec --prompt "And now?" --variant text:"Hello" --variant text:"Hi" --wait
586
+
587
+ # Drill round 2 into the round-1-A-pickers (Pattern B).
588
+ $ ish ask add-round a-6ec \\
589
+ --prompt "What would make you actually click?" \\
590
+ --subset-round 1 --subset-variant 5f3a... \\
591
+ --wait
592
+
593
+ If --subset-round / --subset-variant fails to resolve (round missing, variant
594
+ not on that round, or zero pickers), the backend returns a 422 with
595
+ error_kind: "audience_subset_invalid".`)
525
596
  .action(async (id, opts, cmd) => {
526
597
  await withClient(cmd, async (client, globals) => {
527
598
  const aid = resolveAsk(pickAskRef(id, opts.ask));
528
599
  const ask = await client.get(`/asks/${aid}`);
529
600
  const round = await buildRoundInput(client, ask.product_id, opts, !!globals.quiet);
601
+ const subset = parseAudienceSubset(opts.subsetRound, opts.subsetVariant);
602
+ if (subset)
603
+ round.audience_subset = subset;
530
604
  const created = await client.post(`/asks/${aid}/rounds`, round);
531
605
  if (opts.wait) {
532
606
  const timeoutMs = parseWaitTimeout(opts.timeout);
@@ -607,6 +681,57 @@ text, slider, likert, single-choice, multiple-choice, number.`)
607
681
  }, globals.json);
608
682
  });
609
683
  });
684
+ // ---- retry --------------------------------------------------------------
685
+ ask
686
+ .command("retry")
687
+ .description("Re-dispatch only the errored responses on a round (idempotent: zero-errored is a no-op).")
688
+ .argument("[id]", "Ask alias or UUID (defaults to active ask)")
689
+ .option("--ask <id>", "Ask ID; alternative to positional argument")
690
+ .requiredOption("--round <n|round-id>", "Round number (1-indexed) or round id/alias")
691
+ .option("--wait", "Wait until the retried round completes (or errors)")
692
+ .option("--timeout <s>", "Wait timeout in seconds (default 300)")
693
+ .addHelpText("after", `
694
+ Examples:
695
+ # Retry the errored 4 of 5 testers on round 1.
696
+ $ ish ask retry a-d3e --round 1
697
+
698
+ # Retry and wait for the round to settle.
699
+ $ ish ask retry a-d3e --round 1 --wait
700
+
701
+ Notes:
702
+ - COMPLETED responses are left untouched. Only ERRORED rows are reset to PENDING and re-run from scratch.
703
+ - The round flips back to RUNNING for the duration of the retry; the prior round summary is dropped and rebuilt once the retry settles.
704
+ - On a round with no errored responses, the verb is a no-op and returns the round unchanged.`)
705
+ .action(async (id, opts, cmd) => {
706
+ await withClient(cmd, async (client, globals) => {
707
+ const aid = resolveAsk(pickAskRef(id, opts.ask));
708
+ const ask = await client.get(`/asks/${aid}`);
709
+ const round = getRoundByIndexOrId(ask, opts.round);
710
+ const updated = await client.post(`/asks/${aid}/rounds/${round.id}/retry`, {});
711
+ if (opts.wait) {
712
+ const timeoutMs = parseWaitTimeout(opts.timeout);
713
+ await pollUntilRoundDone(client, aid, updated.order_index, timeoutMs, !!globals.quiet);
714
+ const refreshed = await client.get(`/asks/${aid}`);
715
+ const target = refreshed.rounds.find((r) => r.id === updated.id);
716
+ if (target) {
717
+ formatRoundDetail(target, globals.json);
718
+ return;
719
+ }
720
+ }
721
+ if (!globals.json || globals.verbose) {
722
+ formatRoundDetail(updated, globals.json);
723
+ return;
724
+ }
725
+ output({
726
+ id: aid,
727
+ alias: tagAlias(ALIAS_PREFIX.ask, aid),
728
+ round: {
729
+ round_number: updated.order_index + 1,
730
+ status: updated.status,
731
+ },
732
+ }, globals.json);
733
+ });
734
+ });
610
735
  // ---- add-testers --------------------------------------------------------
611
736
  const askAddTesters = ask
612
737
  .command("add-testers")
@@ -0,0 +1,17 @@
1
+ /**
2
+ * ish chat — Configure chatbot endpoints and run chat-modality studies.
3
+ *
4
+ * The CLI's primary user is autonomous AI agents. Every verb here is
5
+ * scriptable: deterministic JSON outputs, no interactive prompts, no
6
+ * REPLs. Endpoint editing matches the editor dialog's semantics
7
+ * (full-replace via PUT) plus client-side field-shorthand flags for
8
+ * common one-line edits.
9
+ *
10
+ * Chat-modality studies are reached via the existing `ish study create
11
+ * --modality chat --endpoint <id>` extension; this file does NOT
12
+ * fork a parallel `chat run` verb tree.
13
+ */
14
+ import type { Command } from "commander";
15
+ import { envelopeFromRow } from "../lib/chat-endpoint-formatters.js";
16
+ export declare function registerChatCommand(program: Command): void;
17
+ export { envelopeFromRow };