@ishlabs/cli 0.8.4 → 0.9.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.
@@ -11,9 +11,105 @@ import { deterministicAlias, getAliasMap, ALIAS_PREFIX } from "./alias-store.js"
11
11
  // --- Lean JSON: strip noise for agent-friendly output ---
12
12
  let _verbose = false;
13
13
  let _fields;
14
+ let _getField;
14
15
  /** Set by withClient() based on global flags. */
15
16
  export function setVerbose(v) { _verbose = v; }
16
17
  export function setFields(fields) { _fields = fields; }
18
+ /**
19
+ * Pattern Ω capture mode: when set, jsonOutput() returns the bare value at
20
+ * the dotted path instead of the full JSON. Cleared between command runs by
21
+ * each invocation of `applyGlobals()`.
22
+ */
23
+ export function setGetField(field) { _getField = field; }
24
+ /**
25
+ * Walk a dotted path through a JSON value. Returns the resolved value or
26
+ * `MISSING` if any step is undefined. Numeric segments index into arrays;
27
+ * non-numeric segments key into objects. When a segment is non-numeric and
28
+ * the current value is an array, the segment is mapped over the array
29
+ * (e.g. `items.alias` on `{items: [...]}` after `items` is unwrapped to the
30
+ * array yields the per-element `alias` values).
31
+ */
32
+ const MISSING = Symbol("missing");
33
+ function walkPath(data, segments) {
34
+ let cur = data;
35
+ for (const seg of segments) {
36
+ if (cur === null || cur === undefined)
37
+ return MISSING;
38
+ if (Array.isArray(cur)) {
39
+ // `seg` could be a numeric index, or a key to apply to each element.
40
+ const asIndex = /^\d+$/.test(seg) ? parseInt(seg, 10) : null;
41
+ if (asIndex !== null) {
42
+ if (asIndex < 0 || asIndex >= cur.length)
43
+ return MISSING;
44
+ cur = cur[asIndex];
45
+ continue;
46
+ }
47
+ // Map across array: pick the key on each element. Skip elements that
48
+ // lack the key so `--get items.alias` on a list with one bad row
49
+ // still returns the rest.
50
+ const mapped = [];
51
+ for (const el of cur) {
52
+ if (el !== null && typeof el === "object" && seg in el) {
53
+ mapped.push(el[seg]);
54
+ }
55
+ }
56
+ if (mapped.length === 0)
57
+ return MISSING;
58
+ cur = mapped;
59
+ continue;
60
+ }
61
+ if (typeof cur !== "object")
62
+ return MISSING;
63
+ const obj = cur;
64
+ if (!(seg in obj))
65
+ return MISSING;
66
+ cur = obj[seg];
67
+ }
68
+ return cur;
69
+ }
70
+ /**
71
+ * Resolve `_getField` against `data`. Auto-descends into a top-level
72
+ * `items: [...]` wrapper when the requested path doesn't start with `items`
73
+ * and the path resolves on items but not at top level — i.e.
74
+ * `--get alias` on a list response acts like `--get items.alias`.
75
+ */
76
+ function extractGetField(data, path) {
77
+ const segments = path.split(".").map((s) => s.trim()).filter(Boolean);
78
+ if (segments.length === 0)
79
+ return MISSING;
80
+ const direct = walkPath(data, segments);
81
+ if (direct !== MISSING)
82
+ return direct;
83
+ // Auto-descend through {items: [...]} wrapper for paginated list responses.
84
+ if (segments[0] !== "items"
85
+ && data !== null
86
+ && typeof data === "object"
87
+ && !Array.isArray(data)
88
+ && Array.isArray(data.items)) {
89
+ const viaItems = walkPath(data, ["items", ...segments]);
90
+ if (viaItems !== MISSING)
91
+ return viaItems;
92
+ }
93
+ return MISSING;
94
+ }
95
+ /**
96
+ * Render an extracted value as a bare string for stdout. Rules:
97
+ * - string/number/boolean: printed as-is (no JSON quotes).
98
+ * - null: empty string.
99
+ * - arrays: one element per line, each element rendered by the same rules
100
+ * (objects within the array are compact JSON).
101
+ * - objects: compact JSON on a single line.
102
+ */
103
+ function renderBare(value) {
104
+ if (value === null || value === undefined)
105
+ return "";
106
+ if (Array.isArray(value)) {
107
+ return value.map((v) => renderBare(v)).join("\n");
108
+ }
109
+ if (typeof value === "object")
110
+ return JSON.stringify(value);
111
+ return String(value);
112
+ }
17
113
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
18
114
  const TIMESTAMP_KEYS = new Set(["created_at", "updated_at"]);
19
115
  const PAGINATION_KEYS = new Set(["items", "total", "returned", "limit", "offset", "has_more"]);
@@ -151,6 +247,19 @@ function jsonOutput(data, options = {}) {
151
247
  if (_fields && _fields.length > 0) {
152
248
  out = pickFields(out, _fields);
153
249
  }
250
+ // Pattern Ω capture mode: --get <field> returns bare values instead of
251
+ // structured JSON. We extract from the post-lean / post-fields data so the
252
+ // path the agent reasons about matches what they'd see on a normal --json
253
+ // call (e.g. UUIDs already replaced by aliases).
254
+ if (_getField) {
255
+ const extracted = extractGetField(out, _getField);
256
+ if (extracted === MISSING) {
257
+ const err = new Error(`--get: field "${_getField}" not found in response.`);
258
+ err.name = "ValidationError";
259
+ throw err;
260
+ }
261
+ return renderBare(extracted);
262
+ }
154
263
  return JSON.stringify(out, null, 2);
155
264
  }
156
265
  /**
@@ -166,9 +275,29 @@ function injectAliases(items, prefix, idField = "id") {
166
275
  }
167
276
  }
168
277
  // --- JSON mode ---
278
+ /**
279
+ * Catch jsonOutput's --get extraction failure (a ValidationError thrown when
280
+ * the requested field is missing) and route it through outputError + exit 2,
281
+ * so commands that don't go through withClient/runInline (e.g. `ish docs *`)
282
+ * still surface a clean usage error instead of an uncaught stack trace.
283
+ */
284
+ function safeJsonOutput(data, options = {}) {
285
+ try {
286
+ return jsonOutput(data, options);
287
+ }
288
+ catch (err) {
289
+ if (err instanceof Error && err.name === "ValidationError") {
290
+ outputError(err, true);
291
+ process.exit(2);
292
+ }
293
+ throw err;
294
+ }
295
+ }
169
296
  export function output(data, json, options = {}) {
170
297
  if (json) {
171
- console.log(jsonOutput(data, options));
298
+ const text = safeJsonOutput(data, options);
299
+ if (text !== undefined)
300
+ console.log(text);
172
301
  return;
173
302
  }
174
303
  if (data === null || data === undefined)
@@ -185,7 +314,9 @@ export function output(data, json, options = {}) {
185
314
  }
186
315
  export function outputList(rows, json) {
187
316
  if (json) {
188
- console.log(jsonOutput(rows));
317
+ const text = safeJsonOutput(rows);
318
+ if (text !== undefined)
319
+ console.log(text);
189
320
  return;
190
321
  }
191
322
  if (rows.length === 0) {
@@ -77,7 +77,7 @@ Workspace (= product)
77
77
  ├── Tester Profiles (tp-…) reusable audience personas
78
78
  │ └── Sources (tps-…) transcripts/audio/images that seed generation
79
79
  ├── Study (s-…) persistent research artifact
80
- │ ├── modality interactive | text | video | audio | image | document
80
+ │ ├── modality interactive | text | video | audio | image | document | chat
81
81
  │ ├── assignments tasks the tester does
82
82
  │ ├── questionnaire questions the tester answers
83
83
  │ └── Iterations (i-…) one configured run; carries the URL or media
@@ -140,10 +140,58 @@ See \`references/workflows.md\` in this skill for end-to-end transcripts:
140
140
  - Targeting a gated URL (basic auth, session cookie, login form)
141
141
  - Re-running a study with a fresh audience
142
142
 
143
+ ## Display vs. capture: the right output mode
144
+
145
+ Three output modes — pick the one matching your intent, **don't reach
146
+ for \`jq\` / \`python\` reflexively**:
147
+
148
+ | Intent | Use |
149
+ |-------------------------------------------------|----------------------|
150
+ | Show the user a list/table | bare command (TTY) or \`--human\` |
151
+ | Capture one value to feed into the next command | \`--get <field>\` |
152
+ | Parse multiple fields / nested shape | \`--json\` |
153
+
154
+ \`--get\` extracts a single field from the JSON response and prints its
155
+ bare value. It supports dotted paths and auto-descends into list
156
+ \`items\` so \`--get alias\` on a paginated list yields one alias per
157
+ line. \`--human\` forces human output even when stdout is piped — use
158
+ it when you want to \`tee\` a table to a file but still show it. The
159
+ two flags are mutually exclusive (capture and display are different
160
+ intents).
161
+
162
+ ### Worked example — capture in a script, display to the user
163
+
164
+ \`\`\`bash
165
+ # DON'T: shim around the CLI with jq just to grab one value.
166
+ # ASK=$(ish ask create … --json | jq -r .alias)
167
+
168
+ # DO: capture mode — bare value, exit 0.
169
+ ASK=$(ish ask create --new --name demo \\
170
+ --prompt "Which?" --variant text:A --variant text:B \\
171
+ --sample 30 --get alias)
172
+
173
+ # DON'T: pipe --json through jq when you want to show the user a table.
174
+ # ish ask results "$ASK" --json | jq … | tee /tmp/x.txt
175
+
176
+ # DO: --human keeps the table layout even through tee.
177
+ ish ask results "$ASK" --human | tee /tmp/transcript.txt
178
+ \`\`\`
179
+
180
+ Missing field on \`--get\` → exit 2 with a usage error. \`--get\` also
181
+ implies \`--quiet\` so the bare value is the only thing on stdout.
182
+
143
183
  ## Output handling
144
184
 
145
185
  - Every command supports \`--json\`. JSON mode is **auto-enabled when
146
186
  stdout is piped**, so an agent rarely needs \`--json\` explicitly.
187
+ - **\`--get <field>\` is the right way to capture a single value.**
188
+ Dotted paths supported (\`tester_profile.name\`); on a paginated
189
+ \`{items: [...]}\` response, a leading non-\`items\` segment
190
+ auto-descends into items. Replaces the
191
+ \`--json | jq -r .field\` shim. Implies \`--json\` and \`--quiet\`.
192
+ - **\`--human\` forces human output even when stdout is piped.** Use it
193
+ to \`tee\` a table without losing the layout. Mutually exclusive
194
+ with \`--get\`.
147
195
  - \`--fields a,b,c\` strips JSON output to the listed fields (saves
148
196
  tokens). \`--verbose\` adds full UUIDs and timestamps.
149
197
  - **Stdout is data only.** All progress, status, and "Open in browser"
@@ -175,10 +223,38 @@ See \`references/workflows.md\` in this skill for end-to-end transcripts:
175
223
  Top-level field with per-round picks/winner snapshots and
176
224
  \`picks_delta\` (R1 → last). Don't diff two \`ask results\` calls by
177
225
  hand.
178
- - **\`--workspace\` is accepted on every workspace-scoped subcommand.**
179
- Pass it reflexively on any \`ask\`/\`study\`/\`iteration\`/\`profile\`/
180
- \`source\` subcommand when workspace is inferable from an ID alias
181
- it's silently ignored.
226
+ - **\`--workspace\` works at the program root AND every subcommand.**
227
+ \`ish --workspace w-6ec study list\` and \`ish study list --workspace
228
+ w-6ec\` are equivalent; if both are passed, the subcommand-level
229
+ flag wins. Without either, the CLI falls back to \`ISH_WORKSPACE\`
230
+ env then the active workspace in \`~/.ish/config.json\`.
231
+ - **\`profile generate\` emits stderr progress.** \`generating N
232
+ profiles…\` then \`generated N profiles\` around the ~10–20s LLM
233
+ call. Suppress with \`--quiet\`. Generated bios reference the
234
+ brief's domain context naturally (occupation, daily work,
235
+ frustrations) — they no longer parrot vocabulary from the brief
236
+ verbatim. DOBs spread across the year instead of all-on-\`06-15\`.
237
+ - **Empty-pool errors include a country-suggestion line.** When
238
+ \`study run\` / \`ask run --new\` rejects because \`--country XX\`
239
+ matched zero profiles, the error includes the top-3 populated
240
+ countries that satisfy your *other* filters. Pivot directly without
241
+ a second \`profile list\` round-trip.
242
+ - **\`<entity> list\` emits a stderr pagination hint** when
243
+ \`has_more=true\` and \`--quiet\` is unset. Goes to stderr in **every
244
+ mode** (including \`--json\` and piped stdout) — it never pollutes
245
+ machine-readable stdout but is visible to any agent reading stderr.
246
+ Format: "showing N–M of TOTAL; pass --offset M --limit N for more."
247
+ - **\`study delete\` requires explicit confirmation.** Interactive:
248
+ prompts on stderr. Non-interactive (\`--json\`, piped, non-TTY
249
+ stdin): pass \`-y\` / \`--yes\` to confirm. Without it, the CLI
250
+ exits with usage code 2.
251
+ - **\`ask add-questions\` supports \`--wait\` / \`--timeout\`.** Match
252
+ the parity of \`ask create\` and \`ask run\`. Without \`--wait\` the
253
+ command returns after dispatch (round still running).
254
+ - **\`pick_confidence\` (0..1) is on every \`--wants-pick\` response.**
255
+ The model's self-reported confidence in its variant choice. Use it
256
+ to break ties when nominal pick counts are close. See
257
+ \`ish docs get-page concepts/ask\`.
182
258
  - Exit codes carry meaning: 0 success, 2 usage/validation,
183
259
  3 auth, 4 not-found, 5 transient. See
184
260
  \`ish docs get-page reference/json-mode\`.
@@ -404,6 +480,35 @@ URL=$(jq -r 'select(.status=="connected") | .tunnel_url' /tmp/ish-tunnel.log | h
404
480
  ish iteration create --url "$URL"
405
481
  \`\`\`
406
482
 
483
+ ## 7. Display-vs-capture: a script that does both
484
+
485
+ Goal: drive an A/B in a script, capture aliases without \`jq\`, and
486
+ still show the human a readable result table at the end.
487
+
488
+ \`\`\`bash
489
+ # Capture mode — bare values, suitable for shell variables.
490
+ ASK=$(ish ask create --new --name "tagline AB" \\
491
+ --prompt "Which sounds better?" \\
492
+ --variant text:"Short and punchy." \\
493
+ --variant text:"A longer, descriptive line." \\
494
+ --sample 30 --wants-pick --get alias)
495
+
496
+ # Wait silently — exit code is what matters here.
497
+ ish ask wait "$ASK" --timeout 600 --quiet
498
+
499
+ # Capture the winner letter for downstream branching:
500
+ WINNER=$(ish ask results "$ASK" --get rounds.aggregates.winner.letter)
501
+ echo "Winning variant: $WINNER"
502
+
503
+ # Display mode — show the user the full results table even though
504
+ # we're inside a script (stdout is piped to tee).
505
+ ish ask results "$ASK" --human | tee "/tmp/ask-\${ASK}.txt"
506
+ \`\`\`
507
+
508
+ The mental rule: **\`--get\` is for capture, bare commands / \`--human\`
509
+ are for display, \`--json\` is for chaining (multiple fields at once).**
510
+ If you find yourself reaching for \`jq -r .x\`, you wanted \`--get x\`.
511
+
407
512
  ## Tips for chaining commands as an agent
408
513
 
409
514
  - Capture aliases from JSON: \`ITER=$(ish iteration create --url … --json | jq -r .alias)\`
@@ -428,12 +533,16 @@ ish iteration create --url "$URL"
428
533
 
429
534
  | You want to… | Don't | Do |
430
535
  |-------------------------------------------|----------------------------------------|--------------------------------------------------------------------|
536
+ | Capture a single value (alias, id, …) | \`--json \\| jq -r .alias\` | \`--get alias\` |
537
+ | Capture a nested value | \`--json \\| jq -r .tester_profile.name\` | \`--get tester_profile.name\` |
538
+ | Capture every alias from a list | \`--json \\| jq -r '.items[].alias'\` | \`--get alias\` (auto-descends into \`items\`, one per line) |
539
+ | Force human output through tee/redirect | none, output silently became JSON | \`--human\` |
431
540
  | Look up 2-3 specific profiles | \`profile list --json \\| jq '.items[] \\| select(...)'\` | \`ish profile get tp-1b9 tp-fc1 tp-2fc\` |
432
541
  | Show only some fields | \`--json \\| jq '{alias, name, country}'\` | \`--fields alias,name,country\` |
433
542
  | Count testers on an ask | \`--json \\| jq '.testers \\| length'\` | \`ish ask get a-… --fields alias,testers_count\` |
434
543
  | Count responses on a round | \`--json \\| jq '.rounds[0].responses \\| length'\` | \`ish ask get a-… --fields alias,rounds,responses_complete,responses_total\` |
435
544
  | Pick the A/B winner | \`--json \\| jq '.rounds[0].responses…'\` | \`ish ask results a-… --json\` then read \`.rounds[].aggregates.winner\` |
436
- | List of testers from \`study run\` | \`--json \\| jq '.testers[].id'\` | \`--json\` already has \`tester_ids[]\` and \`tester_aliases[]\` |
545
+ | List of testers from \`study run\` | \`--json \\| jq '.testers[].id'\` | \`--get tester_aliases\` (or \`tester_ids\` for UUIDs) |
437
546
 
438
547
  The bias here is intentional: \`ish\` ships shapes designed for agent
439
548
  consumption. If you find yourself reaching for \`jq\` or \`python\` to
@@ -496,6 +605,8 @@ is expected. Full UUIDs always work too. See
496
605
 
497
606
  | Flag | Effect |
498
607
  |------------------|----------------------------------------------------------|
608
+ | \`--get <field>\` | **Capture mode.** Print the bare value at the dotted path; auto-descends into list \`items\`. Implies \`--json\` + \`--quiet\`. Replaces \`--json \\| jq -r .field\`. |
609
+ | \`--human\` | **Force display mode** even when stdout is piped (overrides JSON-when-piped). Mutually exclusive with \`--get\`. |
499
610
  | \`--json\` | JSON output (auto-on when stdout is piped) |
500
611
  | \`--fields a,b\` | Keep only listed fields in JSON |
501
612
  | \`--verbose\` | Include UUIDs + timestamps in JSON |
@@ -503,6 +614,9 @@ is expected. Full UUIDs always work too. See
503
614
  | \`-t, --token\` | Auth token (else ISH_TOKEN env, else \`ish login\` saved) |
504
615
  | \`--api-url\` | Override backend (default https://api.ishlabs.io) |
505
616
 
617
+ See \`ish docs get-page reference/json-mode\` for the full display-vs-
618
+ capture-vs-chain decision rule.
619
+
506
620
  ## Exit codes
507
621
 
508
622
  \`0\` ok · \`1\` general · \`2\` usage/validation · \`3\` auth ·
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ishlabs/cli",
3
- "version": "0.8.4",
4
- "description": "The command-line interface for Ish",
3
+ "version": "0.9.0",
4
+ "description": "The command-line interface for ish",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "ish": "./dist/index.js"
@@ -41,4 +41,4 @@
41
41
  "@types/node": "^22.0.0",
42
42
  "typescript": "^5.7.0"
43
43
  }
44
- }
44
+ }