@ishlabs/cli 0.8.3 → 0.8.4
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 +6 -0
- package/dist/auth.d.ts +1 -0
- package/dist/auth.js +12 -3
- package/dist/commands/ask.js +59 -16
- package/dist/commands/iteration.js +45 -11
- package/dist/commands/profile.js +65 -12
- package/dist/commands/study-run.js +49 -0
- package/dist/commands/study-tester.js +5 -2
- package/dist/commands/study.js +71 -16
- package/dist/connect.js +7 -7
- package/dist/index.js +119 -2
- package/dist/lib/api-client.js +29 -7
- package/dist/lib/command-helpers.d.ts +14 -0
- package/dist/lib/command-helpers.js +40 -0
- package/dist/lib/docs.js +430 -13
- package/dist/lib/output.js +437 -63
- package/dist/lib/skill-content.js +102 -9
- package/dist/lib/types.d.ts +3 -1
- package/dist/upgrade.js +3 -3
- package/package.json +1 -1
package/dist/commands/study.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ish study — Manage studies.
|
|
3
3
|
*/
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
4
5
|
import { withClient, getWebUrl, terminalLink, resolveWorkspace } from "../lib/command-helpers.js";
|
|
5
6
|
import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
|
|
6
7
|
import { loadConfig, saveConfig } from "../config.js";
|
|
@@ -76,7 +77,7 @@ Concept pages: ish docs get-page concepts/study
|
|
|
76
77
|
});
|
|
77
78
|
study
|
|
78
79
|
.command("create")
|
|
79
|
-
.description("Create a new study (the persistent shape: modality, tasks, questionnaire)")
|
|
80
|
+
.description("Create a new study (the persistent shape: modality, tasks, questionnaire). Optionally creates iteration A inline when --content-text or --url is passed.")
|
|
80
81
|
.option("--workspace <id>", "Workspace ID")
|
|
81
82
|
.requiredOption("--name <name>", "Study name")
|
|
82
83
|
.option("--description <description>", "Study description")
|
|
@@ -87,13 +88,15 @@ Concept pages: ish docs get-page concepts/study
|
|
|
87
88
|
.option("--assignments <json>", "Inline JSON array of assignments (escape hatch)")
|
|
88
89
|
.option("--question <text>", "Add a text question to the questionnaire (repeatable; type=text, timing=after)", collectRepeatable, [])
|
|
89
90
|
.option("--questionnaire <path>", "JSON file defining the questionnaire (supports text, slider, likert, single-choice, multiple-choice, number; timing=before|after)")
|
|
91
|
+
.option("--content-text <text>", "Text content to evaluate, or @filepath to read from file. Creates iteration A inline (text modality only)")
|
|
92
|
+
.option("--url <url>", "URL to test. Creates iteration A inline (interactive modality only)")
|
|
90
93
|
.addHelpText("after", `
|
|
91
94
|
Note: --workspace is optional if set via \`ish workspace use <alias>\`.
|
|
92
95
|
|
|
93
96
|
The questionnaire is the set of questions testers answer. Use \`--question\` to
|
|
94
97
|
quickly add simple text questions, or \`--questionnaire <file.json>\` for richer
|
|
95
|
-
types (slider, likert, choice) and custom
|
|
96
|
-
exclusive — pick one.
|
|
98
|
+
types (slider, likert, single-choice, multiple-choice, number) and custom
|
|
99
|
+
timing. The two forms are mutually exclusive — pick one.
|
|
97
100
|
|
|
98
101
|
Examples:
|
|
99
102
|
# Interactive study with one assignment and a single-question questionnaire:
|
|
@@ -145,6 +148,28 @@ Next: configure a run with \`ish iteration create --study <id>\`,
|
|
|
145
148
|
throw new ValidationError(`Invalid content type "${opts.contentType}" for modality "${opts.modality}".`, validTypes);
|
|
146
149
|
}
|
|
147
150
|
}
|
|
151
|
+
// Pattern E (cli half): build an inline iteration A when --content-text
|
|
152
|
+
// or --url is provided, so a single `study create` produces a study
|
|
153
|
+
// that's immediately runnable. Without these flags the backend
|
|
154
|
+
// creates zero iterations and the first `iteration create` becomes A.
|
|
155
|
+
let inlineIteration;
|
|
156
|
+
if (opts.contentText !== undefined) {
|
|
157
|
+
if (opts.modality && opts.modality !== "text") {
|
|
158
|
+
throw new Error(`--content-text is only valid with --modality text (got "${opts.modality}").`);
|
|
159
|
+
}
|
|
160
|
+
const text = opts.contentText.startsWith("@")
|
|
161
|
+
? readFileSync(opts.contentText.slice(1), "utf8")
|
|
162
|
+
: opts.contentText;
|
|
163
|
+
inlineIteration = { details: { type: "text", content_text: text } };
|
|
164
|
+
}
|
|
165
|
+
else if (opts.url !== undefined) {
|
|
166
|
+
if (opts.modality && opts.modality !== "interactive") {
|
|
167
|
+
throw new Error(`--url is only valid with --modality interactive (got "${opts.modality}").`);
|
|
168
|
+
}
|
|
169
|
+
inlineIteration = {
|
|
170
|
+
details: { type: "interactive", url: opts.url, platform: "browser" },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
148
173
|
const resolvedWs = resolveWorkspace(opts.workspace);
|
|
149
174
|
const body = {
|
|
150
175
|
product_id: resolvedWs,
|
|
@@ -154,6 +179,7 @@ Next: configure a run with \`ish iteration create --study <id>\`,
|
|
|
154
179
|
...(opts.contentType !== undefined && { content_type: opts.contentType }),
|
|
155
180
|
...(assignments && { assignments }),
|
|
156
181
|
...(interviewQuestions && { interview_questions: interviewQuestions }),
|
|
182
|
+
...(inlineIteration && { iteration: inlineIteration }),
|
|
157
183
|
};
|
|
158
184
|
const data = await client.post(`/products/${resolvedWs}/studies`, body);
|
|
159
185
|
if (data.id) {
|
|
@@ -198,20 +224,47 @@ Next: configure a run with \`ish iteration create --study <id>\`,
|
|
|
198
224
|
});
|
|
199
225
|
study
|
|
200
226
|
.command("get")
|
|
201
|
-
.description("Get study overview (
|
|
202
|
-
.argument("<
|
|
203
|
-
.addHelpText("after",
|
|
204
|
-
|
|
227
|
+
.description("Get study overview (accepts multiple IDs for batched lookup)")
|
|
228
|
+
.argument("<ids...>", "Study ID(s) — one or more aliases/UUIDs (space- or comma-separated)")
|
|
229
|
+
.addHelpText("after", `
|
|
230
|
+
Examples:
|
|
231
|
+
$ ish study get s-b2c
|
|
232
|
+
$ ish study get s-b2c --json
|
|
233
|
+
$ ish study get s-b2c s-d4e s-f0a
|
|
234
|
+
$ ish study get s-b2c,s-d4e --fields alias,name,modality,status
|
|
235
|
+
|
|
236
|
+
With multiple IDs, returns a {items:[...], total:N} envelope and uses the
|
|
237
|
+
list table layout in human mode.`)
|
|
238
|
+
.action(async (ids, _opts, cmd) => {
|
|
205
239
|
await withClient(cmd, async (client, globals) => {
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
240
|
+
const flat = ids.flatMap((s) => s.split(",").map((x) => x.trim()).filter(Boolean));
|
|
241
|
+
if (flat.length === 0)
|
|
242
|
+
throw new Error("Provide at least one study id.");
|
|
243
|
+
if (flat.length === 1) {
|
|
244
|
+
const rid = resolveId(flat[0]);
|
|
245
|
+
const data = await client.get(`/studies/${rid}`);
|
|
246
|
+
const result = data;
|
|
247
|
+
if (result.id)
|
|
248
|
+
result.alias = tagAlias(ALIAS_PREFIX.study, String(result.id));
|
|
249
|
+
formatStudyDetail(result, globals.json);
|
|
250
|
+
if (!globals.json && data.product_id) {
|
|
251
|
+
const url = getWebUrl(globals, `/${data.product_id}/${rid}/overview`);
|
|
252
|
+
console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
|
|
253
|
+
}
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const results = await Promise.all(flat.map(async (raw) => {
|
|
257
|
+
const data = await client.get(`/studies/${resolveId(raw)}`);
|
|
258
|
+
const r = data;
|
|
259
|
+
if (r.id)
|
|
260
|
+
r.alias = tagAlias(ALIAS_PREFIX.study, String(r.id));
|
|
261
|
+
return r;
|
|
262
|
+
}));
|
|
263
|
+
if (globals.json) {
|
|
264
|
+
output({ items: results, total: results.length }, true);
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
formatStudyList(results, false);
|
|
215
268
|
}
|
|
216
269
|
});
|
|
217
270
|
});
|
|
@@ -219,6 +272,7 @@ Next: configure a run with \`ish iteration create --study <id>\`,
|
|
|
219
272
|
.command("results")
|
|
220
273
|
.description("View aggregated results: tester counts, sentiment, interview answers. Returns a stable envelope with empty fields when no runs have completed.")
|
|
221
274
|
.argument("<id>", "Study ID")
|
|
275
|
+
.option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the study)")
|
|
222
276
|
.addHelpText("after", `
|
|
223
277
|
Examples:
|
|
224
278
|
$ ish study results <id>
|
|
@@ -319,6 +373,7 @@ Examples:
|
|
|
319
373
|
.command("delete")
|
|
320
374
|
.description("Delete a study")
|
|
321
375
|
.argument("<id>", "Study ID")
|
|
376
|
+
.option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the study)")
|
|
322
377
|
.addHelpText("after", "\nExamples:\n $ ish study delete <id>")
|
|
323
378
|
.action(async (id, _opts, cmd) => {
|
|
324
379
|
await withClient(cmd, async (client, globals) => {
|
package/dist/connect.js
CHANGED
|
@@ -348,7 +348,7 @@ function manualInstallInstructions() {
|
|
|
348
348
|
function startCloudflared(port, binPath, json) {
|
|
349
349
|
return new Promise((resolve, reject) => {
|
|
350
350
|
if (!json)
|
|
351
|
-
console.
|
|
351
|
+
console.error(`Connecting to localhost:${port}...`);
|
|
352
352
|
const proc = spawn(binPath, ["tunnel", "--url", `http://localhost:${port}`], {
|
|
353
353
|
stdio: ["ignore", "pipe", "pipe"],
|
|
354
354
|
});
|
|
@@ -368,7 +368,7 @@ function startCloudflared(port, binPath, json) {
|
|
|
368
368
|
resolve({ process: proc, tunnelUrl });
|
|
369
369
|
}
|
|
370
370
|
});
|
|
371
|
-
proc.on("exit", (
|
|
371
|
+
proc.on("exit", () => {
|
|
372
372
|
clearTimeout(timeout);
|
|
373
373
|
if (!tunnelUrl) {
|
|
374
374
|
reject(new Error("cloudflared exited unexpectedly."));
|
|
@@ -415,7 +415,7 @@ async function deregisterTunnel(apiUrl, token, json) {
|
|
|
415
415
|
console.log(JSON.stringify({ status: "disconnected" }));
|
|
416
416
|
}
|
|
417
417
|
else {
|
|
418
|
-
console.
|
|
418
|
+
console.error("Disconnected");
|
|
419
419
|
}
|
|
420
420
|
}
|
|
421
421
|
catch (e) {
|
|
@@ -455,7 +455,7 @@ function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal,
|
|
|
455
455
|
const newToken = await doRefresh();
|
|
456
456
|
onTokenRefreshed(newToken);
|
|
457
457
|
if (!json)
|
|
458
|
-
console.
|
|
458
|
+
console.error("Token refreshed.");
|
|
459
459
|
// Retry heartbeat with new token
|
|
460
460
|
const retry = await fetch(`${apiUrl}${API_BASE}/connect/heartbeat`, {
|
|
461
461
|
method: "POST",
|
|
@@ -514,7 +514,7 @@ function scheduleProactiveRefresh(token, doRefresh, onTokenRefreshed, json) {
|
|
|
514
514
|
const newToken = await doRefresh();
|
|
515
515
|
onTokenRefreshed(newToken);
|
|
516
516
|
if (!json)
|
|
517
|
-
console.
|
|
517
|
+
console.error("Token proactively refreshed.");
|
|
518
518
|
// Schedule next refresh for the new token
|
|
519
519
|
scheduleProactiveRefresh(newToken, doRefresh, onTokenRefreshed, json);
|
|
520
520
|
}
|
|
@@ -583,7 +583,7 @@ export async function runTunnel(port, tokenArg, apiUrlArg, tokenFileArg, outputO
|
|
|
583
583
|
process.exit(1);
|
|
584
584
|
shuttingDown = true;
|
|
585
585
|
if (!json)
|
|
586
|
-
console.
|
|
586
|
+
console.error("\nShutting down...");
|
|
587
587
|
heartbeat.stop();
|
|
588
588
|
proactiveRefresh.stop();
|
|
589
589
|
cfProcess.kill();
|
|
@@ -593,7 +593,7 @@ export async function runTunnel(port, tokenArg, apiUrlArg, tokenFileArg, outputO
|
|
|
593
593
|
process.on("SIGINT", shutdown);
|
|
594
594
|
process.on("SIGTERM", shutdown);
|
|
595
595
|
if (!json && !quiet) {
|
|
596
|
-
console.
|
|
596
|
+
console.error("Press Ctrl+C to disconnect.\n");
|
|
597
597
|
}
|
|
598
598
|
cfProcess.on("exit", async () => {
|
|
599
599
|
if (!shuttingDown) {
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { program, Option } from "commander";
|
|
3
3
|
import { runTunnel } from "./connect.js";
|
|
4
|
-
import { login, getAppUrl } from "./auth.js";
|
|
4
|
+
import { login, getAppUrl, decodeJwtClaims } from "./auth.js";
|
|
5
5
|
import { loadConfig, saveConfig } from "./config.js";
|
|
6
6
|
import { upgrade } from "./upgrade.js";
|
|
7
7
|
import { registerWorkspaceCommands } from "./commands/workspace.js";
|
|
@@ -14,8 +14,12 @@ import { registerAskCommands } from "./commands/ask.js";
|
|
|
14
14
|
import { registerDocsCommands } from "./commands/docs.js";
|
|
15
15
|
import { registerInitCommands } from "./commands/init.js";
|
|
16
16
|
import { AGENT_HELP_FOOTER } from "./lib/docs.js";
|
|
17
|
-
import { runInline, EXIT_USAGE } from "./lib/command-helpers.js";
|
|
17
|
+
import { runInline, EXIT_USAGE, injectGlobalWorkspaceOption } from "./lib/command-helpers.js";
|
|
18
|
+
import { resolveApiUrl, resolveToken } from "./lib/auth.js";
|
|
19
|
+
import { ApiClient } from "./lib/api-client.js";
|
|
20
|
+
import { tagAlias, ALIAS_PREFIX } from "./lib/alias-store.js";
|
|
18
21
|
import { output } from "./lib/output.js";
|
|
22
|
+
import { ishDir } from "./lib/paths.js";
|
|
19
23
|
import pkg from "../package.json" with { type: "json" };
|
|
20
24
|
const { version } = pkg;
|
|
21
25
|
program
|
|
@@ -98,6 +102,118 @@ program
|
|
|
98
102
|
output({ message: "Logged out" }, globals.json);
|
|
99
103
|
});
|
|
100
104
|
});
|
|
105
|
+
program
|
|
106
|
+
.command("status")
|
|
107
|
+
.alias("whoami")
|
|
108
|
+
.description("Show active session — user, workspace, study, ask")
|
|
109
|
+
.addHelpText("after", "\nFirst command to run when starting cold. Outputs the active workspace,\nstudy, and ask handles plus token validity. Doesn't error on no-token —\nreturns user: null with a hint instead. JSON safe for piping.")
|
|
110
|
+
.action(async (_opts, cmd) => {
|
|
111
|
+
await runInline(cmd, async (globals) => {
|
|
112
|
+
const apiUrl = resolveApiUrl(globals.apiUrl, globals.dev);
|
|
113
|
+
const config = loadConfig();
|
|
114
|
+
// Try to resolve a token; on auth failure, return a no-token status
|
|
115
|
+
// instead of throwing so the command works pre-login.
|
|
116
|
+
let token;
|
|
117
|
+
let tokenError;
|
|
118
|
+
try {
|
|
119
|
+
token = await resolveToken(globals.token, apiUrl, globals.tokenFile);
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
tokenError = err instanceof Error ? err.message : String(err);
|
|
123
|
+
}
|
|
124
|
+
let user = null;
|
|
125
|
+
if (token) {
|
|
126
|
+
const claims = decodeJwtClaims(token);
|
|
127
|
+
const exp = typeof claims?.exp === "number" ? claims.exp : 0;
|
|
128
|
+
const expiresIn = exp ? Math.max(0, exp - Math.floor(Date.now() / 1000)) : 0;
|
|
129
|
+
user = {
|
|
130
|
+
email: typeof claims?.email === "string" ? claims.email : null,
|
|
131
|
+
token_valid: expiresIn > 0,
|
|
132
|
+
expires_in_seconds: expiresIn,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
// Resolve names of active resources. Failures are non-fatal — we still
|
|
136
|
+
// return the saved IDs so the user knows what's configured.
|
|
137
|
+
const client = token ? new ApiClient({ apiUrl, token }) : null;
|
|
138
|
+
let workspace = null;
|
|
139
|
+
if (config.workspace) {
|
|
140
|
+
workspace = {
|
|
141
|
+
id: config.workspace,
|
|
142
|
+
alias: tagAlias(ALIAS_PREFIX.workspace, config.workspace),
|
|
143
|
+
};
|
|
144
|
+
if (client) {
|
|
145
|
+
try {
|
|
146
|
+
const ws = await client.get(`/products/${config.workspace}`);
|
|
147
|
+
if (ws?.name)
|
|
148
|
+
workspace.name = ws.name;
|
|
149
|
+
}
|
|
150
|
+
catch { /* keep id+alias only */ }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
let study = null;
|
|
154
|
+
if (config.study) {
|
|
155
|
+
study = {
|
|
156
|
+
id: config.study,
|
|
157
|
+
alias: tagAlias(ALIAS_PREFIX.study, config.study),
|
|
158
|
+
};
|
|
159
|
+
if (client) {
|
|
160
|
+
try {
|
|
161
|
+
const s = await client.get(`/studies/${config.study}`);
|
|
162
|
+
if (s?.name)
|
|
163
|
+
study.name = s.name;
|
|
164
|
+
}
|
|
165
|
+
catch { /* keep id+alias only */ }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
let ask = null;
|
|
169
|
+
if (config.ask) {
|
|
170
|
+
ask = {
|
|
171
|
+
id: config.ask,
|
|
172
|
+
alias: tagAlias(ALIAS_PREFIX.ask, config.ask),
|
|
173
|
+
};
|
|
174
|
+
if (client) {
|
|
175
|
+
try {
|
|
176
|
+
const a = await client.get(`/asks/${config.ask}`);
|
|
177
|
+
if (a?.name)
|
|
178
|
+
ask.name = a.name;
|
|
179
|
+
}
|
|
180
|
+
catch { /* keep id+alias only */ }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const payload = {
|
|
184
|
+
user,
|
|
185
|
+
workspace,
|
|
186
|
+
study,
|
|
187
|
+
ask,
|
|
188
|
+
api_url: apiUrl,
|
|
189
|
+
home: ishDir(),
|
|
190
|
+
};
|
|
191
|
+
if (tokenError && !token)
|
|
192
|
+
payload.hint = `Run \`ish login\`. (${tokenError})`;
|
|
193
|
+
if (globals.json) {
|
|
194
|
+
output(payload, true);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
// Human output
|
|
198
|
+
const fmtSeconds = (s) => {
|
|
199
|
+
if (s <= 0)
|
|
200
|
+
return "expired";
|
|
201
|
+
const m = Math.floor(s / 60);
|
|
202
|
+
const h = Math.floor(m / 60);
|
|
203
|
+
if (h > 0)
|
|
204
|
+
return `${h}h${m % 60}m`;
|
|
205
|
+
return `${m}m`;
|
|
206
|
+
};
|
|
207
|
+
console.log(`User: ${user ? `${user.email ?? "(no email)"} (token ${user.token_valid ? "valid" : "expired"}, expires in ${fmtSeconds(user.expires_in_seconds)})` : "(not logged in — run `ish login`)"}`);
|
|
208
|
+
console.log(`Workspace: ${workspace ? `${workspace.name ?? "(name unavailable)"} (${workspace.alias})` : "—"}`);
|
|
209
|
+
console.log(`Study: ${study ? `${study.name ?? "(name unavailable)"} (${study.alias})` : "—"}`);
|
|
210
|
+
console.log(`Ask: ${ask ? `${ask.name ?? "(name unavailable)"} (${ask.alias})` : "—"}`);
|
|
211
|
+
console.log(`Home: ${ishDir()}`);
|
|
212
|
+
console.log(`API: ${apiUrl}`);
|
|
213
|
+
if (tokenError && !token)
|
|
214
|
+
console.error(`\nHint: ${payload.hint}`);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
101
217
|
program
|
|
102
218
|
.command("connect")
|
|
103
219
|
.description("Expose your localhost to Ish via a Cloudflare tunnel")
|
|
@@ -134,4 +250,5 @@ program
|
|
|
134
250
|
.action(async (options) => {
|
|
135
251
|
await upgrade(version, options.release);
|
|
136
252
|
});
|
|
253
|
+
injectGlobalWorkspaceOption(program);
|
|
137
254
|
program.parse();
|
package/dist/lib/api-client.js
CHANGED
|
@@ -31,15 +31,30 @@ export class ApiError extends Error {
|
|
|
31
31
|
error_code;
|
|
32
32
|
retryable;
|
|
33
33
|
constructor(status, statusText, body) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
// FastAPI HTTPException(detail=...) wraps detail under a top-level "detail" key.
|
|
35
|
+
// When detail is a structured object (our convention for typed errors),
|
|
36
|
+
// pull the message off detail.detail and prefer detail.error_code.
|
|
37
|
+
let detail;
|
|
38
|
+
let bodyErrorCode;
|
|
39
|
+
if (typeof body === "object" && body !== null && "detail" in body) {
|
|
40
|
+
detail = body.detail;
|
|
41
|
+
if (typeof detail === "object" && detail !== null && "error_code" in detail
|
|
42
|
+
&& typeof detail.error_code === "string") {
|
|
43
|
+
bodyErrorCode = detail.error_code;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const detailMsg = typeof detail === "string"
|
|
47
|
+
? detail
|
|
48
|
+
: (typeof detail === "object" && detail !== null && "detail" in detail
|
|
49
|
+
? String(detail.detail)
|
|
50
|
+
: undefined);
|
|
51
|
+
const msg = detailMsg ?? `HTTP ${status} ${statusText}`;
|
|
37
52
|
super(msg);
|
|
38
53
|
this.status = status;
|
|
39
54
|
this.statusText = statusText;
|
|
40
55
|
this.body = body;
|
|
41
56
|
this.name = "ApiError";
|
|
42
|
-
this.error_code = mapErrorCode(status);
|
|
57
|
+
this.error_code = bodyErrorCode ?? mapErrorCode(status);
|
|
43
58
|
this.retryable = isRetryable(status);
|
|
44
59
|
}
|
|
45
60
|
}
|
|
@@ -176,7 +191,9 @@ export class ApiClient {
|
|
|
176
191
|
return this.handleResponse(res);
|
|
177
192
|
}
|
|
178
193
|
async del(path, opts) {
|
|
179
|
-
|
|
194
|
+
// Deletes typically cascade (rounds → responses → testers → audience),
|
|
195
|
+
// so default to a longer timeout than the standard 15s.
|
|
196
|
+
const timeout = opts?.timeout ?? 60_000;
|
|
180
197
|
const url = `${this.baseUrl}${path}`;
|
|
181
198
|
let res;
|
|
182
199
|
try {
|
|
@@ -187,8 +204,13 @@ export class ApiClient {
|
|
|
187
204
|
});
|
|
188
205
|
}
|
|
189
206
|
catch (err) {
|
|
190
|
-
if (isAbortTimeout(err))
|
|
191
|
-
|
|
207
|
+
if (isAbortTimeout(err)) {
|
|
208
|
+
const seconds = Math.round(timeout / 1000);
|
|
209
|
+
throw new ApiError(408, "Request Timeout", {
|
|
210
|
+
detail: `DELETE request timed out after ${seconds}s. The deletion may have completed server-side — ` +
|
|
211
|
+
`re-fetch the resource (e.g. \`ish ask get <id>\`) before retrying to avoid a 404.`,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
192
214
|
if (err instanceof TypeError)
|
|
193
215
|
throw networkError(url);
|
|
194
216
|
throw err;
|
|
@@ -100,3 +100,17 @@ export declare function collectRepeatable(value: string, prev?: string[]): strin
|
|
|
100
100
|
export declare function collectIds(value: string, prev?: string[]): string[];
|
|
101
101
|
/** Parse a `--timeout <seconds>` flag into milliseconds, with validation. */
|
|
102
102
|
export declare function parseWaitTimeout(raw: string | undefined, defaultMs?: number): number;
|
|
103
|
+
/**
|
|
104
|
+
* Inject `--workspace <id>` on every leaf subcommand under the workspace-scoped
|
|
105
|
+
* groups that doesn't already declare it. Run once after all `registerXxxCommands`
|
|
106
|
+
* calls have populated the program tree. Agents reflexively pass `--workspace`
|
|
107
|
+
* on any command — without this, Commander rejects it with a generic "unknown
|
|
108
|
+
* option" on read-side commands like `ask delete`, `ask get`, `study get`,
|
|
109
|
+
* `profile get`, etc.
|
|
110
|
+
*
|
|
111
|
+
* The injected option is documentation-only on commands where the workspace is
|
|
112
|
+
* inferred from an ID alias; it's accepted and then discarded by the action
|
|
113
|
+
* body. Resolvers (`resolveWorkspace`, `resolveAudienceProfileIds`) ignore
|
|
114
|
+
* unused values.
|
|
115
|
+
*/
|
|
116
|
+
export declare function injectGlobalWorkspaceOption(program: Command): void;
|
|
@@ -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
|
}
|
|
@@ -355,3 +358,40 @@ export function parseWaitTimeout(raw, defaultMs = 5 * 60 * 1000) {
|
|
|
355
358
|
}
|
|
356
359
|
return n * 1000;
|
|
357
360
|
}
|
|
361
|
+
/** Top-level command groups whose subcommands should accept `--workspace`. */
|
|
362
|
+
const WORKSPACE_SCOPED_GROUPS = new Set([
|
|
363
|
+
"ask",
|
|
364
|
+
"study",
|
|
365
|
+
"iteration",
|
|
366
|
+
"profile",
|
|
367
|
+
"source",
|
|
368
|
+
]);
|
|
369
|
+
/**
|
|
370
|
+
* Inject `--workspace <id>` on every leaf subcommand under the workspace-scoped
|
|
371
|
+
* groups that doesn't already declare it. Run once after all `registerXxxCommands`
|
|
372
|
+
* calls have populated the program tree. Agents reflexively pass `--workspace`
|
|
373
|
+
* on any command — without this, Commander rejects it with a generic "unknown
|
|
374
|
+
* option" on read-side commands like `ask delete`, `ask get`, `study get`,
|
|
375
|
+
* `profile get`, etc.
|
|
376
|
+
*
|
|
377
|
+
* The injected option is documentation-only on commands where the workspace is
|
|
378
|
+
* inferred from an ID alias; it's accepted and then discarded by the action
|
|
379
|
+
* body. Resolvers (`resolveWorkspace`, `resolveAudienceProfileIds`) ignore
|
|
380
|
+
* unused values.
|
|
381
|
+
*/
|
|
382
|
+
export function injectGlobalWorkspaceOption(program) {
|
|
383
|
+
const walk = (cmd) => {
|
|
384
|
+
if (cmd.commands.length === 0) {
|
|
385
|
+
const hasWorkspace = cmd.options.some((o) => o.long === "--workspace");
|
|
386
|
+
if (!hasWorkspace) {
|
|
387
|
+
cmd.option("--workspace <id>", "Workspace ID; accepted for consistency (inferred from alias / active context)");
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
for (const sub of cmd.commands)
|
|
391
|
+
walk(sub);
|
|
392
|
+
};
|
|
393
|
+
for (const top of program.commands) {
|
|
394
|
+
if (WORKSPACE_SCOPED_GROUPS.has(top.name()))
|
|
395
|
+
walk(top);
|
|
396
|
+
}
|
|
397
|
+
}
|