@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.
- package/README.md +323 -21
- package/dist/auth.d.ts +17 -1
- package/dist/auth.js +62 -9
- package/dist/commands/ask.d.ts +5 -0
- package/dist/commands/ask.js +722 -0
- package/dist/commands/config.js +25 -1
- package/dist/commands/docs.d.ts +17 -0
- package/dist/commands/docs.js +147 -0
- package/dist/commands/init.d.ts +16 -0
- package/dist/commands/init.js +182 -0
- package/dist/commands/iteration.d.ts +5 -1
- package/dist/commands/iteration.js +243 -31
- package/dist/commands/profile.d.ts +5 -0
- package/dist/commands/profile.js +313 -0
- package/dist/commands/source.d.ts +10 -0
- package/dist/commands/source.js +78 -0
- package/dist/commands/study-run.d.ts +11 -0
- package/dist/commands/study-run.js +552 -0
- package/dist/commands/study-tester.d.ts +8 -0
- package/dist/commands/study-tester.js +149 -0
- package/dist/commands/study.js +145 -70
- package/dist/commands/workspace.js +193 -7
- package/dist/config.d.ts +3 -1
- package/dist/config.js +10 -10
- package/dist/connect.d.ts +4 -1
- package/dist/connect.js +127 -94
- package/dist/index.js +82 -34
- package/dist/lib/alias-store.d.ts +3 -0
- package/dist/lib/alias-store.js +9 -7
- package/dist/lib/api-client.d.ts +9 -6
- package/dist/lib/api-client.js +87 -26
- package/dist/lib/ask-questions.d.ts +9 -0
- package/dist/lib/ask-questions.js +35 -0
- package/dist/lib/ask-variants.d.ts +48 -0
- package/dist/lib/ask-variants.js +236 -0
- package/dist/lib/auth.d.ts +1 -1
- package/dist/lib/auth.js +24 -8
- package/dist/lib/colors.d.ts +30 -0
- package/dist/lib/colors.js +48 -0
- package/dist/lib/command-helpers.d.ts +74 -0
- package/dist/lib/command-helpers.js +232 -6
- package/dist/lib/docs.d.ts +32 -0
- package/dist/lib/docs.js +930 -0
- package/dist/lib/local-sim/browser.d.ts +0 -1
- package/dist/lib/local-sim/browser.js +0 -2
- package/dist/lib/local-sim/install.d.ts +4 -7
- package/dist/lib/local-sim/install.js +6 -21
- package/dist/lib/output.d.ts +25 -3
- package/dist/lib/output.js +465 -20
- package/dist/lib/paths.d.ts +14 -0
- package/dist/lib/paths.js +36 -0
- package/dist/lib/profile-sources.d.ts +55 -0
- package/dist/lib/profile-sources.js +157 -0
- package/dist/lib/site-access.d.ts +80 -0
- package/dist/lib/site-access.js +188 -0
- package/dist/lib/skill-content.d.ts +31 -0
- package/dist/lib/skill-content.js +462 -0
- package/dist/lib/study-inputs.d.ts +20 -0
- package/dist/lib/study-inputs.js +72 -0
- package/dist/lib/types.d.ts +207 -9
- package/dist/lib/types.js +7 -0
- package/dist/lib/upload.js +2 -2
- package/dist/upgrade.js +11 -1
- package/package.json +1 -1
- package/dist/commands/simulation.d.ts +0 -10
- package/dist/commands/simulation.js +0 -647
- package/dist/commands/tester-profile.d.ts +0 -5
- package/dist/commands/tester-profile.js +0 -109
- package/dist/commands/tester.d.ts +0 -5
- package/dist/commands/tester.js +0 -73
|
@@ -1,13 +1,102 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ish iteration — Manage iterations
|
|
2
|
+
* ish iteration — Manage iterations of a study.
|
|
3
|
+
*
|
|
4
|
+
* An iteration carries the run-time details (URL for interactive,
|
|
5
|
+
* content/file for media). Create one before `ish study run` dispatches
|
|
6
|
+
* simulations against it.
|
|
3
7
|
*/
|
|
4
8
|
import { withClient, resolveStudy } from "../lib/command-helpers.js";
|
|
5
9
|
import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
|
|
6
10
|
import { output, formatIterationList } from "../lib/output.js";
|
|
11
|
+
import { resolveContentUrl, resolveContentUrls, resolveTextContent } from "../lib/upload.js";
|
|
12
|
+
import { MEDIA_MODALITIES } from "../lib/types.js";
|
|
13
|
+
function isMediaModality(modality) {
|
|
14
|
+
return !!modality && MEDIA_MODALITIES.includes(modality);
|
|
15
|
+
}
|
|
16
|
+
function buildCopyContent(opts) {
|
|
17
|
+
if (!opts.copyText)
|
|
18
|
+
return undefined;
|
|
19
|
+
return {
|
|
20
|
+
text: opts.copyText,
|
|
21
|
+
...(opts.copyHtml && { html: opts.copyHtml }),
|
|
22
|
+
...(opts.socialPlatform && { social_platform: opts.socialPlatform }),
|
|
23
|
+
...(opts.copyPosition && { position: opts.copyPosition }),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function buildIterationDetails(modality, opts) {
|
|
27
|
+
switch (modality) {
|
|
28
|
+
case "text":
|
|
29
|
+
if (!opts.contentText) {
|
|
30
|
+
throw new Error("Text iterations require --content-text. Provide the text content to evaluate.");
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
type: "text",
|
|
34
|
+
content_text: opts.contentText,
|
|
35
|
+
...(opts.contentHtml && { content_html: opts.contentHtml }),
|
|
36
|
+
...(opts.title && { title: opts.title }),
|
|
37
|
+
...(opts.mimeType && { mime_type: opts.mimeType }),
|
|
38
|
+
};
|
|
39
|
+
case "video":
|
|
40
|
+
case "audio": {
|
|
41
|
+
if (!opts.contentUrl) {
|
|
42
|
+
throw new Error(`${modality} iterations require --content-url. Provide the URL or local file path.`);
|
|
43
|
+
}
|
|
44
|
+
const copy = buildCopyContent(opts);
|
|
45
|
+
return {
|
|
46
|
+
type: modality,
|
|
47
|
+
content_url: opts.contentUrl,
|
|
48
|
+
...(opts.title && { title: opts.title }),
|
|
49
|
+
...(opts.mimeType && { mime_type: opts.mimeType }),
|
|
50
|
+
...(copy && { copy_content: copy }),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
case "image": {
|
|
54
|
+
if (!opts.imageUrls) {
|
|
55
|
+
throw new Error("Image iterations require --image-urls. Provide comma-separated URLs or local file paths.");
|
|
56
|
+
}
|
|
57
|
+
const copy = buildCopyContent(opts);
|
|
58
|
+
return {
|
|
59
|
+
type: "image",
|
|
60
|
+
image_urls: opts.imageUrls.split(",").map((s) => s.trim()).filter(Boolean),
|
|
61
|
+
...(opts.title && { title: opts.title }),
|
|
62
|
+
...(opts.mimeType && { mime_type: opts.mimeType }),
|
|
63
|
+
...(copy && { copy_content: copy }),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
case "document":
|
|
67
|
+
if (!opts.contentUrl) {
|
|
68
|
+
throw new Error("Document iterations require --content-url. Provide the URL or local file path.");
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
type: "document",
|
|
72
|
+
content_url: opts.contentUrl,
|
|
73
|
+
...(opts.title && { title: opts.title }),
|
|
74
|
+
...(opts.mimeType && { mime_type: opts.mimeType }),
|
|
75
|
+
};
|
|
76
|
+
default:
|
|
77
|
+
if (!opts.url) {
|
|
78
|
+
throw new Error("Interactive iterations require --url. Provide the URL to test.");
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
type: "interactive",
|
|
82
|
+
platform: opts.platform || "browser",
|
|
83
|
+
url: opts.url,
|
|
84
|
+
screen_format: opts.screenFormat || "desktop",
|
|
85
|
+
...(opts.locale && { locale: opts.locale }),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
7
89
|
export function registerIterationCommands(program) {
|
|
8
90
|
const iteration = program
|
|
9
91
|
.command("iteration")
|
|
10
|
-
.description("Manage iterations
|
|
92
|
+
.description("Manage iterations of a study (a study's run-time configuration)")
|
|
93
|
+
.addHelpText("after", `
|
|
94
|
+
An iteration is one configured run of a study — it carries the URL (interactive) or
|
|
95
|
+
media content (text/video/image/document). A study has 1..N iterations; \`ish study run\`
|
|
96
|
+
defaults to the latest. Local file paths in --content-url / --image-urls are auto-uploaded.
|
|
97
|
+
|
|
98
|
+
Concept pages: ish docs get-page concepts/iteration
|
|
99
|
+
ish docs get-page concepts/study`);
|
|
11
100
|
iteration
|
|
12
101
|
.command("list")
|
|
13
102
|
.description("List iterations for a study")
|
|
@@ -16,53 +105,176 @@ export function registerIterationCommands(program) {
|
|
|
16
105
|
.action(async (opts, cmd) => {
|
|
17
106
|
await withClient(cmd, async (client, globals) => {
|
|
18
107
|
const data = await client.get(`/studies/${resolveStudy(opts.study)}/iterations`);
|
|
19
|
-
|
|
108
|
+
const rows = data;
|
|
109
|
+
if (globals.json) {
|
|
110
|
+
// Legacy iterations come back with `name`, `description`, `details`
|
|
111
|
+
// set to null. The default lean-JSON pass strips nulls, which makes
|
|
112
|
+
// those keys disappear from the output and breaks consumers doing
|
|
113
|
+
// `it.details?.type` — the row would be missing the key entirely.
|
|
114
|
+
// Project to a stable, agent-friendly shape and bypass leanJson.
|
|
115
|
+
const projected = rows.map((it) => ({
|
|
116
|
+
id: it.id ?? null,
|
|
117
|
+
alias: it.id ? tagAlias(ALIAS_PREFIX.iteration, String(it.id)) : null,
|
|
118
|
+
label: it.label ?? null,
|
|
119
|
+
name: it.name ?? null,
|
|
120
|
+
description: it.description ?? null,
|
|
121
|
+
details: it.details ?? null,
|
|
122
|
+
order_index: it.order_index ?? null,
|
|
123
|
+
created_at: it.created_at ?? null,
|
|
124
|
+
}));
|
|
125
|
+
output(projected, true, { preProjected: true });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
formatIterationList(rows, globals.json);
|
|
20
129
|
});
|
|
21
130
|
});
|
|
22
131
|
iteration
|
|
23
132
|
.command("create")
|
|
24
|
-
.description("Create a new iteration
|
|
25
|
-
.option("--study <id>", "Study ID")
|
|
26
|
-
.
|
|
133
|
+
.description("Create a new iteration with run-time content/URL")
|
|
134
|
+
.option("--study <id>", "Study ID (or set via `ish study use`)")
|
|
135
|
+
.option("--name <name>", "Iteration name (auto-generated if omitted)")
|
|
27
136
|
.option("--description <description>", "Iteration description")
|
|
28
|
-
|
|
137
|
+
// Interactive
|
|
138
|
+
.option("--platform <platform>", "Platform (browser, android, figma, code) — interactive only")
|
|
139
|
+
.option("--url <url>", "URL to test — interactive only")
|
|
140
|
+
.option("--screen-format <format>", "Screen format (mobile_portrait, desktop) — interactive only")
|
|
141
|
+
.option("--locale <locale>", "Locale code (e.g. en-US) — interactive only")
|
|
142
|
+
// Media text
|
|
143
|
+
.option("--content-text <text>", "Text content to evaluate, or @filepath to read from file — text modality")
|
|
144
|
+
.option("--content-html <html>", "HTML version of the text, or @filepath to read from file — text modality")
|
|
145
|
+
// Media video/audio/document
|
|
146
|
+
.option("--content-url <url>", "URL or local file path to media file — video, audio, document modalities")
|
|
147
|
+
// Media image
|
|
148
|
+
.option("--image-urls <urls>", "Comma-separated image URLs or local file paths — image modality")
|
|
149
|
+
// Shared media
|
|
150
|
+
.option("--title <title>", "Content title — media modalities")
|
|
151
|
+
.option("--mime-type <type>", "MIME type (e.g. video/mp4) — media modalities")
|
|
152
|
+
// Copy/caption
|
|
153
|
+
.option("--copy-text <text>", "Ad copy or social post caption (or @filepath) — ads & social posts")
|
|
154
|
+
.option("--copy-html <html>", "HTML version of copy text (or @filepath)")
|
|
155
|
+
.option("--social-platform <platform>", "Social platform (instagram, tiktok, facebook, linkedin, x)")
|
|
156
|
+
.option("--copy-position <pos>", "Copy position relative to media (before, after)", "after")
|
|
157
|
+
// Escape hatch
|
|
158
|
+
.option("--details-json <json>", "Raw iteration details JSON (overrides individual flags)")
|
|
29
159
|
.addHelpText("after", `
|
|
160
|
+
Note: --study is optional if set via \`ish study use <alias>\`. Local files
|
|
161
|
+
passed to --content-url, --image-urls, --content-text, etc. are uploaded
|
|
162
|
+
automatically. Use @filepath for text-style flags to read from a file.
|
|
163
|
+
|
|
30
164
|
Examples:
|
|
31
|
-
# Interactive:
|
|
32
|
-
$ ish iteration create --study
|
|
33
|
-
--details-json '{"type":"interactive","platform":"browser","url":"https://example.com","screen_format":"desktop"}'
|
|
165
|
+
# Interactive (URL):
|
|
166
|
+
$ ish iteration create --study s-b2c --url https://example.com
|
|
34
167
|
|
|
35
|
-
#
|
|
36
|
-
$ ish iteration create --
|
|
37
|
-
--details-json '{"type":"text","content_text":"Your email content here","title":"Newsletter"}'
|
|
168
|
+
# Interactive on mobile:
|
|
169
|
+
$ ish iteration create --url https://example.com --screen-format mobile_portrait
|
|
38
170
|
|
|
39
|
-
#
|
|
40
|
-
$ ish iteration create --
|
|
41
|
-
|
|
171
|
+
# Text/email (inline or @file):
|
|
172
|
+
$ ish iteration create --content-text "Your email content..."
|
|
173
|
+
$ ish iteration create --content-text @./email.html --title "Newsletter"
|
|
42
174
|
|
|
43
|
-
#
|
|
44
|
-
$ ish iteration create --
|
|
45
|
-
|
|
175
|
+
# Video (URL or local file — local files auto-uploaded):
|
|
176
|
+
$ ish iteration create --content-url ./video.mp4
|
|
177
|
+
|
|
178
|
+
# Image set:
|
|
179
|
+
$ ish iteration create --image-urls "./a.png,./b.png"
|
|
46
180
|
|
|
47
181
|
# Document (PDF):
|
|
48
|
-
$ ish iteration create --
|
|
49
|
-
|
|
182
|
+
$ ish iteration create --content-url ./report.pdf
|
|
183
|
+
|
|
184
|
+
# Video ad with copy text:
|
|
185
|
+
$ ish iteration create --content-url ./ad.mp4 --copy-text "Buy now — 50% off!"
|
|
186
|
+
|
|
187
|
+
# Social post with caption:
|
|
188
|
+
$ ish iteration create --image-urls ./post.png \\
|
|
189
|
+
--copy-text @./caption.txt --social-platform instagram
|
|
190
|
+
|
|
191
|
+
# Raw JSON escape hatch (overrides individual flags):
|
|
192
|
+
$ ish iteration create --study s-b2c --details-json \\
|
|
193
|
+
'{"type":"interactive","platform":"browser","url":"https://example.com","screen_format":"desktop"}'
|
|
194
|
+
|
|
195
|
+
Local files passed to --content-url, --image-urls, etc. are uploaded to the
|
|
196
|
+
workspace's public storage bucket. Validation now happens before upload.
|
|
50
197
|
|
|
51
|
-
|
|
52
|
-
uploads files and resolves URLs (e.g. --content-url ./video.mp4).`)
|
|
198
|
+
Next: \`ish study run\` to dispatch simulations against this iteration.`)
|
|
53
199
|
.action(async (opts, cmd) => {
|
|
54
200
|
await withClient(cmd, async (client, globals) => {
|
|
201
|
+
const studyId = resolveStudy(opts.study);
|
|
202
|
+
let details;
|
|
203
|
+
if (opts.detailsJson) {
|
|
204
|
+
try {
|
|
205
|
+
details = JSON.parse(opts.detailsJson);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
throw new Error("Invalid --details-json: expected valid JSON string");
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
// Need the study's modality to validate flags + shape details
|
|
213
|
+
const study = await client.get(`/studies/${studyId}`);
|
|
214
|
+
const modality = study.modality || "interactive";
|
|
215
|
+
const isMedia = isMediaModality(modality);
|
|
216
|
+
if (isMedia && opts.url) {
|
|
217
|
+
throw new Error(`This study uses "${modality}" modality — --url is for interactive studies. Use --content-text, --content-url, or --image-urls instead.`);
|
|
218
|
+
}
|
|
219
|
+
if (!isMedia && (opts.contentText || opts.contentUrl || opts.imageUrls)) {
|
|
220
|
+
throw new Error(`This study uses "interactive" modality — --content-text, --content-url, and --image-urls are for media studies. Use --url instead.`);
|
|
221
|
+
}
|
|
222
|
+
// Validate per-modality required flags BEFORE any upload so we don't
|
|
223
|
+
// orphan blobs in storage when the wrong flag is passed (e.g.
|
|
224
|
+
// --content-url to an image-modality study).
|
|
225
|
+
switch (modality) {
|
|
226
|
+
case "text":
|
|
227
|
+
if (!opts.contentText) {
|
|
228
|
+
throw new Error("Text iterations require --content-text. Provide the text content to evaluate.");
|
|
229
|
+
}
|
|
230
|
+
break;
|
|
231
|
+
case "video":
|
|
232
|
+
case "audio":
|
|
233
|
+
if (!opts.contentUrl) {
|
|
234
|
+
throw new Error(`${modality} iterations require --content-url. Provide the URL or local file path.`);
|
|
235
|
+
}
|
|
236
|
+
break;
|
|
237
|
+
case "image":
|
|
238
|
+
if (!opts.imageUrls) {
|
|
239
|
+
throw new Error("Image iterations require --image-urls. Provide comma-separated URLs or local file paths.");
|
|
240
|
+
}
|
|
241
|
+
break;
|
|
242
|
+
case "document":
|
|
243
|
+
if (!opts.contentUrl) {
|
|
244
|
+
throw new Error("Document iterations require --content-url. Provide the URL or local file path.");
|
|
245
|
+
}
|
|
246
|
+
break;
|
|
247
|
+
default:
|
|
248
|
+
if (!opts.url) {
|
|
249
|
+
throw new Error("Interactive iterations require --url. Provide the URL to test.");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const resolved = { ...opts };
|
|
253
|
+
if (isMedia) {
|
|
254
|
+
if (resolved.contentText)
|
|
255
|
+
resolved.contentText = resolveTextContent(resolved.contentText);
|
|
256
|
+
if (resolved.contentHtml)
|
|
257
|
+
resolved.contentHtml = resolveTextContent(resolved.contentHtml);
|
|
258
|
+
if (resolved.copyText)
|
|
259
|
+
resolved.copyText = resolveTextContent(resolved.copyText);
|
|
260
|
+
if (resolved.copyHtml)
|
|
261
|
+
resolved.copyHtml = resolveTextContent(resolved.copyHtml);
|
|
262
|
+
if (resolved.contentUrl) {
|
|
263
|
+
resolved.contentUrl = await resolveContentUrl(client, studyId, resolved.contentUrl, { mimeTypeOverride: resolved.mimeType, quiet: globals.quiet });
|
|
264
|
+
}
|
|
265
|
+
if (resolved.imageUrls) {
|
|
266
|
+
const urls = await resolveContentUrls(client, studyId, resolved.imageUrls, { mimeTypeOverride: resolved.mimeType, quiet: globals.quiet });
|
|
267
|
+
resolved.imageUrls = urls.join(",");
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
details = buildIterationDetails(modality, resolved);
|
|
271
|
+
}
|
|
55
272
|
const body = {
|
|
56
|
-
name: opts.name,
|
|
273
|
+
name: opts.name || `CLI ${new Date().toISOString().slice(0, 16)}`,
|
|
57
274
|
...(opts.description !== undefined && { description: opts.description }),
|
|
58
|
-
...(
|
|
59
|
-
return JSON.parse(opts.detailsJson);
|
|
60
|
-
}
|
|
61
|
-
catch {
|
|
62
|
-
throw new Error("Invalid --details-json: expected valid JSON string");
|
|
63
|
-
} })() }),
|
|
275
|
+
...(details && { details }),
|
|
64
276
|
};
|
|
65
|
-
const data = await client.post(`/studies/${
|
|
277
|
+
const data = await client.post(`/studies/${studyId}/iterations`, body);
|
|
66
278
|
const result = data;
|
|
67
279
|
if (result.id)
|
|
68
280
|
result.alias = tagAlias(ALIAS_PREFIX.iteration, String(result.id));
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ish profile — Manage profiles, audience generation, and source uploads.
|
|
3
|
+
*/
|
|
4
|
+
import { withClient, readJsonFileOrStdin, resolveWorkspace } from "../lib/command-helpers.js";
|
|
5
|
+
import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
|
|
6
|
+
import { formatTesterProfileList, formatGeneratedProfileList, output, } from "../lib/output.js";
|
|
7
|
+
import { resolveTextContent } from "../lib/upload.js";
|
|
8
|
+
import { isUuid, resolveSourceRef } from "../lib/profile-sources.js";
|
|
9
|
+
function collect(value, prev) {
|
|
10
|
+
return prev.concat(value);
|
|
11
|
+
}
|
|
12
|
+
export function registerProfileCommands(program) {
|
|
13
|
+
const profile = program
|
|
14
|
+
.command("profile")
|
|
15
|
+
.alias("tester-profile")
|
|
16
|
+
.description("Manage profiles, audience generation, and source uploads")
|
|
17
|
+
.addHelpText("after", `
|
|
18
|
+
A tester profile is a reusable audience persona scoped to a workspace.
|
|
19
|
+
\`ish profile generate\` produces profiles from a written brief and/or sources
|
|
20
|
+
(transcripts, audio, images, PDFs). Distinct from a "tester" (\`t-\`), which is one
|
|
21
|
+
instance of a profile inside one iteration.
|
|
22
|
+
|
|
23
|
+
Concept pages: ish docs get-page concepts/profile
|
|
24
|
+
ish docs get-page concepts/source
|
|
25
|
+
ish docs get-page concepts/audience`);
|
|
26
|
+
profile
|
|
27
|
+
.command("list")
|
|
28
|
+
.description("List profiles (defaults to simulatable AI profiles)")
|
|
29
|
+
.option("--workspace <id>", "Filter by workspace ID")
|
|
30
|
+
.option("--search <query>", "Free-text search (matches profile name and bio)")
|
|
31
|
+
.option("--type <type>", "Profile type: ai, human, all (default: ai)", "ai")
|
|
32
|
+
.option("--gender <gender>", "Filter by gender (repeatable)", collect, [])
|
|
33
|
+
.option("--country <country>", "Filter by country code, e.g. US (repeatable)", collect, [])
|
|
34
|
+
.option("--min-age <n>", "Minimum age")
|
|
35
|
+
.option("--max-age <n>", "Maximum age")
|
|
36
|
+
.option("--limit <n>", "Max results (default 50)", "50")
|
|
37
|
+
.option("--offset <n>", "Offset for pagination", "0")
|
|
38
|
+
.addHelpText("after", `
|
|
39
|
+
Examples:
|
|
40
|
+
$ ish profile list
|
|
41
|
+
$ ish profile list --search "engineer" --country US
|
|
42
|
+
$ ish profile list --gender female --gender male --country US --country GB
|
|
43
|
+
$ ish profile list --type all --json`)
|
|
44
|
+
.action(async (opts, cmd) => {
|
|
45
|
+
await withClient(cmd, async (client, globals) => {
|
|
46
|
+
const params = {
|
|
47
|
+
limit: opts.limit,
|
|
48
|
+
offset: opts.offset,
|
|
49
|
+
};
|
|
50
|
+
if (opts.workspace)
|
|
51
|
+
params.product_id = resolveWorkspace(opts.workspace);
|
|
52
|
+
if (opts.search)
|
|
53
|
+
params.search = opts.search;
|
|
54
|
+
if (opts.type !== "all")
|
|
55
|
+
params.type = opts.type;
|
|
56
|
+
if (opts.gender.length > 0)
|
|
57
|
+
params.gender = opts.gender;
|
|
58
|
+
if (opts.country.length > 0)
|
|
59
|
+
params.country = opts.country;
|
|
60
|
+
if (opts.minAge)
|
|
61
|
+
params.min_age = opts.minAge;
|
|
62
|
+
if (opts.maxAge)
|
|
63
|
+
params.max_age = opts.maxAge;
|
|
64
|
+
const data = await client.get("/tester-profiles", params);
|
|
65
|
+
formatTesterProfileList(data, globals.json, parseInt(opts.limit, 10));
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
profile
|
|
69
|
+
.command("create")
|
|
70
|
+
.description("Create a profile from an exact JSON spec (no LLM)")
|
|
71
|
+
.requiredOption("--file <path>", "JSON file with profile data")
|
|
72
|
+
.option("--workspace <id>", "Workspace (product) ID; falls back to active workspace")
|
|
73
|
+
.addHelpText("after", "\nExamples:\n $ ish profile create --file profile.json\n\n Expected JSON: { \"name\": \"...\", \"type\": \"ai\", \"gender\": \"female\", \"country\": \"US\", \"occupation\": \"...\", \"bio\": \"...\" }")
|
|
74
|
+
.action(async (opts, cmd) => {
|
|
75
|
+
await withClient(cmd, async (client, globals) => {
|
|
76
|
+
const body = await readJsonFileOrStdin(opts.file);
|
|
77
|
+
if (opts.workspace)
|
|
78
|
+
body.product_id = resolveWorkspace(opts.workspace);
|
|
79
|
+
const data = await client.post("/tester-profiles", body);
|
|
80
|
+
const result = data;
|
|
81
|
+
if (result.id)
|
|
82
|
+
result.alias = tagAlias(ALIAS_PREFIX.testerProfile, String(result.id));
|
|
83
|
+
output(result, globals.json, { writePath: true });
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
profile
|
|
87
|
+
.command("generate")
|
|
88
|
+
.description("Generate one or many profiles via LLM (audience generation)")
|
|
89
|
+
.option("--description <text>", "Audience description (use @path to read from file)")
|
|
90
|
+
.option("--description-file <path>", "Read description from a file")
|
|
91
|
+
.option("--source <id-or-path>", "Source UUID or local file path; auto-uploads paths (repeatable)", collect, [])
|
|
92
|
+
.option("--source-description <text>", "Per-source context note (paired with --source by index, repeatable). Only applies to local-path sources — for already-uploaded aliases the description is whatever was set at upload time.", collect, [])
|
|
93
|
+
.option("--diarize", "Apply speaker diarization to audio sources (silently ignored for text/image)")
|
|
94
|
+
.option("--count <n>", "Number of profiles to generate (1-10). Omit to let the model propose")
|
|
95
|
+
.option("--propose-count", "Print the LLM's suggested audience size for a single processed source and exit (no generation)")
|
|
96
|
+
.option("--workspace <id>", "Workspace (product) ID; falls back to active workspace")
|
|
97
|
+
.option("--no-wait", "Don't poll source-processing status. Only relevant when --source is a local path (paths get auto-uploaded and processed).")
|
|
98
|
+
.option("--timeout <seconds>", "Source-processing poll timeout in seconds. Only relevant when --source is a local path.", "300")
|
|
99
|
+
.option("--include-simulation-config", "Include the full simulation_config (system prompt + model settings) on each generated profile in JSON output")
|
|
100
|
+
.addHelpText("after", `
|
|
101
|
+
Examples:
|
|
102
|
+
# Generate 3 profiles from a description
|
|
103
|
+
$ ish profile generate --description "Tech-savvy millennials in the US who use mobile banking" --count 3
|
|
104
|
+
|
|
105
|
+
# Generate one profile from a transcript (auto-uploads the file)
|
|
106
|
+
$ ish profile generate --source ./interviews/sarah.txt --count 1
|
|
107
|
+
|
|
108
|
+
# Generate 5 profiles from an audio call + a written brief
|
|
109
|
+
$ ish profile generate --description "Voices behind support tickets" --source ./call.mp3 --diarize --count 5
|
|
110
|
+
|
|
111
|
+
# Use a previously-uploaded source by alias
|
|
112
|
+
$ ish profile generate --source tps-3a4 --count 2
|
|
113
|
+
|
|
114
|
+
# Ask the LLM how many profiles a processed source warrants (no generation)
|
|
115
|
+
$ ish profile generate --source tps-3a4 --propose-count`)
|
|
116
|
+
.action(async (opts, cmd) => {
|
|
117
|
+
await withClient(cmd, async (client, globals) => {
|
|
118
|
+
const productId = resolveWorkspace(opts.workspace);
|
|
119
|
+
// --propose-count is a planning helper: requires exactly one already-uploaded
|
|
120
|
+
// source (UUID or alias), calls the propose-count endpoint, prints, and exits.
|
|
121
|
+
if (opts.proposeCount) {
|
|
122
|
+
if (opts.source.length !== 1) {
|
|
123
|
+
throw new Error("--propose-count requires exactly one --source (a UUID or alias).");
|
|
124
|
+
}
|
|
125
|
+
if (opts.sourceDescription.length > 0) {
|
|
126
|
+
throw new Error("--source-description doesn't apply to --propose-count: the source is already uploaded and its description is set at upload time. Drop --source-description, or set it at upload time via `ish source upload --description ...`.");
|
|
127
|
+
}
|
|
128
|
+
const raw = opts.source[0];
|
|
129
|
+
const id = isUuid(raw) ? raw : tryResolveSourceAlias(raw);
|
|
130
|
+
if (!id) {
|
|
131
|
+
throw new Error("--propose-count expects an uploaded source ID or alias (e.g. tps-3a4). Upload first with `ish source upload`.");
|
|
132
|
+
}
|
|
133
|
+
const data = await client.post(`/tester-profiles/sources/${id}/propose-count`, undefined, { timeout: 60_000 });
|
|
134
|
+
output(data, globals.json);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
// Resolve description: explicit text, @path, or --description-file.
|
|
138
|
+
let description;
|
|
139
|
+
if (opts.description)
|
|
140
|
+
description = resolveTextContent(opts.description);
|
|
141
|
+
if (opts.descriptionFile) {
|
|
142
|
+
description = resolveTextContent(`@${opts.descriptionFile}`);
|
|
143
|
+
}
|
|
144
|
+
if (!description && opts.source.length === 0) {
|
|
145
|
+
throw new Error("Provide --description, --description-file, or at least one --source.");
|
|
146
|
+
}
|
|
147
|
+
const timeoutMs = Math.max(1, parseInt(opts.timeout, 10)) * 1000;
|
|
148
|
+
const wait = opts.wait !== false;
|
|
149
|
+
if (opts.sourceDescription.length > opts.source.length) {
|
|
150
|
+
throw new Error(`Got ${opts.sourceDescription.length} --source-description value(s) but only ${opts.source.length} --source value(s). Each --source-description is paired with --source by position; provide one --source per --source-description.`);
|
|
151
|
+
}
|
|
152
|
+
// Resolve every --source: UUIDs/aliases pass through; paths get uploaded.
|
|
153
|
+
const sourceIds = [];
|
|
154
|
+
for (let i = 0; i < opts.source.length; i++) {
|
|
155
|
+
const raw = opts.source[i];
|
|
156
|
+
const desc = opts.sourceDescription[i];
|
|
157
|
+
// Treat as alias if it matches our prefix pattern.
|
|
158
|
+
const candidate = isUuid(raw) ? raw : tryResolveSourceAlias(raw);
|
|
159
|
+
if (candidate) {
|
|
160
|
+
// Already-uploaded source: the backend's GenerateAudienceRequest accepts
|
|
161
|
+
// only source_upload_ids, no per-source description override. The
|
|
162
|
+
// description set at upload time (ConfirmSourceUploadRequest.description)
|
|
163
|
+
// is what the LLM sees. Silently dropping a CLI-supplied description here
|
|
164
|
+
// is the M3 bug — error out instead so the user knows.
|
|
165
|
+
if (desc !== undefined) {
|
|
166
|
+
throw new Error(`--source-description for "${raw}" is ignored because that source is already uploaded — the description set at upload time is what generate uses.\n` +
|
|
167
|
+
"Either:\n" +
|
|
168
|
+
" - Drop --source-description for this source, OR\n" +
|
|
169
|
+
" - Re-upload via path: --source <local-path> --source-description \"...\"");
|
|
170
|
+
}
|
|
171
|
+
sourceIds.push(candidate);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const id = await resolveSourceRef(client, raw, {
|
|
175
|
+
productId,
|
|
176
|
+
description: desc,
|
|
177
|
+
diarize: opts.diarize,
|
|
178
|
+
wait,
|
|
179
|
+
timeoutMs,
|
|
180
|
+
quiet: globals.quiet,
|
|
181
|
+
});
|
|
182
|
+
sourceIds.push(id);
|
|
183
|
+
}
|
|
184
|
+
const body = {
|
|
185
|
+
product_id: productId,
|
|
186
|
+
};
|
|
187
|
+
if (description)
|
|
188
|
+
body.description = description;
|
|
189
|
+
if (sourceIds.length > 0)
|
|
190
|
+
body.source_upload_ids = sourceIds;
|
|
191
|
+
if (opts.count) {
|
|
192
|
+
const n = parseInt(opts.count, 10);
|
|
193
|
+
if (Number.isNaN(n) || n < 1 || n > 10) {
|
|
194
|
+
throw new Error("--count must be an integer between 1 and 10.");
|
|
195
|
+
}
|
|
196
|
+
body.count = n;
|
|
197
|
+
}
|
|
198
|
+
// /generate is LLM-backed and slow.
|
|
199
|
+
const profiles = await client.post("/tester-profiles/generate", body, { timeout: 180_000 });
|
|
200
|
+
// simulation_config is the inlined system prompt + model settings — ~3.5KB
|
|
201
|
+
// of mostly-identical boilerplate per profile. Strip it from the default
|
|
202
|
+
// JSON output; users who need it can pass --include-simulation-config or
|
|
203
|
+
// fetch it later via `profile get --json`.
|
|
204
|
+
const trimmed = opts.includeSimulationConfig
|
|
205
|
+
? profiles
|
|
206
|
+
: profiles.map((p) => {
|
|
207
|
+
const { simulation_config: _drop, ...rest } = p;
|
|
208
|
+
return rest;
|
|
209
|
+
});
|
|
210
|
+
formatGeneratedProfileList(trimmed, globals.json);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
profile
|
|
214
|
+
.command("get")
|
|
215
|
+
.description("Get profile details")
|
|
216
|
+
.argument("<id>", "Profile ID")
|
|
217
|
+
.addHelpText("after", "\nExamples:\n $ ish profile get <id>\n $ ish profile get <id> --json")
|
|
218
|
+
.action(async (id, _opts, cmd) => {
|
|
219
|
+
await withClient(cmd, async (client, globals) => {
|
|
220
|
+
const data = await client.get(`/tester-profiles/${resolveId(id)}`);
|
|
221
|
+
const result = data;
|
|
222
|
+
if (result.id)
|
|
223
|
+
result.alias = tagAlias(ALIAS_PREFIX.testerProfile, String(result.id));
|
|
224
|
+
output(result, globals.json);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
profile
|
|
228
|
+
.command("update")
|
|
229
|
+
.description("Update a profile")
|
|
230
|
+
.argument("<id>", "Profile ID")
|
|
231
|
+
.option("--file <path>", "JSON file with update data (escape hatch for fields not covered by inline flags)")
|
|
232
|
+
.option("--name <text>", "Profile name")
|
|
233
|
+
.option("--description <text>", "Profile description")
|
|
234
|
+
.option("--bio <text>", "Profile bio")
|
|
235
|
+
.option("--occupation <text>", "Occupation")
|
|
236
|
+
.option("--country <code>", "Country code, e.g. US")
|
|
237
|
+
.option("--gender <g>", "Gender, e.g. female")
|
|
238
|
+
.option("--date-of-birth <YYYY-MM-DD>", "Date of birth")
|
|
239
|
+
.option("--tech-savviness <n>", "Tech savviness score")
|
|
240
|
+
.addHelpText("after", `
|
|
241
|
+
Examples:
|
|
242
|
+
$ ish profile update <id> --bio "Edited bio"
|
|
243
|
+
$ ish profile update <id> --name "Alice" --country US --tech-savviness 8
|
|
244
|
+
$ ish profile update <id> --file updates.json
|
|
245
|
+
|
|
246
|
+
Inline flags compose into the patch body. --file is an escape hatch when you
|
|
247
|
+
need fields not covered by the inline flags. When both are provided, inline
|
|
248
|
+
flags override values from --file.`)
|
|
249
|
+
.action(async (id, opts, cmd) => {
|
|
250
|
+
await withClient(cmd, async (client, globals) => {
|
|
251
|
+
let body = {};
|
|
252
|
+
if (opts.file) {
|
|
253
|
+
body = (await readJsonFileOrStdin(opts.file));
|
|
254
|
+
}
|
|
255
|
+
if (opts.name !== undefined)
|
|
256
|
+
body.name = opts.name;
|
|
257
|
+
if (opts.description !== undefined)
|
|
258
|
+
body.description = opts.description;
|
|
259
|
+
if (opts.bio !== undefined)
|
|
260
|
+
body.bio = opts.bio;
|
|
261
|
+
if (opts.occupation !== undefined)
|
|
262
|
+
body.occupation = opts.occupation;
|
|
263
|
+
if (opts.country !== undefined)
|
|
264
|
+
body.country = opts.country;
|
|
265
|
+
if (opts.gender !== undefined)
|
|
266
|
+
body.gender = opts.gender;
|
|
267
|
+
if (opts.dateOfBirth !== undefined)
|
|
268
|
+
body.date_of_birth = opts.dateOfBirth;
|
|
269
|
+
if (opts.techSavviness !== undefined) {
|
|
270
|
+
const n = parseInt(opts.techSavviness, 10);
|
|
271
|
+
if (Number.isNaN(n)) {
|
|
272
|
+
throw new Error("--tech-savviness must be an integer.");
|
|
273
|
+
}
|
|
274
|
+
body.tech_savviness = n;
|
|
275
|
+
}
|
|
276
|
+
if (Object.keys(body).length === 0) {
|
|
277
|
+
throw new Error("Nothing to update. Provide --file or at least one inline flag (e.g. --bio).");
|
|
278
|
+
}
|
|
279
|
+
const data = await client.put(`/tester-profiles/${resolveId(id)}`, body);
|
|
280
|
+
const result = data;
|
|
281
|
+
if (result.id)
|
|
282
|
+
result.alias = tagAlias(ALIAS_PREFIX.testerProfile, String(result.id));
|
|
283
|
+
output(result, globals.json, { writePath: true });
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
profile
|
|
287
|
+
.command("delete")
|
|
288
|
+
.description("Delete a profile")
|
|
289
|
+
.argument("<id>", "Profile ID")
|
|
290
|
+
.addHelpText("after", "\nExamples:\n $ ish profile delete <id>")
|
|
291
|
+
.action(async (id, _opts, cmd) => {
|
|
292
|
+
await withClient(cmd, async (client, globals) => {
|
|
293
|
+
const rid = resolveId(id);
|
|
294
|
+
await client.del(`/tester-profiles/${rid}`);
|
|
295
|
+
output({ id: rid, alias: tagAlias(ALIAS_PREFIX.testerProfile, rid), message: "Profile deleted" }, globals.json, { writePath: true });
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* If the value matches the testerProfileSource alias pattern (e.g. "tps-3a4"),
|
|
301
|
+
* resolve it to the underlying UUID. Otherwise return undefined so the caller
|
|
302
|
+
* falls back to treating the value as a path.
|
|
303
|
+
*/
|
|
304
|
+
function tryResolveSourceAlias(value) {
|
|
305
|
+
if (!/^tps-[0-9a-f]{3,}$/.test(value))
|
|
306
|
+
return undefined;
|
|
307
|
+
try {
|
|
308
|
+
return resolveId(value);
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ish source — Upload and inspect audience-generation sources.
|
|
3
|
+
*
|
|
4
|
+
* Sources (transcripts, audio, images, PDFs) are inputs to `ish profile
|
|
5
|
+
* generate`. For one-shot generation, `profile generate --source <path>`
|
|
6
|
+
* auto-uploads. Use these commands to upload once and reuse across multiple
|
|
7
|
+
* generation runs, or to inspect processing status.
|
|
8
|
+
*/
|
|
9
|
+
import type { Command } from "commander";
|
|
10
|
+
export declare function registerSourceCommands(program: Command): void;
|