@letterstory/cli 0.121.0 → 0.121.1

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
@@ -568,6 +568,8 @@ letterstory arena endpoint delete <arena-id> --yes
568
568
  letterstory arena file <arena-id> > arena.json # the file the arena runs now
569
569
  letterstory arena file <arena-id> --patch '{"rules":{"context-figures":"measure"}}' --note "try it"
570
570
  letterstory arena file <arena-id> --file arena.json # save a whole file as the next version
571
+ letterstory arena file <arena-id> --file arena.json --candidate # save beside live; runs nothing yet
572
+ letterstory arena promote <arena-id> <version> --yes # ship a candidate, or roll back
571
573
  letterstory arena versions <arena-id> # your copy's versions and our releases
572
574
  letterstory arena pin <arena-id> <version-id|none> # pin one of our releases, or follow live
573
575
  letterstory arena offer <arena-id> [--accept] # our newer version, over your own changes
@@ -621,6 +623,8 @@ letterstory shredder job <job-id> # poll it your
621
623
  # The shredder as ONE versioned file (like a kernel file; schema at /api/schema/shredder):
622
624
  letterstory shredder file <endpoint-id> > shredder.json # the file the endpoint shreds with
623
625
  letterstory shredder file <endpoint-id> --patch '{"rewriters":{"excludeWriterFamily":true}}'
626
+ letterstory shredder file <endpoint-id> --file s.json --candidate # save beside live; shreds nothing yet
627
+ letterstory shredder promote <endpoint-id> <version> --yes # ship a candidate, or roll back
624
628
  letterstory shredder versions <endpoint-id> # your copy's versions and our releases
625
629
  letterstory shredder pin <endpoint-id> <version-id|none> # pin one of our releases, or follow live
626
630
  letterstory shredder offer <endpoint-id> [--accept] # our newer version, over your own changes
@@ -659,6 +663,7 @@ letterstory org export > acme.json # the bundle — the same doc
659
663
  letterstory org export --sections primitives.kernels --sections data.collections
660
664
  letterstory org export --split acme/ # one file per record, for keeping config in git
661
665
  letterstory org join acme/ > acme.json # rebuild the identical bundle from the directory
666
+ letterstory org fields data.collections # what each setting means: label, path, how it's edited, its gate
662
667
  letterstory org diff acme.json # what applying the bundle would change: + add, ~ change, - only in the org
663
668
  letterstory org apply acme.json --dry-run # what apply would write; then drop --dry-run to apply
664
669
  letterstory org apply acme.json --prune --yes # also delete records the bundle lacks
package/lib/cli.mjs CHANGED
@@ -60,13 +60,15 @@ import {
60
60
  } from "./commands.mjs";
61
61
 
62
62
  // Keep in sync with cli/package.json.
63
- export const VERSION = "0.121.0";
63
+ export const VERSION = "0.121.1";
64
64
 
65
65
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
66
66
  // can't accidentally swallow the id as --json's value. Every `type: "bool"` flag (and its
67
67
  // `negate` spelling) in a command's ARGS map must be listed; boolean-flags.test.ts enforces it.
68
68
  export const BOOLEAN_FLAGS = new Set([
69
69
  "json",
70
+ // shredder file / arena file: save the version as a candidate beside live.
71
+ "candidate",
70
72
  // site tree: include children a fold-by-default family would hide; items over the whole subtree.
71
73
  "unfold",
72
74
  "subtree",
@@ -105,6 +107,11 @@ export const BOOLEAN_FLAGS = new Set([
105
107
  "prune",
106
108
  // org git sync --force
107
109
  "force",
110
+ // `series create --paused`; `series update <id> --pause | --resume | --retire`.
111
+ "paused",
112
+ "pause",
113
+ "resume",
114
+ "retire",
108
115
  "buy",
109
116
  "rebuild",
110
117
  // phantom spread-looks --apply
@@ -178,6 +185,8 @@ export const BOOLEAN_FLAGS = new Set([
178
185
  "retired",
179
186
  "retire",
180
187
  "restore",
188
+ // `collection add "<headline>" --collection <id> --queue`.
189
+ "queue",
181
190
  // Bool flags from command ARGS maps that were missing here.
182
191
  "apply-rules",
183
192
  "async",
@@ -332,6 +341,14 @@ Collections (the content engine as a primitive — one door, 'collection run'):
332
341
  publish → (hosted) rebuild. Spends one of the
333
342
  week's cadence slots. 20–60 min; followed unless
334
343
  --no-wait; stops at the gate under manual approval.
344
+ collection queue <suggestion-id> Queue a headline, as the app's queue button does: the
345
+ next slot of the schedule when the Planner is on,
346
+ else a draft to write by hand
347
+ collection add "<headline>" --collection <id> [--queue]
348
+ A headline you wrote: joins Up next, or with --queue
349
+ is queued at once (the next slot, or a draft)
350
+ collection headline <suggestion-id> [--dismiss | --later | --restore] [--headline "…"]
351
+ Dismiss, park, bring back or reword a headline
335
352
  collection run-status <run-id> Status, steps, constraints, verification
336
353
  collection runs [--collection <id>] [--status <s>] [--limit <n>]
337
354
  collection approve <run-id> [--now] Answer the gate (--now publishes immediately)
@@ -349,6 +366,7 @@ Phantomstory (a collection Letterstory hosts as an independent publication):
349
366
  'collection run --collection <its collection_id>'.
350
367
  phantom get <phantom-id> Status, URL, domain, DNS
351
368
  phantom config <phantom-id> [--name … --theme … --collection …] [--collection-config '<json>']
369
+ [--style '<json>'|none] [--stock-cover '<json>'|none] [--logo '<pick json>']
352
370
  [--configure [--dry-run]]
353
371
  The site's file joined to its collection's
354
372
  phantom rebuild <phantom-id> · phantom diagnostics <phantom-id> · phantom balance
@@ -492,7 +510,11 @@ Offerings (context layered org → offering → collection; a layer sets only wh
492
510
  onboarding status Show the onboarding checklist
493
511
  onboarding step [--current <step>] [--complete <step>] [--skip <step>] [--status <status>]
494
512
  series list The org's content series + coverage state
495
- series create --name <n> --description <d> [--coverage a,b,c] [--collection <uuid>] [--template <key>]
513
+ series create --name <n> [--description <d>] [--coverage a,b,c] [--collection <uuid>] [--template <key>]
514
+ [--templates a,b] [--every-days <n>] [--purpose <text>] [--paused]
515
+ series update <series-id> [--every-days <n>|default] [--templates a,b] [--template <key>] [--purpose <text>]
516
+ [--description <d>] [--name <n>] [--coverage a,b,c] [--pause|--resume] [--retire]
517
+ Pace, templates, pause; --retire is the delete
496
518
  series backfill <series-id> [--collection <uuid>] Reconstruct a series from existing posts
497
519
 
498
520
  Ranking targets (what the org wants to be THE answer for — company-wide or on one offering):
@@ -992,9 +1014,11 @@ Shredder (diversify content produced outside Letterstory; structure preserved):
992
1014
  shredder runs [--endpoint <id>] [--limit <n>] Recent shred calls (telemetry), newest first:
993
1015
  the shredder version, level, seats and models each ran
994
1016
  shredder file <endpoint-id> Print the shredder file the endpoint shreds with
995
- shredder file <endpoint-id> (--file f.json | --patch '<json>') [--note "…"]
1017
+ shredder file <endpoint-id> (--file f.json | --patch '<json>') [--note "…"] [--candidate]
996
1018
  Save a new version of the endpoint's own copy
997
- (schema: GET /api/schema/shredder)
1019
+ (schema: GET /api/schema/shredder); --candidate
1020
+ stores it beside live without changing a shred
1021
+ shredder promote <endpoint-id> <version> --yes Make a version live: ship a candidate, or roll back
998
1022
  shredder versions <endpoint-id> Your copy's versions and our released ones
999
1023
  shredder pin <endpoint-id> <version-id|none> Pin one of our versions, or follow our live one
1000
1024
  shredder offer <endpoint-id> [--accept] What our newer version would change; --accept takes it
@@ -197,11 +197,17 @@ export const ARGS = {
197
197
  get_arena_file: {
198
198
  arena_id: { positional: 0, label: "arena-id", required: true },
199
199
  },
200
+ // Live unless --candidate: a candidate changes nothing until `arena promote` ships it.
200
201
  save_arena_file: {
201
202
  arena_id: { positional: 0, label: "arena-id", required: true },
202
203
  file: { value: (flags) => readJsonFileFlag(flags, "file") },
203
204
  patch: { value: (flags) => readJsonObjectFlag(flags, "patch") },
204
205
  note: "note",
206
+ status: { value: (flags) => (flags.candidate === true ? "candidate" : undefined) },
207
+ },
208
+ promote_arena_version: {
209
+ arena_id: { positional: 0, label: "arena-id", required: true },
210
+ version: { value: (_flags, positionals) => positiveInt(positionals[1], "version") },
205
211
  },
206
212
  list_arena_versions: {
207
213
  arena_id: { positional: 0, label: "arena-id", required: true },
@@ -255,13 +261,15 @@ export async function cmdArena(ctx) {
255
261
  return arenaFile(rest);
256
262
  case "versions":
257
263
  return arenaTool(rest, "list_arena_versions");
264
+ case "promote":
265
+ return arenaPromote(rest);
258
266
  case "pin":
259
267
  return arenaTool(rest, "pin_arena_version");
260
268
  case "offer":
261
269
  return rest.flags.accept === true ? arenaTool(rest, "accept_arena_offer") : arenaOffer(rest);
262
270
  default:
263
271
  throw new CliError(
264
- `Unknown arena subcommand: ${sub ?? "(none)"}. Try: run, run-status, runs, latest, direction, cancel, balance, list, endpoint, file, versions, pin, offer, roles, role`
272
+ `Unknown arena subcommand: ${sub ?? "(none)"}. Try: run, run-status, runs, latest, direction, cancel, balance, list, endpoint, file, promote, versions, pin, offer, roles, role`
265
273
  );
266
274
  }
267
275
  }
@@ -450,7 +458,13 @@ async function arenaFile(ctx) {
450
458
  "save_arena_file",
451
459
  argsFromFlags(ARGS.save_arena_file, flags, ctx.positionals)
452
460
  );
453
- ok(ctx, `Saved ${toolField(result, "version")?.label ?? "a new version"}; the arena runs it from now on.`);
461
+ const saved = toolField(result, "saved");
462
+ ok(
463
+ ctx,
464
+ saved?.status === "candidate"
465
+ ? `Saved ${saved.label} as a candidate; ship it: ${ctx.bin} arena promote ${ctx.positionals[0]} ${saved.version} --yes`
466
+ : `Saved ${saved?.label ?? toolField(result, "version")?.label ?? "a new version"}; the arena runs it from now on.`
467
+ );
454
468
  printResult(io, flags, result);
455
469
  return 0;
456
470
  }
@@ -520,3 +534,30 @@ async function arenaEndpoint(ctx) {
520
534
  }
521
535
  throw new CliError(`Unknown arena endpoint action: ${action ?? "(none)"}. Try: create, update, delete, config`);
522
536
  }
537
+
538
+ function positiveInt(raw, label) {
539
+ if (raw === undefined) throw new CliError(`Missing required <${label}>`);
540
+ const n = Number(raw);
541
+ if (!Number.isInteger(n) || n < 1) throw new CliError(`<${label}> must be a positive whole number (got "${raw}")`);
542
+ return n;
543
+ }
544
+
545
+ /** `arena promote <arena-id> <version> --yes` — make a version live: ship a candidate, or roll back. */
546
+ async function arenaPromote(ctx) {
547
+ const { client, positionals, flags, io } = ctx;
548
+ const args = argsFromFlags(ARGS.promote_arena_version, flags, positionals);
549
+ if (!flags.yes) {
550
+ io.error(
551
+ `Every run of this arena will use version ${args.version} from its next call. Re-run with --yes to confirm: ${ctx.bin} arena promote ${args.arena_id} ${args.version} --yes`
552
+ );
553
+ return 1;
554
+ }
555
+ const result = await client.callTool("promote_arena_version", args);
556
+ const previous = toolField(result, "previous_live");
557
+ ok(
558
+ ctx,
559
+ `Version ${args.version} is live.${previous ? ` To undo: ${ctx.bin} arena promote ${args.arena_id} ${previous} --yes` : ""}`
560
+ );
561
+ printResult(io, flags, result);
562
+ return 0;
563
+ }
@@ -87,6 +87,22 @@ export const ARGS = {
87
87
  rebuild: { flag: "no-rebuild", type: "bool", invert: true },
88
88
  schedule_for: { flag: "at" },
89
89
  },
90
+ queue_suggestion: {
91
+ suggestion_id: { positional: 0, label: "suggestion-id", required: true },
92
+ },
93
+ add_headline: {
94
+ collection_id: { flag: "collection", required: true },
95
+ headline: { positional: 0, label: "headline", required: true },
96
+ queue: { flag: "queue", type: "bool" },
97
+ },
98
+ update_suggestion: {
99
+ suggestion_id: { positional: 0, label: "suggestion-id", required: true },
100
+ status: {
101
+ value: (flags) =>
102
+ flags.dismiss ? "dismissed" : flags.later ? "backlog" : flags.restore ? "suggested" : undefined,
103
+ },
104
+ headline: { flag: "headline" },
105
+ },
90
106
  get_collection_run: {
91
107
  run_id: { positional: 0, label: "run-id", required: true },
92
108
  },
@@ -143,6 +159,12 @@ export async function cmdCollection(ctx) {
143
159
  return collectionDelete(rest);
144
160
  case "run":
145
161
  return collectionRun(rest);
162
+ case "queue":
163
+ return collectionQueue(rest);
164
+ case "headline":
165
+ return collectionHeadline(rest);
166
+ case "add":
167
+ return collectionAdd(rest);
146
168
  case "run-status":
147
169
  case "status":
148
170
  return collectionRunStatus(rest);
@@ -160,7 +182,7 @@ export async function cmdCollection(ctx) {
160
182
  return collectionLinks(rest);
161
183
  default:
162
184
  throw new CliError(
163
- `Unknown collection subcommand: ${sub ?? "(none)"}. Try: list, overview, new, config, delete, run, run-status, runs, approve, cancel, inventory, links`
185
+ `Unknown collection subcommand: ${sub ?? "(none)"}. Try: list, overview, new, config, delete, run, add, queue, headline, run-status, runs, approve, cancel, inventory, links`
164
186
  );
165
187
  }
166
188
  }
@@ -266,6 +288,60 @@ async function collectionRun(ctx) {
266
288
  return 0;
267
289
  }
268
290
 
291
+ // `collection headline <suggestion-id> [--dismiss | --later | --restore] [--headline "…"]` —
292
+ // the app's Dismiss and Edit on a suggested headline (update_suggestion).
293
+ async function collectionHeadline(ctx) {
294
+ const { client, flags, io } = ctx;
295
+ refuseUnknownFlags(ctx, ARGS.update_suggestion, ["dismiss", "later", "restore"]);
296
+ const result = await client.callTool(
297
+ "update_suggestion",
298
+ argsFromFlags(ARGS.update_suggestion, flags, ctx.positionals)
299
+ );
300
+ ok(ctx, "Headline updated.");
301
+ printResult(io, flags, result);
302
+ return 0;
303
+ }
304
+
305
+ // `collection add "<headline>" --collection <id> [--queue]` — the letterbase page's New post: a
306
+ // headline you wrote joins Up next, or with --queue takes the next slot (a draft, planner off).
307
+ async function collectionAdd(ctx) {
308
+ const { client, flags, io } = ctx;
309
+ const result = await client.callTool("add_headline", argsFromFlags(ARGS.add_headline, flags, ctx.positionals));
310
+ const queued = toolField(result, "queued");
311
+ ok(
312
+ ctx,
313
+ !queued
314
+ ? "Added to Up next."
315
+ : queued.via === "slot"
316
+ ? `Queued for ${queued.scheduled_for ?? "?"} (run ${queued.run_id ?? "?"}).`
317
+ : `The Planner is off: drafted as post ${queued.article?.id ?? "?"}.`
318
+ );
319
+ printResult(io, flags, result);
320
+ return 0;
321
+ }
322
+
323
+ // `collection queue <suggestion-id>` — the app's queue button: the Planner's next slot when
324
+ // it is on (a run that writes on the slot's lead time), else a draft to write by hand.
325
+ async function collectionQueue(ctx) {
326
+ const { client, flags, io, bin } = ctx;
327
+ const result = await client.callTool(
328
+ "queue_suggestion",
329
+ argsFromFlags(ARGS.queue_suggestion, flags, ctx.positionals)
330
+ );
331
+ const via = toolField(result, "via");
332
+ if (via === "slot") {
333
+ const runId = toolField(result, "run_id");
334
+ ok(
335
+ ctx,
336
+ `Queued on the schedule for ${toolField(result, "scheduled_for") ?? "?"} (run ${runId ?? "?"}). Follow it with: ${bin} collection run-status ${runId ?? "<run-id>"}`
337
+ );
338
+ } else {
339
+ ok(ctx, `The Planner is off: drafted as post ${toolField(result, "article")?.id ?? "?"}.`);
340
+ }
341
+ printResult(io, flags, result);
342
+ return 0;
343
+ }
344
+
269
345
  async function collectionRunStatus(ctx) {
270
346
  const result = await ctx.client.callTool(
271
347
  "get_collection_run",
@@ -18,6 +18,7 @@
18
18
  // unless you tell it not to.
19
19
 
20
20
  import { readFileSync } from "node:fs";
21
+ import { basename } from "node:path";
21
22
  import { CliError } from "../client.mjs";
22
23
  import {
23
24
  flagStr,
@@ -241,8 +242,25 @@ export const ARGS = {
241
242
  source_kernel_id: { positional: 0, label: "source-kernel-id", required: true },
242
243
  name: "name",
243
244
  },
245
+ // `--name` names the new kernel (over the file's own name); `--from` starts from a kernel you can
246
+ // see; `--skill SKILL.md` makes one from your own writing instructions.
244
247
  create_kernel: {
245
- file: { value: (flags) => readJsonFileFlag(flags, "file") },
248
+ file: {
249
+ value: (flags) => {
250
+ const file = readJsonFileFlag(flags, "file");
251
+ const name = flagStr(flags.name);
252
+ if (name === undefined) return file;
253
+ return { ...(file ?? {}), name };
254
+ },
255
+ },
256
+ from: "from",
257
+ skill: { value: (flags) => readTextFileFlag(flags, "skill") },
258
+ skill_filename: {
259
+ value: (flags) => {
260
+ const path = flagStr(flags.skill);
261
+ return path === undefined ? undefined : basename(path);
262
+ },
263
+ },
246
264
  },
247
265
  get_org_writing_kernel: {},
248
266
  set_org_writing_kernel: {
@@ -767,15 +785,28 @@ async function kernelFork(ctx) {
767
785
  return 0;
768
786
  }
769
787
 
770
- /** `kernel create --file kernel.json` — bring your own: a kernel made from a file (a name and a voice is enough). */
788
+ /**
789
+ * `kernel create` — a kernel your org owns, from one of three starts (combinable):
790
+ * --file kernel.json a kernel file (a name and a voice is enough)
791
+ * --from <kernel-id|name> a kernel you can see: your current one for a new kernel, any other to fork
792
+ * --skill SKILL.md your own writing instructions, over --from or else your current kernel
793
+ * `--name` names it.
794
+ */
771
795
  async function kernelCreate(ctx) {
772
796
  const { client, positionals, flags, io } = ctx;
773
- if (flagStr(flags.file) === undefined) {
774
- throw new CliError('Provide the kernel file with --file <path> (as short as {"name": "…", "voice": "…"}).');
797
+ if (flagStr(flags.file) === undefined && flagStr(flags.from) === undefined && flagStr(flags.skill) === undefined) {
798
+ throw new CliError(
799
+ 'Provide --file <path> (as short as {"name": "…", "voice": "…"}), --from <kernel-id|name> or --skill <SKILL.md>.'
800
+ );
775
801
  }
776
802
  const result = await client.callTool("create_kernel", argsFromFlags(ARGS.create_kernel, flags, positionals));
777
803
  const kernel = toolField(result, "kernel");
778
- if (kernel?.id) ok(ctx, `Created "${kernel.name}" (${kernel.id}), version 1, live.`);
804
+ const basedOn = toolField(result, "based_on");
805
+ if (kernel?.id)
806
+ ok(
807
+ ctx,
808
+ `Created "${kernel.name}" (${kernel.id}), version 1, live${basedOn ? `, from ${basedOn.name} v${basedOn.version}` : ""}.`
809
+ );
779
810
  printResult(io, flags, result);
780
811
  return 0;
781
812
  }
@@ -25,6 +25,7 @@ export const ARGS = {
25
25
  allow: { flag: "allow", type: "list" },
26
26
  confirm: { flag: "confirm" },
27
27
  },
28
+ describe_org_config_fields: {},
28
29
  get_org_config_source: {},
29
30
  list_integrations: {},
30
31
  get_linear_connection: {},
@@ -63,11 +64,13 @@ export async function cmdOrg(ctx) {
63
64
  return orgGit(rest);
64
65
  case "integrations":
65
66
  return orgIntegrations(rest);
67
+ case "fields":
68
+ return orgFields(rest);
66
69
  case "linear":
67
70
  return orgLinear(rest);
68
71
  default:
69
72
  throw new CliError(
70
- `Unknown org subcommand: ${sub ?? "(none)"}. Try: export, join, diff, apply, git, integrations, linear`
73
+ `Unknown org subcommand: ${sub ?? "(none)"}. Try: export, join, diff, apply, git, integrations, linear, fields`
71
74
  );
72
75
  }
73
76
  }
@@ -84,6 +87,24 @@ async function orgIntegrations(ctx) {
84
87
  return 0;
85
88
  }
86
89
 
90
+ /** `org fields [section]`: what each setting in the bundle means (describe_org_config_fields). */
91
+ async function orgFields(ctx) {
92
+ const { client, flags, io, positionals } = ctx;
93
+ refuseUnknownFlags(ctx, {}, []);
94
+ const section = positionals[0];
95
+ const result = await client.callTool("describe_org_config_fields", {});
96
+ printResult(io, flags, result, (r) =>
97
+ r.fields
98
+ .filter((f) => !section || f.section === section)
99
+ .map(
100
+ (f) =>
101
+ `${f.section} ${f.path} ${f.label}${f.readOnly ? " (read-only)" : ""}${f.gate ? ` [${f.gate}]` : ""}`
102
+ )
103
+ .join("\n")
104
+ );
105
+ return 0;
106
+ }
107
+
87
108
  /**
88
109
  * `org linear` — where Letter files Linear issues (connecting is OAuth, in Settings):
89
110
  * org linear the connection (get_linear_connection)
@@ -42,6 +42,10 @@ function readJsonObjectFlag(flags, name) {
42
42
  }
43
43
  return readJsonFileFlag(flags, `${name}-file`);
44
44
  }
45
+ /** `--<name> none` → null (clear it); otherwise the JSON object, inline or from `--<name>-file`. */
46
+ function readJsonObjectOrNone(flags, name) {
47
+ return flagStr(flags[name]) === "none" ? null : readJsonObjectFlag(flags, name);
48
+ }
45
49
  function stripScheme(domain) {
46
50
  return String(domain)
47
51
  .replace(/^https?:\/\//i, "")
@@ -79,6 +83,11 @@ export const ARGS = {
79
83
  value: (flags) =>
80
84
  flags.layout === undefined ? undefined : flags.layout === "none" ? null : String(flags.layout),
81
85
  },
86
+ // The site's palette and fonts, stock covers ('{"enabled":true}'), each `none` to clear;
87
+ // a logo pick ('{"spec":{"archetype":"geometric","variantId":"…"}}') to compose.
88
+ style: { value: (flags) => readJsonObjectOrNone(flags, "style") },
89
+ stock_cover: { value: (flags) => readJsonObjectOrNone(flags, "stock-cover") },
90
+ logo: { value: (flags) => readJsonObjectFlag(flags, "logo") },
82
91
  maverick_enabled: { flag: "maverick", type: "bool", negate: "no-maverick" },
83
92
  // `--maverick-customer-id none` clears it (identification back to pending).
84
93
  maverick_customer_id: {
@@ -144,7 +144,10 @@ const ARGS = {
144
144
  status: { flag: "status", enum: ["running", "succeeded", "no_changes", "failed", "cancelled", "sent"] },
145
145
  limit: { flag: "limit", type: "num" },
146
146
  audit_id: "audit",
147
- source_kind: { flag: "source", enum: ["site_audit_issue", "site_quick_check", "letterprove_install"] },
147
+ source_kind: {
148
+ flag: "source",
149
+ enum: ["site_audit_issue", "site_quick_check", "letterprove_install", "collection_publish"],
150
+ },
148
151
  site_host: "site",
149
152
  },
150
153
  list_github_app_repos: {},
@@ -2,10 +2,69 @@
2
2
  // point beside topic clusters, hierarchy revamp D/G). A series is a coverage
3
3
  // obligation: "content that looks like this", one post per declared item; the
4
4
  // planner queue writes the gaps and backfill reconstructs what existing posts
5
- // already fulfill.
5
+ // already fulfill. It is an automation on the collection it writes into
6
+ // (docs/architecture/content-databases.md): "a comparison post every 7 days" is
7
+ // a series with a comparison template and --every-days 7.
6
8
 
7
9
  import { CliError } from "../client.mjs";
8
- import { compact, flagStr, ok, printResult, requireFlag, requirePositional } from "./shared.mjs";
10
+ import { argsFromFlags, compact, flagStr, ok, printResult, refuseUnknownFlags, requirePositional } from "./shared.mjs";
11
+
12
+ /** `--coverage a,b,c` (or `--templates a,b`) → a list; absent → undefined. */
13
+ const commaList = (name) => (flags) => {
14
+ const v = flagStr(flags[name]);
15
+ return v === undefined
16
+ ? undefined
17
+ : v
18
+ .split(",")
19
+ .map((c) => c.trim())
20
+ .filter(Boolean);
21
+ };
22
+
23
+ /** `--every-days 7` → 7; `--every-days default` → null (the default pace, 7). */
24
+ const everyDays = (flags) => {
25
+ const v = flagStr(flags["every-days"]);
26
+ if (v === undefined) return undefined;
27
+ if (v === "default") return null;
28
+ const n = Number(v);
29
+ if (!Number.isInteger(n) || n < 1) throw new CliError(`--every-days must be a whole number of days or "default"`);
30
+ return n;
31
+ };
32
+
33
+ /**
34
+ * How every command in this file sources its tool inputs — see `argsFromFlags` in
35
+ * shared.mjs and `scripts/parity-check.ts`, which compares these keys against each tool's
36
+ * published input schema.
37
+ */
38
+ export const ARGS = {
39
+ create_series: {
40
+ name: { flag: "name", required: true },
41
+ description: { flag: "description" },
42
+ collection_id: { flag: "collection" },
43
+ template_key: { flag: "template" },
44
+ template_keys: { value: commaList("templates") },
45
+ coverage: { value: commaList("coverage") },
46
+ purpose: { flag: "purpose" },
47
+ every_days: { value: everyDays },
48
+ paused: { flag: "paused", type: "bool" },
49
+ },
50
+ update_series: {
51
+ series_id: { positional: 0, label: "series-id", required: true },
52
+ name: { flag: "name" },
53
+ description: { flag: "description" },
54
+ template_key: { flag: "template" },
55
+ template_keys: { value: commaList("templates") },
56
+ coverage: { value: commaList("coverage") },
57
+ purpose: { flag: "purpose" },
58
+ every_days: { value: everyDays },
59
+ paused: { flag: "pause", type: "bool", negate: "resume" },
60
+ // `--retire` is the delete (members keep their history). `--status` is a global value-less flag.
61
+ status: { value: (flags) => (flags.retire === true ? "retired" : undefined) },
62
+ },
63
+ queue_series_next: {
64
+ series_id: { positional: 0, label: "series-id", required: true },
65
+ collection_id: { flag: "collection", required: true },
66
+ },
67
+ };
9
68
 
10
69
  export async function cmdSeries(ctx) {
11
70
  const sub = ctx.positionals[0];
@@ -15,10 +74,16 @@ export async function cmdSeries(ctx) {
15
74
  return seriesList(rest);
16
75
  case "create":
17
76
  return seriesCreate(rest);
77
+ case "update":
78
+ return seriesUpdate(rest);
18
79
  case "backfill":
19
80
  return seriesBackfill(rest);
81
+ case "queue":
82
+ return seriesQueue(rest);
20
83
  default:
21
- throw new CliError(`Unknown series subcommand: ${sub ?? "(none)"}. Try: list, create, backfill`);
84
+ throw new CliError(
85
+ `Unknown series subcommand: ${sub ?? "(none)"}. Try: list, create, update, queue, backfill`
86
+ );
22
87
  }
23
88
  }
24
89
 
@@ -45,25 +110,41 @@ async function seriesList(ctx) {
45
110
 
46
111
  async function seriesCreate(ctx) {
47
112
  const { client, flags, io } = ctx;
48
- const coverage = flagStr(flags.coverage);
49
- const args = compact({
50
- name: requireFlag(flags, "name"),
51
- description: requireFlag(flags, "description"),
52
- collection_id: flagStr(flags.collection),
53
- template_key: flagStr(flags.template),
54
- coverage: coverage
55
- ? coverage
56
- .split(",")
57
- .map((c) => c.trim())
58
- .filter(Boolean)
59
- : undefined,
60
- });
61
- const result = await client.callTool("create_series", args);
113
+ refuseUnknownFlags(ctx, ARGS.create_series);
114
+ const result = await client.callTool("create_series", argsFromFlags(ARGS.create_series, flags, ctx.positionals));
62
115
  ok(ctx, "Series created — the queue starts covering its items.");
63
116
  printResult(io, flags, result);
64
117
  return 0;
65
118
  }
66
119
 
120
+ async function seriesUpdate(ctx) {
121
+ const { client, flags, io } = ctx;
122
+ refuseUnknownFlags(ctx, ARGS.update_series);
123
+ const args = argsFromFlags(ARGS.update_series, flags, ctx.positionals);
124
+ if (Object.keys(args).length < 2) {
125
+ throw new CliError(
126
+ "Nothing to update. Pass --every-days, --templates, --purpose, --description, --name, --coverage, --pause/--resume or --retire."
127
+ );
128
+ }
129
+ const result = await client.callTool("update_series", args);
130
+ ok(ctx, "Series updated.");
131
+ printResult(io, flags, result);
132
+ return 0;
133
+ }
134
+
135
+ /** `series queue <series-id> --collection <id>`: its next installment, now (queue_series_next). */
136
+ async function seriesQueue(ctx) {
137
+ const { client, flags, io } = ctx;
138
+ refuseUnknownFlags(ctx, ARGS.queue_series_next);
139
+ const result = await client.callTool(
140
+ "queue_series_next",
141
+ argsFromFlags(ARGS.queue_series_next, flags, ctx.positionals)
142
+ );
143
+ ok(ctx, `Queued "${result?.headline ?? "the next installment"}".`);
144
+ printResult(io, flags, result);
145
+ return 0;
146
+ }
147
+
67
148
  async function seriesBackfill(ctx) {
68
149
  const { client, flags, io } = ctx;
69
150
  const args = compact({
@@ -109,11 +109,17 @@ export const ARGS = {
109
109
  get_shredder_file: {
110
110
  endpoint_id: { positional: 0, label: "endpoint-id", required: true },
111
111
  },
112
+ // Live unless --candidate: a candidate changes nothing until `shredder promote` ships it.
112
113
  save_shredder_file: {
113
114
  endpoint_id: { positional: 0, label: "endpoint-id", required: true },
114
115
  file: { value: (flags) => readJsonFileFlag(flags, "file") },
115
116
  patch: { value: (flags) => readJsonObjectFlag(flags, "patch") },
116
117
  note: "note",
118
+ status: { value: (flags) => (flags.candidate === true ? "candidate" : undefined) },
119
+ },
120
+ promote_shredder_version: {
121
+ endpoint_id: { positional: 0, label: "endpoint-id", required: true },
122
+ version: { value: (_flags, positionals) => positiveInt(positionals[1], "version") },
117
123
  },
118
124
  list_shredder_versions: {
119
125
  endpoint_id: { positional: 0, label: "endpoint-id", required: true },
@@ -163,6 +169,8 @@ export async function cmdShredder(ctx) {
163
169
  return shredderFile(rest);
164
170
  case "versions":
165
171
  return shredderTool(rest, "list_shredder_versions");
172
+ case "promote":
173
+ return shredderPromote(rest);
166
174
  case "pin":
167
175
  return shredderTool(rest, "pin_shredder_version");
168
176
  case "offer":
@@ -171,7 +179,7 @@ export async function cmdShredder(ctx) {
171
179
  return shredderTool(rest, "get_shred_family_health");
172
180
  default:
173
181
  throw new CliError(
174
- `Unknown shredder subcommand: ${sub ?? "(none)"}. Try: list, create, delete, endpoint, run, job, runs, balance, file, versions, pin, offer, families`
182
+ `Unknown shredder subcommand: ${sub ?? "(none)"}. Try: list, create, delete, endpoint, run, job, runs, balance, file, promote, versions, pin, offer, families`
175
183
  );
176
184
  }
177
185
  }
@@ -366,9 +374,12 @@ async function shredderFile(ctx) {
366
374
  "save_shredder_file",
367
375
  argsFromFlags(ARGS.save_shredder_file, flags, ctx.positionals)
368
376
  );
377
+ const saved = toolField(result, "saved");
369
378
  ok(
370
379
  ctx,
371
- `Saved ${toolField(result, "version")?.label ?? "a new version"}; the endpoint shreds with it from now on.`
380
+ saved?.status === "candidate"
381
+ ? `Saved ${saved.label} as a candidate; ship it: ${ctx.bin} shredder promote ${ctx.positionals[0]} ${saved.version} --yes`
382
+ : `Saved ${saved?.label ?? toolField(result, "version")?.label ?? "a new version"}; the endpoint shreds with it from now on.`
372
383
  );
373
384
  printResult(io, flags, result);
374
385
  return 0;
@@ -393,3 +404,30 @@ async function shredderTool(ctx, tool) {
393
404
  printResult(ctx.io, ctx.flags, result);
394
405
  return 0;
395
406
  }
407
+
408
+ function positiveInt(raw, label) {
409
+ if (raw === undefined) throw new CliError(`Missing required <${label}>`);
410
+ const n = Number(raw);
411
+ if (!Number.isInteger(n) || n < 1) throw new CliError(`<${label}> must be a positive whole number (got "${raw}")`);
412
+ return n;
413
+ }
414
+
415
+ /** `shredder promote <endpoint-id> <version> --yes` — make a version live: ship a candidate, or roll back. */
416
+ async function shredderPromote(ctx) {
417
+ const { client, positionals, flags, io } = ctx;
418
+ const args = argsFromFlags(ARGS.promote_shredder_version, flags, positionals);
419
+ if (!flags.yes) {
420
+ io.error(
421
+ `Every shred through this endpoint will use version ${args.version} from its next call. Re-run with --yes to confirm: ${ctx.bin} shredder promote ${args.endpoint_id} ${args.version} --yes`
422
+ );
423
+ return 1;
424
+ }
425
+ const result = await client.callTool("promote_shredder_version", args);
426
+ const previous = toolField(result, "previous_live");
427
+ ok(
428
+ ctx,
429
+ `Version ${args.version} is live.${previous ? ` To undo: ${ctx.bin} shredder promote ${args.endpoint_id} ${previous} --yes` : ""}`
430
+ );
431
+ printResult(io, flags, result);
432
+ return 0;
433
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.121.0",
3
+ "version": "0.121.1",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",