@letterstory/cli 0.15.3 → 0.15.5

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
@@ -261,6 +261,32 @@ LETTERSTORY_STAFF_API_KEY=<your staff key with org:create> \
261
261
  Everything the parent job does is also its own tool for existing orgs:
262
262
  `ensure_collection_enhance_flow`, `provision_org_visuals`, `propose_seers` (`--args '{"apply":true}'`).
263
263
 
264
+ ### Propose, then onboard a phantom
265
+
266
+ The Fleet wizard's Plan and Name screens as two commands: pick the beat, then the name,
267
+ before anything is created. Both persist nothing and spend nothing (domain checks are
268
+ read-only). Save the proposal as JSON; the chosen plan travels by `--plan-file` + `--plan-id`,
269
+ so its direction AND its content areas are pinned on the site exactly as approved.
270
+
271
+ ```bash
272
+ # Step one: the beat. Omit the domain to use your org's own.
273
+ letterstory phantom-onboard propose acme.com --json > proposal.json
274
+ letterstory phantom-onboard propose acme.com --about "bank regulators on agentic AI" # variations on a beat
275
+
276
+ # Step two: the name — candidate mastheads for ONE plan, each with its own priced domains.
277
+ letterstory phantom-onboard mastheads acme.com --plan-file proposal.json --plan-id plan-2
278
+ jq '.topicPlans[1]' proposal.json | letterstory phantom-onboard mastheads acme.com --plan-file -
279
+
280
+ # Then onboard with the plan (direction + pillars), the masthead and, if any, the domain.
281
+ letterstory phantom-onboard acme.com --plan-file proposal.json --plan-id plan-2 \
282
+ --name "The Compliance Wire" --domain compliancewire.com --domain-mode buy_now --real
283
+ ```
284
+
285
+ `propose` reads the client's homepage and returns distinct editorial angles (`topicPlans`), the
286
+ single fallback plan, the client's existing publications with an overlap warning, and the org's
287
+ custom-domain headroom. `mastheads` returns names that never carry the client's own name. Without
288
+ `--plan-file`, `phantom-onboard <domain>` still plans from the homepage alone.
289
+
264
290
  ## Content series
265
291
 
266
292
  A series is a coverage obligation, not a keyword bet: "content that looks like this",
package/lib/cli.mjs CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  cmdDomains,
20
20
  cmdPosts,
21
21
  cmdPublished,
22
+ cmdReviewers,
22
23
  cmdCollections,
23
24
  cmdAuthors,
24
25
  cmdFlows,
@@ -43,7 +44,7 @@ import {
43
44
  } from "./commands.mjs";
44
45
 
45
46
  // Keep in sync with cli/package.json.
46
- export const VERSION = "0.15.3";
47
+ export const VERSION = "0.15.5";
47
48
 
48
49
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
49
50
  // can't accidentally swallow the id as --json's value.
@@ -183,6 +184,13 @@ Content:
183
184
  published list [--limit <n>] [--collection <uuid>] [--format html|md]
184
185
  published show (--id <uuid> | --slug <slug>) [--format html|md]
185
186
 
187
+ Reviewers:
188
+ reviewers standing list List the org's standing reviewers
189
+ reviewers standing add --email <e> [--name <n>] Auto-invite this contact as a
190
+ guest reviewer on every future
191
+ finished draft (no email sent now)
192
+ reviewers standing remove <reviewer-id> Stop future auto-invites
193
+
186
194
  Collections:
187
195
  collections list List your collections
188
196
  collections new --name <n> [--description <text>]
@@ -305,6 +313,26 @@ Phantom onboarding recipe (domain -> live phantom blog with first posts):
305
313
  create (it seeds the URL); pass it before
306
314
  applying. --domain-mode buy_now SPENDS
307
315
  money (2/org, $30 cap).
316
+ [--plan-file <path|-> --plan-id <id>] Pin a topic plan chosen with the propose
317
+ lane below: its direction AND pillars are
318
+ applied verbatim, so the site covers what
319
+ was approved.
320
+
321
+ Propose lane (the Fleet wizard's Plan and Name screens; persists nothing, spends nothing):
322
+ ${bin === "phantom" ? "onboard" : "phantom-onboard"} propose [<client-domain>] [--about "the beat"] [--name X] [--json]
323
+ Step ONE: read the client's homepage and
324
+ propose distinct editorial angles
325
+ (topicPlans), the fallback plan, the
326
+ client's existing publications (overlap
327
+ warning) and domain headroom. Omit the
328
+ domain to use the org's own. Save it with
329
+ --json > proposal.json for step two.
330
+ ${bin === "phantom" ? "onboard" : "phantom-onboard"} mastheads <client-domain> --plan-file <path|-> [--plan-id <id>]
331
+ Step TWO: names for ONE chosen plan, each
332
+ with its own priced, buyable domains.
333
+ --plan-file takes a saved proposal (then
334
+ --plan-id picks the plan) or a single plan
335
+ (--plan '<json>' inline, or - for stdin).
308
336
 
309
337
  Collection onboarding (your OWN blog, on your org's domain — no provisioning):
310
338
  ${bin === "phantom" ? "onboard" : "phantom-onboard"} collection [--collection <uuid>] [--name "<collection name>"]
@@ -402,6 +430,10 @@ Shredder (diversify content produced outside Letterstory; structure preserved):
402
430
  shredder create --name <n> New endpoint (returns a callable public id)
403
431
  shredder delete <endpoint-id> --yes
404
432
  shredder run <endpoint-id> (--body <text>|--file <path>|--file -) [--coverage <0..1>] [--attempts <n>]
433
+ [--aggression standard|high|max]
434
+ standard (default) keeps the piece's own terminology;
435
+ high rewrites more and may reword recurring terms;
436
+ max rewrites hardest — expect vocabulary drift
405
437
  Shred prose through an endpoint (logs telemetry)
406
438
  shredder runs [--endpoint <id>] [--limit <n>] Recent shred calls (telemetry), newest first
407
439
 
@@ -462,6 +494,7 @@ const CLIENT_COMMANDS = {
462
494
  domains: cmdDomains,
463
495
  posts: cmdPosts,
464
496
  published: cmdPublished,
497
+ reviewers: cmdReviewers,
465
498
  collections: cmdCollections,
466
499
  authors: cmdAuthors,
467
500
  flows: cmdFlows,
@@ -33,8 +33,28 @@
33
33
  // map, binds SEO demand, and stocks the Headlines tab. `--watch` polls to completion,
34
34
  // printing each review note as the job writes it.
35
35
 
36
+ // `onboard propose` / `onboard mastheads` are the PROPOSE lane — the Fleet wizard's Plan and
37
+ // Name screens, driven by the same two tools those screens call (`propose_phantom_plan`,
38
+ // `propose_phantom_mastheads`) — so a script or an agent can pick the beat, then the name,
39
+ // BEFORE `onboard <domain>` commits anything. Both persist nothing and spend nothing (domain
40
+ // checks are read-only). The chosen topic plan travels as JSON: save the proposal with
41
+ // `--json > proposal.json`, then hand it back with `--plan-file proposal.json --plan-id <id>`
42
+ // (or one plan on stdin with `--plan-file -`). `onboard <domain> --plan-file … --plan-id …`
43
+ // pins that plan's direction AND pillars verbatim, which is what makes an approved plan
44
+ // binding instead of a preview the topic map may wander from.
45
+
36
46
  import { CliError } from "../client.mjs";
37
- import { flagStr, flagNum, flagBool, requirePositional, printResult, compact, ok, pollUntil } from "./shared.mjs";
47
+ import {
48
+ flagStr,
49
+ flagNum,
50
+ flagBool,
51
+ requirePositional,
52
+ printResult,
53
+ compact,
54
+ ok,
55
+ pollUntil,
56
+ readTextInput,
57
+ } from "./shared.mjs";
38
58
 
39
59
  const COVER_STYLES = ["editorial_image", "full_ai", "line_art"];
40
60
  const DOMAIN_MODES = ["subdomain", "buy_now", "connect_later"];
@@ -75,8 +95,145 @@ function formatStatus(s) {
75
95
  return lines.join("\n");
76
96
  }
77
97
 
98
+ // The topic plan a step-two command needs, as JSON from --plan '<json>' or --plan-file
99
+ // <path|->. Accepts either one plan object (as `propose` lists them under topicPlans) or a
100
+ // whole saved proposal (`propose … --json`), in which case --plan-id names which of its
101
+ // topic plans to use. Returns undefined when neither flag was given.
102
+ export function readTopicPlan(flags) {
103
+ const raw = readTextInput(flags, "plan", "plan-file");
104
+ if (raw === undefined) return undefined;
105
+ let doc;
106
+ try {
107
+ doc = JSON.parse(raw);
108
+ } catch (err) {
109
+ throw new CliError(`--plan is not valid JSON: ${err.message}`);
110
+ }
111
+ const planId = flagStr(flags["plan-id"]);
112
+ if (doc && Array.isArray(doc.topicPlans)) {
113
+ const ids = doc.topicPlans.map((p) => p.id).join(", ") || "none";
114
+ if (!planId) {
115
+ throw new CliError(`That is a whole proposal; pass --plan-id to pick one of its topic plans (${ids}).`);
116
+ }
117
+ const plan = doc.topicPlans.find((p) => p.id === planId);
118
+ if (!plan) throw new CliError(`No topic plan "${planId}" in the proposal (have: ${ids}).`);
119
+ return plan;
120
+ }
121
+ if (!doc || typeof doc !== "object" || typeof doc.direction !== "string" || !Array.isArray(doc.pillars)) {
122
+ throw new CliError(
123
+ "--plan must be one topic plan {id, title, direction, pillars} as `propose` returns them, or a saved --json proposal plus --plan-id."
124
+ );
125
+ }
126
+ return doc;
127
+ }
128
+
129
+ // The recipe's spelling under this binary, for next-step hints.
130
+ function recipeName(ctx) {
131
+ return ctx.bin === "phantom" ? "onboard" : "phantom-onboard";
132
+ }
133
+
134
+ // A priced domain candidate on one line: what it costs, or why it can't be bought.
135
+ function formatDomainSuggestion(d) {
136
+ if (!d.available) return `${d.domain} (taken)`;
137
+ const price = d.purchasePrice === null || d.purchasePrice === undefined ? "unpriced" : `$${d.purchasePrice}`;
138
+ const renews = d.renewalPrice === null || d.renewalPrice === undefined ? "" : `, renews $${d.renewalPrice}`;
139
+ return `${d.domain} ${price}${renews}${d.purchasable ? "" : " — over the cap"}`;
140
+ }
141
+
142
+ function formatTopicPlan(p) {
143
+ const lines = [` ${p.id} ${p.title}`, ` direction: ${p.direction}`];
144
+ lines.push(` pillars: ${(p.pillars ?? []).map((x) => x.name).join(" · ") || "(none)"}`);
145
+ if (p.rationale) lines.push(` why: ${p.rationale}`);
146
+ return lines;
147
+ }
148
+
149
+ function formatProposal(r) {
150
+ const plan = r.plan;
151
+ const lines = [
152
+ `Proposal for ${r.clientDomain} (client: ${r.clientName})`,
153
+ ` fallback plan: ${plan.name ?? "(no name — pass --name)"} — ${plan.description}`,
154
+ ` defaults: ${plan.defaults.cadence_per_week}/week · ${plan.defaults.cover_canvas} · seed ${plan.defaults.posts_to_seed}`,
155
+ ];
156
+ if (r.topicPlans?.length) {
157
+ lines.push(` topic plans (pick one):`);
158
+ for (const p of r.topicPlans) lines.push(...formatTopicPlan(p));
159
+ } else {
160
+ lines.push(` topic plans: (none proposed — the fallback plan is the answer)`);
161
+ }
162
+ if (r.siblings?.length) {
163
+ lines.push(` this client already publishes:`);
164
+ for (const s of r.siblings) lines.push(` - ${s.name}${s.direction ? ` — ${s.direction}` : ""}`);
165
+ }
166
+ if (r.overlapWarning) lines.push(` overlap: ${r.overlapWarning}`);
167
+ if (r.domains?.length) {
168
+ lines.push(` domains (for the fallback masthead):`);
169
+ for (const d of r.domains) lines.push(` - ${formatDomainSuggestion(d)}`);
170
+ }
171
+ lines.push(
172
+ ` domain headroom: ${r.domainHeadroom === null || r.domainHeadroom === undefined ? "unlimited" : `${r.domainHeadroom} more custom domain(s)`}`
173
+ );
174
+ lines.push(` note: ${plan.note}`);
175
+ return lines.join("\n");
176
+ }
177
+
178
+ function formatMastheads(r, plan) {
179
+ if (!r.options?.length) {
180
+ return `No mastheads proposed for "${plan.title}" (model unavailable) — keep the fallback plan's name.`;
181
+ }
182
+ const lines = [`Mastheads for "${plan.title}" (best first):`];
183
+ r.options.forEach((o, i) => {
184
+ lines.push(` ${i + 1}. ${o.masthead} — ${o.description}`);
185
+ if (o.domains?.length) lines.push(` domains: ${o.domains.map(formatDomainSuggestion).join(" · ")}`);
186
+ });
187
+ return lines.join("\n");
188
+ }
189
+
190
+ // `onboard propose [<client-domain>]` — step ONE of the propose lane. The domain is optional
191
+ // because the tool falls back to the org's own recorded domain (the key's org IS the client).
192
+ async function cmdOnboardPropose(ctx) {
193
+ const { client, flags, io } = ctx;
194
+ const args = compact({
195
+ client_domain: ctx.positionals[0],
196
+ direction: flagStr(flags.about) ?? flagStr(flags.direction),
197
+ name: flagStr(flags.name),
198
+ });
199
+ ok(ctx, "Proposing…");
200
+ const proposal = await client.callTool("propose_phantom_plan", args);
201
+ printResult(io, flags, proposal, formatProposal);
202
+ if (proposal.topicPlans?.length) {
203
+ ok(
204
+ ctx,
205
+ `Next: save this with --json > proposal.json, pick a plan, then ${recipeName(ctx)} mastheads ${proposal.clientDomain} --plan-file proposal.json --plan-id <id>`
206
+ );
207
+ }
208
+ return 0;
209
+ }
210
+
211
+ // `onboard mastheads <client-domain> --plan-file <path|-> [--plan-id <id>]` — step TWO.
212
+ async function cmdOnboardMastheads(ctx) {
213
+ const { client, flags, io } = ctx;
214
+ const clientDomain = requirePositional(ctx.positionals, 0, "client-domain");
215
+ const plan = readTopicPlan(flags);
216
+ if (!plan) {
217
+ throw new CliError(
218
+ "onboard mastheads needs the chosen topic plan: --plan '<json>' or --plan-file <path|-> (add --plan-id when the file is a whole proposal)."
219
+ );
220
+ }
221
+ ok(ctx, `Proposing mastheads for "${plan.title}"…`);
222
+ const result = await client.callTool("propose_phantom_mastheads", { client_domain: clientDomain, plan });
223
+ printResult(io, flags, result, (r) => formatMastheads(r, plan));
224
+ if (result.options?.length) {
225
+ ok(
226
+ ctx,
227
+ `Next: ${recipeName(ctx)} ${clientDomain} --name "<masthead>" --plan-file proposal.json --plan-id ${plan.id} [--domain <host> --domain-mode buy_now] [--real]`
228
+ );
229
+ }
230
+ return 0;
231
+ }
232
+
78
233
  // Build the overrides object the tool accepts from the CLI flags (snake_case keys).
79
234
  function overridesFromFlags(flags) {
235
+ // A chosen topic plan pins direction + pillars; an explicit --about still wins on direction.
236
+ const plan = readTopicPlan(flags);
80
237
  const cover = flagStr(flags.cover);
81
238
  if (cover !== undefined && !COVER_STYLES.includes(cover)) {
82
239
  throw new CliError(`--cover must be one of: ${COVER_STYLES.join(", ")}`);
@@ -99,7 +256,9 @@ function overridesFromFlags(flags) {
99
256
  posts_to_seed: flagNum(flags.posts),
100
257
  // The highest-leverage override: it becomes the planner's steering prompt, so it
101
258
  // shapes the topic map and every post, not just the masthead.
102
- direction: flagStr(flags.about) ?? flagStr(flags.direction),
259
+ direction: flagStr(flags.about) ?? flagStr(flags.direction) ?? plan?.direction,
260
+ // The approved plan's content areas become the topic map's pillars VERBATIM.
261
+ pillars: plan?.pillars,
103
262
  site_domain: siteDomain,
104
263
  domain_mode: domainMode,
105
264
  is_test: flagBool(flags["real"]) ? false : undefined,
@@ -380,6 +539,13 @@ export async function cmdOnboard(ctx) {
380
539
  if (ctx.positionals[0] === "sync-sitemap") {
381
540
  return cmdOnboardSyncSitemap({ ...ctx, positionals: ctx.positionals.slice(1) });
382
541
  }
542
+ // `onboard propose [<client-domain>]` / `onboard mastheads <client-domain>` — the propose lane.
543
+ if (ctx.positionals[0] === "propose") {
544
+ return cmdOnboardPropose({ ...ctx, positionals: ctx.positionals.slice(1) });
545
+ }
546
+ if (ctx.positionals[0] === "mastheads") {
547
+ return cmdOnboardMastheads({ ...ctx, positionals: ctx.positionals.slice(1) });
548
+ }
383
549
  const domain = requirePositional(ctx.positionals, 0, "domain");
384
550
  const overrides = overridesFromFlags(flags);
385
551
 
@@ -399,7 +565,8 @@ export async function cmdOnboard(ctx) {
399
565
  if (!flagBool(flags.accept) && !overrideGiven) {
400
566
  ok(
401
567
  ctx,
402
- "Re-run with --accept to apply, or pass --about/--name/--cadence/--cover/--posts/--theme/--domain to adjust. " +
568
+ "Re-run with --accept to apply, or pass --about/--name/--cadence/--cover/--posts/--theme/--domain to adjust " +
569
+ `(or pick the beat and name first: ${recipeName(ctx)} propose / mastheads). ` +
403
570
  "Note: --name is committed at create (it seeds the URL)."
404
571
  );
405
572
  return 0;
@@ -0,0 +1,62 @@
1
+ // `reviewers standing` — an org's standing reviewers (organization_standing_reviewers):
2
+ // external contacts auto-invited as a guest reviewer on every article that reaches a
3
+ // finished, never-published draft (src/lib/services/standing-reviewers.ts + the
4
+ // notify-standing-reviewers cron), instead of inviting them by hand on each article.
5
+ // Same flag/positional/io conventions as posts.mjs — no commander, no color.
6
+
7
+ import { CliError } from "../client.mjs";
8
+ import { flagStr, requireFlag, requirePositional, printResult, compact, ok } from "./shared.mjs";
9
+
10
+ export async function cmdReviewers(ctx) {
11
+ const sub = ctx.positionals[0];
12
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
13
+ switch (sub) {
14
+ case "standing":
15
+ return cmdStandingReviewers(rest);
16
+ default:
17
+ throw new CliError(`Unknown reviewers subcommand: ${sub ?? "(none)"}. Try: standing`);
18
+ }
19
+ }
20
+
21
+ async function cmdStandingReviewers(ctx) {
22
+ const sub = ctx.positionals[0];
23
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
24
+ switch (sub) {
25
+ case "list":
26
+ case "ls":
27
+ return standingList(rest);
28
+ case "add":
29
+ return standingAdd(rest);
30
+ case "remove":
31
+ case "rm":
32
+ return standingRemove(rest);
33
+ default:
34
+ throw new CliError(`Unknown reviewers standing subcommand: ${sub ?? "(none)"}. Try: list, add, remove`);
35
+ }
36
+ }
37
+
38
+ async function standingList(ctx) {
39
+ const { client, flags, io } = ctx;
40
+ const result = await client.callTool("list_standing_reviewers", {});
41
+ printResult(io, flags, result);
42
+ return 0;
43
+ }
44
+
45
+ async function standingAdd(ctx) {
46
+ const { client, flags, io } = ctx;
47
+ const email = requireFlag(flags, "email");
48
+ const args = compact({ email, name: flagStr(flags.name) });
49
+ const result = await client.callTool("add_standing_reviewer", args);
50
+ ok(ctx, `Added ${email} as a standing reviewer.`);
51
+ printResult(io, flags, result);
52
+ return 0;
53
+ }
54
+
55
+ async function standingRemove(ctx) {
56
+ const { client, positionals, flags, io } = ctx;
57
+ const id = requirePositional(positionals, 0, "reviewer-id");
58
+ const result = await client.callTool("remove_standing_reviewer", { reviewer_id: id });
59
+ ok(ctx, "Removed.");
60
+ printResult(io, flags, result);
61
+ return 0;
62
+ }
@@ -89,16 +89,20 @@ export async function pollUntil(io, poll, opts = {}) {
89
89
  }
90
90
  }
91
91
 
92
- // Read a text body from --body, or --file (a path, or "-" for stdin). Returns
93
- // undefined if neither flag was given, so callers can decide whether that's an error.
94
- export function readBodyInput(flags) {
95
- const body = flagStr(flags.body);
96
- if (body !== undefined) return body;
97
- const file = flagStr(flags.file);
92
+ // Read a text value from an inline flag, or from a file flag (a path, or "-" for
93
+ // stdin). Returns undefined if neither flag was given, so callers can decide whether
94
+ // that's an error. `readBodyInput` is the --body/--file pairing; the propose lane reads
95
+ // a topic plan the same way through --plan/--plan-file.
96
+ export function readTextInput(flags, valueFlag, fileFlag) {
97
+ const inline = flagStr(flags[valueFlag]);
98
+ if (inline !== undefined) return inline;
99
+ const file = flagStr(flags[fileFlag]);
98
100
  if (file === undefined) return undefined;
99
101
  if (file === "-") {
100
102
  if (process.stdin.isTTY) {
101
- throw new CliError("No data on stdin. Pipe input in, or use --body/--file <path> instead of --file -.");
103
+ throw new CliError(
104
+ `No data on stdin. Pipe input in, or use --${valueFlag}/--${fileFlag} <path> instead of --${fileFlag} -.`
105
+ );
102
106
  }
103
107
  try {
104
108
  return readFileSync(0, "utf8");
@@ -109,6 +113,11 @@ export function readBodyInput(flags) {
109
113
  try {
110
114
  return readFileSync(file, "utf8");
111
115
  } catch (err) {
112
- throw new CliError(`Could not read --file ${file}: ${err.message}`);
116
+ throw new CliError(`Could not read --${fileFlag} ${file}: ${err.message}`);
113
117
  }
114
118
  }
119
+
120
+ // Read a text body from --body, or --file (a path, or "-" for stdin).
121
+ export function readBodyInput(flags) {
122
+ return readTextInput(flags, "body", "file");
123
+ }
@@ -80,6 +80,8 @@ async function shredderRun(ctx) {
80
80
  text,
81
81
  coverage: flagNum(flags.coverage),
82
82
  attempts_per_unit: flagNum(flags.attempts),
83
+ // How hard to rewrite. Validated service-side; sent only when the caller asked.
84
+ ...(flags.aggression ? { aggression: String(flags.aggression) } : {}),
83
85
  });
84
86
  const result = await client.callTool("run_shred", args);
85
87
  ok(ctx, "Shred complete.");
package/lib/commands.mjs CHANGED
@@ -9,6 +9,7 @@ export * from "./commands/discovery.mjs";
9
9
  export * from "./commands/deploy.mjs";
10
10
  export * from "./commands/mcp.mjs";
11
11
  export * from "./commands/posts.mjs";
12
+ export * from "./commands/reviewers.mjs";
12
13
  export * from "./commands/collections.mjs";
13
14
  export * from "./commands/authors.mjs";
14
15
  export * from "./commands/flows.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.15.3",
3
+ "version": "0.15.5",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",