@ishlabs/cli 0.23.0 → 0.24.0

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.
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Validation + nudge for media/text `segmentation` (the parsed value of
3
+ * `--segmentation-json` on `study create` / `iteration create`).
4
+ *
5
+ * THE PRINCIPLE these guard: **segments are semantic sections, not
6
+ * paragraphs.** Group related paragraphs into a few coherent sections
7
+ * (intro → argument → conclusion). A long article is usually 3–6 sections,
8
+ * not one per paragraph; `paragraph_start`/`paragraph_end` only mark where a
9
+ * section begins and ends — the unit is the *section*.
10
+ *
11
+ * - `validateSegmentation` is FATAL (throws ValidationError → exit 2) on a
12
+ * malformed `section_based` shape — most importantly a missing/empty label,
13
+ * which the backend would otherwise reject after a network round-trip.
14
+ * - `warnIfOverSegmented` is NON-FATAL: an agent that ignores the docs and
15
+ * emits one section per paragraph gets a stderr nudge, but is never blocked
16
+ * (over-segmenting can be intentional).
17
+ *
18
+ * Both take the already-JSON-parsed object; `undefined` is a no-op.
19
+ */
20
+ import { writeSync } from "node:fs";
21
+ import { c } from "./colors.js";
22
+ import { ValidationError } from "./output.js";
23
+ /** Throw on a malformed segmentation shape. No-op for undefined / unknown types. */
24
+ export function validateSegmentation(seg) {
25
+ if (!seg || typeof seg !== "object")
26
+ return;
27
+ const s = seg;
28
+ if (s.type === "section_based") {
29
+ const sections = s.sections;
30
+ if (!Array.isArray(sections) || sections.length === 0) {
31
+ throw new ValidationError("section_based segmentation needs a non-empty `sections` array.", [], "Group related paragraphs into a few semantic sections (intro, argument, conclusion) — not one per paragraph.");
32
+ }
33
+ sections.forEach((raw, i) => {
34
+ const sec = (raw ?? {});
35
+ const name = typeof sec.name === "string" ? sec.name.trim() : "";
36
+ const label = typeof sec.label === "string" ? sec.label.trim() : "";
37
+ if (!name) {
38
+ throw new ValidationError(`section_based sections[${i}] is missing a non-empty \`name\`.`, []);
39
+ }
40
+ if (!label) {
41
+ throw new ValidationError(`section_based sections[${i}] ("${name}") is missing a non-empty \`label\`. ` +
42
+ "Every section needs a human-readable label — it surfaces in the participant UI and in results.", []);
43
+ }
44
+ // Paragraph-bounded sections: validate the range when present. (A
45
+ // marker-bounded section_based variant may omit these — don't require.)
46
+ const start = sec.paragraph_start;
47
+ const end = sec.paragraph_end;
48
+ if (start !== undefined || end !== undefined) {
49
+ if (typeof start !== "number" || typeof end !== "number" || start < 0 || end <= start) {
50
+ throw new ValidationError(`section_based sections[${i}] ("${name}") has an invalid paragraph range ` +
51
+ `(paragraph_start=${String(start)}, paragraph_end=${String(end)}). ` +
52
+ "Need paragraph_start >= 0 and paragraph_end > paragraph_start.", []);
53
+ }
54
+ }
55
+ });
56
+ return;
57
+ }
58
+ if (s.type === "time_based") {
59
+ const iv = s.intervals_seconds;
60
+ if (Array.isArray(iv)) {
61
+ for (let i = 1; i < iv.length; i++) {
62
+ const prev = iv[i - 1];
63
+ const cur = iv[i];
64
+ if (typeof prev !== "number" || typeof cur !== "number" || cur <= prev) {
65
+ throw new ValidationError(`time_based intervals_seconds must be strictly ascending numbers ` +
66
+ `(problem at index ${i}: ${String(prev)} → ${String(cur)}).`, []);
67
+ }
68
+ }
69
+ }
70
+ }
71
+ }
72
+ /**
73
+ * Non-fatal nudge toward semantic sections. Conservative on purpose: only
74
+ * fires for `section_based` with >= 5 sections that EACH span a single
75
+ * paragraph — the signature of one-section-per-paragraph — so a genuine
76
+ * 3-section piece never trips it. stderr only (keeps --json stdout clean);
77
+ * suppressed under --quiet.
78
+ */
79
+ export function warnIfOverSegmented(seg, opts = {}) {
80
+ if (opts.quiet)
81
+ return;
82
+ if (!seg || typeof seg !== "object")
83
+ return;
84
+ const s = seg;
85
+ if (s.type !== "section_based" || !Array.isArray(s.sections))
86
+ return;
87
+ const sections = s.sections;
88
+ if (sections.length < 5)
89
+ return;
90
+ const allSingleParagraph = sections.every((sec) => {
91
+ const start = sec?.paragraph_start;
92
+ const end = sec?.paragraph_end;
93
+ return typeof start === "number" && typeof end === "number" && end - start <= 1;
94
+ });
95
+ if (!allSingleParagraph)
96
+ return;
97
+ // Synchronous fd-2 write, not console.error: this fires moments before the
98
+ // command's own output + a process.exit (via exitWithFlush), which truncates
99
+ // async-buffered stderr writes to a pipe/file. writeSync guarantees the nudge
100
+ // lands.
101
+ writeSync(2, `${c.yellow}⚠ ${sections.length} single-paragraph sections.${c.reset} ` +
102
+ "Segments are meant to be semantic sections — group related paragraphs into a few " +
103
+ "coherent sections (e.g. intro → argument → conclusion), not one per paragraph. " +
104
+ "A long article is usually 3–6 sections. Proceeding as-is.\n");
105
+ }
@@ -207,6 +207,10 @@ The most common multi-turn question: "user wants to change X — re-use the exis
207
207
 
208
208
  When in doubt: side-by-side comparison usually beats in-place edits. Ids are cheap; result history isn't.
209
209
 
210
+ ## Sharing results (no-login link)
211
+
212
+ To hand a study to someone **without an ish account** — a prospect, a stakeholder — create a public share link. \`ish study share [study]\` prints a no-login \`share_url\` to the web viewer (summary, key insights, participant journeys, interactive frames, segment breakdowns). \`ish study share --list\` lists your links; \`ish study unshare <token>\` revokes one (takes the **raw token**, not a study id/alias). \`--expires <days>\` auto-expires the link. Brand the link by setting a workspace logo first: \`ish workspace update <id> --logo <url>\` — the logo shows on the shared page. Share **after** the study has run + been analyzed, so the viewer renders the summary + insights. Deep dive: \`ish docs get-page concepts/sharing\`. (CLI-only — the MCP has no share tool yet.)
213
+
210
214
  ## Pitfalls
211
215
 
212
216
  - **Cold start on free plan**: \`workspace_create\` returns \`usage_limit_reached\` at the free-plan cap (1 workspace). Always inspect with \`workspace_list\` first. **MCP-only recipe** (no \`--ensure\` available): \`workspace_list\` → if non-empty, use the first; if empty, \`workspace_create\`; if \`workspace_create\` returns \`usage_limit_reached\`, re-call \`workspace_list\` (a workspace exists you didn't see — possibly created by another session). **CLI shortcut**: \`ish workspace create --name <name> --ensure\` is idempotent by name.
@@ -222,9 +226,10 @@ When in doubt: side-by-side comparison usually beats in-place edits. Ids are che
222
226
  - **No per-page/per-timestamp scoping for media**: there's no "evaluate just slide 14" or "react to seconds 0-30" API. State the focus explicitly in the \`assignment\` text, or pre-stitch the artifact (e.g. replace one slide locally, upload as a new iteration).
223
227
  - **\`study get --json\` participants live at the top level**, not nested under \`iterations[*].participants\`. The backend split made \`/studies/{id}\` lite (metadata + iteration shells, no participant graph) and added \`/studies/{id}/participants\`; the CLI joins them so \`study get --json\` carries a flat \`participants[]\` with \`iteration_id\` on each row. Read \`.participants[]\`, not \`.iterations[].participants[]\`.
224
228
  - **All destructive deletes require \`--yes\` in non-TTY mode**: \`ish workspace delete\`, \`study delete\`, \`ask delete\`, \`person delete\`, \`source delete\`, \`chat endpoint delete\`. In \`--json\` mode (or any piped/non-TTY invocation), omitting \`--yes\` refuses with \`error_kind: "ConfirmationRequired"\` + an \`example\` field showing the same command with \`--yes\` appended. \`workspace delete\` is the highest-blast-radius: it removes ALL nested studies, asks, people, secrets, configs, sources, and chat endpoints — the prompt names them explicitly.
225
- - **\`ish login\` is idempotent**: with a valid saved token, \`ish login\` short-circuits with "Already logged in" and **does not open a new browser tab**. Use \`--force\` (or \`-f\`) only when actually switching accounts.
229
+ - **\`ish login\` is idempotent**: with a saved token that is unexpired *and* still accepted by the API, \`ish login\` short-circuits with "Already logged in" and **does not open a new browser tab**. If the token is unexpired but the server rejects it (revoked, rotated signing key, or minted against the wrong env — e.g. a dev-Supabase token while calling the prod api), it re-runs the browser flow instead of falsely reporting success. Use \`--force\` (or \`-f\`) only when actually switching accounts.
226
230
  - **\`ish person create\` accepts inline flags** (mirrors \`person update\`): the file-only API (\`--file <path>\`) is preserved as an escape hatch but the common path is \`ish person create --name "X" --type ai --country US ...\` — \`--type\` defaults to \`ai\` when \`--file\` is omitted. See \`ish person create --help\` for the full inline-flag set including \`--household\` (MECE rule applies) and \`--accessibility-profile\`.
227
231
  - **\`ish status\` now surfaces \`chat_endpoint\`** alongside \`workspace\`/\`study\`/\`ask\`. Stale or orphan active refs get a \`warning\` + \`hint\` field on the affected ref (instead of silently dropping the \`name\`). On \`workspace use <other>\`, the CLI cascade-clears \`study\`/\`ask\`/\`chat_endpoint\` (they belong to the previous workspace).
232
+ - **Share link URL host ≠ API host**: \`ish study share\` prints the backend-built \`share_url\` (the web frontend host). Use it verbatim — never reconstruct the URL from the API host or app URL; they differ. \`ish study unshare\` takes the **raw token** (from \`study share\` / \`study share --list\`), not a study id or alias.
228
233
 
229
234
  ## When in doubt
230
235
 
@@ -259,14 +264,22 @@ ish person generate \\
259
264
  # 4. Define the study + iteration A in one call (one-shot path).
260
265
  # The same shape works for image (--image-urls), video / audio /
261
266
  # document (--content-url <url>), and chat (--endpoint <id>).
267
+ # For text/media you can also pass --segmentation-json (+ email-styling
268
+ # --content-html/--sender-name/--sender-email/--featured-image-url) here,
269
+ # so a single SEGMENTED iteration is one call — no separate iteration
270
+ # create (which would leave an empty A + a redundant B).
271
+ # Segments are SEMANTIC sections, not paragraphs: group related paragraphs
272
+ # into a few coherent sections (a long article is usually 3-6 sections, not
273
+ # one per paragraph). The CLI errors on a missing label and warns on
274
+ # one-section-per-paragraph.
262
275
  ish study create --name "Onboarding UX" --modality interactive \\
263
276
  --url https://example.com --screen-format desktop \\
264
277
  --assignment "Sign up:Complete the signup flow" \\
265
278
  --question "How easy was it?"
266
279
  ish study use s-…
267
280
 
268
- # (Optional) add a B variant later instead of inline:
269
- # ish iteration create --url https://example.com/v2
281
+ # (Optional) add a SECOND iteration only when you actually want to A/B:
282
+ # ish iteration create --url https://example.com/v2 # auto-named "B" (next letter), not "CLI <date>"
270
283
 
271
284
  # 6. Run, blocking until done
272
285
  ish study run --all --wait
@@ -302,6 +315,59 @@ ish study get s-… # human: "✓ Add to cart 4/5 (80%)" p
302
315
  ish study get s-… --json --verbose # step_completion[] incl. sample_failures[].participant_id
303
316
  \`\`\`
304
317
 
318
+ ### Rich question types (slider, likert, choice, number)
319
+
320
+ \`--question\` makes simple text questions only. For \`slider\`, \`likert\`,
321
+ \`single-choice\`, \`multiple-choice\`, \`number\`, or \`timing: "before"\`, use
322
+ \`--questionnaire\` — which takes **inline JSON, an @file, or a path** (no temp
323
+ file required). \`--question\` and \`--questionnaire\` are mutually exclusive.
324
+
325
+ \`\`\`bash
326
+ ish study create --name "Pricing page" --modality interactive --url https://example.com \\
327
+ --assignment "React:Look around as you normally would" \\
328
+ --questionnaire '[
329
+ {"question":"What do you think this does?","type":"text","timing":"after"},
330
+ {"question":"How easy was it to understand?","type":"slider","min":0,"max":10},
331
+ {"question":"How strongly do you agree it is for you?","type":"likert",
332
+ "labels":["Strongly disagree","Disagree","Neutral","Agree","Strongly agree"]},
333
+ {"question":"Which fits best?","type":"single-choice","options":["A","B","C"]},
334
+ {"question":"How many seats would you need?","type":"number"}
335
+ ]'
336
+ # @file and path forms also work: --questionnaire @/tmp/q.json | ./q.json
337
+ \`\`\`
338
+
339
+ \`slider\` takes \`min\`/\`max\`(/\`step\`); \`likert\` takes \`labels\`;
340
+ \`single-choice\`/\`multiple-choice\` take \`options\`. The CLI tolerates
341
+ underscored type spellings (\`single_choice\`); the backend validates the shape.
342
+ Same input forms on \`ish ask … --questions\`. See \`ish docs get-page concepts/questionnaire\`.
343
+
344
+ ### 1b. Share the results with someone (no-login link)
345
+
346
+ Goal: hand the finished study to a prospect or stakeholder who has no ish
347
+ account. Run + analyze first so the public viewer renders the summary.
348
+
349
+ \`\`\`bash
350
+ # (optional) brand the workspace — the logo shows on the shared page
351
+ ish workspace update w-… --logo https://logo.clearbit.com/acme.com
352
+
353
+ # generate the AI summary + key insights (needs ≥5 completed participants)
354
+ ish study analyze s-… --wait
355
+
356
+ # create the public, no-login link — the printed share_url is the deliverable
357
+ ish study share s-…
358
+ # → https://<frontend>/share/study/Hk9_… (paste into an email)
359
+
360
+ ish study share s-… --expires 30 # auto-expire in 30 days
361
+ ish study share s-… --json # { token, share_url, expires_at, created_at, id }
362
+
363
+ # manage links
364
+ ish study share --list # all your links: token, study, expires, revoked
365
+ ish study unshare Hk9_… --yes # revoke by RAW TOKEN — URL stops working
366
+ \`\`\`
367
+
368
+ The \`share_url\` host is the web frontend (built server-side) — use it
369
+ verbatim; don't reconstruct it. Deep dive: \`ish docs get-page concepts/sharing\`.
370
+
305
371
  ## 2. Quick A/B ask with image variants
306
372
 
307
373
  Goal: ship 30 simulated reactions to two hero images, with a "which do
@@ -585,7 +651,7 @@ also in \`study poll --json\`. Branch on it instead of treating
585
651
  \`interaction_count: 0\` as a generic failure.
586
652
 
587
653
  Pre-flight tip: \`ish workspace info\` exposes
588
- \`{studies_used, studies_max, participants_used, participants_max, tier}\` so
654
+ \`{studies_used, studies_max, people_used, people_max, concurrent_participants_max, workspace_members_max, tier}\` so
589
655
  you can branch on plan caps before \`study create\` returns
590
656
  \`error_code: usage_limit_reached\`.
591
657
 
@@ -1035,6 +1101,12 @@ table, projection shapes, and the defensive null-handling rules.
1035
1101
  - For \`ask\` write-paths (update/archive/wait/add-questions/add-people),
1036
1102
  default JSON is compact (changed fields + alias). Pass \`--verbose\` for
1037
1103
  the full Ask payload.
1104
+ - The \`ish study create\`/\`update --json\` echo always shows
1105
+ \`assignments\` and \`interview_questions\` — \`[]\` when the study has
1106
+ none, never dropped. Trust it: an empty \`assignments\` means you
1107
+ genuinely created a study with no assignment (add one before
1108
+ \`study run\`, or it fails with "Study has no assignments"); you don't
1109
+ need a follow-up \`study get --verbose\` to tell "none" from "stripped".
1038
1110
  - \`person generate --json\` returns \`{job: {id, status, person_ids},
1039
1111
  profiles: [...]}\`; each person is the lean person shape with its
1040
1112
  evidence-grounded \`scenarios\` attached (\`--no-scenarios\` to omit,
@@ -1127,7 +1199,7 @@ ish <command> --help
1127
1199
  | \`mcp\` | Wire the hosted ish MCP server into local AI | guides/mcp-add |
1128
1200
  | | clients (Cursor, VS Code, Claude Code, | |
1129
1201
  | | Claude Desktop, Windsurf). Idempotent. | |
1130
- | \`login\` | Browser-based auth. Idempotent: short-circuits on valid saved token. \`--force\` to switch accounts. | — |
1202
+ | \`login\` | Browser-based auth. Idempotent: short-circuits only when the saved token is unexpired AND server-accepted. \`--force\` to switch accounts. | — |
1131
1203
  | \`logout\` | Clear saved credentials | — |
1132
1204
  | \`status\` | Show active session (user, workspace, | concepts/active-context |
1133
1205
  | | study, ask, token validity) — alias \`whoami\` | |
@@ -22,6 +22,8 @@ export interface ProductUpdateInput {
22
22
  description?: string;
23
23
  color?: string;
24
24
  base_url?: string;
25
+ /** External logo image URL — backend sets `logo.path` directly (feeds product_logo_url on shared study links). */
26
+ logo_url?: string;
25
27
  }
26
28
  export type SecretScope = "agent" | "project";
27
29
  export type SecretVariableType = "secret" | "variable";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ishlabs/cli",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "The command-line interface for ish",
5
5
  "type": "module",
6
6
  "bin": {
@@ -45,9 +45,11 @@
45
45
  "@sentry/bun": "^10.13.0",
46
46
  "@sentry/node": "^10.13.0",
47
47
  "commander": "^13.0.0",
48
+ "http-proxy": "^1.18.1",
48
49
  "playwright-core": "^1.58.2"
49
50
  },
50
51
  "devDependencies": {
52
+ "@types/http-proxy": "^1.17.17",
51
53
  "@types/node": "^22.0.0",
52
54
  "typescript": "^5.7.0"
53
55
  }