@ishlabs/cli 0.8.5 → 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.
Files changed (39) hide show
  1. package/README.md +55 -6
  2. package/dist/auth.d.ts +23 -4
  3. package/dist/auth.js +165 -39
  4. package/dist/commands/ask.d.ts +12 -0
  5. package/dist/commands/ask.js +127 -2
  6. package/dist/commands/chat.d.ts +17 -0
  7. package/dist/commands/chat.js +589 -0
  8. package/dist/commands/iteration.js +232 -13
  9. package/dist/commands/secret.d.ts +20 -0
  10. package/dist/commands/secret.js +246 -0
  11. package/dist/commands/source.js +24 -2
  12. package/dist/commands/study-run.d.ts +38 -0
  13. package/dist/commands/study-run.js +199 -80
  14. package/dist/commands/study-tester.js +17 -2
  15. package/dist/commands/study.js +311 -39
  16. package/dist/commands/workspace.js +81 -0
  17. package/dist/config.d.ts +7 -0
  18. package/dist/connect.d.ts +3 -0
  19. package/dist/connect.js +359 -24
  20. package/dist/index.js +67 -9
  21. package/dist/lib/alias-hydrate.d.ts +42 -0
  22. package/dist/lib/alias-hydrate.js +175 -0
  23. package/dist/lib/alias-store.d.ts +1 -0
  24. package/dist/lib/alias-store.js +28 -1
  25. package/dist/lib/auth.js +11 -3
  26. package/dist/lib/chat-endpoint-formatters.d.ts +39 -0
  27. package/dist/lib/chat-endpoint-formatters.js +104 -0
  28. package/dist/lib/command-helpers.d.ts +18 -0
  29. package/dist/lib/command-helpers.js +188 -53
  30. package/dist/lib/docs.js +662 -34
  31. package/dist/lib/modality.d.ts +42 -0
  32. package/dist/lib/modality.js +192 -0
  33. package/dist/lib/output.d.ts +41 -0
  34. package/dist/lib/output.js +453 -19
  35. package/dist/lib/paths.d.ts +1 -0
  36. package/dist/lib/paths.js +3 -0
  37. package/dist/lib/skill-content.js +183 -13
  38. package/dist/lib/types.d.ts +15 -0
  39. package/package.json +3 -3
@@ -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,56 +200,18 @@ 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
  }
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
- }
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
+ });
182
215
  if (filterDesc) {
183
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.`);
184
217
  }
@@ -315,6 +348,20 @@ export function exitCodeFromError(err) {
315
348
  // Client-side validation failures
316
349
  if (err.name === "ValidationError" || /^invalid |^cannot read |is empty:|--\w[\w-]* must be|pick an audience|use either /i.test(err.message))
317
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;
318
365
  }
319
366
  return 1;
320
367
  }
@@ -406,6 +453,18 @@ export function readJsonFileOrStdin(filePath) {
406
453
  process.stdin.on("error", reject);
407
454
  });
408
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
+ }
409
468
  /**
410
469
  * Prompt for confirmation of a destructive action, or short-circuit when
411
470
  * `--yes` is set. In `--json` mode without `--yes` we refuse with a usage
@@ -418,11 +477,15 @@ export async function confirmDestructive(prompt, opts) {
418
477
  if (opts.json) {
419
478
  const err = new Error(`--yes is required for destructive actions in --json mode. Refusing to proceed without explicit confirmation.`);
420
479
  err.name = "ValidationError";
480
+ err.error_kind = "ConfirmationRequired";
481
+ err.example = buildConfirmationExample();
421
482
  throw err;
422
483
  }
423
484
  if (!process.stdin.isTTY) {
424
485
  const err = new Error(`--yes is required for destructive actions when stdin is not a TTY. Refusing to proceed without explicit confirmation.`);
425
486
  err.name = "ValidationError";
487
+ err.error_kind = "ConfirmationRequired";
488
+ err.example = buildConfirmationExample();
426
489
  throw err;
427
490
  }
428
491
  process.stderr.write(`${prompt} [y/N] `);
@@ -453,6 +516,23 @@ export async function confirmDestructive(prompt, opts) {
453
516
  throw err;
454
517
  }
455
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
+ }
456
536
  export function resolveWorkspace(explicit) {
457
537
  if (explicit)
458
538
  return resolveId(explicit);
@@ -467,7 +547,10 @@ export function resolveWorkspace(explicit) {
467
547
  const config = loadConfig();
468
548
  if (config.workspace)
469
549
  return config.workspace;
470
- 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
+ ]);
471
554
  }
472
555
  export function resolveStudy(explicit) {
473
556
  if (explicit)
@@ -478,7 +561,10 @@ export function resolveStudy(explicit) {
478
561
  const config = loadConfig();
479
562
  if (config.study)
480
563
  return config.study;
481
- 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
+ ]);
482
568
  }
483
569
  export function resolveAsk(explicit) {
484
570
  if (explicit)
@@ -489,7 +575,28 @@ export function resolveAsk(explicit) {
489
575
  const config = loadConfig();
490
576
  if (config.ask)
491
577
  return config.ask;
492
- 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.');
493
600
  }
494
601
  /** Commander option-collector for repeatable flags (e.g. `--variant text:"..."` repeated). */
495
602
  export function collectRepeatable(value, prev = []) {
@@ -538,6 +645,34 @@ const WORKSPACE_SCOPED_GROUPS = new Set([
538
645
  * body. Resolvers (`resolveWorkspace`, `resolveAudienceProfileIds`) ignore
539
646
  * unused values.
540
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
+ }
541
676
  export function injectGlobalWorkspaceOption(program) {
542
677
  const walk = (cmd) => {
543
678
  if (cmd.commands.length === 0) {