@ishlabs/cli 0.19.0 → 0.20.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.
@@ -28,7 +28,7 @@ const VISIBILITY_ALIASES = {
28
28
  private: "workspace",
29
29
  public: "platform",
30
30
  };
31
- function normalizeVisibility(raw) {
31
+ export function normalizeVisibility(raw) {
32
32
  if (raw === undefined)
33
33
  return undefined;
34
34
  return VISIBILITY_ALIASES[raw] ?? raw;
@@ -186,6 +186,12 @@ export async function resolvePersonIds(client, workspace, flags, opts = {}) {
186
186
  if (sampleN === undefined && !flags.all && !filtersUsed) {
187
187
  throw new Error(`Select people: pass --person <id> (repeatable), --sample <N>, ${allFlagName}, or filter flags (--bio, --country, --gender, --min-age, --max-age, --occupation, --search, --visibility).`);
188
188
  }
189
+ // NEW-CP-2 / Pattern H: --sample N > backend cap is detectable without
190
+ // any API call — fail fast so a bad value doesn't burn a /people round-trip
191
+ // before the cap message surfaces.
192
+ if (sampleN !== undefined && sampleN > PARTICIPANT_BATCH_CAP) {
193
+ throw new Error(`--sample ${sampleN} exceeds the per-dispatch participant cap of ${PARTICIPANT_BATCH_CAP}. Pass --sample ${PARTICIPANT_BATCH_CAP} or fewer, or split the run into multiple dispatches.`);
194
+ }
189
195
  const params = {
190
196
  product_id: workspace,
191
197
  type: "ai",
@@ -245,15 +251,41 @@ export async function resolvePersonIds(client, workspace, flags, opts = {}) {
245
251
  throw new Error(`No ${sim}people found in workspace ${workspace}.${opts.requireSimulatable ? " Create people with simulation configs first." : ""}`);
246
252
  }
247
253
  if (flags.all)
248
- return pool.map((p) => p.id);
254
+ return enforceParticipantCap(pool.map((p) => p.id), flags, opts);
249
255
  if (sampleN !== undefined) {
250
256
  if (sampleN > pool.length) {
251
257
  throw new Error(`--sample ${sampleN} requested but only ${pool.length} matching person${pool.length === 1 ? "" : "s"} available.`);
252
258
  }
259
+ // sampleN > PARTICIPANT_BATCH_CAP is caught earlier (before the
260
+ // /people fetch) so we don't reach here with an over-cap sample.
253
261
  return shuffleInPlace([...pool]).slice(0, sampleN).map((p) => p.id);
254
262
  }
255
- // Filters only, no --sample/--all → return every match.
256
- return pool.map((p) => p.id);
263
+ // Filters only, no --sample/--all → return every match (subject to cap).
264
+ return enforceParticipantCap(pool.map((p) => p.id), flags, opts);
265
+ }
266
+ /**
267
+ * Backend caps each dispatch batch at 20 simulations (Pydantic
268
+ * `max_length=20` on the `simulations` array in
269
+ * app/api/models/simulation.py, media.py, and chat.py). Without a
270
+ * client-side guard, `--all` on a workspace with platform-visible
271
+ * people resolves to ~200 (the `/people` pagination limit) and the
272
+ * backend returns a `validation_error` ("List should have at most 20
273
+ * items after validation, not 200") that's confusing without context.
274
+ * Throw a helpful client-side error before the dispatch so the user
275
+ * knows to sample explicitly.
276
+ *
277
+ * Discovered during the 2026-05-26 post-remediation checkpoint sweep
278
+ * (NEW-CP-2). If the backend cap changes, update PARTICIPANT_BATCH_CAP.
279
+ */
280
+ const PARTICIPANT_BATCH_CAP = 20;
281
+ function enforceParticipantCap(ids, flags, opts) {
282
+ if (ids.length <= PARTICIPANT_BATCH_CAP)
283
+ return ids;
284
+ const allFlagName = opts.allFlagName ?? "--all";
285
+ const filterDesc = describeFilters(flags) || "no filter";
286
+ throw new Error(`Resolved ${ids.length} participants (${filterDesc}) but the backend caps each dispatch at ${PARTICIPANT_BATCH_CAP}. ` +
287
+ `Pass \`--sample ${PARTICIPANT_BATCH_CAP}\` to randomly subsample the pool, narrow your filters, or run the dispatch ` +
288
+ `multiple times against different slices. ${allFlagName} without --sample is only safe when the matching pool is ≤${PARTICIPANT_BATCH_CAP}.`);
257
289
  }
258
290
  /**
259
291
  * Attach the person-selection flag set to a Commander command.
@@ -375,7 +407,25 @@ export function exitCodeFromError(err) {
375
407
  return 5;
376
408
  }
377
409
  if (err instanceof Error) {
378
- // Auth-related client errors (e.g. missing token)
410
+ // Pattern D: structured `error_code` on the Error object takes
411
+ // precedence over message-regex sniffing. Sites that self-tag
412
+ // (alias-store, auth, sub-command envelopes) get a deterministic
413
+ // exit code; sites that don't fall back to the legacy regex below.
414
+ // Order matters: this block must run BEFORE the /^invalid /i and
415
+ // /no auth token found/ regexes (which would otherwise misclassify
416
+ // the new `not_found` tag as `2` because the message starts with
417
+ // "Invalid ID").
418
+ const code = err.error_code;
419
+ if (typeof code === "string") {
420
+ if (code === "usage_error")
421
+ return 2;
422
+ if (code === "auth_failed")
423
+ return 3;
424
+ if (code === "not_found")
425
+ return 4;
426
+ }
427
+ // Auth-related client errors (e.g. missing token) — legacy regex
428
+ // path; the new error_code tagging above is the preferred route.
379
429
  if (/no auth token found|run "ish login"|saved token is invalid|session expired/i.test(err.message))
380
430
  return 3;
381
431
  // Client-side validation failures
@@ -393,8 +443,30 @@ export function exitCodeFromError(err) {
393
443
  // Structured error_kind on the Error object (set by chat endpoint test/init,
394
444
  // simulation routes, etc.). TunnelInactive is the canonical transient one.
395
445
  const kind = err.error_kind;
396
- if (typeof kind === "string" && kind === "TunnelInactive")
397
- return 5;
446
+ if (typeof kind === "string") {
447
+ // Transient: user can fix the cause and retry (tunnel down, bot
448
+ // auth credentials missing/wrong, upstream rate-limited).
449
+ if (kind === "TunnelInactive" || kind === "BotAuthError")
450
+ return 5;
451
+ // Validation-shaped: user passed something bad to the CLI/endpoint.
452
+ if (kind === "ConfirmationRequired" || kind === "BotShapeError")
453
+ return 2;
454
+ }
455
+ // (error_code mapping moved earlier in the function — see top of the
456
+ // `err instanceof Error` block. Order matters: it must run BEFORE
457
+ // the legacy regex sniffers to honor self-tagged errors.)
458
+ // Pattern E (ISSUE-021): DNS / connection failures are transient.
459
+ // Node's fetch surfaces them as TypeError with .cause = { code: "..." }.
460
+ const cause = err.cause;
461
+ if (cause && typeof cause === "object") {
462
+ const causeCode = cause.code;
463
+ if (typeof causeCode === "string" && (causeCode === "ENOTFOUND"
464
+ || causeCode === "ECONNREFUSED"
465
+ || causeCode === "ECONNRESET"
466
+ || causeCode === "ETIMEDOUT"
467
+ || causeCode === "EAI_AGAIN"))
468
+ return 5;
469
+ }
398
470
  }
399
471
  return 1;
400
472
  }
package/dist/lib/docs.js CHANGED
@@ -156,6 +156,25 @@ first scraping the list.
156
156
  The full saturated-account walkthrough (with branch logic + a worked
157
157
  transcript) lives at \`guides/cold-start\`.
158
158
 
159
+ ## Deleting a workspace
160
+
161
+ \`ish workspace delete <id>\` is the **highest-blast-radius destructive op
162
+ in the CLI** — it removes ALL nested studies, asks, people, secrets,
163
+ configs, sources, and chat endpoints. The confirmation guard is
164
+ mandatory:
165
+
166
+ - **Interactive (TTY)**: prompts on stderr naming the workspace; type
167
+ \`y\` to proceed.
168
+ - **Non-interactive** (\`--json\`, piped, or non-TTY stdin): pass
169
+ \`-y\` / \`--yes\` to confirm. Without it, the CLI exits with usage
170
+ code 2 rather than deleting silently.
171
+
172
+ \`\`\`
173
+ ish workspace delete w-6ec # interactive prompt
174
+ ish workspace delete w-6ec --yes # skip prompt
175
+ ish workspace delete w-6ec --json --yes # JSON/agent consumers must be explicit
176
+ \`\`\`
177
+
159
178
  ## Related
160
179
 
161
180
  - \`guides/cold-start\` — saturated-account first-step playbook
@@ -1083,12 +1102,32 @@ round-trips when you know them up front:
1083
1102
  - \`image:./hero-a.png\` — local image (auto-uploaded)
1084
1103
  - \`image:./a.png::label=A\` — with explicit label
1085
1104
 
1105
+ ## Deleting an ask
1106
+
1107
+ \`ish ask delete <id>\` requires explicit confirmation (parallels
1108
+ \`workspace delete\`, \`study delete\`, \`person delete\`, \`source
1109
+ delete\`, \`chat endpoint delete\`):
1110
+
1111
+ - **Interactive (TTY)**: prompts on stderr; type \`y\` to proceed.
1112
+ - **Non-interactive** (\`--json\`, piped, or non-TTY stdin): pass
1113
+ \`-y\` / \`--yes\` to confirm. Without it, the CLI exits with usage
1114
+ code 2 rather than deleting silently.
1115
+
1116
+ \`\`\`
1117
+ ish ask delete a-6ec # interactive prompt
1118
+ ish ask delete a-6ec --yes # skip prompt
1119
+ ish ask delete a-6ec --json --yes # JSON consumers must be explicit
1120
+ \`\`\`
1121
+
1122
+ The active ask is auto-cleared from \`~/.ish/config.json\` if the
1123
+ deleted ask was the active one.
1124
+
1086
1125
  ## Related
1087
1126
 
1088
1127
  - \`concepts/round\` — what a round is and how it executes.
1089
1128
  - \`concepts/people\` — how participants are chosen at ask creation.
1090
1129
  - \`concepts/run-verbs\` — \`ish ask run\` vs \`ish study run\`.
1091
- - \`reference/credits\` — ask rounds bill 1 credit per successful response.
1130
+ - \`reference/credits\` — ask rounds bill \`n_participants * (1 + len(questions))\` credits per round; \`questions\` follow-ups bill *per participant* on top of the base response, so a 3-person panel with 2 follow-up questions costs \`3 * (1 + 2) = 9\` credits when all complete (not 3).
1092
1131
  `;
1093
1132
  const CONCEPT_ROUND = `# concept: round
1094
1133
 
@@ -1261,6 +1300,21 @@ The legacy \`--tech-savviness\` flag was removed in
1261
1300
  \`person-schema-v2\`; passing it now produces commander's standard
1262
1301
  "unknown option" error.
1263
1302
 
1303
+ ## Deleting a person
1304
+
1305
+ \`ish person delete <id>\` requires explicit confirmation:
1306
+
1307
+ - **Interactive (TTY)**: prompts on stderr; type \`y\` to proceed.
1308
+ - **Non-interactive** (\`--json\`, piped, or non-TTY stdin): pass
1309
+ \`-y\` / \`--yes\` to confirm. Without it, the CLI exits with usage
1310
+ code 2 rather than deleting silently.
1311
+
1312
+ \`\`\`
1313
+ ish person delete p-d4e # interactive prompt
1314
+ ish person delete p-d4e --yes # skip prompt
1315
+ ish person delete p-d4e --json --yes # JSON consumers must be explicit
1316
+ \`\`\`
1317
+
1264
1318
  ## Related
1265
1319
 
1266
1320
  - \`concepts/source\` — the inputs to \`person generate\`.
@@ -1303,6 +1357,24 @@ in real customer evidence.
1303
1357
  ish source get ps-3a4
1304
1358
  \`\`\`
1305
1359
 
1360
+ ## Deleting a source
1361
+
1362
+ \`ish source delete <id>\` requires explicit confirmation:
1363
+
1364
+ - **Interactive (TTY)**: prompts on stderr; type \`y\` to proceed.
1365
+ - **Non-interactive** (\`--json\`, piped, or non-TTY stdin): pass
1366
+ \`-y\` / \`--yes\` to confirm. Without it, the CLI exits with usage
1367
+ code 2 rather than deleting silently.
1368
+
1369
+ \`\`\`
1370
+ ish source delete ps-3a4 # interactive prompt
1371
+ ish source delete ps-3a4 --yes # skip prompt
1372
+ ish source delete ps-3a4 --json --yes # JSON consumers must be explicit
1373
+ \`\`\`
1374
+
1375
+ The backend ref-counts the underlying file: the storage object is
1376
+ removed only when no profile mappings remain.
1377
+
1306
1378
  ## Related
1307
1379
 
1308
1380
  - \`concepts/person\` — sources feed profile generation.
@@ -1405,6 +1477,30 @@ manager"\` or \`"retail associate"\` return many. Two adaptations:
1405
1477
  - \`ish ask run\` (without \`--new\`) → cannot change participants; the ask
1406
1478
  fixes it at creation. Audience flags only apply with \`--new\`.
1407
1479
 
1480
+ ## Per-dispatch cap (20)
1481
+
1482
+ Each \`study run\` / \`ask run\` / \`chat\` dispatch is capped at **20
1483
+ participants** by the backend (\`max_length=20\` on the \`simulations\`
1484
+ list). The CLI enforces this client-side BEFORE the network round-trip
1485
+ so a too-large \`--sample\` or an unbounded \`--all\` returns a clear
1486
+ error instead of a confusing server-side \`validation_error\`:
1487
+
1488
+ \`\`\`
1489
+ $ ish study run --all # on a workspace with platform pool
1490
+ Error: Resolved 200 participants (no filter) but the backend caps each dispatch at 20.
1491
+ Pass \`--sample 20\` to randomly subsample the pool, narrow your filters, or run
1492
+ the dispatch multiple times against different slices. --all without --sample is
1493
+ only safe when the matching pool is ≤20.
1494
+
1495
+ $ ish study run --sample 25 # bad value caught before /people fetch
1496
+ Error: --sample 25 exceeds the per-dispatch participant cap of 20.
1497
+ Pass --sample 20 or fewer, or split the run into multiple dispatches.
1498
+ \`\`\`
1499
+
1500
+ For larger panels: dispatch multiple times against different demographic
1501
+ slices (\`--country SE\`, then \`--country GB\`, etc.) or use the web UI
1502
+ which batches behind the scenes.
1503
+
1408
1504
  ## Examples
1409
1505
 
1410
1506
  \`\`\`
@@ -2254,17 +2350,33 @@ The CLI guarantees these contracts so agents can chain safely:
2254
2350
 
2255
2351
  ## Exit codes
2256
2352
 
2257
- | Code | Meaning |
2258
- |------|----------------------|
2259
- | 0 | Success |
2260
- | 1 | General error |
2261
- | 2 | Usage / validation |
2262
- | 3 | Auth (re-run \`ish login\`) |
2263
- | 4 | Not found |
2264
- | 5 | Transient — retryable (timeout, 5xx, network) |
2353
+ | Code | Meaning | Common \`error_code\` values |
2354
+ |------|----------------------|------------------------------|
2355
+ | 0 | Success | — |
2356
+ | 1 | General error | \`server\`, \`client_error\` (uncategorized) |
2357
+ | 2 | Usage / validation | \`usage_error\` (Commander), \`validation_error\` (server), \`ConfirmationRequired\` |
2358
+ | 3 | Auth (re-run \`ish login\`) | \`auth_failed\`, missing-token errors |
2359
+ | 4 | Not found | \`not_found\` |
2360
+ | 5 | Transient — retryable | \`timeout\`, \`TunnelInactive\`, \`BotAuthError\`, DNS / network (\`ENOTFOUND\`, \`ECONNREFUSED\`) |
2265
2361
 
2266
2362
  Use these to branch in scripts; do not parse the human stderr message.
2267
2363
 
2364
+ **Commander-level errors** (unknown command, missing required argument,
2365
+ missing required option) all exit **2** with \`error_code: "usage_error"\`.
2366
+ The suggestion field points at the right help target:
2367
+
2368
+ - Unknown command → \`Run \`ish --help\` for usage\` (the typo IS the
2369
+ command name — don't point at it; Commander also appends
2370
+ \`(Did you mean workspace?)\` for near-matches).
2371
+ - Missing argument / option → \`Run \`ish <command> --help\` for usage\`
2372
+ (substituted with the actual command, e.g. \`ish workspace --help\`).
2373
+
2374
+ **DNS / connection failures** (a wrong \`--api-url\`, a backend that's
2375
+ down, a captive portal) exit **5** so scripts retry rather than abort
2376
+ permanently. The underlying \`fetch\` \`TypeError\` is detected via its
2377
+ \`cause.code\` (\`ENOTFOUND\`, \`ECONNREFUSED\`, \`ECONNRESET\`,
2378
+ \`ETIMEDOUT\`, \`EAI_AGAIN\`).
2379
+
2268
2380
  ## Error envelope
2269
2381
 
2270
2382
  When a command fails with \`--json\` (or piped stdout), the CLI prints
@@ -2294,6 +2406,12 @@ a structured error object on **stdout** and a human message on
2294
2406
  (\`validation\`, \`auth\`, \`not_found\`, \`timeout\`, \`server\`,
2295
2407
  \`network\`, …). \`retryable: true\` matches exit code 5.
2296
2408
 
2409
+ The \`status\` field carries the upstream **HTTP status code** when one
2410
+ is available (e.g. \`401\`, \`404\`, \`422\`). It is **omitted entirely**
2411
+ from envelopes that don't originate from an HTTP response (Commander
2412
+ parse errors, local validation failures, alias-resolution errors). Do
2413
+ not branch on \`status: 0\` — that value is never emitted as of 0.20.
2414
+
2297
2415
  ## Conventions
2298
2416
 
2299
2417
  - Successful commands exit 0 and print one JSON object/array on stdout.
@@ -2411,13 +2529,19 @@ The CLI keeps a small amount of session state in \`~/.ish/config.json\`
2411
2529
  (or wherever \`ISH_HOME\` points) so commands don't need to repeat IDs:
2412
2530
 
2413
2531
  - \`access_token\` / \`refresh_token\` — the OAuth pair from \`ish login\`.
2414
- - \`workspace\` — set by \`ish workspace use <id>\`.
2415
- - \`study\` — set by \`ish study use <id>\`.
2416
- - \`ask\` — set by \`ish ask use <id>\`.
2532
+ - \`workspace\` — set by \`ish workspace use <id>\`.
2533
+ - \`study\` — set by \`ish study use <id>\` (or implicitly by \`ish study create\`).
2534
+ - \`ask\` — set by \`ish ask use <id>\` (or implicitly by \`ish ask create\`).
2535
+ - \`chat_endpoint\` — set by \`ish chat endpoint use <id>\`.
2417
2536
 
2418
2537
  Most commands fall back to these when their corresponding flag is
2419
2538
  omitted (\`--workspace\`, \`--study\`, \`--ask\`).
2420
2539
 
2540
+ **\`workspace\` is the parent** of \`study\`, \`ask\`, and \`chat_endpoint\` —
2541
+ all three are scoped to a single workspace. Switching workspaces
2542
+ (\`ish workspace use <other>\`) clears all three to avoid cross-workspace
2543
+ footguns, and the CLI prints a one-line stderr note when it does so.
2544
+
2421
2545
  ## Inspecting active context
2422
2546
 
2423
2547
  \`ish status\` (alias: \`ish whoami\`) is the canonical way to see what's
@@ -2430,7 +2554,8 @@ ish status
2430
2554
  # User: you@example.com (token valid, expires in 47m)
2431
2555
  # Workspace: Onboarding revamp (w-6ec)
2432
2556
  # Study: —
2433
- # Ask: a-6ec "tagline AB"
2557
+ # Ask: tagline AB (a-6ec)
2558
+ # Chat ep: —
2434
2559
  # Home: /home/you/.ish
2435
2560
  # API: https://api.ishlabs.io
2436
2561
  \`\`\`
@@ -2439,12 +2564,13 @@ JSON shape (\`ish status --json\` or piped):
2439
2564
 
2440
2565
  \`\`\`json
2441
2566
  {
2442
- "user": { "email": "...", "token_valid": true, "expires_in_seconds": 2820 },
2443
- "workspace": { "id": "...", "alias": "w-6ec", "name": "Onboarding revamp" },
2444
- "study": null,
2445
- "ask": { "id": "...", "alias": "a-6ec", "name": "tagline AB" },
2446
- "api_url": "https://api.ishlabs.io",
2447
- "home": "/home/you/.ish"
2567
+ "user": { "email": "...", "token_valid": true, "expires_in_seconds": 2820 },
2568
+ "workspace": { "id": "...", "alias": "w-6ec", "name": "Onboarding revamp" },
2569
+ "study": null,
2570
+ "ask": { "id": "...", "alias": "a-6ec", "name": "tagline AB" },
2571
+ "chat_endpoint": null,
2572
+ "api_url": "https://api.ishlabs.io",
2573
+ "home": "/home/you/.ish"
2448
2574
  }
2449
2575
  \`\`\`
2450
2576
 
@@ -2453,19 +2579,83 @@ JSON shape (\`ish status --json\` or piped):
2453
2579
  \`ish login\`. Safe to run unconditionally at the start of any
2454
2580
  script or agent session.
2455
2581
 
2582
+ ### \`ish login\` is idempotent
2583
+
2584
+ When you already have a valid saved token, \`ish login\` short-circuits
2585
+ with a friendly "Already logged in" message and **does not** open a new
2586
+ browser tab or register a fresh OAuth client. Use \`--force\` (or \`-f\`)
2587
+ to bypass the guard — typical reason is switching accounts.
2588
+
2589
+ \`\`\`bash
2590
+ ish login # no-op when already authenticated
2591
+ ish login --force # always re-run the browser flow
2592
+ \`\`\`
2593
+
2594
+ The short-circuit returns a structured envelope under \`--json\`:
2595
+
2596
+ \`\`\`json
2597
+ {
2598
+ "message": "Already logged in",
2599
+ "email": "you@example.com",
2600
+ "token_valid": true,
2601
+ "expires_in_seconds": 2820,
2602
+ "hint": "Pass --force to re-run the browser flow (e.g. to switch accounts)."
2603
+ }
2604
+ \`\`\`
2605
+
2606
+ ### Orphan / stale active refs
2607
+
2608
+ If an active ref points at an entity that no longer exists or moved
2609
+ workspace, \`status\` surfaces a \`warning\` field on that ref (instead
2610
+ of silently dropping the \`name\`). Each warned ref also gets a \`hint\`
2611
+ field with the exact command to clear or replace it:
2612
+
2613
+ \`\`\`json
2614
+ {
2615
+ "study": {
2616
+ "id": "...",
2617
+ "alias": "s-74d",
2618
+ "warning": "orphan — entity no longer exists in this workspace",
2619
+ "hint": "Active study is no longer accessible (deleted, moved workspace, or auth issue). Use \`ish study use <id>\` to switch, or \`ish study use --clear\` to drop."
2620
+ }
2621
+ }
2622
+ \`\`\`
2623
+
2624
+ In human output the warning prints as \`⚠ ...\` under the row and a
2625
+ follow-up line shows the hint.
2626
+
2456
2627
  ## Setting / clearing active context
2457
2628
 
2458
2629
  \`\`\`bash
2459
- ish workspace use w-6ec # set
2460
- ish workspace use --clear # clear
2630
+ ish workspace use w-6ec # set (also clears active study/ask/chat_endpoint if workspace changed)
2631
+ ish workspace use --clear # clear workspace + all workspace-scoped children
2461
2632
 
2462
2633
  ish study use s-b2c
2463
2634
  ish study use --clear
2464
2635
 
2465
2636
  ish ask use a-6ec
2466
2637
  ish ask use --clear
2638
+
2639
+ ish chat endpoint use ep-abc
2640
+ ish chat endpoint use --clear
2467
2641
  \`\`\`
2468
2642
 
2643
+ ### Auto-activation on create
2644
+
2645
+ \`ish study create\`, \`ish ask create\`, and \`ish workspace use\` all
2646
+ update the active context as a side-effect (so the natural next command
2647
+ — \`ish iteration create --study <new>\`, \`ish ask add-round\`, etc. —
2648
+ "just works" without re-typing the ID). The CLI **emits a one-line
2649
+ stderr notice** when this happens; consumers piping stdout get the new
2650
+ record while the auto-activate is visible to operators.
2651
+
2652
+ ### Cleanup on delete
2653
+
2654
+ \`ish workspace delete\`, \`ish study delete\`, \`ish ask delete\`, and
2655
+ \`ish chat endpoint delete\` automatically clear matching active refs
2656
+ from \`~/.ish/config.json\` so subsequent commands don't render orphans.
2657
+ \`workspace delete\` also clears all workspace-scoped children.
2658
+
2469
2659
  ## Overriding without persisting
2470
2660
 
2471
2661
  Every read command accepts \`--workspace <id>\`, \`--study <id>\`, or
@@ -224,7 +224,11 @@ function wrapList(items, existing, opts = {}) {
224
224
  const limit = typeof existing?.limit === "number" ? existing.limit : returned;
225
225
  const offset = typeof existing?.offset === "number" ? existing.offset : 0;
226
226
  const has_more = total > offset + returned;
227
- const leanItems = _verbose || opts.preProjectedItems
227
+ // ISSUE-031: if the user explicitly named fields via --fields or --get,
228
+ // skip the per-item lean-strip so the requested fields actually survive
229
+ // to the output. Otherwise `--fields id,alias` silently drops `id`.
230
+ const userSpecifiedFields = (_fields && _fields.length > 0) || (typeof _getField === "string" && _getField.length > 0);
231
+ const leanItems = _verbose || opts.preProjectedItems || userSpecifiedFields
228
232
  ? items
229
233
  : leanJson(items) ?? [];
230
234
  return { items: leanItems, total, returned, limit, offset, has_more };
@@ -277,9 +281,16 @@ function pickFields(data, fields) {
277
281
  /** Serialize data as JSON, applying lean transform and field selection. */
278
282
  function jsonOutput(data, options = {}) {
279
283
  let out;
280
- if (_verbose || options.preProjected) {
281
- // Verbose: full payload. preProjected: caller already chose the fields,
282
- // so don't strip again (otherwise leanJson would drop e.g. created_at).
284
+ // ISSUE-031: when the user explicitly names fields via --fields or
285
+ // --get, their request takes precedence over the default lean-strip.
286
+ // Previously --fields id,alias would silently drop `id` because
287
+ // leanJson ran first and stripped UUID-valued fields. The user
288
+ // asking for a field is unambiguous intent — bypass the strip.
289
+ const userSpecifiedFields = (_fields && _fields.length > 0) || (typeof _getField === "string" && _getField.length > 0);
290
+ if (_verbose || options.preProjected || userSpecifiedFields) {
291
+ // Verbose: full payload. preProjected: caller already chose the fields.
292
+ // userSpecifiedFields: caller said exactly what they want — don't
293
+ // second-guess by stripping UUIDs they asked for.
283
294
  out = data;
284
295
  }
285
296
  else {
@@ -461,6 +472,29 @@ function suggestionsForError(err) {
461
472
  }
462
473
  return [];
463
474
  }
475
+ /**
476
+ * Pattern F (ISSUE-023): rewrite server-side internal entity names to the
477
+ * user-facing CLI surface names so messages like "Product not found" don't
478
+ * leak the backend's internal vocabulary to a first-time CLI user.
479
+ *
480
+ * Mapping reflects the rename audit: server still calls workspaces
481
+ * "products" and people "profiles" / "tester profiles" in some response
482
+ * messages; the CLI surface is `workspace` / `person`. Source is
483
+ * `attachment` server-side.
484
+ *
485
+ * Word-boundary anchored to avoid touching e.g. "productivity" or
486
+ * "Attachments must be ..." (the latter is genuinely about attachments).
487
+ */
488
+ function remapEntityName(message) {
489
+ return message
490
+ .replace(/\bProduct not found\b/g, "Workspace not found")
491
+ .replace(/\bproduct not found\b/g, "workspace not found")
492
+ .replace(/\bTester [Pp]rofile not found\b/g, (m) => m.includes("P") ? "Person not found" : "person not found")
493
+ .replace(/\bProfile not found\b/g, "Person not found")
494
+ .replace(/\bprofile not found\b/g, "person not found")
495
+ .replace(/\bAttachment not found\b/g, "Source not found")
496
+ .replace(/\battachment not found\b/g, "source not found");
497
+ }
464
498
  export function outputError(err, json) {
465
499
  const suggestions = suggestionsForError(err);
466
500
  if (err instanceof ApiError) {
@@ -505,7 +539,7 @@ export function outputError(err, json) {
505
539
  : undefined;
506
540
  if (json) {
507
541
  console.error(JSON.stringify({
508
- error: err.message,
542
+ error: remapEntityName(err.message),
509
543
  error_code: err.error_code,
510
544
  status: err.status,
511
545
  retryable: err.retryable,
@@ -527,7 +561,7 @@ export function outputError(err, json) {
527
561
  console.error("Error: Insufficient credits. Purchase more at https://app.ishlabs.io");
528
562
  }
529
563
  else {
530
- console.error(`Error: ${err.message}`);
564
+ console.error(`Error: ${remapEntityName(err.message)}`);
531
565
  }
532
566
  if (Array.isArray(bodyErrors)) {
533
567
  for (const entry of bodyErrors) {
@@ -549,6 +583,12 @@ export function outputError(err, json) {
549
583
  else if (err instanceof ValidationError) {
550
584
  if (json) {
551
585
  console.error(JSON.stringify({
586
+ // ValidationError is CLI-thrown (we control its message), so we
587
+ // don't apply remapEntityName — that helper exists to translate
588
+ // server-side internal vocabulary ("Product"/"Profile"/"Attachment")
589
+ // back to user-facing names. Applying it here risks mangling
590
+ // user-supplied content (e.g. a workspace name containing "Profile
591
+ // not found"). Restrict the remap to the ApiError branch.
552
592
  error: err.message,
553
593
  error_code: "validation_error",
554
594
  retryable: false,
@@ -597,6 +637,10 @@ export function outputError(err, json) {
597
637
  const mergedSuggestions = [...new Set([...suggestions, ...taggedSuggestions])];
598
638
  if (json) {
599
639
  console.error(JSON.stringify({
640
+ // Generic Error: CLI-thrown (we control the message), so we don't
641
+ // apply remapEntityName — see ValidationError branch above for the
642
+ // same reasoning. Server-side names only leak through the ApiError
643
+ // branch where remapEntityName IS applied.
600
644
  error: err.message,
601
645
  error_code: errorCode,
602
646
  retryable,
@@ -702,7 +746,7 @@ export function formatWorkspaceList(workspaces, json) {
702
746
  return;
703
747
  }
704
748
  const aliasMap = getAliasMap(ALIAS_PREFIX.workspace);
705
- printTable(["#", "NAME", "ROOM", "STUDIES", "ASKS", "PARTICIPANTS", "LAST ACTIVITY"], workspaces.map((w) => [
749
+ printTable(["ALIAS", "NAME", "ROOM", "STUDIES", "ASKS", "PARTICIPANTS", "LAST ACTIVITY"], workspaces.map((w) => [
706
750
  aliasMap.get(String(w.id)) || String(w.id || ""),
707
751
  String(w.name || ""),
708
752
  formatHeadroom(w.has_headroom),
@@ -784,7 +828,7 @@ export function formatStudyList(studies, json) {
784
828
  return;
785
829
  }
786
830
  const aliasMap = getAliasMap(ALIAS_PREFIX.study);
787
- printTable(["#", "NAME", "MODALITY", "TYPE", "STATUS", "PARTICIPANTS"], studies.map((s) => [
831
+ printTable(["ALIAS", "NAME", "MODALITY", "TYPE", "STATUS", "PARTICIPANTS"], studies.map((s) => [
788
832
  aliasMap.get(String(s.id)) || String(s.id || ""),
789
833
  String(s.name || ""),
790
834
  String(s.modality || "-"),
@@ -934,7 +978,7 @@ export function formatStudyDetail(study, json, options = {}, participants) {
934
978
  const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
935
979
  if (allParticipants.length > 0) {
936
980
  console.log(`\nParticipants (${allParticipants.length}):`);
937
- printTable(["#", "NAME", "ITERATION", "STATUS", "INTERACTIONS"], allParticipants.map((t) => [
981
+ printTable(["ALIAS", "NAME", "ITERATION", "STATUS", "INTERACTIONS"], allParticipants.map((t) => [
938
982
  t.id ? deterministicAlias(ALIAS_PREFIX.participant, t.id) : t.id,
939
983
  t.name,
940
984
  t.iterationLabel,
@@ -1074,7 +1118,7 @@ export function formatStudyResults(study, participants, json) {
1074
1118
  // Participants table
1075
1119
  if (allParticipants.length > 0) {
1076
1120
  console.log("\nParticipants:");
1077
- printTable(["#", "NAME", "ITERATION", "STATUS", "INTERACTIONS", "SENTIMENT"], allParticipants.map((t) => {
1121
+ printTable(["ALIAS", "NAME", "ITERATION", "STATUS", "INTERACTIONS", "SENTIMENT"], allParticipants.map((t) => {
1078
1122
  const parts = Object.entries(t.sentimentCounts).map(([label, count]) => `${count} ${label.toLowerCase()}`);
1079
1123
  return [
1080
1124
  t.id ? deterministicAlias(ALIAS_PREFIX.participant, t.id) : t.id,
@@ -1375,7 +1419,7 @@ export function formatIterationList(iterations, json) {
1375
1419
  return;
1376
1420
  }
1377
1421
  const aliasMap = getAliasMap(ALIAS_PREFIX.iteration);
1378
- printTable(["#", "LABEL", "NAME", "PARTICIPANTS", "CREATED"], iterations.map((it) => {
1422
+ printTable(["ALIAS", "LABEL", "NAME", "PARTICIPANTS", "CREATED"], iterations.map((it) => {
1379
1423
  const participants = Array.isArray(it.participants) ? it.participants.length : 0;
1380
1424
  return [
1381
1425
  aliasMap.get(String(it.id)) || String(it.id || ""),
@@ -1449,7 +1493,7 @@ export function formatPersonList(profiles, json, limit) {
1449
1493
  console.log("No participant profiles.");
1450
1494
  return;
1451
1495
  }
1452
- printTable(["#", "NAME", "OCCUPATION", "COUNTRY", "GENDER", "AGE"], list.map((p) => [
1496
+ printTable(["ALIAS", "NAME", "OCCUPATION", "COUNTRY", "GENDER", "AGE"], list.map((p) => [
1453
1497
  String(p.alias || p.id || ""),
1454
1498
  String(p.name || ""),
1455
1499
  String(p.occupation || "-"),
@@ -1504,7 +1548,7 @@ export function formatGeneratedProfileList(profiles, json) {
1504
1548
  console.log(jsonOutput(list));
1505
1549
  return;
1506
1550
  }
1507
- printTable(["#", "NAME", "OCCUPATION", "COUNTRY", "GENDER", "AGE"], list.map((p) => [
1551
+ printTable(["ALIAS", "NAME", "OCCUPATION", "COUNTRY", "GENDER", "AGE"], list.map((p) => [
1508
1552
  String(p.alias || p.id || ""),
1509
1553
  String(p.name || ""),
1510
1554
  String(p.occupation || "-"),
@@ -1529,7 +1573,7 @@ export function formatSimulationPoll(results, json, isMedia = false) {
1529
1573
  }
1530
1574
  const aliasMap = getAliasMap(ALIAS_PREFIX.participant);
1531
1575
  const countHeader = isMedia ? "SEGMENTS" : "INTERACTIONS";
1532
- printTable(["#", "PARTICIPANT", "STATUS", countHeader], results.map((r) => {
1576
+ printTable(["ALIAS", "PARTICIPANT", "STATUS", countHeader], results.map((r) => {
1533
1577
  const id = String(r.id || r.participant_id || "");
1534
1578
  return [
1535
1579
  aliasMap.get(id) || id,
@@ -1574,7 +1618,7 @@ export function formatAskList(asks, json) {
1574
1618
  return;
1575
1619
  }
1576
1620
  const aliasMap = getAliasMap(ALIAS_PREFIX.ask);
1577
- printTable(["#", "NAME", "STATUS", "PARTICIPANTS", "ROUNDS", "LAST ROUND", "ARCHIVED"], asks.map((a) => [
1621
+ printTable(["ALIAS", "NAME", "STATUS", "PARTICIPANTS", "ROUNDS", "LAST ROUND", "ARCHIVED"], asks.map((a) => [
1578
1622
  aliasMap.get(String(a.id)) || String(a.id || ""),
1579
1623
  String(a.name || ""),
1580
1624
  String(a.status || "-"),
@@ -1675,7 +1719,7 @@ export function formatAskDetail(ask, json) {
1675
1719
  String(obj.status || "-"),
1676
1720
  ];
1677
1721
  });
1678
- printTable(["#", "NAME", "STATUS"], rows);
1722
+ printTable(["ALIAS", "NAME", "STATUS"], rows);
1679
1723
  if (participants.length > 20)
1680
1724
  console.log(` … and ${participants.length - 20} more`);
1681
1725
  }
@@ -2145,7 +2189,7 @@ export function formatConfigList(configs, json) {
2145
2189
  return;
2146
2190
  }
2147
2191
  const aliasMap = getAliasMap(ALIAS_PREFIX.config);
2148
- printTable(["#", "NAME", "SOURCE", "CREATED"], configs.map((c) => [
2192
+ printTable(["ALIAS", "NAME", "SOURCE", "CREATED"], configs.map((c) => [
2149
2193
  aliasMap.get(String(c.id)) || String(c.id || ""),
2150
2194
  String(c.name || ""),
2151
2195
  String(c.source_type || "manual"),