@ishlabs/cli 0.8.1 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +323 -21
  2. package/dist/auth.d.ts +17 -1
  3. package/dist/auth.js +62 -9
  4. package/dist/commands/ask.d.ts +5 -0
  5. package/dist/commands/ask.js +722 -0
  6. package/dist/commands/config.js +25 -1
  7. package/dist/commands/docs.d.ts +17 -0
  8. package/dist/commands/docs.js +147 -0
  9. package/dist/commands/init.d.ts +16 -0
  10. package/dist/commands/init.js +182 -0
  11. package/dist/commands/iteration.d.ts +5 -1
  12. package/dist/commands/iteration.js +243 -31
  13. package/dist/commands/profile.d.ts +5 -0
  14. package/dist/commands/profile.js +313 -0
  15. package/dist/commands/source.d.ts +10 -0
  16. package/dist/commands/source.js +78 -0
  17. package/dist/commands/study-run.d.ts +11 -0
  18. package/dist/commands/study-run.js +552 -0
  19. package/dist/commands/study-tester.d.ts +8 -0
  20. package/dist/commands/study-tester.js +149 -0
  21. package/dist/commands/study.js +145 -70
  22. package/dist/commands/workspace.js +193 -7
  23. package/dist/config.d.ts +3 -1
  24. package/dist/config.js +10 -10
  25. package/dist/connect.d.ts +4 -1
  26. package/dist/connect.js +127 -94
  27. package/dist/index.js +82 -34
  28. package/dist/lib/alias-store.d.ts +3 -0
  29. package/dist/lib/alias-store.js +9 -7
  30. package/dist/lib/api-client.d.ts +9 -6
  31. package/dist/lib/api-client.js +87 -26
  32. package/dist/lib/ask-questions.d.ts +9 -0
  33. package/dist/lib/ask-questions.js +35 -0
  34. package/dist/lib/ask-variants.d.ts +48 -0
  35. package/dist/lib/ask-variants.js +236 -0
  36. package/dist/lib/auth.d.ts +1 -1
  37. package/dist/lib/auth.js +24 -8
  38. package/dist/lib/colors.d.ts +30 -0
  39. package/dist/lib/colors.js +48 -0
  40. package/dist/lib/command-helpers.d.ts +74 -0
  41. package/dist/lib/command-helpers.js +232 -6
  42. package/dist/lib/docs.d.ts +32 -0
  43. package/dist/lib/docs.js +930 -0
  44. package/dist/lib/local-sim/browser.d.ts +0 -1
  45. package/dist/lib/local-sim/browser.js +0 -2
  46. package/dist/lib/local-sim/install.d.ts +4 -7
  47. package/dist/lib/local-sim/install.js +6 -21
  48. package/dist/lib/output.d.ts +25 -3
  49. package/dist/lib/output.js +465 -20
  50. package/dist/lib/paths.d.ts +14 -0
  51. package/dist/lib/paths.js +36 -0
  52. package/dist/lib/profile-sources.d.ts +55 -0
  53. package/dist/lib/profile-sources.js +157 -0
  54. package/dist/lib/site-access.d.ts +80 -0
  55. package/dist/lib/site-access.js +188 -0
  56. package/dist/lib/skill-content.d.ts +31 -0
  57. package/dist/lib/skill-content.js +462 -0
  58. package/dist/lib/study-inputs.d.ts +20 -0
  59. package/dist/lib/study-inputs.js +72 -0
  60. package/dist/lib/types.d.ts +207 -9
  61. package/dist/lib/types.js +7 -0
  62. package/dist/lib/upload.js +2 -2
  63. package/dist/upgrade.js +11 -1
  64. package/package.json +1 -1
  65. package/dist/commands/simulation.d.ts +0 -10
  66. package/dist/commands/simulation.js +0 -647
  67. package/dist/commands/tester-profile.d.ts +0 -5
  68. package/dist/commands/tester-profile.js +0 -109
  69. package/dist/commands/tester.d.ts +0 -5
  70. package/dist/commands/tester.js +0 -73
@@ -1,13 +1,102 @@
1
1
  /**
2
- * ish iteration — Manage iterations (usually created via `simulation run`).
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 (usually created via `simulation run`)");
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
- formatIterationList(data, globals.json);
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 (low-level)")
25
- .option("--study <id>", "Study ID")
26
- .requiredOption("--name <name>", "Iteration name")
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
- .option("--details-json <json>", "Iteration details as JSON string")
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 S --name "v1" \\
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
- # Text/email:
36
- $ ish iteration create --study S --name "v1" \\
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
- # Video:
40
- $ ish iteration create --study S --name "v1" \\
41
- --details-json '{"type":"video","content_url":"https://cdn.example.com/video.mp4","mime_type":"video/mp4"}'
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
- # Image:
44
- $ ish iteration create --study S --name "v1" \\
45
- --details-json '{"type":"image","image_urls":["https://cdn.example.com/a.png","https://cdn.example.com/b.png"]}'
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 --study S --name "v1" \\
49
- --details-json '{"type":"document","content_url":"https://cdn.example.com/report.pdf","mime_type":"application/pdf"}'
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
- Note: For local file uploads, use \`ish simulation run\` which automatically
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
- ...(opts.detailsJson && { details: (() => { try {
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/${resolveStudy(opts.study)}/iterations`, body);
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,5 @@
1
+ /**
2
+ * ish profile — Manage profiles, audience generation, and source uploads.
3
+ */
4
+ import type { Command } from "commander";
5
+ export declare function registerProfileCommands(program: Command): void;
@@ -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;