@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.
@@ -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;
@@ -100,3 +137,17 @@ export declare function collectRepeatable(value: string, prev?: string[]): strin
100
137
  export declare function collectIds(value: string, prev?: string[]): string[];
101
138
  /** Parse a `--timeout <seconds>` flag into milliseconds, with validation. */
102
139
  export declare function parseWaitTimeout(raw: string | undefined, defaultMs?: number): number;
140
+ /**
141
+ * Inject `--workspace <id>` on every leaf subcommand under the workspace-scoped
142
+ * groups that doesn't already declare it. Run once after all `registerXxxCommands`
143
+ * calls have populated the program tree. Agents reflexively pass `--workspace`
144
+ * on any command — without this, Commander rejects it with a generic "unknown
145
+ * option" on read-side commands like `ask delete`, `ask get`, `study get`,
146
+ * `profile get`, etc.
147
+ *
148
+ * The injected option is documentation-only on commands where the workspace is
149
+ * inferred from an ID alias; it's accepted and then discarded by the action
150
+ * body. Resolvers (`resolveWorkspace`, `resolveAudienceProfileIds`) ignore
151
+ * unused values.
152
+ */
153
+ export declare function injectGlobalWorkspaceOption(program: Command): void;
@@ -6,7 +6,7 @@ import * as fs from "node:fs";
6
6
  import { resolveApiUrl, resolveToken } from "./auth.js";
7
7
  import { getAppUrl } from "../auth.js";
8
8
  import { ApiClient, ApiError } from "./api-client.js";
9
- import { outputError, setVerbose, setFields } from "./output.js";
9
+ import { outputError, setVerbose, setFields, setGetField } from "./output.js";
10
10
  import { setColorsEnabled, colorsEnabled } from "./colors.js";
11
11
  import { loadConfig } from "../config.js";
12
12
  import { resolveId } from "./alias-store.js";
@@ -86,6 +86,9 @@ export async function resolveAudienceProfileIds(client, workspace, flags, opts =
86
86
  }
87
87
  return explicit;
88
88
  }
89
+ if (sampleN !== undefined && flags.all) {
90
+ throw new Error(`Use either --sample <N> or ${allFlagName}, not both. --sample picks a random subset; ${allFlagName} returns every match.`);
91
+ }
89
92
  if (sampleN === undefined && !flags.all && !filtersUsed) {
90
93
  throw new Error(`Pick an audience: pass --profile <id> (repeatable), --sample <N>, ${allFlagName}, or filter flags (--country, --gender, --min-age, --max-age, --search, --visibility).`);
91
94
  }
@@ -126,8 +129,58 @@ export async function resolveAudienceProfileIds(client, workspace, flags, opts =
126
129
  if (opts.excludeProfileIds && opts.excludeProfileIds.size > 0 && !filterDesc) {
127
130
  throw new Error("All matching profiles are already in this audience.");
128
131
  }
132
+ // When --country was the binding constraint, query the broader pool (drop
133
+ // country filter, keep the rest) and surface the top populated countries
134
+ // so the agent doesn't have to round-trip through `ish profile list` to
135
+ // find one that matches. Pure best-effort — any failure falls back to the
136
+ // original error.
137
+ let suggestion = "";
138
+ if (flags.country && flags.country.length > 0) {
139
+ try {
140
+ const broader = {
141
+ product_id: workspace,
142
+ type: "ai",
143
+ limit: "500",
144
+ offset: "0",
145
+ };
146
+ if (flags.search)
147
+ broader.search = flags.search;
148
+ if (flags.gender && flags.gender.length > 0)
149
+ broader.gender = flags.gender;
150
+ if (flags.minAge)
151
+ broader.min_age = flags.minAge;
152
+ if (flags.maxAge)
153
+ broader.max_age = flags.maxAge;
154
+ if (flags.visibility)
155
+ broader.visibility = flags.visibility;
156
+ const broaderData = await client.get("/tester-profiles", broader);
157
+ const broaderItems = Array.isArray(broaderData)
158
+ ? broaderData
159
+ : Array.isArray(broaderData?.items)
160
+ ? broaderData.items
161
+ : [];
162
+ const broaderPool = opts.requireSimulatable
163
+ ? broaderItems.filter(isSimulatable)
164
+ : broaderItems;
165
+ const counts = new Map();
166
+ for (const p of broaderPool) {
167
+ const c = typeof p.country === "string" ? p.country : null;
168
+ if (c)
169
+ counts.set(c, (counts.get(c) ?? 0) + 1);
170
+ }
171
+ const top = [...counts.entries()]
172
+ .sort((a, b) => b[1] - a[1])
173
+ .slice(0, 3);
174
+ if (top.length > 0) {
175
+ suggestion = ` Populated countries with these other filters: ${top.map(([c, n]) => `${c} (${n})`).join(", ")}.`;
176
+ }
177
+ }
178
+ catch {
179
+ // Swallow — never replace the user's error with a secondary failure.
180
+ }
181
+ }
129
182
  if (filterDesc) {
130
- throw new Error(`No ${sim}tester profiles in workspace ${workspace} match: ${filterDesc}. Broaden your filters or run \`ish profile list\` to inspect the pool.`);
183
+ throw new Error(`No ${sim}tester profiles in workspace ${workspace} match: ${filterDesc}.${suggestion} Broaden your filters or run \`ish profile list\` to inspect the pool.`);
131
184
  }
132
185
  throw new Error(`No ${sim}tester profiles found in workspace ${workspace}.${opts.requireSimulatable ? " Create profiles with simulation configs first." : ""}`);
133
186
  }
@@ -162,9 +215,52 @@ export function addAudienceFilterFlags(cmd, opts = {}) {
162
215
  .option("--visibility <v>", "Filter by visibility (private|public)");
163
216
  }
164
217
  export function getGlobals(cmd) {
218
+ let globals;
219
+ try {
220
+ globals = computeGlobals(cmd);
221
+ }
222
+ catch (err) {
223
+ // Validation errors (e.g. --get with --human) need to surface as a
224
+ // clean usage error regardless of which entry point invoked us. Many
225
+ // commands resolve globals without a withClient/runInline wrapper
226
+ // (e.g. `ish docs *`) so we cannot rely on those try/catches.
227
+ const useJson = process.argv.includes("--json")
228
+ || process.argv.includes("--get")
229
+ || !process.stdout.isTTY;
230
+ outputError(err, useJson);
231
+ process.exit(exitCodeFromError(err));
232
+ }
233
+ // Apply side effects (verbose, fields, colors, get-field, active workspace)
234
+ // here so commands that resolve globals without going through withClient /
235
+ // runInline still get --get / --human / --fields honored.
236
+ applyGlobals(globals);
237
+ return globals;
238
+ }
239
+ function computeGlobals(cmd) {
165
240
  const opts = cmd.optsWithGlobals();
166
- // Auto-switch to JSON when stdout is piped (non-TTY)
167
- const json = opts.json ?? !process.stdout.isTTY;
241
+ // Pattern Ω: display-vs-capture controls.
242
+ // --get <field> implies --json (need structured data to extract from).
243
+ // --human forces the human renderer regardless of TTY/pipe state.
244
+ // Both passed together is a usage error — capture and display are
245
+ // different modes; pick one.
246
+ const getField = typeof opts.get === "string" && opts.get.length > 0
247
+ ? opts.get
248
+ : undefined;
249
+ const human = opts.human === true;
250
+ if (getField && human) {
251
+ const err = new Error("--get and --human are mutually exclusive: --get captures a value (JSON-derived), --human forces human display.");
252
+ err.name = "ValidationError";
253
+ throw err;
254
+ }
255
+ // Auto-switch to JSON when stdout is piped (non-TTY).
256
+ // --human overrides the auto-flip; --get implies --json.
257
+ let json;
258
+ if (human)
259
+ json = false;
260
+ else if (getField)
261
+ json = true;
262
+ else
263
+ json = opts.json ?? !process.stdout.isTTY;
168
264
  // Parse --fields into an array
169
265
  const fields = opts.fields
170
266
  ? String(opts.fields).split(",").map((f) => f.trim()).filter(Boolean)
@@ -179,9 +275,15 @@ export function getGlobals(cmd) {
179
275
  dev: opts.dev,
180
276
  json,
181
277
  verbose: opts.verbose ?? false,
182
- quiet: opts.quiet ?? (json && !opts.json), // auto-quiet when auto-json via pipe
278
+ // --get is silent capture: suppress stderr progress so the bare value is
279
+ // the only thing the agent has to parse. --human keeps progress on.
280
+ quiet: opts.quiet ?? (getField ? true : (json && !opts.json)),
281
+ quietExplicit: opts.quiet === true || getField !== undefined,
183
282
  color,
184
283
  fields,
284
+ get: getField,
285
+ human,
286
+ workspace: typeof opts.workspace === "string" ? opts.workspace : undefined,
185
287
  };
186
288
  }
187
289
  /**
@@ -221,14 +323,23 @@ export async function createClient(globals) {
221
323
  const token = await resolveToken(globals.token, apiUrl, globals.tokenFile);
222
324
  return new ApiClient({ apiUrl, token });
223
325
  }
326
+ /**
327
+ * Module-level fallback for `resolveWorkspace`. Set by `applyGlobals` from the
328
+ * merged `optsWithGlobals().workspace`, so the program-root --workspace flag
329
+ * propagates to subcommand resolvers without each call site having to thread
330
+ * `globals` through. Subcommand-level --workspace still wins via Commander's
331
+ * own merge (it's already reflected in `globals.workspace`).
332
+ */
333
+ let _activeWorkspace;
224
334
  function applyGlobals(globals) {
225
335
  setVerbose(globals.verbose);
226
336
  setFields(globals.fields);
337
+ setGetField(globals.get);
227
338
  setColorsEnabled(globals.color);
339
+ _activeWorkspace = globals.workspace;
228
340
  }
229
341
  export async function withClient(cmd, fn) {
230
342
  const globals = getGlobals(cmd);
231
- applyGlobals(globals);
232
343
  try {
233
344
  const client = await createClient(globals);
234
345
  await fn(client, globals);
@@ -245,7 +356,6 @@ export async function withClient(cmd, fn) {
245
356
  */
246
357
  export async function runInline(cmd, fn) {
247
358
  const globals = getGlobals(cmd);
248
- applyGlobals(globals);
249
359
  try {
250
360
  await fn(globals);
251
361
  }
@@ -296,9 +406,61 @@ export function readJsonFileOrStdin(filePath) {
296
406
  process.stdin.on("error", reject);
297
407
  });
298
408
  }
409
+ /**
410
+ * Prompt for confirmation of a destructive action, or short-circuit when
411
+ * `--yes` is set. In `--json` mode without `--yes` we refuse with a usage
412
+ * error rather than silently proceeding or hanging on a prompt — agents
413
+ * piping through CLI must be explicit about destructive intent.
414
+ */
415
+ export async function confirmDestructive(prompt, opts) {
416
+ if (opts.yes)
417
+ return;
418
+ if (opts.json) {
419
+ const err = new Error(`--yes is required for destructive actions in --json mode. Refusing to proceed without explicit confirmation.`);
420
+ err.name = "ValidationError";
421
+ throw err;
422
+ }
423
+ if (!process.stdin.isTTY) {
424
+ const err = new Error(`--yes is required for destructive actions when stdin is not a TTY. Refusing to proceed without explicit confirmation.`);
425
+ err.name = "ValidationError";
426
+ throw err;
427
+ }
428
+ process.stderr.write(`${prompt} [y/N] `);
429
+ const answer = await new Promise((resolve, reject) => {
430
+ let data = "";
431
+ const onData = (chunk) => {
432
+ data += chunk.toString();
433
+ if (data.includes("\n")) {
434
+ process.stdin.off("data", onData);
435
+ process.stdin.off("error", onError);
436
+ process.stdin.pause();
437
+ resolve(data.trim().toLowerCase());
438
+ }
439
+ };
440
+ const onError = (err) => {
441
+ process.stdin.off("data", onData);
442
+ process.stdin.off("error", onError);
443
+ reject(err);
444
+ };
445
+ process.stdin.setEncoding("utf-8");
446
+ process.stdin.on("data", onData);
447
+ process.stdin.on("error", onError);
448
+ process.stdin.resume();
449
+ });
450
+ if (answer !== "y" && answer !== "yes") {
451
+ const err = new Error("Aborted.");
452
+ err.name = "ValidationError";
453
+ throw err;
454
+ }
455
+ }
299
456
  export function resolveWorkspace(explicit) {
300
457
  if (explicit)
301
458
  return resolveId(explicit);
459
+ // Fall back to the program-root --workspace cached by applyGlobals — covers
460
+ // `ish --workspace W study list` where the subcommand action doesn't see the
461
+ // flag in its local opts.
462
+ if (_activeWorkspace)
463
+ return resolveId(_activeWorkspace);
302
464
  const env = process.env.ISH_WORKSPACE;
303
465
  if (env)
304
466
  return resolveId(env);
@@ -355,3 +517,40 @@ export function parseWaitTimeout(raw, defaultMs = 5 * 60 * 1000) {
355
517
  }
356
518
  return n * 1000;
357
519
  }
520
+ /** Top-level command groups whose subcommands should accept `--workspace`. */
521
+ const WORKSPACE_SCOPED_GROUPS = new Set([
522
+ "ask",
523
+ "study",
524
+ "iteration",
525
+ "profile",
526
+ "source",
527
+ ]);
528
+ /**
529
+ * Inject `--workspace <id>` on every leaf subcommand under the workspace-scoped
530
+ * groups that doesn't already declare it. Run once after all `registerXxxCommands`
531
+ * calls have populated the program tree. Agents reflexively pass `--workspace`
532
+ * on any command — without this, Commander rejects it with a generic "unknown
533
+ * option" on read-side commands like `ask delete`, `ask get`, `study get`,
534
+ * `profile get`, etc.
535
+ *
536
+ * The injected option is documentation-only on commands where the workspace is
537
+ * inferred from an ID alias; it's accepted and then discarded by the action
538
+ * body. Resolvers (`resolveWorkspace`, `resolveAudienceProfileIds`) ignore
539
+ * unused values.
540
+ */
541
+ export function injectGlobalWorkspaceOption(program) {
542
+ const walk = (cmd) => {
543
+ if (cmd.commands.length === 0) {
544
+ const hasWorkspace = cmd.options.some((o) => o.long === "--workspace");
545
+ if (!hasWorkspace) {
546
+ cmd.option("--workspace <id>", "Workspace ID; accepted for consistency (inferred from alias / active context)");
547
+ }
548
+ }
549
+ for (const sub of cmd.commands)
550
+ walk(sub);
551
+ };
552
+ for (const top of program.commands) {
553
+ if (WORKSPACE_SCOPED_GROUPS.has(top.name()))
554
+ walk(top);
555
+ }
556
+ }