@ishlabs/cli 0.8.3 → 0.8.5
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 +7 -1
- package/dist/auth.d.ts +16 -0
- package/dist/auth.js +52 -3
- package/dist/commands/ask.js +86 -17
- package/dist/commands/iteration.js +45 -11
- package/dist/commands/profile.js +79 -13
- package/dist/commands/study-run.js +49 -0
- package/dist/commands/study-tester.js +5 -2
- package/dist/commands/study.js +82 -19
- package/dist/connect.js +94 -19
- package/dist/index.js +122 -2
- package/dist/lib/api-client.js +29 -7
- package/dist/lib/command-helpers.d.ts +51 -0
- package/dist/lib/command-helpers.js +206 -7
- package/dist/lib/docs.js +621 -30
- package/dist/lib/output.d.ts +6 -0
- package/dist/lib/output.js +570 -65
- package/dist/lib/skill-content.js +216 -9
- package/dist/lib/types.d.ts +3 -1
- package/dist/upgrade.js +3 -3
- package/package.json +1 -1
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:
|
|
@@ -49,7 +55,7 @@ Two top-level research primitives, both consume reusable tester profiles:
|
|
|
49
55
|
Workspace (= product, top-level container)
|
|
50
56
|
│
|
|
51
57
|
├── Tester Profiles ← reusable audience personas
|
|
52
|
-
│ └── Audience Sources (
|
|
58
|
+
│ └── Audience Sources (images/PDFs/audio/video/text transcripts that seed generation)
|
|
53
59
|
│
|
|
54
60
|
├── Study ─────────────── "structured research artifact"
|
|
55
61
|
│ ├── modality (interactive | text | video | audio | image | document)
|
package/dist/auth.d.ts
CHANGED
|
@@ -15,11 +15,27 @@ 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;
|
|
21
22
|
refreshToken: string;
|
|
22
23
|
}>;
|
|
24
|
+
/**
|
|
25
|
+
* Thrown by `refreshTokens` when the refresh attempt failed permanently and
|
|
26
|
+
* the local config can't recover (e.g. Supabase `refresh_token_not_found`,
|
|
27
|
+
* `invalid_grant`, or any 400-class response). Callers should treat this as
|
|
28
|
+
* "user must run `ish login` again" — retrying won't help.
|
|
29
|
+
*
|
|
30
|
+
* Transient failures (network errors, 5xx, timeouts) are NOT this error and
|
|
31
|
+
* may be worth retrying.
|
|
32
|
+
*/
|
|
33
|
+
export declare class AuthRefreshPermanentError extends Error {
|
|
34
|
+
readonly httpStatus: number;
|
|
35
|
+
readonly errorCode: string | undefined;
|
|
36
|
+
readonly body: string;
|
|
37
|
+
constructor(httpStatus: number, body: string, errorCode: string | undefined);
|
|
38
|
+
}
|
|
23
39
|
export declare function refreshTokens(refreshToken: string, options?: {
|
|
24
40
|
/** The (possibly expired) access token. Used to pick the correct Supabase project. */
|
|
25
41
|
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));
|
|
@@ -130,6 +139,43 @@ export async function login(appUrl) {
|
|
|
130
139
|
throw new Error("Login timed out. Please try again.");
|
|
131
140
|
}
|
|
132
141
|
// --- Token refresh ---
|
|
142
|
+
/**
|
|
143
|
+
* Thrown by `refreshTokens` when the refresh attempt failed permanently and
|
|
144
|
+
* the local config can't recover (e.g. Supabase `refresh_token_not_found`,
|
|
145
|
+
* `invalid_grant`, or any 400-class response). Callers should treat this as
|
|
146
|
+
* "user must run `ish login` again" — retrying won't help.
|
|
147
|
+
*
|
|
148
|
+
* Transient failures (network errors, 5xx, timeouts) are NOT this error and
|
|
149
|
+
* may be worth retrying.
|
|
150
|
+
*/
|
|
151
|
+
export class AuthRefreshPermanentError extends Error {
|
|
152
|
+
httpStatus;
|
|
153
|
+
errorCode;
|
|
154
|
+
body;
|
|
155
|
+
constructor(httpStatus, body, errorCode) {
|
|
156
|
+
const detail = errorCode ? ` ${errorCode}` : "";
|
|
157
|
+
super(`Token refresh failed permanently (HTTP ${httpStatus}${detail}): ${body}`);
|
|
158
|
+
this.name = "AuthRefreshPermanentError";
|
|
159
|
+
this.httpStatus = httpStatus;
|
|
160
|
+
this.errorCode = errorCode;
|
|
161
|
+
this.body = body;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function parseRefreshErrorCode(body) {
|
|
165
|
+
try {
|
|
166
|
+
const parsed = JSON.parse(body);
|
|
167
|
+
if (parsed && typeof parsed === "object") {
|
|
168
|
+
if (typeof parsed.error_code === "string")
|
|
169
|
+
return parsed.error_code;
|
|
170
|
+
if (typeof parsed.error === "string")
|
|
171
|
+
return parsed.error;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// not JSON — fall through
|
|
176
|
+
}
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
133
179
|
export async function refreshTokens(refreshToken, options) {
|
|
134
180
|
const project = options?.supabaseUrl && options?.anonKey
|
|
135
181
|
? { url: options.supabaseUrl, anonKey: options.anonKey }
|
|
@@ -145,6 +191,9 @@ export async function refreshTokens(refreshToken, options) {
|
|
|
145
191
|
});
|
|
146
192
|
if (!resp.ok) {
|
|
147
193
|
const body = await resp.text().catch(() => "");
|
|
194
|
+
if (resp.status >= 400 && resp.status < 500) {
|
|
195
|
+
throw new AuthRefreshPermanentError(resp.status, body, parseRefreshErrorCode(body));
|
|
196
|
+
}
|
|
148
197
|
throw new Error(`Token refresh failed (HTTP ${resp.status}): ${body}`);
|
|
149
198
|
}
|
|
150
199
|
const data = await resp.json();
|
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,9 +323,13 @@ 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
|
+
|
|
330
|
+
Picks come back with a \`pick_confidence\` (0..1) score per tester when
|
|
331
|
+
\`--wants-pick\` is set — read it off \`pick.confidence\` in the response. See
|
|
332
|
+
\`ish docs get-page concepts/ask\` for interpretation.
|
|
329
333
|
`)
|
|
330
334
|
.action(async (opts, cmd) => {
|
|
331
335
|
await withClient(cmd, async (client, globals) => {
|
|
@@ -362,14 +366,47 @@ Minimal --questions JSON (server keys: "question" + "type"):
|
|
|
362
366
|
// ---- get ----------------------------------------------------------------
|
|
363
367
|
ask
|
|
364
368
|
.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
|
-
|
|
369
|
+
.description("Get full ask detail (accepts multiple IDs for batched lookup)")
|
|
370
|
+
.argument("[ids...]", "Ask alias(es)/UUID(s); defaults to active ask from `ish ask use`")
|
|
371
|
+
.option("--ask <id>", "Ask ID; alternative to a single positional argument")
|
|
372
|
+
.option("--round <n>", "Show only round N (1-indexed); single-ask only")
|
|
373
|
+
.addHelpText("after", `
|
|
374
|
+
Examples:
|
|
375
|
+
$ ish ask get a-6ec
|
|
376
|
+
$ ish ask get a-6ec --round 2 --json
|
|
377
|
+
$ ish ask get a-6ec a-7c2 a-9d3
|
|
378
|
+
$ ish ask get a-6ec,a-7c2 --fields alias,name,testers_count,responses_total
|
|
379
|
+
|
|
380
|
+
With multiple IDs, returns a {items:[...], total:N} envelope and uses the
|
|
381
|
+
list table layout in human mode. --ask and --round only apply to single-ask
|
|
382
|
+
lookups.`)
|
|
383
|
+
.action(async (ids, opts, cmd) => {
|
|
371
384
|
await withClient(cmd, async (client, globals) => {
|
|
372
|
-
const
|
|
385
|
+
const flat = (ids ?? []).flatMap((s) => s.split(",").map((x) => x.trim()).filter(Boolean));
|
|
386
|
+
if (flat.length > 1) {
|
|
387
|
+
if (opts.ask) {
|
|
388
|
+
throw new Error("Pass either --ask or multiple positional ids, not both.");
|
|
389
|
+
}
|
|
390
|
+
if (opts.round !== undefined) {
|
|
391
|
+
throw new Error("--round only applies when fetching a single ask.");
|
|
392
|
+
}
|
|
393
|
+
const results = await Promise.all(flat.map(async (raw) => {
|
|
394
|
+
const aid = resolveId(raw);
|
|
395
|
+
const data = await client.get(`/asks/${aid}`);
|
|
396
|
+
const r = data;
|
|
397
|
+
if (r.id)
|
|
398
|
+
r.alias = tagAlias(ALIAS_PREFIX.ask, String(r.id));
|
|
399
|
+
return r;
|
|
400
|
+
}));
|
|
401
|
+
if (globals.json) {
|
|
402
|
+
output({ items: results, total: results.length }, true);
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
formatAskList(results, false);
|
|
406
|
+
}
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const aid = resolveAsk(pickAskRef(flat[0], opts.ask));
|
|
373
410
|
const data = await client.get(`/asks/${aid}`);
|
|
374
411
|
if (opts.round !== undefined) {
|
|
375
412
|
const target = parseInt(opts.round, 10);
|
|
@@ -397,7 +434,14 @@ Minimal --questions JSON (server keys: "question" + "type"):
|
|
|
397
434
|
.argument("[id]", "Ask alias or UUID (defaults to active ask)")
|
|
398
435
|
.option("--ask <id>", "Ask ID; alternative to positional argument")
|
|
399
436
|
.option("--round <n>", "Show only round N (1-indexed; default: all rounds)")
|
|
400
|
-
.addHelpText("after",
|
|
437
|
+
.addHelpText("after", `
|
|
438
|
+
Examples:
|
|
439
|
+
$ ish ask results a-6ec
|
|
440
|
+
$ ish ask results a-6ec --round 1 --json
|
|
441
|
+
|
|
442
|
+
Each pick has a \`pick_confidence\` field (0..1, when --wants-pick was set) —
|
|
443
|
+
the model's self-reported confidence in its variant choice. See
|
|
444
|
+
\`ish docs get-page concepts/ask\` for how to use it for ranking ties.`)
|
|
401
445
|
.action(async (id, opts, cmd) => {
|
|
402
446
|
await withClient(cmd, async (client, globals) => {
|
|
403
447
|
const aid = resolveAsk(pickAskRef(id, opts.ask));
|
|
@@ -468,12 +512,13 @@ Minimal --questions JSON (server keys: "question" + "type"):
|
|
|
468
512
|
.description("Append a new round to an existing ask (max 5 per ask)")
|
|
469
513
|
.argument("[id]", "Ask alias or UUID (defaults to active ask)")
|
|
470
514
|
.option("--ask <id>", "Ask ID; alternative to positional argument")
|
|
515
|
+
.option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the ask)")
|
|
471
516
|
.requiredOption("--prompt <prompt>", "Round prompt")
|
|
472
517
|
.option("--variant <kind:value>", "Variant flag (repeatable; same syntax as `ask create`)", collectRepeatable, [])
|
|
473
518
|
.option("--variants <file.json>", "JSON manifest of variants")
|
|
474
519
|
.option("--wants-pick", "Each tester picks a favourite variant (compatible with --wants-ratings; can be set together).")
|
|
475
520
|
.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":"
|
|
521
|
+
.option("--questions <file.json>", `Questions JSON file: [{"question":"...","type":"text"|"slider"|"likert"|"single-choice"|"multiple-choice"|"number"}]`)
|
|
477
522
|
.option("--wait", "Wait until the new round completes")
|
|
478
523
|
.option("--timeout <s>", "Wait timeout in seconds (default 300)")
|
|
479
524
|
.addHelpText("after", "\nExamples:\n $ ish ask add-round a-6ec --prompt \"And now?\" --variant text:\"Hello\" --variant text:\"Hi\" --wait")
|
|
@@ -499,31 +544,54 @@ Minimal --questions JSON (server keys: "question" + "type"):
|
|
|
499
544
|
// ---- add-questions ------------------------------------------------------
|
|
500
545
|
ask
|
|
501
546
|
.command("add-questions")
|
|
502
|
-
.description("Append questionnaire questions to a round (
|
|
547
|
+
.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
548
|
.argument("[id]", "Ask alias or UUID (defaults to active ask)")
|
|
504
549
|
.option("--ask <id>", "Ask ID; alternative to positional argument")
|
|
505
550
|
.requiredOption("--round <n|round-id>", "Round number (1-indexed) or round id/alias")
|
|
506
|
-
.requiredOption("--questions <file.json>", `Questions JSON file: [{"question":"...","type":"
|
|
551
|
+
.requiredOption("--questions <file.json>", `Questions JSON file: [{"question":"...","type":"text"|"slider"|"likert"|"single-choice"|"multiple-choice"|"number"}]`)
|
|
552
|
+
.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)
|
|
553
|
+
.option("--wait", "Wait until the round completes (or errors)")
|
|
554
|
+
.option("--timeout <s>", "Wait timeout in seconds (default 300)")
|
|
507
555
|
.addHelpText("after", `
|
|
508
556
|
Examples:
|
|
557
|
+
# Additive (default): preserves prior picks/ratings/comments.
|
|
509
558
|
$ ish ask add-questions a-6ec --round 1 --questions ./qs.json
|
|
510
559
|
|
|
560
|
+
# Wait for the round to finish before returning.
|
|
561
|
+
$ ish ask add-questions a-6ec --round 1 --questions ./qs.json --wait
|
|
562
|
+
|
|
563
|
+
# Legacy reset: re-runs the whole round; prior picks may shift.
|
|
564
|
+
$ ish ask add-questions a-6ec --round 1 --questions ./qs.json --redispatch-all --wait
|
|
565
|
+
|
|
511
566
|
Minimal valid --questions JSON:
|
|
512
567
|
[
|
|
513
|
-
{ "question": "What stood out?", "type": "
|
|
568
|
+
{ "question": "What stood out?", "type": "text" },
|
|
514
569
|
{ "question": "Rate it 1-5", "type": "slider" }
|
|
515
570
|
]
|
|
516
571
|
|
|
517
572
|
Note: the server requires the key "question" (not "text"). Valid type values:
|
|
518
|
-
|
|
573
|
+
text, slider, likert, single-choice, multiple-choice, number.`)
|
|
519
574
|
.action(async (id, opts, cmd) => {
|
|
520
575
|
await withClient(cmd, async (client, globals) => {
|
|
521
576
|
const aid = resolveAsk(pickAskRef(id, opts.ask));
|
|
522
577
|
const ask = await client.get(`/asks/${aid}`);
|
|
523
578
|
const round = getRoundByIndexOrId(ask, opts.round);
|
|
524
579
|
const questions = loadQuestionsManifest(opts.questions);
|
|
525
|
-
const body = {
|
|
580
|
+
const body = {
|
|
581
|
+
questions,
|
|
582
|
+
...(opts.redispatchAll && { redispatch_all: true }),
|
|
583
|
+
};
|
|
526
584
|
const updated = await client.post(`/asks/${aid}/rounds/${round.id}/questions`, body);
|
|
585
|
+
if (opts.wait) {
|
|
586
|
+
const timeoutMs = parseWaitTimeout(opts.timeout);
|
|
587
|
+
await pollUntilRoundDone(client, aid, updated.order_index, timeoutMs, !!globals.quiet);
|
|
588
|
+
const refreshed = await client.get(`/asks/${aid}`);
|
|
589
|
+
const target = refreshed.rounds.find((r) => r.id === updated.id);
|
|
590
|
+
if (target) {
|
|
591
|
+
formatRoundDetail(target, globals.json);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
527
595
|
if (!globals.json || globals.verbose) {
|
|
528
596
|
formatRoundDetail(updated, globals.json);
|
|
529
597
|
return;
|
|
@@ -545,6 +613,7 @@ open_ended, slider, choice, likert.`)
|
|
|
545
613
|
.description("Add testers to an existing ask (optionally backfill prior rounds)")
|
|
546
614
|
.argument("[id]", "Ask alias or UUID (defaults to active ask)")
|
|
547
615
|
.option("--ask <id>", "Ask ID; alternative to positional argument")
|
|
616
|
+
.option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the ask)")
|
|
548
617
|
.requiredOption("--round <n|round-id>", "Round to add testers to (required)");
|
|
549
618
|
addAudienceFilterFlags(askAddTesters, {
|
|
550
619
|
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
|
@@ -40,15 +40,19 @@ Examples:
|
|
|
40
40
|
$ ish profile list
|
|
41
41
|
$ ish profile list --search "engineer" --country US
|
|
42
42
|
$ ish profile list --gender female --gender male --country US --country GB
|
|
43
|
-
$ ish profile list --type all --json
|
|
43
|
+
$ ish profile list --type all --json
|
|
44
|
+
|
|
45
|
+
# Pagination — default --limit is 50; iterate with --offset:
|
|
46
|
+
$ ish profile list --limit 100
|
|
47
|
+
$ ish profile list --limit 100 --offset 100 # next page
|
|
48
|
+
# When more results exist, a stderr hint surfaces the next --offset / --limit.`)
|
|
44
49
|
.action(async (opts, cmd) => {
|
|
45
50
|
await withClient(cmd, async (client, globals) => {
|
|
46
51
|
const params = {
|
|
47
52
|
limit: opts.limit,
|
|
48
53
|
offset: opts.offset,
|
|
54
|
+
product_id: resolveWorkspace(opts.workspace),
|
|
49
55
|
};
|
|
50
|
-
if (opts.workspace)
|
|
51
|
-
params.product_id = resolveWorkspace(opts.workspace);
|
|
52
56
|
if (opts.search)
|
|
53
57
|
params.search = opts.search;
|
|
54
58
|
if (opts.type !== "all")
|
|
@@ -63,6 +67,29 @@ Examples:
|
|
|
63
67
|
params.max_age = opts.maxAge;
|
|
64
68
|
const data = await client.get("/tester-profiles", params);
|
|
65
69
|
formatTesterProfileList(data, globals.json, parseInt(opts.limit, 10));
|
|
70
|
+
// Pattern H1: when there's more data, surface a stderr hint so agents
|
|
71
|
+
// know `--limit / --offset` exist without re-reading help. Stderr only,
|
|
72
|
+
// so it doesn't pollute machine-readable stdout — that means we DON'T
|
|
73
|
+
// gate on globals.json (auto-flips on pipe and would silence the hint
|
|
74
|
+
// exactly when the agent needs it most). --quiet is the explicit
|
|
75
|
+
// opt-out for progress chatter.
|
|
76
|
+
//
|
|
77
|
+
// The raw `/tester-profiles` envelope is `{items, total, limit, offset}`
|
|
78
|
+
// — `has_more` and `returned` are synthesized client-side by `wrapList`
|
|
79
|
+
// only when the response is rendered. Compute them locally here so the
|
|
80
|
+
// hint actually fires; reading `data.has_more` directly would always
|
|
81
|
+
// see undefined and silently skip.
|
|
82
|
+
if (!globals.quietExplicit && typeof data === "object" && data !== null) {
|
|
83
|
+
const d = data;
|
|
84
|
+
const items = Array.isArray(d.items) ? d.items : [];
|
|
85
|
+
const returned = items.length;
|
|
86
|
+
const total = typeof d.total === "number" ? d.total : returned;
|
|
87
|
+
const offset = typeof d.offset === "number" ? d.offset : parseInt(opts.offset, 10) || 0;
|
|
88
|
+
const hasMore = total > offset + returned;
|
|
89
|
+
if (hasMore && returned > 0) {
|
|
90
|
+
console.error(`\n showing ${offset + 1}–${offset + returned} of ${total}; pass --offset ${offset + returned} --limit ${returned} for more.`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
66
93
|
});
|
|
67
94
|
});
|
|
68
95
|
profile
|
|
@@ -74,8 +101,9 @@ Examples:
|
|
|
74
101
|
.action(async (opts, cmd) => {
|
|
75
102
|
await withClient(cmd, async (client, globals) => {
|
|
76
103
|
const body = await readJsonFileOrStdin(opts.file);
|
|
77
|
-
if (opts.workspace)
|
|
104
|
+
if (opts.workspace || body.product_id == null) {
|
|
78
105
|
body.product_id = resolveWorkspace(opts.workspace);
|
|
106
|
+
}
|
|
79
107
|
const data = await client.post("/tester-profiles", body);
|
|
80
108
|
const result = data;
|
|
81
109
|
if (result.id)
|
|
@@ -195,8 +223,18 @@ Examples:
|
|
|
195
223
|
}
|
|
196
224
|
body.count = n;
|
|
197
225
|
}
|
|
226
|
+
// Pattern D1: emit stderr progress before the LLM call so agents (and
|
|
227
|
+
// humans) see something is happening during the ~10–20s wait. Mirrors
|
|
228
|
+
// the `--wait` ergonomics on `ask create` / `study run`.
|
|
229
|
+
if (!globals.quiet) {
|
|
230
|
+
const target = body.count ? `${body.count} profile${body.count === 1 ? "" : "s"}` : "profiles";
|
|
231
|
+
console.error(` generating ${target}...`);
|
|
232
|
+
}
|
|
198
233
|
// /generate is LLM-backed and slow.
|
|
199
234
|
const profiles = await client.post("/tester-profiles/generate", body, { timeout: 180_000 });
|
|
235
|
+
if (!globals.quiet) {
|
|
236
|
+
console.error(` generated ${profiles.length} profile${profiles.length === 1 ? "" : "s"}`);
|
|
237
|
+
}
|
|
200
238
|
// simulation_config is the inlined system prompt + model settings — ~3.5KB
|
|
201
239
|
// of mostly-identical boilerplate per profile. Strip it from the default
|
|
202
240
|
// JSON output; users who need it can pass --include-simulation-config or
|
|
@@ -212,16 +250,43 @@ Examples:
|
|
|
212
250
|
});
|
|
213
251
|
profile
|
|
214
252
|
.command("get")
|
|
215
|
-
.description("Get profile details")
|
|
216
|
-
.argument("<
|
|
217
|
-
.addHelpText("after",
|
|
218
|
-
|
|
253
|
+
.description("Get profile details (accepts multiple IDs for batched lookup)")
|
|
254
|
+
.argument("<ids...>", "Profile ID(s) — one or more aliases/UUIDs (space- or comma-separated)")
|
|
255
|
+
.addHelpText("after", `
|
|
256
|
+
Examples:
|
|
257
|
+
$ ish profile get tp-1b9
|
|
258
|
+
$ ish profile get tp-1b9 --json
|
|
259
|
+
$ ish profile get tp-1b9 tp-fc1 tp-2fc
|
|
260
|
+
$ ish profile get tp-1b9,tp-fc1,tp-2fc --fields alias,name,country,occupation
|
|
261
|
+
|
|
262
|
+
With multiple IDs, returns a {items:[...], total:N} envelope and uses the
|
|
263
|
+
list table layout in human mode. Use --fields to project per-item.`)
|
|
264
|
+
.action(async (ids, _opts, cmd) => {
|
|
219
265
|
await withClient(cmd, async (client, globals) => {
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
266
|
+
const flat = ids.flatMap((s) => s.split(",").map((x) => x.trim()).filter(Boolean));
|
|
267
|
+
if (flat.length === 0)
|
|
268
|
+
throw new Error("Provide at least one profile id.");
|
|
269
|
+
if (flat.length === 1) {
|
|
270
|
+
const data = await client.get(`/tester-profiles/${resolveId(flat[0])}`);
|
|
271
|
+
const result = data;
|
|
272
|
+
if (result.id)
|
|
273
|
+
result.alias = tagAlias(ALIAS_PREFIX.testerProfile, String(result.id));
|
|
274
|
+
output(result, globals.json);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const results = await Promise.all(flat.map(async (raw) => {
|
|
278
|
+
const data = await client.get(`/tester-profiles/${resolveId(raw)}`);
|
|
279
|
+
const r = data;
|
|
280
|
+
if (r.id)
|
|
281
|
+
r.alias = tagAlias(ALIAS_PREFIX.testerProfile, String(r.id));
|
|
282
|
+
return r;
|
|
283
|
+
}));
|
|
284
|
+
if (globals.json) {
|
|
285
|
+
output({ items: results, total: results.length }, true);
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
formatTesterProfileList(results, false);
|
|
289
|
+
}
|
|
225
290
|
});
|
|
226
291
|
});
|
|
227
292
|
profile
|
|
@@ -287,6 +352,7 @@ flags override values from --file.`)
|
|
|
287
352
|
.command("delete")
|
|
288
353
|
.description("Delete a profile")
|
|
289
354
|
.argument("<id>", "Profile ID")
|
|
355
|
+
.option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the profile)")
|
|
290
356
|
.addHelpText("after", "\nExamples:\n $ ish profile delete <id>")
|
|
291
357
|
.action(async (id, _opts, cmd) => {
|
|
292
358
|
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),
|