@letterstory/cli 0.15.2 → 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 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
@@ -24,6 +24,8 @@ import {
24
24
  cmdFlows,
25
25
  cmdConnectors,
26
26
  cmdStrategy,
27
+ cmdProbes,
28
+ cmdSuggestions,
27
29
  cmdOnboarding,
28
30
  cmdSeries,
29
31
  cmdInsights,
@@ -41,7 +43,7 @@ import {
41
43
  } from "./commands.mjs";
42
44
 
43
45
  // Keep in sync with cli/package.json.
44
- export const VERSION = "0.15.2";
46
+ export const VERSION = "0.15.4";
45
47
 
46
48
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
47
49
  // can't accidentally swallow the id as --json's value.
@@ -242,6 +244,12 @@ Strategy & onboarding:
242
244
  strategy sitemap --collection <uuid> --url <sitemap_url> [--sub <url> …] [--pattern <glob>]
243
245
  strategy topics list --collection <uuid> List the topic map's clusters/spokes
244
246
  strategy topics set --collection <uuid> (--topic <topic-id> | --suggestion <suggestion-id>)
247
+ suggestions list <collection_id> List pending strategy suggestions + their auto-apply countdown
248
+ suggestions approve | dismiss | snooze <verdict|proposal> <id> [--days <n>]
249
+ probes list [--collection <uuid>] [--active-only] [--kind mention|citation|page]
250
+ probes keep | pause | resume <probe_id> Keep = protect an AI probe from rotation (no pause)
251
+ probes edit <probe_id> <new question text> Fix/sharpen a generated probe
252
+ probes add <question> [--kind mention|citation] [--scope site|company|<blogId>] [--cluster <id>]
245
253
  onboarding status Show the onboarding checklist
246
254
  onboarding step [--current <step>] [--complete <step>] [--skip <step>] [--status <status>]
247
255
  series list The org's content series + coverage state
@@ -297,6 +305,26 @@ Phantom onboarding recipe (domain -> live phantom blog with first posts):
297
305
  create (it seeds the URL); pass it before
298
306
  applying. --domain-mode buy_now SPENDS
299
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).
300
328
 
301
329
  Collection onboarding (your OWN blog, on your org's domain — no provisioning):
302
330
  ${bin === "phantom" ? "onboard" : "phantom-onboard"} collection [--collection <uuid>] [--name "<collection name>"]
@@ -459,6 +487,8 @@ const CLIENT_COMMANDS = {
459
487
  flows: cmdFlows,
460
488
  connectors: cmdConnectors,
461
489
  strategy: cmdStrategy,
490
+ probes: cmdProbes,
491
+ suggestions: cmdSuggestions,
462
492
  onboarding: cmdOnboarding,
463
493
  series: cmdSeries,
464
494
  insights: cmdInsights,
@@ -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,145 @@
1
+ // `probes` + `suggestions` — surface parity with the Measure → Probes tab and
2
+ // the collection strategy queue. Thin wrappers over the MCP tools (list_probes,
3
+ // control_probe, add_probe; list_strategy_suggestions, decide_strategy_suggestion),
4
+ // which themselves call the same shared lib seams the web + REST use. So an agent
5
+ // or a script can see and act on exactly what a person can.
6
+
7
+ import { CliError } from "../client.mjs";
8
+ import { flagStr, flagNum, flagBool, requirePositional, printResult, compact, ok } from "./shared.mjs";
9
+
10
+ export async function cmdProbes(ctx) {
11
+ const sub = ctx.positionals[0];
12
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
13
+ switch (sub) {
14
+ case "list":
15
+ case "ls":
16
+ return probesList(rest);
17
+ case "keep":
18
+ return probesControl(rest, "keep");
19
+ case "pause":
20
+ return probesControl(rest, "pause");
21
+ case "resume":
22
+ return probesControl(rest, "resume");
23
+ case "edit":
24
+ return probesEdit(rest);
25
+ case "add":
26
+ return probesAdd(rest);
27
+ default:
28
+ throw new CliError("Usage: probes <list|keep|pause|resume|edit|add>");
29
+ }
30
+ }
31
+
32
+ function formatProbeLine(p) {
33
+ const state = p.lifecycle_state ? `[${p.lifecycle_state}]` : p.is_active ? "[active]" : "[paused]";
34
+ return `${p.id} ${state} (${p.probe_kind}/${p.scope}, source=${p.source ?? "?"}) ${p.text}`;
35
+ }
36
+
37
+ async function probesList(ctx) {
38
+ const { client, flags, io } = ctx;
39
+ const result = await client.callTool(
40
+ "list_probes",
41
+ compact({
42
+ collection_id: flagStr(flags.collection),
43
+ active_only: flagBool(flags["active-only"]),
44
+ probe_kind: flagStr(flags.kind),
45
+ })
46
+ );
47
+ printResult(io, flags, result, (r) => {
48
+ const lines = (r.probes ?? []).map(formatProbeLine);
49
+ if (!lines.length) lines.push("No probes match.");
50
+ return lines.join("\n");
51
+ });
52
+ return 0;
53
+ }
54
+
55
+ async function probesControl(ctx, action) {
56
+ const { client, positionals } = ctx;
57
+ const probeId = requirePositional(positionals, 0, "probe_id");
58
+ const result = await client.callTool("control_probe", { probe_id: probeId, action });
59
+ ok(ctx, result.noop ? "Nothing to change (already in that state)." : `Probe ${action}d.`);
60
+ return 0;
61
+ }
62
+
63
+ async function probesEdit(ctx) {
64
+ const { client, positionals } = ctx;
65
+ const probeId = requirePositional(positionals, 0, "probe_id");
66
+ const text = positionals.slice(1).join(" ").trim();
67
+ if (!text) throw new CliError("Usage: probes edit <probe_id> <new question text>");
68
+ await client.callTool("control_probe", { probe_id: probeId, action: "edit", text });
69
+ ok(ctx, "Question updated.");
70
+ return 0;
71
+ }
72
+
73
+ async function probesAdd(ctx) {
74
+ const { client, positionals, flags } = ctx;
75
+ const text = positionals.join(" ").trim();
76
+ if (!text)
77
+ throw new CliError(
78
+ "Usage: probes add <question> [--kind mention|citation] [--scope site|company|<blogId>] [--cluster <id>]"
79
+ );
80
+ const result = await client.callTool(
81
+ "add_probe",
82
+ compact({
83
+ text,
84
+ probe_kind: flagStr(flags.kind) ?? "mention",
85
+ scope: flagStr(flags.scope) ?? "site",
86
+ cluster_id: flagStr(flags.cluster),
87
+ })
88
+ );
89
+ ok(ctx, `Added probe ${result.id}.`);
90
+ return 0;
91
+ }
92
+
93
+ export async function cmdSuggestions(ctx) {
94
+ const sub = ctx.positionals[0];
95
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
96
+ switch (sub) {
97
+ case "list":
98
+ case "ls":
99
+ return suggestionsList(rest);
100
+ case "approve":
101
+ return suggestionsDecide(rest, "approve");
102
+ case "dismiss":
103
+ return suggestionsDecide(rest, "dismiss");
104
+ case "snooze":
105
+ return suggestionsDecide(rest, "snooze");
106
+ default:
107
+ throw new CliError(
108
+ "Usage: suggestions <list <collection_id>|approve|dismiss|snooze <verdict|proposal> <id>>"
109
+ );
110
+ }
111
+ }
112
+
113
+ function formatSuggestionLine(s) {
114
+ const when =
115
+ s.disposition === "auto"
116
+ ? s.snoozed_until
117
+ ? `snoozed→${s.snoozed_until.slice(0, 10)}`
118
+ : `auto→${(s.auto_apply_at ?? "").slice(0, 10)}`
119
+ : "hold";
120
+ return `${s.kind} ${s.id} [${when}] ${s.cluster_name ?? "site"}: ${s.title}`;
121
+ }
122
+
123
+ async function suggestionsList(ctx) {
124
+ const { client, positionals, flags, io } = ctx;
125
+ const collectionId = requirePositional(positionals, 0, "collection_id");
126
+ const result = await client.callTool("list_strategy_suggestions", { collection_id: collectionId });
127
+ printResult(io, flags, result, (r) => {
128
+ const lines = (r.suggestions ?? []).map(formatSuggestionLine);
129
+ if (!lines.length) lines.push("Nothing pending.");
130
+ return lines.join("\n");
131
+ });
132
+ return 0;
133
+ }
134
+
135
+ async function suggestionsDecide(ctx, decision) {
136
+ const { client, positionals, flags } = ctx;
137
+ const kind = requirePositional(positionals, 0, "kind (verdict|proposal)");
138
+ const id = requirePositional(positionals, 1, "id");
139
+ const result = await client.callTool(
140
+ "decide_strategy_suggestion",
141
+ compact({ kind, id, decision, snooze_days: decision === "snooze" ? flagNum(flags.days) : undefined })
142
+ );
143
+ ok(ctx, result.applied ?? `Suggestion ${decision}d.`);
144
+ return 0;
145
+ }
@@ -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
+ }
package/lib/commands.mjs CHANGED
@@ -14,6 +14,7 @@ export * from "./commands/authors.mjs";
14
14
  export * from "./commands/flows.mjs";
15
15
  export * from "./commands/connectors.mjs";
16
16
  export * from "./commands/strategy.mjs";
17
+ export * from "./commands/probes.mjs";
17
18
  export * from "./commands/series.mjs";
18
19
  export * from "./commands/insights.mjs";
19
20
  export * from "./commands/covers.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.15.2",
3
+ "version": "0.15.4",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",