@ishlabs/cli 0.8.3 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,6 +41,12 @@ The CLI resolves your auth token in this order:
41
41
  2. `ISH_TOKEN` env var
42
42
  3. Saved token from `ish login`
43
43
 
44
+ ## Testing
45
+
46
+ Test plan is available at `/Users/felixweiland/ish-cli-test-plan.md`.
47
+
48
+ ---
49
+
44
50
  ## Concepts
45
51
 
46
52
  Two top-level research primitives, both consume reusable tester profiles:
@@ -49,7 +55,7 @@ Two top-level research primitives, both consume reusable tester profiles:
49
55
  Workspace (= product, top-level container)
50
56
 
51
57
  ├── Tester Profiles ← reusable audience personas
52
- │ └── Audience Sources (transcripts / audio / images that seed generation)
58
+ │ └── Audience Sources (images/PDFs/audio/video/text transcripts that seed generation)
53
59
 
54
60
  ├── Study ─────────────── "structured research artifact"
55
61
  │ ├── modality (interactive | text | video | audio | image | document)
package/dist/auth.d.ts CHANGED
@@ -15,11 +15,27 @@ export declare function resolveSupabaseProjectFromToken(accessToken: string | un
15
15
  anonKey: string;
16
16
  };
17
17
  export declare function decodeJwtExp(token: string): number;
18
+ export declare function decodeJwtClaims(token: string): Record<string, unknown> | undefined;
18
19
  export declare function isTokenExpired(token: string, bufferSeconds?: number): boolean;
19
20
  export declare function login(appUrl?: string): Promise<{
20
21
  accessToken: string;
21
22
  refreshToken: string;
22
23
  }>;
24
+ /**
25
+ * Thrown by `refreshTokens` when the refresh attempt failed permanently and
26
+ * the local config can't recover (e.g. Supabase `refresh_token_not_found`,
27
+ * `invalid_grant`, or any 400-class response). Callers should treat this as
28
+ * "user must run `ish login` again" — retrying won't help.
29
+ *
30
+ * Transient failures (network errors, 5xx, timeouts) are NOT this error and
31
+ * may be worth retrying.
32
+ */
33
+ export declare class AuthRefreshPermanentError extends Error {
34
+ readonly httpStatus: number;
35
+ readonly errorCode: string | undefined;
36
+ readonly body: string;
37
+ constructor(httpStatus: number, body: string, errorCode: string | undefined);
38
+ }
23
39
  export declare function refreshTokens(refreshToken: string, options?: {
24
40
  /** The (possibly expired) access token. Used to pick the correct Supabase project. */
25
41
  accessToken?: string;
package/dist/auth.js CHANGED
@@ -93,6 +93,15 @@ export function decodeJwtExp(token) {
93
93
  return 0;
94
94
  }
95
95
  }
96
+ export function decodeJwtClaims(token) {
97
+ try {
98
+ const payload = token.split(".")[1];
99
+ return JSON.parse(Buffer.from(payload, "base64url").toString());
100
+ }
101
+ catch {
102
+ return undefined;
103
+ }
104
+ }
96
105
  export function isTokenExpired(token, bufferSeconds = 300) {
97
106
  const exp = decodeJwtExp(token);
98
107
  if (!exp)
@@ -104,10 +113,10 @@ export async function login(appUrl) {
104
113
  const url = appUrl ?? getAppUrl();
105
114
  const state = crypto.randomBytes(32).toString("hex");
106
115
  const loginUrl = `${url}/auth/plugin?state=${state}`;
107
- console.log("Opening browser to sign in...");
108
- console.log(`If the browser doesn't open, visit:\n ${loginUrl}\n`);
116
+ console.error("Opening browser to sign in...");
117
+ console.error(`If the browser doesn't open, visit:\n ${loginUrl}\n`);
109
118
  openBrowser(loginUrl);
110
- console.log("Waiting for authentication...");
119
+ console.error("Waiting for authentication...");
111
120
  const deadline = Date.now() + LOGIN_TIMEOUT;
112
121
  while (Date.now() < deadline) {
113
122
  await new Promise((r) => setTimeout(r, POLL_INTERVAL));
@@ -130,6 +139,43 @@ export async function login(appUrl) {
130
139
  throw new Error("Login timed out. Please try again.");
131
140
  }
132
141
  // --- Token refresh ---
142
+ /**
143
+ * Thrown by `refreshTokens` when the refresh attempt failed permanently and
144
+ * the local config can't recover (e.g. Supabase `refresh_token_not_found`,
145
+ * `invalid_grant`, or any 400-class response). Callers should treat this as
146
+ * "user must run `ish login` again" — retrying won't help.
147
+ *
148
+ * Transient failures (network errors, 5xx, timeouts) are NOT this error and
149
+ * may be worth retrying.
150
+ */
151
+ export class AuthRefreshPermanentError extends Error {
152
+ httpStatus;
153
+ errorCode;
154
+ body;
155
+ constructor(httpStatus, body, errorCode) {
156
+ const detail = errorCode ? ` ${errorCode}` : "";
157
+ super(`Token refresh failed permanently (HTTP ${httpStatus}${detail}): ${body}`);
158
+ this.name = "AuthRefreshPermanentError";
159
+ this.httpStatus = httpStatus;
160
+ this.errorCode = errorCode;
161
+ this.body = body;
162
+ }
163
+ }
164
+ function parseRefreshErrorCode(body) {
165
+ try {
166
+ const parsed = JSON.parse(body);
167
+ if (parsed && typeof parsed === "object") {
168
+ if (typeof parsed.error_code === "string")
169
+ return parsed.error_code;
170
+ if (typeof parsed.error === "string")
171
+ return parsed.error;
172
+ }
173
+ }
174
+ catch {
175
+ // not JSON — fall through
176
+ }
177
+ return undefined;
178
+ }
133
179
  export async function refreshTokens(refreshToken, options) {
134
180
  const project = options?.supabaseUrl && options?.anonKey
135
181
  ? { url: options.supabaseUrl, anonKey: options.anonKey }
@@ -145,6 +191,9 @@ export async function refreshTokens(refreshToken, options) {
145
191
  });
146
192
  if (!resp.ok) {
147
193
  const body = await resp.text().catch(() => "");
194
+ if (resp.status >= 400 && resp.status < 500) {
195
+ throw new AuthRefreshPermanentError(resp.status, body, parseRefreshErrorCode(body));
196
+ }
148
197
  throw new Error(`Token refresh failed (HTTP ${resp.status}): ${body}`);
149
198
  }
150
199
  const data = await resp.json();
@@ -139,7 +139,7 @@ Concept pages: ish docs get-page concepts/ask
139
139
  .option("--variants <file.json>", "JSON manifest of variants (alternative to --variant)")
140
140
  .option("--wants-pick", "Each tester picks a favourite variant (compatible with --wants-ratings; can be set together).")
141
141
  .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.")
142
- .option("--questions <file.json>", `Questions JSON file: [{"question":"...","type":"open_ended"|"slider"|"choice"|"likert"}]`)
142
+ .option("--questions <file.json>", `Questions JSON file: [{"question":"...","type":"text"|"slider"|"likert"|"single-choice"|"multiple-choice"|"number"}]`)
143
143
  .option("--language <code>", "2-letter language code (with --new only)", "en");
144
144
  addAudienceFilterFlags(askRun, {
145
145
  allFlagName: "--all-simulatable",
@@ -285,7 +285,7 @@ Examples:
285
285
  .option("--variants <file.json>", "JSON manifest of variants (alternative to --variant)")
286
286
  .option("--wants-pick", "Each tester picks a favourite variant (compatible with --wants-ratings; can be set together).")
287
287
  .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.")
288
- .option("--questions <file.json>", `Questions JSON file: [{"question":"...","type":"open_ended"|"slider"|"choice"|"likert"}]`)
288
+ .option("--questions <file.json>", `Questions JSON file: [{"question":"...","type":"text"|"slider"|"likert"|"single-choice"|"multiple-choice"|"number"}]`)
289
289
  .option("--language <code>", "2-letter language code", "en");
290
290
  addAudienceFilterFlags(askCreate, {
291
291
  allFlagName: "--all-simulatable",
@@ -323,9 +323,13 @@ Examples:
323
323
 
324
324
  Minimal --questions JSON (server keys: "question" + "type"):
325
325
  [
326
- { "question": "What stood out?", "type": "open_ended" },
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) => {
@@ -362,14 +366,47 @@ Minimal --questions JSON (server keys: "question" + "type"):
362
366
  // ---- get ----------------------------------------------------------------
363
367
  ask
364
368
  .command("get")
365
- .description("Get full ask detail including rounds and responses")
366
- .argument("[id]", "Ask alias or UUID (defaults to active ask from `ish ask use`)")
367
- .option("--ask <id>", "Ask ID; alternative to positional argument")
368
- .option("--round <n>", "Show only round N (1-indexed)")
369
- .addHelpText("after", "\nExamples:\n $ ish ask get a-6ec\n $ ish ask get a-6ec --round 2 --json")
370
- .action(async (id, opts, cmd) => {
369
+ .description("Get full ask detail (accepts multiple IDs for batched lookup)")
370
+ .argument("[ids...]", "Ask alias(es)/UUID(s); defaults to active ask from `ish ask use`")
371
+ .option("--ask <id>", "Ask ID; alternative to a single positional argument")
372
+ .option("--round <n>", "Show only round N (1-indexed); single-ask only")
373
+ .addHelpText("after", `
374
+ Examples:
375
+ $ ish ask get a-6ec
376
+ $ ish ask get a-6ec --round 2 --json
377
+ $ ish ask get a-6ec a-7c2 a-9d3
378
+ $ ish ask get a-6ec,a-7c2 --fields alias,name,testers_count,responses_total
379
+
380
+ With multiple IDs, returns a {items:[...], total:N} envelope and uses the
381
+ list table layout in human mode. --ask and --round only apply to single-ask
382
+ lookups.`)
383
+ .action(async (ids, opts, cmd) => {
371
384
  await withClient(cmd, async (client, globals) => {
372
- const aid = resolveAsk(pickAskRef(id, opts.ask));
385
+ const flat = (ids ?? []).flatMap((s) => s.split(",").map((x) => x.trim()).filter(Boolean));
386
+ if (flat.length > 1) {
387
+ if (opts.ask) {
388
+ throw new Error("Pass either --ask or multiple positional ids, not both.");
389
+ }
390
+ if (opts.round !== undefined) {
391
+ throw new Error("--round only applies when fetching a single ask.");
392
+ }
393
+ const results = await Promise.all(flat.map(async (raw) => {
394
+ const aid = resolveId(raw);
395
+ const data = await client.get(`/asks/${aid}`);
396
+ const r = data;
397
+ if (r.id)
398
+ r.alias = tagAlias(ALIAS_PREFIX.ask, String(r.id));
399
+ return r;
400
+ }));
401
+ if (globals.json) {
402
+ output({ items: results, total: results.length }, true);
403
+ }
404
+ else {
405
+ formatAskList(results, false);
406
+ }
407
+ return;
408
+ }
409
+ const aid = resolveAsk(pickAskRef(flat[0], opts.ask));
373
410
  const data = await client.get(`/asks/${aid}`);
374
411
  if (opts.round !== undefined) {
375
412
  const target = parseInt(opts.round, 10);
@@ -397,7 +434,14 @@ Minimal --questions JSON (server keys: "question" + "type"):
397
434
  .argument("[id]", "Ask alias or UUID (defaults to active ask)")
398
435
  .option("--ask <id>", "Ask ID; alternative to positional argument")
399
436
  .option("--round <n>", "Show only round N (1-indexed; default: all rounds)")
400
- .addHelpText("after", "\nExamples:\n $ ish ask results a-6ec\n $ ish ask results a-6ec --round 1 --json")
437
+ .addHelpText("after", `
438
+ Examples:
439
+ $ ish ask results a-6ec
440
+ $ ish ask results a-6ec --round 1 --json
441
+
442
+ Each pick has a \`pick_confidence\` field (0..1, when --wants-pick was set) —
443
+ the model's self-reported confidence in its variant choice. See
444
+ \`ish docs get-page concepts/ask\` for how to use it for ranking ties.`)
401
445
  .action(async (id, opts, cmd) => {
402
446
  await withClient(cmd, async (client, globals) => {
403
447
  const aid = resolveAsk(pickAskRef(id, opts.ask));
@@ -468,12 +512,13 @@ Minimal --questions JSON (server keys: "question" + "type"):
468
512
  .description("Append a new round to an existing ask (max 5 per ask)")
469
513
  .argument("[id]", "Ask alias or UUID (defaults to active ask)")
470
514
  .option("--ask <id>", "Ask ID; alternative to positional argument")
515
+ .option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the ask)")
471
516
  .requiredOption("--prompt <prompt>", "Round prompt")
472
517
  .option("--variant <kind:value>", "Variant flag (repeatable; same syntax as `ask create`)", collectRepeatable, [])
473
518
  .option("--variants <file.json>", "JSON manifest of variants")
474
519
  .option("--wants-pick", "Each tester picks a favourite variant (compatible with --wants-ratings; can be set together).")
475
520
  .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.")
476
- .option("--questions <file.json>", `Questions JSON file: [{"question":"...","type":"open_ended"|"slider"|"choice"|"likert"}]`)
521
+ .option("--questions <file.json>", `Questions JSON file: [{"question":"...","type":"text"|"slider"|"likert"|"single-choice"|"multiple-choice"|"number"}]`)
477
522
  .option("--wait", "Wait until the new round completes")
478
523
  .option("--timeout <s>", "Wait timeout in seconds (default 300)")
479
524
  .addHelpText("after", "\nExamples:\n $ ish ask add-round a-6ec --prompt \"And now?\" --variant text:\"Hello\" --variant text:\"Hi\" --wait")
@@ -499,31 +544,54 @@ Minimal --questions JSON (server keys: "question" + "type"):
499
544
  // ---- add-questions ------------------------------------------------------
500
545
  ask
501
546
  .command("add-questions")
502
- .description("Append questionnaire questions to a round (resets responses, re-dispatches)")
547
+ .description("Append questionnaire questions to a round. Default: additive re-dispatch (prior picks/comments/ratings preserved; only the new questions are answered). Pass --redispatch-all to fully reset and re-run the round from scratch.")
503
548
  .argument("[id]", "Ask alias or UUID (defaults to active ask)")
504
549
  .option("--ask <id>", "Ask ID; alternative to positional argument")
505
550
  .requiredOption("--round <n|round-id>", "Round number (1-indexed) or round id/alias")
506
- .requiredOption("--questions <file.json>", `Questions JSON file: [{"question":"...","type":"open_ended"|"slider"|"choice"|"likert"}]`)
551
+ .requiredOption("--questions <file.json>", `Questions JSON file: [{"question":"...","type":"text"|"slider"|"likert"|"single-choice"|"multiple-choice"|"number"}]`)
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)")
507
555
  .addHelpText("after", `
508
556
  Examples:
557
+ # Additive (default): preserves prior picks/ratings/comments.
509
558
  $ ish ask add-questions a-6ec --round 1 --questions ./qs.json
510
559
 
560
+ # Wait for the round to finish before returning.
561
+ $ ish ask add-questions a-6ec --round 1 --questions ./qs.json --wait
562
+
563
+ # Legacy reset: re-runs the whole round; prior picks may shift.
564
+ $ ish ask add-questions a-6ec --round 1 --questions ./qs.json --redispatch-all --wait
565
+
511
566
  Minimal valid --questions JSON:
512
567
  [
513
- { "question": "What stood out?", "type": "open_ended" },
568
+ { "question": "What stood out?", "type": "text" },
514
569
  { "question": "Rate it 1-5", "type": "slider" }
515
570
  ]
516
571
 
517
572
  Note: the server requires the key "question" (not "text"). Valid type values:
518
- open_ended, slider, choice, likert.`)
573
+ text, slider, likert, single-choice, multiple-choice, number.`)
519
574
  .action(async (id, opts, cmd) => {
520
575
  await withClient(cmd, async (client, globals) => {
521
576
  const aid = resolveAsk(pickAskRef(id, opts.ask));
522
577
  const ask = await client.get(`/asks/${aid}`);
523
578
  const round = getRoundByIndexOrId(ask, opts.round);
524
579
  const questions = loadQuestionsManifest(opts.questions);
525
- const body = { questions };
580
+ const body = {
581
+ questions,
582
+ ...(opts.redispatchAll && { redispatch_all: true }),
583
+ };
526
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
+ }
527
595
  if (!globals.json || globals.verbose) {
528
596
  formatRoundDetail(updated, globals.json);
529
597
  return;
@@ -545,6 +613,7 @@ open_ended, slider, choice, likert.`)
545
613
  .description("Add testers to an existing ask (optionally backfill prior rounds)")
546
614
  .argument("[id]", "Ask alias or UUID (defaults to active ask)")
547
615
  .option("--ask <id>", "Ask ID; alternative to positional argument")
616
+ .option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the ask)")
548
617
  .requiredOption("--round <n|round-id>", "Round to add testers to (required)");
549
618
  addAudienceFilterFlags(askAddTesters, {
550
619
  allFlagName: "--all-simulatable",
@@ -5,7 +5,7 @@
5
5
  * content/file for media). Create one before `ish study run` dispatches
6
6
  * simulations against it.
7
7
  */
8
- import { withClient, resolveStudy } from "../lib/command-helpers.js";
8
+ import { withClient, resolveStudy, resolveWorkspace } from "../lib/command-helpers.js";
9
9
  import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
10
10
  import { output, formatIterationList } from "../lib/output.js";
11
11
  import { resolveContentUrl, resolveContentUrls, resolveTextContent } from "../lib/upload.js";
@@ -101,9 +101,12 @@ Concept pages: ish docs get-page concepts/iteration
101
101
  .command("list")
102
102
  .description("List iterations for a study")
103
103
  .option("--study <id>", "Study ID")
104
+ .option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the study)")
104
105
  .addHelpText("after", "\nExamples:\n $ ish iteration list --study <id>\n $ ish iteration list --study <id> --json")
105
106
  .action(async (opts, cmd) => {
106
107
  await withClient(cmd, async (client, globals) => {
108
+ if (opts.workspace)
109
+ resolveWorkspace(opts.workspace);
107
110
  const data = await client.get(`/studies/${resolveStudy(opts.study)}/iterations`);
108
111
  const rows = data;
109
112
  if (globals.json) {
@@ -122,7 +125,9 @@ Concept pages: ish docs get-page concepts/iteration
122
125
  order_index: it.order_index ?? null,
123
126
  created_at: it.created_at ?? null,
124
127
  }));
125
- output(projected, true, { preProjected: true });
128
+ // Synthesized pagination metadata backend returns a flat array.
129
+ const total = projected.length;
130
+ output({ items: projected, total, returned: total, limit: total, offset: 0, has_more: false }, true, { preProjected: true });
126
131
  return;
127
132
  }
128
133
  formatIterationList(rows, globals.json);
@@ -132,6 +137,7 @@ Concept pages: ish docs get-page concepts/iteration
132
137
  .command("create")
133
138
  .description("Create a new iteration with run-time content/URL")
134
139
  .option("--study <id>", "Study ID (or set via `ish study use`)")
140
+ .option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the study)")
135
141
  .option("--name <name>", "Iteration name (auto-generated if omitted)")
136
142
  .option("--description <description>", "Iteration description")
137
143
  // Interactive
@@ -198,6 +204,8 @@ workspace's public storage bucket. Validation now happens before upload.
198
204
  Next: \`ish study run\` to dispatch simulations against this iteration.`)
199
205
  .action(async (opts, cmd) => {
200
206
  await withClient(cmd, async (client, globals) => {
207
+ if (opts.workspace)
208
+ resolveWorkspace(opts.workspace);
201
209
  const studyId = resolveStudy(opts.study);
202
210
  let details;
203
211
  if (opts.detailsJson) {
@@ -283,16 +291,42 @@ Next: \`ish study run\` to dispatch simulations against this iteration.`)
283
291
  });
284
292
  iteration
285
293
  .command("get")
286
- .description("Get iteration details")
287
- .argument("<id>", "Iteration ID")
288
- .addHelpText("after", "\nExamples:\n $ ish iteration get <id>\n $ ish iteration get <id> --json")
289
- .action(async (id, _opts, cmd) => {
294
+ .description("Get iteration details (accepts multiple IDs for batched lookup)")
295
+ .argument("<ids...>", "Iteration ID(s) — one or more aliases/UUIDs (space- or comma-separated)")
296
+ .addHelpText("after", `
297
+ Examples:
298
+ $ ish iteration get i-d4e
299
+ $ ish iteration get i-d4e --json
300
+ $ ish iteration get i-d4e i-f0a i-9c7
301
+ $ ish iteration get i-d4e,i-f0a --fields alias,label,name
302
+
303
+ With multiple IDs, returns a {items:[...], total:N} envelope.`)
304
+ .action(async (ids, _opts, cmd) => {
290
305
  await withClient(cmd, async (client, globals) => {
291
- const data = await client.get(`/iterations/${resolveId(id)}`);
292
- const result = data;
293
- if (result.id)
294
- result.alias = tagAlias(ALIAS_PREFIX.iteration, String(result.id));
295
- output(result, globals.json);
306
+ const flat = ids.flatMap((s) => s.split(",").map((x) => x.trim()).filter(Boolean));
307
+ if (flat.length === 0)
308
+ throw new Error("Provide at least one iteration id.");
309
+ if (flat.length === 1) {
310
+ const data = await client.get(`/iterations/${resolveId(flat[0])}`);
311
+ const result = data;
312
+ if (result.id)
313
+ result.alias = tagAlias(ALIAS_PREFIX.iteration, String(result.id));
314
+ output(result, globals.json);
315
+ return;
316
+ }
317
+ const results = await Promise.all(flat.map(async (raw) => {
318
+ const data = await client.get(`/iterations/${resolveId(raw)}`);
319
+ const r = data;
320
+ if (r.id)
321
+ r.alias = tagAlias(ALIAS_PREFIX.iteration, String(r.id));
322
+ return r;
323
+ }));
324
+ if (globals.json) {
325
+ output({ items: results, total: results.length }, true);
326
+ }
327
+ else {
328
+ formatIterationList(results, false);
329
+ }
296
330
  });
297
331
  });
298
332
  iteration
@@ -40,15 +40,19 @@ Examples:
40
40
  $ ish profile list
41
41
  $ ish profile list --search "engineer" --country US
42
42
  $ ish profile list --gender female --gender male --country US --country GB
43
- $ ish profile list --type all --json`)
43
+ $ ish profile list --type all --json
44
+
45
+ # Pagination — default --limit is 50; iterate with --offset:
46
+ $ ish profile list --limit 100
47
+ $ ish profile list --limit 100 --offset 100 # next page
48
+ # When more results exist, a stderr hint surfaces the next --offset / --limit.`)
44
49
  .action(async (opts, cmd) => {
45
50
  await withClient(cmd, async (client, globals) => {
46
51
  const params = {
47
52
  limit: opts.limit,
48
53
  offset: opts.offset,
54
+ product_id: resolveWorkspace(opts.workspace),
49
55
  };
50
- if (opts.workspace)
51
- params.product_id = resolveWorkspace(opts.workspace);
52
56
  if (opts.search)
53
57
  params.search = opts.search;
54
58
  if (opts.type !== "all")
@@ -63,6 +67,29 @@ Examples:
63
67
  params.max_age = opts.maxAge;
64
68
  const data = await client.get("/tester-profiles", params);
65
69
  formatTesterProfileList(data, globals.json, parseInt(opts.limit, 10));
70
+ // Pattern H1: when there's more data, surface a stderr hint so agents
71
+ // know `--limit / --offset` exist without re-reading help. Stderr only,
72
+ // so it doesn't pollute machine-readable stdout — that means we DON'T
73
+ // gate on globals.json (auto-flips on pipe and would silence the hint
74
+ // exactly when the agent needs it most). --quiet is the explicit
75
+ // opt-out for progress chatter.
76
+ //
77
+ // The raw `/tester-profiles` envelope is `{items, total, limit, offset}`
78
+ // — `has_more` and `returned` are synthesized client-side by `wrapList`
79
+ // only when the response is rendered. Compute them locally here so the
80
+ // hint actually fires; reading `data.has_more` directly would always
81
+ // see undefined and silently skip.
82
+ if (!globals.quietExplicit && typeof data === "object" && data !== null) {
83
+ const d = data;
84
+ const items = Array.isArray(d.items) ? d.items : [];
85
+ const returned = items.length;
86
+ const total = typeof d.total === "number" ? d.total : returned;
87
+ const offset = typeof d.offset === "number" ? d.offset : parseInt(opts.offset, 10) || 0;
88
+ const hasMore = total > offset + returned;
89
+ if (hasMore && returned > 0) {
90
+ console.error(`\n showing ${offset + 1}–${offset + returned} of ${total}; pass --offset ${offset + returned} --limit ${returned} for more.`);
91
+ }
92
+ }
66
93
  });
67
94
  });
68
95
  profile
@@ -74,8 +101,9 @@ Examples:
74
101
  .action(async (opts, cmd) => {
75
102
  await withClient(cmd, async (client, globals) => {
76
103
  const body = await readJsonFileOrStdin(opts.file);
77
- if (opts.workspace)
104
+ if (opts.workspace || body.product_id == null) {
78
105
  body.product_id = resolveWorkspace(opts.workspace);
106
+ }
79
107
  const data = await client.post("/tester-profiles", body);
80
108
  const result = data;
81
109
  if (result.id)
@@ -195,8 +223,18 @@ Examples:
195
223
  }
196
224
  body.count = n;
197
225
  }
226
+ // Pattern D1: emit stderr progress before the LLM call so agents (and
227
+ // humans) see something is happening during the ~10–20s wait. Mirrors
228
+ // the `--wait` ergonomics on `ask create` / `study run`.
229
+ if (!globals.quiet) {
230
+ const target = body.count ? `${body.count} profile${body.count === 1 ? "" : "s"}` : "profiles";
231
+ console.error(` generating ${target}...`);
232
+ }
198
233
  // /generate is LLM-backed and slow.
199
234
  const profiles = await client.post("/tester-profiles/generate", body, { timeout: 180_000 });
235
+ if (!globals.quiet) {
236
+ console.error(` generated ${profiles.length} profile${profiles.length === 1 ? "" : "s"}`);
237
+ }
200
238
  // simulation_config is the inlined system prompt + model settings — ~3.5KB
201
239
  // of mostly-identical boilerplate per profile. Strip it from the default
202
240
  // JSON output; users who need it can pass --include-simulation-config or
@@ -212,16 +250,43 @@ Examples:
212
250
  });
213
251
  profile
214
252
  .command("get")
215
- .description("Get profile details")
216
- .argument("<id>", "Profile ID")
217
- .addHelpText("after", "\nExamples:\n $ ish profile get <id>\n $ ish profile get <id> --json")
218
- .action(async (id, _opts, cmd) => {
253
+ .description("Get profile details (accepts multiple IDs for batched lookup)")
254
+ .argument("<ids...>", "Profile ID(s) — one or more aliases/UUIDs (space- or comma-separated)")
255
+ .addHelpText("after", `
256
+ Examples:
257
+ $ ish profile get tp-1b9
258
+ $ ish profile get tp-1b9 --json
259
+ $ ish profile get tp-1b9 tp-fc1 tp-2fc
260
+ $ ish profile get tp-1b9,tp-fc1,tp-2fc --fields alias,name,country,occupation
261
+
262
+ With multiple IDs, returns a {items:[...], total:N} envelope and uses the
263
+ list table layout in human mode. Use --fields to project per-item.`)
264
+ .action(async (ids, _opts, cmd) => {
219
265
  await withClient(cmd, async (client, globals) => {
220
- const data = await client.get(`/tester-profiles/${resolveId(id)}`);
221
- const result = data;
222
- if (result.id)
223
- result.alias = tagAlias(ALIAS_PREFIX.testerProfile, String(result.id));
224
- output(result, globals.json);
266
+ const flat = ids.flatMap((s) => s.split(",").map((x) => x.trim()).filter(Boolean));
267
+ if (flat.length === 0)
268
+ throw new Error("Provide at least one profile id.");
269
+ if (flat.length === 1) {
270
+ const data = await client.get(`/tester-profiles/${resolveId(flat[0])}`);
271
+ const result = data;
272
+ if (result.id)
273
+ result.alias = tagAlias(ALIAS_PREFIX.testerProfile, String(result.id));
274
+ output(result, globals.json);
275
+ return;
276
+ }
277
+ const results = await Promise.all(flat.map(async (raw) => {
278
+ const data = await client.get(`/tester-profiles/${resolveId(raw)}`);
279
+ const r = data;
280
+ if (r.id)
281
+ r.alias = tagAlias(ALIAS_PREFIX.testerProfile, String(r.id));
282
+ return r;
283
+ }));
284
+ if (globals.json) {
285
+ output({ items: results, total: results.length }, true);
286
+ }
287
+ else {
288
+ formatTesterProfileList(results, false);
289
+ }
225
290
  });
226
291
  });
227
292
  profile
@@ -287,6 +352,7 @@ flags override values from --file.`)
287
352
  .command("delete")
288
353
  .description("Delete a profile")
289
354
  .argument("<id>", "Profile ID")
355
+ .option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the profile)")
290
356
  .addHelpText("after", "\nExamples:\n $ ish profile delete <id>")
291
357
  .action(async (id, _opts, cmd) => {
292
358
  await withClient(cmd, async (client, globals) => {
@@ -29,6 +29,43 @@ function parseSlowMo(value) {
29
29
  function isMediaModality(modality) {
30
30
  return !!modality && MEDIA_MODALITIES.includes(modality);
31
31
  }
32
+ /**
33
+ * Pattern F (Issue #9): an iteration without content is a footgun.
34
+ * `ish study generate` auto-creates an empty iteration "A" that becomes
35
+ * `study run`'s default target unless `--iteration` is explicit; agents
36
+ * silently dispatch against it. This predicate is the gate that lets
37
+ * study run refuse with a clear suggestion instead of silent failure.
38
+ */
39
+ function iterationHasContent(details, modality) {
40
+ if (!details || typeof details !== "object")
41
+ return false;
42
+ if (modality === "text") {
43
+ return typeof details.content_text === "string" && details.content_text.length > 0;
44
+ }
45
+ if (modality === "video" || modality === "audio" || modality === "document") {
46
+ return typeof details.content_url === "string" && details.content_url.length > 0;
47
+ }
48
+ if (modality === "image") {
49
+ const urls = details.image_urls;
50
+ if (Array.isArray(urls))
51
+ return urls.length > 0;
52
+ if (typeof urls === "string")
53
+ return urls.length > 0;
54
+ return false;
55
+ }
56
+ // interactive (default)
57
+ return typeof details.url === "string" && details.url.length > 0;
58
+ }
59
+ function describeRequiredContentFlag(modality) {
60
+ if (modality === "text")
61
+ return "--content-text <text-or-@file>";
62
+ if (modality === "video" || modality === "audio" || modality === "document") {
63
+ return "--content-url <url-or-file>";
64
+ }
65
+ if (modality === "image")
66
+ return "--image-urls <comma-separated>";
67
+ return "--url <url>";
68
+ }
32
69
  const POLL_INTERVAL_MS = 5_000;
33
70
  const TERMINAL_STATUSES = new Set(["completed", "errored", "failed", "cancelled", "canceled"]);
34
71
  function flattenTesterStatuses(iterations, only) {
@@ -187,6 +224,18 @@ Examples:
187
224
  }
188
225
  const iterationId = iteration.id;
189
226
  const iterationLabel = iteration.label || iteration.name || iterationId.slice(0, 8);
227
+ // Pattern F (Issue #9): refuse on empty iteration. `study generate`
228
+ // auto-creates an empty iteration A; agents who don't pass
229
+ // --iteration silently dispatch against it. Detect and refuse with
230
+ // a clear suggestion rather than masking the problem.
231
+ if (!iterationHasContent(iteration.details, modality)) {
232
+ const flagHint = describeRequiredContentFlag(modality);
233
+ const iterAlias = tagAlias(ALIAS_PREFIX.iteration, iterationId);
234
+ throw new Error(`Iteration "${iterationLabel}" (${iterAlias}) has no ${isMedia ? "content" : "URL"} configured yet. ` +
235
+ `Add ${isMedia ? "content" : "a URL"} with ` +
236
+ `\`ish iteration create --study ${resolvedStudy} ${flagHint}\` ` +
237
+ `(or update the existing iteration via \`ish iteration update ${iterAlias} --details-json '{...}'\`), then retry.`);
238
+ }
190
239
  const detailsView = readIterationDetails(iteration.details);
191
240
  // Step 2: Resolve audience.
192
241
  // - If any audience flag is set (--profile / --sample / --all / filter flags),