@ishlabs/cli 0.23.1 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -168,7 +168,7 @@ Concept pages: ish docs get-page concepts/ask
168
168
  .option("--variants <file.json>", "JSON manifest of variants (alternative to --variant)")
169
169
  .option("--wants-pick", "Each participant picks a favourite variant (compatible with --wants-ratings; can be set together).")
170
170
  .option("--wants-ratings", "Each participant rates every variant 1–5 (compatible with --wants-pick; can be set together). If neither is set, participants leave a free-form comment only.")
171
- .option("--questions <file.json>", `Questions JSON file: [{"question":"...","type":"text"|"slider"|"likert"|"single-choice"|"multiple-choice"|"number"}]`)
171
+ .option("--questions <json|@file|path>", `Questions as inline JSON, @file, or a JSON file path: [{"question":"...","type":"text"|"slider"|"likert"|"single-choice"|"multiple-choice"|"number"}]`)
172
172
  .option("--language <code>", "2-letter language code (with --new only)", "en");
173
173
  addPersonFilterFlags(askRun, {
174
174
  allFlagName: "--all-simulatable",
@@ -343,7 +343,7 @@ Examples:
343
343
  .option("--variants <file.json>", "JSON manifest of variants (alternative to --variant)")
344
344
  .option("--wants-pick", "Each participant picks a favourite variant (compatible with --wants-ratings; can be set together).")
345
345
  .option("--wants-ratings", "Each participant rates every variant 1–5 (compatible with --wants-pick; can be set together). If neither is set, participants leave a free-form comment only.")
346
- .option("--questions <file.json>", `Questions JSON file: [{"question":"...","type":"text"|"slider"|"likert"|"single-choice"|"multiple-choice"|"number"}]`)
346
+ .option("--questions <json|@file|path>", `Questions as inline JSON, @file, or a JSON file path: [{"question":"...","type":"text"|"slider"|"likert"|"single-choice"|"multiple-choice"|"number"}]`)
347
347
  .option("--language <code>", "2-letter language code", "en");
348
348
  addPersonFilterFlags(askCreate, {
349
349
  allFlagName: "--all-simulatable",
@@ -628,7 +628,7 @@ the model's self-reported confidence in its variant choice. See
628
628
  .option("--variants <file.json>", "JSON manifest of variants")
629
629
  .option("--wants-pick", "Each participant picks a favourite variant (compatible with --wants-ratings; can be set together).")
630
630
  .option("--wants-ratings", "Each participant rates every variant 1–5 (compatible with --wants-pick; can be set together). If neither is set, participants leave a free-form comment only.")
631
- .option("--questions <file.json>", `Questions JSON file: [{"question":"...","type":"text"|"slider"|"likert"|"single-choice"|"multiple-choice"|"number"}]`)
631
+ .option("--questions <json|@file|path>", `Questions as inline JSON, @file, or a JSON file path: [{"question":"...","type":"text"|"slider"|"likert"|"single-choice"|"multiple-choice"|"number"}]`)
632
632
  .option("--subset-round <n>", "Drill-in subset (Pattern B) — 1-indexed prior round to filter against. Pair with --subset-variant. The new round dispatches only to participants who picked --subset-variant on round N.")
633
633
  .option("--subset-variant <variant_id>", "Drill-in subset (Pattern B) — variant id (UUID) on the prior round whose pickers should inherit. Pair with --subset-round. Read from `aggregates.pick_buckets` or `variants[*].id` on the prior round.")
634
634
  .option("--wait", "Wait until the new round completes")
@@ -676,7 +676,7 @@ error_kind: "participant_subset_invalid".`)
676
676
  .argument("[id]", "Ask alias or UUID (defaults to active ask)")
677
677
  .option("--ask <id>", "Ask ID; alternative to positional argument")
678
678
  .requiredOption("--round <n|round-id>", "Round number (1-indexed) or round id/alias")
679
- .requiredOption("--questions <file.json>", `Questions JSON file: [{"question":"...","type":"text"|"slider"|"likert"|"single-choice"|"multiple-choice"|"number"}]`)
679
+ .requiredOption("--questions <json|@file|path>", `Questions as inline JSON, @file, or a JSON file path: [{"question":"...","type":"text"|"slider"|"likert"|"single-choice"|"multiple-choice"|"number"}]`)
680
680
  .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)
681
681
  .option("--wait", "Wait until the round completes (or errors)")
682
682
  .option("--timeout <s>", "Wait timeout in seconds (default 300)")
@@ -11,6 +11,7 @@ import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
11
11
  import { output, formatIterationList, ValidationError } from "../lib/output.js";
12
12
  import { resolveContentUrl, resolveContentUrls, resolveTextContent } from "../lib/upload.js";
13
13
  import { isMediaModality, validateIterationDetails, normalizeChatMode, validateRoleCriteria } from "../lib/modality.js";
14
+ import { validateSegmentation, warnIfOverSegmented } from "../lib/segmentation.js";
14
15
  import { normalizeEnumValue, SCREEN_FORMATS } from "../lib/enums.js";
15
16
  /**
16
17
  * Read text inline or from a file when prefixed with `@/path/to/file`.
@@ -361,7 +362,7 @@ Concept pages: ish docs get-page concepts/iteration
361
362
  .description("Create a new iteration with run-time content/URL")
362
363
  .option("--study <id>", "Study ID (or set via `ish study use`)")
363
364
  .option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the study)")
364
- .option("--name <name>", "Iteration name (auto-generated if omitted)")
365
+ .option("--name <name>", "Iteration name (defaults to the next position letter A/B/C… if omitted)")
365
366
  .option("--description <description>", "Iteration description")
366
367
  // Interactive
367
368
  .option("--platform <platform>", "Platform (browser, android, figma, code) — interactive only")
@@ -390,7 +391,7 @@ Concept pages: ish docs get-page concepts/iteration
390
391
  .option("--social-platform <platform>", "Social platform (instagram, tiktok, facebook, linkedin, x)")
391
392
  .option("--copy-position <pos>", "Copy position relative to media (before, after)", "after")
392
393
  // Segmentation / per-iteration evaluation config (media modalities)
393
- .option("--segmentation-json <json>", "Segmentation JSON — time_based {intervals_seconds, labels?}, section_based {sections[{name,label,...}]}, or page_based {} — media modalities")
394
+ .option("--segmentation-json <json>", "Segmentation JSON — time_based {intervals_seconds, labels?}, section_based {sections[{name,label,...}]}, or page_based {} — media modalities. section_based sections are SEMANTIC: group related paragraphs into a few coherent sections (a long article is usually 3-6 sections, not one per paragraph; paragraph_start/end just mark where each section begins and ends).")
394
395
  .option("--content-config-json <json>", "Content config JSON — {early_termination, selected_segment_indices?} — media modalities")
395
396
  // Chat modality
396
397
  .option("--chat-mode <mode>", "Chat mode: external_chatbot (default; probe a customer chatbot) or participant_pair (two AI people talk to each other)")
@@ -671,8 +672,29 @@ Next: \`ish study run\` to dispatch simulations against this iteration.`)
671
672
  details = buildIterationDetails(modality, resolved);
672
673
  }
673
674
  }
675
+ // Segments are semantic sections, not paragraphs: reject a malformed
676
+ // shape (e.g. a missing label) before the network call, and nudge when
677
+ // it looks like one-section-per-paragraph.
678
+ if (details && typeof details === "object") {
679
+ validateSegmentation(details.segmentation);
680
+ warnIfOverSegmented(details.segmentation, { quiet: globals.quietExplicit });
681
+ }
682
+ // Default name = the iteration's position letter (A, B, C…), matching how
683
+ // `study create` names the inline iteration "A". Never an opaque
684
+ // "CLI <timestamp>" — iterations should read as what they are.
685
+ let iterationName = opts.name;
686
+ if (!iterationName) {
687
+ try {
688
+ const study = await client.get(`/studies/${studyId}`);
689
+ const n = Array.isArray(study.iterations) ? study.iterations.length : 0;
690
+ iterationName = n < 26 ? String.fromCharCode(65 + n) : `Iteration ${n + 1}`;
691
+ }
692
+ catch {
693
+ iterationName = "A";
694
+ }
695
+ }
674
696
  const body = {
675
- name: opts.name || `CLI ${new Date().toISOString().slice(0, 16)}`,
697
+ name: iterationName,
676
698
  ...(opts.description !== undefined && { description: opts.description }),
677
699
  ...(details && { details }),
678
700
  };
@@ -14,7 +14,7 @@ import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
14
14
  import { output, formatSimulationPoll } from "../lib/output.js";
15
15
  import { fetchStudyParticipants } from "../lib/study-participants.js";
16
16
  import { streamStudyEvents } from "../lib/study-events.js";
17
- import { isMediaModality, isChatModality, iterationHasContent, describeRequiredContentFlag, readChatMode, readParticipantPairConfig, summarizeRoleCriteria, } from "../lib/modality.js";
17
+ import { isMediaModality, isChatModality, iterationHasContent, describeRequiredContentFlag, readChatMode, readParticipantPairConfig, summarizeRoleCriteria, toModality, } from "../lib/modality.js";
18
18
  // NOTE: local-sim modules are loaded via dynamic import at the `--local`
19
19
  // branch below, NOT statically here. `local-sim/install.ts` deep-imports
20
20
  // `playwright-core/lib/server/registry/index`, which is not exposed by
@@ -663,7 +663,7 @@ Examples:
663
663
  : `CLI default — pass --max-interactions to override`;
664
664
  log(` Max steps: ${stepsForMedia} (${source})`);
665
665
  if (Number.isFinite(stepsForMedia)) {
666
- const est = estimateMediaRun({ participantCount, maxInteractions: stepsForMedia });
666
+ const est = estimateMediaRun({ modality: toModality(modality), participantCount, maxInteractions: stepsForMedia });
667
667
  log(` Credits (est): ≈ ${est.upper_bound} credit(s) upper bound — ${est.breakdown}`);
668
668
  }
669
669
  }
@@ -1021,7 +1021,7 @@ Examples:
1021
1021
  const steps = resolveMaxInteractions(opts.maxInteractions, iteration.details);
1022
1022
  if (!Number.isFinite(steps))
1023
1023
  return null;
1024
- return estimateMediaRun({ participantCount, maxInteractions: steps });
1024
+ return estimateMediaRun({ modality: toModality(modality), participantCount, maxInteractions: steps });
1025
1025
  })();
1026
1026
  if (!opts.wait) {
1027
1027
  if (globals.json) {
@@ -0,0 +1,18 @@
1
+ /**
2
+ * ish study share / ish study unshare — public, no-login share links for a
3
+ * study's results, so a salesperson can send a prospect a URL they open in a
4
+ * browser without an account.
5
+ *
6
+ * Wraps backend endpoints already shipped server-side and used by the web
7
+ * share viewer (`/share/study/[token]`):
8
+ *
9
+ * POST /share/study — create a link → { id, token, share_url, expires_at, created_at }
10
+ * GET /share/study/links — list the current user's study share links
11
+ * DELETE /share/study/{token} — revoke a link by its raw token (204)
12
+ *
13
+ * The backend builds `share_url` from its own `frontend_url` setting, which is
14
+ * a DIFFERENT host than the CLI's app URL (getAppUrl → app.ishlabs.io). Always
15
+ * print the backend's `share_url` verbatim; never reconstruct it client-side.
16
+ */
17
+ import type { Command } from "commander";
18
+ export declare function attachStudyShareCommands(study: Command): void;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * ish study share / ish study unshare — public, no-login share links for a
3
+ * study's results, so a salesperson can send a prospect a URL they open in a
4
+ * browser without an account.
5
+ *
6
+ * Wraps backend endpoints already shipped server-side and used by the web
7
+ * share viewer (`/share/study/[token]`):
8
+ *
9
+ * POST /share/study — create a link → { id, token, share_url, expires_at, created_at }
10
+ * GET /share/study/links — list the current user's study share links
11
+ * DELETE /share/study/{token} — revoke a link by its raw token (204)
12
+ *
13
+ * The backend builds `share_url` from its own `frontend_url` setting, which is
14
+ * a DIFFERENT host than the CLI's app URL (getAppUrl → app.ishlabs.io). Always
15
+ * print the backend's `share_url` verbatim; never reconstruct it client-side.
16
+ */
17
+ import { withClient, resolveStudy, confirmDestructive } from "../lib/command-helpers.js";
18
+ import { tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
19
+ import { output, printTable, ValidationError } from "../lib/output.js";
20
+ import { c } from "../lib/colors.js";
21
+ /** Convert a positive-integer `--expires <days>` into an ISO-8601 UTC datetime. */
22
+ function expiresAtFromDays(raw) {
23
+ const days = Number(raw);
24
+ if (!Number.isInteger(days) || days <= 0) {
25
+ throw new ValidationError(`--expires must be a positive integer number of days, got "${raw}".`, []);
26
+ }
27
+ return new Date(Date.now() + days * 86_400_000).toISOString();
28
+ }
29
+ export function attachStudyShareCommands(study) {
30
+ study
31
+ .command("share")
32
+ .description("Create a public, no-login link to a study's results (summary, frames, " +
33
+ "journeys, segments). Anyone with the link can view it — share with prospects.")
34
+ .argument("[id]", "Study ID (defaults to active study)")
35
+ .option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the study)")
36
+ .option("--expires <days>", "Link expires after N days (default: never)")
37
+ .option("--list", "List all your study share links instead of creating one")
38
+ .addHelpText("after", `
39
+ Examples:
40
+ $ ish study share # share the active study
41
+ $ ish study share <study-id>
42
+ $ ish study share <study-id> --expires 30 # auto-expire in 30 days
43
+ $ ish study share <study-id> --json # { token, share_url, expires_at, ... }
44
+ $ ish study share --list # all your share links
45
+
46
+ The printed share_url is a no-login public URL — anyone with it can view the
47
+ study results. Revoke with \`ish study unshare <token>\`.`)
48
+ .action(async (id, opts, cmd) => {
49
+ await withClient(cmd, async (client, globals) => {
50
+ if (opts.list) {
51
+ const links = await client.get("/share/study/links");
52
+ if (globals.json) {
53
+ output(links.map((l) => ({
54
+ token: l.token,
55
+ study: tagAlias(ALIAS_PREFIX.study, l.study_id),
56
+ expires_at: l.expires_at,
57
+ is_revoked: l.is_revoked,
58
+ })), true, { preProjected: true });
59
+ return;
60
+ }
61
+ if (links.length === 0) {
62
+ console.log("No share links yet. Create one with `ish study share <study-id>`.");
63
+ return;
64
+ }
65
+ printTable(["TOKEN", "STUDY", "EXPIRES", "REVOKED"], links.map((l) => [
66
+ l.token,
67
+ tagAlias(ALIAS_PREFIX.study, l.study_id),
68
+ l.expires_at ?? "never",
69
+ l.is_revoked ? "yes" : "no",
70
+ ]));
71
+ return;
72
+ }
73
+ const studyId = resolveStudy(id);
74
+ const body = { study_id: studyId };
75
+ if (opts.expires !== undefined) {
76
+ body.expires_at = expiresAtFromDays(opts.expires);
77
+ }
78
+ const link = await client.post("/share/study", body);
79
+ if (globals.json) {
80
+ output(link, true, { preProjected: true });
81
+ return;
82
+ }
83
+ // The share_url is the deliverable (a URL to paste into an email) →
84
+ // stdout. Everything else is context → stderr (stdout=data convention).
85
+ console.log(`${c.bold}${c.cyan}${link.share_url}${c.reset}`);
86
+ console.error(`\n ${c.dim}token:${c.reset} ${link.token}`);
87
+ console.error(` ${c.dim}expires:${c.reset} ${link.expires_at ?? "never"}`);
88
+ console.error(`\n ${c.dim}Anyone with this link can view the study results — no login required.${c.reset}`);
89
+ console.error(` ${c.dim}Revoke with:${c.reset} ish study unshare ${link.token}`);
90
+ });
91
+ });
92
+ study
93
+ .command("unshare")
94
+ .description("Revoke a study share link by its token. The public URL stops working immediately.")
95
+ .argument("<token>", "Share link token (from `ish study share` or `ish study share --list`)")
96
+ .option("--workspace <id>", "Workspace ID; accepted for consistency")
97
+ .option("-y, --yes", "Skip the confirmation prompt")
98
+ .addHelpText("after", `
99
+ Examples:
100
+ $ ish study unshare <token>
101
+ $ ish study unshare <token> --yes
102
+
103
+ The <token> is the raw share token, not a study ID or alias. List tokens with
104
+ \`ish study share --list\`.`)
105
+ .action(async (token, opts, cmd) => {
106
+ await withClient(cmd, async (client, globals) => {
107
+ await confirmDestructive(`Revoke share link ${token}? The public URL will stop working.`, { yes: opts.yes, json: globals.json });
108
+ await client.del(`/share/study/${encodeURIComponent(token)}`);
109
+ if (globals.json) {
110
+ output({ token, revoked: true }, true, { writePath: true });
111
+ }
112
+ else {
113
+ console.log(`${c.green}Share link revoked:${c.reset} ${token}`);
114
+ }
115
+ });
116
+ });
117
+ }
@@ -4,6 +4,7 @@
4
4
  import { readFileSync } from "node:fs";
5
5
  import { Option } from "commander";
6
6
  import { withClient, getWebUrl, terminalLink, resolveWorkspace, confirmDestructive, readFileOrStdin, collectIds } from "../lib/command-helpers.js";
7
+ import { validateSegmentation, warnIfOverSegmented } from "../lib/segmentation.js";
7
8
  import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
8
9
  import { loadConfig, saveConfig } from "../config.js";
9
10
  import { formatStudyList, formatStudyDetail, formatStudyResults, buildStudyResultsEnvelope, buildStudyResultsSummary, buildChatTranscript, formatStudyResultsGroupBy, output, ValidationError, } from "../lib/output.js";
@@ -20,6 +21,7 @@ import { attachStudyRunCommands } from "./study-run.js";
20
21
  import { attachStudyParticipantCommands } from "./study-participant.js";
21
22
  import { attachStudyAnalyzeCommands } from "./study-analyze.js";
22
23
  import { attachStudyScreenshotsCommands } from "./study-screenshots.js";
24
+ import { attachStudyShareCommands } from "./study-share.js";
23
25
  function collectRepeatable(value, prev = []) {
24
26
  return prev.concat([value]);
25
27
  }
@@ -116,13 +118,19 @@ Concept pages: ish docs get-page concepts/study
116
118
  .option("--assignments-file <path>", "JSON file with assignments array")
117
119
  .option("--assignments <json>", "Inline JSON array of assignments (escape hatch)")
118
120
  .option("--question <text>", "Add a text question to the questionnaire (repeatable; type=text, timing=after)", collectRepeatable, [])
119
- .option("--questionnaire <path>", "JSON file defining the questionnaire (supports text, slider, likert, single-choice, multiple-choice, number; timing=before|after)")
121
+ .option("--questionnaire <json|@file|path>", "Questionnaire as inline JSON array, @file, or a JSON file path — supports text, slider, likert, single-choice, multiple-choice, number; timing=before|after")
120
122
  .option("--content-text <text>", "Text content to evaluate, or @filepath to read from file. Creates iteration A inline (text modality only)")
121
123
  .option("--url <url>", "URL to test. Creates iteration A inline (interactive modality only)")
122
124
  .option("--screen-format <format>", "Screen format for interactive iterations: desktop (default) or mobile_portrait (hyphen/underscore variants accepted)")
123
125
  .option("--content-url <url>", "Public URL of the media file. Creates iteration A inline (video, audio, document modalities). For local files, use the 2-step `iteration create` flow.")
124
126
  .option("--image-urls <urls>", "Comma-separated public image URLs. Creates iteration A inline (image modality). For local files, use the 2-step `iteration create` flow.")
125
127
  .option("--title <title>", "Content title (text + media modalities — image, video, audio, document; optional). Not used for interactive / chat.")
128
+ .option("--segmentation-json <json>", "Segmentation JSON for the inline iteration A — time_based {intervals_seconds, labels?}, section_based {sections[{name,label,...}]}, or page_based {} (text + media). section_based sections are SEMANTIC: group related paragraphs into a few coherent sections (a long article is usually 3-6 sections, not one per paragraph). Lets one `study create` build a complete segmented iteration — no separate `iteration create` needed.")
129
+ .option("--content-config-json <json>", "Content-config JSON for the inline iteration A (early_termination, selected_segment_indices) — text + media.")
130
+ .option("--content-html <html>", "HTML version of the text, or @filepath — text modality (email rendering)")
131
+ .option("--sender-name <name>", "Email 'From' display name — text modality (email rendering)")
132
+ .option("--sender-email <email>", "Email sender address — text modality (email rendering)")
133
+ .option("--featured-image-url <url>", "Hero image URL — text modality (email rendering)")
126
134
  .option("--endpoint <id>", "Saved chatbot endpoint id or alias. Creates iteration A inline (chat modality, external_chatbot mode)")
127
135
  .option("--endpoint-config <file>", "ChatbotEndpointConfig JSON file (or `-` for stdin); embedded directly. Mutually exclusive with --endpoint (chat modality, external_chatbot mode)")
128
136
  .option("--max-turns <n>", "Maximum conversation turns per participant (chat modality only; default 12)", (v) => Number(v))
@@ -138,9 +146,11 @@ Concept pages: ish docs get-page concepts/study
138
146
  Note: --workspace is optional if set via \`ish workspace use <alias>\`.
139
147
 
140
148
  The questionnaire is the set of questions participants answer. Use \`--question\` to
141
- quickly add simple text questions, or \`--questionnaire <file.json>\` for richer
142
- types (slider, likert, single-choice, multiple-choice, number) and custom
143
- timing. The two forms are mutually exclusive — pick one.
149
+ quickly add simple text questions, or \`--questionnaire\` for richer types (slider,
150
+ likert, single-choice, multiple-choice, number) and custom timing. \`--questionnaire\`
151
+ accepts inline JSON ('[{"question":"How easy?","type":"slider","min":0,"max":10}]'),
152
+ an @file (@/tmp/q.json), or a JSON file path (./questionnaire.json) — no temp file
153
+ required. The two forms are mutually exclusive — pick one.
144
154
 
145
155
  Inline iteration shortcuts (one-shot study + iteration A):
146
156
  --modality interactive --url <url> [--screen-format desktop|mobile_portrait]
@@ -311,6 +321,37 @@ Next: configure a run with \`ish iteration create --study <id>\`,
311
321
  && opts.imageUrls === undefined) {
312
322
  throw new Error("--title only applies with --content-text (text) or --content-url / --image-urls (media). Interactive + chat iterations don't carry a title.");
313
323
  }
324
+ // Inline iteration A can carry segmentation + content-config (text + media)
325
+ // and email-styling (text), so a single `study create` builds one COMPLETE
326
+ // iteration — no separate `iteration create` (which would leave an empty A
327
+ // plus a redundant B). Parse the JSON flags once, here.
328
+ const parseInlineJson = (raw, flag) => {
329
+ if (raw === undefined)
330
+ return undefined;
331
+ try {
332
+ return JSON.parse(raw);
333
+ }
334
+ catch {
335
+ throw new ValidationError(`Invalid ${flag}: expected valid JSON.`, []);
336
+ }
337
+ };
338
+ const inlineMediaExtras = {
339
+ ...(parseInlineJson(opts.segmentationJson, "--segmentation-json") && { segmentation: parseInlineJson(opts.segmentationJson, "--segmentation-json") }),
340
+ ...(parseInlineJson(opts.contentConfigJson, "--content-config-json") && { content_config: parseInlineJson(opts.contentConfigJson, "--content-config-json") }),
341
+ };
342
+ // Segments are semantic sections, not paragraphs: reject a malformed
343
+ // shape (e.g. a missing label) before the network call, and nudge when
344
+ // it looks like one-section-per-paragraph.
345
+ if (inlineMediaExtras.segmentation !== undefined) {
346
+ validateSegmentation(inlineMediaExtras.segmentation);
347
+ warnIfOverSegmented(inlineMediaExtras.segmentation, { quiet: globals.quietExplicit });
348
+ }
349
+ const inlineEmailExtras = {
350
+ ...(opts.contentHtml && { content_html: opts.contentHtml.startsWith("@") ? readFileSync(opts.contentHtml.slice(1), "utf8") : opts.contentHtml }),
351
+ ...(opts.senderName && { sender_name: opts.senderName }),
352
+ ...(opts.senderEmail && { sender_email: opts.senderEmail }),
353
+ ...(opts.featuredImageUrl && { featured_image_url: opts.featuredImageUrl }),
354
+ };
314
355
  let inlineIteration;
315
356
  let chatbotEndpointId = null;
316
357
  if (opts.contentText !== undefined) {
@@ -326,6 +367,8 @@ Next: configure a run with \`ish iteration create --study <id>\`,
326
367
  type: "text",
327
368
  content_text: text,
328
369
  ...(opts.title && { title: opts.title }),
370
+ ...inlineEmailExtras,
371
+ ...inlineMediaExtras,
329
372
  },
330
373
  };
331
374
  }
@@ -361,6 +404,7 @@ Next: configure a run with \`ish iteration create --study <id>\`,
361
404
  type: "image",
362
405
  image_urls: urls,
363
406
  ...(opts.title && { title: opts.title }),
407
+ ...inlineMediaExtras,
364
408
  },
365
409
  };
366
410
  }
@@ -381,6 +425,7 @@ Next: configure a run with \`ish iteration create --study <id>\`,
381
425
  type: opts.modality,
382
426
  content_url: opts.contentUrl,
383
427
  ...(opts.title && { title: opts.title }),
428
+ ...inlineMediaExtras,
384
429
  },
385
430
  };
386
431
  }
@@ -1097,11 +1142,12 @@ When no runs have completed, the default envelope is returned with zero counts a
1097
1142
  .option("--assignments-file <path>", "JSON file with assignments array")
1098
1143
  .option("--assignments <json>", "Inline JSON array of assignments (escape hatch)")
1099
1144
  .option("--question <text>", "Replace the questionnaire with these text questions (repeatable)", collectRepeatable, [])
1100
- .option("--questionnaire <path>", "Replace the questionnaire from a JSON file (full InterviewQuestion shape)")
1145
+ .option("--questionnaire <json|@file|path>", "Replace the questionnaire from inline JSON, @file, or a JSON file path (full InterviewQuestion shape)")
1101
1146
  .addHelpText("after", `
1102
1147
  Replacing the questionnaire: pass either \`--question\` (one or more text
1103
- questions) or \`--questionnaire <file.json>\` (full shape: slider, likert,
1104
- choice, custom timing). The two are mutually exclusive.
1148
+ questions) or \`--questionnaire\` (full shape: slider, likert, choice, custom
1149
+ timing) as inline JSON, an @file, or a JSON file path. The two are mutually
1150
+ exclusive.
1105
1151
 
1106
1152
  Examples:
1107
1153
  $ ish study update <id> --name "Updated Name"
@@ -1207,4 +1253,5 @@ checklists ("steps") ride along when present in the JSON forms
1207
1253
  attachStudyParticipantCommands(study);
1208
1254
  attachStudyAnalyzeCommands(study);
1209
1255
  attachStudyScreenshotsCommands(study);
1256
+ attachStudyShareCommands(study);
1210
1257
  }
@@ -111,7 +111,8 @@ existing workspace was returned. On creation, \`reused: false\`.`)
111
111
  .option("--name <name>", "Workspace name")
112
112
  .option("--description <description>", "Workspace description")
113
113
  .option("--base-url <url>", "Default base URL")
114
- .addHelpText("after", "\nExamples:\n $ ish workspace update <id> --name \"New Name\"\n $ ish workspace update <id> --base-url https://example.com --json")
114
+ .option("--logo <url>", "Brand logo image URL (shown on the workspace and on shared study links)")
115
+ .addHelpText("after", "\nExamples:\n $ ish workspace update <id> --name \"New Name\"\n $ ish workspace update <id> --base-url https://example.com --json\n $ ish workspace update <id> --logo https://logo.clearbit.com/acme.com")
115
116
  .action(async (id, opts, cmd) => {
116
117
  await withClient(cmd, async (client, globals) => {
117
118
  const body = {};
@@ -121,6 +122,8 @@ existing workspace was returned. On creation, \`reused: false\`.`)
121
122
  body.description = opts.description;
122
123
  if (opts.baseUrl !== undefined)
123
124
  body.base_url = opts.baseUrl;
125
+ if (opts.logo !== undefined)
126
+ body.logo_url = opts.logo;
124
127
  if (Object.keys(body).length === 0) {
125
128
  console.error("No update flags provided. Run `ish workspace update --help` for options.");
126
129
  return;
package/dist/connect.d.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * Localhost connect CLI — wraps cloudflared and registers with Ish backend.
3
3
  */
4
+ import { type Route } from "./lib/reverse-proxy.js";
5
+ export type { Route } from "./lib/reverse-proxy.js";
4
6
  export declare function runTunnel(port: number, tokenArg?: string, apiUrlArg?: string, tokenFileArg?: string, outputOpts?: {
5
7
  json?: boolean;
6
8
  quiet?: boolean;
7
- }): Promise<void>;
8
- export declare function runDetached(port: number, apiUrlArg: string | undefined, tokenArg: string | undefined, tokenFileArg: string | undefined): Promise<void>;
9
+ }, routes?: Route[]): Promise<void>;
10
+ export declare function runDetached(port: number, apiUrlArg: string | undefined, tokenArg: string | undefined, tokenFileArg: string | undefined, routes?: Route[]): Promise<void>;
9
11
  export declare function connectStatus(json: boolean): void;
10
12
  export declare function disconnect(json: boolean): Promise<void>;