@ishlabs/cli 0.8.3 → 0.8.4

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:
package/dist/auth.d.ts CHANGED
@@ -15,6 +15,7 @@ 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;
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));
@@ -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,7 +323,7 @@ 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
329
  `)
@@ -362,14 +362,47 @@ Minimal --questions JSON (server keys: "question" + "type"):
362
362
  // ---- get ----------------------------------------------------------------
363
363
  ask
364
364
  .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) => {
365
+ .description("Get full ask detail (accepts multiple IDs for batched lookup)")
366
+ .argument("[ids...]", "Ask alias(es)/UUID(s); defaults to active ask from `ish ask use`")
367
+ .option("--ask <id>", "Ask ID; alternative to a single positional argument")
368
+ .option("--round <n>", "Show only round N (1-indexed); single-ask only")
369
+ .addHelpText("after", `
370
+ Examples:
371
+ $ ish ask get a-6ec
372
+ $ ish ask get a-6ec --round 2 --json
373
+ $ ish ask get a-6ec a-7c2 a-9d3
374
+ $ ish ask get a-6ec,a-7c2 --fields alias,name,testers_count,responses_total
375
+
376
+ With multiple IDs, returns a {items:[...], total:N} envelope and uses the
377
+ list table layout in human mode. --ask and --round only apply to single-ask
378
+ lookups.`)
379
+ .action(async (ids, opts, cmd) => {
371
380
  await withClient(cmd, async (client, globals) => {
372
- const aid = resolveAsk(pickAskRef(id, opts.ask));
381
+ const flat = (ids ?? []).flatMap((s) => s.split(",").map((x) => x.trim()).filter(Boolean));
382
+ if (flat.length > 1) {
383
+ if (opts.ask) {
384
+ throw new Error("Pass either --ask or multiple positional ids, not both.");
385
+ }
386
+ if (opts.round !== undefined) {
387
+ throw new Error("--round only applies when fetching a single ask.");
388
+ }
389
+ const results = await Promise.all(flat.map(async (raw) => {
390
+ const aid = resolveId(raw);
391
+ const data = await client.get(`/asks/${aid}`);
392
+ const r = data;
393
+ if (r.id)
394
+ r.alias = tagAlias(ALIAS_PREFIX.ask, String(r.id));
395
+ return r;
396
+ }));
397
+ if (globals.json) {
398
+ output({ items: results, total: results.length }, true);
399
+ }
400
+ else {
401
+ formatAskList(results, false);
402
+ }
403
+ return;
404
+ }
405
+ const aid = resolveAsk(pickAskRef(flat[0], opts.ask));
373
406
  const data = await client.get(`/asks/${aid}`);
374
407
  if (opts.round !== undefined) {
375
408
  const target = parseInt(opts.round, 10);
@@ -468,12 +501,13 @@ Minimal --questions JSON (server keys: "question" + "type"):
468
501
  .description("Append a new round to an existing ask (max 5 per ask)")
469
502
  .argument("[id]", "Ask alias or UUID (defaults to active ask)")
470
503
  .option("--ask <id>", "Ask ID; alternative to positional argument")
504
+ .option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the ask)")
471
505
  .requiredOption("--prompt <prompt>", "Round prompt")
472
506
  .option("--variant <kind:value>", "Variant flag (repeatable; same syntax as `ask create`)", collectRepeatable, [])
473
507
  .option("--variants <file.json>", "JSON manifest of variants")
474
508
  .option("--wants-pick", "Each tester picks a favourite variant (compatible with --wants-ratings; can be set together).")
475
509
  .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"}]`)
510
+ .option("--questions <file.json>", `Questions JSON file: [{"question":"...","type":"text"|"slider"|"likert"|"single-choice"|"multiple-choice"|"number"}]`)
477
511
  .option("--wait", "Wait until the new round completes")
478
512
  .option("--timeout <s>", "Wait timeout in seconds (default 300)")
479
513
  .addHelpText("after", "\nExamples:\n $ ish ask add-round a-6ec --prompt \"And now?\" --variant text:\"Hello\" --variant text:\"Hi\" --wait")
@@ -499,30 +533,38 @@ Minimal --questions JSON (server keys: "question" + "type"):
499
533
  // ---- add-questions ------------------------------------------------------
500
534
  ask
501
535
  .command("add-questions")
502
- .description("Append questionnaire questions to a round (resets responses, re-dispatches)")
536
+ .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
537
  .argument("[id]", "Ask alias or UUID (defaults to active ask)")
504
538
  .option("--ask <id>", "Ask ID; alternative to positional argument")
505
539
  .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"}]`)
540
+ .requiredOption("--questions <file.json>", `Questions JSON file: [{"question":"...","type":"text"|"slider"|"likert"|"single-choice"|"multiple-choice"|"number"}]`)
541
+ .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)
507
542
  .addHelpText("after", `
508
543
  Examples:
544
+ # Additive (default): preserves prior picks/ratings/comments.
509
545
  $ ish ask add-questions a-6ec --round 1 --questions ./qs.json
510
546
 
547
+ # Legacy reset: re-runs the whole round; prior picks may shift.
548
+ $ ish ask add-questions a-6ec --round 1 --questions ./qs.json --redispatch-all
549
+
511
550
  Minimal valid --questions JSON:
512
551
  [
513
- { "question": "What stood out?", "type": "open_ended" },
552
+ { "question": "What stood out?", "type": "text" },
514
553
  { "question": "Rate it 1-5", "type": "slider" }
515
554
  ]
516
555
 
517
556
  Note: the server requires the key "question" (not "text"). Valid type values:
518
- open_ended, slider, choice, likert.`)
557
+ text, slider, likert, single-choice, multiple-choice, number.`)
519
558
  .action(async (id, opts, cmd) => {
520
559
  await withClient(cmd, async (client, globals) => {
521
560
  const aid = resolveAsk(pickAskRef(id, opts.ask));
522
561
  const ask = await client.get(`/asks/${aid}`);
523
562
  const round = getRoundByIndexOrId(ask, opts.round);
524
563
  const questions = loadQuestionsManifest(opts.questions);
525
- const body = { questions };
564
+ const body = {
565
+ questions,
566
+ ...(opts.redispatchAll && { redispatch_all: true }),
567
+ };
526
568
  const updated = await client.post(`/asks/${aid}/rounds/${round.id}/questions`, body);
527
569
  if (!globals.json || globals.verbose) {
528
570
  formatRoundDetail(updated, globals.json);
@@ -545,6 +587,7 @@ open_ended, slider, choice, likert.`)
545
587
  .description("Add testers to an existing ask (optionally backfill prior rounds)")
546
588
  .argument("[id]", "Ask alias or UUID (defaults to active ask)")
547
589
  .option("--ask <id>", "Ask ID; alternative to positional argument")
590
+ .option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the ask)")
548
591
  .requiredOption("--round <n|round-id>", "Round to add testers to (required)");
549
592
  addAudienceFilterFlags(askAddTesters, {
550
593
  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
@@ -46,9 +46,8 @@ Examples:
46
46
  const params = {
47
47
  limit: opts.limit,
48
48
  offset: opts.offset,
49
+ product_id: resolveWorkspace(opts.workspace),
49
50
  };
50
- if (opts.workspace)
51
- params.product_id = resolveWorkspace(opts.workspace);
52
51
  if (opts.search)
53
52
  params.search = opts.search;
54
53
  if (opts.type !== "all")
@@ -63,6 +62,21 @@ Examples:
63
62
  params.max_age = opts.maxAge;
64
63
  const data = await client.get("/tester-profiles", params);
65
64
  formatTesterProfileList(data, globals.json, parseInt(opts.limit, 10));
65
+ // Pattern H1: when there's more data, surface a stderr hint so agents
66
+ // know `--limit / --offset` exist without re-reading help. Only when
67
+ // not in JSON mode and not silenced — JSON consumers read has_more
68
+ // off the envelope themselves; quiet suppresses progress chatter.
69
+ if (!globals.json && !globals.quiet && typeof data === "object" && data !== null) {
70
+ const d = data;
71
+ if (d.has_more === true) {
72
+ const total = typeof d.total === "number" ? d.total : null;
73
+ const returned = typeof d.returned === "number" ? d.returned : null;
74
+ const offset = typeof d.offset === "number" ? d.offset : 0;
75
+ if (total !== null && returned !== null) {
76
+ console.error(`\n showing ${offset + 1}–${offset + returned} of ${total}; pass --offset ${offset + returned} --limit ${returned} for more.`);
77
+ }
78
+ }
79
+ }
66
80
  });
67
81
  });
68
82
  profile
@@ -74,8 +88,9 @@ Examples:
74
88
  .action(async (opts, cmd) => {
75
89
  await withClient(cmd, async (client, globals) => {
76
90
  const body = await readJsonFileOrStdin(opts.file);
77
- if (opts.workspace)
91
+ if (opts.workspace || body.product_id == null) {
78
92
  body.product_id = resolveWorkspace(opts.workspace);
93
+ }
79
94
  const data = await client.post("/tester-profiles", body);
80
95
  const result = data;
81
96
  if (result.id)
@@ -195,8 +210,18 @@ Examples:
195
210
  }
196
211
  body.count = n;
197
212
  }
213
+ // Pattern D1: emit stderr progress before the LLM call so agents (and
214
+ // humans) see something is happening during the ~10–20s wait. Mirrors
215
+ // the `--wait` ergonomics on `ask create` / `study run`.
216
+ if (!globals.quiet) {
217
+ const target = body.count ? `${body.count} profile${body.count === 1 ? "" : "s"}` : "profiles";
218
+ console.error(` generating ${target}...`);
219
+ }
198
220
  // /generate is LLM-backed and slow.
199
221
  const profiles = await client.post("/tester-profiles/generate", body, { timeout: 180_000 });
222
+ if (!globals.quiet) {
223
+ console.error(` generated ${profiles.length} profile${profiles.length === 1 ? "" : "s"}`);
224
+ }
200
225
  // simulation_config is the inlined system prompt + model settings — ~3.5KB
201
226
  // of mostly-identical boilerplate per profile. Strip it from the default
202
227
  // JSON output; users who need it can pass --include-simulation-config or
@@ -212,16 +237,43 @@ Examples:
212
237
  });
213
238
  profile
214
239
  .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) => {
240
+ .description("Get profile details (accepts multiple IDs for batched lookup)")
241
+ .argument("<ids...>", "Profile ID(s) — one or more aliases/UUIDs (space- or comma-separated)")
242
+ .addHelpText("after", `
243
+ Examples:
244
+ $ ish profile get tp-1b9
245
+ $ ish profile get tp-1b9 --json
246
+ $ ish profile get tp-1b9 tp-fc1 tp-2fc
247
+ $ ish profile get tp-1b9,tp-fc1,tp-2fc --fields alias,name,country,occupation
248
+
249
+ With multiple IDs, returns a {items:[...], total:N} envelope and uses the
250
+ list table layout in human mode. Use --fields to project per-item.`)
251
+ .action(async (ids, _opts, cmd) => {
219
252
  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);
253
+ const flat = ids.flatMap((s) => s.split(",").map((x) => x.trim()).filter(Boolean));
254
+ if (flat.length === 0)
255
+ throw new Error("Provide at least one profile id.");
256
+ if (flat.length === 1) {
257
+ const data = await client.get(`/tester-profiles/${resolveId(flat[0])}`);
258
+ const result = data;
259
+ if (result.id)
260
+ result.alias = tagAlias(ALIAS_PREFIX.testerProfile, String(result.id));
261
+ output(result, globals.json);
262
+ return;
263
+ }
264
+ const results = await Promise.all(flat.map(async (raw) => {
265
+ const data = await client.get(`/tester-profiles/${resolveId(raw)}`);
266
+ const r = data;
267
+ if (r.id)
268
+ r.alias = tagAlias(ALIAS_PREFIX.testerProfile, String(r.id));
269
+ return r;
270
+ }));
271
+ if (globals.json) {
272
+ output({ items: results, total: results.length }, true);
273
+ }
274
+ else {
275
+ formatTesterProfileList(results, false);
276
+ }
225
277
  });
226
278
  });
227
279
  profile
@@ -287,6 +339,7 @@ flags override values from --file.`)
287
339
  .command("delete")
288
340
  .description("Delete a profile")
289
341
  .argument("<id>", "Profile ID")
342
+ .option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the profile)")
290
343
  .addHelpText("after", "\nExamples:\n $ ish profile delete <id>")
291
344
  .action(async (id, _opts, cmd) => {
292
345
  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),
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Default action: `ish study tester <id>` shows tester details and results.
6
6
  */
7
- import { withClient, readJsonFileOrStdin } from "../lib/command-helpers.js";
7
+ import { withClient, readJsonFileOrStdin, resolveWorkspace } from "../lib/command-helpers.js";
8
8
  import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
9
9
  import { formatTesterDetail, output } from "../lib/output.js";
10
10
  /** Pick the latest iteration on a study (highest order_index, falling back to last). */
@@ -52,12 +52,15 @@ export function attachStudyTesterCommands(study) {
52
52
  .command("tester")
53
53
  .description("Inspect or manage testers (low-level; usually created via `study run`)")
54
54
  .argument("[id]", "Tester ID — pass directly to view tester details and results")
55
+ .option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the tester)")
55
56
  .addHelpText("after", "\nExamples:\n $ ish study tester t-d4e # show tester details\n $ ish study tester create --iteration <id> --profile <id>\n $ ish study tester batch-create --iteration <id> --file testers.json\n $ ish study tester delete t-d4e")
56
- .action(async (id, _opts, cmd) => {
57
+ .action(async (id, opts, cmd) => {
57
58
  if (!id) {
58
59
  cmd.help();
59
60
  }
60
61
  await withClient(cmd, async (client, globals) => {
62
+ if (opts.workspace)
63
+ resolveWorkspace(opts.workspace);
61
64
  const data = await client.get(`/testers/${resolveId(id)}`);
62
65
  const result = data;
63
66
  if (result.id)