@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.
@@ -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";
@@ -45,6 +45,77 @@ function describeFilters(flags) {
45
45
  parts.push(`--visibility ${flags.visibility}`);
46
46
  return parts.join(" ");
47
47
  }
48
+ /**
49
+ * Best-effort: surface the top-3 populated countries so an empty-audience
50
+ * error doesn't strand the agent without a pivot. Two tiers — tier 1 keeps
51
+ * non-country filters and drops `--country` (used when `--country` was the
52
+ * binding constraint); tier 2 fires when tier 1 returns empty (or when
53
+ * `--country` wasn't set at all) and drops every filter so the workspace's
54
+ * overall country distribution is the fallback. Returns the empty string on
55
+ * any failure — never replaces the primary error with a secondary one.
56
+ */
57
+ async function suggestCountries(client, workspace, flags, opts) {
58
+ const countOf = (items) => {
59
+ const counts = new Map();
60
+ for (const p of items) {
61
+ const c = typeof p.country === "string" ? p.country : null;
62
+ if (c)
63
+ counts.set(c, (counts.get(c) ?? 0) + 1);
64
+ }
65
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3);
66
+ };
67
+ const fetchFacet = async (keepOtherFilters) => {
68
+ try {
69
+ const broader = {
70
+ product_id: workspace,
71
+ type: "ai",
72
+ limit: "500",
73
+ offset: "0",
74
+ };
75
+ if (keepOtherFilters) {
76
+ if (flags.search)
77
+ broader.search = flags.search;
78
+ if (flags.gender && flags.gender.length > 0)
79
+ broader.gender = flags.gender;
80
+ if (flags.minAge)
81
+ broader.min_age = flags.minAge;
82
+ if (flags.maxAge)
83
+ broader.max_age = flags.maxAge;
84
+ if (flags.visibility)
85
+ broader.visibility = flags.visibility;
86
+ }
87
+ const data = await client.get("/tester-profiles", broader);
88
+ const items = Array.isArray(data)
89
+ ? data
90
+ : Array.isArray(data?.items)
91
+ ? data.items
92
+ : [];
93
+ const pool = opts.requireSimulatable ? items.filter(isSimulatable) : items;
94
+ return countOf(pool);
95
+ }
96
+ catch {
97
+ return [];
98
+ }
99
+ };
100
+ const countrySet = !!(flags.country && flags.country.length > 0);
101
+ // Tier 1: when --country is set, drop just country and keep other filters.
102
+ // When --country isn't set, skip straight to the unfiltered fallback —
103
+ // tier 1 with `keepOtherFilters=false` would re-issue the same query that
104
+ // already returned 0.
105
+ let top = [];
106
+ let label = "Populated countries";
107
+ if (countrySet) {
108
+ top = await fetchFacet(true);
109
+ label = "Populated countries with these other filters";
110
+ }
111
+ if (top.length === 0) {
112
+ top = await fetchFacet(false);
113
+ label = "Populated countries";
114
+ }
115
+ if (top.length === 0)
116
+ return "";
117
+ return ` ${label}: ${top.map(([c, n]) => `${c} (${n})`).join(", ")}.`;
118
+ }
48
119
  function hasFilterFlag(flags) {
49
120
  return Boolean(flags.search
50
121
  || (flags.gender && flags.gender.length > 0)
@@ -129,8 +200,20 @@ export async function resolveAudienceProfileIds(client, workspace, flags, opts =
129
200
  if (opts.excludeProfileIds && opts.excludeProfileIds.size > 0 && !filterDesc) {
130
201
  throw new Error("All matching profiles are already in this audience.");
131
202
  }
203
+ // Country suggestion: surface the top populated countries so the agent
204
+ // doesn't have to round-trip through `ish profile list` to find one that
205
+ // matches. Two tiers — tier 1 drops `--country` only and retains other
206
+ // filters when `--country` was set (so the hint reflects "of countries
207
+ // that match your other filters, here are the populated ones"). Tier 2
208
+ // fires when tier 1 returns nothing *and* tier 1 was scoped, or when
209
+ // `--country` wasn't the constraint to begin with: drop every filter and
210
+ // surface the workspace's overall country distribution. Pure best-effort
211
+ // — any failure falls back to the original error.
212
+ const suggestion = await suggestCountries(client, workspace, flags, {
213
+ requireSimulatable: !!opts.requireSimulatable,
214
+ });
132
215
  if (filterDesc) {
133
- throw new Error(`No ${sim}tester profiles in workspace ${workspace} match: ${filterDesc}. Broaden your filters or run \`ish profile list\` to inspect the pool.`);
216
+ 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.`);
134
217
  }
135
218
  throw new Error(`No ${sim}tester profiles found in workspace ${workspace}.${opts.requireSimulatable ? " Create profiles with simulation configs first." : ""}`);
136
219
  }
@@ -165,9 +248,52 @@ export function addAudienceFilterFlags(cmd, opts = {}) {
165
248
  .option("--visibility <v>", "Filter by visibility (private|public)");
166
249
  }
167
250
  export function getGlobals(cmd) {
251
+ let globals;
252
+ try {
253
+ globals = computeGlobals(cmd);
254
+ }
255
+ catch (err) {
256
+ // Validation errors (e.g. --get with --human) need to surface as a
257
+ // clean usage error regardless of which entry point invoked us. Many
258
+ // commands resolve globals without a withClient/runInline wrapper
259
+ // (e.g. `ish docs *`) so we cannot rely on those try/catches.
260
+ const useJson = process.argv.includes("--json")
261
+ || process.argv.includes("--get")
262
+ || !process.stdout.isTTY;
263
+ outputError(err, useJson);
264
+ process.exit(exitCodeFromError(err));
265
+ }
266
+ // Apply side effects (verbose, fields, colors, get-field, active workspace)
267
+ // here so commands that resolve globals without going through withClient /
268
+ // runInline still get --get / --human / --fields honored.
269
+ applyGlobals(globals);
270
+ return globals;
271
+ }
272
+ function computeGlobals(cmd) {
168
273
  const opts = cmd.optsWithGlobals();
169
- // Auto-switch to JSON when stdout is piped (non-TTY)
170
- const json = opts.json ?? !process.stdout.isTTY;
274
+ // Pattern Ω: display-vs-capture controls.
275
+ // --get <field> implies --json (need structured data to extract from).
276
+ // --human forces the human renderer regardless of TTY/pipe state.
277
+ // Both passed together is a usage error — capture and display are
278
+ // different modes; pick one.
279
+ const getField = typeof opts.get === "string" && opts.get.length > 0
280
+ ? opts.get
281
+ : undefined;
282
+ const human = opts.human === true;
283
+ if (getField && human) {
284
+ const err = new Error("--get and --human are mutually exclusive: --get captures a value (JSON-derived), --human forces human display.");
285
+ err.name = "ValidationError";
286
+ throw err;
287
+ }
288
+ // Auto-switch to JSON when stdout is piped (non-TTY).
289
+ // --human overrides the auto-flip; --get implies --json.
290
+ let json;
291
+ if (human)
292
+ json = false;
293
+ else if (getField)
294
+ json = true;
295
+ else
296
+ json = opts.json ?? !process.stdout.isTTY;
171
297
  // Parse --fields into an array
172
298
  const fields = opts.fields
173
299
  ? String(opts.fields).split(",").map((f) => f.trim()).filter(Boolean)
@@ -182,9 +308,15 @@ export function getGlobals(cmd) {
182
308
  dev: opts.dev,
183
309
  json,
184
310
  verbose: opts.verbose ?? false,
185
- quiet: opts.quiet ?? (json && !opts.json), // auto-quiet when auto-json via pipe
311
+ // --get is silent capture: suppress stderr progress so the bare value is
312
+ // the only thing the agent has to parse. --human keeps progress on.
313
+ quiet: opts.quiet ?? (getField ? true : (json && !opts.json)),
314
+ quietExplicit: opts.quiet === true || getField !== undefined,
186
315
  color,
187
316
  fields,
317
+ get: getField,
318
+ human,
319
+ workspace: typeof opts.workspace === "string" ? opts.workspace : undefined,
188
320
  };
189
321
  }
190
322
  /**
@@ -224,14 +356,23 @@ export async function createClient(globals) {
224
356
  const token = await resolveToken(globals.token, apiUrl, globals.tokenFile);
225
357
  return new ApiClient({ apiUrl, token });
226
358
  }
359
+ /**
360
+ * Module-level fallback for `resolveWorkspace`. Set by `applyGlobals` from the
361
+ * merged `optsWithGlobals().workspace`, so the program-root --workspace flag
362
+ * propagates to subcommand resolvers without each call site having to thread
363
+ * `globals` through. Subcommand-level --workspace still wins via Commander's
364
+ * own merge (it's already reflected in `globals.workspace`).
365
+ */
366
+ let _activeWorkspace;
227
367
  function applyGlobals(globals) {
228
368
  setVerbose(globals.verbose);
229
369
  setFields(globals.fields);
370
+ setGetField(globals.get);
230
371
  setColorsEnabled(globals.color);
372
+ _activeWorkspace = globals.workspace;
231
373
  }
232
374
  export async function withClient(cmd, fn) {
233
375
  const globals = getGlobals(cmd);
234
- applyGlobals(globals);
235
376
  try {
236
377
  const client = await createClient(globals);
237
378
  await fn(client, globals);
@@ -248,7 +389,6 @@ export async function withClient(cmd, fn) {
248
389
  */
249
390
  export async function runInline(cmd, fn) {
250
391
  const globals = getGlobals(cmd);
251
- applyGlobals(globals);
252
392
  try {
253
393
  await fn(globals);
254
394
  }
@@ -299,9 +439,61 @@ export function readJsonFileOrStdin(filePath) {
299
439
  process.stdin.on("error", reject);
300
440
  });
301
441
  }
442
+ /**
443
+ * Prompt for confirmation of a destructive action, or short-circuit when
444
+ * `--yes` is set. In `--json` mode without `--yes` we refuse with a usage
445
+ * error rather than silently proceeding or hanging on a prompt — agents
446
+ * piping through CLI must be explicit about destructive intent.
447
+ */
448
+ export async function confirmDestructive(prompt, opts) {
449
+ if (opts.yes)
450
+ return;
451
+ if (opts.json) {
452
+ const err = new Error(`--yes is required for destructive actions in --json mode. Refusing to proceed without explicit confirmation.`);
453
+ err.name = "ValidationError";
454
+ throw err;
455
+ }
456
+ if (!process.stdin.isTTY) {
457
+ const err = new Error(`--yes is required for destructive actions when stdin is not a TTY. Refusing to proceed without explicit confirmation.`);
458
+ err.name = "ValidationError";
459
+ throw err;
460
+ }
461
+ process.stderr.write(`${prompt} [y/N] `);
462
+ const answer = await new Promise((resolve, reject) => {
463
+ let data = "";
464
+ const onData = (chunk) => {
465
+ data += chunk.toString();
466
+ if (data.includes("\n")) {
467
+ process.stdin.off("data", onData);
468
+ process.stdin.off("error", onError);
469
+ process.stdin.pause();
470
+ resolve(data.trim().toLowerCase());
471
+ }
472
+ };
473
+ const onError = (err) => {
474
+ process.stdin.off("data", onData);
475
+ process.stdin.off("error", onError);
476
+ reject(err);
477
+ };
478
+ process.stdin.setEncoding("utf-8");
479
+ process.stdin.on("data", onData);
480
+ process.stdin.on("error", onError);
481
+ process.stdin.resume();
482
+ });
483
+ if (answer !== "y" && answer !== "yes") {
484
+ const err = new Error("Aborted.");
485
+ err.name = "ValidationError";
486
+ throw err;
487
+ }
488
+ }
302
489
  export function resolveWorkspace(explicit) {
303
490
  if (explicit)
304
491
  return resolveId(explicit);
492
+ // Fall back to the program-root --workspace cached by applyGlobals — covers
493
+ // `ish --workspace W study list` where the subcommand action doesn't see the
494
+ // flag in its local opts.
495
+ if (_activeWorkspace)
496
+ return resolveId(_activeWorkspace);
305
497
  const env = process.env.ISH_WORKSPACE;
306
498
  if (env)
307
499
  return resolveId(env);