@ishlabs/cli 0.8.3 → 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.
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Default action: `ish study tester <id>` shows tester details and results.
6
6
  */
7
- import { withClient, readJsonFileOrStdin } from "../lib/command-helpers.js";
7
+ import { withClient, readJsonFileOrStdin, resolveWorkspace } from "../lib/command-helpers.js";
8
8
  import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
9
9
  import { formatTesterDetail, output } from "../lib/output.js";
10
10
  /** Pick the latest iteration on a study (highest order_index, falling back to last). */
@@ -52,12 +52,15 @@ export function attachStudyTesterCommands(study) {
52
52
  .command("tester")
53
53
  .description("Inspect or manage testers (low-level; usually created via `study run`)")
54
54
  .argument("[id]", "Tester ID — pass directly to view tester details and results")
55
+ .option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the tester)")
55
56
  .addHelpText("after", "\nExamples:\n $ ish study tester t-d4e # show tester details\n $ ish study tester create --iteration <id> --profile <id>\n $ ish study tester batch-create --iteration <id> --file testers.json\n $ ish study tester delete t-d4e")
56
- .action(async (id, _opts, cmd) => {
57
+ .action(async (id, opts, cmd) => {
57
58
  if (!id) {
58
59
  cmd.help();
59
60
  }
60
61
  await withClient(cmd, async (client, globals) => {
62
+ if (opts.workspace)
63
+ resolveWorkspace(opts.workspace);
61
64
  const data = await client.get(`/testers/${resolveId(id)}`);
62
65
  const result = data;
63
66
  if (result.id)
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * ish study — Manage studies.
3
3
  */
4
- import { withClient, getWebUrl, terminalLink, resolveWorkspace } from "../lib/command-helpers.js";
4
+ import { readFileSync } from "node:fs";
5
+ import { withClient, getWebUrl, terminalLink, resolveWorkspace, confirmDestructive } from "../lib/command-helpers.js";
5
6
  import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
6
7
  import { loadConfig, saveConfig } from "../config.js";
7
8
  import { formatStudyList, formatStudyDetail, formatStudyResults, output, ValidationError } from "../lib/output.js";
@@ -76,7 +77,7 @@ Concept pages: ish docs get-page concepts/study
76
77
  });
77
78
  study
78
79
  .command("create")
79
- .description("Create a new study (the persistent shape: modality, tasks, questionnaire)")
80
+ .description("Create a new study (the persistent shape: modality, tasks, questionnaire). Optionally creates iteration A inline when --content-text or --url is passed.")
80
81
  .option("--workspace <id>", "Workspace ID")
81
82
  .requiredOption("--name <name>", "Study name")
82
83
  .option("--description <description>", "Study description")
@@ -87,13 +88,15 @@ Concept pages: ish docs get-page concepts/study
87
88
  .option("--assignments <json>", "Inline JSON array of assignments (escape hatch)")
88
89
  .option("--question <text>", "Add a text question to the questionnaire (repeatable; type=text, timing=after)", collectRepeatable, [])
89
90
  .option("--questionnaire <path>", "JSON file defining the questionnaire (supports text, slider, likert, single-choice, multiple-choice, number; timing=before|after)")
91
+ .option("--content-text <text>", "Text content to evaluate, or @filepath to read from file. Creates iteration A inline (text modality only)")
92
+ .option("--url <url>", "URL to test. Creates iteration A inline (interactive modality only)")
90
93
  .addHelpText("after", `
91
94
  Note: --workspace is optional if set via \`ish workspace use <alias>\`.
92
95
 
93
96
  The questionnaire is the set of questions testers answer. Use \`--question\` to
94
97
  quickly add simple text questions, or \`--questionnaire <file.json>\` for richer
95
- types (slider, likert, choice) and custom timing. The two forms are mutually
96
- exclusive — pick one.
98
+ types (slider, likert, single-choice, multiple-choice, number) and custom
99
+ timing. The two forms are mutually exclusive — pick one.
97
100
 
98
101
  Examples:
99
102
  # Interactive study with one assignment and a single-question questionnaire:
@@ -145,6 +148,31 @@ Next: configure a run with \`ish iteration create --study <id>\`,
145
148
  throw new ValidationError(`Invalid content type "${opts.contentType}" for modality "${opts.modality}".`, validTypes);
146
149
  }
147
150
  }
151
+ // Pattern E (cli half): build an inline iteration A when --content-text
152
+ // or --url is provided, so a single `study create` produces a study
153
+ // that's immediately runnable. Without these flags the backend
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.
157
+ let inlineIteration;
158
+ if (opts.contentText !== undefined) {
159
+ if (opts.modality && opts.modality !== "text") {
160
+ throw new Error(`--content-text is only valid with --modality text (got "${opts.modality}").`);
161
+ }
162
+ const text = opts.contentText.startsWith("@")
163
+ ? readFileSync(opts.contentText.slice(1), "utf8")
164
+ : opts.contentText;
165
+ inlineIteration = { name: "A", details: { type: "text", content_text: text } };
166
+ }
167
+ else if (opts.url !== undefined) {
168
+ if (opts.modality && opts.modality !== "interactive") {
169
+ throw new Error(`--url is only valid with --modality interactive (got "${opts.modality}").`);
170
+ }
171
+ inlineIteration = {
172
+ name: "A",
173
+ details: { type: "interactive", url: opts.url, platform: "browser" },
174
+ };
175
+ }
148
176
  const resolvedWs = resolveWorkspace(opts.workspace);
149
177
  const body = {
150
178
  product_id: resolvedWs,
@@ -154,6 +182,7 @@ Next: configure a run with \`ish iteration create --study <id>\`,
154
182
  ...(opts.contentType !== undefined && { content_type: opts.contentType }),
155
183
  ...(assignments && { assignments }),
156
184
  ...(interviewQuestions && { interview_questions: interviewQuestions }),
185
+ ...(inlineIteration && { iteration: inlineIteration }),
157
186
  };
158
187
  const data = await client.post(`/products/${resolvedWs}/studies`, body);
159
188
  if (data.id) {
@@ -198,20 +227,47 @@ Next: configure a run with \`ish iteration create --study <id>\`,
198
227
  });
199
228
  study
200
229
  .command("get")
201
- .description("Get study overview (assignments, questions, testers)")
202
- .argument("<id>", "Study ID")
203
- .addHelpText("after", "\nExamples:\n $ ish study get <id>\n $ ish study get <id> --json")
204
- .action(async (id, _opts, cmd) => {
230
+ .description("Get study overview (accepts multiple IDs for batched lookup)")
231
+ .argument("<ids...>", "Study ID(s) — one or more aliases/UUIDs (space- or comma-separated)")
232
+ .addHelpText("after", `
233
+ Examples:
234
+ $ ish study get s-b2c
235
+ $ ish study get s-b2c --json
236
+ $ ish study get s-b2c s-d4e s-f0a
237
+ $ ish study get s-b2c,s-d4e --fields alias,name,modality,status
238
+
239
+ With multiple IDs, returns a {items:[...], total:N} envelope and uses the
240
+ list table layout in human mode.`)
241
+ .action(async (ids, _opts, cmd) => {
205
242
  await withClient(cmd, async (client, globals) => {
206
- const rid = resolveId(id);
207
- const data = await client.get(`/studies/${rid}`);
208
- const result = data;
209
- if (result.id)
210
- result.alias = tagAlias(ALIAS_PREFIX.study, String(result.id));
211
- formatStudyDetail(result, globals.json);
212
- if (!globals.json && data.product_id) {
213
- const url = getWebUrl(globals, `/${data.product_id}/${rid}/overview`);
214
- console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
243
+ const flat = ids.flatMap((s) => s.split(",").map((x) => x.trim()).filter(Boolean));
244
+ if (flat.length === 0)
245
+ throw new Error("Provide at least one study id.");
246
+ if (flat.length === 1) {
247
+ const rid = resolveId(flat[0]);
248
+ const data = await client.get(`/studies/${rid}`);
249
+ const result = data;
250
+ if (result.id)
251
+ result.alias = tagAlias(ALIAS_PREFIX.study, String(result.id));
252
+ formatStudyDetail(result, globals.json);
253
+ if (!globals.json && data.product_id) {
254
+ const url = getWebUrl(globals, `/${data.product_id}/${rid}/overview`);
255
+ console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
256
+ }
257
+ return;
258
+ }
259
+ const results = await Promise.all(flat.map(async (raw) => {
260
+ const data = await client.get(`/studies/${resolveId(raw)}`);
261
+ const r = data;
262
+ if (r.id)
263
+ r.alias = tagAlias(ALIAS_PREFIX.study, String(r.id));
264
+ return r;
265
+ }));
266
+ if (globals.json) {
267
+ output({ items: results, total: results.length }, true);
268
+ }
269
+ else {
270
+ formatStudyList(results, false);
215
271
  }
216
272
  });
217
273
  });
@@ -219,6 +275,7 @@ Next: configure a run with \`ish iteration create --study <id>\`,
219
275
  .command("results")
220
276
  .description("View aggregated results: tester counts, sentiment, interview answers. Returns a stable envelope with empty fields when no runs have completed.")
221
277
  .argument("<id>", "Study ID")
278
+ .option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the study)")
222
279
  .addHelpText("after", `
223
280
  Examples:
224
281
  $ ish study results <id>
@@ -319,10 +376,16 @@ Examples:
319
376
  .command("delete")
320
377
  .description("Delete a study")
321
378
  .argument("<id>", "Study ID")
322
- .addHelpText("after", "\nExamples:\n $ ish study delete <id>")
323
- .action(async (id, _opts, cmd) => {
379
+ .option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the study)")
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) => {
324
383
  await withClient(cmd, async (client, globals) => {
325
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
+ });
326
389
  await client.del(`/studies/${rid}`);
327
390
  output({ id: rid, alias: tagAlias(ALIAS_PREFIX.study, rid), message: "Study deleted" }, globals.json, { writePath: true });
328
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/;
@@ -348,7 +348,7 @@ function manualInstallInstructions() {
348
348
  function startCloudflared(port, binPath, json) {
349
349
  return new Promise((resolve, reject) => {
350
350
  if (!json)
351
- console.log(`Connecting to localhost:${port}...`);
351
+ console.error(`Connecting to localhost:${port}...`);
352
352
  const proc = spawn(binPath, ["tunnel", "--url", `http://localhost:${port}`], {
353
353
  stdio: ["ignore", "pipe", "pipe"],
354
354
  });
@@ -368,7 +368,7 @@ function startCloudflared(port, binPath, json) {
368
368
  resolve({ process: proc, tunnelUrl });
369
369
  }
370
370
  });
371
- proc.on("exit", (code) => {
371
+ proc.on("exit", () => {
372
372
  clearTimeout(timeout);
373
373
  if (!tunnelUrl) {
374
374
  reject(new Error("cloudflared exited unexpectedly."));
@@ -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",
@@ -415,13 +422,32 @@ async function deregisterTunnel(apiUrl, token, json) {
415
422
  console.log(JSON.stringify({ status: "disconnected" }));
416
423
  }
417
424
  else {
418
- console.log("Disconnected");
425
+ console.error("Disconnected");
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 () => {
@@ -455,7 +481,7 @@ function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal,
455
481
  const newToken = await doRefresh();
456
482
  onTokenRefreshed(newToken);
457
483
  if (!json)
458
- console.log("Token refreshed.");
484
+ console.error("Token refreshed.");
459
485
  // Retry heartbeat with new token
460
486
  const retry = await fetch(`${apiUrl}${API_BASE}/connect/heartbeat`, {
461
487
  method: "POST",
@@ -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);
@@ -514,11 +549,17 @@ function scheduleProactiveRefresh(token, doRefresh, onTokenRefreshed, json) {
514
549
  const newToken = await doRefresh();
515
550
  onTokenRefreshed(newToken);
516
551
  if (!json)
517
- console.log("Token proactively refreshed.");
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
- console.log("\nShutting down...");
587
- heartbeat.stop();
588
- proactiveRefresh.stop();
661
+ console.error("\nShutting down...");
662
+ heartbeat?.stop();
663
+ proactiveRefresh?.stop();
589
664
  cfProcess.kill();
590
665
  await deregisterTunnel(apiUrl, currentToken, json);
591
666
  process.exit(0);
@@ -593,12 +668,12 @@ export async function runTunnel(port, tokenArg, apiUrlArg, tokenFileArg, outputO
593
668
  process.on("SIGINT", shutdown);
594
669
  process.on("SIGTERM", shutdown);
595
670
  if (!json && !quiet) {
596
- console.log("Press Ctrl+C to disconnect.\n");
671
+ console.error("Press Ctrl+C to disconnect.\n");
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
@@ -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 } from "./auth.js";
4
+ import { login, getAppUrl, 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";
@@ -14,8 +14,12 @@ import { registerAskCommands } from "./commands/ask.js";
14
14
  import { registerDocsCommands } from "./commands/docs.js";
15
15
  import { registerInitCommands } from "./commands/init.js";
16
16
  import { AGENT_HELP_FOOTER } from "./lib/docs.js";
17
- import { runInline, EXIT_USAGE } from "./lib/command-helpers.js";
17
+ import { runInline, EXIT_USAGE, injectGlobalWorkspaceOption } from "./lib/command-helpers.js";
18
+ import { resolveApiUrl, resolveToken } from "./lib/auth.js";
19
+ import { ApiClient } from "./lib/api-client.js";
20
+ import { tagAlias, ALIAS_PREFIX } from "./lib/alias-store.js";
18
21
  import { output } from "./lib/output.js";
22
+ import { ishDir } from "./lib/paths.js";
19
23
  import pkg from "../package.json" with { type: "json" };
20
24
  const { version } = pkg;
21
25
  program
@@ -65,7 +69,10 @@ program
65
69
  .option("--token-file <path>", "Read auth token from a file (preferred over --token / ISH_TOKEN)")
66
70
  .option("--api-url <url>", "Backend API URL (default: ISH_API_URL or https://api.ishlabs.io)")
67
71
  .addOption(new Option("--dev", "Use local dev API (http://localhost:8000)").hideHelp())
72
+ .option("--workspace <id>", "Default workspace ID; per-subcommand --workspace overrides")
68
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)")
69
76
  .option("--fields <fields>", "Comma-separated fields to include in JSON output (e.g. alias,name,status)")
70
77
  .option("--verbose", "Include full UUIDs and timestamps in JSON output")
71
78
  .option("--no-color", "Disable colored output (also honored: NO_COLOR env var)")
@@ -98,6 +105,118 @@ program
98
105
  output({ message: "Logged out" }, globals.json);
99
106
  });
100
107
  });
108
+ program
109
+ .command("status")
110
+ .alias("whoami")
111
+ .description("Show active session — user, workspace, study, ask")
112
+ .addHelpText("after", "\nFirst command to run when starting cold. Outputs the active workspace,\nstudy, and ask handles plus token validity. Doesn't error on no-token —\nreturns user: null with a hint instead. JSON safe for piping.")
113
+ .action(async (_opts, cmd) => {
114
+ await runInline(cmd, async (globals) => {
115
+ const apiUrl = resolveApiUrl(globals.apiUrl, globals.dev);
116
+ const config = loadConfig();
117
+ // Try to resolve a token; on auth failure, return a no-token status
118
+ // instead of throwing so the command works pre-login.
119
+ let token;
120
+ let tokenError;
121
+ try {
122
+ token = await resolveToken(globals.token, apiUrl, globals.tokenFile);
123
+ }
124
+ catch (err) {
125
+ tokenError = err instanceof Error ? err.message : String(err);
126
+ }
127
+ let user = null;
128
+ if (token) {
129
+ const claims = decodeJwtClaims(token);
130
+ const exp = typeof claims?.exp === "number" ? claims.exp : 0;
131
+ const expiresIn = exp ? Math.max(0, exp - Math.floor(Date.now() / 1000)) : 0;
132
+ user = {
133
+ email: typeof claims?.email === "string" ? claims.email : null,
134
+ token_valid: expiresIn > 0,
135
+ expires_in_seconds: expiresIn,
136
+ };
137
+ }
138
+ // Resolve names of active resources. Failures are non-fatal — we still
139
+ // return the saved IDs so the user knows what's configured.
140
+ const client = token ? new ApiClient({ apiUrl, token }) : null;
141
+ let workspace = null;
142
+ if (config.workspace) {
143
+ workspace = {
144
+ id: config.workspace,
145
+ alias: tagAlias(ALIAS_PREFIX.workspace, config.workspace),
146
+ };
147
+ if (client) {
148
+ try {
149
+ const ws = await client.get(`/products/${config.workspace}`);
150
+ if (ws?.name)
151
+ workspace.name = ws.name;
152
+ }
153
+ catch { /* keep id+alias only */ }
154
+ }
155
+ }
156
+ let study = null;
157
+ if (config.study) {
158
+ study = {
159
+ id: config.study,
160
+ alias: tagAlias(ALIAS_PREFIX.study, config.study),
161
+ };
162
+ if (client) {
163
+ try {
164
+ const s = await client.get(`/studies/${config.study}`);
165
+ if (s?.name)
166
+ study.name = s.name;
167
+ }
168
+ catch { /* keep id+alias only */ }
169
+ }
170
+ }
171
+ let ask = null;
172
+ if (config.ask) {
173
+ ask = {
174
+ id: config.ask,
175
+ alias: tagAlias(ALIAS_PREFIX.ask, config.ask),
176
+ };
177
+ if (client) {
178
+ try {
179
+ const a = await client.get(`/asks/${config.ask}`);
180
+ if (a?.name)
181
+ ask.name = a.name;
182
+ }
183
+ catch { /* keep id+alias only */ }
184
+ }
185
+ }
186
+ const payload = {
187
+ user,
188
+ workspace,
189
+ study,
190
+ ask,
191
+ api_url: apiUrl,
192
+ home: ishDir(),
193
+ };
194
+ if (tokenError && !token)
195
+ payload.hint = `Run \`ish login\`. (${tokenError})`;
196
+ if (globals.json) {
197
+ output(payload, true);
198
+ return;
199
+ }
200
+ // Human output
201
+ const fmtSeconds = (s) => {
202
+ if (s <= 0)
203
+ return "expired";
204
+ const m = Math.floor(s / 60);
205
+ const h = Math.floor(m / 60);
206
+ if (h > 0)
207
+ return `${h}h${m % 60}m`;
208
+ return `${m}m`;
209
+ };
210
+ console.log(`User: ${user ? `${user.email ?? "(no email)"} (token ${user.token_valid ? "valid" : "expired"}, expires in ${fmtSeconds(user.expires_in_seconds)})` : "(not logged in — run `ish login`)"}`);
211
+ console.log(`Workspace: ${workspace ? `${workspace.name ?? "(name unavailable)"} (${workspace.alias})` : "—"}`);
212
+ console.log(`Study: ${study ? `${study.name ?? "(name unavailable)"} (${study.alias})` : "—"}`);
213
+ console.log(`Ask: ${ask ? `${ask.name ?? "(name unavailable)"} (${ask.alias})` : "—"}`);
214
+ console.log(`Home: ${ishDir()}`);
215
+ console.log(`API: ${apiUrl}`);
216
+ if (tokenError && !token)
217
+ console.error(`\nHint: ${payload.hint}`);
218
+ });
219
+ });
101
220
  program
102
221
  .command("connect")
103
222
  .description("Expose your localhost to Ish via a Cloudflare tunnel")
@@ -134,4 +253,5 @@ program
134
253
  .action(async (options) => {
135
254
  await upgrade(version, options.release);
136
255
  });
256
+ injectGlobalWorkspaceOption(program);
137
257
  program.parse();
@@ -31,15 +31,30 @@ export class ApiError extends Error {
31
31
  error_code;
32
32
  retryable;
33
33
  constructor(status, statusText, body) {
34
- const msg = typeof body === "object" && body !== null && "detail" in body
35
- ? String(body.detail)
36
- : `HTTP ${status} ${statusText}`;
34
+ // FastAPI HTTPException(detail=...) wraps detail under a top-level "detail" key.
35
+ // When detail is a structured object (our convention for typed errors),
36
+ // pull the message off detail.detail and prefer detail.error_code.
37
+ let detail;
38
+ let bodyErrorCode;
39
+ if (typeof body === "object" && body !== null && "detail" in body) {
40
+ detail = body.detail;
41
+ if (typeof detail === "object" && detail !== null && "error_code" in detail
42
+ && typeof detail.error_code === "string") {
43
+ bodyErrorCode = detail.error_code;
44
+ }
45
+ }
46
+ const detailMsg = typeof detail === "string"
47
+ ? detail
48
+ : (typeof detail === "object" && detail !== null && "detail" in detail
49
+ ? String(detail.detail)
50
+ : undefined);
51
+ const msg = detailMsg ?? `HTTP ${status} ${statusText}`;
37
52
  super(msg);
38
53
  this.status = status;
39
54
  this.statusText = statusText;
40
55
  this.body = body;
41
56
  this.name = "ApiError";
42
- this.error_code = mapErrorCode(status);
57
+ this.error_code = bodyErrorCode ?? mapErrorCode(status);
43
58
  this.retryable = isRetryable(status);
44
59
  }
45
60
  }
@@ -176,7 +191,9 @@ export class ApiClient {
176
191
  return this.handleResponse(res);
177
192
  }
178
193
  async del(path, opts) {
179
- const timeout = opts?.timeout ?? DEFAULT_TIMEOUT_MS;
194
+ // Deletes typically cascade (rounds → responses → testers → audience),
195
+ // so default to a longer timeout than the standard 15s.
196
+ const timeout = opts?.timeout ?? 60_000;
180
197
  const url = `${this.baseUrl}${path}`;
181
198
  let res;
182
199
  try {
@@ -187,8 +204,13 @@ export class ApiClient {
187
204
  });
188
205
  }
189
206
  catch (err) {
190
- if (isAbortTimeout(err))
191
- throw timeoutError("DELETE", timeout);
207
+ if (isAbortTimeout(err)) {
208
+ const seconds = Math.round(timeout / 1000);
209
+ throw new ApiError(408, "Request Timeout", {
210
+ detail: `DELETE request timed out after ${seconds}s. The deletion may have completed server-side — ` +
211
+ `re-fetch the resource (e.g. \`ish ask get <id>\`) before retrying to avoid a 404.`,
212
+ });
213
+ }
192
214
  if (err instanceof TypeError)
193
215
  throw networkError(url);
194
216
  throw err;