@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.
@@ -2,41 +2,90 @@
2
2
  * Credit cost estimators — mirror the backend's billing formulas so the CLI
3
3
  * can surface a pre-dispatch estimate without a network round-trip.
4
4
  *
5
- * The numbers here MUST match `ish-backend/app/{media,chat}/billing.py`
6
- * and `app/billing/service.py`. If the backend formula changes, update
7
- * this file in the same commit, otherwise the CLI will mislead agents.
5
+ * Source of truth: `ish-backend/app/billing/rates.py` (`MODALITY_RATES`,
6
+ * `compute_step_cost`, `compute_chat_cost`, `ASK_PER_RESPONSE_CREDITS`,
7
+ * `PAIR_CHAT_SIDE_MULTIPLIER`). These numbers MUST match that module when a
8
+ * multiplier changes there, update this file in the same commit, otherwise the
9
+ * CLI will mislead agents. The backend prices through one `price_run` dispatcher
10
+ * shared by `POST /billing/estimate` and the real preflight reservation, so the
11
+ * preview here is calibrated to the actual charge as long as the rates agree.
8
12
  *
9
- * Today every modality uses the same shape: `max(1, round(steps / 10))`
10
- * per principal (per participant for media/interactive, per conversation for
11
- * chat, ×2 for participant-pair). Asks bill flat 1 credit per successful
12
- * participant response. These are intentionally per-run estimates; long-term
13
- * we'll fetch `GET /billing/rates` and parameterise modalitiessee
14
- * `reference/credits` docs page.
13
+ * Each modality has its own per-step rate (interactive costs the most —
14
+ * screenshot + vision per step; text-only the least). The per-principal cost is
15
+ * `max(1, round(steps * per_step_credits))`, per participant for step-based
16
+ * modalities and per conversation (×2 in pair mode) for chat. Asks bill a flat
17
+ * credit per successful participant response (an upper bound refusals/errors
18
+ * don't bill). Clients that need authoritative live rates can fetch them from
19
+ * `GET /billing/rates`; this offline mirror is for the pre-dispatch preview.
15
20
  */
16
- /** Mirror of `app/media/billing.py::media_credit_cost`. */
17
- export function mediaCreditCost(steps) {
21
+ /**
22
+ * Per-step credit multiplier per modality. Mirror of
23
+ * `app/billing/rates.py::MODALITY_RATES` (the `per_step_credits` field).
24
+ */
25
+ const PER_STEP_CREDITS = {
26
+ interactive: 1.0,
27
+ video: 0.5,
28
+ audio: 0.3,
29
+ image: 0.2,
30
+ text: 0.1,
31
+ document: 0.3,
32
+ chat: 0.2,
33
+ };
34
+ /** Mirror of `app/billing/rates.py::ASK_PER_RESPONSE_CREDITS`. */
35
+ const ASK_PER_RESPONSE_CREDITS = 1;
36
+ /** Mirror of `app/billing/rates.py::PAIR_CHAT_SIDE_MULTIPLIER`. */
37
+ const PAIR_CHAT_SIDE_MULTIPLIER = 2;
38
+ /**
39
+ * Round half-to-even ("banker's rounding") to match Python's built-in
40
+ * `round()`, which the backend uses. `Math.round` rounds half *up*, so for
41
+ * costs that land on a .5 boundary (e.g. video at rate 0.5 with an odd step
42
+ * count: 5 × 0.5 = 2.5) it would over-quote by one credit vs the real charge.
43
+ * Replicating banker's rounding keeps the preview byte-for-byte with the
44
+ * backend's `compute_step_cost` / `compute_chat_cost`.
45
+ */
46
+ function bankersRound(value) {
47
+ const floor = Math.floor(value);
48
+ const diff = value - floor;
49
+ if (diff < 0.5)
50
+ return floor;
51
+ if (diff > 0.5)
52
+ return floor + 1;
53
+ // Exactly .5 — round to the nearest even integer.
54
+ return floor % 2 === 0 ? floor : floor + 1;
55
+ }
56
+ /**
57
+ * Per-principal step-based cost for one participant running `steps` interactions
58
+ * on a study of `modality`. Mirror of `app/billing/rates.py::compute_step_cost`.
59
+ */
60
+ export function stepCreditCost(modality, steps) {
18
61
  if (!Number.isFinite(steps) || steps <= 0)
19
62
  return 1;
20
- return Math.max(1, Math.round(steps / 10));
63
+ return Math.max(1, bankersRound(steps * PER_STEP_CREDITS[modality]));
21
64
  }
22
- /** Mirror of `app/chat/billing.py::chat_credit_cost`. */
23
- export function chatCreditCost(turns) {
65
+ /**
66
+ * Per-conversation chat cost. Mirror of `app/billing/rates.py::compute_chat_cost`
67
+ * — `max(1, round(turns * chat_rate))`, doubled in pair mode (both sides bill
68
+ * per turn).
69
+ */
70
+ export function chatCreditCost(turns, isPair = false) {
24
71
  if (!Number.isFinite(turns) || turns <= 0)
25
- return 1;
26
- return Math.max(1, Math.round(turns / 10));
72
+ return isPair ? PAIR_CHAT_SIDE_MULTIPLIER : 1;
73
+ const perSide = Math.max(1, bankersRound(turns * PER_STEP_CREDITS.chat));
74
+ return isPair ? perSide * PAIR_CHAT_SIDE_MULTIPLIER : perSide;
27
75
  }
28
76
  /**
29
- * Media/interactive run: 1 credit-cost-per-participant × participant count. Modality
30
- * doesn't currently affect the rate (interactive == text == video at the
31
- * billing layer) — kept as a parameter for forward compatibility.
77
+ * Step-based run (interactive / text / image / video / audio / document):
78
+ * per-participant cost × participant count. The per-step rate is modality-
79
+ * specific (see `PER_STEP_CREDITS`).
32
80
  */
33
81
  export function estimateMediaRun(args) {
34
- const perParticipant = mediaCreditCost(args.maxInteractions);
82
+ const perParticipant = stepCreditCost(args.modality, args.maxInteractions);
35
83
  const total = Math.max(0, args.participantCount) * perParticipant;
84
+ const rate = PER_STEP_CREDITS[args.modality];
36
85
  return {
37
86
  upper_bound: total,
38
87
  formula: "media_per_participant",
39
- breakdown: `${args.participantCount} participant(s) × max(1, round(${args.maxInteractions} steps / 10)) = ${args.participantCount} × ${perParticipant} = ${total}`,
88
+ breakdown: `${args.participantCount} participant(s) × max(1, round(${args.maxInteractions} steps × ${rate})) = ${args.participantCount} × ${perParticipant} = ${total}`,
40
89
  unit: "credits",
41
90
  };
42
91
  }
@@ -47,18 +96,19 @@ export function estimateChatSolo(args) {
47
96
  return {
48
97
  upper_bound: total,
49
98
  formula: "chat_solo",
50
- breakdown: `${args.participantCount} participant(s) × max(1, round(${args.maxTurns} turns / 10)) = ${args.participantCount} × ${perParticipant} = ${total}`,
99
+ breakdown: `${args.participantCount} participant(s) × max(1, round(${args.maxTurns} turns × ${PER_STEP_CREDITS.chat})) = ${args.participantCount} × ${perParticipant} = ${total}`,
51
100
  unit: "credits",
52
101
  };
53
102
  }
54
103
  /** Participant-pair chat: each turn bills both sides, so cost doubles. */
55
104
  export function estimateChatPair(args) {
56
- const perSide = chatCreditCost(args.maxTurns);
57
- const total = Math.max(0, args.conversationCount) * perSide * 2;
105
+ const perConversation = chatCreditCost(args.maxTurns, true);
106
+ const perSide = perConversation / PAIR_CHAT_SIDE_MULTIPLIER;
107
+ const total = Math.max(0, args.conversationCount) * perConversation;
58
108
  return {
59
109
  upper_bound: total,
60
110
  formula: "chat_pair",
61
- breakdown: `${args.conversationCount} conv × max(1, round(${args.maxTurns} turns / 10)) × 2 sides = ${args.conversationCount} × ${perSide} × 2 = ${total}`,
111
+ breakdown: `${args.conversationCount} conv × max(1, round(${args.maxTurns} turns × ${PER_STEP_CREDITS.chat})) × ${PAIR_CHAT_SIDE_MULTIPLIER} sides = ${args.conversationCount} × ${perSide} × ${PAIR_CHAT_SIDE_MULTIPLIER} = ${total}`,
62
112
  unit: "credits",
63
113
  };
64
114
  }
@@ -67,11 +117,11 @@ export function estimateChatPair(args) {
67
117
  * for completed responses; the upper bound assumes everyone completes).
68
118
  */
69
119
  export function estimateAskRound(args) {
70
- const total = Math.max(0, args.participantCount);
120
+ const total = Math.max(0, args.participantCount) * ASK_PER_RESPONSE_CREDITS;
71
121
  return {
72
122
  upper_bound: total,
73
123
  formula: "ask_per_response",
74
- breakdown: `${args.participantCount} participant(s) × 1 credit/response = ${total} (upper bound; only successful responses bill)`,
124
+ breakdown: `${args.participantCount} participant(s) × ${ASK_PER_RESPONSE_CREDITS} credit/response = ${total} (upper bound; only successful responses bill)`,
75
125
  unit: "credits",
76
126
  };
77
127
  }
@@ -657,14 +657,38 @@ function noActiveContextError(message, errorCode, suggestions) {
657
657
  err.suggestions = suggestions;
658
658
  return err;
659
659
  }
660
+ /**
661
+ * Reject an explicitly-supplied-but-empty context flag (`--study ""`,
662
+ * `--workspace " "`). Distinct from the flag being *omitted*
663
+ * (`explicit === undefined`), which legitimately falls through to env →
664
+ * active-config. An empty/whitespace value is a usage error: it would
665
+ * otherwise be falsy and silently resolve to the active context, attaching
666
+ * work to the wrong entity. Maps to exit 2 via the ValidationError name.
667
+ */
668
+ function rejectEmptyContextFlag(explicit, flag, noun) {
669
+ if (!explicit.trim()) {
670
+ const err = new Error(`${flag} was given an empty value. Pass a ${noun} id/alias, or omit it to use the active ${noun}.`);
671
+ err.name = "ValidationError";
672
+ throw err;
673
+ }
674
+ }
660
675
  export function resolveWorkspace(explicit) {
661
- if (explicit)
676
+ if (explicit !== undefined) {
677
+ rejectEmptyContextFlag(explicit, "--workspace", "workspace");
662
678
  return resolveId(explicit);
679
+ }
663
680
  // Fall back to the program-root --workspace cached by applyGlobals — covers
664
681
  // `ish --workspace W study list` where the subcommand action doesn't see the
665
- // flag in its local opts.
666
- if (_activeWorkspace)
682
+ // flag in its local opts. The GLOBAL --workspace shadows the subcommand's
683
+ // local one, so an empty `--workspace ""` arrives HERE (as `_activeWorkspace
684
+ // === ""`), never via `explicit`. computeGlobals preserves the empty-vs-
685
+ // omitted distinction (`""` when passed empty, `undefined` when omitted), so
686
+ // apply the same empty-value guard as `explicit` above — otherwise
687
+ // `--workspace ""` silently falls through to env/saved config (exit 0).
688
+ if (_activeWorkspace !== undefined) {
689
+ rejectEmptyContextFlag(_activeWorkspace, "--workspace", "workspace");
667
690
  return resolveId(_activeWorkspace);
691
+ }
668
692
  const env = process.env.ISH_WORKSPACE;
669
693
  if (env)
670
694
  return resolveId(env);
@@ -677,8 +701,10 @@ export function resolveWorkspace(explicit) {
677
701
  ]);
678
702
  }
679
703
  export function resolveStudy(explicit) {
680
- if (explicit)
704
+ if (explicit !== undefined) {
705
+ rejectEmptyContextFlag(explicit, "--study", "study");
681
706
  return resolveId(explicit);
707
+ }
682
708
  const env = process.env.ISH_STUDY;
683
709
  if (env)
684
710
  return resolveId(env);
@@ -691,8 +717,10 @@ export function resolveStudy(explicit) {
691
717
  ]);
692
718
  }
693
719
  export function resolveAsk(explicit) {
694
- if (explicit)
720
+ if (explicit !== undefined) {
721
+ rejectEmptyContextFlag(explicit, "--ask", "ask");
695
722
  return resolveId(explicit);
723
+ }
696
724
  const env = process.env.ISH_ASK;
697
725
  if (env)
698
726
  return resolveId(env);
package/dist/lib/docs.js CHANGED
@@ -98,10 +98,20 @@ ish workspace list
98
98
  ish workspace create --name "My product" --base-url https://example.com
99
99
  ish workspace use w-6ec # set as active
100
100
  ish workspace get # show the active workspace
101
+ ish workspace update w-6ec --logo https://logo.clearbit.com/acme.com # brand logo
101
102
  ish workspace info # usage counters + plan caps (see below)
102
103
  ish workspace site-access status
103
104
  \`\`\`
104
105
 
106
+ ## Branding a workspace (\`--logo\`)
107
+
108
+ \`ish workspace update <id> --logo <url>\` sets a brand logo from an
109
+ external image URL. The logo shows on the workspace and — importantly —
110
+ on **shared study links** (\`ish study share\`), so a prospect opening the
111
+ public link sees the demo branded with their own logo. There is no
112
+ \`--logo\` on \`workspace create\`; create first, then update. See
113
+ \`concepts/sharing\`.
114
+
105
115
  ## Checking usage before destructive calls
106
116
 
107
117
  \`ish workspace info\` shows usage counters so an agent can branch on
@@ -217,6 +227,15 @@ its iterations. Think: a study is the recipe; an iteration is one batch.
217
227
  iteration A inline in the same call. Useful when you have a single
218
228
  test artifact and don't need to A/B iterations:
219
229
 
230
+ For text + media, the inline iteration A can also carry
231
+ \`--segmentation-json\` (+ \`--content-config-json\`) and the text
232
+ email-styling flags (\`--content-html\`, \`--sender-name\`,
233
+ \`--sender-email\`, \`--featured-image-url\`). So a single-iteration
234
+ **segmented** study is one \`study create\` call — you do NOT need a
235
+ second \`iteration create\` (which would leave an empty A plus a
236
+ redundant B). Reach for \`iteration create\` only when you genuinely
237
+ want a 2nd iteration to A/B.
238
+
220
239
  | Modality | Inline content flag |
221
240
  |-----------------|------------------------------------------------------|
222
241
  | \`interactive\` | \`--url <url>\` (\`--screen-format desktop\` is the default; pass \`mobile_portrait\` for mobile) |
@@ -402,6 +421,14 @@ Each segment can carry a human-readable **label** ("Intro", "Pricing
402
421
  section", "Call to action") that surfaces in the participant UI and in
403
422
  results.
404
423
 
424
+ **Segments are semantic sections, not paragraphs.** Group related
425
+ paragraphs into a few coherent sections — a 16-paragraph article is
426
+ usually 3–6 sections (e.g. "Lede", "The argument", "Counterpoints",
427
+ "Conclusion"), not 16. \`paragraph_start\`/\`paragraph_end\` only mark
428
+ where a section begins and ends; the unit you are choosing is the
429
+ *section*. The CLI errors on a missing label and warns when you emit one
430
+ section per paragraph.
431
+
405
432
  Segments live inside the iteration's \`segmentation\` field — there is
406
433
  no separate segments resource. Three discriminated shapes:
407
434
 
@@ -431,6 +458,11 @@ no separate segments resource. Three discriminated shapes:
431
458
  }
432
459
  \`\`\`
433
460
 
461
+ The three sections above each group several paragraphs (greeting +
462
+ context, the body, the call to action) — semantic grouping, not one
463
+ section per paragraph. Adjust the ranges to your content's logical
464
+ structure.
465
+
434
466
  - **page_based** (document): pages are auto-derived from the document.
435
467
  No additional fields.
436
468
 
@@ -888,12 +920,16 @@ Two flags, mutually exclusive:
888
920
  # --question is repeatable. Defaults to type=text, timing=after.
889
921
  ish study create … --question "How easy was it?" --question "Anything confusing?"
890
922
 
891
- # Richer types from a JSON manifest:
892
- ish study create --questionnaire ./questionnaire.json
923
+ # Richer types via --questionnaire. Three interchangeable input forms — no
924
+ # temp file required (mirrors how --assignments takes inline JSON):
925
+ ish study create … --questionnaire '[{"question":"How easy?","type":"slider","min":0,"max":10}]' # inline JSON
926
+ ish study create … --questionnaire @/tmp/questionnaire.json # @file
927
+ ish study create … --questionnaire ./questionnaire.json # bare path
893
928
  \`\`\`
894
929
 
895
- \`questionnaire.json\` is an array of question objects in the shape above.
896
- The same shape is accepted by \`ish ask add-questions --questions …\`.
930
+ The payload is always an array of question objects in the shape above
931
+ (inline JSON must start with \`[\`; an \`@\`-prefixed or bare value is read
932
+ from disk). The same three input forms are accepted by \`ish ask … --questions\`.
897
933
 
898
934
  The \`type\` field is hyphenated for the multi-word values (\`single-choice\`,
899
935
  \`multiple-choice\`). The CLI normalises the underscored variants
@@ -2130,11 +2166,27 @@ The CLI guarantees these contracts so agents can chain safely:
2130
2166
  \`--fields\` set, you can identify the affected resource. Default
2131
2167
  write-path JSON is compact (\`{id, alias, name, updated_at,
2132
2168
  ...changed_fields}\`); pass \`--verbose\` for the full server payload.
2169
+ - **Write-path echoes keep collection arrays even when empty.** On a
2170
+ create/update echo (e.g. \`study create\`/\`study update\`), entity
2171
+ collections like \`assignments\`, \`interview_questions\`, and
2172
+ \`iterations\` are always present — \`[]\` when the resource has none,
2173
+ not dropped. So the echo reflects exactly what was persisted: an empty
2174
+ \`assignments\` means the study genuinely has no assignment and will
2175
+ fail at run with "Study has no assignments" — you don't need a second
2176
+ \`--verbose\` (or \`study get\`) call to tell "zero persisted" from
2177
+ "stripped by lean mode." (Read-path \`list\` responses still drop empty
2178
+ per-item arrays as noise; this guarantee is write-path only.)
2133
2179
  - **\`person generate\` returns \`{job: {id, status, person_ids},
2134
2180
  profiles: [...]}\`** in \`--json\` mode. Each profile is the
2135
2181
  lean \`person\` shape (pass \`--verbose\` for the full record,
2136
2182
  including \`simulation_config\`) with its evidence-grounded
2137
2183
  \`scenarios\` attached; pass \`--no-scenarios\` to omit them.
2184
+ - **\`study share\` returns \`{id, token, share_url, expires_at,
2185
+ created_at}\`** in \`--json\` mode (full envelope, not lean-stripped).
2186
+ \`share_url\` is the public no-login URL — use it verbatim. In human
2187
+ mode \`share_url\` goes to stdout, context to stderr. \`study share
2188
+ --list\` returns rows of \`{token, study, expires_at, is_revoked}\`
2189
+ (no \`share_url\` — only create returns it). See \`concepts/sharing\`.
2138
2190
  - **\`<entity> get\` accepts multiple IDs.** \`person get\`, \`study get\`,
2139
2191
  \`iteration get\`, and \`ask get\` all take \`<ids...>\` — pass two or
2140
2192
  more aliases (space- or comma-separated) and the response is a
@@ -2835,10 +2887,16 @@ script or agent session.
2835
2887
 
2836
2888
  ### \`ish login\` is idempotent
2837
2889
 
2838
- When you already have a valid saved token, \`ish login\` short-circuits
2839
- with a friendly "Already logged in" message and **does not** open a new
2840
- browser tab or register a fresh OAuth client. Use \`--force\` (or \`-f\`)
2841
- to bypass the guard typical reason is switching accounts.
2890
+ When you already have a saved token that is **both unexpired and still
2891
+ accepted by the API**, \`ish login\` short-circuits with a friendly
2892
+ "Already logged in" message and **does not** open a new browser tab or
2893
+ register a fresh OAuth client. If the saved token is unexpired but the
2894
+ server rejects it — a revoked session, a rotated signing key, or a token
2895
+ minted against the wrong Supabase project (e.g. a dev-issued token while
2896
+ calling the prod api) — the guard falls through and re-runs the browser
2897
+ flow instead of falsely reporting "Already logged in". Use \`--force\`
2898
+ (or \`-f\`) to bypass the guard unconditionally — typical reason is
2899
+ switching accounts.
2842
2900
 
2843
2901
  \`\`\`bash
2844
2902
  ish login # no-op when already authenticated
@@ -4187,6 +4245,74 @@ overridden URL.
4187
4245
 
4188
4246
  - \`reference/json-mode\` — display vs capture vs chain output rules.
4189
4247
  `;
4248
+ const CONCEPT_SHARE = `# concept: sharing study results
4249
+
4250
+ A **share link** is a public, no-login URL to one study's results. Anyone
4251
+ with the link opens it in a browser — no ish account — and sees the study's
4252
+ summary, key insights, participant journeys, interactive frames, and segment
4253
+ breakdowns (read-only). This is how you hand a study to someone outside your
4254
+ workspace: a prospect, a stakeholder, a teammate without a seat.
4255
+
4256
+ - Created via: \`ish study share [id]\` (defaults to the active study).
4257
+ - Revoked via: \`ish study unshare <token>\`.
4258
+ - The link host is the **web app frontend**, not the API host. The backend
4259
+ returns the fully-formed \`share_url\` — print/use it verbatim. Do NOT
4260
+ hand-build the URL from the API host or app URL; they differ.
4261
+
4262
+ ## Create a link
4263
+
4264
+ \`\`\`
4265
+ ish study share # share the active study
4266
+ ish study share s-b2c # share a specific study
4267
+ ish study share s-b2c --expires 30 # auto-expire 30 days from now
4268
+ ish study share s-b2c --json # { token, share_url, expires_at, created_at, id }
4269
+ \`\`\`
4270
+
4271
+ Human mode prints the \`share_url\` to **stdout** (it's the deliverable — a
4272
+ URL to paste into an email) and the token / expiry / revoke hint to stderr.
4273
+ JSON mode returns the full create envelope:
4274
+
4275
+ \`\`\`json
4276
+ {
4277
+ "id": "…",
4278
+ "token": "Hk9_…", // opaque url-safe token, NOT an alias
4279
+ "share_url": "https://<frontend>/share/study/Hk9_…",
4280
+ "expires_at": null, // null = never expires
4281
+ "created_at": "…"
4282
+ }
4283
+ \`\`\`
4284
+
4285
+ ## List and revoke
4286
+
4287
+ \`\`\`
4288
+ ish study share --list # every share link you created (all studies)
4289
+ ish study unshare Hk9_… # revoke by raw token; URL stops working immediately
4290
+ ish study unshare Hk9_… --yes # skip the confirmation (required in --json / non-TTY)
4291
+ \`\`\`
4292
+
4293
+ The \`--list\` rows carry \`token\`, \`study\` (aliased), \`expires_at\`,
4294
+ \`is_revoked\`. The full \`share_url\` only comes back from \`share\` (create) —
4295
+ list responses do not reconstruct it. \`study unshare\` takes the **raw token**,
4296
+ never a study ID or alias.
4297
+
4298
+ ## What a good shareable study looks like
4299
+
4300
+ The viewer is only as good as the run behind it. Before sharing, make sure:
4301
+ - The study has **run** with enough participants (\`ish study run … --wait\`;
4302
+ analysis needs ≥5 completed participants) and no broken simulations.
4303
+ - An **analysis** has been generated so the summary + key insights render
4304
+ (\`ish study analyze --wait\` → \`ish study insights\`).
4305
+ - For media studies, every **segment is labelled** (see \`concepts/iteration\`).
4306
+ - The workspace has a **logo** if you want the link branded
4307
+ (\`ish workspace update <id> --logo <url>\`).
4308
+
4309
+ ## Related
4310
+
4311
+ - \`concepts/study\` — the artifact a link points at.
4312
+ - \`concepts/workspace\` — \`--logo\` branding shown on the shared link.
4313
+ - \`concepts/active-context\` — \`ish study share\` defaults to the active study.
4314
+ - \`reference/json-mode\` — the \`{ token, share_url, … }\` envelope.
4315
+ `;
4190
4316
  const PAGES = [
4191
4317
  {
4192
4318
  slug: "overview",
@@ -4284,6 +4410,12 @@ const PAGES = [
4284
4410
  description: "Saved workspace/study/ask state and how to inspect it (ish status).",
4285
4411
  body: CONCEPT_ACTIVE_CONTEXT,
4286
4412
  },
4413
+ {
4414
+ slug: "concepts/sharing",
4415
+ title: "concept: sharing study results",
4416
+ description: "Public no-login share links for a study: study share / study unshare / --list, --expires, token vs URL, branding with workspace --logo.",
4417
+ body: CONCEPT_SHARE,
4418
+ },
4287
4419
  {
4288
4420
  slug: "reference/aliases",
4289
4421
  title: "reference: aliases",
@@ -7,7 +7,16 @@
7
7
  * and the call sites (`study run`, `iteration update`, ...) pick up the
8
8
  * change for free.
9
9
  */
10
- export type Modality = "interactive" | "video" | "audio" | "text" | "image" | "document" | "chat";
10
+ /** All study modalities. Matches the backend `Modality` enum value set. */
11
+ export declare const MODALITIES: readonly ["interactive", "video", "audio", "text", "image", "document", "chat"];
12
+ export type Modality = typeof MODALITIES[number];
13
+ /**
14
+ * Coerce a raw study-modality string to a known `Modality`, defaulting to
15
+ * `"interactive"` for unknown/empty values — the same default the CLI uses
16
+ * elsewhere (`study.modality || "interactive"`) and the highest-cost rate, so
17
+ * an unrecognised modality errs toward over-estimating rather than under.
18
+ */
19
+ export declare function toModality(raw: string | undefined): Modality;
11
20
  /** True for the media-bucket modalities that share batch + content semantics. */
12
21
  export declare function isMediaModality(modality: string | undefined): boolean;
13
22
  /** True for chat modality. Wrapped as a helper so callers don't sprinkle string literals. */
@@ -9,6 +9,27 @@
9
9
  */
10
10
  import { normalizeEnumValue } from "./enums.js";
11
11
  import { MEDIA_MODALITIES } from "./types.js";
12
+ /** All study modalities. Matches the backend `Modality` enum value set. */
13
+ export const MODALITIES = [
14
+ "interactive",
15
+ "video",
16
+ "audio",
17
+ "text",
18
+ "image",
19
+ "document",
20
+ "chat",
21
+ ];
22
+ /**
23
+ * Coerce a raw study-modality string to a known `Modality`, defaulting to
24
+ * `"interactive"` for unknown/empty values — the same default the CLI uses
25
+ * elsewhere (`study.modality || "interactive"`) and the highest-cost rate, so
26
+ * an unrecognised modality errs toward over-estimating rather than under.
27
+ */
28
+ export function toModality(raw) {
29
+ return MODALITIES.includes(raw ?? "")
30
+ ? raw
31
+ : "interactive";
32
+ }
12
33
  /** True for the media-bucket modalities that share batch + content semantics. */
13
34
  export function isMediaModality(modality) {
14
35
  return !!modality && MEDIA_MODALITIES.includes(modality);
@@ -196,7 +196,14 @@ function leanJson(data, keepIds = false) {
196
196
  // Recurse into objects/arrays
197
197
  if (typeof value === "object") {
198
198
  const cleaned = leanJson(value, keepIds);
199
- if (cleaned !== undefined && !(Array.isArray(cleaned) && cleaned.length === 0)) {
199
+ // Read paths drop empty arrays as noise. Write-path echoes (keepIds)
200
+ // must NOT: an empty `assignments`/`interview_questions` is the
201
+ // "zero persisted" signal the create/update echo exists to surface —
202
+ // a study with no assignments fails at run with "Study has no
203
+ // assignments". Dropping it made the echo indistinguishable from a
204
+ // lean-strip, which is why agents were told not to trust it.
205
+ const dropEmptyArray = !keepIds && Array.isArray(cleaned) && cleaned.length === 0;
206
+ if (cleaned !== undefined && !dropEmptyArray) {
200
207
  result[key] = cleaned;
201
208
  }
202
209
  continue;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Local reverse proxy: fan one inbound port out to multiple localhost services
3
+ * by path prefix. Wired into `ish connect` so a single cloudflared tunnel can
4
+ * serve a frontend + backend + extras under one origin (no CORS / cookie
5
+ * cross-origin pain in the cloud browser).
6
+ */
7
+ export type Route = {
8
+ prefix: string;
9
+ target: string;
10
+ };
11
+ export interface ReverseProxyHandle {
12
+ port: number;
13
+ close: () => Promise<void>;
14
+ }
15
+ export interface StartReverseProxyOptions {
16
+ primaryPort: number;
17
+ routes: Route[];
18
+ }
19
+ export declare function startReverseProxy(opts: StartReverseProxyOptions): Promise<ReverseProxyHandle>;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Local reverse proxy: fan one inbound port out to multiple localhost services
3
+ * by path prefix. Wired into `ish connect` so a single cloudflared tunnel can
4
+ * serve a frontend + backend + extras under one origin (no CORS / cookie
5
+ * cross-origin pain in the cloud browser).
6
+ */
7
+ import http from "node:http";
8
+ import httpProxy from "http-proxy";
9
+ function resolveRoute(url, sortedRoutes, fallback) {
10
+ const path = url ?? "/";
11
+ for (const route of sortedRoutes) {
12
+ // Match the prefix at a segment boundary so `/api` doesn't catch `/apiary`.
13
+ if (path === route.prefix || path.startsWith(route.prefix + "/") || path.startsWith(route.prefix + "?")) {
14
+ return route.target;
15
+ }
16
+ }
17
+ return fallback;
18
+ }
19
+ export function startReverseProxy(opts) {
20
+ return new Promise((resolve, reject) => {
21
+ const primaryTarget = `http://127.0.0.1:${opts.primaryPort}`;
22
+ // Longest prefix wins: a request to `/api/v1/x` with routes
23
+ // `[/api, /api/v1]` should land on `/api/v1`.
24
+ const sortedRoutes = [...opts.routes]
25
+ .map((r) => ({ prefix: r.prefix, target: r.target }))
26
+ .sort((a, b) => b.prefix.length - a.prefix.length);
27
+ const proxy = httpProxy.createProxyServer({
28
+ xfwd: true,
29
+ ws: true,
30
+ // Preserve the full original path — http-proxy does this by default when
31
+ // we pass `target` without `prependPath`/`ignorePath`. Setting changeOrigin
32
+ // false keeps the Host header pointing at the upstream's address.
33
+ changeOrigin: false,
34
+ });
35
+ proxy.on("error", (err, _req, res) => {
36
+ // `res` can be either an HTTP response or a raw socket (WS upgrade path).
37
+ if (res && "writeHead" in res && typeof res.writeHead === "function") {
38
+ const httpRes = res;
39
+ if (!httpRes.headersSent) {
40
+ httpRes.writeHead(502, { "Content-Type": "text/plain; charset=utf-8" });
41
+ }
42
+ httpRes.end(`Bad gateway: upstream not reachable (${err.message})`);
43
+ }
44
+ else if (res && "destroy" in res && typeof res.destroy === "function") {
45
+ res.destroy();
46
+ }
47
+ });
48
+ // Track open sockets so close() can force-destroy them — mirrors the
49
+ // shutdown discipline in src/auth.ts. server.close() alone waits for
50
+ // keep-alive sockets to drain, which hangs the CLI on SIGINT.
51
+ const sockets = new Set();
52
+ const server = http.createServer((req, res) => {
53
+ const target = resolveRoute(req.url, sortedRoutes, primaryTarget);
54
+ proxy.web(req, res, { target });
55
+ });
56
+ server.on("upgrade", (req, socket, head) => {
57
+ const target = resolveRoute(req.url, sortedRoutes, primaryTarget);
58
+ proxy.ws(req, socket, head, { target });
59
+ });
60
+ server.on("connection", (socket) => {
61
+ sockets.add(socket);
62
+ socket.on("close", () => sockets.delete(socket));
63
+ });
64
+ server.on("error", reject);
65
+ server.listen(0, "127.0.0.1", () => {
66
+ const addr = server.address();
67
+ if (!addr || typeof addr === "string") {
68
+ reject(new Error("Failed to bind reverse proxy"));
69
+ return;
70
+ }
71
+ resolve({
72
+ port: addr.port,
73
+ close: () => new Promise((resolveClose) => {
74
+ // Stop accepting new connections, then force-destroy anything still
75
+ // open. closeAllConnections + the manual socket sweep is what makes
76
+ // shutdown reliable on macOS (see auth.ts comment).
77
+ server.close(() => resolveClose());
78
+ server.closeAllConnections?.();
79
+ for (const socket of sockets)
80
+ socket.destroy();
81
+ sockets.clear();
82
+ proxy.close();
83
+ }),
84
+ });
85
+ });
86
+ });
87
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Smoke test for the reverse-proxy module. Spins up two mock HTTP servers,
3
+ * routes through the proxy, and asserts paths land on the right upstream
4
+ * with the full path preserved. Also verifies a raw WebSocket upgrade
5
+ * routes via the prefix rules.
6
+ *
7
+ * Compiled to dist/lib/reverse-proxy.test.js and runnable with:
8
+ * node --test dist/lib/reverse-proxy.test.js
9
+ */
10
+ export {};