@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 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.36.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,
@@ -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: "POST",
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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",