@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,149 @@
1
+ /**
2
+ * ish study tester — Inspect and manage testers (low-level; usually
3
+ * created via `ish study run`).
4
+ *
5
+ * Default action: `ish study tester <id>` shows tester details and results.
6
+ */
7
+ import { withClient, readJsonFileOrStdin } from "../lib/command-helpers.js";
8
+ import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
9
+ import { formatTesterDetail, output } from "../lib/output.js";
10
+ /** Pick the latest iteration on a study (highest order_index, falling back to last). */
11
+ async function latestIterationForStudy(client, studyId) {
12
+ const study = await client.get(`/studies/${studyId}`);
13
+ const iterations = study.iterations ?? [];
14
+ if (iterations.length === 0) {
15
+ throw new Error(`Study ${studyId} has no iterations. Create one first with \`ish iteration create\`.`);
16
+ }
17
+ const sorted = [...iterations].sort((a, b) => (b.order_index ?? 0) - (a.order_index ?? 0));
18
+ return sorted[0].id;
19
+ }
20
+ /**
21
+ * Walk a parsed JSON body and resolve any alias-shaped strings (tp-..., i-..., s-...)
22
+ * appearing in well-known id fields. Pure client-side: avoids a backend round-trip
23
+ * when the body uses local aliases that the backend wouldn't recognise.
24
+ */
25
+ const ALIAS_FIELDS = new Set(["tester_profile_id", "iteration_id", "study_id"]);
26
+ const ALIAS_RE = /^(tp|i|s)-[0-9a-f]{3,}$/i;
27
+ function prewalkAliases(value) {
28
+ if (Array.isArray(value)) {
29
+ return value.map(prewalkAliases);
30
+ }
31
+ if (value && typeof value === "object") {
32
+ const out = {};
33
+ for (const [k, v] of Object.entries(value)) {
34
+ if (ALIAS_FIELDS.has(k) && typeof v === "string" && ALIAS_RE.test(v)) {
35
+ try {
36
+ out[k] = resolveId(v);
37
+ }
38
+ catch {
39
+ out[k] = v;
40
+ }
41
+ }
42
+ else {
43
+ out[k] = prewalkAliases(v);
44
+ }
45
+ }
46
+ return out;
47
+ }
48
+ return value;
49
+ }
50
+ export function attachStudyTesterCommands(study) {
51
+ const tester = study
52
+ .command("tester")
53
+ .description("Inspect or manage testers (low-level; usually created via `study run`)")
54
+ .argument("[id]", "Tester ID — pass directly to view tester details and results")
55
+ .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
+ if (!id) {
58
+ cmd.help();
59
+ }
60
+ await withClient(cmd, async (client, globals) => {
61
+ const data = await client.get(`/testers/${resolveId(id)}`);
62
+ const result = data;
63
+ if (result.id)
64
+ result.alias = tagAlias(ALIAS_PREFIX.tester, String(result.id));
65
+ formatTesterDetail(result, globals.json);
66
+ });
67
+ });
68
+ tester
69
+ .command("create")
70
+ .description("Create a tester (low-level)")
71
+ .option("--iteration <id>", "Iteration ID (or use --study to pick the latest iteration on a study)")
72
+ .option("--study <id>", "Study ID; resolves to the latest iteration on that study (alternative to --iteration)")
73
+ .requiredOption("--profile <id>", "Tester profile ID")
74
+ .option("--language <lang>", "Language code (e.g. en, sv)")
75
+ .option("--platform <platform>", "Platform (browser, android, figma, code)")
76
+ .option("--tester-type <type>", "Tester type (ai, human)", "ai")
77
+ .addHelpText("after", "\nExamples:\n $ ish study tester create --iteration <id> --profile <id>\n $ ish study tester create --study s-XXX --profile tp-XXX\n $ ish study tester create --iteration <id> --profile <id> --platform android --json")
78
+ .action(async (opts, cmd) => {
79
+ await withClient(cmd, async (client, globals) => {
80
+ if (!opts.iteration && !opts.study) {
81
+ throw new Error("Provide --iteration <id> or --study <id> (the latest iteration on that study will be used).");
82
+ }
83
+ if (opts.iteration && opts.study) {
84
+ throw new Error("Pass either --iteration or --study, not both.");
85
+ }
86
+ const iterationId = opts.iteration
87
+ ? resolveId(opts.iteration)
88
+ : await latestIterationForStudy(client, resolveId(opts.study));
89
+ const body = {
90
+ tester_profile_id: resolveId(opts.profile),
91
+ tester_type: opts.testerType,
92
+ ...(opts.language && { language: opts.language }),
93
+ ...(opts.platform && { platform: opts.platform }),
94
+ };
95
+ const data = await client.post(`/iterations/${iterationId}/testers`, body);
96
+ const result = data;
97
+ if (result.id)
98
+ result.alias = tagAlias(ALIAS_PREFIX.tester, String(result.id));
99
+ output(result, globals.json);
100
+ });
101
+ });
102
+ tester
103
+ .command("batch-create")
104
+ .description("Create multiple testers (low-level)")
105
+ .requiredOption("--iteration <id>", "Iteration ID")
106
+ .option("--file <path>", "JSON file with testers array (alternative to --profiles)")
107
+ .option("--profiles <ids>", "Comma-separated profile IDs/aliases; shortcut for --file with one tester per profile (mutually exclusive with --file)")
108
+ .addHelpText("after", "\nExamples:\n $ ish study tester batch-create --iteration <id> --file testers.json\n $ ish study tester batch-create --iteration <id> --profiles tp-eba,tp-289,tp-913\n\n Expected JSON: [{ \"tester_profile_id\": \"<id>\", \"platform\": \"browser\" }, ...]\n Profile-id strings inside --file (tester_profile_id, iteration_id, study_id) are resolved client-side via the local alias store before submitting.")
109
+ .action(async (opts, cmd) => {
110
+ await withClient(cmd, async (client, globals) => {
111
+ if (!opts.file && !opts.profiles) {
112
+ throw new Error("Provide --file <path> or --profiles <ids>.");
113
+ }
114
+ if (opts.file && opts.profiles) {
115
+ throw new Error("Pass either --file or --profiles, not both.");
116
+ }
117
+ const iterationId = resolveId(opts.iteration);
118
+ let body;
119
+ if (opts.profiles) {
120
+ const ids = opts.profiles
121
+ .split(",")
122
+ .map((s) => s.trim())
123
+ .filter(Boolean)
124
+ .map((s) => resolveId(s));
125
+ if (ids.length === 0) {
126
+ throw new Error("--profiles must contain at least one profile id/alias.");
127
+ }
128
+ body = { testers: ids.map((pid) => ({ tester_profile_id: pid, tester_type: "ai" })) };
129
+ }
130
+ else {
131
+ const parsed = await readJsonFileOrStdin(opts.file);
132
+ body = prewalkAliases(parsed);
133
+ }
134
+ const data = await client.post(`/iterations/${iterationId}/testers/batch`, body);
135
+ output(data, globals.json);
136
+ });
137
+ });
138
+ tester
139
+ .command("delete")
140
+ .description("Delete a tester")
141
+ .argument("<id>", "Tester ID")
142
+ .addHelpText("after", "\nExamples:\n $ ish study tester delete <id>")
143
+ .action(async (id, _opts, cmd) => {
144
+ await withClient(cmd, async (client, globals) => {
145
+ await client.del(`/testers/${resolveId(id)}`);
146
+ output({ message: "Tester deleted" }, globals.json);
147
+ });
148
+ });
149
+ }
@@ -6,10 +6,63 @@ import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
6
6
  import { loadConfig, saveConfig } from "../config.js";
7
7
  import { formatStudyList, formatStudyDetail, formatStudyResults, output, ValidationError } from "../lib/output.js";
8
8
  import { VALID_CONTENT_TYPES } from "../lib/types.js";
9
+ import { parseAssignment, loadAssignmentsFile, parseQuestion } from "../lib/study-inputs.js";
10
+ import { loadQuestionsManifest } from "../lib/ask-questions.js";
11
+ import { attachStudyRunCommands } from "./study-run.js";
12
+ import { attachStudyTesterCommands } from "./study-tester.js";
13
+ function collectRepeatable(value, prev = []) {
14
+ return prev.concat([value]);
15
+ }
16
+ function resolveAssignments(opts) {
17
+ const sources = [];
18
+ if (opts.assignment && opts.assignment.length > 0)
19
+ sources.push("--assignment");
20
+ if (opts.assignmentsFile)
21
+ sources.push("--assignments-file");
22
+ if (opts.assignments)
23
+ sources.push("--assignments");
24
+ if (sources.length > 1) {
25
+ throw new Error(`Pass only one of: ${sources.join(", ")}.`);
26
+ }
27
+ if (opts.assignment && opts.assignment.length > 0) {
28
+ return opts.assignment.map(parseAssignment);
29
+ }
30
+ if (opts.assignmentsFile) {
31
+ return loadAssignmentsFile(opts.assignmentsFile);
32
+ }
33
+ if (opts.assignments) {
34
+ try {
35
+ return JSON.parse(opts.assignments);
36
+ }
37
+ catch {
38
+ throw new Error("Invalid --assignments JSON");
39
+ }
40
+ }
41
+ return undefined;
42
+ }
43
+ function resolveQuestionnaire(opts) {
44
+ if (opts.question && opts.question.length > 0 && opts.questionnaire) {
45
+ throw new Error("Pass only one of: --question, --questionnaire.");
46
+ }
47
+ if (opts.question && opts.question.length > 0) {
48
+ return opts.question.map(parseQuestion);
49
+ }
50
+ if (opts.questionnaire) {
51
+ return loadQuestionsManifest(opts.questionnaire);
52
+ }
53
+ return undefined;
54
+ }
9
55
  export function registerStudyCommands(program) {
10
56
  const study = program
11
57
  .command("study")
12
- .description("Manage studies");
58
+ .description("Manage and run studies")
59
+ .addHelpText("after", `
60
+ A study is the persistent research artifact: modality + assignments + questionnaire.
61
+ Iterations carry the URL or media. \`ish study run\` dispatches simulations on the latest iteration.
62
+
63
+ Concept pages: ish docs get-page concepts/study
64
+ ish docs get-page concepts/run-verbs
65
+ ish docs get-page concepts/assignment`);
13
66
  study
14
67
  .command("list")
15
68
  .description("List studies for a workspace")
@@ -23,69 +76,68 @@ export function registerStudyCommands(program) {
23
76
  });
24
77
  study
25
78
  .command("create")
26
- .description("Create a new study")
79
+ .description("Create a new study (the persistent shape: modality, tasks, questionnaire)")
27
80
  .option("--workspace <id>", "Workspace ID")
28
81
  .requiredOption("--name <name>", "Study name")
29
82
  .option("--description <description>", "Study description")
30
83
  .option("--modality <modality>", "Study modality (interactive, video, audio, text, image, document)")
31
84
  .option("--content-type <type>", "Content type (varies by modality — see examples below)")
32
- .option("--assignments <json>", "JSON array of assignments, e.g. '[{\"name\":\"Task\",\"instructions\":\"Do something\"}]'")
33
- .option("--questions <json>", "JSON array of interview questions, e.g. '[{\"question\":\"How was it?\",\"type\":\"text\",\"timing\":\"after\"}]'")
85
+ .option("--assignment <name:instructions>", "Assignment as 'Name:Instructions' (repeatable)", collectRepeatable, [])
86
+ .option("--assignments-file <path>", "JSON file with assignments array")
87
+ .option("--assignments <json>", "Inline JSON array of assignments (escape hatch)")
88
+ .option("--question <text>", "Add a text question to the questionnaire (repeatable; type=text, timing=after)", collectRepeatable, [])
89
+ .option("--questionnaire <path>", "JSON file defining the questionnaire (supports text, slider, likert, single-choice, multiple-choice, number; timing=before|after)")
34
90
  .addHelpText("after", `
35
91
  Note: --workspace is optional if set via \`ish workspace use <alias>\`.
36
92
 
93
+ The questionnaire is the set of questions testers answer. Use \`--question\` to
94
+ quickly add simple text questions, or \`--questionnaire <file.json>\` for richer
95
+ types (slider, likert, choice) and custom timing. The two forms are mutually
96
+ exclusive — pick one.
97
+
37
98
  Examples:
38
- # Interactive study:
99
+ # Interactive study with one assignment and a single-question questionnaire:
39
100
  $ ish study create --name "Onboarding UX" --modality interactive \\
40
- --assignments '[{"name":"Sign up","instructions":"Complete the signup flow"}]'
101
+ --assignment "Sign up:Complete the signup flow" \\
102
+ --question "How easy was it?"
41
103
 
42
- # Text/email study:
43
- $ ish study create --name "Newsletter" --modality text --content-type email \\
44
- --assignments '[{"name":"Read","instructions":"Read this email naturally"}]'
104
+ # Multiple assignments + a richer questionnaire from a file:
105
+ $ ish study create --name "Checkout" --modality interactive \\
106
+ --assignment "Browse:Find a product you like" \\
107
+ --assignment "Buy:Add to cart and checkout" \\
108
+ --questionnaire ./questionnaire.json
45
109
 
46
- # Audio conversation study:
47
- $ ish study create --name "Episode Review" --modality audio --content-type conversation \\
48
- --assignments '[{"name":"Listen","instructions":"Listen and react naturally"}]'
110
+ # Text/email study, all assignments from a file:
111
+ $ ish study create --name "Newsletter" --modality text --content-type email \\
112
+ --assignments-file ./assignments.json
49
113
 
50
- # Video ad study:
114
+ # Video ad study with a quick two-question questionnaire:
51
115
  $ ish study create --name "Product Ad" --modality video --content-type ad \\
52
- --assignments '[{"name":"Watch","instructions":"Watch this ad and share your reaction"}]'
53
-
54
- # Image social post study:
55
- $ ish study create --name "Instagram Post" --modality image --content-type social_post \\
56
- --assignments '[{"name":"View","instructions":"Look at this post naturally"}]'
116
+ --assignment "Watch:Watch this ad and share your reaction" \\
117
+ --question "What stuck with you?" \\
118
+ --question "Would you click through?"
57
119
 
58
- # With interview questions:
59
- $ ish study create --name "Checkout" --modality interactive \\
60
- --assignments '[{"name":"Buy","instructions":"Add to cart and checkout"}]' \\
61
- --questions '[{"question":"How easy was it?","type":"slider","timing":"after","min":0,"max":10}]'
120
+ # Sample questionnaire.json (full InterviewQuestion shape):
121
+ # [
122
+ # { "question": "How easy?", "type": "slider", "timing": "after",
123
+ # "min": 0, "max": 10 },
124
+ # { "question": "Which fits best?", "type": "single-choice",
125
+ # "options": ["A","B","C"] }
126
+ # ]
62
127
 
63
128
  Content types by modality:
64
129
  text: narrative, informational, commercial, editorial, reference, email, news
65
130
  video: tutorial, documentary, entertainment, review, lifestyle, news, social_post, ad
66
131
  audio: music, narration, conversation, speech, soundscape, news, ad
67
132
  image: product, photography, infographic, artwork, interface, social_post, ad
68
- document: deck, presentation, report, brochure, guide`)
133
+ document: deck, presentation, report, brochure, guide
134
+
135
+ Next: configure a run with \`ish iteration create --study <id>\`,
136
+ then dispatch with \`ish study run\`.`)
69
137
  .action(async (opts, cmd) => {
70
138
  await withClient(cmd, async (client, globals) => {
71
- let assignments;
72
- let interviewQuestions;
73
- if (opts.assignments) {
74
- try {
75
- assignments = JSON.parse(opts.assignments);
76
- }
77
- catch {
78
- throw new Error("Invalid --assignments JSON");
79
- }
80
- }
81
- if (opts.questions) {
82
- try {
83
- interviewQuestions = JSON.parse(opts.questions);
84
- }
85
- catch {
86
- throw new Error("Invalid --questions JSON");
87
- }
88
- }
139
+ const assignments = resolveAssignments(opts);
140
+ const interviewQuestions = resolveQuestionnaire(opts);
89
141
  // Validate content_type against modality
90
142
  if (opts.contentType && opts.modality) {
91
143
  const validTypes = VALID_CONTENT_TYPES[opts.modality];
@@ -112,7 +164,7 @@ Content types by modality:
112
164
  const result = data;
113
165
  if (result.id)
114
166
  result.alias = tagAlias(ALIAS_PREFIX.study, String(result.id));
115
- formatStudyDetail(result, globals.json);
167
+ formatStudyDetail(result, globals.json, { writePath: true });
116
168
  if (!globals.json && data.id) {
117
169
  const url = getWebUrl(globals, `/${resolvedWs}/${data.id}/overview`);
118
170
  console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
@@ -133,11 +185,11 @@ Content types by modality:
133
185
  ...(opts.targetUrl && { target_url: opts.targetUrl }),
134
186
  };
135
187
  const resolvedWs = resolveWorkspace(opts.workspace);
136
- const data = await client.post(`/products/${resolvedWs}/studies/generate`, body);
188
+ const data = await client.post(`/products/${resolvedWs}/studies/generate`, body, { timeout: 120_000 });
137
189
  const result = data;
138
190
  if (result.id)
139
191
  result.alias = tagAlias(ALIAS_PREFIX.study, String(result.id));
140
- formatStudyDetail(result, globals.json);
192
+ formatStudyDetail(result, globals.json, { writePath: true });
141
193
  if (!globals.json && data.id) {
142
194
  const url = getWebUrl(globals, `/${resolvedWs}/${data.id}/overview`);
143
195
  console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
@@ -165,9 +217,31 @@ Content types by modality:
165
217
  });
166
218
  study
167
219
  .command("results")
168
- .description("View aggregated results (sentiment, interview answers)")
220
+ .description("View aggregated results: tester counts, sentiment, interview answers. Returns a stable envelope with empty fields when no runs have completed.")
169
221
  .argument("<id>", "Study ID")
170
- .addHelpText("after", "\nExamples:\n $ ish study results <id>\n $ ish study results <id> --json")
222
+ .addHelpText("after", `
223
+ Examples:
224
+ $ ish study results <id>
225
+ $ ish study results <id> --json
226
+
227
+ Example response (--json):
228
+ {
229
+ "study_id": "<uuid>",
230
+ "alias": "s-...",
231
+ "name": "...",
232
+ "tester_count": 12,
233
+ "completed_count": 8,
234
+ "errored_count": 0,
235
+ "total_interactions": 142,
236
+ "sentiment": { "Satisfied": 5, "Frustrated": 2, "Neutral": 1 },
237
+ "interview_answers": [
238
+ { "question_id": "...", "question": "...", "type": "text",
239
+ "answers": [ { "tester_id": "...", "tester_alias": "t-...", "answer": "..." } ] }
240
+ ],
241
+ "testers": [ { "id": "...", "alias": "t-...", "name": "...", "status": "completed", "interaction_count": 12 } ]
242
+ }
243
+
244
+ When no runs have completed, the same envelope is returned with zero counts and empty arrays.`)
171
245
  .action(async (id, _opts, cmd) => {
172
246
  await withClient(cmd, async (client, globals) => {
173
247
  const rid = resolveId(id);
@@ -188,29 +262,27 @@ Content types by modality:
188
262
  .option("--status <status>", "Study status (draft, running, completed)")
189
263
  .option("--modality <modality>", "Study modality (interactive, video, audio, text, image, document)")
190
264
  .option("--content-type <type>", "Content type")
191
- .option("--assignments <json>", "JSON array of assignments, e.g. '[{\"name\":\"Task\",\"instructions\":\"Do something\"}]'")
192
- .option("--questions <json>", "JSON array of interview questions")
193
- .addHelpText("after", "\nExamples:\n $ ish study update <id> --name \"Updated Name\"\n $ ish study update <id> --status running --json")
265
+ .option("--assignment <name:instructions>", "Replace all assignments with these (repeatable)", collectRepeatable, [])
266
+ .option("--assignments-file <path>", "JSON file with assignments array")
267
+ .option("--assignments <json>", "Inline JSON array of assignments (escape hatch)")
268
+ .option("--question <text>", "Replace the questionnaire with these text questions (repeatable)", collectRepeatable, [])
269
+ .option("--questionnaire <path>", "Replace the questionnaire from a JSON file (full InterviewQuestion shape)")
270
+ .addHelpText("after", `
271
+ Replacing the questionnaire: pass either \`--question\` (one or more text
272
+ questions) or \`--questionnaire <file.json>\` (full shape: slider, likert,
273
+ choice, custom timing). The two are mutually exclusive.
274
+
275
+ Examples:
276
+ $ ish study update <id> --name "Updated Name"
277
+ $ ish study update <id> --status running --json
278
+ $ ish study update <id> --assignment "Sign up:Complete the signup flow" \\
279
+ --question "How easy was it?"
280
+ $ ish study update <id> --questionnaire ./questionnaire.json
281
+ $ ish study update <id> --assignments-file ./assignments.json`)
194
282
  .action(async (id, opts, cmd) => {
195
283
  await withClient(cmd, async (client, globals) => {
196
- let assignments;
197
- let interviewQuestions;
198
- if (opts.assignments) {
199
- try {
200
- assignments = JSON.parse(opts.assignments);
201
- }
202
- catch {
203
- throw new Error("Invalid --assignments JSON");
204
- }
205
- }
206
- if (opts.questions) {
207
- try {
208
- interviewQuestions = JSON.parse(opts.questions);
209
- }
210
- catch {
211
- throw new Error("Invalid --questions JSON");
212
- }
213
- }
284
+ const assignments = resolveAssignments(opts);
285
+ const interviewQuestions = resolveQuestionnaire(opts);
214
286
  if (opts.contentType && opts.modality) {
215
287
  const validTypes = VALID_CONTENT_TYPES[opts.modality];
216
288
  if (validTypes && !validTypes.includes(opts.contentType)) {
@@ -240,7 +312,7 @@ Content types by modality:
240
312
  const result = data;
241
313
  if (result.id)
242
314
  result.alias = tagAlias(ALIAS_PREFIX.study, String(result.id));
243
- formatStudyDetail(result, globals.json);
315
+ formatStudyDetail(result, globals.json, { writePath: true });
244
316
  });
245
317
  });
246
318
  study
@@ -250,8 +322,9 @@ Content types by modality:
250
322
  .addHelpText("after", "\nExamples:\n $ ish study delete <id>")
251
323
  .action(async (id, _opts, cmd) => {
252
324
  await withClient(cmd, async (client, globals) => {
253
- await client.del(`/studies/${resolveId(id)}`);
254
- output({ message: "Study deleted" }, globals.json);
325
+ const rid = resolveId(id);
326
+ await client.del(`/studies/${rid}`);
327
+ output({ id: rid, alias: tagAlias(ALIAS_PREFIX.study, rid), message: "Study deleted" }, globals.json, { writePath: true });
255
328
  });
256
329
  });
257
330
  study
@@ -280,4 +353,6 @@ Content types by modality:
280
353
  console.error(`Active study set to "${data.name || rid}".`);
281
354
  });
282
355
  });
356
+ attachStudyRunCommands(study);
357
+ attachStudyTesterCommands(study);
283
358
  }