@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.
- package/README.md +2 -2
- package/dist/auth.d.ts +38 -4
- package/dist/auth.js +205 -39
- package/dist/commands/ask.js +28 -2
- package/dist/commands/iteration.js +105 -6
- package/dist/commands/profile.js +25 -12
- package/dist/commands/source.js +24 -2
- package/dist/commands/study.js +14 -6
- package/dist/config.d.ts +4 -0
- package/dist/connect.js +100 -14
- package/dist/index.js +6 -3
- package/dist/lib/auth.js +7 -1
- package/dist/lib/command-helpers.d.ts +37 -0
- package/dist/lib/command-helpers.js +199 -7
- package/dist/lib/docs.js +316 -39
- package/dist/lib/output.d.ts +6 -0
- package/dist/lib/output.js +133 -2
- package/dist/lib/skill-content.js +120 -6
- package/package.json +3 -3
|
@@ -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}
|
|
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
|
-
//
|
|
170
|
-
|
|
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
|
-
|
|
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);
|