@ishlabs/cli 0.8.2 → 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 +6 -0
- package/dist/auth.d.ts +1 -0
- package/dist/auth.js +12 -3
- package/dist/commands/ask.js +59 -16
- package/dist/commands/iteration.js +45 -11
- package/dist/commands/profile.js +65 -12
- package/dist/commands/study-run.js +49 -0
- package/dist/commands/study-tester.js +5 -2
- package/dist/commands/study.js +71 -16
- package/dist/connect.js +7 -7
- package/dist/index.js +119 -2
- package/dist/lib/api-client.js +29 -7
- package/dist/lib/command-helpers.d.ts +14 -0
- package/dist/lib/command-helpers.js +40 -0
- package/dist/lib/docs.js +430 -13
- package/dist/lib/local-sim/install.d.ts +0 -7
- package/dist/lib/local-sim/install.js +20 -13
- package/dist/lib/output.js +437 -63
- package/dist/lib/skill-content.js +102 -9
- package/dist/lib/types.d.ts +3 -1
- package/dist/upgrade.js +3 -3
- package/package.json +3 -2
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.
|
|
108
|
-
console.
|
|
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.
|
|
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));
|
package/dist/commands/ask.js
CHANGED
|
@@ -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":"
|
|
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":"
|
|
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": "
|
|
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
|
|
366
|
-
.argument("[
|
|
367
|
-
.option("--ask <id>", "Ask ID; alternative to positional argument")
|
|
368
|
-
.option("--round <n>", "Show only round N (1-indexed)")
|
|
369
|
-
.addHelpText("after",
|
|
370
|
-
|
|
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
|
|
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":"
|
|
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 (
|
|
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":"
|
|
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": "
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
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("<
|
|
288
|
-
.addHelpText("after",
|
|
289
|
-
|
|
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
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
package/dist/commands/profile.js
CHANGED
|
@@ -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("<
|
|
217
|
-
.addHelpText("after",
|
|
218
|
-
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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,
|
|
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)
|