@ishlabs/cli 0.9.0 → 0.10.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.
package/README.md CHANGED
@@ -88,7 +88,7 @@ Workspace (= product, top-level container)
88
88
  ```bash
89
89
  ish login # browser auth
90
90
  ish logout
91
- ish connect <port> # Cloudflare tunnel exposing localhost
91
+ ish connect <port> # Cloudflare tunnel exposing localhost (--detach, ish disconnect, ish connect status)
92
92
  ish upgrade # self-update (single-binary installs only)
93
93
  ish upgrade --release 0.8.1 # pin a specific release
94
94
  ```
@@ -98,15 +98,21 @@ ish upgrade --release 0.8.1 # pin a specific release
98
98
  ### Workspaces, studies, iterations, profiles, configs (CRUD groups)
99
99
 
100
100
  ```bash
101
- ish workspace list | create | get | update | delete | use
101
+ ish workspace list | create | get | update | delete | use | info
102
102
  ish workspace site-access status | basic-auth | cookie | login | affirm-public | clear
103
103
  ish study list | create | generate | get | results | update | delete | use
104
104
  ish iteration list | create | get | update | delete
105
105
  ish profile list | create | generate | get | update | delete
106
106
  ish source upload | get | delete
107
107
  ish config list | create | get | schema | update | delete
108
+ ish chat endpoint list | create | get | update | delete | use | init | test
109
+ ish secret list | set | delete
108
110
  ```
109
111
 
112
+ `ish workspace info` reports `studies_used / studies_max / testers_used / testers_max / tier` so an agent can branch on plan caps before a destructive call returns `error_code: usage_limit_reached`.
113
+
114
+ `ish chat endpoint` configures HTTP-bot endpoints for chat-modality studies (auto-detect from a curl example, smoke-test, edit). `ish secret` is the per-workspace KV store referenced from chatbot endpoint headers via `{{secret:KEY}}` placeholders. Run `ish docs get-page guides/chat` for the end-to-end recipe.
115
+
110
116
  Testers live as a nested group on a study (low-level — usually created via `study run`):
111
117
 
112
118
  ```bash
@@ -347,16 +353,59 @@ ish profile generate --source tps-3a4 --propose-count
347
353
  ish profile generate --source tps-3a4 --count 4
348
354
  ```
349
355
 
356
+ ### Chat-modality studies — `ish chat`
357
+
358
+ Configure a customer chatbot endpoint and run chat-modality studies against it.
359
+
360
+ ```bash
361
+ # Author from a curl example (or hand-write the config)
362
+ ish chat endpoint init --from-curl ./bot.curl --name my-bot
363
+ ish chat endpoint create --endpoint-config ./bot-config.json --name "my-bot"
364
+
365
+ # CRUD on saved endpoints (every dialog edit reduces to one of these)
366
+ ish chat endpoint list
367
+ ish chat endpoint get ep-abc --verbose # round-trippable {id, name, isTunnelBacked, config}
368
+ ish chat endpoint update ep-abc --name "Production support bot"
369
+ ish chat endpoint update ep-abc --url https://api.example.com/v2/chat --mode stateless
370
+ ish chat endpoint get ep-abc --verbose | jq '.config.outgoing.headers["X-API-Key"] = "{{secret:KEY}}"' \
371
+ | ish chat endpoint update ep-abc --endpoint-config -
372
+ ish chat endpoint delete ep-abc
373
+ ish chat endpoint use ep-abc # set as the active chat endpoint
374
+
375
+ # Smoke test the connection (single turn; tunnel pre-flight when applicable)
376
+ ish chat endpoint test ep-abc -m "Hello"
377
+ ish chat endpoint test ep-abc -m "Tell me more" --conversation-id "$CID" # stateful threading
378
+
379
+ # Run a chat-modality study using the saved endpoint (existing study verbs).
380
+ # Audience size lives on study run via --sample / --all / --profile.
381
+ ish study create --modality chat --endpoint ep-abc --name "Sign-up Q1" --assignment "Sign up:Try to sign up"
382
+ ish study run --study stu-xyz --sample 5 --wait
383
+ ish study results stu-xyz --json | jq '.testers'
384
+ ```
385
+
386
+ Local bots (`localhost` / `127.0.0.1` / `0.0.0.0`) auto-flag `is_tunnel_backed=true` on `init`; pair with `ish connect <port>` in another shell. Override with `--tunnel-backed` / `--no-tunnel-backed`.
387
+
388
+ `init` returns `confidence` (`high` / `medium` / `low`) and a `missingSignals: [...]` array naming any inputs the inference couldn't observe (e.g. `["response_shape", "message_path"]` when no response sample is provided). When confidence is `low`, verify with `chat endpoint test` before running a study.
389
+
390
+ Failures from `chat endpoint test` carry a structured `error_kind`: `TunnelInactive` (run `ish connect <port>` first), `BotUnreachable` (URL/port wrong or bot down), `BotResponseError` (non-2xx with a status code), `BotEnvelopeError` (200 OK with the bot's own error in the body — see `raw_excerpt`), `BotInvalidResponseError` (response doesn't match the parsing schema), `BotAuthError`, `BotTimeoutError`, `BotRetryExhaustedError`.
391
+
392
+ Full guide: `ish docs get-page guides/chat`.
393
+
350
394
  ### Expose localhost
351
395
 
352
- For interactive studies that need to reach a service running on your machine:
396
+ For interactive studies (and chat endpoints with `is_tunnel_backed=true`) that need to reach a service running on your machine:
353
397
 
354
398
  ```bash
355
- ish connect 3000 # Cloudflare tunnel to localhost:3000
399
+ ish connect 3000 # foreground Cloudflare tunnel to :3000
400
+ ish connect 3000 --detach --json # fork after first heartbeat; prints {pid, tunnel_url, registered}
401
+ ish connect status --json # {active, pid, tunnel_url, registered_at} or {active:false}
402
+ ish disconnect --json # graceful shutdown of an active tunnel
356
403
  ISH_TOKEN=YOUR_TOKEN ish connect 8080
357
404
  ```
358
405
 
359
- `connect` is a long-running command — keep it open while testers run. The Cloudflare tunnel URL prints prominently after "Connected"; pass `--json` for one-line machine-readable output (`{"status":"connected","tunnel_url":"...","local_port":3000,"registered":true}`) suitable for scripts.
406
+ Foreground `connect` is long-running — keep it open while testers run. The tunnel URL prints prominently after "Connected"; pass `--json` for one-line machine-readable output (`{"status":"connected","tunnel_url":"...","local_port":3000,"registered":true}`). The `--detach` form forks after the first successful heartbeat and returns immediately, tracking PID + URL in `~/.ish/connect.lock` so `connect status` and `disconnect` find it later.
407
+
408
+ Destructive verbs in `--json` mode (e.g. `chat endpoint delete`, `study delete`) require an explicit `--yes`; the rejection envelope carries `error_kind: "ConfirmationRequired"` and an `example` field with the same command + `--yes` appended, so an agent can recover without re-reading the help text.
360
409
 
361
410
  ## Global flags
362
411
 
@@ -2,4 +2,16 @@
2
2
  * ish ask — Create and run asks (multi-round surveys with variants).
3
3
  */
4
4
  import type { Command } from "commander";
5
+ import type { AudienceSubset } from "../lib/types.js";
6
+ /**
7
+ * Parse the `--subset-round <n> --subset-variant <variant_id>` pair into
8
+ * an `AudienceSubset` payload (Pattern B). Both flags must be passed
9
+ * together or neither — half a subset is a misconfiguration the agent
10
+ * should fix before dispatch, not a silent fallthrough to the full
11
+ * audience.
12
+ *
13
+ * Returns `undefined` when neither flag is set; throws when only one is
14
+ * set or when `--subset-round` isn't a positive integer.
15
+ */
16
+ export declare function parseAudienceSubset(subsetRound: string | undefined, subsetVariant: string | undefined): AudienceSubset | undefined;
5
17
  export declare function registerAskCommands(program: Command): void;
@@ -7,6 +7,7 @@ import { loadConfig, saveConfig } from "../config.js";
7
7
  import { formatAskList, formatAskDetail, formatRoundDetail, formatAskResults, output, } from "../lib/output.js";
8
8
  import { parseVariantInputs, uploadAndBuildVariants, } from "../lib/ask-variants.js";
9
9
  import { loadQuestionsManifest } from "../lib/ask-questions.js";
10
+ import { ApiError } from "../lib/api-client.js";
10
11
  const POLL_INTERVAL_MS = 5_000;
11
12
  // ---------------------------------------------------------------------------
12
13
  // Helpers
@@ -108,6 +109,32 @@ async function buildRoundInput(client, productId, opts, quiet) {
108
109
  round.questions = questions;
109
110
  return round;
110
111
  }
112
+ /**
113
+ * Parse the `--subset-round <n> --subset-variant <variant_id>` pair into
114
+ * an `AudienceSubset` payload (Pattern B). Both flags must be passed
115
+ * together or neither — half a subset is a misconfiguration the agent
116
+ * should fix before dispatch, not a silent fallthrough to the full
117
+ * audience.
118
+ *
119
+ * Returns `undefined` when neither flag is set; throws when only one is
120
+ * set or when `--subset-round` isn't a positive integer.
121
+ */
122
+ export function parseAudienceSubset(subsetRound, subsetVariant) {
123
+ if (subsetRound === undefined && subsetVariant === undefined)
124
+ return undefined;
125
+ if (subsetRound === undefined || subsetVariant === undefined) {
126
+ throw new Error("--subset-round and --subset-variant must be passed together (or both omitted).");
127
+ }
128
+ const round = Number.parseInt(subsetRound, 10);
129
+ if (!Number.isFinite(round) || round < 1 || !/^\d+$/.test(subsetRound)) {
130
+ throw new Error(`--subset-round must be a positive integer (got "${subsetRound}").`);
131
+ }
132
+ const trimmedVariant = subsetVariant.trim();
133
+ if (trimmedVariant.length === 0) {
134
+ throw new Error("--subset-variant must be a variant UUID (got empty string).");
135
+ }
136
+ return { round, picked_variant_id: trimmedVariant };
137
+ }
111
138
  // ---------------------------------------------------------------------------
112
139
  // Command registration
113
140
  // ---------------------------------------------------------------------------
@@ -145,6 +172,8 @@ Concept pages: ish docs get-page concepts/ask
145
172
  allFlagName: "--all-simulatable",
146
173
  allFlagDescription: "Use every simulatable AI profile matching the filters (with --new only)",
147
174
  })
175
+ .option("--subset-round <n>", "Drill-in subset (Pattern B) — append-round only. 1-indexed prior round to filter against. Pair with --subset-variant.")
176
+ .option("--subset-variant <variant_id>", "Drill-in subset (Pattern B) — append-round only. Variant id (UUID) on the prior round whose pickers should inherit. Read from `aggregates.pick_buckets` or `variants[*].id` on the prior round's `ask results --json`.")
148
177
  .option("--wait", "Wait until the round completes (or errors)")
149
178
  .option("--timeout <s>", "Wait timeout in seconds (default 300)")
150
179
  .addHelpText("after", `
@@ -169,6 +198,9 @@ Examples:
169
198
  if (pickedId) {
170
199
  throw new Error("Cannot pass an ask id together with --new. Drop the id, or drop --new to append a round.");
171
200
  }
201
+ if (opts.subsetRound !== undefined || opts.subsetVariant !== undefined) {
202
+ throw new Error("--subset-round / --subset-variant are only valid when appending to an existing ask. Drop --new or drop the subset flags.");
203
+ }
172
204
  const wid = resolveWorkspace(opts.workspace);
173
205
  const testerIds = await resolveAudienceProfileIds(client, wid, audienceFlags(opts), { requireSimulatable: true, allFlagName: "--all-simulatable" });
174
206
  const round = await buildRoundInput(client, wid, opts, !!globals.quiet);
@@ -180,7 +212,28 @@ Examples:
180
212
  tester_profile_ids: testerIds,
181
213
  first_round: round,
182
214
  };
183
- let data = await client.post(`/products/${wid}/asks`, body, { timeout: 120_000 });
215
+ // M5 / Pattern G: `ask run --new` POSTs to a non-idempotent
216
+ // create endpoint. If the backend errors after the row is
217
+ // committed (a 500 mid-pipeline, a network timeout after the
218
+ // POST landed), an automatic retry would create a duplicate
219
+ // ask. Override `retryable` to false on any failure here so
220
+ // agents don't auto-retry. The error envelope also reminds
221
+ // the agent to inspect `ish ask list --workspace <id>` before
222
+ // re-running, since the resource may already exist.
223
+ let data;
224
+ try {
225
+ data = await client.post(`/products/${wid}/asks`, body, { timeout: 120_000 });
226
+ }
227
+ catch (err) {
228
+ if (err instanceof ApiError) {
229
+ err.retryable = false;
230
+ const tagged = err;
231
+ tagged.suggestions = [
232
+ `\`ish ask list --workspace ${wid}\` to check whether the ask was created server-side before retrying — \`ask run --new\` is non-idempotent and will duplicate on retry.`,
233
+ ];
234
+ }
235
+ throw err;
236
+ }
184
237
  if (data.id) {
185
238
  const config = loadConfig();
186
239
  config.ask = data.id;
@@ -223,6 +276,9 @@ Examples:
223
276
  }
224
277
  const ask = await client.get(`/asks/${aid}`);
225
278
  const round = await buildRoundInput(client, ask.product_id, opts, !!globals.quiet);
279
+ const subset = parseAudienceSubset(opts.subsetRound, opts.subsetVariant);
280
+ if (subset)
281
+ round.audience_subset = subset;
226
282
  const created = await client.post(`/asks/${aid}/rounds`, round);
227
283
  if (opts.wait) {
228
284
  const timeoutMs = parseWaitTimeout(opts.timeout);
@@ -519,14 +575,32 @@ the model's self-reported confidence in its variant choice. See
519
575
  .option("--wants-pick", "Each tester picks a favourite variant (compatible with --wants-ratings; can be set together).")
520
576
  .option("--wants-ratings", "Each tester rates every variant 1–5 (compatible with --wants-pick; can be set together). If neither is set, testers leave a free-form comment only.")
521
577
  .option("--questions <file.json>", `Questions JSON file: [{"question":"...","type":"text"|"slider"|"likert"|"single-choice"|"multiple-choice"|"number"}]`)
578
+ .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 testers who picked --subset-variant on round N.")
579
+ .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.")
522
580
  .option("--wait", "Wait until the new round completes")
523
581
  .option("--timeout <s>", "Wait timeout in seconds (default 300)")
524
- .addHelpText("after", "\nExamples:\n $ ish ask add-round a-6ec --prompt \"And now?\" --variant text:\"Hello\" --variant text:\"Hi\" --wait")
582
+ .addHelpText("after", `
583
+ Examples:
584
+ # Append round 2 to the same audience.
585
+ $ ish ask add-round a-6ec --prompt "And now?" --variant text:"Hello" --variant text:"Hi" --wait
586
+
587
+ # Drill round 2 into the round-1-A-pickers (Pattern B).
588
+ $ ish ask add-round a-6ec \\
589
+ --prompt "What would make you actually click?" \\
590
+ --subset-round 1 --subset-variant 5f3a... \\
591
+ --wait
592
+
593
+ If --subset-round / --subset-variant fails to resolve (round missing, variant
594
+ not on that round, or zero pickers), the backend returns a 422 with
595
+ error_kind: "audience_subset_invalid".`)
525
596
  .action(async (id, opts, cmd) => {
526
597
  await withClient(cmd, async (client, globals) => {
527
598
  const aid = resolveAsk(pickAskRef(id, opts.ask));
528
599
  const ask = await client.get(`/asks/${aid}`);
529
600
  const round = await buildRoundInput(client, ask.product_id, opts, !!globals.quiet);
601
+ const subset = parseAudienceSubset(opts.subsetRound, opts.subsetVariant);
602
+ if (subset)
603
+ round.audience_subset = subset;
530
604
  const created = await client.post(`/asks/${aid}/rounds`, round);
531
605
  if (opts.wait) {
532
606
  const timeoutMs = parseWaitTimeout(opts.timeout);
@@ -607,6 +681,57 @@ text, slider, likert, single-choice, multiple-choice, number.`)
607
681
  }, globals.json);
608
682
  });
609
683
  });
684
+ // ---- retry --------------------------------------------------------------
685
+ ask
686
+ .command("retry")
687
+ .description("Re-dispatch only the errored responses on a round (idempotent: zero-errored is a no-op).")
688
+ .argument("[id]", "Ask alias or UUID (defaults to active ask)")
689
+ .option("--ask <id>", "Ask ID; alternative to positional argument")
690
+ .requiredOption("--round <n|round-id>", "Round number (1-indexed) or round id/alias")
691
+ .option("--wait", "Wait until the retried round completes (or errors)")
692
+ .option("--timeout <s>", "Wait timeout in seconds (default 300)")
693
+ .addHelpText("after", `
694
+ Examples:
695
+ # Retry the errored 4 of 5 testers on round 1.
696
+ $ ish ask retry a-d3e --round 1
697
+
698
+ # Retry and wait for the round to settle.
699
+ $ ish ask retry a-d3e --round 1 --wait
700
+
701
+ Notes:
702
+ - COMPLETED responses are left untouched. Only ERRORED rows are reset to PENDING and re-run from scratch.
703
+ - The round flips back to RUNNING for the duration of the retry; the prior round summary is dropped and rebuilt once the retry settles.
704
+ - On a round with no errored responses, the verb is a no-op and returns the round unchanged.`)
705
+ .action(async (id, opts, cmd) => {
706
+ await withClient(cmd, async (client, globals) => {
707
+ const aid = resolveAsk(pickAskRef(id, opts.ask));
708
+ const ask = await client.get(`/asks/${aid}`);
709
+ const round = getRoundByIndexOrId(ask, opts.round);
710
+ const updated = await client.post(`/asks/${aid}/rounds/${round.id}/retry`, {});
711
+ if (opts.wait) {
712
+ const timeoutMs = parseWaitTimeout(opts.timeout);
713
+ await pollUntilRoundDone(client, aid, updated.order_index, timeoutMs, !!globals.quiet);
714
+ const refreshed = await client.get(`/asks/${aid}`);
715
+ const target = refreshed.rounds.find((r) => r.id === updated.id);
716
+ if (target) {
717
+ formatRoundDetail(target, globals.json);
718
+ return;
719
+ }
720
+ }
721
+ if (!globals.json || globals.verbose) {
722
+ formatRoundDetail(updated, globals.json);
723
+ return;
724
+ }
725
+ output({
726
+ id: aid,
727
+ alias: tagAlias(ALIAS_PREFIX.ask, aid),
728
+ round: {
729
+ round_number: updated.order_index + 1,
730
+ status: updated.status,
731
+ },
732
+ }, globals.json);
733
+ });
734
+ });
610
735
  // ---- add-testers --------------------------------------------------------
611
736
  const askAddTesters = ask
612
737
  .command("add-testers")
@@ -0,0 +1,17 @@
1
+ /**
2
+ * ish chat — Configure chatbot endpoints and run chat-modality studies.
3
+ *
4
+ * The CLI's primary user is autonomous AI agents. Every verb here is
5
+ * scriptable: deterministic JSON outputs, no interactive prompts, no
6
+ * REPLs. Endpoint editing matches the editor dialog's semantics
7
+ * (full-replace via PUT) plus client-side field-shorthand flags for
8
+ * common one-line edits.
9
+ *
10
+ * Chat-modality studies are reached via the existing `ish study create
11
+ * --modality chat --endpoint <id>` extension; this file does NOT
12
+ * fork a parallel `chat run` verb tree.
13
+ */
14
+ import type { Command } from "commander";
15
+ import { envelopeFromRow } from "../lib/chat-endpoint-formatters.js";
16
+ export declare function registerChatCommand(program: Command): void;
17
+ export { envelopeFromRow };