speqs 0.4.0 → 0.5.1

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.
@@ -0,0 +1,5 @@
1
+ /**
2
+ * speqs config — Manage simulation configs.
3
+ */
4
+ import type { Command } from "commander";
5
+ export declare function registerConfigCommands(program: Command): void;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * speqs config — Manage simulation configs.
3
+ */
4
+ import { withClient, readJsonFileOrStdin } from "../lib/command-helpers.js";
5
+ import { resolveId } from "../lib/alias-store.js";
6
+ import { output, formatConfigList } from "../lib/output.js";
7
+ export function registerConfigCommands(program) {
8
+ const config = program
9
+ .command("config")
10
+ .description("Manage simulation configs");
11
+ config
12
+ .command("list")
13
+ .description("List all simulation configs")
14
+ .addHelpText("after", "\nExamples:\n $ speqs config list\n $ speqs config list --json")
15
+ .action(async (_opts, cmd) => {
16
+ await withClient(cmd, async (client, globals) => {
17
+ const data = await client.get("/dev/simulation-configs");
18
+ formatConfigList(data, globals.json);
19
+ });
20
+ });
21
+ config
22
+ .command("create")
23
+ .description("Create a simulation config")
24
+ .requiredOption("--file <path>", "JSON file with config data")
25
+ .action(async (opts, cmd) => {
26
+ await withClient(cmd, async (client, globals) => {
27
+ const body = await readJsonFileOrStdin(opts.file);
28
+ const data = await client.post("/dev/simulation-configs", body);
29
+ output(data, globals.json);
30
+ });
31
+ });
32
+ config
33
+ .command("get")
34
+ .description("Get simulation config details")
35
+ .argument("<id>", "Config ID")
36
+ .action(async (id, _opts, cmd) => {
37
+ await withClient(cmd, async (client, globals) => {
38
+ const data = await client.get(`/dev/simulation-configs/${resolveId(id)}`);
39
+ output(data, globals.json);
40
+ });
41
+ });
42
+ config
43
+ .command("schema")
44
+ .description("Get simulation config schema with defaults")
45
+ .action(async (_opts, cmd) => {
46
+ await withClient(cmd, async (client, globals) => {
47
+ const data = await client.get("/dev/simulation-configs/schema");
48
+ output(data, globals.json);
49
+ });
50
+ });
51
+ config
52
+ .command("update")
53
+ .description("Update a simulation config")
54
+ .argument("<id>", "Config ID")
55
+ .requiredOption("--file <path>", "JSON file with update data")
56
+ .action(async (id, opts, cmd) => {
57
+ await withClient(cmd, async (client, globals) => {
58
+ const body = await readJsonFileOrStdin(opts.file);
59
+ const data = await client.put(`/dev/simulation-configs/${resolveId(id)}`, body);
60
+ output(data, globals.json);
61
+ });
62
+ });
63
+ config
64
+ .command("delete")
65
+ .description("Delete a simulation config")
66
+ .argument("<id>", "Config ID")
67
+ .action(async (id, _opts, cmd) => {
68
+ await withClient(cmd, async (client, globals) => {
69
+ await client.del(`/dev/simulation-configs/${resolveId(id)}`);
70
+ output({ message: "Config deleted" }, globals.json);
71
+ });
72
+ });
73
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * speqs iteration — Manage iterations (usually created via `simulation run`).
3
+ */
4
+ import type { Command } from "commander";
5
+ export declare function registerIterationCommands(program: Command): void;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * speqs iteration — Manage iterations (usually created via `simulation run`).
3
+ */
4
+ import { withClient } from "../lib/command-helpers.js";
5
+ import { resolveId } from "../lib/alias-store.js";
6
+ import { output, formatIterationList } from "../lib/output.js";
7
+ export function registerIterationCommands(program) {
8
+ const iteration = program
9
+ .command("iteration")
10
+ .description("Manage iterations (usually created via `simulation run`)");
11
+ iteration
12
+ .command("list")
13
+ .description("List iterations for a study")
14
+ .requiredOption("--study <id>", "Study ID")
15
+ .addHelpText("after", "\nExamples:\n $ speqs iteration list --study <id>\n $ speqs iteration list --study <id> --json")
16
+ .action(async (opts, cmd) => {
17
+ await withClient(cmd, async (client, globals) => {
18
+ const data = await client.get(`/studies/${resolveId(opts.study)}/iterations`);
19
+ formatIterationList(data, globals.json);
20
+ });
21
+ });
22
+ iteration
23
+ .command("create")
24
+ .description("Create a new iteration (low-level)")
25
+ .requiredOption("--study <id>", "Study ID")
26
+ .requiredOption("--name <name>", "Iteration name")
27
+ .option("--description <description>", "Iteration description")
28
+ .option("--details-json <json>", "Iteration details as JSON string")
29
+ .action(async (opts, cmd) => {
30
+ await withClient(cmd, async (client, globals) => {
31
+ const body = {
32
+ name: opts.name,
33
+ ...(opts.description && { description: opts.description }),
34
+ ...(opts.detailsJson && { details: JSON.parse(opts.detailsJson) }),
35
+ };
36
+ const data = await client.post(`/studies/${resolveId(opts.study)}/iterations`, body);
37
+ output(data, globals.json);
38
+ });
39
+ });
40
+ iteration
41
+ .command("get")
42
+ .description("Get iteration details")
43
+ .argument("<id>", "Iteration ID")
44
+ .action(async (id, _opts, cmd) => {
45
+ await withClient(cmd, async (client, globals) => {
46
+ const data = await client.get(`/iterations/${resolveId(id)}`);
47
+ output(data, globals.json);
48
+ });
49
+ });
50
+ iteration
51
+ .command("update")
52
+ .description("Update an iteration")
53
+ .argument("<id>", "Iteration ID")
54
+ .option("--name <name>", "Iteration name")
55
+ .option("--description <description>", "Iteration description")
56
+ .option("--details-json <json>", "Iteration details as JSON string")
57
+ .option("--label <label>", "Iteration label (uppercase letters)")
58
+ .action(async (id, opts, cmd) => {
59
+ await withClient(cmd, async (client, globals) => {
60
+ const body = {};
61
+ if (opts.name !== undefined)
62
+ body.name = opts.name;
63
+ if (opts.description !== undefined)
64
+ body.description = opts.description;
65
+ if (opts.detailsJson !== undefined)
66
+ body.details = JSON.parse(opts.detailsJson);
67
+ if (opts.label !== undefined)
68
+ body.label = opts.label;
69
+ const data = await client.put(`/iterations/${resolveId(id)}`, body);
70
+ output(data, globals.json);
71
+ });
72
+ });
73
+ iteration
74
+ .command("delete")
75
+ .description("Delete an iteration")
76
+ .argument("<id>", "Iteration ID")
77
+ .action(async (id, _opts, cmd) => {
78
+ await withClient(cmd, async (client, globals) => {
79
+ await client.del(`/iterations/${resolveId(id)}`);
80
+ output({ message: "Iteration deleted" }, globals.json);
81
+ });
82
+ });
83
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * speqs simulation — Run, monitor, and cancel simulations.
3
+ *
4
+ * Primary command: `speqs simulation run` — orchestrates the full flow:
5
+ * 1. Creates iteration (if not provided)
6
+ * 2. Creates testers from profiles
7
+ * 3. Starts simulations
8
+ */
9
+ import type { Command } from "commander";
10
+ export declare function registerSimulationCommands(program: Command): void;
@@ -0,0 +1,314 @@
1
+ /**
2
+ * speqs simulation — Run, monitor, and cancel simulations.
3
+ *
4
+ * Primary command: `speqs simulation run` — orchestrates the full flow:
5
+ * 1. Creates iteration (if not provided)
6
+ * 2. Creates testers from profiles
7
+ * 3. Starts simulations
8
+ */
9
+ import * as readline from "node:readline/promises";
10
+ import { withClient, getWebUrl, terminalLink } from "../lib/command-helpers.js";
11
+ import { resolveId } from "../lib/alias-store.js";
12
+ import { output, outputError, formatSimulationPoll } from "../lib/output.js";
13
+ function parseMaxInteractions(value) {
14
+ const n = parseInt(value, 10);
15
+ if (isNaN(n) || n < 1)
16
+ throw new Error(`Invalid --max-interactions value: ${value}`);
17
+ return n;
18
+ }
19
+ export function registerSimulationCommands(program) {
20
+ const sim = program
21
+ .command("simulation")
22
+ .alias("sim")
23
+ .description("Run and monitor simulations");
24
+ // --- Primary: `simulation run` ---
25
+ sim
26
+ .command("run")
27
+ .description("Run simulations (creates iteration + testers + starts simulations)")
28
+ .requiredOption("--workspace <id>", "Workspace ID")
29
+ .requiredOption("--study <id>", "Study ID")
30
+ .option("--profiles <ids>", "Comma-separated tester profile IDs (auto-selected from last iteration if omitted)")
31
+ .option("--iteration <id>", "Use existing iteration (skip creation)")
32
+ .option("--iteration-name <name>", "Name for new iteration (forces creating a new iteration)")
33
+ .option("--platform <platform>", "Platform (browser, android, figma, code)")
34
+ .option("--url <url>", "URL to test")
35
+ .option("--screen-format <format>", "Screen format (mobile_portrait, desktop)")
36
+ .option("--max-interactions <n>", "Max interactions per tester")
37
+ .option("--language <lang>", "Language code (e.g. en, sv)")
38
+ .option("--locale <locale>", "Locale code (e.g. en-US)")
39
+ .option("-y, --yes", "Skip confirmation prompt")
40
+ .addHelpText("after", `
41
+ Examples:
42
+ # Re-run with same settings and profiles as last time:
43
+ $ speqs sim run --workspace W --study S
44
+
45
+ # Re-run with different profiles:
46
+ $ speqs sim run --workspace W --study S --profiles p1,p2
47
+
48
+ # Force a new iteration:
49
+ $ speqs sim run --workspace W --study S --iteration-name "Round 2"
50
+
51
+ # First run (no previous iteration):
52
+ $ speqs sim run --workspace W --study S --profiles p1,p2 --url https://example.com`)
53
+ .action(async (opts, cmd) => {
54
+ await withClient(cmd, async (client, globals) => {
55
+ const log = (msg) => { if (!globals.quiet)
56
+ console.error(msg); };
57
+ // Step 0: Resolve defaults from latest iteration
58
+ let resolvedPlatform = opts.platform;
59
+ let resolvedUrl = opts.url;
60
+ let resolvedScreenFormat = opts.screenFormat;
61
+ let profileIds = opts.profiles
62
+ ? opts.profiles.split(",").map((s) => s.trim()).filter(Boolean).map(resolveId)
63
+ : [];
64
+ const profileNames = new Map();
65
+ let iterationId = opts.iteration ? resolveId(opts.iteration) : undefined;
66
+ const resolvedWorkspace = resolveId(opts.workspace);
67
+ const resolvedStudy = resolveId(opts.study);
68
+ if (!iterationId || profileIds.length === 0 || !resolvedPlatform || !resolvedScreenFormat) {
69
+ const study = await client.get(`/studies/${resolvedStudy}`);
70
+ if (!study.assignments || study.assignments.length === 0) {
71
+ outputError(new Error("Study has no assignments. Add tasks with --assignments when creating the study, or use `speqs study generate`."), globals.json);
72
+ process.exit(1);
73
+ }
74
+ const iterations = study.iterations || [];
75
+ const latest = iterations.length > 0 ? iterations[iterations.length - 1] : null;
76
+ if (latest) {
77
+ const iterLabel = latest.label || latest.name || latest.id.slice(0, 8);
78
+ log(`Using iteration "${iterLabel}" as baseline`);
79
+ // Reuse iteration if not creating a new one
80
+ if (!iterationId && !opts.iterationName) {
81
+ iterationId = latest.id;
82
+ }
83
+ // Fill platform/url/screen-format from iteration details
84
+ const details = latest.details;
85
+ if (details) {
86
+ if (!resolvedPlatform)
87
+ resolvedPlatform = details.platform;
88
+ if (!resolvedUrl)
89
+ resolvedUrl = details.url;
90
+ if (!resolvedScreenFormat)
91
+ resolvedScreenFormat = details.screen_format || details.screenFormat;
92
+ }
93
+ // Auto-select profiles from latest iteration's testers
94
+ if (profileIds.length === 0 && latest.testers && latest.testers.length > 0) {
95
+ const seen = new Set();
96
+ for (const t of latest.testers) {
97
+ const pid = t.tester_profile_id || t.tester_profile?.id;
98
+ if (pid && !seen.has(pid)) {
99
+ seen.add(pid);
100
+ profileIds.push(pid);
101
+ const name = t.tester_profile?.name;
102
+ if (name)
103
+ profileNames.set(pid, name);
104
+ }
105
+ }
106
+ }
107
+ }
108
+ }
109
+ // Apply hardcoded fallbacks
110
+ resolvedPlatform = resolvedPlatform || "browser";
111
+ resolvedScreenFormat = resolvedScreenFormat || "desktop";
112
+ if (profileIds.length === 0) {
113
+ outputError(new Error("No profiles specified and no previous iteration to copy from. Use --profiles <ids>."), globals.json);
114
+ process.exit(1);
115
+ }
116
+ // Confirmation step — show resolved settings
117
+ if (!globals.json && !opts.yes) {
118
+ log("");
119
+ log(" Simulation settings:");
120
+ log(` Platform: ${resolvedPlatform}`);
121
+ log(` Screen format: ${resolvedScreenFormat}`);
122
+ if (resolvedUrl)
123
+ log(` URL: ${resolvedUrl}`);
124
+ if (opts.language)
125
+ log(` Language: ${opts.language}`);
126
+ log(` Profiles (${profileIds.length}):`);
127
+ for (const pid of profileIds) {
128
+ const name = profileNames.get(pid);
129
+ log(` - ${name ? `${name} (${pid})` : pid}`);
130
+ }
131
+ log("");
132
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
133
+ const answer = await rl.question(" Proceed? [Y/n] ");
134
+ rl.close();
135
+ if (answer && !["y", "yes", ""].includes(answer.toLowerCase().trim())) {
136
+ log("Aborted.");
137
+ process.exit(0);
138
+ }
139
+ log("");
140
+ }
141
+ // Step 1: Create or use existing iteration
142
+ if (!iterationId) {
143
+ const iterName = opts.iterationName || `CLI ${new Date().toISOString().slice(0, 16)}`;
144
+ const iterBody = {
145
+ name: iterName,
146
+ details: {
147
+ type: "interactive",
148
+ platform: resolvedPlatform,
149
+ url: resolvedUrl || "",
150
+ screen_format: resolvedScreenFormat,
151
+ ...(opts.locale && { locale: opts.locale }),
152
+ },
153
+ };
154
+ log(`Creating iteration "${iterName}"...`);
155
+ const iter = await client.post(`/studies/${resolvedStudy}/iterations`, iterBody);
156
+ iterationId = iter.id;
157
+ log(`Created iteration "${iterName}"`);
158
+ }
159
+ // Step 2: Create testers from profiles
160
+ const testerInputs = profileIds.map((profileId) => ({
161
+ tester_profile_id: profileId,
162
+ tester_type: "ai",
163
+ status: "draft",
164
+ ...(opts.language && { language: opts.language }),
165
+ platform: resolvedPlatform,
166
+ }));
167
+ log(`Creating ${testerInputs.length} tester${testerInputs.length > 1 ? "s" : ""}...`);
168
+ const batchResult = await client.post(`/iterations/${iterationId}/testers/batch`, { testers: testerInputs });
169
+ const createdTesters = batchResult.testers;
170
+ log(`Created ${createdTesters.length} tester${createdTesters.length > 1 ? "s" : ""}`);
171
+ // Step 3: Start simulations (batch) — config resolved from profiles by backend
172
+ const simItems = createdTesters.map((t) => ({
173
+ study_id: resolvedStudy,
174
+ tester_id: t.id,
175
+ ...(opts.language && { language: opts.language }),
176
+ ...(opts.locale && { locale: opts.locale }),
177
+ }));
178
+ log(`Starting ${simItems.length} simulation${simItems.length > 1 ? "s" : ""}...`);
179
+ const simResult = await client.post("/simulation/interactive/start/batch", {
180
+ product_id: resolvedWorkspace,
181
+ simulations: simItems,
182
+ platform: resolvedPlatform,
183
+ ...(resolvedUrl && { url: resolvedUrl }),
184
+ screen_format: resolvedScreenFormat,
185
+ ...(opts.maxInteractions && { max_interactions: parseMaxInteractions(opts.maxInteractions) }),
186
+ }, { timeout: 60_000 });
187
+ if (globals.json) {
188
+ output({
189
+ iteration_id: iterationId,
190
+ testers: createdTesters.map((t) => ({ id: t.id, profile_name: t.tester_profile?.name })),
191
+ simulations: simResult.results,
192
+ }, true);
193
+ }
194
+ else {
195
+ // Human-readable summary (to stderr — not data)
196
+ for (let i = 0; i < simResult.results.length; i++) {
197
+ const tester = createdTesters[i];
198
+ const profileName = tester?.tester_profile?.name || "Unknown";
199
+ log(` ${profileName.padEnd(24)} QUEUED`);
200
+ }
201
+ const url = getWebUrl(globals, `/${resolvedWorkspace}/${resolvedStudy}/timeline`);
202
+ log(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
203
+ log(`Run \`speqs simulation poll --study ${resolvedStudy}\` to check progress.`);
204
+ }
205
+ });
206
+ });
207
+ // --- Poll: check simulation progress ---
208
+ sim
209
+ .command("poll")
210
+ .description("Check simulation progress")
211
+ .argument("[job_id]", "Job ID (for single simulation)")
212
+ .option("--study <id>", "Study ID (poll all simulations for study)")
213
+ .addHelpText("after", "\nExamples:\n $ speqs simulation poll --study <study_id>\n $ speqs simulation poll <job_id> --json")
214
+ .action(async (jobId, opts, cmd) => {
215
+ await withClient(cmd, async (client, globals) => {
216
+ if (jobId) {
217
+ // Single job status
218
+ const data = await client.get(`/simulation/status/${resolveId(jobId)}`);
219
+ output(data, globals.json);
220
+ }
221
+ else if (opts.study) {
222
+ const rid = resolveId(opts.study);
223
+ // Get study to find all testers with running/queued simulations
224
+ const study = await client.get(`/studies/${rid}`);
225
+ // Collect all testers across iterations
226
+ const allTesters = [];
227
+ for (const iteration of study.iterations || []) {
228
+ for (const tester of iteration.testers || []) {
229
+ allTesters.push({
230
+ id: tester.id,
231
+ status: tester.status,
232
+ tester_name: tester.tester_profile?.name || "Unknown",
233
+ interaction_count: Array.isArray(tester.interactions) ? tester.interactions.length : 0,
234
+ });
235
+ }
236
+ }
237
+ formatSimulationPoll(allTesters, globals.json);
238
+ if (!globals.json && study.product_id) {
239
+ const url = getWebUrl(globals, `/${study.product_id}/${rid}/timeline`);
240
+ console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
241
+ }
242
+ }
243
+ else {
244
+ outputError(new Error("Provide a job_id argument or --study flag"), globals.json);
245
+ process.exit(1);
246
+ }
247
+ });
248
+ });
249
+ // --- Lower-level commands ---
250
+ sim
251
+ .command("start")
252
+ .description("Start a single interactive simulation (low-level)")
253
+ .requiredOption("--workspace <id>", "Workspace ID")
254
+ .requiredOption("--study <id>", "Study ID")
255
+ .requiredOption("--tester <id>", "Tester ID")
256
+ .option("--config <id>", "Simulation config ID (resolved from profile if omitted)")
257
+ .option("--platform <platform>", "Platform (browser, android, figma, code)", "browser")
258
+ .option("--url <url>", "URL to test")
259
+ .option("--screen-format <format>", "Screen format (mobile_portrait, desktop)")
260
+ .option("--max-interactions <n>", "Max interactions")
261
+ .option("--language <lang>", "Language code")
262
+ .option("--locale <locale>", "Locale code")
263
+ .action(async (opts, cmd) => {
264
+ await withClient(cmd, async (client, globals) => {
265
+ const body = {
266
+ product_id: resolveId(opts.workspace),
267
+ study_id: resolveId(opts.study),
268
+ tester_id: resolveId(opts.tester),
269
+ ...(opts.config && { config_id: resolveId(opts.config) }),
270
+ platform: opts.platform,
271
+ ...(opts.url && { url: opts.url }),
272
+ ...(opts.screenFormat && { screen_format: opts.screenFormat }),
273
+ ...(opts.maxInteractions && { max_interactions: parseMaxInteractions(opts.maxInteractions) }),
274
+ ...(opts.language && { language: opts.language }),
275
+ ...(opts.locale && { locale: opts.locale }),
276
+ };
277
+ const data = await client.post("/simulation/interactive/start", body);
278
+ output(data, globals.json);
279
+ });
280
+ });
281
+ sim
282
+ .command("start-media")
283
+ .description("Start a media simulation (low-level)")
284
+ .requiredOption("--workspace <id>", "Workspace ID")
285
+ .requiredOption("--study <id>", "Study ID")
286
+ .requiredOption("--tester <id>", "Tester ID")
287
+ .option("--config <id>", "Simulation config ID (resolved from profile if omitted)")
288
+ .option("--max-interactions <n>", "Max interactions")
289
+ .option("--language <lang>", "Language code")
290
+ .action(async (opts, cmd) => {
291
+ await withClient(cmd, async (client, globals) => {
292
+ const body = {
293
+ product_id: resolveId(opts.workspace),
294
+ study_id: resolveId(opts.study),
295
+ tester_id: resolveId(opts.tester),
296
+ ...(opts.config && { config_id: resolveId(opts.config) }),
297
+ ...(opts.maxInteractions && { max_interactions: parseMaxInteractions(opts.maxInteractions) }),
298
+ ...(opts.language && { language: opts.language }),
299
+ };
300
+ const data = await client.post("/simulation/media/start", body);
301
+ output(data, globals.json);
302
+ });
303
+ });
304
+ sim
305
+ .command("cancel")
306
+ .description("Cancel a simulation")
307
+ .argument("<job_id>", "Job ID")
308
+ .action(async (jobId, _opts, cmd) => {
309
+ await withClient(cmd, async (client, globals) => {
310
+ const data = await client.post(`/simulation/cancel/${resolveId(jobId)}`);
311
+ output(data, globals.json);
312
+ });
313
+ });
314
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * speqs study — Manage studies.
3
+ */
4
+ import type { Command } from "commander";
5
+ export declare function registerStudyCommands(program: Command): void;