@ishlabs/cli 0.9.0 → 0.10.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.
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Best-effort alias-cache hydration on alias-miss (Pattern F).
3
+ *
4
+ * The CLI persists aliases to ``~/.ish/aliases.json``, so the cache survives
5
+ * across processes. Where it bites: the file is missing/empty (fresh install,
6
+ * `rm ~/.ish/aliases.json`, agent running in a sandbox with a fresh
7
+ * `ISH_HOME`) and the agent has an alias from a prior process or the docs.
8
+ *
9
+ * ``resolveIdAsync(input, client, hints?)`` mirrors the sync ``resolveId``
10
+ * contract but, on alias-miss, attempts a single ``GET /list`` to repopulate
11
+ * the cache before retrying. The hydrate is BEST-EFFORT: a failing list
12
+ * call is swallowed and the canonical "Unknown alias" error fires with the
13
+ * actionable suggestion in the message.
14
+ *
15
+ * For prefixes whose list endpoint requires a parent ID (study/iteration/
16
+ * ask/etc), the caller passes ``hints`` carrying the parent (workspaceId,
17
+ * studyId). Without a parent we skip the hydrate — global N+1 fan-out is
18
+ * too expensive for a papercut.
19
+ */
20
+ import { ALIAS_PREFIX, resolveId, tagAlias } from "./alias-store.js";
21
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
22
+ const ALIAS_RE = /^[a-z]+-[0-9a-f]{3,}$|^[a-z]+\d{2,}$/;
23
+ function aliasPrefix(value) {
24
+ if (!ALIAS_RE.test(value))
25
+ return null;
26
+ const m = value.match(/^([a-z]+)/);
27
+ return m ? m[1] : null;
28
+ }
29
+ function isAliasShape(value) {
30
+ return ALIAS_RE.test(value);
31
+ }
32
+ function isUuid(value) {
33
+ return UUID_RE.test(value);
34
+ }
35
+ /**
36
+ * Try to resolve a parent ID — accepts either a UUID or a known alias. Used
37
+ * to extract ``workspaceId`` / ``studyId`` from hints before we fan out to a
38
+ * scoped list endpoint. Returns ``null`` if the value can't be resolved
39
+ * cheaply (e.g. it's an alias that's also missing from the cache).
40
+ */
41
+ function resolveParent(value) {
42
+ if (!value)
43
+ return null;
44
+ if (isUuid(value))
45
+ return value;
46
+ // Try the sync resolver but never throw.
47
+ try {
48
+ return resolveId(value);
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ /**
55
+ * Hit a list endpoint and tag every returned ID under the given prefix.
56
+ * Silently swallows network/auth failures — we only ever block the
57
+ * underlying call when the alias is genuinely unknown after hydrate.
58
+ */
59
+ async function hydrateList(client, prefix, path, params) {
60
+ let data;
61
+ try {
62
+ data = await client.get(path, params);
63
+ }
64
+ catch {
65
+ return;
66
+ }
67
+ let items = [];
68
+ if (Array.isArray(data)) {
69
+ items = data;
70
+ }
71
+ else if (typeof data === "object" && data !== null && "items" in data) {
72
+ const inner = data.items;
73
+ if (Array.isArray(inner))
74
+ items = inner;
75
+ }
76
+ for (const item of items) {
77
+ if (typeof item !== "object" || item === null)
78
+ continue;
79
+ const id = item.id;
80
+ if (typeof id === "string" && id.length > 0) {
81
+ try {
82
+ tagAlias(prefix, id);
83
+ }
84
+ catch {
85
+ // Ignore — bad UUIDs are not catastrophic.
86
+ }
87
+ }
88
+ }
89
+ }
90
+ /**
91
+ * Best-effort hydrate of the alias cache for ``alias``'s entity type. Returns
92
+ * silently on success or any failure — the caller checks the cache after
93
+ * this returns.
94
+ */
95
+ export async function hydrateForAlias(client, alias, hints = {}) {
96
+ const prefix = aliasPrefix(alias);
97
+ if (!prefix)
98
+ return;
99
+ const ws = resolveParent(hints.workspaceId);
100
+ const study = resolveParent(hints.studyId);
101
+ switch (prefix) {
102
+ case ALIAS_PREFIX.workspace: {
103
+ // Top-level — no parent.
104
+ await hydrateList(client, ALIAS_PREFIX.workspace, "/products");
105
+ return;
106
+ }
107
+ case ALIAS_PREFIX.study: {
108
+ if (!ws)
109
+ return;
110
+ await hydrateList(client, ALIAS_PREFIX.study, `/products/${ws}/studies`);
111
+ return;
112
+ }
113
+ case ALIAS_PREFIX.iteration: {
114
+ if (!study)
115
+ return;
116
+ await hydrateList(client, ALIAS_PREFIX.iteration, `/studies/${study}/iterations`);
117
+ return;
118
+ }
119
+ case ALIAS_PREFIX.testerProfile: {
120
+ if (!ws)
121
+ return;
122
+ await hydrateList(client, ALIAS_PREFIX.testerProfile, "/tester-profiles", { workspace_id: ws, type: "all", limit: "200" });
123
+ return;
124
+ }
125
+ case ALIAS_PREFIX.ask: {
126
+ if (!ws)
127
+ return;
128
+ await hydrateList(client, ALIAS_PREFIX.ask, `/products/${ws}/asks`);
129
+ return;
130
+ }
131
+ case ALIAS_PREFIX.chatEndpoint: {
132
+ if (!ws)
133
+ return;
134
+ await hydrateList(client, ALIAS_PREFIX.chatEndpoint, `/products/${ws}/chatbot-endpoints`);
135
+ return;
136
+ }
137
+ // No cheap single-call hydrate for the rest:
138
+ // tester (`t-`) — scoped to iteration
139
+ // ask round (`r-`) — nested on the ask
140
+ // audience source (`tps-`) — fetched per-id
141
+ // simulation config (`c-`) — no list endpoint yet
142
+ default:
143
+ return;
144
+ }
145
+ }
146
+ /**
147
+ * Async sibling of ``resolveId``: same UUID-or-alias contract, but on
148
+ * alias-miss attempts a best-effort hydrate via the matching list endpoint
149
+ * before retrying. Falls back to the canonical "Unknown alias" error
150
+ * (with named list-command suggestion) when the alias is still missing
151
+ * after the hydrate.
152
+ *
153
+ * Use this at command-handler entry points where the agent supplies an
154
+ * alias and the same call carries enough context (workspace / study) to
155
+ * scope the hydrate cheaply.
156
+ */
157
+ export async function resolveIdAsync(input, client, hints = {}) {
158
+ if (isUuid(input))
159
+ return input;
160
+ if (!isAliasShape(input)) {
161
+ // Fall through to the sync resolver so the "Invalid ID" guidance fires.
162
+ return resolveId(input);
163
+ }
164
+ // Cheap-path: alias already in store.
165
+ try {
166
+ return resolveId(input);
167
+ }
168
+ catch {
169
+ // Cache miss — attempt best-effort hydrate.
170
+ await hydrateForAlias(client, input, hints);
171
+ // Retry — sync resolver re-reads ``aliases.json`` from disk on every
172
+ // call (see loadAliases), so the freshly tagged entries are visible.
173
+ return resolveId(input);
174
+ }
175
+ }
@@ -17,6 +17,7 @@ export declare const ALIAS_PREFIX: {
17
17
  readonly job: "j";
18
18
  readonly ask: "a";
19
19
  readonly askRound: "r";
20
+ readonly chatEndpoint: "ep";
20
21
  };
21
22
  /**
22
23
  * Save aliases for a list of IDs under the given prefix.
@@ -20,6 +20,7 @@ export const ALIAS_PREFIX = {
20
20
  job: "j",
21
21
  ask: "a",
22
22
  askRound: "r",
23
+ chatEndpoint: "ep",
23
24
  };
24
25
  /** Format a number with zero-padding (minimum 2 digits). */
25
26
  function padNum(n) {
@@ -110,6 +111,32 @@ export function deterministicAlias(prefix, uuid) {
110
111
  }
111
112
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
112
113
  const ALIAS_RE = /^[a-z]+-[0-9a-f]{3,}$|^[a-z]+\d{2,}$/;
114
+ /**
115
+ * Suggested `ish ... list` command per alias prefix. Surfaced in the
116
+ * unknown-alias error (Pattern F) so the agent doesn't have to guess
117
+ * which list command to run.
118
+ */
119
+ const HYDRATE_HINT = {
120
+ w: "ish workspace list",
121
+ s: "ish study list",
122
+ i: "ish iteration list --study <study-id>",
123
+ tp: "ish profile list",
124
+ tps: "ish source list",
125
+ t: "ish tester get <tester-id>",
126
+ c: "ish config list",
127
+ a: "ish ask list",
128
+ r: "ish ask get <ask-id>",
129
+ ep: "ish chat endpoint list",
130
+ // Legacy two-letter prefixes the deterministic generator may have
131
+ // produced before; defaults below cover anything else.
132
+ };
133
+ function hintForPrefix(alias) {
134
+ // Pull the leading alpha run, which is the prefix.
135
+ const m = alias.match(/^([a-z]+)/);
136
+ if (!m)
137
+ return "the matching list command";
138
+ return HYDRATE_HINT[m[1]] ?? "the matching list command";
139
+ }
113
140
  /**
114
141
  * Resolve a short alias to a full UUID, or validate and pass through a full UUID.
115
142
  *
@@ -130,7 +157,7 @@ export function resolveId(input) {
130
157
  const uuid = aliases[input];
131
158
  if (uuid)
132
159
  return uuid;
133
- throw new Error(`Unknown alias "${input}". Run a list command first to generate aliases.`);
160
+ throw new Error(`Unknown alias "${input}". Run \`${hintForPrefix(input)}\` first to generate aliases.`);
134
161
  }
135
162
  // 3. Anything else — fail with helpful guidance
136
163
  throw new Error(`Invalid ID "${input}". Use a short alias (e.g. w-a3f, s-b2c) or a full UUID.\n` +
package/dist/lib/auth.js CHANGED
@@ -24,8 +24,10 @@ async function verifyToken(token, apiUrl) {
24
24
  return resp.status !== 401 && resp.status !== 403;
25
25
  }
26
26
  catch {
27
- // Network error can't verify, assume ok
28
- console.error("Warning: Could not verify token (network error). Proceeding anyway.");
27
+ // Network blip on the best-effort probe. The subsequent API call will
28
+ // surface the real auth failure (with a proper exit code 3) if there
29
+ // is one, so don't pollute stderr on every command — it fired on
30
+ // every successful run during Phase A (Pattern F / C4-finding-5).
29
31
  return true;
30
32
  }
31
33
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Lean-vs-verbose projection for ChatbotEndpointResponse.
3
+ *
4
+ * The backend returns a nested camelCase shape (id, name, productId, config,
5
+ * isTunnelBacked, createdAt, updatedAt). The lean projection keeps only the
6
+ * fields an agent typically branches on: id/alias/name, transport, the
7
+ * outgoing url + method, the incoming messagePath, the slot-path count, and
8
+ * isTunnelBacked. `--verbose` (or piped) passes the raw response.
9
+ */
10
+ export interface OutgoingHttp {
11
+ url?: string;
12
+ method?: string;
13
+ mode?: string;
14
+ [key: string]: unknown;
15
+ }
16
+ export interface IncomingHttp {
17
+ messagePath?: string;
18
+ slotsContainerPaths?: string[];
19
+ [key: string]: unknown;
20
+ }
21
+ export interface ChatbotEndpointConfig {
22
+ transport?: string;
23
+ outgoing?: OutgoingHttp;
24
+ incoming?: IncomingHttp;
25
+ isTunnelBacked?: boolean;
26
+ [key: string]: unknown;
27
+ }
28
+ export interface ChatbotEndpointRow {
29
+ id?: string;
30
+ name?: string;
31
+ productId?: string;
32
+ config?: ChatbotEndpointConfig;
33
+ isTunnelBacked?: boolean;
34
+ [key: string]: unknown;
35
+ }
36
+ /** Return the round-trippable envelope used by `endpoint get --verbose`. */
37
+ export declare function envelopeFromRow(row: ChatbotEndpointRow): Record<string, unknown>;
38
+ export declare function formatChatEndpointList(rows: ChatbotEndpointRow[], json: boolean, verbose: boolean): void;
39
+ export declare function formatChatEndpointDetail(row: ChatbotEndpointRow, json: boolean, verbose: boolean): void;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Lean-vs-verbose projection for ChatbotEndpointResponse.
3
+ *
4
+ * The backend returns a nested camelCase shape (id, name, productId, config,
5
+ * isTunnelBacked, createdAt, updatedAt). The lean projection keeps only the
6
+ * fields an agent typically branches on: id/alias/name, transport, the
7
+ * outgoing url + method, the incoming messagePath, the slot-path count, and
8
+ * isTunnelBacked. `--verbose` (or piped) passes the raw response.
9
+ */
10
+ import { tagAlias, ALIAS_PREFIX } from "./alias-store.js";
11
+ import { output, printTable } from "./output.js";
12
+ function leanRow(row) {
13
+ const cfg = row.config ?? {};
14
+ const out = {};
15
+ if (row.id)
16
+ out.alias = tagAlias(ALIAS_PREFIX.chatEndpoint, row.id);
17
+ if (row.id)
18
+ out.id = row.id;
19
+ if (row.name)
20
+ out.name = row.name;
21
+ if (cfg.transport)
22
+ out.transport = cfg.transport;
23
+ out.is_tunnel_backed = Boolean(row.isTunnelBacked);
24
+ if (cfg.outgoing?.url)
25
+ out.url = cfg.outgoing.url;
26
+ if (cfg.outgoing?.method)
27
+ out.method = cfg.outgoing.method;
28
+ if (cfg.outgoing?.mode)
29
+ out.mode = cfg.outgoing.mode;
30
+ if (cfg.incoming?.messagePath)
31
+ out.message_path = cfg.incoming.messagePath;
32
+ const slotsCount = Array.isArray(cfg.incoming?.slotsContainerPaths)
33
+ ? cfg.incoming.slotsContainerPaths.length
34
+ : 0;
35
+ out.slots_paths = slotsCount;
36
+ return out;
37
+ }
38
+ /** Return the round-trippable envelope used by `endpoint get --verbose`. */
39
+ export function envelopeFromRow(row) {
40
+ return {
41
+ id: row.id,
42
+ name: row.name,
43
+ isTunnelBacked: Boolean(row.isTunnelBacked),
44
+ config: row.config ?? {},
45
+ };
46
+ }
47
+ export function formatChatEndpointList(rows, json, verbose) {
48
+ if (json) {
49
+ if (verbose) {
50
+ output(rows, true);
51
+ return;
52
+ }
53
+ output(rows.map(leanRow), true);
54
+ return;
55
+ }
56
+ if (rows.length === 0) {
57
+ console.log("No chatbot endpoints.");
58
+ return;
59
+ }
60
+ const lean = rows.map(leanRow);
61
+ printTable(["#", "NAME", "TRANSPORT", "URL", "METHOD", "MODE", "TUNNEL"], lean.map((r) => [
62
+ String(r.alias ?? r.id ?? ""),
63
+ String(r.name ?? ""),
64
+ String(r.transport ?? "-"),
65
+ String(r.url ?? "-"),
66
+ String(r.method ?? "-"),
67
+ String(r.mode ?? "-"),
68
+ r.is_tunnel_backed ? "yes" : "no",
69
+ ]));
70
+ }
71
+ export function formatChatEndpointDetail(row, json, verbose) {
72
+ if (json) {
73
+ if (verbose) {
74
+ output(envelopeFromRow(row), true);
75
+ return;
76
+ }
77
+ output(leanRow(row), true);
78
+ return;
79
+ }
80
+ const cfg = row.config ?? {};
81
+ const alias = row.id ? tagAlias(ALIAS_PREFIX.chatEndpoint, row.id) : "-";
82
+ console.log(`${row.name || "Untitled"} (${alias})`);
83
+ const meta = [];
84
+ if (cfg.transport)
85
+ meta.push(String(cfg.transport));
86
+ if (row.isTunnelBacked)
87
+ meta.push("tunnel-backed");
88
+ if (meta.length > 0)
89
+ console.log(meta.join(" · "));
90
+ if (cfg.outgoing) {
91
+ console.log("");
92
+ console.log(` URL ${cfg.outgoing.url ?? "-"}`);
93
+ console.log(` Method ${cfg.outgoing.method ?? "-"}`);
94
+ console.log(` Mode ${cfg.outgoing.mode ?? "-"}`);
95
+ }
96
+ if (cfg.incoming) {
97
+ console.log("");
98
+ console.log(` Message path ${cfg.incoming.messagePath ?? "-"}`);
99
+ const slots = Array.isArray(cfg.incoming.slotsContainerPaths)
100
+ ? cfg.incoming.slotsContainerPaths.length
101
+ : 0;
102
+ console.log(` Slot paths ${slots}`);
103
+ }
104
+ }
@@ -123,6 +123,12 @@ export declare function confirmDestructive(prompt: string, opts: {
123
123
  export declare function resolveWorkspace(explicit?: string): string;
124
124
  export declare function resolveStudy(explicit?: string): string;
125
125
  export declare function resolveAsk(explicit?: string): string;
126
+ /**
127
+ * Resolve a chat endpoint id from (in order): the positional argument, the
128
+ * `--endpoint <id>` flag, the `ISH_CHAT_ENDPOINT` env var, or the active
129
+ * endpoint persisted by `ish chat endpoint use`. Throws when none are set.
130
+ */
131
+ export declare function resolveChatEndpoint(positional?: string, flag?: string): string;
126
132
  /** Commander option-collector for repeatable flags (e.g. `--variant text:"..."` repeated). */
127
133
  export declare function collectRepeatable(value: string, prev?: string[]): string[];
128
134
  /**
@@ -150,4 +156,16 @@ export declare function parseWaitTimeout(raw: string | undefined, defaultMs?: nu
150
156
  * body. Resolvers (`resolveWorkspace`, `resolveAudienceProfileIds`) ignore
151
157
  * unused values.
152
158
  */
159
+ /**
160
+ * Read a `--*-config <file>` style flag value, treating "-" as "read from
161
+ * stdin" and any other value as a file path on disk. Trailing newlines on
162
+ * stdin input are stripped so the resulting string parses cleanly as JSON.
163
+ *
164
+ * Throws when "-" is passed but stdin is a TTY (no upstream pipe).
165
+ *
166
+ * Mirrors the readSecretFlag pattern in src/commands/workspace.ts; extracted
167
+ * so every `--<x>-config <file>` flag across commands shares one
168
+ * implementation.
169
+ */
170
+ export declare function readFileOrStdin(path: string): Promise<string>;
153
171
  export declare function injectGlobalWorkspaceOption(program: Command): void;
@@ -348,6 +348,20 @@ export function exitCodeFromError(err) {
348
348
  // Client-side validation failures
349
349
  if (err.name === "ValidationError" || /^invalid |^cannot read |is empty:|--\w[\w-]* must be|pick an audience|use either /i.test(err.message))
350
350
  return 2;
351
+ // Errors that pre-declare their own retryability / error_code take
352
+ // precedence — WaitTimeoutError sets `error_code: "wait_timeout"`
353
+ // and `retryable: true`, so callers can branch on exit 5 (transient)
354
+ // distinct from the api-client's generic 408/timeout family.
355
+ const tagged = err;
356
+ if (tagged.error_code === "wait_timeout")
357
+ return 5;
358
+ if (typeof tagged.retryable === "boolean" && tagged.retryable)
359
+ return 5;
360
+ // Structured error_kind on the Error object (set by chat endpoint test/init,
361
+ // simulation routes, etc.). TunnelInactive is the canonical transient one.
362
+ const kind = err.error_kind;
363
+ if (typeof kind === "string" && kind === "TunnelInactive")
364
+ return 5;
351
365
  }
352
366
  return 1;
353
367
  }
@@ -439,6 +453,18 @@ export function readJsonFileOrStdin(filePath) {
439
453
  process.stdin.on("error", reject);
440
454
  });
441
455
  }
456
+ /**
457
+ * Build the suggested re-invocation example by taking the live argv and
458
+ * appending `--yes` if it isn't already there. Strips the `node` /
459
+ * `dist/index.js` prefix so the example reads as a normal `ish` command.
460
+ */
461
+ function buildConfirmationExample() {
462
+ const argv = process.argv.slice(2);
463
+ const args = argv.includes("--yes") || argv.includes("-y")
464
+ ? argv
465
+ : [...argv, "--yes"];
466
+ return ["ish", ...args].join(" ");
467
+ }
442
468
  /**
443
469
  * Prompt for confirmation of a destructive action, or short-circuit when
444
470
  * `--yes` is set. In `--json` mode without `--yes` we refuse with a usage
@@ -451,11 +477,15 @@ export async function confirmDestructive(prompt, opts) {
451
477
  if (opts.json) {
452
478
  const err = new Error(`--yes is required for destructive actions in --json mode. Refusing to proceed without explicit confirmation.`);
453
479
  err.name = "ValidationError";
480
+ err.error_kind = "ConfirmationRequired";
481
+ err.example = buildConfirmationExample();
454
482
  throw err;
455
483
  }
456
484
  if (!process.stdin.isTTY) {
457
485
  const err = new Error(`--yes is required for destructive actions when stdin is not a TTY. Refusing to proceed without explicit confirmation.`);
458
486
  err.name = "ValidationError";
487
+ err.error_kind = "ConfirmationRequired";
488
+ err.example = buildConfirmationExample();
459
489
  throw err;
460
490
  }
461
491
  process.stderr.write(`${prompt} [y/N] `);
@@ -486,6 +516,23 @@ export async function confirmDestructive(prompt, opts) {
486
516
  throw err;
487
517
  }
488
518
  }
519
+ /**
520
+ * Construct a structured "no active <thing>" Error so the JSON envelope from
521
+ * `outputError` carries `error_code` + `suggestions` rather than a generic
522
+ * `client_error`. Pattern A (Sprint 2): when the active study/workspace
523
+ * evaporates between commands, agents need to branch on the error code, not
524
+ * scrape prose. Without this, downstream modality validation in
525
+ * `iteration create` would surface a misleading "Image iterations require
526
+ * --image-urls" message instead of the real "no active study" cause.
527
+ */
528
+ function noActiveContextError(message, errorCode, suggestions) {
529
+ const err = new Error(message);
530
+ err.name = "NoActiveContextError";
531
+ err.error_code = errorCode;
532
+ err.retryable = false;
533
+ err.suggestions = suggestions;
534
+ return err;
535
+ }
489
536
  export function resolveWorkspace(explicit) {
490
537
  if (explicit)
491
538
  return resolveId(explicit);
@@ -500,7 +547,10 @@ export function resolveWorkspace(explicit) {
500
547
  const config = loadConfig();
501
548
  if (config.workspace)
502
549
  return config.workspace;
503
- throw new Error('No workspace set. Use `ish workspace use <alias>` or pass --workspace.');
550
+ throw noActiveContextError('No active workspace. Run `ish workspace use <workspace-id>` or pass --workspace <id>.', "no_active_workspace", [
551
+ "ish workspace list",
552
+ "ish workspace use <workspace-id>",
553
+ ]);
504
554
  }
505
555
  export function resolveStudy(explicit) {
506
556
  if (explicit)
@@ -511,7 +561,10 @@ export function resolveStudy(explicit) {
511
561
  const config = loadConfig();
512
562
  if (config.study)
513
563
  return config.study;
514
- throw new Error('No study set. Use `ish study use <alias>` or pass --study.');
564
+ throw noActiveContextError('No active study. Run `ish study use <study-id>` or pass --study <id>.', "no_active_study", [
565
+ "ish study use <study-id>",
566
+ "ish iteration create --study <study-id> ...",
567
+ ]);
515
568
  }
516
569
  export function resolveAsk(explicit) {
517
570
  if (explicit)
@@ -522,7 +575,28 @@ export function resolveAsk(explicit) {
522
575
  const config = loadConfig();
523
576
  if (config.ask)
524
577
  return config.ask;
525
- throw new Error('No ask set. Use `ish ask use <alias>` or pass the ask ID as an argument.');
578
+ throw noActiveContextError('No active ask. Run `ish ask use <ask-id>` or pass the ask ID as an argument.', "no_active_ask", [
579
+ "ish ask list",
580
+ "ish ask use <ask-id>",
581
+ ]);
582
+ }
583
+ /**
584
+ * Resolve a chat endpoint id from (in order): the positional argument, the
585
+ * `--endpoint <id>` flag, the `ISH_CHAT_ENDPOINT` env var, or the active
586
+ * endpoint persisted by `ish chat endpoint use`. Throws when none are set.
587
+ */
588
+ export function resolveChatEndpoint(positional, flag) {
589
+ if (positional)
590
+ return resolveId(positional);
591
+ if (flag)
592
+ return resolveId(flag);
593
+ const env = process.env.ISH_CHAT_ENDPOINT;
594
+ if (env)
595
+ return resolveId(env);
596
+ const config = loadConfig();
597
+ if (config.chat_endpoint)
598
+ return config.chat_endpoint;
599
+ throw new Error('No chat endpoint set. Use `ish chat endpoint use <id>`, pass the endpoint id, or set --endpoint.');
526
600
  }
527
601
  /** Commander option-collector for repeatable flags (e.g. `--variant text:"..."` repeated). */
528
602
  export function collectRepeatable(value, prev = []) {
@@ -571,6 +645,34 @@ const WORKSPACE_SCOPED_GROUPS = new Set([
571
645
  * body. Resolvers (`resolveWorkspace`, `resolveAudienceProfileIds`) ignore
572
646
  * unused values.
573
647
  */
648
+ /**
649
+ * Read a `--*-config <file>` style flag value, treating "-" as "read from
650
+ * stdin" and any other value as a file path on disk. Trailing newlines on
651
+ * stdin input are stripped so the resulting string parses cleanly as JSON.
652
+ *
653
+ * Throws when "-" is passed but stdin is a TTY (no upstream pipe).
654
+ *
655
+ * Mirrors the readSecretFlag pattern in src/commands/workspace.ts; extracted
656
+ * so every `--<x>-config <file>` flag across commands shares one
657
+ * implementation.
658
+ */
659
+ export async function readFileOrStdin(path) {
660
+ if (path === "-") {
661
+ if (process.stdin.isTTY) {
662
+ throw new Error('Use "-" only when piping the value on stdin.');
663
+ }
664
+ return await new Promise((resolve, reject) => {
665
+ let data = "";
666
+ process.stdin.setEncoding("utf-8");
667
+ process.stdin.on("data", (chunk) => {
668
+ data += chunk;
669
+ });
670
+ process.stdin.on("end", () => resolve(data.replace(/\r?\n$/, "")));
671
+ process.stdin.on("error", reject);
672
+ });
673
+ }
674
+ return fs.readFileSync(path, "utf-8");
675
+ }
574
676
  export function injectGlobalWorkspaceOption(program) {
575
677
  const walk = (cmd) => {
576
678
  if (cmd.commands.length === 0) {