@letterstory/cli 0.36.0 → 0.37.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 +39 -0
- package/lib/cli.mjs +41 -1
- package/lib/commands/admin.mjs +196 -3
- package/lib/commands/compositions.mjs +193 -0
- package/lib/commands.mjs +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -324,6 +324,29 @@ letterstory admin ask "which clients published nothing in the last 30 days?" --s
|
|
|
324
324
|
This is the headless half of `/superadmin/ask` — same function, same schema — so a
|
|
325
325
|
long-running agent can watch the fleet on a schedule without a browser.
|
|
326
326
|
|
|
327
|
+
### Staff: kernel experiments
|
|
328
|
+
|
|
329
|
+
```bash
|
|
330
|
+
# Needs a STAFF key with the tuning:experiment capability. A key always acts as an
|
|
331
|
+
# agent: it can propose, plan, note, spend and conclude, never approve a plan.
|
|
332
|
+
export LETTERSTORY_STAFF_API_KEY=<your staff key with tuning:experiment>
|
|
333
|
+
|
|
334
|
+
letterstory admin tuning-context --q "colons" # what the agent knows (JSON)
|
|
335
|
+
letterstory admin experiments list --q colons # what we've tried
|
|
336
|
+
letterstory admin experiments new "We use too many colons" --as "claude-code"
|
|
337
|
+
letterstory admin experiments update <id> --plan-file plan.json --budget 120
|
|
338
|
+
# ...a person approves the plan in Kernel Tuning -> Experiments...
|
|
339
|
+
letterstory admin experiments spend <id> 54
|
|
340
|
+
letterstory admin tuning-import lab/results/colons/publish/<brief>.json --experiment <id>
|
|
341
|
+
letterstory admin experiments conclude <id> --decision drop \
|
|
342
|
+
--conclusion "Colons fell 60%; P(machine) did not move; quality held." \
|
|
343
|
+
--evidence lab:"kernels lab/results/colons/report.md"
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
The ledger behind Kernel Tuning's Experiments tab
|
|
347
|
+
(docs/architecture/kernel-experiment-agent.md): every question asked of the kernels,
|
|
348
|
+
how it was tested, and what we learned and why.
|
|
349
|
+
|
|
327
350
|
### Propose, then onboard a phantom
|
|
328
351
|
|
|
329
352
|
The Fleet wizard's Plan and Name screens as two commands: pick the beat, then the name,
|
|
@@ -522,6 +545,22 @@ letterstory templates update <template-id> --angle "…" # unset fields keep t
|
|
|
522
545
|
letterstory templates delete <template-id> --yes # bound seers fall back to defaults
|
|
523
546
|
```
|
|
524
547
|
|
|
548
|
+
Primitive compositions (a named bundle of kernel/shredder/template/seer references —
|
|
549
|
+
`docs/formats/compositions.md`; not a primitive, no endpoint or run. Needs
|
|
550
|
+
`composition:read` / `composition:write`):
|
|
551
|
+
|
|
552
|
+
```bash
|
|
553
|
+
letterstory compositions list
|
|
554
|
+
letterstory compositions create --name "Regulation-watch site kit" \
|
|
555
|
+
--content '{"kernel_family":"Cedar","shred":true,"template_key":"tmpl_howto"}' \
|
|
556
|
+
--phantom '{"theme":"gazette"}' \
|
|
557
|
+
--seer '{"name":"AI regulation watch","provider":"regulation_change","mode":"auto","config":{"query":"AI regulation"}}'
|
|
558
|
+
letterstory compositions resolve comp_9f1a2b3c4d5e # preview, read-only — costs nothing
|
|
559
|
+
letterstory compositions update <composition-id> --clear-seer
|
|
560
|
+
letterstory compositions delete <composition-id> --yes # non-destructive to phantoms already built from it
|
|
561
|
+
letterstory phantom create --name "EU AI Act Weekly" --composition '{"key":"comp_9f1a2b3c4d5e"}'
|
|
562
|
+
```
|
|
563
|
+
|
|
525
564
|
## Cover images
|
|
526
565
|
|
|
527
566
|
Which image model draws a post's cover is a CLI decision, not a UI-only one. `variants`
|
package/lib/cli.mjs
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
cmdOnboard,
|
|
38
38
|
cmdSeers,
|
|
39
39
|
cmdTemplates,
|
|
40
|
+
cmdCompositions,
|
|
40
41
|
cmdShredder,
|
|
41
42
|
cmdLede,
|
|
42
43
|
cmdLandings,
|
|
@@ -46,7 +47,7 @@ import {
|
|
|
46
47
|
} from "./commands.mjs";
|
|
47
48
|
|
|
48
49
|
// Keep in sync with cli/package.json.
|
|
49
|
-
export const VERSION = "0.
|
|
50
|
+
export const VERSION = "0.37.0";
|
|
50
51
|
|
|
51
52
|
// Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
|
|
52
53
|
// can't accidentally swallow the id as --json's value.
|
|
@@ -505,6 +506,24 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
|
|
|
505
506
|
read-only SQL across every org; --sql prints
|
|
506
507
|
the queries behind the answer. Needs the
|
|
507
508
|
fleet:query capability on the staff key.
|
|
509
|
+
admin experiments list [--q colons] [--status proposed] | show <id>
|
|
510
|
+
| new "<question>" [--hypothesis …] [--plan-file p.json] [--tags a,b] [--as "<agent>"]
|
|
511
|
+
| update <id> [--plan-file …] [--result-file …] [--evidence kind:ref,…] [--budget N]
|
|
512
|
+
| note|started <id> "<text>" | spend <id> <runs>
|
|
513
|
+
| conclude <id> --decision ship|drop|follow-up|inconclusive --conclusion "…" [--evidence …]
|
|
514
|
+
| abandon <id> --reason "…"
|
|
515
|
+
The kernel experiment ledger: what we tried,
|
|
516
|
+
how, what it found and why. Approving a plan
|
|
517
|
+
stays with a signed-in person. Needs the
|
|
518
|
+
tuning:experiment capability.
|
|
519
|
+
admin tuning-import <brief-results.json> [--experiment <id>]
|
|
520
|
+
One brief's kernels lab results as a console
|
|
521
|
+
session (a trial per arm), linked to its
|
|
522
|
+
ledger entry. Written by \`npm run lab --
|
|
523
|
+
publish\` in the kernels repo.
|
|
524
|
+
admin tuning-context [--q "<question>"] The experiment agent's context pack (JSON):
|
|
525
|
+
the pipeline as deployed, levers, measures,
|
|
526
|
+
goals, related history.
|
|
508
527
|
admin create-org --domain <bare-domain> [--name "Acme"] [--test] [--no-key]
|
|
509
528
|
[--key-name N] [--capabilities a,b] [--onboard] [--watch] [+ the onboard domain flags]
|
|
510
529
|
The key exchange: create the client org from
|
|
@@ -564,6 +583,26 @@ Content templates (compose structures seers bind via config.template_key):
|
|
|
564
583
|
for coverage in your own words and lets the engine pick the shape.
|
|
565
584
|
templates delete <template-id> --yes
|
|
566
585
|
|
|
586
|
+
Primitive compositions (named bundles of kernel/shredder/template/seer; not a primitive —
|
|
587
|
+
no endpoint, no run. Applied on phantom create --composition '{"key":"..."}'):
|
|
588
|
+
compositions list Org's compositions, by key
|
|
589
|
+
compositions show <composition-id>
|
|
590
|
+
compositions create --name <n> [--description <text>]
|
|
591
|
+
[--content '<json>'|--content-file <path>] The collection's own content
|
|
592
|
+
section (kernel_family, shred,
|
|
593
|
+
shredder_endpoint_id, template_key,
|
|
594
|
+
mention_degree, cover, visuals, verify).
|
|
595
|
+
[--phantom '<json>'|--phantom-file <path>] Phantom-hosting defaults ({theme}).
|
|
596
|
+
[--seer '<json>'|--seer-file <path>] Optional — the same shape
|
|
597
|
+
'seers create' takes, minus
|
|
598
|
+
collection/org (filled in once
|
|
599
|
+
the phantom's collection exists).
|
|
600
|
+
compositions update <composition-id> [--name] [--description]
|
|
601
|
+
[--content ...] [--phantom ...] [--seer '<json>'|--seer-file <path>|--clear-seer]
|
|
602
|
+
compositions delete <composition-id> --yes
|
|
603
|
+
compositions resolve <composition-key> Preview what create_phantom would
|
|
604
|
+
merge in — read-only, costs nothing.
|
|
605
|
+
|
|
567
606
|
Shredder (diversify content produced outside Letterstory; structure preserved):
|
|
568
607
|
shredder list Your shredder endpoints + run counts
|
|
569
608
|
shredder create --name <n> New endpoint (returns a callable public id)
|
|
@@ -696,6 +735,7 @@ const CLIENT_COMMANDS = {
|
|
|
696
735
|
"phantom-onboard": cmdOnboard,
|
|
697
736
|
seers: cmdSeers,
|
|
698
737
|
templates: cmdTemplates,
|
|
738
|
+
compositions: cmdCompositions,
|
|
699
739
|
shredder: cmdShredder,
|
|
700
740
|
lede: cmdLede,
|
|
701
741
|
ledes: cmdLede,
|
package/lib/commands/admin.mjs
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
// routes, because they exist precisely to act BEFORE an org exists.
|
|
5
5
|
//
|
|
6
6
|
// admin ask "which kernels are hooked up to the most clients?" [--sql] [--json]
|
|
7
|
+
// admin experiments list|show|new|update|note|started|spend|conclude|abandon … (tuning:experiment)
|
|
8
|
+
// admin tuning-context [--q "<question>"] (tuning:experiment)
|
|
9
|
+
// admin tuning-import <brief-results.json> [--experiment <id>] (tuning:experiment)
|
|
7
10
|
// admin create-org --domain acme.com [--name "Acme"] [--test] [--no-key] [--key-name N]
|
|
8
11
|
// [--capabilities a,b] [--onboard] [--about "…"] [--sitemap <url>]
|
|
9
12
|
// [--cms <provider>] [--posts N] [--seers suggest|skip] [--no-visuals]
|
|
@@ -26,6 +29,7 @@
|
|
|
26
29
|
// The staff key comes from --staff-key or LETTERSTORY_STAFF_API_KEY; the base URL from the
|
|
27
30
|
// usual --url / LETTERSTORY_API_URL / config resolution.
|
|
28
31
|
|
|
32
|
+
import { readFileSync } from "node:fs";
|
|
29
33
|
import { CliError, LetterstoryClient } from "../client.mjs";
|
|
30
34
|
import { flagStr, flagNum, flagBool, flagList, printResult, compact, ok } from "./shared.mjs";
|
|
31
35
|
import { watchDomainJob } from "./onboard.mjs";
|
|
@@ -46,12 +50,17 @@ function staffKeyFrom(flags) {
|
|
|
46
50
|
|
|
47
51
|
/** POST a superadmin route with the staff key; surfaces the server's error text on non-2xx. */
|
|
48
52
|
async function staffPost(ctx, path, body) {
|
|
53
|
+
return staffRequest(ctx, "POST", path, body);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Any method against a superadmin route with the staff key. */
|
|
57
|
+
async function staffRequest(ctx, method, path, body) {
|
|
49
58
|
const { client, flags } = ctx;
|
|
50
59
|
const url = `${client.url}${path}`;
|
|
51
60
|
const res = await client.fetch(url, {
|
|
52
|
-
method
|
|
61
|
+
method,
|
|
53
62
|
headers: { "content-type": "application/json", "x-staff-api-key": staffKeyFrom(flags) },
|
|
54
|
-
body: JSON.stringify(body),
|
|
63
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
55
64
|
});
|
|
56
65
|
let payload = null;
|
|
57
66
|
try {
|
|
@@ -220,11 +229,195 @@ async function cmdAdminAsk(ctx) {
|
|
|
220
229
|
}
|
|
221
230
|
}
|
|
222
231
|
|
|
232
|
+
/* ------------------------------------------------------------ kernel experiments */
|
|
233
|
+
|
|
234
|
+
const EXPERIMENTS = "/api/superadmin/tuning/experiments";
|
|
235
|
+
|
|
236
|
+
/** `--as "<agent name>"` records the actor as that agent; a staff key always acts as an agent. */
|
|
237
|
+
function actorFrom(flags) {
|
|
238
|
+
const as = flagStr(flags.as);
|
|
239
|
+
return as ? { kind: "agent", label: as } : undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** A JSON object from `--<name>-file <path>` (or `-` for stdin is not supported: pass a file). */
|
|
243
|
+
function jsonFile(flags, name) {
|
|
244
|
+
const path = flagStr(flags[`${name}-file`]);
|
|
245
|
+
if (!path) return undefined;
|
|
246
|
+
try {
|
|
247
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
248
|
+
} catch (err) {
|
|
249
|
+
throw new CliError(`--${name}-file ${path}: ${err.message}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** `--evidence kind:ref[,kind:ref…]`, e.g. `lab:kernels lab/results/colons/report.md,session:<uuid>`. */
|
|
254
|
+
function evidenceFrom(flags) {
|
|
255
|
+
const items = flagList(flags.evidence);
|
|
256
|
+
if (!items?.length) return undefined;
|
|
257
|
+
return items.map((item) => {
|
|
258
|
+
const at = item.indexOf(":");
|
|
259
|
+
if (at < 1) throw new CliError(`--evidence items are kind:ref, got "${item}"`);
|
|
260
|
+
return { kind: item.slice(0, at), ref: item.slice(at + 1) };
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function experimentLine(e) {
|
|
265
|
+
const decision = e.decision ? ` → ${e.decision}` : "";
|
|
266
|
+
return `${e.id} [${e.status}${decision}] ${e.question}${e.gist ? `\n ${e.gist}` : ""}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* `admin experiments …` — the experiment ledger (docs/architecture/kernel-experiment-agent.md),
|
|
271
|
+
* the headless half of the tuning console's Experiments tab. Needs `tuning:experiment` on the key.
|
|
272
|
+
*/
|
|
273
|
+
async function cmdAdminExperiments(ctx) {
|
|
274
|
+
const [sub, id, ...rest] = ctx.positionals;
|
|
275
|
+
const { flags, io } = ctx;
|
|
276
|
+
const json = flagBool(flags.json);
|
|
277
|
+
const out = (result, line) => (json ? printResult(io, flags, result) : io.log(line(result)));
|
|
278
|
+
const one = (r) => {
|
|
279
|
+
const e = r.experiment;
|
|
280
|
+
const lines = [experimentLine(e)];
|
|
281
|
+
if (e.restated) lines.push(` hypothesis: ${e.restated}`);
|
|
282
|
+
if (e.plan) lines.push(` plan: ${JSON.stringify(e.plan)}`);
|
|
283
|
+
lines.push(` runs: ${e.runsSpent} of ${e.budgetRuns}`);
|
|
284
|
+
for (const ev of e.events ?? [])
|
|
285
|
+
lines.push(` ${ev.at} ${ev.kind} ${ev.actorLabel ?? ev.actorKind}${ev.text ? `: ${ev.text}` : ""}`);
|
|
286
|
+
return lines.join("\n");
|
|
287
|
+
};
|
|
288
|
+
switch (sub) {
|
|
289
|
+
case "list": {
|
|
290
|
+
const params = new URLSearchParams(
|
|
291
|
+
compact({ q: flagStr(flags.q), status: flagStr(flags.status), limit: flagStr(flags.limit) })
|
|
292
|
+
);
|
|
293
|
+
const result = await staffRequest(ctx, "GET", `${EXPERIMENTS}?${params}`);
|
|
294
|
+
return out(result, (r) =>
|
|
295
|
+
r.experiments.length ? r.experiments.map(experimentLine).join("\n") : "(no experiments)"
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
case "show":
|
|
299
|
+
if (!id) throw new CliError("Usage: admin experiments show <id>");
|
|
300
|
+
return out(await staffRequest(ctx, "GET", `${EXPERIMENTS}/${id}`), one);
|
|
301
|
+
case "new": {
|
|
302
|
+
const question = [id, ...rest].filter(Boolean).join(" ").trim();
|
|
303
|
+
if (question.length < 3)
|
|
304
|
+
throw new CliError('Usage: admin experiments new "<question>" [--plan-file plan.json] [--tags a,b]');
|
|
305
|
+
const body = compact({
|
|
306
|
+
question,
|
|
307
|
+
restated: flagStr(flags.hypothesis),
|
|
308
|
+
plan: jsonFile(flags, "plan"),
|
|
309
|
+
method: flagStr(flags.method),
|
|
310
|
+
tags: flagList(flags.tags),
|
|
311
|
+
builds_on: flagList(flags["builds-on"]),
|
|
312
|
+
evidence: evidenceFrom(flags),
|
|
313
|
+
actor: actorFrom(flags),
|
|
314
|
+
});
|
|
315
|
+
return out(await staffRequest(ctx, "POST", EXPERIMENTS, body), one);
|
|
316
|
+
}
|
|
317
|
+
case "update": {
|
|
318
|
+
if (!id)
|
|
319
|
+
throw new CliError(
|
|
320
|
+
"Usage: admin experiments update <id> [--hypothesis …] [--plan-file …] [--result-file …] [--evidence kind:ref,…]"
|
|
321
|
+
);
|
|
322
|
+
const body = compact({
|
|
323
|
+
restated: flagStr(flags.hypothesis),
|
|
324
|
+
plan: jsonFile(flags, "plan"),
|
|
325
|
+
measures: jsonFile(flags, "measures"),
|
|
326
|
+
result: jsonFile(flags, "result"),
|
|
327
|
+
method: flagStr(flags.method),
|
|
328
|
+
tags: flagList(flags.tags),
|
|
329
|
+
budget_runs: flagNum(flags.budget),
|
|
330
|
+
engine_build: flagStr(flags.build),
|
|
331
|
+
add_evidence: evidenceFrom(flags),
|
|
332
|
+
actor: actorFrom(flags),
|
|
333
|
+
});
|
|
334
|
+
return out(await staffRequest(ctx, "PATCH", `${EXPERIMENTS}/${id}`, body), one);
|
|
335
|
+
}
|
|
336
|
+
case "note":
|
|
337
|
+
case "started": {
|
|
338
|
+
const text = rest.join(" ").trim();
|
|
339
|
+
if (!id || (sub === "note" && !text)) throw new CliError(`Usage: admin experiments ${sub} <id> "<text>"`);
|
|
340
|
+
const body = compact({ kind: sub, text: text || undefined, actor: actorFrom(flags) });
|
|
341
|
+
return out(await staffRequest(ctx, "POST", `${EXPERIMENTS}/${id}/events`, body), one);
|
|
342
|
+
}
|
|
343
|
+
case "spend": {
|
|
344
|
+
const runs = Number(rest[0]);
|
|
345
|
+
if (!id || !Number.isInteger(runs) || runs < 1)
|
|
346
|
+
throw new CliError("Usage: admin experiments spend <id> <runs> [--text …]");
|
|
347
|
+
const body = compact({ kind: "spend", runs, text: flagStr(flags.text), actor: actorFrom(flags) });
|
|
348
|
+
return out(await staffRequest(ctx, "POST", `${EXPERIMENTS}/${id}/events`, body), one);
|
|
349
|
+
}
|
|
350
|
+
case "conclude": {
|
|
351
|
+
const decision = flagStr(flags.decision);
|
|
352
|
+
const conclusion = flagStr(flags.conclusion);
|
|
353
|
+
if (!id || !decision || !conclusion)
|
|
354
|
+
throw new CliError(
|
|
355
|
+
'Usage: admin experiments conclude <id> --decision ship|drop|follow-up|inconclusive --conclusion "<what we learned and why>" [--evidence kind:ref,…] [--result-file r.json]'
|
|
356
|
+
);
|
|
357
|
+
const body = compact({
|
|
358
|
+
action: "conclude",
|
|
359
|
+
decision,
|
|
360
|
+
conclusion,
|
|
361
|
+
result: jsonFile(flags, "result"),
|
|
362
|
+
add_evidence: evidenceFrom(flags),
|
|
363
|
+
actor: actorFrom(flags),
|
|
364
|
+
});
|
|
365
|
+
return out(await staffRequest(ctx, "POST", `${EXPERIMENTS}/${id}/decide`, body), one);
|
|
366
|
+
}
|
|
367
|
+
case "abandon": {
|
|
368
|
+
const reason = flagStr(flags.reason);
|
|
369
|
+
if (!id || !reason) throw new CliError('Usage: admin experiments abandon <id> --reason "<why>"');
|
|
370
|
+
const body = compact({ action: "abandon", reason, actor: actorFrom(flags) });
|
|
371
|
+
return out(await staffRequest(ctx, "POST", `${EXPERIMENTS}/${id}/decide`, body), one);
|
|
372
|
+
}
|
|
373
|
+
default:
|
|
374
|
+
throw new CliError(
|
|
375
|
+
"Usage: admin experiments list|show|new|update|note|started|spend|conclude|abandon (see --help)"
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* `admin tuning-import <file.json>` — one brief's kernels lab results into a console session
|
|
382
|
+
* (the lab's `publish` writes these files; the body is POST /api/superadmin/tuning/lab-import's).
|
|
383
|
+
*/
|
|
384
|
+
async function cmdAdminTuningImport(ctx) {
|
|
385
|
+
const path = ctx.positionals[0];
|
|
386
|
+
if (!path) throw new CliError("Usage: admin tuning-import <brief-results.json> [--experiment <id>]");
|
|
387
|
+
let body;
|
|
388
|
+
try {
|
|
389
|
+
body = JSON.parse(readFileSync(path, "utf8"));
|
|
390
|
+
} catch (err) {
|
|
391
|
+
throw new CliError(`${path}: ${err.message}`);
|
|
392
|
+
}
|
|
393
|
+
const experiment = flagStr(ctx.flags.experiment);
|
|
394
|
+
if (experiment) body.experiment_id = experiment;
|
|
395
|
+
const result = await staffRequest(ctx, "POST", "/api/superadmin/tuning/lab-import", body);
|
|
396
|
+
if (flagBool(ctx.flags.json)) return printResult(ctx.io, ctx.flags, result);
|
|
397
|
+
ctx.io.log(
|
|
398
|
+
`${result.trials} trials, ${result.runs} runs → ${ctx.client.url}/superadmin/tuning/${result.sessionId}`
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** `admin tuning-context [--q "<question>"]` — the experiment agent's context pack, as JSON. */
|
|
403
|
+
async function cmdAdminTuningContext(ctx) {
|
|
404
|
+
const q = flagStr(ctx.flags.q) || ctx.positionals.join(" ").trim() || undefined;
|
|
405
|
+
const params = q ? `?${new URLSearchParams({ q })}` : "";
|
|
406
|
+
printResult(
|
|
407
|
+
ctx.io,
|
|
408
|
+
{ ...ctx.flags, json: true },
|
|
409
|
+
await staffRequest(ctx, "GET", `/api/superadmin/tuning/context${params}`)
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
223
413
|
export async function cmdAdmin(ctx) {
|
|
224
414
|
const sub = ctx.positionals[0];
|
|
225
415
|
if (sub === "create-org") return cmdAdminCreateOrg({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
226
416
|
if (sub === "ask") return cmdAdminAsk({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
417
|
+
if (sub === "experiments") return cmdAdminExperiments({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
418
|
+
if (sub === "tuning-context") return cmdAdminTuningContext({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
419
|
+
if (sub === "tuning-import") return cmdAdminTuningImport({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
227
420
|
throw new CliError(
|
|
228
|
-
'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" (see --help)'
|
|
421
|
+
'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" | admin experiments … | admin tuning-context (see --help)'
|
|
229
422
|
);
|
|
230
423
|
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// `compositions` — Primitive Compositions, the FORMAT that bundles a kernel, a shredder, a
|
|
2
|
+
// content template and a seer into one named, reusable reference (docs/formats/compositions.md).
|
|
3
|
+
//
|
|
4
|
+
// Not a primitive: no endpoint, no run, no balance. What it has instead of a door is a
|
|
5
|
+
// RESOLUTION — `compositions resolve <key>` — and one consuming door, `phantom create
|
|
6
|
+
// --composition '{"key":"..."}'` (see phantom.mjs), which resolves it exactly once and merges
|
|
7
|
+
// it in. Authoring (list/show/create/update/delete) is CLI/REST parity work only; the mechanism
|
|
8
|
+
// itself shipped with the format.
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { CliError } from "../client.mjs";
|
|
12
|
+
import { flagStr, requirePositional, printResult, ok, argsFromFlags } from "./shared.mjs";
|
|
13
|
+
|
|
14
|
+
function readJsonFileFlag(flags, flag) {
|
|
15
|
+
const path = flagStr(flags[flag]);
|
|
16
|
+
if (path === undefined) return undefined;
|
|
17
|
+
let text;
|
|
18
|
+
try {
|
|
19
|
+
text = readFileSync(path, "utf8");
|
|
20
|
+
} catch (error) {
|
|
21
|
+
throw new CliError(
|
|
22
|
+
`Could not read --${flag} ${path}: ${error instanceof Error ? error.message : String(error)}`
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(text);
|
|
27
|
+
} catch {
|
|
28
|
+
throw new CliError(`--${flag} ${path} is not valid JSON.`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readJsonObjectFlag(flags, name) {
|
|
33
|
+
const inline = flagStr(flags[name]);
|
|
34
|
+
if (inline !== undefined) {
|
|
35
|
+
let parsed;
|
|
36
|
+
try {
|
|
37
|
+
parsed = JSON.parse(inline);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
throw new CliError(`--${name} is not valid JSON: ${err.message}`);
|
|
40
|
+
}
|
|
41
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
42
|
+
throw new CliError(`--${name} must be a JSON object`);
|
|
43
|
+
return parsed;
|
|
44
|
+
}
|
|
45
|
+
return readJsonFileFlag(flags, `${name}-file`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// `--seer '<json>'`/`--seer-file <path>` sets it, `--clear-seer` sends null (the composition
|
|
49
|
+
// stands up no seer), neither leaves it unchanged — same idiom as seers.mjs's clearableValue.
|
|
50
|
+
function seerFromFlags(flags) {
|
|
51
|
+
if (flags["clear-seer"] === true) return null;
|
|
52
|
+
return readJsonObjectFlag(flags, "seer");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* How this command sources its tool inputs — see `argsFromFlags` in shared.mjs and
|
|
57
|
+
* `scripts/parity-check.ts`. `content`/`phantom`/`seer` are each a JSON object (the collection
|
|
58
|
+
* content section, `{theme}`, and a create_seer-shaped spec, respectively) — the same
|
|
59
|
+
* `--flag '<json>'`/`--flag-file <path>` idiom `phantom create --composition` already uses,
|
|
60
|
+
* not decomposed field-by-field: they're bundles authored once and read back, not edited a
|
|
61
|
+
* field at a time from the CLI.
|
|
62
|
+
*/
|
|
63
|
+
export const ARGS = {
|
|
64
|
+
list_compositions: {},
|
|
65
|
+
get_composition: {
|
|
66
|
+
composition_id: { positional: 0, label: "composition-id", required: true },
|
|
67
|
+
},
|
|
68
|
+
resolve_composition: {
|
|
69
|
+
key: { positional: 0, label: "composition-key", required: true },
|
|
70
|
+
},
|
|
71
|
+
create_composition: {
|
|
72
|
+
name: { flag: "name", required: true },
|
|
73
|
+
description: "description",
|
|
74
|
+
content: { value: (flags) => readJsonObjectFlag(flags, "content") },
|
|
75
|
+
phantom: { value: (flags) => readJsonObjectFlag(flags, "phantom") },
|
|
76
|
+
seer: { value: (flags) => readJsonObjectFlag(flags, "seer") },
|
|
77
|
+
},
|
|
78
|
+
update_composition: {
|
|
79
|
+
composition_id: { positional: 0, label: "composition-id", required: true },
|
|
80
|
+
name: "name",
|
|
81
|
+
description: "description",
|
|
82
|
+
content: { value: (flags) => readJsonObjectFlag(flags, "content") },
|
|
83
|
+
phantom: { value: (flags) => readJsonObjectFlag(flags, "phantom") },
|
|
84
|
+
seer: { value: (flags) => seerFromFlags(flags) },
|
|
85
|
+
},
|
|
86
|
+
delete_composition: {
|
|
87
|
+
composition_id: { positional: 0, label: "composition-id", required: true },
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export async function cmdCompositions(ctx) {
|
|
92
|
+
const sub = ctx.positionals[0];
|
|
93
|
+
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
94
|
+
switch (sub) {
|
|
95
|
+
case "list":
|
|
96
|
+
case "ls":
|
|
97
|
+
return compositionsList(rest);
|
|
98
|
+
case "show":
|
|
99
|
+
case "get":
|
|
100
|
+
return compositionsShow(rest);
|
|
101
|
+
case "create":
|
|
102
|
+
case "new":
|
|
103
|
+
return compositionsCreate(rest);
|
|
104
|
+
case "update":
|
|
105
|
+
return compositionsUpdate(rest);
|
|
106
|
+
case "delete":
|
|
107
|
+
case "rm":
|
|
108
|
+
return compositionsDelete(rest);
|
|
109
|
+
case "resolve":
|
|
110
|
+
return compositionsResolve(rest);
|
|
111
|
+
default:
|
|
112
|
+
throw new CliError(
|
|
113
|
+
`Unknown compositions subcommand: ${sub ?? "(none)"}. Try: list, show, create, update, delete, resolve`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function compositionsList(ctx) {
|
|
119
|
+
const { client, flags, io } = ctx;
|
|
120
|
+
const result = await client.callTool("list_compositions", {});
|
|
121
|
+
printResult(io, flags, result, (r) =>
|
|
122
|
+
(r.compositions ?? []).length
|
|
123
|
+
? (r.compositions ?? [])
|
|
124
|
+
.map((c) => ` ${c.key} ${c.name}${c.seer ? " (+seer)" : ""} — id ${c.id}`)
|
|
125
|
+
.join("\n")
|
|
126
|
+
: "No compositions yet."
|
|
127
|
+
);
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function compositionsShow(ctx) {
|
|
132
|
+
const { client, positionals, flags, io } = ctx;
|
|
133
|
+
requirePositional(positionals, 0, "composition-id");
|
|
134
|
+
const result = await client.callTool("get_composition", argsFromFlags(ARGS.get_composition, flags, positionals));
|
|
135
|
+
printResult(io, flags, result);
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function compositionsCreate(ctx) {
|
|
140
|
+
const { client, flags, io } = ctx;
|
|
141
|
+
const args = argsFromFlags(ARGS.create_composition, flags, []);
|
|
142
|
+
if (!args.name) throw new CliError("Missing required --name");
|
|
143
|
+
const result = await client.callTool("create_composition", args);
|
|
144
|
+
ok(ctx, `Composition created — key ${result.composition?.key}.`);
|
|
145
|
+
printResult(io, flags, result);
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function compositionsUpdate(ctx) {
|
|
150
|
+
const { client, positionals, flags, io } = ctx;
|
|
151
|
+
requirePositional(positionals, 0, "composition-id");
|
|
152
|
+
const result = await client.callTool(
|
|
153
|
+
"update_composition",
|
|
154
|
+
argsFromFlags(ARGS.update_composition, flags, positionals)
|
|
155
|
+
);
|
|
156
|
+
ok(ctx, "Composition updated.");
|
|
157
|
+
printResult(io, flags, result);
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function compositionsDelete(ctx) {
|
|
162
|
+
const { client, positionals, flags, io } = ctx;
|
|
163
|
+
requirePositional(positionals, 0, "composition-id");
|
|
164
|
+
if (!flags.yes) {
|
|
165
|
+
throw new CliError(
|
|
166
|
+
"Non-destructive to phantoms already built from it (their resolved_from_composition becomes a name pointing at nothing). Re-run with --yes to delete."
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
const result = await client.callTool(
|
|
170
|
+
"delete_composition",
|
|
171
|
+
argsFromFlags(ARGS.delete_composition, flags, positionals)
|
|
172
|
+
);
|
|
173
|
+
ok(ctx, "Composition deleted.");
|
|
174
|
+
printResult(io, flags, result);
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* `compositions resolve <key>` — turn a composition into the sections `phantom create
|
|
180
|
+
* --composition` would merge in. Read-only, costs nothing. Preview a bundle, or build the
|
|
181
|
+
* sections yourself for a door that doesn't take `composition` directly yet (`collection
|
|
182
|
+
* config` / `seers create`).
|
|
183
|
+
*/
|
|
184
|
+
async function compositionsResolve(ctx) {
|
|
185
|
+
const { client, positionals, flags, io } = ctx;
|
|
186
|
+
requirePositional(positionals, 0, "composition-key");
|
|
187
|
+
const result = await client.callTool(
|
|
188
|
+
"resolve_composition",
|
|
189
|
+
argsFromFlags(ARGS.resolve_composition, flags, positionals)
|
|
190
|
+
);
|
|
191
|
+
printResult(io, flags, result);
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
package/lib/commands.mjs
CHANGED
|
@@ -23,6 +23,7 @@ export * from "./commands/insights.mjs";
|
|
|
23
23
|
export * from "./commands/covers.mjs";
|
|
24
24
|
export * from "./commands/arena.mjs";
|
|
25
25
|
export * from "./commands/templates.mjs";
|
|
26
|
+
export * from "./commands/compositions.mjs";
|
|
26
27
|
export * from "./commands/autopen.mjs";
|
|
27
28
|
export * from "./commands/kernel.mjs";
|
|
28
29
|
export * from "./commands/lede.mjs";
|