@letterstory/cli 0.19.0 → 0.23.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.
@@ -17,6 +17,12 @@
17
17
  // so to use a name other than the inferred one, pass --name BEFORE applying. The first
18
18
  // posts are written by background jobs (20-40min each); they're reported as job ids and
19
19
  // this command does not block on them — poll with `phantom-job status <job-id>`.
20
+ //
21
+ // `--accept-plan <path|->` closes the review loop: the apply PINS the plan instead of
22
+ // recomputing it, so the masthead that was reviewed is the masthead that is committed. In one
23
+ // command the dry run's plan is carried into the apply automatically; the flag is for the
24
+ // two-command shape (`onboard acme.com --json > plan.json`, read it, then `onboard acme.com
25
+ // --accept-plan plan.json`), which is what an agent or a script actually does.
20
26
 
21
27
  // `onboard domain` is the ONE-SHOT parent: a bare company domain in (the org's registered
22
28
  // domain, from the API key), a fully-configured organization out — the main blog onboarded
@@ -46,18 +52,119 @@
46
52
  import { CliError } from "../client.mjs";
47
53
  import {
48
54
  flagStr,
49
- flagNum,
50
55
  flagBool,
51
56
  requirePositional,
52
57
  printResult,
58
+ refuseUnknownFlags,
53
59
  compact,
54
60
  ok,
55
61
  pollUntil,
56
62
  readTextInput,
63
+ argsFromFlags,
57
64
  } from "./shared.mjs";
58
65
 
59
66
  const COVER_STYLES = ["editorial_image", "full_ai", "line_art"];
60
67
  const DOMAIN_MODES = ["subdomain", "buy_now", "connect_later"];
68
+ const SEER_MODES = ["suggest", "skip"];
69
+ const CMS_PROVIDERS = ["webflow", "framer", "sanity", "contentful"];
70
+ const APPROVAL_MODES = ["autopilot", "review_window", "manual"];
71
+
72
+ // How every tool this module calls gets its arguments — see `argsFromFlags` in shared.mjs.
73
+ // `scripts/parity-check.ts` compares these keys against each tool's published input schema.
74
+ export const ARGS = {
75
+ // The router read. It takes no arguments at all — the org comes from the API
76
+ // key — but it still declares an entry, because the parity ratchet's rule is
77
+ // that a curated command's tool is DECLARED, not that it has flags. An empty
78
+ // map says "no inputs" explicitly, where a missing one says "nobody checked".
79
+ get_onboarding_plan: {},
80
+ onboard_collection: {
81
+ collection_id: "collection",
82
+ collection_name: "name",
83
+ direction: ["about", "direction"],
84
+ sitemap_url: "sitemap",
85
+ // Source the existing blog from a connected CMS instead of a sitemap.
86
+ cms_provider: { flag: "cms", enum: CMS_PROVIDERS },
87
+ // Planner activation (#1731): the same one-call activation the app's Guided review
88
+ // step performs. Off unless asked for; needs a user-scoped key (an acting user).
89
+ enable_planner: { flag: "enable-planner", type: "bool" },
90
+ cadence_per_week: { flag: "cadence", type: "num" },
91
+ approval_mode: { flag: "approval", enum: APPROVAL_MODES },
92
+ },
93
+ get_collection_onboard_status: {
94
+ job_id: { internal: "the job id onboard_collection just returned" },
95
+ },
96
+ sync_collection_sitemap: {
97
+ collection_id: "collection",
98
+ },
99
+ onboard_domain: {
100
+ direction: ["about", "direction"],
101
+ sitemap_url: "sitemap",
102
+ cms_provider: { flag: "cms", enum: CMS_PROVIDERS },
103
+ collection_name: "name",
104
+ phantom: {
105
+ object: {
106
+ enabled: { flag: "no-phantom", type: "bool", invert: true },
107
+ posts_to_seed: { flag: "posts", type: "num" },
108
+ name: "phantom-name",
109
+ site_domain: "site-domain",
110
+ domain_mode: { flag: "domain-mode", enum: DOMAIN_MODES },
111
+ },
112
+ },
113
+ seers: { flag: "seers", enum: SEER_MODES },
114
+ visuals: { flag: "no-visuals", type: "bool", invert: true },
115
+ enhance: { flag: "no-enhance", type: "bool", invert: true },
116
+ is_test: { flag: "test", type: "bool" },
117
+ readiness_minutes: { flag: "readiness-minutes", type: "num" },
118
+ client_email: "client-email",
119
+ },
120
+ get_domain_onboard_status: {
121
+ job_id: "attach",
122
+ },
123
+ onboard_phantom: {
124
+ domain: { positional: 0, label: "domain", required: true },
125
+ // --accept gates the two-call flow below (dry run, then apply) rather than being
126
+ // forwarded: the first call never sends it, the second always sends true.
127
+ accept: { internal: "false on the dry run, true on apply — decided by the command" },
128
+ // The reviewed plan, pinned so the apply does not recompute the masthead the user
129
+ // just approved (#1773 / C2). Like `accept`, the command decides it rather than a
130
+ // flag mapping straight through: it comes from `--accept-plan <path|->` /
131
+ // `--accept-plan-json`, or from the dry run this same command just printed.
132
+ plan: { internal: "the reviewed plan — from --accept-plan, or the dry run just printed" },
133
+ overrides: {
134
+ object: {
135
+ name: "name",
136
+ cadence_per_week: { flag: "cadence", type: "num" },
137
+ cover_canvas: { flag: "cover", enum: COVER_STYLES },
138
+ theme: "theme",
139
+ posts_to_seed: { flag: "posts", type: "num" },
140
+ // The highest-leverage override: it becomes the planner's steering prompt, so it
141
+ // shapes the topic map and every post, not just the masthead. A chosen topic
142
+ // plan (--plan-file) supplies it when no --about is given.
143
+ direction: {
144
+ value: (flags, _p, extra) =>
145
+ flagStr(flags.about) ?? flagStr(flags.direction) ?? extra.plan?.direction,
146
+ },
147
+ // The approved plan's content areas become the topic map's pillars VERBATIM.
148
+ pillars: { value: (_f, _p, extra) => extra.plan?.pillars },
149
+ site_domain: "domain",
150
+ domain_mode: { flag: "domain-mode", enum: DOMAIN_MODES },
151
+ is_test: { flag: "real", type: "bool", invert: true },
152
+ },
153
+ },
154
+ },
155
+ get_phantom_onboard_status: {
156
+ job_id: { internal: "the job id onboard_phantom just returned" },
157
+ },
158
+ propose_phantom_plan: {
159
+ client_domain: { positional: 0, label: "client-domain" },
160
+ direction: ["about", "direction"],
161
+ name: "name",
162
+ },
163
+ propose_phantom_mastheads: {
164
+ client_domain: { positional: 0, label: "client-domain", required: true },
165
+ plan: { value: (_f, _p, extra) => extra.plan },
166
+ },
167
+ };
61
168
 
62
169
  function formatPlan(plan) {
63
170
  const lines = [
@@ -126,6 +233,38 @@ export function readTopicPlan(flags) {
126
233
  return doc;
127
234
  }
128
235
 
236
+ // The plan a caller already reviewed, handed back to be applied VERBATIM: `--accept-plan
237
+ // <path|->` (or `--accept-plan-json '<json>'`). Accepts either the plan object itself or a
238
+ // saved dry-run response, whose `plan` key holds it — so `onboard <domain> --json > plan.json`
239
+ // then `onboard <domain> --accept-plan plan.json` is the whole review-then-accept loop.
240
+ //
241
+ // Why it exists: without a pinned plan the apply RE-PLANS — a second homepage read and a
242
+ // second masthead proposal, which can answer differently than the one just approved, and
243
+ // that name is committed into the URL when the site is created.
244
+ export function readAcceptedPlan(flags) {
245
+ const raw = readTextInput(flags, "accept-plan-json", "accept-plan");
246
+ if (raw === undefined) return undefined;
247
+ let doc;
248
+ try {
249
+ doc = JSON.parse(raw);
250
+ } catch (err) {
251
+ throw new CliError(`--accept-plan is not valid JSON: ${err.message}`);
252
+ }
253
+ // A saved dry-run response wraps the plan; the plan itself does not.
254
+ const plan = doc && typeof doc === "object" && doc.plan && typeof doc.plan === "object" ? doc.plan : doc;
255
+ if (!plan || typeof plan !== "object" || typeof plan.description !== "string" || !plan.defaults) {
256
+ throw new CliError(
257
+ "--accept-plan must be the `plan` object from an onboard dry run (or the whole saved --json response)."
258
+ );
259
+ }
260
+ if (!plan.name) {
261
+ throw new CliError(
262
+ "That plan has no masthead name, and the name is committed at create. Re-run the dry run with --name <MastheadName> and accept the plan it returns."
263
+ );
264
+ }
265
+ return plan;
266
+ }
267
+
129
268
  // The recipe's spelling under this binary, for next-step hints.
130
269
  function recipeName(ctx) {
131
270
  return ctx.bin === "phantom" ? "onboard" : "phantom-onboard";
@@ -191,11 +330,7 @@ function formatMastheads(r, plan) {
191
330
  // because the tool falls back to the org's own recorded domain (the key's org IS the client).
192
331
  async function cmdOnboardPropose(ctx) {
193
332
  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
- });
333
+ const args = argsFromFlags(ARGS.propose_phantom_plan, flags, ctx.positionals);
199
334
  ok(ctx, "Proposing…");
200
335
  const proposal = await client.callTool("propose_phantom_plan", args);
201
336
  printResult(io, flags, proposal, formatProposal);
@@ -219,7 +354,10 @@ async function cmdOnboardMastheads(ctx) {
219
354
  );
220
355
  }
221
356
  ok(ctx, `Proposing mastheads for "${plan.title}"…`);
222
- const result = await client.callTool("propose_phantom_mastheads", { client_domain: clientDomain, plan });
357
+ const result = await client.callTool(
358
+ "propose_phantom_mastheads",
359
+ argsFromFlags(ARGS.propose_phantom_mastheads, flags, ctx.positionals, { plan })
360
+ );
223
361
  printResult(io, flags, result, (r) => formatMastheads(r, plan));
224
362
  if (result.options?.length) {
225
363
  ok(
@@ -234,35 +372,13 @@ async function cmdOnboardMastheads(ctx) {
234
372
  function overridesFromFlags(flags) {
235
373
  // A chosen topic plan pins direction + pillars; an explicit --about still wins on direction.
236
374
  const plan = readTopicPlan(flags);
237
- const cover = flagStr(flags.cover);
238
- if (cover !== undefined && !COVER_STYLES.includes(cover)) {
239
- throw new CliError(`--cover must be one of: ${COVER_STYLES.join(", ")}`);
240
- }
241
- const domainMode = flagStr(flags["domain-mode"]);
242
- if (domainMode !== undefined && !DOMAIN_MODES.includes(domainMode)) {
243
- throw new CliError(`--domain-mode must be one of: ${DOMAIN_MODES.join(", ")}`);
244
- }
245
- const siteDomain = flagStr(flags.domain);
375
+ const overrides = argsFromFlags(ARGS.onboard_phantom.overrides.object, flags, [], { plan });
246
376
  // buy_now spends real money against a 2-per-org cap; refusing the mismatch here beats
247
377
  // silently downgrading it to a subdomain launch the caller didn't ask for.
248
- if (domainMode && domainMode !== "subdomain" && !siteDomain) {
249
- throw new CliError(`--domain-mode ${domainMode} needs --domain <bare-domain>`);
250
- }
251
- return compact({
252
- name: flagStr(flags.name),
253
- cadence_per_week: flagNum(flags.cadence),
254
- cover_canvas: cover,
255
- theme: flagStr(flags.theme),
256
- posts_to_seed: flagNum(flags.posts),
257
- // The highest-leverage override: it becomes the planner's steering prompt, so it
258
- // shapes the topic map and every post, not just the masthead.
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,
262
- site_domain: siteDomain,
263
- domain_mode: domainMode,
264
- is_test: flagBool(flags["real"]) ? false : undefined,
265
- });
378
+ if (overrides.domain_mode && overrides.domain_mode !== "subdomain" && !overrides.site_domain) {
379
+ throw new CliError(`--domain-mode ${overrides.domain_mode} needs --domain <bare-domain>`);
380
+ }
381
+ return overrides;
266
382
  }
267
383
 
268
384
  // Final readback of a collection-onboarding job — the counts the engine PERSISTED.
@@ -282,7 +398,9 @@ function formatCollectionStatus(s) {
282
398
  ` series: ${r.series ?? 0}`,
283
399
  ` open topics: ${r.spokes_open ?? 0} (${r.spokes_seo_bound ?? 0} SEO-bound)`,
284
400
  ` headlines suggested: ${r.headlines_suggested ?? 0}`,
285
- ` Review them in Headlines & Queue — the Planner is configured but OFF (manual approval).`
401
+ s.enable_planner
402
+ ? ` Planner: ON — ${s.cadence_per_week ?? "?"}/week, ${s.approval_mode ?? "manual"} approval, queue filled.`
403
+ : ` Review them in Headlines & Queue — the Planner is configured but OFF (pass --enable-planner to turn it on).`
286
404
  );
287
405
  }
288
406
  return lines.join("\n");
@@ -302,14 +420,38 @@ async function cmdOnboardCollection(ctx) {
302
420
  );
303
421
  }
304
422
 
305
- const args = compact({
306
- collection_id: flagStr(flags.collection),
307
- collection_name: flagStr(flags.name),
308
- direction: flagStr(flags.about) ?? flagStr(flags.direction),
309
- sitemap_url: flagStr(flags.sitemap),
310
- // Source the existing blog from a connected CMS instead of a sitemap.
311
- cms_provider: flagStr(flags.cms),
312
- });
423
+ // READ PATHS FIRST, before anything that writes. `--status` and `--attach` mean here
424
+ // exactly what they mean on `onboard domain`: show me the job, do not start one.
425
+ // They had no branch at all, so they fell through to onboard_collection and STARTED A
426
+ // SECOND ONBOARDING — a read verb that spent money and left a duplicate collection.
427
+ const attach = flagStr(flags.attach);
428
+ if (attach) {
429
+ const s = await client.callTool("get_collection_onboard_status", { job_id: attach });
430
+ printResult(ctx.io, flags, s, formatCollectionStatus);
431
+ return 0;
432
+ }
433
+ if (flagBool(flags.status)) {
434
+ const collectionId = flagStr(flags.collection);
435
+ const s = await client.callTool(
436
+ "get_collection_onboard_status",
437
+ collectionId ? { collection_id: collectionId } : {}
438
+ );
439
+ printResult(ctx.io, flags, s, formatCollectionStatus);
440
+ return 0;
441
+ }
442
+
443
+ // Everything below WRITES. A flag we do not understand is refused rather than ignored:
444
+ // the ARGS map that builds the call is the list of what may be passed to it.
445
+ refuseUnknownFlags(ctx, ARGS.onboard_collection, ["status", "attach", "watch", "no-wait", "domain"]);
446
+
447
+ const args = argsFromFlags(ARGS.onboard_collection, flags, ctx.positionals);
448
+ // --cadence / --approval only mean something once the Planner is being turned on; a
449
+ // silently ignored flag is exactly the kind of invisible opinion this lane exists to remove.
450
+ if ((args.cadence_per_week !== undefined || args.approval_mode !== undefined) && !args.enable_planner) {
451
+ throw new CliError(
452
+ "--cadence and --approval only apply with --enable-planner (the Planner stays OFF otherwise)."
453
+ );
454
+ }
313
455
 
314
456
  ok(ctx, "Starting collection onboarding…");
315
457
  const started = await client.callTool("onboard_collection", args);
@@ -357,13 +499,13 @@ async function cmdOnboardCollection(ctx) {
357
499
  // bounded per run, so it returns synchronously.
358
500
  async function cmdOnboardSyncSitemap(ctx) {
359
501
  const { client, flags } = ctx;
360
- const collectionId = flagStr(flags.collection);
361
- if (!collectionId) {
502
+ const args = argsFromFlags(ARGS.sync_collection_sitemap, flags, ctx.positionals);
503
+ if (!args.collection_id) {
362
504
  throw new CliError("onboard sync-sitemap requires --collection <uuid>.");
363
505
  }
364
506
 
365
507
  ok(ctx, "Re-checking the sitemap…");
366
- const result = await client.callTool("sync_collection_sitemap", { collection_id: collectionId });
508
+ const result = await client.callTool("sync_collection_sitemap", args);
367
509
 
368
510
  printResult(ctx.io, flags, result, (r) =>
369
511
  [
@@ -383,9 +525,6 @@ async function cmdOnboardSyncSitemap(ctx) {
383
525
  return 0;
384
526
  }
385
527
 
386
- const SEER_MODES = ["suggest", "skip"];
387
- const CMS_PROVIDERS = ["webflow", "framer", "sanity", "contentful"];
388
-
389
528
  // Final readback of a domain-onboarding job — the counts the engine PERSISTED.
390
529
  export function formatDomainStatus(s) {
391
530
  const r = s.result ?? {};
@@ -474,35 +613,7 @@ async function cmdOnboardDomain(ctx) {
474
613
  return 0;
475
614
  }
476
615
 
477
- const seers = flagStr(flags.seers);
478
- if (seers !== undefined && !SEER_MODES.includes(seers)) {
479
- throw new CliError(`--seers must be one of: ${SEER_MODES.join(", ")}`);
480
- }
481
- const cms = flagStr(flags.cms);
482
- if (cms !== undefined && !CMS_PROVIDERS.includes(cms)) {
483
- throw new CliError(`--cms must be one of: ${CMS_PROVIDERS.join(", ")}`);
484
- }
485
- const posts = flagNum(flags.posts);
486
- const phantom = compact({
487
- enabled: flagBool(flags["no-phantom"]) ? false : undefined,
488
- posts_to_seed: posts,
489
- name: flagStr(flags["phantom-name"]),
490
- site_domain: flagStr(flags["site-domain"]),
491
- domain_mode: flagStr(flags["domain-mode"]),
492
- });
493
- const args = compact({
494
- direction: flagStr(flags.about) ?? flagStr(flags.direction),
495
- sitemap_url: flagStr(flags.sitemap),
496
- cms_provider: cms,
497
- collection_name: flagStr(flags.name),
498
- phantom: Object.keys(phantom).length ? phantom : undefined,
499
- seers,
500
- visuals: flagBool(flags["no-visuals"]) ? false : undefined,
501
- enhance: flagBool(flags["no-enhance"]) ? false : undefined,
502
- is_test: flagBool(flags.test) ? true : undefined,
503
- readiness_minutes: flagNum(flags["readiness-minutes"]),
504
- client_email: flagStr(flags["client-email"]),
505
- });
616
+ const args = argsFromFlags(ARGS.onboard_domain, flags, ctx.positionals);
506
617
 
507
618
  ok(ctx, "Starting domain onboarding…");
508
619
  const started = await client.callTool("onboard_domain", args);
@@ -525,8 +636,108 @@ async function cmdOnboardDomain(ctx) {
525
636
  return watchDomainJob(ctx, started.job_id);
526
637
  }
527
638
 
639
+ // `onboard plan` — what does this organization still need? One read of get_onboarding_plan
640
+ // (the router), printed as a table. `onboard next` prints the first ready step's exact CLI
641
+ // invocation with what the facts already determine (the main collection's id) filled in.
642
+ // Neither decides anything: an optional step is listed, never chosen.
643
+ const STATUS_ORDER = ["ready", "running", "blocked", "done"];
644
+
645
+ function formatPlanTable(plan) {
646
+ const rows = plan.steps.map((s) => [
647
+ s.id,
648
+ s.status,
649
+ s.disposition,
650
+ s.blocked_by.length
651
+ ? s.blocked_by.join(", ")
652
+ : s.missing.length && s.status === "done"
653
+ ? `(${s.missing.join(", ")})`
654
+ : "",
655
+ s.cli,
656
+ ]);
657
+ const head = ["step", "status", "opinion", "blocked by", "run"];
658
+ const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i]).length)));
659
+ const line = (cells) =>
660
+ cells
661
+ .map((c, i) => String(c).padEnd(widths[i]))
662
+ .join(" ")
663
+ .trimEnd();
664
+ const lines = [line(head), line(widths.map((w) => "-".repeat(w))), ...rows.map(line)];
665
+ lines.push("");
666
+ lines.push(
667
+ plan.next
668
+ ? `Next: ${plan.next} — ${plan.steps.find((s) => s.id === plan.next)?.why ?? ""}`
669
+ : plan.steps.every((s) => s.status === "done")
670
+ ? "Nothing left: every step is done."
671
+ : "Nothing is ready to run — see the blocked-by column."
672
+ );
673
+ if (plan.optional_ready?.length) lines.push(`Optional and ready (needs a yes): ${plan.optional_ready.join(", ")}`);
674
+ return lines.join("\n");
675
+ }
676
+
677
+ function cliInvocation(ctx, step) {
678
+ const hint = Object.entries(step.args_hint ?? {});
679
+ // The generic door: `tool <name> --arg k=v` takes the schema's own keys.
680
+ if (/^tool /.test(step.cli)) {
681
+ const args = hint.map(([k, v]) => ` --arg ${k}=${v}`).join("");
682
+ return `${ctx.bin} ${step.cli}${args}`;
683
+ }
684
+ // A curated command: the main collection's id is `--collection` on every one that takes it.
685
+ const collection = step.args_hint?.collection_id ? ` --collection ${step.args_hint.collection_id}` : "";
686
+ return `${ctx.bin} ${step.cli}${collection}`;
687
+ }
688
+
689
+ async function cmdOnboardPlan(ctx) {
690
+ const plan = await ctx.client.callTool("get_onboarding_plan", {});
691
+ if (!Array.isArray(plan.steps)) throw new CliError("get_onboarding_plan did not return steps.");
692
+ printResult(ctx.io, ctx.flags, plan, formatPlanTable);
693
+ return 0;
694
+ }
695
+
696
+ async function cmdOnboardNext(ctx) {
697
+ const plan = await ctx.client.callTool("get_onboarding_plan", {});
698
+ if (!Array.isArray(plan.steps)) throw new CliError("get_onboarding_plan did not return steps.");
699
+ const next = plan.next ? plan.steps.find((s) => s.id === plan.next) : null;
700
+ if (!next) {
701
+ const running = plan.steps.find((s) => s.status === "running");
702
+ if (running) {
703
+ printResult(ctx.io, ctx.flags, running, (s) => `Waiting on "${s.id}": ${s.why}`);
704
+ return 0;
705
+ }
706
+ const optional = (plan.optional_ready ?? []).map((id) => plan.steps.find((s) => s.id === id)).filter(Boolean);
707
+ printResult(ctx.io, ctx.flags, plan, () =>
708
+ [
709
+ plan.steps.every((s) => s.status === "done")
710
+ ? "Nothing left: every step is done."
711
+ : "Nothing is ready to run.",
712
+ ...optional.map((s) => `Optional (needs a yes): ${s.id} — ${cliInvocation(ctx, s)}`),
713
+ ...plan.steps
714
+ .filter((s) => s.status === "blocked")
715
+ .sort((a, b) => STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status))
716
+ .map((s) => `Blocked: ${s.id} — ${s.why}`),
717
+ ].join("\n")
718
+ );
719
+ return 0;
720
+ }
721
+ printResult(ctx.io, ctx.flags, next, (s) =>
722
+ [
723
+ `Next: ${s.title} (${s.disposition})`,
724
+ ` ${s.purpose}`,
725
+ s.prepare.length ? ` Read first: ${s.prepare.join(", ")}` : null,
726
+ ` Run: ${cliInvocation(ctx, s)}`,
727
+ s.poll ? ` Poll: ${s.poll}` : null,
728
+ ` Docs: https://app.letterstory.com${s.docs}`,
729
+ ]
730
+ .filter(Boolean)
731
+ .join("\n")
732
+ );
733
+ return 0;
734
+ }
735
+
528
736
  export async function cmdOnboard(ctx) {
529
737
  const { client, flags, io } = ctx;
738
+ // `onboard plan` / `onboard next` — the router: what does this org still need?
739
+ if (ctx.positionals[0] === "plan") return cmdOnboardPlan(ctx);
740
+ if (ctx.positionals[0] === "next") return cmdOnboardNext(ctx);
530
741
  // `onboard collection …` — the client-blog variant (no <domain>; org from the key).
531
742
  if (ctx.positionals[0] === "collection") {
532
743
  return cmdOnboardCollection({ ...ctx, positionals: ctx.positionals.slice(1) });
@@ -549,39 +760,54 @@ export async function cmdOnboard(ctx) {
549
760
  const domain = requirePositional(ctx.positionals, 0, "domain");
550
761
  const overrides = overridesFromFlags(flags);
551
762
 
552
- // Step 1: dry-run plan (accept defaults to false server-side). Persists nothing.
553
- const plan = await client.callTool(
554
- "onboard_phantom",
555
- compact({ domain, overrides: Object.keys(overrides).length ? overrides : undefined })
556
- );
557
- ok(ctx, "Onboarding plan:");
558
- printResult(io, flags, plan, formatPlan);
763
+ // A plan handed in with --accept-plan was reviewed already, and re-running the dry run
764
+ // would re-read the homepage and re-propose a masthead — exactly what pinning prevents.
765
+ // So it also stands in for --accept: you cannot hand back a plan you have not read.
766
+ let reviewedPlan = readAcceptedPlan(flags);
559
767
 
560
- if (flagBool(flags["dry-run"])) return 0;
561
-
562
- // Step 2: the review gate. A bare, unaccepted run stops here so nothing is provisioned
563
- // by accident — re-run with --accept (or an override flag) once happy.
564
- const overrideGiven = Object.keys(overrides).length > 0;
565
- if (!flagBool(flags.accept) && !overrideGiven) {
566
- ok(
567
- ctx,
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). ` +
570
- "Note: --name is committed at create (it seeds the URL)."
571
- );
572
- return 0;
573
- }
574
- if (plan.proposed_name === null && flagStr(flags.name) === undefined) {
575
- throw new CliError(
576
- "No blog name available: the plan proposed none (strategy soft-failed). Pass --name <MastheadName> to apply."
768
+ if (!reviewedPlan) {
769
+ // Step 1: dry-run plan (accept defaults to false server-side). Persists nothing.
770
+ const dryRun = await client.callTool(
771
+ "onboard_phantom",
772
+ compact({ domain, overrides: Object.keys(overrides).length ? overrides : undefined })
577
773
  );
774
+ ok(ctx, "Onboarding plan:");
775
+ printResult(io, flags, dryRun, formatPlan);
776
+
777
+ if (flagBool(flags["dry-run"])) return 0;
778
+
779
+ // Step 2: the review gate. A bare, unaccepted run stops here so nothing is provisioned
780
+ // by accident — re-run with --accept (or an override flag) once happy.
781
+ const overrideGiven = Object.keys(overrides).length > 0;
782
+ if (!flagBool(flags.accept) && !overrideGiven) {
783
+ ok(
784
+ ctx,
785
+ "Re-run with --accept to apply, or pass --about/--name/--cadence/--cover/--posts/--theme/--domain to adjust " +
786
+ `(or pick the beat and name first: ${recipeName(ctx)} propose / mastheads). ` +
787
+ "Note: --name is committed at create (it seeds the URL)."
788
+ );
789
+ return 0;
790
+ }
791
+ if (dryRun.proposed_name === null && flagStr(flags.name) === undefined) {
792
+ throw new CliError(
793
+ "No blog name available: the plan proposed none (strategy soft-failed). Pass --name <MastheadName> to apply."
794
+ );
795
+ }
796
+ // Apply the plan that was just printed, not a fresh one: an old server that does not
797
+ // return `plan` leaves this undefined and the job plans for itself, as before.
798
+ reviewedPlan = dryRun.plan;
578
799
  }
579
800
 
580
801
  // Step 3: apply — start the durable onboarding job.
581
802
  ok(ctx, "Applying — starting the onboarding job…");
582
803
  const started = await client.callTool(
583
804
  "onboard_phantom",
584
- compact({ domain, accept: true, overrides: Object.keys(overrides).length ? overrides : undefined })
805
+ compact({
806
+ domain,
807
+ accept: true,
808
+ overrides: Object.keys(overrides).length ? overrides : undefined,
809
+ plan: reviewedPlan,
810
+ })
585
811
  );
586
812
  const jobId = started.job_id;
587
813
  if (!jobId) throw new CliError("onboard_phantom did not return a job id.");