@ishlabs/cli 0.8.1 → 0.8.2

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 (70) hide show
  1. package/README.md +323 -21
  2. package/dist/auth.d.ts +17 -1
  3. package/dist/auth.js +62 -9
  4. package/dist/commands/ask.d.ts +5 -0
  5. package/dist/commands/ask.js +722 -0
  6. package/dist/commands/config.js +25 -1
  7. package/dist/commands/docs.d.ts +17 -0
  8. package/dist/commands/docs.js +147 -0
  9. package/dist/commands/init.d.ts +16 -0
  10. package/dist/commands/init.js +182 -0
  11. package/dist/commands/iteration.d.ts +5 -1
  12. package/dist/commands/iteration.js +243 -31
  13. package/dist/commands/profile.d.ts +5 -0
  14. package/dist/commands/profile.js +313 -0
  15. package/dist/commands/source.d.ts +10 -0
  16. package/dist/commands/source.js +78 -0
  17. package/dist/commands/study-run.d.ts +11 -0
  18. package/dist/commands/study-run.js +552 -0
  19. package/dist/commands/study-tester.d.ts +8 -0
  20. package/dist/commands/study-tester.js +149 -0
  21. package/dist/commands/study.js +145 -70
  22. package/dist/commands/workspace.js +193 -7
  23. package/dist/config.d.ts +3 -1
  24. package/dist/config.js +10 -10
  25. package/dist/connect.d.ts +4 -1
  26. package/dist/connect.js +127 -94
  27. package/dist/index.js +82 -34
  28. package/dist/lib/alias-store.d.ts +3 -0
  29. package/dist/lib/alias-store.js +9 -7
  30. package/dist/lib/api-client.d.ts +9 -6
  31. package/dist/lib/api-client.js +87 -26
  32. package/dist/lib/ask-questions.d.ts +9 -0
  33. package/dist/lib/ask-questions.js +35 -0
  34. package/dist/lib/ask-variants.d.ts +48 -0
  35. package/dist/lib/ask-variants.js +236 -0
  36. package/dist/lib/auth.d.ts +1 -1
  37. package/dist/lib/auth.js +24 -8
  38. package/dist/lib/colors.d.ts +30 -0
  39. package/dist/lib/colors.js +48 -0
  40. package/dist/lib/command-helpers.d.ts +74 -0
  41. package/dist/lib/command-helpers.js +232 -6
  42. package/dist/lib/docs.d.ts +32 -0
  43. package/dist/lib/docs.js +930 -0
  44. package/dist/lib/local-sim/browser.d.ts +0 -1
  45. package/dist/lib/local-sim/browser.js +0 -2
  46. package/dist/lib/local-sim/install.d.ts +4 -7
  47. package/dist/lib/local-sim/install.js +6 -21
  48. package/dist/lib/output.d.ts +25 -3
  49. package/dist/lib/output.js +465 -20
  50. package/dist/lib/paths.d.ts +14 -0
  51. package/dist/lib/paths.js +36 -0
  52. package/dist/lib/profile-sources.d.ts +55 -0
  53. package/dist/lib/profile-sources.js +157 -0
  54. package/dist/lib/site-access.d.ts +80 -0
  55. package/dist/lib/site-access.js +188 -0
  56. package/dist/lib/skill-content.d.ts +31 -0
  57. package/dist/lib/skill-content.js +462 -0
  58. package/dist/lib/study-inputs.d.ts +20 -0
  59. package/dist/lib/study-inputs.js +72 -0
  60. package/dist/lib/types.d.ts +207 -9
  61. package/dist/lib/types.js +7 -0
  62. package/dist/lib/upload.js +2 -2
  63. package/dist/upgrade.js +11 -1
  64. package/package.json +1 -1
  65. package/dist/commands/simulation.d.ts +0 -10
  66. package/dist/commands/simulation.js +0 -647
  67. package/dist/commands/tester-profile.d.ts +0 -5
  68. package/dist/commands/tester-profile.js +0 -109
  69. package/dist/commands/tester.d.ts +0 -5
  70. package/dist/commands/tester.js +0 -73
@@ -0,0 +1,722 @@
1
+ /**
2
+ * ish ask — Create and run asks (multi-round surveys with variants).
3
+ */
4
+ import { withClient, getWebUrl, terminalLink, resolveWorkspace, resolveAsk, collectRepeatable, parseWaitTimeout, resolveAudienceProfileIds, addAudienceFilterFlags, } from "../lib/command-helpers.js";
5
+ import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
6
+ import { loadConfig, saveConfig } from "../config.js";
7
+ import { formatAskList, formatAskDetail, formatRoundDetail, formatAskResults, output, } from "../lib/output.js";
8
+ import { parseVariantInputs, uploadAndBuildVariants, } from "../lib/ask-variants.js";
9
+ import { loadQuestionsManifest } from "../lib/ask-questions.js";
10
+ const POLL_INTERVAL_MS = 5_000;
11
+ // ---------------------------------------------------------------------------
12
+ // Helpers
13
+ // ---------------------------------------------------------------------------
14
+ /** Map raw Commander opts (with `allSimulatable`) to the shared AudienceFilterOpts shape. */
15
+ function audienceFlags(opts) {
16
+ return {
17
+ profile: opts.profile,
18
+ sample: opts.sample,
19
+ all: opts.allSimulatable,
20
+ search: opts.search,
21
+ gender: opts.gender,
22
+ country: opts.country,
23
+ minAge: opts.minAge,
24
+ maxAge: opts.maxAge,
25
+ visibility: opts.visibility,
26
+ };
27
+ }
28
+ function truncate60(s) {
29
+ return s.length > 60 ? s.slice(0, 57) + "..." : s;
30
+ }
31
+ /**
32
+ * Pick the ask id to operate on. Both forms are supported:
33
+ * - positional `[id]`
34
+ * - `--ask <id>` flag
35
+ * If both are passed and disagree, --ask wins and a stderr warning is emitted.
36
+ */
37
+ function pickAskRef(positional, flag) {
38
+ if (flag && positional && flag !== positional) {
39
+ process.stderr.write(`Warning: both [id] (${positional}) and --ask (${flag}) provided; using --ask.\n`);
40
+ }
41
+ return flag ?? positional;
42
+ }
43
+ function getRoundByIndexOrId(ask, ref) {
44
+ const rounds = ask.rounds ?? [];
45
+ if (typeof ref === "number") {
46
+ const r = rounds.find((x) => x.order_index === ref - 1);
47
+ if (!r)
48
+ throw new Error(`Ask has no round ${ref}.`);
49
+ return r;
50
+ }
51
+ // String — try numeric first, else treat as round id (UUID or alias).
52
+ const asNum = parseInt(ref, 10);
53
+ if (!Number.isNaN(asNum) && /^\d+$/.test(ref)) {
54
+ return getRoundByIndexOrId(ask, asNum);
55
+ }
56
+ const rid = resolveId(ref);
57
+ const r = rounds.find((x) => x.id === rid);
58
+ if (!r)
59
+ throw new Error(`Ask has no round with id ${ref}.`);
60
+ return r;
61
+ }
62
+ async function pollUntilRoundDone(client, askId, roundIndex, timeoutMs, quiet) {
63
+ const start = Date.now();
64
+ let lastReported = "";
65
+ while (true) {
66
+ const ask = await client.get(`/asks/${askId}`);
67
+ const round = ask.rounds?.find((r) => r.order_index === roundIndex);
68
+ if (!round) {
69
+ throw new Error(`Round at order_index ${roundIndex} not found while waiting.`);
70
+ }
71
+ const responses = round.responses ?? [];
72
+ const completed = responses.filter((r) => r.status === "completed").length;
73
+ const errored = responses.filter((r) => r.status === "errored").length;
74
+ const status = `${round.status} · ${completed}/${responses.length} done${errored > 0 ? `, ${errored} errored` : ""}`;
75
+ if (!quiet && status !== lastReported) {
76
+ process.stderr.write(` ${status}\n`);
77
+ lastReported = status;
78
+ }
79
+ if (round.status === "completed" || round.status === "errored") {
80
+ return ask;
81
+ }
82
+ if (Date.now() - start > timeoutMs) {
83
+ throw new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for round ${roundIndex + 1}. ` +
84
+ `Run \`ish ask get <ask-id>\` to check status.`);
85
+ }
86
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
87
+ }
88
+ }
89
+ async function buildRoundInput(client, productId, opts, quiet) {
90
+ const parsed = parseVariantInputs({ variant: opts.variant, variants: opts.variants });
91
+ if (opts.wantsPick && parsed.length < 2) {
92
+ throw new Error("--wants-pick requires at least 2 variants.");
93
+ }
94
+ if (opts.wantsRatings && parsed.length < 1) {
95
+ throw new Error("--wants-ratings requires at least 1 variant.");
96
+ }
97
+ const variants = await uploadAndBuildVariants(client, productId, parsed, { quiet });
98
+ const questions = opts.questions
99
+ ? loadQuestionsManifest(opts.questions)
100
+ : undefined;
101
+ const round = {
102
+ prompt: opts.prompt,
103
+ variants,
104
+ wants_pick: !!opts.wantsPick,
105
+ wants_ratings: !!opts.wantsRatings,
106
+ };
107
+ if (questions)
108
+ round.questions = questions;
109
+ return round;
110
+ }
111
+ // ---------------------------------------------------------------------------
112
+ // Command registration
113
+ // ---------------------------------------------------------------------------
114
+ export function registerAskCommands(program) {
115
+ const ask = program
116
+ .command("ask")
117
+ .description("Create and run asks — quick variant comparisons with an audience")
118
+ .addHelpText("after", `
119
+ An ask is a lightweight reaction artifact. Audience is fixed at creation; up to 5 rounds per ask.
120
+ Reach for an ask for tagline/image/copy comparisons; reach for a study when the tester must do something.
121
+
122
+ Concept pages: ish docs get-page concepts/ask
123
+ ish docs get-page concepts/round
124
+ ish docs get-page concepts/run-verbs`);
125
+ // ---- run ----------------------------------------------------------------
126
+ // Smart wrapper: --new creates a new ask + first round (like `ask create`);
127
+ // otherwise appends a round to the active or specified ask (like `ask add-round`).
128
+ const askRun = ask
129
+ .command("run")
130
+ .description("Run an ask — append a round to the active ask, or `--new` to create a fresh ask")
131
+ .argument("[id]", "Ask alias or UUID to append to (defaults to active ask; ignored with --new)")
132
+ .option("--ask <id>", "Ask ID; alternative to positional argument")
133
+ .option("--new", "Create a new ask instead of appending a round")
134
+ .option("--name <name>", "Ask name (with --new; auto-generated if omitted)")
135
+ .option("--description <description>", "Ask description (with --new only)")
136
+ .option("--workspace <id>", "Workspace ID (with --new only)")
137
+ .requiredOption("--prompt <prompt>", "Round prompt — what testers respond to")
138
+ .option("--variant <kind:value>", "Variant: text:\"...\", ./file.png, image:./file.png, ./file.png::label=B (repeatable)", collectRepeatable, [])
139
+ .option("--variants <file.json>", "JSON manifest of variants (alternative to --variant)")
140
+ .option("--wants-pick", "Each tester picks a favourite variant (compatible with --wants-ratings; can be set together).")
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"}]`)
143
+ .option("--language <code>", "2-letter language code (with --new only)", "en");
144
+ addAudienceFilterFlags(askRun, {
145
+ allFlagName: "--all-simulatable",
146
+ allFlagDescription: "Use every simulatable AI profile matching the filters (with --new only)",
147
+ })
148
+ .option("--wait", "Wait until the round completes (or errors)")
149
+ .option("--timeout <s>", "Wait timeout in seconds (default 300)")
150
+ .addHelpText("after", `
151
+ Examples:
152
+ # Append a round to the active ask:
153
+ $ ish ask run --prompt "And now which?" --variant text:"X" --variant text:"Y" --wait
154
+
155
+ # Append to a specific ask:
156
+ $ ish ask run a-6ec --prompt "Round 2" --variant text:"A" --variant text:"B"
157
+
158
+ # Create a fresh ask with round 1, sampled from a demographic:
159
+ $ ish ask run --new --name "tagline AB" \\
160
+ --prompt "Which sounds better?" \\
161
+ --variant text:"Short and punchy." \\
162
+ --variant text:"A longer, descriptive line." \\
163
+ --country SE --min-age 35 --max-age 50 --sample 10 --wants-pick --wait
164
+ `)
165
+ .action(async (id, opts, cmd) => {
166
+ await withClient(cmd, async (client, globals) => {
167
+ const pickedId = pickAskRef(id, opts.ask);
168
+ if (opts.new) {
169
+ if (pickedId) {
170
+ throw new Error("Cannot pass an ask id together with --new. Drop the id, or drop --new to append a round.");
171
+ }
172
+ const wid = resolveWorkspace(opts.workspace);
173
+ const testerIds = await resolveAudienceProfileIds(client, wid, audienceFlags(opts), { requireSimulatable: true, allFlagName: "--all-simulatable" });
174
+ const round = await buildRoundInput(client, wid, opts, !!globals.quiet);
175
+ const askName = opts.name || `CLI ${new Date().toISOString().slice(0, 16)}`;
176
+ const body = {
177
+ name: askName,
178
+ ...(opts.description !== undefined && { description: opts.description }),
179
+ language: opts.language,
180
+ tester_profile_ids: testerIds,
181
+ first_round: round,
182
+ };
183
+ let data = await client.post(`/products/${wid}/asks`, body, { timeout: 120_000 });
184
+ if (data.id) {
185
+ const config = loadConfig();
186
+ config.ask = data.id;
187
+ saveConfig(config);
188
+ }
189
+ if (opts.wait) {
190
+ const timeoutMs = parseWaitTimeout(opts.timeout);
191
+ data = await pollUntilRoundDone(client, data.id, 0, timeoutMs, !!globals.quiet);
192
+ }
193
+ const result = data;
194
+ if (result.id)
195
+ result.alias = tagAlias(ALIAS_PREFIX.ask, String(result.id));
196
+ formatAskDetail(result, globals.json);
197
+ if (!globals.json && data.id) {
198
+ const url = getWebUrl(globals, `/${wid}/asks/${data.id}`);
199
+ process.stderr.write(`\n ${terminalLink(url, "Open in browser ↗")}\n\n`);
200
+ }
201
+ return;
202
+ }
203
+ // Append-round path — reject audience/creation flags that have no effect here
204
+ const appendAudienceSet = (opts.profile && opts.profile.length > 0)
205
+ || opts.sample !== undefined
206
+ || opts.allSimulatable
207
+ || opts.search
208
+ || (opts.gender && opts.gender.length > 0)
209
+ || (opts.country && opts.country.length > 0)
210
+ || opts.minAge
211
+ || opts.maxAge
212
+ || opts.visibility;
213
+ if (opts.name || opts.description || opts.workspace || appendAudienceSet) {
214
+ throw new Error("Audience and ask-creation flags (--name, --description, --workspace, --profile, --sample, --all-simulatable, --country, --gender, --min-age, --max-age, --search, --visibility) are only valid with --new. " +
215
+ "Drop them to append a round, or add --new to create a fresh ask.");
216
+ }
217
+ let aid;
218
+ try {
219
+ aid = resolveAsk(pickedId);
220
+ }
221
+ catch {
222
+ throw new Error("No active ask. Use `ish ask use <id>`, pass an ask id as an argument, or `--new` to create a fresh ask.");
223
+ }
224
+ const ask = await client.get(`/asks/${aid}`);
225
+ const round = await buildRoundInput(client, ask.product_id, opts, !!globals.quiet);
226
+ const created = await client.post(`/asks/${aid}/rounds`, round);
227
+ if (opts.wait) {
228
+ const timeoutMs = parseWaitTimeout(opts.timeout);
229
+ await pollUntilRoundDone(client, aid, created.order_index, timeoutMs, !!globals.quiet);
230
+ const refreshed = await client.get(`/asks/${aid}`);
231
+ const target = refreshed.rounds.find((r) => r.id === created.id);
232
+ if (target) {
233
+ formatRoundDetail(target, globals.json);
234
+ return;
235
+ }
236
+ }
237
+ formatRoundDetail(created, globals.json);
238
+ });
239
+ });
240
+ // ---- list ---------------------------------------------------------------
241
+ ask
242
+ .command("list")
243
+ .description("List asks for a workspace")
244
+ .option("--workspace <id>", "Workspace ID")
245
+ .option("--archived", "Include archived asks alongside active ones (default: active only).")
246
+ .addHelpText("after", "\nExamples:\n $ ish ask list --workspace w-6ec\n $ ish ask list --workspace w-6ec --json")
247
+ .action(async (opts, cmd) => {
248
+ await withClient(cmd, async (client, globals) => {
249
+ const wid = resolveWorkspace(opts.workspace);
250
+ const data = await client.get(`/products/${wid}/asks`);
251
+ const filtered = opts.archived ? data : data.filter((a) => !a.is_archived);
252
+ // Enrich each item with description and first_round_prompt (truncated to 60 chars)
253
+ // so listings with duplicate names can be disambiguated. The list endpoint
254
+ // doesn't return rounds, so fetch them in parallel.
255
+ const enriched = await Promise.all(filtered.map(async (item) => {
256
+ let firstRoundPrompt = null;
257
+ try {
258
+ const detail = await client.get(`/asks/${item.id}`);
259
+ const first = (detail.rounds ?? []).find((r) => r.order_index === 0);
260
+ if (first?.prompt)
261
+ firstRoundPrompt = truncate60(first.prompt);
262
+ }
263
+ catch {
264
+ // Best-effort: leave first_round_prompt as null on fetch failure.
265
+ }
266
+ const out = item;
267
+ return {
268
+ ...out,
269
+ description: out.description ? truncate60(String(out.description)) : null,
270
+ first_round_prompt: firstRoundPrompt,
271
+ };
272
+ }));
273
+ formatAskList(enriched, globals.json);
274
+ });
275
+ });
276
+ // ---- create -------------------------------------------------------------
277
+ const askCreate = ask
278
+ .command("create")
279
+ .description("Create a new ask with a first round and dispatch to the audience")
280
+ .option("--workspace <id>", "Workspace ID")
281
+ .requiredOption("--name <name>", "Ask name")
282
+ .option("--description <description>", "Ask description")
283
+ .requiredOption("--prompt <prompt>", "Round prompt — what testers respond to")
284
+ .option("--variant <kind:value>", "Variant: text:\"...\", ./file.png, image:./file.png, ./file.png::label=B (repeatable)", collectRepeatable, [])
285
+ .option("--variants <file.json>", "JSON manifest of variants (alternative to --variant)")
286
+ .option("--wants-pick", "Each tester picks a favourite variant (compatible with --wants-ratings; can be set together).")
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"}]`)
289
+ .option("--language <code>", "2-letter language code", "en");
290
+ addAudienceFilterFlags(askCreate, {
291
+ allFlagName: "--all-simulatable",
292
+ allFlagDescription: "Use every simulatable AI profile matching the filters",
293
+ })
294
+ .option("--wait", "Wait until the first round completes (or errors)")
295
+ .option("--timeout <s>", "Wait timeout in seconds (default 300)")
296
+ .addHelpText("after", `
297
+ Examples:
298
+ # Two-text comparison, sample 30 testers, wait for results:
299
+ $ ish ask create --workspace w-6ec --name "tagline AB" \\
300
+ --prompt "Which sounds better?" \\
301
+ --variant text:"Short and punchy." \\
302
+ --variant text:"A longer, descriptive line." \\
303
+ --sample 30 --wants-pick --wait
304
+
305
+ # Demographic-filtered sample (Swedish profiles aged 35–50):
306
+ $ ish ask create --workspace w-6ec --name "SE 35-50" \\
307
+ --prompt "Which sounds better?" \\
308
+ --variant text:"A" --variant text:"B" \\
309
+ --country SE --min-age 35 --max-age 50 --sample 10 --wants-pick
310
+
311
+ # Image comparison from files with explicit profiles and ratings:
312
+ $ ish ask create --workspace w-6ec --name "hero shots" \\
313
+ --prompt "Which feels premium?" \\
314
+ --variant image:./hero-a.png::label=A \\
315
+ --variant image:./hero-b.png::label=B \\
316
+ --profile tp-d4e,tp-a17 --wants-ratings
317
+
318
+ # Text from a markdown file + JSON questionnaire:
319
+ $ ish ask create --workspace w-6ec --name "newsletter" \\
320
+ --prompt "Would you read this?" \\
321
+ --variant text:@./body.md \\
322
+ --questions ./questions.json --sample 30
323
+
324
+ Minimal --questions JSON (server keys: "question" + "type"):
325
+ [
326
+ { "question": "What stood out?", "type": "open_ended" },
327
+ { "question": "Rate it 1-5", "type": "slider" }
328
+ ]
329
+ `)
330
+ .action(async (opts, cmd) => {
331
+ await withClient(cmd, async (client, globals) => {
332
+ const wid = resolveWorkspace(opts.workspace);
333
+ const testerIds = await resolveAudienceProfileIds(client, wid, audienceFlags(opts), { requireSimulatable: true, allFlagName: "--all-simulatable" });
334
+ const round = await buildRoundInput(client, wid, opts, !!globals.quiet);
335
+ const body = {
336
+ name: opts.name,
337
+ ...(opts.description !== undefined && { description: opts.description }),
338
+ language: opts.language,
339
+ tester_profile_ids: testerIds,
340
+ first_round: round,
341
+ };
342
+ let data = await client.post(`/products/${wid}/asks`, body);
343
+ if (data.id) {
344
+ const config = loadConfig();
345
+ config.ask = data.id;
346
+ saveConfig(config);
347
+ }
348
+ if (opts.wait) {
349
+ const timeoutMs = parseWaitTimeout(opts.timeout);
350
+ data = await pollUntilRoundDone(client, data.id, 0, timeoutMs, !!globals.quiet);
351
+ }
352
+ const result = data;
353
+ if (result.id)
354
+ result.alias = tagAlias(ALIAS_PREFIX.ask, String(result.id));
355
+ formatAskDetail(result, globals.json);
356
+ if (!globals.json && data.id) {
357
+ const url = getWebUrl(globals, `/${wid}/asks/${data.id}`);
358
+ process.stderr.write(`\n ${terminalLink(url, "Open in browser ↗")}\n\n`);
359
+ }
360
+ });
361
+ });
362
+ // ---- get ----------------------------------------------------------------
363
+ ask
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) => {
371
+ await withClient(cmd, async (client, globals) => {
372
+ const aid = resolveAsk(pickAskRef(id, opts.ask));
373
+ const data = await client.get(`/asks/${aid}`);
374
+ if (opts.round !== undefined) {
375
+ const target = parseInt(opts.round, 10);
376
+ if (Number.isNaN(target) || target <= 0) {
377
+ throw new Error(`--round must be a positive integer.`);
378
+ }
379
+ const round = getRoundByIndexOrId(data, target);
380
+ formatRoundDetail(round, globals.json);
381
+ return;
382
+ }
383
+ const result = data;
384
+ if (result.id)
385
+ result.alias = tagAlias(ALIAS_PREFIX.ask, String(result.id));
386
+ formatAskDetail(result, globals.json);
387
+ if (!globals.json && data.product_id) {
388
+ const url = getWebUrl(globals, `/${data.product_id}/asks/${aid}`);
389
+ process.stderr.write(`\n ${terminalLink(url, "Open in browser ↗")}\n\n`);
390
+ }
391
+ });
392
+ });
393
+ // ---- results ------------------------------------------------------------
394
+ ask
395
+ .command("results")
396
+ .description("Show aggregated results (picks, mean ratings, comments, summary)")
397
+ .argument("[id]", "Ask alias or UUID (defaults to active ask)")
398
+ .option("--ask <id>", "Ask ID; alternative to positional argument")
399
+ .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")
401
+ .action(async (id, opts, cmd) => {
402
+ await withClient(cmd, async (client, globals) => {
403
+ const aid = resolveAsk(pickAskRef(id, opts.ask));
404
+ const data = await client.get(`/asks/${aid}`);
405
+ const target = opts.round !== undefined ? parseInt(opts.round, 10) : undefined;
406
+ if (target !== undefined && (Number.isNaN(target) || target <= 0)) {
407
+ throw new Error(`--round must be a positive integer.`);
408
+ }
409
+ formatAskResults(data, globals.json, target);
410
+ });
411
+ });
412
+ // ---- wait ---------------------------------------------------------------
413
+ ask
414
+ .command("wait")
415
+ .description("Poll until a round is completed (or errored)")
416
+ .argument("[id]", "Ask alias or UUID (defaults to active ask)")
417
+ .option("--ask <id>", "Ask ID; alternative to positional argument")
418
+ .option("--round <n>", "Round number to wait for (1-indexed; default: last round)")
419
+ .option("--timeout <s>", "Max seconds to wait (default 300)")
420
+ .addHelpText("after", "\nExamples:\n $ ish ask wait a-6ec\n $ ish ask wait a-6ec --round 2 --timeout 600")
421
+ .action(async (id, opts, cmd) => {
422
+ await withClient(cmd, async (client, globals) => {
423
+ const aid = resolveAsk(pickAskRef(id, opts.ask));
424
+ const initial = await client.get(`/asks/${aid}`);
425
+ if (!initial.rounds || initial.rounds.length === 0) {
426
+ throw new Error("Ask has no rounds to wait for.");
427
+ }
428
+ let targetIdx;
429
+ if (opts.round !== undefined) {
430
+ const n = parseInt(opts.round, 10);
431
+ if (Number.isNaN(n) || n <= 0)
432
+ throw new Error(`--round must be a positive integer.`);
433
+ targetIdx = n - 1;
434
+ }
435
+ else {
436
+ targetIdx = Math.max(...initial.rounds.map((r) => r.order_index));
437
+ }
438
+ const timeoutMs = parseWaitTimeout(opts.timeout);
439
+ const data = await pollUntilRoundDone(client, aid, targetIdx, timeoutMs, !!globals.quiet);
440
+ if (!globals.json || globals.verbose) {
441
+ formatAskDetail(data, globals.json);
442
+ return;
443
+ }
444
+ const round = data.rounds.find((r) => r.order_index === targetIdx);
445
+ const responses = round?.responses ?? [];
446
+ const completed = responses.filter((r) => r.status === "completed").length;
447
+ const errored = responses.filter((r) => r.status === "errored").length;
448
+ output({
449
+ id: data.id,
450
+ alias: tagAlias(ALIAS_PREFIX.ask, data.id),
451
+ name: data.name,
452
+ round: round
453
+ ? {
454
+ round_number: round.order_index + 1,
455
+ status: round.status,
456
+ completed_at: round.completed_at ?? null,
457
+ response_count: responses.length,
458
+ completed_count: completed,
459
+ errored_count: errored,
460
+ }
461
+ : null,
462
+ }, globals.json);
463
+ });
464
+ });
465
+ // ---- add-round ----------------------------------------------------------
466
+ ask
467
+ .command("add-round")
468
+ .description("Append a new round to an existing ask (max 5 per ask)")
469
+ .argument("[id]", "Ask alias or UUID (defaults to active ask)")
470
+ .option("--ask <id>", "Ask ID; alternative to positional argument")
471
+ .requiredOption("--prompt <prompt>", "Round prompt")
472
+ .option("--variant <kind:value>", "Variant flag (repeatable; same syntax as `ask create`)", collectRepeatable, [])
473
+ .option("--variants <file.json>", "JSON manifest of variants")
474
+ .option("--wants-pick", "Each tester picks a favourite variant (compatible with --wants-ratings; can be set together).")
475
+ .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"}]`)
477
+ .option("--wait", "Wait until the new round completes")
478
+ .option("--timeout <s>", "Wait timeout in seconds (default 300)")
479
+ .addHelpText("after", "\nExamples:\n $ ish ask add-round a-6ec --prompt \"And now?\" --variant text:\"Hello\" --variant text:\"Hi\" --wait")
480
+ .action(async (id, opts, cmd) => {
481
+ await withClient(cmd, async (client, globals) => {
482
+ const aid = resolveAsk(pickAskRef(id, opts.ask));
483
+ const ask = await client.get(`/asks/${aid}`);
484
+ const round = await buildRoundInput(client, ask.product_id, opts, !!globals.quiet);
485
+ const created = await client.post(`/asks/${aid}/rounds`, round);
486
+ if (opts.wait) {
487
+ const timeoutMs = parseWaitTimeout(opts.timeout);
488
+ await pollUntilRoundDone(client, aid, created.order_index, timeoutMs, !!globals.quiet);
489
+ const refreshed = await client.get(`/asks/${aid}`);
490
+ const target = refreshed.rounds.find((r) => r.id === created.id);
491
+ if (target) {
492
+ formatRoundDetail(target, globals.json);
493
+ return;
494
+ }
495
+ }
496
+ formatRoundDetail(created, globals.json);
497
+ });
498
+ });
499
+ // ---- add-questions ------------------------------------------------------
500
+ ask
501
+ .command("add-questions")
502
+ .description("Append questionnaire questions to a round (resets responses, re-dispatches)")
503
+ .argument("[id]", "Ask alias or UUID (defaults to active ask)")
504
+ .option("--ask <id>", "Ask ID; alternative to positional argument")
505
+ .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"}]`)
507
+ .addHelpText("after", `
508
+ Examples:
509
+ $ ish ask add-questions a-6ec --round 1 --questions ./qs.json
510
+
511
+ Minimal valid --questions JSON:
512
+ [
513
+ { "question": "What stood out?", "type": "open_ended" },
514
+ { "question": "Rate it 1-5", "type": "slider" }
515
+ ]
516
+
517
+ Note: the server requires the key "question" (not "text"). Valid type values:
518
+ open_ended, slider, choice, likert.`)
519
+ .action(async (id, opts, cmd) => {
520
+ await withClient(cmd, async (client, globals) => {
521
+ const aid = resolveAsk(pickAskRef(id, opts.ask));
522
+ const ask = await client.get(`/asks/${aid}`);
523
+ const round = getRoundByIndexOrId(ask, opts.round);
524
+ const questions = loadQuestionsManifest(opts.questions);
525
+ const body = { questions };
526
+ const updated = await client.post(`/asks/${aid}/rounds/${round.id}/questions`, body);
527
+ if (!globals.json || globals.verbose) {
528
+ formatRoundDetail(updated, globals.json);
529
+ return;
530
+ }
531
+ output({
532
+ id: aid,
533
+ alias: tagAlias(ALIAS_PREFIX.ask, aid),
534
+ round: {
535
+ round_number: updated.order_index + 1,
536
+ status: updated.status,
537
+ question_count: (updated.questions ?? []).length,
538
+ },
539
+ }, globals.json);
540
+ });
541
+ });
542
+ // ---- add-testers --------------------------------------------------------
543
+ const askAddTesters = ask
544
+ .command("add-testers")
545
+ .description("Add testers to an existing ask (optionally backfill prior rounds)")
546
+ .argument("[id]", "Ask alias or UUID (defaults to active ask)")
547
+ .option("--ask <id>", "Ask ID; alternative to positional argument")
548
+ .requiredOption("--round <n|round-id>", "Round to add testers to (required)");
549
+ addAudienceFilterFlags(askAddTesters, {
550
+ allFlagName: "--all-simulatable",
551
+ allFlagDescription: "Add every simulatable AI profile matching the filters not already in the audience",
552
+ })
553
+ .option("--backfill", "Backfill prior rounds in order for new testers")
554
+ .addHelpText("after", `
555
+ Examples:
556
+ $ ish ask add-testers a-6ec --round 1 --sample 5
557
+ $ ish ask add-testers a-6ec --round 1 --country GB --sample 3 --backfill
558
+ $ ish ask add-testers a-6ec --round 2 --profile tp-d4e,tp-a17 --backfill`)
559
+ .action(async (id, opts, cmd) => {
560
+ await withClient(cmd, async (client, globals) => {
561
+ const aid = resolveAsk(pickAskRef(id, opts.ask));
562
+ const askDetail = await client.get(`/asks/${aid}`);
563
+ const round = getRoundByIndexOrId(askDetail, opts.round);
564
+ const existingProfileIds = new Set((askDetail.testers ?? [])
565
+ .map((t) => (t.tester_profile_id ? String(t.tester_profile_id) : ""))
566
+ .filter(Boolean));
567
+ const candidateIds = await resolveAudienceProfileIds(client, askDetail.product_id, audienceFlags(opts), {
568
+ requireSimulatable: true,
569
+ allFlagName: "--all-simulatable",
570
+ excludeProfileIds: existingProfileIds,
571
+ });
572
+ const body = {
573
+ tester_profile_ids: candidateIds,
574
+ round_id: round.id,
575
+ backfill_prior_rounds: !!opts.backfill,
576
+ };
577
+ const data = await client.post(`/asks/${aid}/testers`, body);
578
+ if (!globals.json || globals.verbose) {
579
+ formatAskDetail(data, globals.json);
580
+ return;
581
+ }
582
+ const targetRound = data.rounds.find((r) => r.id === round.id);
583
+ const responses = targetRound?.responses ?? [];
584
+ output({
585
+ id: data.id,
586
+ alias: tagAlias(ALIAS_PREFIX.ask, data.id),
587
+ name: data.name,
588
+ round: {
589
+ round_number: (targetRound?.order_index ?? round.order_index) + 1,
590
+ tester_count: (data.testers ?? []).length,
591
+ response_count: responses.length,
592
+ new_tester_count: candidateIds.length,
593
+ },
594
+ }, globals.json);
595
+ });
596
+ });
597
+ // ---- update -------------------------------------------------------------
598
+ ask
599
+ .command("update")
600
+ .description("Update an ask's name or description")
601
+ .argument("[id]", "Ask alias or UUID (defaults to active ask)")
602
+ .option("--ask <id>", "Ask ID; alternative to positional argument")
603
+ .option("--name <name>", "New name")
604
+ .option("--description <description>", "New description")
605
+ .addHelpText("after", "\nExamples:\n $ ish ask update a-6ec --name \"renamed\"")
606
+ .action(async (id, opts, cmd) => {
607
+ await withClient(cmd, async (client, globals) => {
608
+ const aid = resolveAsk(pickAskRef(id, opts.ask));
609
+ const body = {};
610
+ if (opts.name !== undefined)
611
+ body.name = opts.name;
612
+ if (opts.description !== undefined)
613
+ body.description = opts.description;
614
+ if (Object.keys(body).length === 0) {
615
+ throw new Error("Pass --name or --description.");
616
+ }
617
+ const data = await client.patch(`/asks/${aid}`, body);
618
+ if (!globals.json || globals.verbose) {
619
+ formatAskDetail(data, globals.json);
620
+ return;
621
+ }
622
+ output({
623
+ id: data.id,
624
+ alias: tagAlias(ALIAS_PREFIX.ask, data.id),
625
+ name: data.name,
626
+ description: data.description ?? null,
627
+ updated_at: data.updated_at,
628
+ }, globals.json);
629
+ });
630
+ });
631
+ // ---- archive / unarchive ------------------------------------------------
632
+ ask
633
+ .command("archive")
634
+ .description("Archive an ask (hides it from default list)")
635
+ .argument("[id]", "Ask alias or UUID (defaults to active ask)")
636
+ .option("--ask <id>", "Ask ID; alternative to positional argument")
637
+ .action(async (id, opts, cmd) => {
638
+ await withClient(cmd, async (client, globals) => {
639
+ const aid = resolveAsk(pickAskRef(id, opts.ask));
640
+ const data = await client.patch(`/asks/${aid}`, { is_archived: true });
641
+ if (!globals.json || globals.verbose) {
642
+ formatAskDetail(data, globals.json);
643
+ return;
644
+ }
645
+ output({
646
+ id: data.id,
647
+ alias: tagAlias(ALIAS_PREFIX.ask, data.id),
648
+ name: data.name,
649
+ is_archived: data.is_archived,
650
+ updated_at: data.updated_at,
651
+ }, globals.json);
652
+ });
653
+ });
654
+ ask
655
+ .command("unarchive")
656
+ .description("Restore an archived ask")
657
+ .argument("[id]", "Ask alias or UUID (defaults to active ask)")
658
+ .option("--ask <id>", "Ask ID; alternative to positional argument")
659
+ .action(async (id, opts, cmd) => {
660
+ await withClient(cmd, async (client, globals) => {
661
+ const aid = resolveAsk(pickAskRef(id, opts.ask));
662
+ const data = await client.patch(`/asks/${aid}`, { is_archived: false });
663
+ if (!globals.json || globals.verbose) {
664
+ formatAskDetail(data, globals.json);
665
+ return;
666
+ }
667
+ output({
668
+ id: data.id,
669
+ alias: tagAlias(ALIAS_PREFIX.ask, data.id),
670
+ name: data.name,
671
+ is_archived: data.is_archived,
672
+ updated_at: data.updated_at,
673
+ }, globals.json);
674
+ });
675
+ });
676
+ // ---- delete -------------------------------------------------------------
677
+ ask
678
+ .command("delete")
679
+ .description("Delete an ask and all its rounds, responses, and audience")
680
+ .argument("[id]", "Ask alias or UUID (defaults to active ask)")
681
+ .option("--ask <id>", "Ask ID; alternative to positional argument")
682
+ .addHelpText("after", "\nExamples:\n $ ish ask delete a-6ec")
683
+ .action(async (id, opts, cmd) => {
684
+ await withClient(cmd, async (client, globals) => {
685
+ const aid = resolveAsk(pickAskRef(id, opts.ask));
686
+ await client.del(`/asks/${aid}`);
687
+ // If we just deleted the active ask, clear it from config.
688
+ const config = loadConfig();
689
+ if (config.ask === aid) {
690
+ delete config.ask;
691
+ saveConfig(config);
692
+ }
693
+ output({ message: "Ask deleted" }, globals.json);
694
+ });
695
+ });
696
+ // ---- use ----------------------------------------------------------------
697
+ ask
698
+ .command("use")
699
+ .description("Set the active ask (saved to ~/.ish/config.json)")
700
+ .argument("[id]", "Ask alias or UUID")
701
+ .option("--clear", "Remove the active ask from config")
702
+ .addHelpText("after", "\nExamples:\n $ ish ask use a-6ec\n $ ish ask use --clear")
703
+ .action(async (id, opts, cmd) => {
704
+ if (opts.clear) {
705
+ const config = loadConfig();
706
+ delete config.ask;
707
+ saveConfig(config);
708
+ process.stderr.write("Cleared active ask.\n");
709
+ return;
710
+ }
711
+ if (!id)
712
+ throw new Error("Provide an ask alias or UUID, or use --clear.");
713
+ await withClient(cmd, async (client) => {
714
+ const rid = resolveId(id);
715
+ const data = await client.get(`/asks/${rid}`);
716
+ const config = loadConfig();
717
+ config.ask = rid;
718
+ saveConfig(config);
719
+ process.stderr.write(`Active ask set to "${data.name || rid}".\n`);
720
+ });
721
+ });
722
+ }