@letterstory/cli 0.7.0 → 0.9.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.
package/README.md CHANGED
@@ -171,6 +171,22 @@ letterstory collections delete <id> --yes
171
171
  letterstory collections assign <article-id> <collection-id>
172
172
  ```
173
173
 
174
+ ## Landing pages
175
+
176
+ A landing page is a link plus context describing what lives there. Collections with
177
+ auto-mention enabled (toggle in Collection Settings) weave relevant landing pages into
178
+ articles as they're written. Pass the URL as `--link` — `--url` is the global flag that
179
+ overrides the API base URL.
180
+
181
+ ```bash
182
+ letterstory landings list
183
+ letterstory landings get <landing-id>
184
+ letterstory landings create --link https://example.com/dataset --context "Our 2026 benchmark dataset. Cite it when an article discusses response-time or CSAT metrics."
185
+ letterstory landings create --link https://example.com/report --file ./context.md # context from a file (or --file - for stdin)
186
+ letterstory landings update <landing-id> --context "Updated context"
187
+ letterstory landings delete <landing-id> --yes
188
+ ```
189
+
174
190
  ## Flows
175
191
 
176
192
  Run an editorial pass over an article, check on it, and manage the completion webhook:
@@ -213,21 +229,6 @@ letterstory onboarding status
213
229
  letterstory onboarding step --complete connect_domain
214
230
  ```
215
231
 
216
- ### Magical onboarding
217
-
218
- One command from a domain to a filled-out strategy: it infers and saves the manifesto,
219
- stances, and identity guardrail from the client's website (never clobbering anything
220
- hand-written), optionally imports the blog's post history from a sitemap, infers keywords
221
- and builds topic clusters, then detects the content series those posts already imply and
222
- adopts them — coverage reconstructed from existing posts, gaps left for the queue to write.
223
- Idempotent; safe to re-run.
224
-
225
- ```bash
226
- letterstory onboarding magic # use the saved company domain
227
- letterstory onboarding magic --domain acme.com --sitemap https://acme.com/sitemap.xml
228
- letterstory onboarding magic --dry-run # preview series proposals only
229
- ```
230
-
231
232
  ## Content series
232
233
 
233
234
  A series is a coverage obligation, not a keyword bet: "content that looks like this",
@@ -235,7 +236,6 @@ one post per declared item. Series feed the same planner queue as topic clusters
235
236
 
236
237
  ```bash
237
238
  letterstory series list
238
- letterstory series propose # detect series in existing posts
239
239
  letterstory series create --name "Secrets, tool by tool" \
240
240
  --description "One practical secrets guide per IaC tool." \
241
241
  --coverage "Pulumi,Terraform,Ansible" --template <key>
@@ -330,7 +330,7 @@ Content templates (the compose structures seers bind via `config.template_key`):
330
330
  ```bash
331
331
  letterstory templates list # custom + read-only built-ins
332
332
  letterstory templates create --name "Release notes" --angle "What shipped and why" \
333
- --sections-file sections.json
333
+ --blocks-file blocks.json
334
334
  letterstory templates update <template-id> --angle "…" # unset fields keep their values
335
335
  letterstory templates delete <template-id> --yes # bound seers fall back to defaults
336
336
  ```
package/lib/cli.mjs CHANGED
@@ -28,23 +28,28 @@ import {
28
28
  cmdSeries,
29
29
  cmdInsights,
30
30
  cmdResearch,
31
+ cmdAutoPen,
31
32
  cmdCovers,
32
33
  cmdKernel,
33
34
  cmdPhantomJob,
35
+ cmdOnboard,
34
36
  cmdSeers,
35
37
  cmdTemplates,
36
38
  cmdShredder,
39
+ cmdLandings,
37
40
  } from "./commands.mjs";
38
41
 
39
42
  // Keep in sync with cli/package.json.
40
- export const VERSION = "0.7.0";
43
+ export const VERSION = "0.9.0";
41
44
 
42
45
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
43
46
  // can't accidentally swallow the id as --json's value.
44
47
  const BOOLEAN_FLAGS = new Set([
45
48
  "json",
46
49
  "yes",
50
+ "confirm-missing-target",
47
51
  "no-wait",
52
+ "accept",
48
53
  "help",
49
54
  "version",
50
55
  "quiet",
@@ -167,6 +172,13 @@ Collections:
167
172
  collections update <id> [--name] [--description] [--cadence-target <n>] [--cadence-period week|month] [--cover-canvas-type-id <uuid>|none]
168
173
  collections delete <id> --yes
169
174
  collections assign <article-id> <collection-id>
175
+ collections autopen <id> [--template <key>|none] [--kernel-family <name>|org]
176
+ [--mention off|subtle|woven] [--outline-depth barebones|fleshed_out]
177
+ [--direction-gate true|false] [--auto-start true|false]
178
+ The Auto-Pen settings the app's panel writes. --template binds a content
179
+ template (from 'templates list') as this collection's default, which is how
180
+ a template you author here actually gets used. A patch: unset flags are
181
+ left alone.
170
182
 
171
183
  Authors (the per-collection author bank — recurring bylines):
172
184
  authors list <collection-id> List the bank + byline distribution
@@ -188,6 +200,8 @@ Flows:
188
200
  Connectors:
189
201
  connectors list List external publish destinations
190
202
  connectors publish <article-id> --to <connector> --target <uuid> [--flow-run <uuid>]
203
+ [--confirm-missing-target] re-publish even though
204
+ the item was deleted/unpublished on the provider
191
205
  connectors status --connector <connector> --publish-id <id>
192
206
 
193
207
  Strategy & onboarding:
@@ -200,12 +214,7 @@ Strategy & onboarding:
200
214
  strategy topics set --collection <uuid> (--topic <topic-id> | --suggestion <suggestion-id>)
201
215
  onboarding status Show the onboarding checklist
202
216
  onboarding step [--current <step>] [--complete <step>] [--skip <step>] [--status <status>]
203
- onboarding magic [--domain <domain>] [--sitemap <url>] [--collection <uuid>] [--dry-run]
204
- Enter a domain -> strategy filled out:
205
- manifesto+stances, optional history import,
206
- clusters, detected series (see below)
207
217
  series list The org's content series + coverage state
208
- series propose [--collection <uuid>] Detect series existing posts already imply
209
218
  series create --name <n> --description <d> [--coverage a,b,c] [--collection <uuid>] [--template <key>]
210
219
  series backfill <series-id> [--collection <uuid>] Reconstruct a series from existing posts
211
220
 
@@ -218,6 +227,12 @@ Research agent (deep research -> outline written into the post):
218
227
  Answer the --gate pause
219
228
  research cancel --article <uuid> Cancel a run in flight
220
229
 
230
+ Auto-Pen (research -> kernel -> Enhance, in one run — and the ONLY path that
231
+ applies a collection's content template; kernel run does not):
232
+ autopen start --article <uuid> Start the run; takes 10-40 min
233
+ autopen status --article <uuid> Step states + the template in use
234
+ autopen cancel --article <uuid> --yes Stop a run in flight
235
+
221
236
  Cover images (which image model draws the post's cover):
222
237
  covers models Image models connected right now
223
238
  covers variants --article <uuid> [--model <id> …] [--ref-url <url> …]
@@ -238,6 +253,21 @@ Phantom orchestrator:
238
253
  Topic -> draft -> publish -> rebuild
239
254
  phantom-job status <job-id> Check a job's stage
240
255
 
256
+ Phantom onboarding recipe (domain -> live phantom blog with first posts):
257
+ ${bin === "phantom" ? "onboard" : "phantom-onboard"} <domain> [--about "the beat"] [--name X]
258
+ [--posts N] [--cadence N] [--cover editorial_image|full_ai|line_art] [--theme T]
259
+ [--real] [--domain host] [--domain-mode subdomain|buy_now|connect_later]
260
+ [--accept] [--dry-run]
261
+ Plan (read-only) then, with --accept
262
+ or an override flag, provision + configure
263
+ + seed. --about becomes the planner's
264
+ steering prompt, so it shapes the topic map
265
+ and every post — use it for a client's
266
+ second phantom. --name is committed at
267
+ create (it seeds the URL); pass it before
268
+ applying. --domain-mode buy_now SPENDS
269
+ money (2/org, $30 cap).
270
+
241
271
  Seers (event-driven signals -> drafts):
242
272
  seers list List seers with recent activity
243
273
  seers get <seer-id> One seer's full config + poll state
@@ -264,8 +294,14 @@ Seers (event-driven signals -> drafts):
264
294
  Content templates (compose structures seers bind via config.template_key):
265
295
  templates list Custom + built-in catalog
266
296
  templates show <template-id>
267
- templates create --name <n> --angle <text> (--sections '<json>'|--sections-file <path>)
268
- templates update <template-id> [--name] [--angle] [--sections '<json>'|--sections-file <path>]
297
+ templates create --name <n> (--blocks '<json>'|--blocks-file <path>) [--angle <text>]
298
+ templates update <template-id> [--name] [--blocks '<json>'|--blocks-file <path>] [--angle]
299
+ A block is { id, kind: 'formatted'|'open', text (markdown), optional? }. In a
300
+ 'formatted' block the markdown IS the shape: prose -> paragraphs; "- item" -> a
301
+ bulleted list; "1. item" -> numbered steps; a table skeleton -> a comparison;
302
+ "> quoted" -> reproduced word for word. Empty items ("- ") ask for a list without
303
+ dictating it — how many it has is decided from the material. An 'open' block asks
304
+ for coverage in your own words and lets the engine pick the shape.
269
305
  templates delete <template-id> --yes
270
306
 
271
307
  Shredder (diversify content produced outside Letterstory; structure preserved):
@@ -276,6 +312,14 @@ Shredder (diversify content produced outside Letterstory; structure preserved):
276
312
  Shred prose through an endpoint (logs telemetry)
277
313
  shredder runs [--endpoint <id>] [--limit <n>] Recent shred calls (telemetry), newest first
278
314
 
315
+ Landings (a link + context; auto-mentioned in articles by collections you enable):
316
+ landings list Your landing pages + mention counts
317
+ landings get <landing-id> Show one landing page
318
+ landings create --link <url> [--context <text>|--file <path>|--file -]
319
+ Add a landing page (--link, not --url)
320
+ landings update <landing-id> [--link <url>] [--context <text>|--file <path>]
321
+ landings delete <landing-id> --yes
322
+
279
323
  Insights:
280
324
  insights site [--period 14d|30d|90d] [--collection <uuid>]
281
325
  insights post <article-id> [--period 14d|30d|90d]
@@ -283,7 +327,7 @@ Insights:
283
327
 
284
328
  Anything else:
285
329
  tools list List every tool this server exposes, by name
286
- tools show <name> Show one tool's capability + full argument schema
330
+ tools show <name> Show one tool's capability, argument schema, and return shape
287
331
  call <tool> [--args '<json>'] [--flag value …] Call any tool directly
288
332
  tool <name> [--arg k=v …] [--json-args '<json>'] [--stdin]
289
333
  Call any tool with schema-coerced arguments
@@ -334,12 +378,15 @@ const CLIENT_COMMANDS = {
334
378
  series: cmdSeries,
335
379
  insights: cmdInsights,
336
380
  research: cmdResearch,
381
+ autopen: cmdAutoPen,
337
382
  covers: cmdCovers,
338
383
  kernel: cmdKernel,
339
384
  "phantom-job": cmdPhantomJob,
385
+ "phantom-onboard": cmdOnboard,
340
386
  seers: cmdSeers,
341
387
  templates: cmdTemplates,
342
388
  shredder: cmdShredder,
389
+ landings: cmdLandings,
343
390
  };
344
391
 
345
392
  // LETTERSTORY_POLL_INTERVAL_MS / LETTERSTORY_MAX_POLLS let an operator (or an
@@ -382,7 +429,10 @@ export async function run(argv, io = defaultIo()) {
382
429
  return 0;
383
430
  }
384
431
 
385
- const handler = CLIENT_COMMANDS[command];
432
+ // Under the phantom-branded binary, `phantom onboard` is the natural spelling — accept
433
+ // it as an alias for the recipe so users don't type the stuttering `phantom phantom-onboard`.
434
+ // The `letterstory` binary keeps only `phantom-onboard`, so it never shadows `onboarding`.
435
+ const handler = CLIENT_COMMANDS[command] ?? (bin === "phantom" && command === "onboard" ? cmdOnboard : undefined);
386
436
  if (!handler) {
387
437
  io.error(`Unknown command: ${command}`);
388
438
  io.error(`Run \`${bin} help\` for usage.`);
package/lib/client.mjs CHANGED
@@ -87,8 +87,9 @@ export class LetterstoryClient {
87
87
  return `${this.url}/api/mcp`;
88
88
  }
89
89
 
90
- // Unauthenticated discovery (GET /api/mcp): tool names + descriptions, no schema.
91
- // Lets `letterstory tools` work before you've logged in.
90
+ // Unauthenticated discovery (GET /api/mcp): tool names, descriptions, capability, and
91
+ // the derived input + output schemas. Lets `letterstory tools`/`tools show` work before
92
+ // you've logged in.
92
93
  async discover() {
93
94
  if (this.verbose) this.verboseLog(`» GET ${this.mcpEndpoint}`);
94
95
  let res;
@@ -0,0 +1,59 @@
1
+ // `autopen` — run a collection's full Auto-Pen sequence over an existing draft:
2
+ // research, write with the writing kernel, then the collection's Enhance flow.
3
+ //
4
+ // This is the command that makes a TEMPLATE take effect. A template bound with
5
+ // `collections autopen <id> --template-key <key>` is read inside the Auto-Pen run and
6
+ // nowhere else, so `kernel run` (which hands the draft's current text straight to a
7
+ // kernel) will not produce a templated post no matter how the collection is configured.
8
+ //
9
+ // A run takes 10-40 minutes, so `start` returns as soon as it is queued. `status` both
10
+ // reports and nudges the run along; a sweep also advances it every 5 minutes, so a run
11
+ // finishes whether or not anyone polls.
12
+
13
+ import { CliError } from "../client.mjs";
14
+ import { flagBool, requireFlag, printResult, ok } from "./shared.mjs";
15
+
16
+ export async function cmdAutoPen(ctx) {
17
+ const sub = ctx.positionals[0];
18
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
19
+ switch (sub) {
20
+ case "start":
21
+ case "run":
22
+ return autoPenStart(rest);
23
+ case "status":
24
+ return autoPenStatus(rest);
25
+ case "cancel":
26
+ return autoPenCancel(rest);
27
+ default:
28
+ throw new CliError(`Unknown autopen subcommand: ${sub ?? "(none)"}. Try: start, status, cancel`);
29
+ }
30
+ }
31
+
32
+ async function autoPenStart(ctx) {
33
+ const { client, flags, io } = ctx;
34
+ const article = requireFlag(flags, "article");
35
+ const result = await client.callTool("run_auto_pen", { article_id: article });
36
+ ok(ctx, `Auto-Pen started. Check progress with: autopen status --article ${article}`);
37
+ printResult(io, flags, result);
38
+ return 0;
39
+ }
40
+
41
+ async function autoPenStatus(ctx) {
42
+ const { client, flags, io } = ctx;
43
+ const article = requireFlag(flags, "article");
44
+ const result = await client.callTool("get_auto_pen_status", { article_id: article });
45
+ printResult(io, flags, result);
46
+ return 0;
47
+ }
48
+
49
+ async function autoPenCancel(ctx) {
50
+ const { client, flags, io } = ctx;
51
+ const article = requireFlag(flags, "article");
52
+ if (!flagBool(flags.yes)) {
53
+ throw new CliError("Cancelling stops a run in progress. Re-run with --yes to confirm.");
54
+ }
55
+ const result = await client.callTool("cancel_auto_pen", { article_id: article });
56
+ ok(ctx, "Auto-Pen cancelled.");
57
+ printResult(io, flags, result);
58
+ return 0;
59
+ }
@@ -21,9 +21,11 @@ export async function cmdCollections(ctx) {
21
21
  return collectionsDelete(rest);
22
22
  case "assign":
23
23
  return collectionsAssign(rest);
24
+ case "autopen":
25
+ return collectionsAutoPen(rest);
24
26
  default:
25
27
  throw new CliError(
26
- `Unknown collections subcommand: ${sub ?? "(none)"}. Try: list, new, update, delete, assign`
28
+ `Unknown collections subcommand: ${sub ?? "(none)"}. Try: list, new, update, delete, assign, autopen`
27
29
  );
28
30
  }
29
31
  }
@@ -99,3 +101,45 @@ async function collectionsAssign(ctx) {
99
101
  printResult(io, flags, result);
100
102
  return 0;
101
103
  }
104
+
105
+ /**
106
+ * `collections autopen <id>` — the Auto-Pen settings the app's panel writes, including which
107
+ * template the collection's posts compose with. Without it, a template authored through this
108
+ * CLI could be created but never actually used by anything.
109
+ *
110
+ * A patch: only the flags given are sent, and the server leaves the rest alone.
111
+ */
112
+ async function collectionsAutoPen(ctx) {
113
+ const { client, positionals, flags, io } = ctx;
114
+ const collectionId = requirePositional(positionals, 0, "collection-id");
115
+ const patch = { collection_id: collectionId };
116
+
117
+ // `--template none` clears the binding; the server takes null for that.
118
+ const template = flagStr(flags.template);
119
+ if (template !== undefined) patch.template_key = template === "none" ? null : template;
120
+
121
+ const kernelFamily = flagStr(flags["kernel-family"]);
122
+ if (kernelFamily !== undefined) patch.kernel = { family: kernelFamily === "org" ? null : kernelFamily };
123
+
124
+ const mention = flagStr(flags.mention);
125
+ if (mention !== undefined) patch.mention = { degree: mention };
126
+
127
+ const depth = flagStr(flags["outline-depth"]);
128
+ if (depth !== undefined) patch.outline = { ...(patch.outline ?? {}), depth };
129
+
130
+ if (flags["direction-gate"] !== undefined) {
131
+ patch.outline = { ...(patch.outline ?? {}), direction_gate: flags["direction-gate"] !== "false" };
132
+ }
133
+ if (flags["auto-start"] !== undefined) patch.auto_start_on_curate = flags["auto-start"] !== "false";
134
+
135
+ if (Object.keys(patch).length === 1) {
136
+ throw new CliError(
137
+ "Nothing to change. Pass at least one of --template, --kernel-family, --mention, --outline-depth, --direction-gate, --auto-start."
138
+ );
139
+ }
140
+
141
+ const result = await client.callTool("update_auto_pen_config", patch);
142
+ ok(ctx, `Auto-Pen settings updated for ${collectionId}.`);
143
+ printResult(io, flags, result);
144
+ return 0;
145
+ }
@@ -3,7 +3,7 @@
3
3
  // phantomstory-cli's connectors.ts.
4
4
 
5
5
  import { CliError } from "../client.mjs";
6
- import { flagStr, requireFlag, requirePositional, printResult, compact, ok } from "./shared.mjs";
6
+ import { flagStr, flagBool, requireFlag, requirePositional, printResult, compact, ok } from "./shared.mjs";
7
7
 
8
8
  export async function cmdConnectors(ctx) {
9
9
  const sub = ctx.positionals[0];
@@ -34,11 +34,16 @@ async function connectorsPublish(ctx) {
34
34
  const articleId = requirePositional(positionals, 0, "article-id");
35
35
  const to = requireFlag(flags, "to");
36
36
  const target = requireFlag(flags, "target");
37
+ // A re-publish whose external item was deleted or unpublished in the client's CMS
38
+ // is refused, and the refusal explains itself. --confirm-missing-target is the
39
+ // answer to that specific question: it re-creates / re-publishes the item there.
40
+ // Deliberately not bundled into a general --yes, and never set by default.
37
41
  const args = compact({
38
42
  article_id: articleId,
39
43
  connector: to,
40
44
  target_id: target,
41
45
  flow_run_id: flagStr(flags["flow-run"]),
46
+ confirm_missing_target: flagBool(flags["confirm-missing-target"]) || undefined,
42
47
  });
43
48
  const result = await client.callTool("publish_to_connector", args);
44
49
  ok(ctx, `Publishing to ${to}.`);
@@ -73,17 +73,55 @@ function renderTool(io, tool) {
73
73
  const detail = detailOf(props[name]);
74
74
  io.log(` ${name} (${typeLabel(props[name])}, ${req})${detail ? ` — ${detail}` : ""}`);
75
75
  }
76
+ renderReturns(io, tool.outputSchema);
77
+ }
78
+
79
+ // The object node to descend into for a field: the node itself if it has
80
+ // properties, or an array's element when that element is an object. Mirrors the
81
+ // `/docs` "Returns" tree so the CLI and the web console describe one shape.
82
+ function returnObject(node) {
83
+ if (!node || typeof node !== "object") return null;
84
+ if (node.properties) return node;
85
+ if (node.type === "array" && node.items?.properties) return node.items;
86
+ if (Array.isArray(node.anyOf)) return node.anyOf.find((n) => n?.properties) ?? null;
87
+ return null;
88
+ }
89
+
90
+ // Print the tool's success-response shape as an indented `name (type)` tree, so a
91
+ // caller sees what comes back without running the call. Fed by the `outputSchema`
92
+ // that `tools/list` now advertises; silent for a tool whose return isn't modelled.
93
+ function renderReturns(io, schema) {
94
+ const root = returnObject(schema);
95
+ if (!root?.properties) return;
96
+ io.log(``);
97
+ io.log(` returns:`);
98
+ const walk = (node, indent) => {
99
+ const obj = returnObject(node);
100
+ if (!obj?.properties) return;
101
+ const req = new Set(obj.required ?? []);
102
+ for (const [name, child] of Object.entries(obj.properties)) {
103
+ const opt = req.has(name) ? "" : ", optional";
104
+ const desc = child.description ? ` — ${child.description}` : "";
105
+ io.log(`${" ".repeat(indent)}${name} (${typeLabel(child)}${opt})${desc}`);
106
+ walk(child, indent + 2);
107
+ }
108
+ };
109
+ walk(root, 4);
76
110
  }
77
111
 
78
112
  function typeLabel(prop) {
79
113
  if (!prop) return "any";
80
114
  if (prop.enum) return "enum";
115
+ if (prop.type === "array") return `${typeLabel(prop.items)}[]`;
81
116
  if (typeof prop.type === "string") return prop.type;
82
117
  if (Array.isArray(prop.type)) return prop.type.filter((t) => t !== "null").join("|") || "any";
83
118
  if (prop.anyOf) {
84
- const types = prop.anyOf.map((a) => (typeof a.type === "string" ? a.type : "any")).filter((t) => t !== "null");
85
- return [...new Set(types)].join("|") || "any";
119
+ const nullable = prop.anyOf.some((a) => a?.type === "null");
120
+ const types = prop.anyOf.filter((a) => a?.type !== "null").map(typeLabel);
121
+ const base = [...new Set(types)].join("|") || "any";
122
+ return nullable ? `${base}|null` : base;
86
123
  }
124
+ if (prop.properties) return "object";
87
125
  return "any";
88
126
  }
89
127
 
@@ -0,0 +1,86 @@
1
+ // `landings` — manage landing pages: a link plus context describing what lives there.
2
+ // Collections with auto-mention enabled weave relevant landing pages into articles as
3
+ // they're written. NOTE: the landing's URL is passed as --link, not --url, because
4
+ // --url is the global flag that overrides the API base URL.
5
+
6
+ import { CliError } from "../client.mjs";
7
+ import { flagStr, requireFlag, requirePositional, readBodyInput, printResult, compact, ok } from "./shared.mjs";
8
+
9
+ export async function cmdLandings(ctx) {
10
+ const sub = ctx.positionals[0];
11
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
12
+ switch (sub) {
13
+ case "list":
14
+ case "ls":
15
+ return landingsList(rest);
16
+ case "get":
17
+ return landingsGet(rest);
18
+ case "new":
19
+ case "create":
20
+ return landingsCreate(rest);
21
+ case "update":
22
+ return landingsUpdate(rest);
23
+ case "delete":
24
+ case "rm":
25
+ return landingsDelete(rest);
26
+ default:
27
+ throw new CliError(
28
+ `Unknown landings subcommand: ${sub ?? "(none)"}. Try: list, get, create, update, delete`
29
+ );
30
+ }
31
+ }
32
+
33
+ async function landingsList(ctx) {
34
+ const { client, flags, io } = ctx;
35
+ const result = await client.callTool("list_landings", {});
36
+ printResult(io, flags, result);
37
+ return 0;
38
+ }
39
+
40
+ async function landingsGet(ctx) {
41
+ const { client, positionals, flags, io } = ctx;
42
+ const id = requirePositional(positionals, 0, "landing-id");
43
+ const result = await client.callTool("get_landing", { landing_id: id });
44
+ printResult(io, flags, result);
45
+ return 0;
46
+ }
47
+
48
+ async function landingsCreate(ctx) {
49
+ const { client, flags, io } = ctx;
50
+ // --link (not --url: --url is the global API-base override) is the landing's URL.
51
+ const url = requireFlag(flags, "link");
52
+ // Context comes from --context, or --body/--file/stdin for a larger blob.
53
+ const context = flagStr(flags.context) ?? readBodyInput(flags);
54
+ const result = await client.callTool("create_landing", compact({ url, context }));
55
+ ok(ctx, `Created landing page ${url}.`);
56
+ printResult(io, flags, result);
57
+ return 0;
58
+ }
59
+
60
+ async function landingsUpdate(ctx) {
61
+ const { client, positionals, flags, io } = ctx;
62
+ const id = requirePositional(positionals, 0, "landing-id");
63
+ const url = flagStr(flags.link);
64
+ const context = flagStr(flags.context) ?? readBodyInput(flags);
65
+ const patch = compact({ landing_id: id, url, context });
66
+ if (Object.keys(patch).length <= 1) {
67
+ throw new CliError("Nothing to update — pass --link <url> and/or --context <text> (or --file <path>).");
68
+ }
69
+ const result = await client.callTool("update_landing", patch);
70
+ ok(ctx, "Updated landing page.");
71
+ printResult(io, flags, result);
72
+ return 0;
73
+ }
74
+
75
+ async function landingsDelete(ctx) {
76
+ const { client, positionals, flags, io } = ctx;
77
+ const id = requirePositional(positionals, 0, "landing-id");
78
+ if (!flags.yes) {
79
+ io.error(`Re-run with --yes to confirm: ${ctx.bin} landings delete ${id} --yes`);
80
+ return 1;
81
+ }
82
+ const result = await client.callTool("delete_landing", { landing_id: id });
83
+ ok(ctx, "Landing page deleted.");
84
+ printResult(io, flags, result);
85
+ return 0;
86
+ }
@@ -0,0 +1,154 @@
1
+ // `onboard` — the one-shot phantom onboarding recipe: a client domain in, a live phantom
2
+ // blog with its first post(s) out. It drives ONE server tool, `onboard_phantom`:
3
+ // - a bare `onboard <domain>` is a safe DRY RUN (accept:false) — it prints the proposed
4
+ // plan (masthead name, description, strategy, cadence/cover/seed defaults) and stops.
5
+ // - `--accept` (or any override flag) APPLIES: `onboard_phantom{accept:true}` starts a
6
+ // durable onboarding job server-side and returns a job_id; this command then polls
7
+ // `get_phantom_onboard_status` until the job is done (provision → configure → seed).
8
+ //
9
+ // `--about "<the beat>"` is the highest-leverage flag: it becomes the publication's planner
10
+ // steering prompt, so it shapes the topic map and every post the site ever writes — not just
11
+ // the name. Use it when the client already has a phantom and this one needs a different beat.
12
+ //
13
+ // `--domain <host>` records a custom domain; nothing is bought unless you also pass
14
+ // `--domain-mode buy_now`, which SPENDS money against a 2-per-org, $30-per-domain cap.
15
+ //
16
+ // IMPORTANT: the masthead name is committed when the site is created (it seeds the URL),
17
+ // so to use a name other than the inferred one, pass --name BEFORE applying. The first
18
+ // posts are written by background jobs (20-40min each); they're reported as job ids and
19
+ // this command does not block on them — poll with `phantom-job status <job-id>`.
20
+
21
+ import { CliError } from "../client.mjs";
22
+ import { flagStr, flagNum, flagBool, requirePositional, printResult, compact, ok, pollUntil } from "./shared.mjs";
23
+
24
+ const COVER_STYLES = ["editorial_image", "full_ai", "line_art"];
25
+ const DOMAIN_MODES = ["subdomain", "buy_now", "connect_later"];
26
+
27
+ function formatPlan(plan) {
28
+ const lines = [
29
+ `Plan for ${plan.domain} (client: ${plan.client_label})`,
30
+ ` proposed name: ${plan.proposed_name ?? "(none — pass --name)"}`,
31
+ ` description: ${plan.description}`,
32
+ ` defaults: ${plan.defaults.cadence_per_week}/week · ${plan.defaults.cover_canvas} · seed ${plan.defaults.posts_to_seed}`,
33
+ ];
34
+ if (plan.direction) lines.push(` direction: ${plan.direction}`);
35
+ if (plan.domain_plan?.site_domain) {
36
+ lines.push(` domain: ${plan.domain_plan.site_domain} (${plan.domain_plan.mode})`);
37
+ }
38
+ if (plan.strategy) {
39
+ lines.push(` topics: ${(plan.strategy.priority_topics ?? []).slice(0, 5).join(", ") || "(none)"}`);
40
+ } else {
41
+ lines.push(` strategy: (none inferred)`);
42
+ }
43
+ lines.push(` note: ${plan.note}`);
44
+ return lines.join("\n");
45
+ }
46
+
47
+ function formatStatus(s) {
48
+ const lines = [
49
+ `Onboarding ${s.status} at stage "${s.stage}"`,
50
+ ` domain: ${s.domain}`,
51
+ ` deployment: ${s.deployment_id ?? "(pending)"}`,
52
+ ` collection: ${s.collection_id ?? "(pending)"}`,
53
+ ];
54
+ if (s.error) lines.push(` error: ${s.error}`);
55
+ if (s.seed_job_ids?.length) {
56
+ lines.push(` seeded ${s.seed_job_ids.length} post job(s):`);
57
+ for (const id of s.seed_job_ids) lines.push(` - ${id}`);
58
+ lines.push(` poll them with: phantom-job status <job-id>`);
59
+ }
60
+ return lines.join("\n");
61
+ }
62
+
63
+ // Build the overrides object the tool accepts from the CLI flags (snake_case keys).
64
+ function overridesFromFlags(flags) {
65
+ const cover = flagStr(flags.cover);
66
+ if (cover !== undefined && !COVER_STYLES.includes(cover)) {
67
+ throw new CliError(`--cover must be one of: ${COVER_STYLES.join(", ")}`);
68
+ }
69
+ const domainMode = flagStr(flags["domain-mode"]);
70
+ if (domainMode !== undefined && !DOMAIN_MODES.includes(domainMode)) {
71
+ throw new CliError(`--domain-mode must be one of: ${DOMAIN_MODES.join(", ")}`);
72
+ }
73
+ const siteDomain = flagStr(flags.domain);
74
+ // buy_now spends real money against a 2-per-org cap; refusing the mismatch here beats
75
+ // silently downgrading it to a subdomain launch the caller didn't ask for.
76
+ if (domainMode && domainMode !== "subdomain" && !siteDomain) {
77
+ throw new CliError(`--domain-mode ${domainMode} needs --domain <bare-domain>`);
78
+ }
79
+ return compact({
80
+ name: flagStr(flags.name),
81
+ cadence_per_week: flagNum(flags.cadence),
82
+ cover_canvas: cover,
83
+ theme: flagStr(flags.theme),
84
+ posts_to_seed: flagNum(flags.posts),
85
+ // The highest-leverage override: it becomes the planner's steering prompt, so it
86
+ // shapes the topic map and every post, not just the masthead.
87
+ direction: flagStr(flags.about) ?? flagStr(flags.direction),
88
+ site_domain: siteDomain,
89
+ domain_mode: domainMode,
90
+ is_test: flagBool(flags["real"]) ? false : undefined,
91
+ });
92
+ }
93
+
94
+ export async function cmdOnboard(ctx) {
95
+ const { client, flags, io } = ctx;
96
+ const domain = requirePositional(ctx.positionals, 0, "domain");
97
+ const overrides = overridesFromFlags(flags);
98
+
99
+ // Step 1: dry-run plan (accept defaults to false server-side). Persists nothing.
100
+ const plan = await client.callTool(
101
+ "onboard_phantom",
102
+ compact({ domain, overrides: Object.keys(overrides).length ? overrides : undefined })
103
+ );
104
+ ok(ctx, "Onboarding plan:");
105
+ printResult(io, flags, plan, formatPlan);
106
+
107
+ if (flagBool(flags["dry-run"])) return 0;
108
+
109
+ // Step 2: the review gate. A bare, unaccepted run stops here so nothing is provisioned
110
+ // by accident — re-run with --accept (or an override flag) once happy.
111
+ const overrideGiven = Object.keys(overrides).length > 0;
112
+ if (!flagBool(flags.accept) && !overrideGiven) {
113
+ ok(
114
+ ctx,
115
+ "Re-run with --accept to apply, or pass --about/--name/--cadence/--cover/--posts/--theme/--domain to adjust. " +
116
+ "Note: --name is committed at create (it seeds the URL)."
117
+ );
118
+ return 0;
119
+ }
120
+ if (plan.proposed_name === null && flagStr(flags.name) === undefined) {
121
+ throw new CliError(
122
+ "No blog name available: the plan proposed none (strategy soft-failed). Pass --name <MastheadName> to apply."
123
+ );
124
+ }
125
+
126
+ // Step 3: apply — start the durable onboarding job.
127
+ ok(ctx, "Applying — starting the onboarding job…");
128
+ const started = await client.callTool(
129
+ "onboard_phantom",
130
+ compact({ domain, accept: true, overrides: Object.keys(overrides).length ? overrides : undefined })
131
+ );
132
+ const jobId = started.job_id;
133
+ if (!jobId) throw new CliError("onboard_phantom did not return a job id.");
134
+
135
+ // Step 4: poll the job to a terminal state, printing stage transitions.
136
+ let lastStage = null;
137
+ const final = await pollUntil(
138
+ io,
139
+ async () => {
140
+ const s = await client.callTool("get_phantom_onboard_status", { job_id: jobId });
141
+ if (s.status === "failed")
142
+ throw new CliError(`Onboarding failed at stage "${s.stage}": ${s.error ?? "unknown error"}`);
143
+ if (s.stage !== lastStage) {
144
+ ok(ctx, ` … ${s.stage}`);
145
+ lastStage = s.stage;
146
+ }
147
+ return { done: s.status === "done", value: s };
148
+ },
149
+ { label: "onboarding to finish", timeoutMs: 20 * 60 * 1000, intervalMs: 4000 }
150
+ );
151
+
152
+ printResult(io, flags, final, formatStatus);
153
+ return 0;
154
+ }
@@ -625,18 +625,18 @@ async function templatesShow(ctx) {
625
625
  return 0;
626
626
  }
627
627
 
628
- function templateSectionsFromFlags(flags) {
629
- const inline = flagStr(flags.sections);
630
- const file = flagStr(flags["sections-file"]);
628
+ function templateBlocksFromFlags(flags) {
629
+ const inline = flagStr(flags.blocks);
630
+ const file = flagStr(flags["blocks-file"]);
631
631
  if (inline === undefined && file === undefined) return undefined;
632
- const raw = inline !== undefined ? inline : readFileOrThrow(file, "--sections-file");
632
+ const raw = inline !== undefined ? inline : readFileOrThrow(file, "--blocks-file");
633
633
  let parsed;
634
634
  try {
635
635
  parsed = JSON.parse(raw);
636
636
  } catch (err) {
637
- throw new CliError(`--sections is not valid JSON: ${err.message}`);
637
+ throw new CliError(`--blocks is not valid JSON: ${err.message}`);
638
638
  }
639
- if (!Array.isArray(parsed)) throw new CliError("--sections must be a JSON array of section objects");
639
+ if (!Array.isArray(parsed)) throw new CliError("--blocks must be a JSON array of block objects");
640
640
  return parsed;
641
641
  }
642
642
 
@@ -644,11 +644,14 @@ async function templatesCreate(ctx) {
644
644
  const { client, flags, io } = ctx;
645
645
  const name = flagStr(flags.name);
646
646
  const angle = flagStr(flags.angle);
647
- const sections = templateSectionsFromFlags(flags);
647
+ const blocks = templateBlocksFromFlags(flags);
648
648
  if (!name) throw new CliError("Missing required --name");
649
- if (!angle) throw new CliError("Missing required --angle");
650
- if (!sections) throw new CliError("Missing required --sections '<json>' or --sections-file <path>");
651
- const result = await client.callTool("create_template", { name, angle, sections });
649
+ // `--angle` is OPTIONAL, and omitting it is the better answer. It was required here while
650
+ // the server schema had already made it optional and the app had dropped the field: a
651
+ // template constrains a post's SHAPE and never says what it is about, so an angle belongs
652
+ // to the post — which already has one, from its research direction.
653
+ if (!blocks) throw new CliError("Missing required --blocks '<json>' or --blocks-file <path>");
654
+ const result = await client.callTool("create_template", { name, blocks, ...(angle ? { angle } : {}) });
652
655
  ok(ctx, `Template created — key ${result.template?.key}.`);
653
656
  printResult(io, flags, result);
654
657
  return 0;
@@ -659,11 +662,11 @@ async function templatesUpdate(ctx) {
659
662
  const templateId = requirePositional(positionals, 0, "template-id");
660
663
  const name = flagStr(flags.name);
661
664
  const angle = flagStr(flags.angle);
662
- const sections = templateSectionsFromFlags(flags);
665
+ const blocks = templateBlocksFromFlags(flags);
663
666
  // update_template replaces the whole template; fetch current values for any
664
667
  // field the caller didn't pass so a partial edit doesn't blank the rest.
665
668
  let current;
666
- if (!name || !angle || !sections) {
669
+ if (!name || !angle || !blocks) {
667
670
  const existing = await client.callTool("get_template", { template_id: templateId });
668
671
  current = existing.template;
669
672
  }
@@ -671,7 +674,7 @@ async function templatesUpdate(ctx) {
671
674
  template_id: templateId,
672
675
  name: name ?? current.name,
673
676
  angle: angle ?? current.angle,
674
- sections: sections ?? current.sections,
677
+ blocks: blocks ?? current.blocks,
675
678
  });
676
679
  ok(ctx, "Template updated.");
677
680
  printResult(io, flags, result);
@@ -17,10 +17,8 @@ export async function cmdSeries(ctx) {
17
17
  return seriesCreate(rest);
18
18
  case "backfill":
19
19
  return seriesBackfill(rest);
20
- case "propose":
21
- return seriesPropose(rest);
22
20
  default:
23
- throw new CliError(`Unknown series subcommand: ${sub ?? "(none)"}. Try: list, create, backfill, propose`);
21
+ throw new CliError(`Unknown series subcommand: ${sub ?? "(none)"}. Try: list, create, backfill`);
24
22
  }
25
23
  }
26
24
 
@@ -77,25 +75,3 @@ async function seriesBackfill(ctx) {
77
75
  printResult(io, flags, result);
78
76
  return 0;
79
77
  }
80
-
81
- function formatProposals(result) {
82
- const proposals = result?.proposals ?? [];
83
- if (proposals.length === 0) return "No series detected — this blog has no clearly repeating shapes.";
84
- return proposals
85
- .map((p) => {
86
- const matched = p.matchedTitles?.length ?? 0;
87
- return [
88
- `${p.name} — ${p.description}`,
89
- ` covers ${matched} existing post(s); items: ${p.coverage.join(", ")}`,
90
- ].join("\n");
91
- })
92
- .join("\n");
93
- }
94
-
95
- async function seriesPropose(ctx) {
96
- const { client, flags, io } = ctx;
97
- const args = compact({ collection_id: flagStr(flags.collection) });
98
- const result = await client.callTool("propose_series", args);
99
- printResult(io, flags, result, formatProposals);
100
- return 0;
101
- }
@@ -70,6 +70,25 @@ export function ok(ctx, message) {
70
70
  ctx.io.log(message);
71
71
  }
72
72
 
73
+ // Poll an async step to a terminal state. `poll` runs one check and returns
74
+ // `{ done, value }`: `done:true` ends the loop and `pollUntil` resolves to `value`.
75
+ // Progress logging is the caller's job (it happens inside `poll`, which has ctx),
76
+ // so this stays --json/--quiet-clean. Throws a CliError once the deadline passes.
77
+ export async function pollUntil(io, poll, opts = {}) {
78
+ const intervalMs = opts.intervalMs ?? 3000;
79
+ const timeoutMs = opts.timeoutMs ?? 300000;
80
+ const label = opts.label ?? "operation";
81
+ const deadline = Date.now() + timeoutMs;
82
+ for (;;) {
83
+ const { done, value } = await poll();
84
+ if (done) return value;
85
+ if (Date.now() >= deadline) {
86
+ throw new CliError(`Timed out waiting for ${label} after ${Math.round(timeoutMs / 1000)}s.`);
87
+ }
88
+ await io.sleep(intervalMs);
89
+ }
90
+ }
91
+
73
92
  // Read a text body from --body, or --file (a path, or "-" for stdin). Returns
74
93
  // undefined if neither flag was given, so callers can decide whether that's an error.
75
94
  export function readBodyInput(flags) {
@@ -220,120 +220,11 @@ export async function cmdOnboarding(ctx) {
220
220
  return onboardingStatus(rest);
221
221
  case "step":
222
222
  return onboardingStep(rest);
223
- case "magic":
224
- return onboardingMagic(rest);
225
223
  default:
226
- throw new CliError(`Unknown onboarding subcommand: ${sub ?? "(none)"}. Try: status, step, magic`);
224
+ throw new CliError(`Unknown onboarding subcommand: ${sub ?? "(none)"}. Try: status, step`);
227
225
  }
228
226
  }
229
227
 
230
- // --- magic: enter a domain → strategy filled out -------------------------
231
- //
232
- // The G1 "magical onboarding": one command that composes the platform's
233
- // existing inference into a filled-out strategy — manifesto + stances +
234
- // identity (infer_org_strategy), optional post-history import from the
235
- // sitemap, keywords + topic clusters (infer_client_keywords), and detected
236
- // content series (propose_series → create_series → backfill_series). Every
237
- // step is idempotent and never clobbers hand-authored data, so re-running is
238
- // always safe. --dry-run previews series proposals instead of adopting them.
239
-
240
- async function onboardingMagic(ctx) {
241
- const { client, flags, io } = ctx;
242
- const dryRun = flags["dry-run"] === true;
243
- const summary = {};
244
-
245
- // 1. Company profile → the domain everything reads from.
246
- ok(ctx, "Reading company profile…");
247
- const company = await client.callTool("get_company_info", {});
248
- const domain = flagStr(flags.domain) ?? company.domain ?? undefined;
249
- summary.domain = domain ?? null;
250
-
251
- // 2. Strategy: manifesto, stances, identity — saved with never-clobber guards.
252
- ok(ctx, domain ? `Inferring strategy from ${domain}…` : "Inferring strategy…");
253
- const strategy = await client.callTool("infer_org_strategy", compact({ domain }));
254
- summary.strategy = strategy;
255
- if (strategy.status === "skipped") {
256
- ok(ctx, `Strategy skipped (${strategy.reason}): ${strategy.detail}`);
257
- } else {
258
- ok(ctx, strategy.status === "reused" ? "Strategy already in place — kept as-is." : "Strategy saved.");
259
- }
260
-
261
- // 3. Post history: optionally import the blog's existing posts from a sitemap
262
- // so clustering/series have an inventory to read.
263
- let inventory = await client.callTool("get_content_inventory", {});
264
- if (flagStr(flags.sitemap)) {
265
- const collectionId = flagStr(flags.collection) ?? (await resolveMainCollection(client));
266
- ok(ctx, "Importing existing posts from the sitemap (this can take a while)…");
267
- const imported = await client.callTool("import_sitemap", {
268
- collection_id: collectionId,
269
- sitemap_url: flagStr(flags.sitemap),
270
- });
271
- summary.sitemap_import = imported;
272
- inventory = await client.callTool("get_content_inventory", {});
273
- } else if ((inventory.total ?? 0) === 0) {
274
- ok(ctx, "No posts in the inventory yet — pass --sitemap <url> to import the blog's history.");
275
- }
276
- summary.inventory = inventory;
277
-
278
- // 4. Keywords + topic clusters over whatever inventory exists.
279
- if ((inventory.total ?? 0) > 0) {
280
- ok(ctx, "Inferring keywords and building topic clusters…");
281
- summary.keywords = await client.callTool("infer_client_keywords", {});
282
- }
283
-
284
- // 5. Series detection — adopt each grounded proposal (create + backfill),
285
- // or just show them under --dry-run.
286
- ok(ctx, "Detecting content series…");
287
- const proposed = await client.callTool("propose_series", compact({ collection_id: flagStr(flags.collection) }));
288
- const proposals = proposed.proposals ?? [];
289
- summary.series = [];
290
- for (const p of proposals) {
291
- if (dryRun) {
292
- summary.series.push({ name: p.name, coverage: p.coverage, adopted: false });
293
- continue;
294
- }
295
- const created = await client.callTool("create_series", {
296
- name: p.name,
297
- description: p.description,
298
- coverage: p.coverage,
299
- });
300
- const backfilled = await client.callTool("backfill_series", {
301
- series_id: created.id,
302
- collection_id: proposed.collection_id,
303
- });
304
- summary.series.push({ name: p.name, coverage: p.coverage, adopted: true, backfilled: backfilled.matched });
305
- ok(ctx, `Series "${p.name}" created — ${backfilled.matched} existing post(s) reconstructed into it.`);
306
- }
307
- if (proposals.length === 0) ok(ctx, "No series detected — this blog has no clearly repeating shapes.");
308
-
309
- printResult(io, flags, summary, (s) => {
310
- const lines = [];
311
- lines.push(`Strategy: ${s.strategy.status}${s.strategy.reason ? ` (${s.strategy.reason})` : ""}`);
312
- lines.push(`Inventory: ${s.inventory.total ?? 0} post(s)${s.inventory.lowInventory ? " (low)" : ""}`);
313
- if (s.keywords) {
314
- lines.push(
315
- `Clusters: ${s.keywords.assigned ?? 0} post(s) assigned, ${s.keywords.newClusters ?? 0} new cluster(s)`
316
- );
317
- }
318
- lines.push(
319
- s.series.length === 0
320
- ? "Series: none detected"
321
- : `Series: ${s.series.map((x) => `${x.name}${x.adopted ? "" : " (proposed)"}`).join("; ")}`
322
- );
323
- return lines.join("\n");
324
- });
325
- return 0;
326
- }
327
-
328
- // The org's main (non-phantom) collection — where history imports and
329
- // org-level series live. list_collections includes `kind` for exactly this.
330
- async function resolveMainCollection(client) {
331
- const result = await client.callTool("list_collections", {});
332
- const main = (result.items ?? []).find((c) => c.kind !== "phantom");
333
- if (!main) throw new CliError("No main collection found — create one first, or pass --collection <id>.");
334
- return main.collection_id;
335
- }
336
-
337
228
  async function onboardingStatus(ctx) {
338
229
  const { client, flags, io } = ctx;
339
230
  const result = await client.callTool("get_onboarding_status", {});
package/lib/commands.mjs CHANGED
@@ -18,7 +18,10 @@ export * from "./commands/series.mjs";
18
18
  export * from "./commands/insights.mjs";
19
19
  export * from "./commands/covers.mjs";
20
20
  export * from "./commands/research.mjs";
21
+ export * from "./commands/autopen.mjs";
21
22
  export * from "./commands/kernel.mjs";
22
23
  export * from "./commands/phantom-job.mjs";
24
+ export * from "./commands/onboard.mjs";
23
25
  export * from "./commands/seers.mjs";
24
26
  export * from "./commands/shredder.mjs";
27
+ export * from "./commands/landings.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",