@letterstory/cli 0.15.3 → 0.15.4
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 +26 -0
- package/lib/cli.mjs +21 -1
- package/lib/commands/onboard.mjs +170 -3
- package/lib/commands/shared.mjs +17 -8
- package/package.json +1 -1
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
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
} from "./commands.mjs";
|
|
44
44
|
|
|
45
45
|
// Keep in sync with cli/package.json.
|
|
46
|
-
export const VERSION = "0.15.
|
|
46
|
+
export const VERSION = "0.15.4";
|
|
47
47
|
|
|
48
48
|
// Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
|
|
49
49
|
// can't accidentally swallow the id as --json's value.
|
|
@@ -305,6 +305,26 @@ Phantom onboarding recipe (domain -> live phantom blog with first posts):
|
|
|
305
305
|
create (it seeds the URL); pass it before
|
|
306
306
|
applying. --domain-mode buy_now SPENDS
|
|
307
307
|
money (2/org, $30 cap).
|
|
308
|
+
[--plan-file <path|-> --plan-id <id>] Pin a topic plan chosen with the propose
|
|
309
|
+
lane below: its direction AND pillars are
|
|
310
|
+
applied verbatim, so the site covers what
|
|
311
|
+
was approved.
|
|
312
|
+
|
|
313
|
+
Propose lane (the Fleet wizard's Plan and Name screens; persists nothing, spends nothing):
|
|
314
|
+
${bin === "phantom" ? "onboard" : "phantom-onboard"} propose [<client-domain>] [--about "the beat"] [--name X] [--json]
|
|
315
|
+
Step ONE: read the client's homepage and
|
|
316
|
+
propose distinct editorial angles
|
|
317
|
+
(topicPlans), the fallback plan, the
|
|
318
|
+
client's existing publications (overlap
|
|
319
|
+
warning) and domain headroom. Omit the
|
|
320
|
+
domain to use the org's own. Save it with
|
|
321
|
+
--json > proposal.json for step two.
|
|
322
|
+
${bin === "phantom" ? "onboard" : "phantom-onboard"} mastheads <client-domain> --plan-file <path|-> [--plan-id <id>]
|
|
323
|
+
Step TWO: names for ONE chosen plan, each
|
|
324
|
+
with its own priced, buyable domains.
|
|
325
|
+
--plan-file takes a saved proposal (then
|
|
326
|
+
--plan-id picks the plan) or a single plan
|
|
327
|
+
(--plan '<json>' inline, or - for stdin).
|
|
308
328
|
|
|
309
329
|
Collection onboarding (your OWN blog, on your org's domain — no provisioning):
|
|
310
330
|
${bin === "phantom" ? "onboard" : "phantom-onboard"} collection [--collection <uuid>] [--name "<collection name>"]
|
package/lib/commands/onboard.mjs
CHANGED
|
@@ -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 {
|
|
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;
|
package/lib/commands/shared.mjs
CHANGED
|
@@ -89,16 +89,20 @@ export async function pollUntil(io, poll, opts = {}) {
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
// Read a text
|
|
93
|
-
// undefined if neither flag was given, so callers can decide whether
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
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(
|
|
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
|
|
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
|
+
}
|