@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.
@@ -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
  });
@@ -7,8 +7,8 @@
7
7
  * generation runs, or to inspect processing status.
8
8
  */
9
9
  import { withClient, resolveWorkspace } from "../lib/command-helpers.js";
10
- import { resolveId } from "../lib/alias-store.js";
11
- import { formatAudienceSource } from "../lib/output.js";
10
+ import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
11
+ import { formatAudienceSource, output } from "../lib/output.js";
12
12
  import { inferSourceKind, uploadAndProcessSource, } from "../lib/profile-sources.js";
13
13
  const VALID_KINDS = ["text_file", "audio", "image"];
14
14
  export function registerSourceCommands(program) {
@@ -75,4 +75,26 @@ Examples:
75
75
  formatAudienceSource(src, globals.json);
76
76
  });
77
77
  });
78
+ source
79
+ .command("delete")
80
+ .description("Delete an audience source plus its uploaded file")
81
+ .argument("<id>", "Source ID or alias")
82
+ .addHelpText("after", `
83
+ Removes both the database row and the underlying uploaded file. Profiles
84
+ already generated from this source remain in place — they don't reference
85
+ the source after generation.
86
+
87
+ Examples:
88
+ $ ish source delete tps-3a4`)
89
+ .action(async (id, _opts, cmd) => {
90
+ await withClient(cmd, async (client, globals) => {
91
+ const rid = resolveId(id);
92
+ await client.del(`/tester-profiles/sources/${rid}`);
93
+ output({
94
+ id: rid,
95
+ alias: tagAlias(ALIAS_PREFIX.testerProfileSource, rid),
96
+ message: "Source deleted",
97
+ }, globals.json, { writePath: true });
98
+ });
99
+ });
78
100
  }
@@ -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";
@@ -81,7 +81,7 @@ Concept pages: ish docs get-page concepts/study
81
81
  .option("--workspace <id>", "Workspace ID")
82
82
  .requiredOption("--name <name>", "Study name")
83
83
  .option("--description <description>", "Study description")
84
- .option("--modality <modality>", "Study modality (interactive, video, audio, text, image, document)")
84
+ .option("--modality <modality>", "Study modality (interactive, video, audio, text, image, document, chat)")
85
85
  .option("--content-type <type>", "Content type (varies by modality — see examples below)")
86
86
  .option("--assignment <name:instructions>", "Assignment as 'Name:Instructions' (repeatable)", collectRepeatable, [])
87
87
  .option("--assignments-file <path>", "JSON file with assignments array")
@@ -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
  }
@@ -314,7 +317,7 @@ When no runs have completed, the same envelope is returned with zero counts and
314
317
  .option("--name <name>", "Study name")
315
318
  .option("--description <description>", "Study description")
316
319
  .option("--status <status>", "Study status (draft, running, completed)")
317
- .option("--modality <modality>", "Study modality (interactive, video, audio, text, image, document)")
320
+ .option("--modality <modality>", "Study modality (interactive, video, audio, text, image, document, chat)")
318
321
  .option("--content-type <type>", "Content type")
319
322
  .option("--assignment <name:instructions>", "Replace all assignments with these (repeatable)", collectRepeatable, [])
320
323
  .option("--assignments-file <path>", "JSON file with assignments array")
@@ -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/config.d.ts CHANGED
@@ -5,6 +5,10 @@
5
5
  export interface IshConfig {
6
6
  access_token?: string;
7
7
  refresh_token?: string;
8
+ /** OAuth client_id minted by Supabase DCR at last login. Required to refresh
9
+ * tokens issued by Supabase OAuth Server (public client → must include
10
+ * client_id on refresh). Absent for legacy/non-OAuth tokens. */
11
+ oauth_client_id?: string;
8
12
  token?: string;
9
13
  workspace?: string;
10
14
  study?: string;
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/;
@@ -233,8 +233,14 @@ async function resolveToken(tokenArg, apiUrl, tokenFileArg) {
233
233
  let accessToken = config.access_token;
234
234
  // Refresh if expired or close to expiry
235
235
  if (isTokenExpired(accessToken)) {
236
+ if (!config.oauth_client_id) {
237
+ throw new Error('Saved tokens are missing oauth_client_id. Run "ish login" to re-authenticate.');
238
+ }
236
239
  try {
237
- const tokens = await refreshTokens(config.refresh_token);
240
+ const tokens = await refreshTokens(config.refresh_token, {
241
+ accessToken,
242
+ clientId: config.oauth_client_id,
243
+ });
238
244
  accessToken = tokens.accessToken;
239
245
  config.access_token = tokens.accessToken;
240
246
  config.refresh_token = tokens.refreshToken;
@@ -250,7 +256,12 @@ async function resolveToken(tokenArg, apiUrl, tokenFileArg) {
250
256
  const cfg = loadConfig();
251
257
  if (!cfg.refresh_token)
252
258
  throw new Error("No refresh token");
253
- const tokens = await refreshTokens(cfg.refresh_token);
259
+ if (!cfg.oauth_client_id)
260
+ throw new Error('Missing oauth_client_id; run "ish login" again.');
261
+ const tokens = await refreshTokens(cfg.refresh_token, {
262
+ accessToken: cfg.access_token,
263
+ clientId: cfg.oauth_client_id,
264
+ });
254
265
  cfg.access_token = tokens.accessToken;
255
266
  cfg.refresh_token = tokens.refreshToken;
256
267
  saveConfig(cfg);
@@ -402,7 +413,14 @@ async function registerTunnel(apiUrl, token, tunnelUrl, port) {
402
413
  return false;
403
414
  }
404
415
  }
405
- async function deregisterTunnel(apiUrl, token, json) {
416
+ /**
417
+ * Best-effort deregister. When `suppressAuthFailures` is true (we're already
418
+ * shutting down because of an auth-permanent failure), 401/403 errors during
419
+ * deregister are silently swallowed — logging "Failed to deregister: HTTP 401"
420
+ * right after telling the user their auth expired is ironic and noisy.
421
+ * Non-auth failures are still logged.
422
+ */
423
+ async function deregisterTunnel(apiUrl, token, json, suppressAuthFailures = false) {
406
424
  try {
407
425
  const resp = await fetch(`${apiUrl}${API_BASE}/connect`, {
408
426
  method: "DELETE",
@@ -419,9 +437,28 @@ async function deregisterTunnel(apiUrl, token, json) {
419
437
  }
420
438
  }
421
439
  catch (e) {
440
+ if (suppressAuthFailures && /HTTP (401|403)/.test(String(e)))
441
+ return;
422
442
  console.error(`Warning: Failed to deregister connection: ${e}`);
423
443
  }
424
444
  }
445
+ /**
446
+ * Classify whether a thrown error from the auth refresh path is permanent.
447
+ * Permanent = "the local config can't recover, user must `ish login` again."
448
+ * Examples: Supabase `refresh_token_not_found`, `invalid_grant`, any 4xx on
449
+ * the refresh endpoint. Network blips and 5xx are NOT permanent.
450
+ *
451
+ * The typed sentinel from `src/auth.ts` is the primary signal. We also string-
452
+ * match legacy error messages in case something throws a plain Error.
453
+ */
454
+ function isPermanentAuthFailure(err) {
455
+ if (err instanceof AuthRefreshPermanentError)
456
+ return true;
457
+ if (err instanceof Error) {
458
+ return /refresh_token_not_found|invalid_grant|Token refresh failed \(HTTP 4\d\d/i.test(err.message);
459
+ }
460
+ return false;
461
+ }
425
462
  function processHeartbeatResponse(resp, renderCards) {
426
463
  resp.json().then((data) => {
427
464
  const sims = data.simulations ?? [];
@@ -437,7 +474,7 @@ function processHeartbeatResponse(resp, renderCards) {
437
474
  // Non-fatal: response parsing failed, silently continue
438
475
  });
439
476
  }
440
- function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal, json) {
477
+ function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal, onAuthPermanent, json) {
441
478
  let consecutiveFailures = 0;
442
479
  let stopped = false;
443
480
  const interval = setInterval(async () => {
@@ -469,6 +506,15 @@ function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal,
469
506
  return;
470
507
  }
471
508
  catch (refreshErr) {
509
+ // Permanent refresh failure (refresh_token_not_found etc.) — no
510
+ // amount of retrying will fix this. Bail out immediately with an
511
+ // actionable message; don't waste the 3-strike countdown.
512
+ if (isPermanentAuthFailure(refreshErr)) {
513
+ stopped = true;
514
+ clearInterval(interval);
515
+ await onAuthPermanent(refreshErr);
516
+ return;
517
+ }
472
518
  console.error(`Token refresh failed: ${refreshErr}`);
473
519
  }
474
520
  }
@@ -499,7 +545,7 @@ function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal,
499
545
  * Schedule a proactive token refresh before the JWT expires.
500
546
  * Refreshes 10 minutes before expiry.
501
547
  */
502
- function scheduleProactiveRefresh(token, doRefresh, onTokenRefreshed, json) {
548
+ function scheduleProactiveRefresh(token, doRefresh, onTokenRefreshed, onAuthPermanent, json) {
503
549
  if (!doRefresh)
504
550
  return { stop: () => { } };
505
551
  const exp = decodeJwtExp(token);
@@ -516,9 +562,15 @@ function scheduleProactiveRefresh(token, doRefresh, onTokenRefreshed, json) {
516
562
  if (!json)
517
563
  console.error("Token proactively refreshed.");
518
564
  // Schedule next refresh for the new token
519
- scheduleProactiveRefresh(newToken, doRefresh, onTokenRefreshed, json);
565
+ scheduleProactiveRefresh(newToken, doRefresh, onTokenRefreshed, onAuthPermanent, json);
520
566
  }
521
567
  catch (e) {
568
+ // Permanent refresh failure: short-circuit to the actionable message
569
+ // path instead of letting the next heartbeat eat 3 strikes.
570
+ if (isPermanentAuthFailure(e)) {
571
+ await onAuthPermanent(e);
572
+ return;
573
+ }
522
574
  console.error(`Proactive token refresh failed: ${e}`);
523
575
  }
524
576
  }, delay);
@@ -572,20 +624,54 @@ export async function runTunnel(port, tokenArg, apiUrlArg, tokenFileArg, outputO
572
624
  console.log(`Tunnel URL: ${tunnelUrl} → http://localhost:${port}\n`);
573
625
  }
574
626
  let shuttingDown = false;
575
- const heartbeat = startHeartbeat(apiUrl, () => currentToken, serializedRefresh, onTokenRefreshed, async () => {
627
+ // Holders for forward references `onAuthPermanent` needs to stop these
628
+ // timers, but `startHeartbeat`/`scheduleProactiveRefresh` need
629
+ // `onAuthPermanent` to wire up. Declared as nullable, assigned just below.
630
+ let heartbeat = null;
631
+ let proactiveRefresh = null;
632
+ // Fast-fail path for non-recoverable auth failures (e.g. Supabase
633
+ // refresh_token_not_found). Skips the 3-strike heartbeat countdown and
634
+ // gives the user a single actionable next step.
635
+ const onAuthPermanent = async (cause) => {
636
+ if (shuttingDown)
637
+ return;
638
+ shuttingDown = true;
639
+ heartbeat?.stop();
640
+ proactiveRefresh?.stop();
641
+ if (json) {
642
+ console.error(JSON.stringify({
643
+ status: "auth_expired",
644
+ message: `Authentication expired. Run \`ish login\` and re-run \`ish connect ${port}\`.`,
645
+ detail: "refresh_token expired or revoked; the local config can't recover",
646
+ }));
647
+ }
648
+ else {
649
+ console.error(`\nAuthentication expired. Run \`ish login\` and re-run \`ish connect ${port}\`.`);
650
+ console.error("(refresh_token expired or revoked; the local config can't recover)");
651
+ }
652
+ if (cause instanceof Error && cause.message) {
653
+ // Keep the underlying detail discoverable for log scrapers / agents,
654
+ // without making it the headline.
655
+ console.error(`Cause: ${cause.message}`);
656
+ }
657
+ await deregisterTunnel(apiUrl, currentToken, json, true);
658
+ cfProcess.kill();
659
+ process.exit(3);
660
+ };
661
+ heartbeat = startHeartbeat(apiUrl, () => currentToken, serializedRefresh, onTokenRefreshed, async () => {
576
662
  await deregisterTunnel(apiUrl, currentToken, json);
577
663
  cfProcess.kill();
578
664
  process.exit(1);
579
- }, json);
580
- const proactiveRefresh = scheduleProactiveRefresh(currentToken, serializedRefresh, onTokenRefreshed, json);
665
+ }, onAuthPermanent, json);
666
+ proactiveRefresh = scheduleProactiveRefresh(currentToken, serializedRefresh, onTokenRefreshed, onAuthPermanent, json);
581
667
  const shutdown = async () => {
582
668
  if (shuttingDown)
583
669
  process.exit(1);
584
670
  shuttingDown = true;
585
671
  if (!json)
586
672
  console.error("\nShutting down...");
587
- heartbeat.stop();
588
- proactiveRefresh.stop();
673
+ heartbeat?.stop();
674
+ proactiveRefresh?.stop();
589
675
  cfProcess.kill();
590
676
  await deregisterTunnel(apiUrl, currentToken, json);
591
677
  process.exit(0);
@@ -597,8 +683,8 @@ export async function runTunnel(port, tokenArg, apiUrlArg, tokenFileArg, outputO
597
683
  }
598
684
  cfProcess.on("exit", async () => {
599
685
  if (!shuttingDown) {
600
- heartbeat.stop();
601
- proactiveRefresh.stop();
686
+ heartbeat?.stop();
687
+ proactiveRefresh?.stop();
602
688
  await deregisterTunnel(apiUrl, currentToken, json);
603
689
  process.exit(0);
604
690
  }
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { program, Option } from "commander";
3
3
  import { runTunnel } from "./connect.js";
4
- import { login, getAppUrl, decodeJwtClaims } from "./auth.js";
4
+ import { login, decodeJwtClaims } from "./auth.js";
5
5
  import { loadConfig, saveConfig } from "./config.js";
6
6
  import { upgrade } from "./upgrade.js";
7
7
  import { registerWorkspaceCommands } from "./commands/workspace.js";
@@ -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)")
@@ -80,11 +83,11 @@ program
80
83
  .description("Authenticate with Ish via your browser")
81
84
  .action(async (_opts, cmd) => {
82
85
  await runInline(cmd, async (globals) => {
83
- const appUrl = globals.dev ? "http://localhost:3000" : getAppUrl();
84
- const tokens = await login(appUrl);
86
+ const tokens = await login();
85
87
  const config = loadConfig();
86
88
  config.access_token = tokens.accessToken;
87
89
  config.refresh_token = tokens.refreshToken;
90
+ config.oauth_client_id = tokens.clientId;
88
91
  saveConfig(config);
89
92
  output({ message: "Login successful" }, globals.json);
90
93
  });
package/dist/lib/auth.js CHANGED
@@ -57,8 +57,14 @@ export async function resolveToken(tokenArg, apiUrl, tokenFileArg) {
57
57
  let accessToken = config.access_token;
58
58
  // Refresh if expired or close to expiry
59
59
  if (isTokenExpired(accessToken)) {
60
+ if (!config.oauth_client_id) {
61
+ throw new Error('Saved tokens are missing oauth_client_id. Run "ish login" to re-authenticate.');
62
+ }
60
63
  try {
61
- const tokens = await refreshTokens(config.refresh_token, { accessToken });
64
+ const tokens = await refreshTokens(config.refresh_token, {
65
+ accessToken,
66
+ clientId: config.oauth_client_id,
67
+ });
62
68
  accessToken = tokens.accessToken;
63
69
  config.access_token = tokens.accessToken;
64
70
  config.refresh_token = tokens.refreshToken;
@@ -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;