@ishlabs/cli 0.8.4 → 0.8.5

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
@@ -55,7 +55,7 @@ Two top-level research primitives, both consume reusable tester profiles:
55
55
  Workspace (= product, top-level container)
56
56
 
57
57
  ├── Tester Profiles ← reusable audience personas
58
- │ └── Audience Sources (transcripts / audio / images that seed generation)
58
+ │ └── Audience Sources (images/PDFs/audio/video/text transcripts that seed generation)
59
59
 
60
60
  ├── Study ─────────────── "structured research artifact"
61
61
  │ ├── modality (interactive | text | video | audio | image | document)
package/dist/auth.d.ts CHANGED
@@ -21,6 +21,21 @@ export declare function login(appUrl?: string): Promise<{
21
21
  accessToken: string;
22
22
  refreshToken: string;
23
23
  }>;
24
+ /**
25
+ * Thrown by `refreshTokens` when the refresh attempt failed permanently and
26
+ * the local config can't recover (e.g. Supabase `refresh_token_not_found`,
27
+ * `invalid_grant`, or any 400-class response). Callers should treat this as
28
+ * "user must run `ish login` again" — retrying won't help.
29
+ *
30
+ * Transient failures (network errors, 5xx, timeouts) are NOT this error and
31
+ * may be worth retrying.
32
+ */
33
+ export declare class AuthRefreshPermanentError extends Error {
34
+ readonly httpStatus: number;
35
+ readonly errorCode: string | undefined;
36
+ readonly body: string;
37
+ constructor(httpStatus: number, body: string, errorCode: string | undefined);
38
+ }
24
39
  export declare function refreshTokens(refreshToken: string, options?: {
25
40
  /** The (possibly expired) access token. Used to pick the correct Supabase project. */
26
41
  accessToken?: string;
package/dist/auth.js CHANGED
@@ -139,6 +139,43 @@ export async function login(appUrl) {
139
139
  throw new Error("Login timed out. Please try again.");
140
140
  }
141
141
  // --- Token refresh ---
142
+ /**
143
+ * Thrown by `refreshTokens` when the refresh attempt failed permanently and
144
+ * the local config can't recover (e.g. Supabase `refresh_token_not_found`,
145
+ * `invalid_grant`, or any 400-class response). Callers should treat this as
146
+ * "user must run `ish login` again" — retrying won't help.
147
+ *
148
+ * Transient failures (network errors, 5xx, timeouts) are NOT this error and
149
+ * may be worth retrying.
150
+ */
151
+ export class AuthRefreshPermanentError extends Error {
152
+ httpStatus;
153
+ errorCode;
154
+ body;
155
+ constructor(httpStatus, body, errorCode) {
156
+ const detail = errorCode ? ` ${errorCode}` : "";
157
+ super(`Token refresh failed permanently (HTTP ${httpStatus}${detail}): ${body}`);
158
+ this.name = "AuthRefreshPermanentError";
159
+ this.httpStatus = httpStatus;
160
+ this.errorCode = errorCode;
161
+ this.body = body;
162
+ }
163
+ }
164
+ function parseRefreshErrorCode(body) {
165
+ try {
166
+ const parsed = JSON.parse(body);
167
+ if (parsed && typeof parsed === "object") {
168
+ if (typeof parsed.error_code === "string")
169
+ return parsed.error_code;
170
+ if (typeof parsed.error === "string")
171
+ return parsed.error;
172
+ }
173
+ }
174
+ catch {
175
+ // not JSON — fall through
176
+ }
177
+ return undefined;
178
+ }
142
179
  export async function refreshTokens(refreshToken, options) {
143
180
  const project = options?.supabaseUrl && options?.anonKey
144
181
  ? { url: options.supabaseUrl, anonKey: options.anonKey }
@@ -154,6 +191,9 @@ export async function refreshTokens(refreshToken, options) {
154
191
  });
155
192
  if (!resp.ok) {
156
193
  const body = await resp.text().catch(() => "");
194
+ if (resp.status >= 400 && resp.status < 500) {
195
+ throw new AuthRefreshPermanentError(resp.status, body, parseRefreshErrorCode(body));
196
+ }
157
197
  throw new Error(`Token refresh failed (HTTP ${resp.status}): ${body}`);
158
198
  }
159
199
  const data = await resp.json();
@@ -326,6 +326,10 @@ Minimal --questions JSON (server keys: "question" + "type"):
326
326
  { "question": "What stood out?", "type": "text" },
327
327
  { "question": "Rate it 1-5", "type": "slider" }
328
328
  ]
329
+
330
+ Picks come back with a \`pick_confidence\` (0..1) score per tester when
331
+ \`--wants-pick\` is set — read it off \`pick.confidence\` in the response. See
332
+ \`ish docs get-page concepts/ask\` for interpretation.
329
333
  `)
330
334
  .action(async (opts, cmd) => {
331
335
  await withClient(cmd, async (client, globals) => {
@@ -430,7 +434,14 @@ lookups.`)
430
434
  .argument("[id]", "Ask alias or UUID (defaults to active ask)")
431
435
  .option("--ask <id>", "Ask ID; alternative to positional argument")
432
436
  .option("--round <n>", "Show only round N (1-indexed; default: all rounds)")
433
- .addHelpText("after", "\nExamples:\n $ ish ask results a-6ec\n $ ish ask results a-6ec --round 1 --json")
437
+ .addHelpText("after", `
438
+ Examples:
439
+ $ ish ask results a-6ec
440
+ $ ish ask results a-6ec --round 1 --json
441
+
442
+ Each pick has a \`pick_confidence\` field (0..1, when --wants-pick was set) —
443
+ the model's self-reported confidence in its variant choice. See
444
+ \`ish docs get-page concepts/ask\` for how to use it for ranking ties.`)
434
445
  .action(async (id, opts, cmd) => {
435
446
  await withClient(cmd, async (client, globals) => {
436
447
  const aid = resolveAsk(pickAskRef(id, opts.ask));
@@ -539,13 +550,18 @@ lookups.`)
539
550
  .requiredOption("--round <n|round-id>", "Round number (1-indexed) or round id/alias")
540
551
  .requiredOption("--questions <file.json>", `Questions JSON file: [{"question":"...","type":"text"|"slider"|"likert"|"single-choice"|"multiple-choice"|"number"}]`)
541
552
  .option("--redispatch-all", "Clear prior phase-1 outputs (comment, pick, ratings) and re-run the entire round from scratch (legacy behavior). Default is additive — only the new questions are answered.", false)
553
+ .option("--wait", "Wait until the round completes (or errors)")
554
+ .option("--timeout <s>", "Wait timeout in seconds (default 300)")
542
555
  .addHelpText("after", `
543
556
  Examples:
544
557
  # Additive (default): preserves prior picks/ratings/comments.
545
558
  $ ish ask add-questions a-6ec --round 1 --questions ./qs.json
546
559
 
560
+ # Wait for the round to finish before returning.
561
+ $ ish ask add-questions a-6ec --round 1 --questions ./qs.json --wait
562
+
547
563
  # Legacy reset: re-runs the whole round; prior picks may shift.
548
- $ ish ask add-questions a-6ec --round 1 --questions ./qs.json --redispatch-all
564
+ $ ish ask add-questions a-6ec --round 1 --questions ./qs.json --redispatch-all --wait
549
565
 
550
566
  Minimal valid --questions JSON:
551
567
  [
@@ -566,6 +582,16 @@ text, slider, likert, single-choice, multiple-choice, number.`)
566
582
  ...(opts.redispatchAll && { redispatch_all: true }),
567
583
  };
568
584
  const updated = await client.post(`/asks/${aid}/rounds/${round.id}/questions`, body);
585
+ if (opts.wait) {
586
+ const timeoutMs = parseWaitTimeout(opts.timeout);
587
+ await pollUntilRoundDone(client, aid, updated.order_index, timeoutMs, !!globals.quiet);
588
+ const refreshed = await client.get(`/asks/${aid}`);
589
+ const target = refreshed.rounds.find((r) => r.id === updated.id);
590
+ if (target) {
591
+ formatRoundDetail(target, globals.json);
592
+ return;
593
+ }
594
+ }
569
595
  if (!globals.json || globals.verbose) {
570
596
  formatRoundDetail(updated, globals.json);
571
597
  return;
@@ -40,7 +40,12 @@ Examples:
40
40
  $ ish profile list
41
41
  $ ish profile list --search "engineer" --country US
42
42
  $ ish profile list --gender female --gender male --country US --country GB
43
- $ ish profile list --type all --json`)
43
+ $ ish profile list --type all --json
44
+
45
+ # Pagination — default --limit is 50; iterate with --offset:
46
+ $ ish profile list --limit 100
47
+ $ ish profile list --limit 100 --offset 100 # next page
48
+ # When more results exist, a stderr hint surfaces the next --offset / --limit.`)
44
49
  .action(async (opts, cmd) => {
45
50
  await withClient(cmd, async (client, globals) => {
46
51
  const params = {
@@ -63,18 +68,26 @@ Examples:
63
68
  const data = await client.get("/tester-profiles", params);
64
69
  formatTesterProfileList(data, globals.json, parseInt(opts.limit, 10));
65
70
  // Pattern H1: when there's more data, surface a stderr hint so agents
66
- // know `--limit / --offset` exist without re-reading help. Only when
67
- // not in JSON mode and not silenced JSON consumers read has_more
68
- // off the envelope themselves; quiet suppresses progress chatter.
69
- if (!globals.json && !globals.quiet && typeof data === "object" && data !== null) {
71
+ // know `--limit / --offset` exist without re-reading help. Stderr only,
72
+ // so it doesn't pollute machine-readable stdoutthat means we DON'T
73
+ // gate on globals.json (auto-flips on pipe and would silence the hint
74
+ // exactly when the agent needs it most). --quiet is the explicit
75
+ // opt-out for progress chatter.
76
+ //
77
+ // The raw `/tester-profiles` envelope is `{items, total, limit, offset}`
78
+ // — `has_more` and `returned` are synthesized client-side by `wrapList`
79
+ // only when the response is rendered. Compute them locally here so the
80
+ // hint actually fires; reading `data.has_more` directly would always
81
+ // see undefined and silently skip.
82
+ if (!globals.quietExplicit && typeof data === "object" && data !== null) {
70
83
  const d = data;
71
- if (d.has_more === true) {
72
- const total = typeof d.total === "number" ? d.total : null;
73
- const returned = typeof d.returned === "number" ? d.returned : null;
74
- const offset = typeof d.offset === "number" ? d.offset : 0;
75
- if (total !== null && returned !== null) {
76
- console.error(`\n showing ${offset + 1}–${offset + returned} of ${total}; pass --offset ${offset + returned} --limit ${returned} for more.`);
77
- }
84
+ const items = Array.isArray(d.items) ? d.items : [];
85
+ const returned = items.length;
86
+ const total = typeof d.total === "number" ? d.total : returned;
87
+ const offset = typeof d.offset === "number" ? d.offset : parseInt(opts.offset, 10) || 0;
88
+ const hasMore = total > offset + returned;
89
+ if (hasMore && returned > 0) {
90
+ console.error(`\n showing ${offset + 1}–${offset + returned} of ${total}; pass --offset ${offset + returned} --limit ${returned} for more.`);
78
91
  }
79
92
  }
80
93
  });
@@ -2,7 +2,7 @@
2
2
  * ish study — Manage studies.
3
3
  */
4
4
  import { readFileSync } from "node:fs";
5
- import { withClient, getWebUrl, terminalLink, resolveWorkspace } from "../lib/command-helpers.js";
5
+ import { withClient, getWebUrl, terminalLink, resolveWorkspace, confirmDestructive } from "../lib/command-helpers.js";
6
6
  import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
7
7
  import { loadConfig, saveConfig } from "../config.js";
8
8
  import { formatStudyList, formatStudyDetail, formatStudyResults, output, ValidationError } from "../lib/output.js";
@@ -152,6 +152,8 @@ Next: configure a run with \`ish iteration create --study <id>\`,
152
152
  // or --url is provided, so a single `study create` produces a study
153
153
  // that's immediately runnable. Without these flags the backend
154
154
  // creates zero iterations and the first `iteration create` becomes A.
155
+ // The backend requires a non-empty `name` on the inline iteration; we
156
+ // default to "A" to match the iteration-naming convention.
155
157
  let inlineIteration;
156
158
  if (opts.contentText !== undefined) {
157
159
  if (opts.modality && opts.modality !== "text") {
@@ -160,13 +162,14 @@ Next: configure a run with \`ish iteration create --study <id>\`,
160
162
  const text = opts.contentText.startsWith("@")
161
163
  ? readFileSync(opts.contentText.slice(1), "utf8")
162
164
  : opts.contentText;
163
- inlineIteration = { details: { type: "text", content_text: text } };
165
+ inlineIteration = { name: "A", details: { type: "text", content_text: text } };
164
166
  }
165
167
  else if (opts.url !== undefined) {
166
168
  if (opts.modality && opts.modality !== "interactive") {
167
169
  throw new Error(`--url is only valid with --modality interactive (got "${opts.modality}").`);
168
170
  }
169
171
  inlineIteration = {
172
+ name: "A",
170
173
  details: { type: "interactive", url: opts.url, platform: "browser" },
171
174
  };
172
175
  }
@@ -374,10 +377,15 @@ Examples:
374
377
  .description("Delete a study")
375
378
  .argument("<id>", "Study ID")
376
379
  .option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the study)")
377
- .addHelpText("after", "\nExamples:\n $ ish study delete <id>")
378
- .action(async (id, _opts, cmd) => {
380
+ .option("-y, --yes", "Skip confirmation prompt (required in --json or non-TTY contexts)")
381
+ .addHelpText("after", "\nExamples:\n $ ish study delete <id> # interactive prompts for confirmation\n $ ish study delete <id> --yes # non-interactive\n $ ish study delete <id> --json --yes")
382
+ .action(async (id, opts, cmd) => {
379
383
  await withClient(cmd, async (client, globals) => {
380
384
  const rid = resolveId(id);
385
+ await confirmDestructive(`Delete study ${tagAlias(ALIAS_PREFIX.study, rid)}? This cannot be undone.`, {
386
+ yes: opts.yes,
387
+ json: globals.json,
388
+ });
381
389
  await client.del(`/studies/${rid}`);
382
390
  output({ id: rid, alias: tagAlias(ALIAS_PREFIX.study, rid), message: "Study deleted" }, globals.json, { writePath: true });
383
391
  });
package/dist/connect.js CHANGED
@@ -5,7 +5,7 @@ import { spawn, execSync } from "node:child_process";
5
5
  import { existsSync, mkdirSync, chmodSync, writeFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { loadConfig, saveConfig } from "./config.js";
8
- import { refreshTokens, isTokenExpired, decodeJwtExp } from "./auth.js";
8
+ import { refreshTokens, isTokenExpired, decodeJwtExp, AuthRefreshPermanentError } from "./auth.js";
9
9
  import { binDir, cloudflaredBin, simulationsDir } from "./lib/paths.js";
10
10
  import { c } from "./lib/colors.js";
11
11
  const TUNNEL_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
@@ -402,7 +402,14 @@ async function registerTunnel(apiUrl, token, tunnelUrl, port) {
402
402
  return false;
403
403
  }
404
404
  }
405
- async function deregisterTunnel(apiUrl, token, json) {
405
+ /**
406
+ * Best-effort deregister. When `suppressAuthFailures` is true (we're already
407
+ * shutting down because of an auth-permanent failure), 401/403 errors during
408
+ * deregister are silently swallowed — logging "Failed to deregister: HTTP 401"
409
+ * right after telling the user their auth expired is ironic and noisy.
410
+ * Non-auth failures are still logged.
411
+ */
412
+ async function deregisterTunnel(apiUrl, token, json, suppressAuthFailures = false) {
406
413
  try {
407
414
  const resp = await fetch(`${apiUrl}${API_BASE}/connect`, {
408
415
  method: "DELETE",
@@ -419,9 +426,28 @@ async function deregisterTunnel(apiUrl, token, json) {
419
426
  }
420
427
  }
421
428
  catch (e) {
429
+ if (suppressAuthFailures && /HTTP (401|403)/.test(String(e)))
430
+ return;
422
431
  console.error(`Warning: Failed to deregister connection: ${e}`);
423
432
  }
424
433
  }
434
+ /**
435
+ * Classify whether a thrown error from the auth refresh path is permanent.
436
+ * Permanent = "the local config can't recover, user must `ish login` again."
437
+ * Examples: Supabase `refresh_token_not_found`, `invalid_grant`, any 4xx on
438
+ * the refresh endpoint. Network blips and 5xx are NOT permanent.
439
+ *
440
+ * The typed sentinel from `src/auth.ts` is the primary signal. We also string-
441
+ * match legacy error messages in case something throws a plain Error.
442
+ */
443
+ function isPermanentAuthFailure(err) {
444
+ if (err instanceof AuthRefreshPermanentError)
445
+ return true;
446
+ if (err instanceof Error) {
447
+ return /refresh_token_not_found|invalid_grant|Token refresh failed \(HTTP 4\d\d/i.test(err.message);
448
+ }
449
+ return false;
450
+ }
425
451
  function processHeartbeatResponse(resp, renderCards) {
426
452
  resp.json().then((data) => {
427
453
  const sims = data.simulations ?? [];
@@ -437,7 +463,7 @@ function processHeartbeatResponse(resp, renderCards) {
437
463
  // Non-fatal: response parsing failed, silently continue
438
464
  });
439
465
  }
440
- function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal, json) {
466
+ function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal, onAuthPermanent, json) {
441
467
  let consecutiveFailures = 0;
442
468
  let stopped = false;
443
469
  const interval = setInterval(async () => {
@@ -469,6 +495,15 @@ function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal,
469
495
  return;
470
496
  }
471
497
  catch (refreshErr) {
498
+ // Permanent refresh failure (refresh_token_not_found etc.) — no
499
+ // amount of retrying will fix this. Bail out immediately with an
500
+ // actionable message; don't waste the 3-strike countdown.
501
+ if (isPermanentAuthFailure(refreshErr)) {
502
+ stopped = true;
503
+ clearInterval(interval);
504
+ await onAuthPermanent(refreshErr);
505
+ return;
506
+ }
472
507
  console.error(`Token refresh failed: ${refreshErr}`);
473
508
  }
474
509
  }
@@ -499,7 +534,7 @@ function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal,
499
534
  * Schedule a proactive token refresh before the JWT expires.
500
535
  * Refreshes 10 minutes before expiry.
501
536
  */
502
- function scheduleProactiveRefresh(token, doRefresh, onTokenRefreshed, json) {
537
+ function scheduleProactiveRefresh(token, doRefresh, onTokenRefreshed, onAuthPermanent, json) {
503
538
  if (!doRefresh)
504
539
  return { stop: () => { } };
505
540
  const exp = decodeJwtExp(token);
@@ -516,9 +551,15 @@ function scheduleProactiveRefresh(token, doRefresh, onTokenRefreshed, json) {
516
551
  if (!json)
517
552
  console.error("Token proactively refreshed.");
518
553
  // Schedule next refresh for the new token
519
- scheduleProactiveRefresh(newToken, doRefresh, onTokenRefreshed, json);
554
+ scheduleProactiveRefresh(newToken, doRefresh, onTokenRefreshed, onAuthPermanent, json);
520
555
  }
521
556
  catch (e) {
557
+ // Permanent refresh failure: short-circuit to the actionable message
558
+ // path instead of letting the next heartbeat eat 3 strikes.
559
+ if (isPermanentAuthFailure(e)) {
560
+ await onAuthPermanent(e);
561
+ return;
562
+ }
522
563
  console.error(`Proactive token refresh failed: ${e}`);
523
564
  }
524
565
  }, delay);
@@ -572,20 +613,54 @@ export async function runTunnel(port, tokenArg, apiUrlArg, tokenFileArg, outputO
572
613
  console.log(`Tunnel URL: ${tunnelUrl} → http://localhost:${port}\n`);
573
614
  }
574
615
  let shuttingDown = false;
575
- const heartbeat = startHeartbeat(apiUrl, () => currentToken, serializedRefresh, onTokenRefreshed, async () => {
616
+ // Holders for forward references `onAuthPermanent` needs to stop these
617
+ // timers, but `startHeartbeat`/`scheduleProactiveRefresh` need
618
+ // `onAuthPermanent` to wire up. Declared as nullable, assigned just below.
619
+ let heartbeat = null;
620
+ let proactiveRefresh = null;
621
+ // Fast-fail path for non-recoverable auth failures (e.g. Supabase
622
+ // refresh_token_not_found). Skips the 3-strike heartbeat countdown and
623
+ // gives the user a single actionable next step.
624
+ const onAuthPermanent = async (cause) => {
625
+ if (shuttingDown)
626
+ return;
627
+ shuttingDown = true;
628
+ heartbeat?.stop();
629
+ proactiveRefresh?.stop();
630
+ if (json) {
631
+ console.error(JSON.stringify({
632
+ status: "auth_expired",
633
+ message: `Authentication expired. Run \`ish login\` and re-run \`ish connect ${port}\`.`,
634
+ detail: "refresh_token expired or revoked; the local config can't recover",
635
+ }));
636
+ }
637
+ else {
638
+ console.error(`\nAuthentication expired. Run \`ish login\` and re-run \`ish connect ${port}\`.`);
639
+ console.error("(refresh_token expired or revoked; the local config can't recover)");
640
+ }
641
+ if (cause instanceof Error && cause.message) {
642
+ // Keep the underlying detail discoverable for log scrapers / agents,
643
+ // without making it the headline.
644
+ console.error(`Cause: ${cause.message}`);
645
+ }
646
+ await deregisterTunnel(apiUrl, currentToken, json, true);
647
+ cfProcess.kill();
648
+ process.exit(3);
649
+ };
650
+ heartbeat = startHeartbeat(apiUrl, () => currentToken, serializedRefresh, onTokenRefreshed, async () => {
576
651
  await deregisterTunnel(apiUrl, currentToken, json);
577
652
  cfProcess.kill();
578
653
  process.exit(1);
579
- }, json);
580
- const proactiveRefresh = scheduleProactiveRefresh(currentToken, serializedRefresh, onTokenRefreshed, json);
654
+ }, onAuthPermanent, json);
655
+ proactiveRefresh = scheduleProactiveRefresh(currentToken, serializedRefresh, onTokenRefreshed, onAuthPermanent, json);
581
656
  const shutdown = async () => {
582
657
  if (shuttingDown)
583
658
  process.exit(1);
584
659
  shuttingDown = true;
585
660
  if (!json)
586
661
  console.error("\nShutting down...");
587
- heartbeat.stop();
588
- proactiveRefresh.stop();
662
+ heartbeat?.stop();
663
+ proactiveRefresh?.stop();
589
664
  cfProcess.kill();
590
665
  await deregisterTunnel(apiUrl, currentToken, json);
591
666
  process.exit(0);
@@ -597,8 +672,8 @@ export async function runTunnel(port, tokenArg, apiUrlArg, tokenFileArg, outputO
597
672
  }
598
673
  cfProcess.on("exit", async () => {
599
674
  if (!shuttingDown) {
600
- heartbeat.stop();
601
- proactiveRefresh.stop();
675
+ heartbeat?.stop();
676
+ proactiveRefresh?.stop();
602
677
  await deregisterTunnel(apiUrl, currentToken, json);
603
678
  process.exit(0);
604
679
  }
package/dist/index.js CHANGED
@@ -69,7 +69,10 @@ program
69
69
  .option("--token-file <path>", "Read auth token from a file (preferred over --token / ISH_TOKEN)")
70
70
  .option("--api-url <url>", "Backend API URL (default: ISH_API_URL or https://api.ishlabs.io)")
71
71
  .addOption(new Option("--dev", "Use local dev API (http://localhost:8000)").hideHelp())
72
+ .option("--workspace <id>", "Default workspace ID; per-subcommand --workspace overrides")
72
73
  .option("--json", "Output as JSON (auto-enabled when piped)")
74
+ .option("--get <field>", "Extract a single field from the JSON response and print only its value (implies --json internally; supports dotted paths e.g. tester_profile.name)")
75
+ .option("--human", "Force human-readable output even when stdout is piped (overrides JSON-when-piped auto-detection)")
73
76
  .option("--fields <fields>", "Comma-separated fields to include in JSON output (e.g. alias,name,status)")
74
77
  .option("--verbose", "Include full UUIDs and timestamps in JSON output")
75
78
  .option("--no-color", "Disable colored output (also honored: NO_COLOR env var)")
@@ -58,6 +58,33 @@ export interface GlobalOpts {
58
58
  quiet: boolean;
59
59
  color: boolean;
60
60
  fields?: string[];
61
+ /**
62
+ * --get <field>: capture mode. Extracts a single field from the JSON
63
+ * response and prints its bare value. Implies --json internally so the
64
+ * renderer always has structured data to extract from.
65
+ */
66
+ get?: string;
67
+ /**
68
+ * --human: forces the human renderer regardless of TTY/pipe state.
69
+ * Mutually exclusive with --get (capture vs display).
70
+ */
71
+ human?: boolean;
72
+ /**
73
+ * Program-level --workspace from the root flag. Subcommand-level --workspace
74
+ * still wins via Commander's optsWithGlobals merge (subcommand opts override
75
+ * parent), so this is effectively the "default workspace for the invocation"
76
+ * agents reflexively pass at the program root.
77
+ */
78
+ workspace?: string;
79
+ /**
80
+ * True only when the user explicitly passed `--quiet` / `-q` (or `--get`).
81
+ * Distinct from `quiet`, which also flips on for auto-quiet (the
82
+ * piped-stdout/auto-JSON path). Use this for actionable hints (e.g. the
83
+ * `profile list` pagination hint) that should still surface when an agent
84
+ * pipes output but hasn't asked for silence — auto-quiet is for progress
85
+ * chatter, not diagnostic signals.
86
+ */
87
+ quietExplicit: boolean;
61
88
  }
62
89
  export declare function getGlobals(cmd: Command): GlobalOpts;
63
90
  /**
@@ -83,6 +110,16 @@ export declare function runInline(cmd: Command, fn: (globals: GlobalOpts) => Pro
83
110
  export declare function getWebUrl(globals: GlobalOpts, path: string): string;
84
111
  export declare function terminalLink(url: string, text: string): string;
85
112
  export declare function readJsonFileOrStdin(filePath?: string): Promise<unknown>;
113
+ /**
114
+ * Prompt for confirmation of a destructive action, or short-circuit when
115
+ * `--yes` is set. In `--json` mode without `--yes` we refuse with a usage
116
+ * error rather than silently proceeding or hanging on a prompt — agents
117
+ * piping through CLI must be explicit about destructive intent.
118
+ */
119
+ export declare function confirmDestructive(prompt: string, opts: {
120
+ yes?: boolean;
121
+ json?: boolean;
122
+ }): Promise<void>;
86
123
  export declare function resolveWorkspace(explicit?: string): string;
87
124
  export declare function resolveStudy(explicit?: string): string;
88
125
  export declare function resolveAsk(explicit?: string): string;