@ishlabs/cli 0.9.0 → 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.
- package/README.md +54 -5
- package/dist/commands/ask.d.ts +12 -0
- package/dist/commands/ask.js +127 -2
- package/dist/commands/chat.d.ts +17 -0
- package/dist/commands/chat.js +589 -0
- package/dist/commands/iteration.js +134 -14
- package/dist/commands/secret.d.ts +20 -0
- package/dist/commands/secret.js +246 -0
- package/dist/commands/study-run.d.ts +38 -0
- package/dist/commands/study-run.js +199 -80
- package/dist/commands/study-tester.js +17 -2
- package/dist/commands/study.js +309 -37
- package/dist/commands/workspace.js +81 -0
- package/dist/config.d.ts +3 -0
- package/dist/connect.d.ts +3 -0
- package/dist/connect.js +346 -22
- package/dist/index.js +64 -6
- package/dist/lib/alias-hydrate.d.ts +42 -0
- package/dist/lib/alias-hydrate.js +175 -0
- package/dist/lib/alias-store.d.ts +1 -0
- package/dist/lib/alias-store.js +28 -1
- package/dist/lib/auth.js +4 -2
- package/dist/lib/chat-endpoint-formatters.d.ts +39 -0
- package/dist/lib/chat-endpoint-formatters.js +104 -0
- package/dist/lib/command-helpers.d.ts +18 -0
- package/dist/lib/command-helpers.js +105 -3
- package/dist/lib/docs.js +542 -17
- package/dist/lib/modality.d.ts +42 -0
- package/dist/lib/modality.js +192 -0
- package/dist/lib/output.d.ts +41 -0
- package/dist/lib/output.js +453 -19
- package/dist/lib/paths.d.ts +1 -0
- package/dist/lib/paths.js +3 -0
- package/dist/lib/skill-content.js +182 -12
- package/dist/lib/types.d.ts +15 -0
- package/package.json +1 -1
|
@@ -5,13 +5,35 @@
|
|
|
5
5
|
* content/file for media). Create one before `ish study run` dispatches
|
|
6
6
|
* simulations against it.
|
|
7
7
|
*/
|
|
8
|
-
import { withClient, resolveStudy, resolveWorkspace } from "../lib/command-helpers.js";
|
|
8
|
+
import { withClient, resolveStudy, resolveWorkspace, readFileOrStdin } from "../lib/command-helpers.js";
|
|
9
9
|
import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
|
|
10
|
-
import { output, formatIterationList } from "../lib/output.js";
|
|
10
|
+
import { output, formatIterationList, ValidationError } from "../lib/output.js";
|
|
11
11
|
import { resolveContentUrl, resolveContentUrls, resolveTextContent } from "../lib/upload.js";
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
import { isMediaModality, validateIterationDetails } from "../lib/modality.js";
|
|
13
|
+
/**
|
|
14
|
+
* Pattern C / M12: project each tester row on an iteration response so its
|
|
15
|
+
* `alias` and `name` survive `leanJson` (which strips raw UUID values). The
|
|
16
|
+
* existing `id` and `tester_profile` payload still pass through under
|
|
17
|
+
* `--verbose`, but the agent-default `--json` shape now exposes the same
|
|
18
|
+
* `{alias, name, status}` triple `study results` already does, so an agent
|
|
19
|
+
* can correlate testers across the two verbs.
|
|
20
|
+
*/
|
|
21
|
+
function enrichIterationTesters(iteration) {
|
|
22
|
+
const testers = iteration.testers;
|
|
23
|
+
if (!Array.isArray(testers))
|
|
24
|
+
return;
|
|
25
|
+
for (const t of testers) {
|
|
26
|
+
if (!t || typeof t !== "object")
|
|
27
|
+
continue;
|
|
28
|
+
const tester = t;
|
|
29
|
+
const id = tester.id ? String(tester.id) : "";
|
|
30
|
+
if (id)
|
|
31
|
+
tester.alias = tagAlias(ALIAS_PREFIX.tester, id);
|
|
32
|
+
const profile = tester.tester_profile;
|
|
33
|
+
if (!tester.name) {
|
|
34
|
+
tester.name = profile?.name ?? tester.instance_name ?? null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
15
37
|
}
|
|
16
38
|
function buildCopyContent(opts) {
|
|
17
39
|
if (!opts.copyText)
|
|
@@ -230,10 +252,17 @@ Concept pages: ish docs get-page concepts/iteration
|
|
|
230
252
|
.option("--segmentation-json <json>", "Segmentation JSON — time_based {intervals_seconds, labels?}, section_based {sections[{name,label,...}]}, or page_based {} — media modalities")
|
|
231
253
|
.option("--content-config-json <json>", "Content config JSON — {early_termination, selected_segment_indices?} — media modalities")
|
|
232
254
|
// Chat modality
|
|
233
|
-
.option("--chat-endpoint-id <id>", "Saved chatbot endpoint id — chat modality")
|
|
234
|
-
.option("--chat-endpoint-json <json>", "Inline chatbot endpoint config JSON — chat modality")
|
|
235
|
-
.option("--max-turns <n>", "Max tester turns (1-50) — chat modality")
|
|
255
|
+
.option("--chat-endpoint-id <id>", "Saved chatbot endpoint id — chat modality (legacy; prefer --endpoint)")
|
|
256
|
+
.option("--chat-endpoint-json <json>", "Inline chatbot endpoint config JSON — chat modality (legacy; prefer --endpoint-config)")
|
|
257
|
+
.option("--max-turns <n>", "Max tester turns (1-50) — chat modality (default 12)")
|
|
236
258
|
.option("--early-termination", "End the chat session early when the tester signals stop — chat modality")
|
|
259
|
+
// Agent-friendly chat shortcuts: --endpoint <id> resolves a saved
|
|
260
|
+
// chatbot endpoint via alias / UUID and fetches its config inline;
|
|
261
|
+
// --endpoint-config <file> takes a raw config file (or `-` for
|
|
262
|
+
// stdin). Mutually exclusive with each other and with the legacy
|
|
263
|
+
// --chat-endpoint-* flags. Only meaningful for chat-modality studies.
|
|
264
|
+
.option("--endpoint <id>", "Saved chatbot endpoint id (alias or UUID) — chat modality")
|
|
265
|
+
.option("--endpoint-config <file>", "Raw ChatbotEndpointConfig JSON file or `-` for stdin — chat modality")
|
|
237
266
|
// Escape hatch
|
|
238
267
|
.option("--details-json <json>", "Raw iteration details JSON (overrides individual flags)")
|
|
239
268
|
.addHelpText("after", `
|
|
@@ -284,8 +313,15 @@ Examples:
|
|
|
284
313
|
--screen-format mobile_portrait --file-key abc123 --start-node-id 0:1 \\
|
|
285
314
|
--flow-name "Onboarding A"
|
|
286
315
|
|
|
287
|
-
# Chat (
|
|
288
|
-
$ ish iteration create --
|
|
316
|
+
# Chat — saved endpoint (the parent study's modality determines the type):
|
|
317
|
+
$ ish iteration create --study s-b2c --endpoint ep-abc --max-turns 6
|
|
318
|
+
|
|
319
|
+
# Chat — inline endpoint config (file or stdin):
|
|
320
|
+
$ ish iteration create --study s-b2c --endpoint-config ./bot.json
|
|
321
|
+
$ cat ./bot.json | ish iteration create --study s-b2c --endpoint-config -
|
|
322
|
+
|
|
323
|
+
# Chat — legacy flags still work:
|
|
324
|
+
$ ish iteration create --chat-endpoint-id ce-abc --max-turns 10 --early-termination
|
|
289
325
|
|
|
290
326
|
# Raw JSON escape hatch (overrides individual flags):
|
|
291
327
|
$ ish iteration create --study s-b2c --details-json \\
|
|
@@ -297,9 +333,13 @@ workspace's public storage bucket. Validation now happens before upload.
|
|
|
297
333
|
Next: \`ish study run\` to dispatch simulations against this iteration.`)
|
|
298
334
|
.action(async (opts, cmd) => {
|
|
299
335
|
await withClient(cmd, async (client, globals) => {
|
|
336
|
+
if (opts.endpoint !== undefined && opts.endpointConfig !== undefined) {
|
|
337
|
+
throw new ValidationError("Pass only one of: --endpoint, --endpoint-config.", ["--endpoint", "--endpoint-config"]);
|
|
338
|
+
}
|
|
300
339
|
if (opts.workspace)
|
|
301
340
|
resolveWorkspace(opts.workspace);
|
|
302
341
|
const studyId = resolveStudy(opts.study);
|
|
342
|
+
let chatbotEndpointIdForOutput = null;
|
|
303
343
|
let details;
|
|
304
344
|
if (opts.detailsJson) {
|
|
305
345
|
try {
|
|
@@ -321,6 +361,9 @@ Next: \`ish study run\` to dispatch simulations against this iteration.`)
|
|
|
321
361
|
if (!isMedia && !isChat && (opts.contentText || opts.contentUrl || opts.imageUrls)) {
|
|
322
362
|
throw new Error(`This study uses "interactive" modality — --content-text, --content-url, and --image-urls are for media studies. Use --url instead.`);
|
|
323
363
|
}
|
|
364
|
+
if (!isChat && (opts.endpoint !== undefined || opts.endpointConfig !== undefined)) {
|
|
365
|
+
throw new Error(`This study uses "${modality}" modality — --endpoint / --endpoint-config are for chat studies.`);
|
|
366
|
+
}
|
|
324
367
|
// Validate per-modality required flags BEFORE any upload so we don't
|
|
325
368
|
// orphan blobs in storage when the wrong flag is passed (e.g.
|
|
326
369
|
// --content-url to an image-modality study).
|
|
@@ -347,8 +390,15 @@ Next: \`ish study run\` to dispatch simulations against this iteration.`)
|
|
|
347
390
|
}
|
|
348
391
|
break;
|
|
349
392
|
case "chat":
|
|
350
|
-
if (!opts.chatEndpointId
|
|
351
|
-
|
|
393
|
+
if (!opts.chatEndpointId
|
|
394
|
+
&& !opts.chatEndpointJson
|
|
395
|
+
&& !opts.endpoint
|
|
396
|
+
&& !opts.endpointConfig) {
|
|
397
|
+
throw new Error("Chat iterations require one of: --endpoint <id>, --endpoint-config <file>, --chat-endpoint-id, or --chat-endpoint-json.");
|
|
398
|
+
}
|
|
399
|
+
if ((opts.endpoint || opts.endpointConfig)
|
|
400
|
+
&& (opts.chatEndpointId || opts.chatEndpointJson)) {
|
|
401
|
+
throw new ValidationError("Pass only one of: --endpoint / --endpoint-config (preferred) or the legacy --chat-endpoint-id / --chat-endpoint-json.", ["--endpoint", "--endpoint-config"]);
|
|
352
402
|
}
|
|
353
403
|
break;
|
|
354
404
|
default:
|
|
@@ -374,7 +424,55 @@ Next: \`ish study run\` to dispatch simulations against this iteration.`)
|
|
|
374
424
|
resolved.imageUrls = urls.join(",");
|
|
375
425
|
}
|
|
376
426
|
}
|
|
377
|
-
|
|
427
|
+
if (isChat && (opts.endpoint || opts.endpointConfig)) {
|
|
428
|
+
// Agent-friendly path: fetch a saved endpoint by alias / UUID
|
|
429
|
+
// (or read a raw config from a file / stdin) and embed the full
|
|
430
|
+
// ChatbotEndpointConfig inline at iteration.details.endpoint.
|
|
431
|
+
// Backend's chat iteration shape is wider than what
|
|
432
|
+
// buildIterationDetails covers; we materialise it here.
|
|
433
|
+
let endpointConfig;
|
|
434
|
+
let chatbotEndpointId = null;
|
|
435
|
+
if (opts.endpoint) {
|
|
436
|
+
const id = resolveId(opts.endpoint);
|
|
437
|
+
const ep = await client.get(`/chatbot-endpoints/${id}`);
|
|
438
|
+
endpointConfig = ep.config;
|
|
439
|
+
chatbotEndpointId = id;
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
const raw = await readFileOrStdin(opts.endpointConfig);
|
|
443
|
+
try {
|
|
444
|
+
const parsed = JSON.parse(raw);
|
|
445
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
446
|
+
throw new Error("expected a JSON object");
|
|
447
|
+
}
|
|
448
|
+
endpointConfig = parsed;
|
|
449
|
+
}
|
|
450
|
+
catch (err) {
|
|
451
|
+
const reason = err instanceof Error ? err.message : "invalid JSON";
|
|
452
|
+
throw new Error(`Invalid --endpoint-config: ${reason}`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
let maxTurns = 12;
|
|
456
|
+
if (opts.maxTurns !== undefined) {
|
|
457
|
+
const parsed = Number.parseInt(opts.maxTurns, 10);
|
|
458
|
+
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
459
|
+
throw new Error("Invalid --max-turns: expected a positive integer");
|
|
460
|
+
}
|
|
461
|
+
maxTurns = parsed;
|
|
462
|
+
}
|
|
463
|
+
const earlyTermination = opts.earlyTermination !== undefined ? opts.earlyTermination : true;
|
|
464
|
+
details = {
|
|
465
|
+
type: "chat",
|
|
466
|
+
endpoint: endpointConfig,
|
|
467
|
+
chatbot_endpoint_id: chatbotEndpointId,
|
|
468
|
+
max_turns: maxTurns,
|
|
469
|
+
early_termination: earlyTermination,
|
|
470
|
+
};
|
|
471
|
+
chatbotEndpointIdForOutput = chatbotEndpointId;
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
details = buildIterationDetails(modality, resolved);
|
|
475
|
+
}
|
|
378
476
|
}
|
|
379
477
|
const body = {
|
|
380
478
|
name: opts.name || `CLI ${new Date().toISOString().slice(0, 16)}`,
|
|
@@ -385,6 +483,9 @@ Next: \`ish study run\` to dispatch simulations against this iteration.`)
|
|
|
385
483
|
const result = data;
|
|
386
484
|
if (result.id)
|
|
387
485
|
result.alias = tagAlias(ALIAS_PREFIX.iteration, String(result.id));
|
|
486
|
+
if (chatbotEndpointIdForOutput !== null) {
|
|
487
|
+
result.chatbot_endpoint_id = chatbotEndpointIdForOutput;
|
|
488
|
+
}
|
|
388
489
|
output(result, globals.json);
|
|
389
490
|
});
|
|
390
491
|
});
|
|
@@ -410,6 +511,7 @@ With multiple IDs, returns a {items:[...], total:N} envelope.`)
|
|
|
410
511
|
const result = data;
|
|
411
512
|
if (result.id)
|
|
412
513
|
result.alias = tagAlias(ALIAS_PREFIX.iteration, String(result.id));
|
|
514
|
+
enrichIterationTesters(result);
|
|
413
515
|
output(result, globals.json);
|
|
414
516
|
return;
|
|
415
517
|
}
|
|
@@ -418,6 +520,7 @@ With multiple IDs, returns a {items:[...], total:N} envelope.`)
|
|
|
418
520
|
const r = data;
|
|
419
521
|
if (r.id)
|
|
420
522
|
r.alias = tagAlias(ALIAS_PREFIX.iteration, String(r.id));
|
|
523
|
+
enrichIterationTesters(r);
|
|
421
524
|
return r;
|
|
422
525
|
}));
|
|
423
526
|
if (globals.json) {
|
|
@@ -458,7 +561,24 @@ With multiple IDs, returns a {items:[...], total:N} envelope.`)
|
|
|
458
561
|
console.error("No update flags provided. Run `ish iteration update --help` for options.");
|
|
459
562
|
return;
|
|
460
563
|
}
|
|
461
|
-
const
|
|
564
|
+
const resolvedIterationId = resolveId(id);
|
|
565
|
+
// Pattern C: validate --details-json shape against the parent
|
|
566
|
+
// study's modality before sending. The backend has its own
|
|
567
|
+
// schema, but a bare `{"url":"..."}` blob silently passing
|
|
568
|
+
// through to a chat iteration corrupts it. One GET on the
|
|
569
|
+
// iteration + one on the study is cheap insurance.
|
|
570
|
+
if (body.details !== undefined) {
|
|
571
|
+
const iter = await client.get(`/iterations/${resolvedIterationId}`);
|
|
572
|
+
if (!iter.study_id) {
|
|
573
|
+
throw new Error(`Iteration ${id} has no study_id; cannot validate --details-json against modality.`);
|
|
574
|
+
}
|
|
575
|
+
const parentStudy = await client.get(`/studies/${iter.study_id}`);
|
|
576
|
+
const verdict = validateIterationDetails(parentStudy.modality, body.details);
|
|
577
|
+
if (!verdict.ok) {
|
|
578
|
+
throw new Error(`--details-json rejected: ${verdict.reason} (study modality is "${parentStudy.modality ?? "unknown"}"). ${verdict.suggestion}`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
const data = await client.put(`/iterations/${resolvedIterationId}`, body);
|
|
462
582
|
const result = data;
|
|
463
583
|
if (result.id)
|
|
464
584
|
result.alias = tagAlias(ALIAS_PREFIX.iteration, String(result.id));
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ish secret — Manage workspace secrets used in {{secret:KEY}} placeholders.
|
|
3
|
+
*
|
|
4
|
+
* Workspace secrets back the `{{secret:KEY}}` placeholder in chatbot endpoint
|
|
5
|
+
* config (request body / headers). They live on the same product-secrets
|
|
6
|
+
* surface as site-access (see src/lib/site-access.ts), but with general,
|
|
7
|
+
* user-chosen keys instead of the reserved BASIC_AUTH_/SESSION_COOKIE_/
|
|
8
|
+
* LOGIN_ keys.
|
|
9
|
+
*
|
|
10
|
+
* Security posture:
|
|
11
|
+
* - Values are write-only. The backend's list endpoint never returns them;
|
|
12
|
+
* this command additionally drops any `value`-shaped fields client-side
|
|
13
|
+
* before printing, so an accidental backend regression doesn't leak.
|
|
14
|
+
* - Inputs come from a positional value, --value-file <path>, or stdin via
|
|
15
|
+
* --value-stdin (mutually exclusive). Stdin/file paths keep secrets out
|
|
16
|
+
* of shell history.
|
|
17
|
+
* - No interactive prompts (CLI is for autonomous agents).
|
|
18
|
+
*/
|
|
19
|
+
import type { Command } from "commander";
|
|
20
|
+
export declare function registerSecretCommands(program: Command): void;
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ish secret — Manage workspace secrets used in {{secret:KEY}} placeholders.
|
|
3
|
+
*
|
|
4
|
+
* Workspace secrets back the `{{secret:KEY}}` placeholder in chatbot endpoint
|
|
5
|
+
* config (request body / headers). They live on the same product-secrets
|
|
6
|
+
* surface as site-access (see src/lib/site-access.ts), but with general,
|
|
7
|
+
* user-chosen keys instead of the reserved BASIC_AUTH_/SESSION_COOKIE_/
|
|
8
|
+
* LOGIN_ keys.
|
|
9
|
+
*
|
|
10
|
+
* Security posture:
|
|
11
|
+
* - Values are write-only. The backend's list endpoint never returns them;
|
|
12
|
+
* this command additionally drops any `value`-shaped fields client-side
|
|
13
|
+
* before printing, so an accidental backend regression doesn't leak.
|
|
14
|
+
* - Inputs come from a positional value, --value-file <path>, or stdin via
|
|
15
|
+
* --value-stdin (mutually exclusive). Stdin/file paths keep secrets out
|
|
16
|
+
* of shell history.
|
|
17
|
+
* - No interactive prompts (CLI is for autonomous agents).
|
|
18
|
+
*/
|
|
19
|
+
import { withClient, resolveWorkspace, readFileOrStdin } from "../lib/command-helpers.js";
|
|
20
|
+
import { output } from "../lib/output.js";
|
|
21
|
+
const RESERVED_SITE_ACCESS_KEYS = new Set([
|
|
22
|
+
"BASIC_AUTH_USERNAME",
|
|
23
|
+
"BASIC_AUTH_PASSWORD",
|
|
24
|
+
"BASIC_AUTH_ORIGIN",
|
|
25
|
+
"LOGIN_USERNAME",
|
|
26
|
+
"LOGIN_PASSWORD",
|
|
27
|
+
"SESSION_COOKIE_NAME",
|
|
28
|
+
"SESSION_COOKIE_VALUE",
|
|
29
|
+
"SESSION_COOKIE_ORIGIN",
|
|
30
|
+
"SITE_ACCESS_PUBLIC_AFFIRMED_ORIGIN",
|
|
31
|
+
]);
|
|
32
|
+
const KEY_PATTERN = /^[A-Z][A-Z0-9_]*$/;
|
|
33
|
+
/** Strip any `value`-shaped fields before render — defensive against backend regressions. */
|
|
34
|
+
function sanitize(secret) {
|
|
35
|
+
return {
|
|
36
|
+
id: String(secret.id ?? ""),
|
|
37
|
+
key: String(secret.key ?? ""),
|
|
38
|
+
description: typeof secret.description === "string" ? secret.description : null,
|
|
39
|
+
scope: String(secret.scope ?? ""),
|
|
40
|
+
variable_type: String(secret.variable_type ?? ""),
|
|
41
|
+
created_at: String(secret.created_at ?? ""),
|
|
42
|
+
updated_at: String(secret.updated_at ?? ""),
|
|
43
|
+
expires_at: typeof secret.expires_at === "string" ? secret.expires_at : null,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function validateKey(key) {
|
|
47
|
+
if (!KEY_PATTERN.test(key)) {
|
|
48
|
+
const err = new Error(`Invalid secret key "${key}". Keys must start with an uppercase letter and use only A-Z, 0-9, and underscores (e.g. "GROQ_API_KEY").`);
|
|
49
|
+
err.name = "ValidationError";
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
if (RESERVED_SITE_ACCESS_KEYS.has(key)) {
|
|
53
|
+
const err = new Error(`"${key}" is a reserved site-access key. Use \`ish workspace site-access\` to manage it.`);
|
|
54
|
+
err.name = "ValidationError";
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function readValueFromStdin() {
|
|
59
|
+
if (process.stdin.isTTY) {
|
|
60
|
+
const err = new Error("--value-stdin requires the value to be piped on stdin.");
|
|
61
|
+
err.name = "ValidationError";
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
return await new Promise((resolve, reject) => {
|
|
65
|
+
let data = "";
|
|
66
|
+
process.stdin.setEncoding("utf-8");
|
|
67
|
+
process.stdin.on("data", (chunk) => {
|
|
68
|
+
data += chunk;
|
|
69
|
+
});
|
|
70
|
+
process.stdin.on("end", () => resolve(data.replace(/\r?\n$/, "")));
|
|
71
|
+
process.stdin.on("error", reject);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
export function registerSecretCommands(program) {
|
|
75
|
+
const secret = program
|
|
76
|
+
.command("secret")
|
|
77
|
+
.description("Manage workspace secrets used in {{secret:KEY}} placeholders")
|
|
78
|
+
.addHelpText("after", `
|
|
79
|
+
Workspace secrets back the \`{{secret:KEY}}\` placeholder in chatbot
|
|
80
|
+
endpoint config (request body / headers). Values are write-only — the
|
|
81
|
+
backend never returns them after creation, and \`secret list\` only
|
|
82
|
+
shows keys.
|
|
83
|
+
|
|
84
|
+
Site-access credentials (BASIC_AUTH_*, LOGIN_*, SESSION_COOKIE_*) live
|
|
85
|
+
on the same store but are managed via \`ish workspace site-access\` so
|
|
86
|
+
the paired-key invariant is enforced.
|
|
87
|
+
|
|
88
|
+
Examples:
|
|
89
|
+
$ ish secret set GROQ_API_KEY gsk_live_abc...
|
|
90
|
+
$ ish secret set GROQ_API_KEY --value-file ./key.txt
|
|
91
|
+
$ printf %s "$KEY" | ish secret set GROQ_API_KEY --value-stdin
|
|
92
|
+
$ ish secret list
|
|
93
|
+
$ ish secret delete GROQ_API_KEY`);
|
|
94
|
+
secret
|
|
95
|
+
.command("list")
|
|
96
|
+
.description("List secret keys for the active workspace (values never returned)")
|
|
97
|
+
.option("--workspace <id>", "Workspace ID; defaults to active workspace")
|
|
98
|
+
.addHelpText("after", "\nValues are write-only. This command returns key + metadata only; even if the backend regresses, values are stripped client-side before render.")
|
|
99
|
+
.action(async (opts, cmd) => {
|
|
100
|
+
await withClient(cmd, async (client, globals) => {
|
|
101
|
+
const wid = resolveWorkspace(opts.workspace);
|
|
102
|
+
const data = await client.get(`/products/${wid}/secrets`);
|
|
103
|
+
const sanitized = data.map((s) => sanitize(s));
|
|
104
|
+
if (globals.json) {
|
|
105
|
+
output(sanitized, true);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (sanitized.length === 0) {
|
|
109
|
+
console.log("No secrets configured for this workspace.");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
for (const s of sanitized) {
|
|
113
|
+
const desc = s.description ? ` — ${s.description}` : "";
|
|
114
|
+
console.log(` ${s.key} (${s.variable_type}, scope=${s.scope})${desc}`);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
secret
|
|
119
|
+
.command("set")
|
|
120
|
+
.description("Create or update a workspace secret")
|
|
121
|
+
.argument("<key>", "Secret key (uppercase, e.g. GROQ_API_KEY)")
|
|
122
|
+
.argument("[value]", "Secret value. Omit when using --value-file or --value-stdin.")
|
|
123
|
+
.option("--value-file <path>", "Read the value from a file on disk (use \"-\" for stdin)")
|
|
124
|
+
.option("--value-stdin", "Read the value from stdin (alias for --value-file -)")
|
|
125
|
+
.option("--description <text>", "Optional description (what this secret is used for)")
|
|
126
|
+
.option("--scope <scope>", "Visibility scope: agent | project (default: agent)", "agent")
|
|
127
|
+
.option("--workspace <id>", "Workspace ID; defaults to active workspace")
|
|
128
|
+
.addHelpText("after", `
|
|
129
|
+
Pick exactly one of: positional <value>, --value-file <path>, --value-stdin.
|
|
130
|
+
Stdin and --value-file are preferred for real keys so the value never lands
|
|
131
|
+
in shell history.
|
|
132
|
+
|
|
133
|
+
Examples:
|
|
134
|
+
$ ish secret set GROQ_API_KEY gsk_live_abc...
|
|
135
|
+
$ ish secret set GROQ_API_KEY --value-file ./key.txt
|
|
136
|
+
$ printf %s "$KEY" | ish secret set GROQ_API_KEY --value-stdin
|
|
137
|
+
$ ish secret set GROQ_API_KEY --value-file - < ./key.txt`)
|
|
138
|
+
.action(async (key, value, opts, cmd) => {
|
|
139
|
+
await withClient(cmd, async (client, globals) => {
|
|
140
|
+
validateKey(key);
|
|
141
|
+
// Exactly one input source.
|
|
142
|
+
const sources = [
|
|
143
|
+
value !== undefined,
|
|
144
|
+
opts.valueFile !== undefined,
|
|
145
|
+
opts.valueStdin === true,
|
|
146
|
+
].filter(Boolean).length;
|
|
147
|
+
if (sources === 0) {
|
|
148
|
+
const err = new Error("No value provided. Pass a positional value, --value-file <path>, or --value-stdin.");
|
|
149
|
+
err.name = "ValidationError";
|
|
150
|
+
throw err;
|
|
151
|
+
}
|
|
152
|
+
if (sources > 1) {
|
|
153
|
+
const err = new Error("Pass exactly one of: positional value, --value-file, --value-stdin.");
|
|
154
|
+
err.name = "ValidationError";
|
|
155
|
+
throw err;
|
|
156
|
+
}
|
|
157
|
+
let resolvedValue;
|
|
158
|
+
if (opts.valueStdin) {
|
|
159
|
+
resolvedValue = await readValueFromStdin();
|
|
160
|
+
}
|
|
161
|
+
else if (opts.valueFile !== undefined) {
|
|
162
|
+
resolvedValue = (await readFileOrStdin(opts.valueFile)).replace(/\r?\n$/, "");
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
resolvedValue = value;
|
|
166
|
+
}
|
|
167
|
+
if (resolvedValue.length === 0) {
|
|
168
|
+
const err = new Error("Secret value is empty.");
|
|
169
|
+
err.name = "ValidationError";
|
|
170
|
+
throw err;
|
|
171
|
+
}
|
|
172
|
+
const scope = opts.scope === "project" ? "project" : "agent";
|
|
173
|
+
const wid = resolveWorkspace(opts.workspace);
|
|
174
|
+
// Find an existing entry by key so we PUT instead of POST when the
|
|
175
|
+
// user is updating. The list endpoint never returns the value, so
|
|
176
|
+
// this lookup leaks nothing.
|
|
177
|
+
const existing = await client.get(`/products/${wid}/secrets`);
|
|
178
|
+
const match = existing.find((s) => s.key === key);
|
|
179
|
+
let saved;
|
|
180
|
+
if (match) {
|
|
181
|
+
saved = await client.put(`/products/${wid}/secrets/${match.id}`, {
|
|
182
|
+
value: resolvedValue,
|
|
183
|
+
...(opts.description !== undefined && { description: opts.description }),
|
|
184
|
+
scope,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
const body = {
|
|
189
|
+
key,
|
|
190
|
+
value: resolvedValue,
|
|
191
|
+
scope,
|
|
192
|
+
variable_type: "secret",
|
|
193
|
+
...(opts.description !== undefined && { description: opts.description }),
|
|
194
|
+
};
|
|
195
|
+
saved = await client.post(`/products/${wid}/secrets`, body);
|
|
196
|
+
}
|
|
197
|
+
// Lean envelope. No value echoed.
|
|
198
|
+
const sanitized = sanitize(saved);
|
|
199
|
+
output({
|
|
200
|
+
success: true,
|
|
201
|
+
action: match ? "updated" : "created",
|
|
202
|
+
key: sanitized.key,
|
|
203
|
+
id: sanitized.id,
|
|
204
|
+
scope: sanitized.scope,
|
|
205
|
+
variable_type: sanitized.variable_type,
|
|
206
|
+
workspace_id: wid,
|
|
207
|
+
}, globals.json);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
secret
|
|
211
|
+
.command("delete")
|
|
212
|
+
.description("Delete a workspace secret by key")
|
|
213
|
+
.argument("<key>", "Secret key to delete")
|
|
214
|
+
.option("--workspace <id>", "Workspace ID; defaults to active workspace")
|
|
215
|
+
.addHelpText("after", "\nExamples:\n $ ish secret delete GROQ_API_KEY")
|
|
216
|
+
.action(async (key, opts, cmd) => {
|
|
217
|
+
await withClient(cmd, async (client, globals) => {
|
|
218
|
+
if (!KEY_PATTERN.test(key)) {
|
|
219
|
+
const err = new Error(`Invalid secret key "${key}". Keys must start with an uppercase letter and use only A-Z, 0-9, and underscores.`);
|
|
220
|
+
err.name = "ValidationError";
|
|
221
|
+
throw err;
|
|
222
|
+
}
|
|
223
|
+
const wid = resolveWorkspace(opts.workspace);
|
|
224
|
+
const all = await client.get(`/products/${wid}/secrets`);
|
|
225
|
+
const match = all.find((s) => s.key === key);
|
|
226
|
+
if (!match) {
|
|
227
|
+
// Surface as ApiError so exitCodeFromError maps to 4 (not-found).
|
|
228
|
+
const { ApiError } = await import("../lib/api-client.js");
|
|
229
|
+
throw new ApiError(404, "Not Found", {
|
|
230
|
+
detail: {
|
|
231
|
+
detail: `No secret with key "${key}" in this workspace.`,
|
|
232
|
+
error_code: "secret_not_found",
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
await client.del(`/products/${wid}/secrets/${match.id}`);
|
|
237
|
+
output({
|
|
238
|
+
success: true,
|
|
239
|
+
action: "deleted",
|
|
240
|
+
key,
|
|
241
|
+
id: match.id,
|
|
242
|
+
workspace_id: wid,
|
|
243
|
+
}, globals.json);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
}
|
|
@@ -8,4 +8,42 @@
|
|
|
8
8
|
* Lower-level: `study poll`, `study cancel`.
|
|
9
9
|
*/
|
|
10
10
|
import type { Command } from "commander";
|
|
11
|
+
/**
|
|
12
|
+
* M8 / M9 / Pattern G: typed wait-timeout error so `--wait` paths can
|
|
13
|
+
* produce a structured envelope (`error_code: "wait_timeout"`, exit 5
|
|
14
|
+
* transient) distinct from the generic timeout/network/server errors
|
|
15
|
+
* that the api-client wrapper produces. Carries the in-flight progress
|
|
16
|
+
* (testers done / total) so `study wait` always emits final state JSON
|
|
17
|
+
* even when it bails on the timer.
|
|
18
|
+
*/
|
|
19
|
+
export declare class WaitTimeoutError extends Error {
|
|
20
|
+
readonly progress: {
|
|
21
|
+
study_id: string;
|
|
22
|
+
iteration_id?: string;
|
|
23
|
+
timeout_seconds: number;
|
|
24
|
+
done: number;
|
|
25
|
+
total: number;
|
|
26
|
+
pending: number;
|
|
27
|
+
rows: TesterStatusRow[];
|
|
28
|
+
};
|
|
29
|
+
readonly error_code = "wait_timeout";
|
|
30
|
+
readonly retryable = true;
|
|
31
|
+
constructor(message: string, progress: {
|
|
32
|
+
study_id: string;
|
|
33
|
+
iteration_id?: string;
|
|
34
|
+
timeout_seconds: number;
|
|
35
|
+
done: number;
|
|
36
|
+
total: number;
|
|
37
|
+
pending: number;
|
|
38
|
+
rows: TesterStatusRow[];
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
interface TesterStatusRow {
|
|
42
|
+
id: string;
|
|
43
|
+
status: string;
|
|
44
|
+
tester_name: string;
|
|
45
|
+
interaction_count: number;
|
|
46
|
+
error_message?: string;
|
|
47
|
+
}
|
|
11
48
|
export declare function attachStudyRunCommands(study: Command): void;
|
|
49
|
+
export {};
|