@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.
- package/README.md +7 -1
- package/dist/auth.d.ts +16 -0
- package/dist/auth.js +52 -3
- package/dist/commands/ask.js +86 -17
- package/dist/commands/iteration.js +45 -11
- package/dist/commands/profile.js +79 -13
- package/dist/commands/study-run.js +49 -0
- package/dist/commands/study-tester.js +5 -2
- package/dist/commands/study.js +82 -19
- package/dist/connect.js +94 -19
- package/dist/index.js +122 -2
- package/dist/lib/api-client.js +29 -7
- package/dist/lib/command-helpers.d.ts +51 -0
- package/dist/lib/command-helpers.js +206 -7
- package/dist/lib/docs.js +621 -30
- package/dist/lib/output.d.ts +6 -0
- package/dist/lib/output.js +570 -65
- package/dist/lib/skill-content.js +216 -9
- package/dist/lib/types.d.ts +3 -1
- package/dist/upgrade.js +3 -3
- package/package.json +1 -1
|
@@ -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}
|
|
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
|
-
//
|
|
167
|
-
|
|
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
|
-
|
|
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
|
+
}
|