@letterstory/cli 0.27.1 → 0.29.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
@@ -79,9 +79,9 @@ refresh automatically. Run `letterstory logout` to revoke the session and forget
79
79
 
80
80
  **Or use a static API key** (for CI/automation, or when you want scoped-down access):
81
81
 
82
- You need a Letterstory API key (starts with `ls_`; legacy `lb_` keys still work) with the `deployment:read` and
83
- `deployment:write` capabilities — mint one in the app under **Settings → API keys**.
84
- Add `deployment:domain` too if you plan to buy custom domains.
82
+ You need a Letterstory API key (starts with `ls_`; legacy `lb_` keys still work) with the `phantom:read` and
83
+ `phantom:write` capabilities — mint one in the app under **Settings → API keys**.
84
+ Add `phantom:domain` too if you plan to buy custom domains.
85
85
 
86
86
  ```bash
87
87
  letterstory login --key ls_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
@@ -95,59 +95,48 @@ Run `letterstory whoami` (alias `status`) any time to confirm which key/url (or
95
95
  resolved and see your company profile. Run `letterstory config` to see the resolved url and
96
96
  credential source without making a network call.
97
97
 
98
- ## Spin up a blog
98
+ ## Phantomstory — a collection Letterstory hosts as an independent publication
99
+
100
+ A phantom is a site we host that renders one of your collections; it never carries your
101
+ company's name. Phantoms are metered in SITES live at once (`phantom balance`), not posts.
99
102
 
100
103
  ```bash
101
- # create a blog and wait until it's live (polls for you)
102
- letterstory deploy create --name "My Blog" --theme gazette
103
-
104
- # Created deployment 7c19c7… (provisioning)
105
- # … provisioning · building
106
- # … provisioning · connecting
107
- # ✓ Live at https://my-blog-7c19c7.letterstory-staging.com
108
-
109
- letterstory deploy list
110
- letterstory deploy get <deployment-id>
111
- letterstory deploy update <deployment-id> --name "New Name"
112
- letterstory deploy rebuild <deployment-id> # after publishing new articles
113
- letterstory deploy diagnostics <deployment-id> # why is my blog empty?
114
- letterstory deploy delete <deployment-id> --yes
104
+ # provision a site and follow it until it's live (then it configures itself in the background:
105
+ # strategy, visuals, writing kernel, cadence, topic map)
106
+ letterstory phantom new --name "Saved Query" --config '{"cadence":{"posts_per_week":6}}'
107
+
108
+ # Phantom 7c19c7… provisioning — following…
109
+ # … provisioning · deploy
110
+ # … provisioning · connect
111
+ # Live at https://saved-query-7c19c7.letterstory-staging.com. Configuration continues in the background
112
+
113
+ letterstory phantom list
114
+ letterstory phantom get <phantom-id>
115
+ letterstory phantom config <phantom-id> # the site's file joined to its collection's
116
+ letterstory phantom config <phantom-id> --theme commons # patch the site (identity/theme rebuild it)
117
+ letterstory phantom config <phantom-id> --collection-config '{"cadence":{"posts_per_week":4}}'
118
+ letterstory phantom config <phantom-id> --configure --dry-run # what re-configuring would decide, applying nothing
119
+ letterstory phantom rebuild <phantom-id> # after publishing outside a collection run
120
+ letterstory phantom diagnostics <phantom-id> # why is my site empty?
121
+ letterstory phantom balance # sites live / granted, and the cadence cap
122
+ letterstory phantom delete <phantom-id> --yes
115
123
  ```
116
124
 
117
- Pass `--no-wait` to return immediately and poll later with `deploy get`, or `--dry-run`
118
- on `create` to print what would be sent without creating anything.
119
-
120
- ### One-shot: bare `deploy`
121
-
122
- For the common case — reserve a blog, price (and optionally buy) a domain, and rebuild,
123
- all in one call — skip the subcommand word entirely:
125
+ Pass `--no-wait` to return immediately and poll later with `phantom get`, `--no-configure`
126
+ for a bare site, or `--dry-run` to see the plan without provisioning anything.
124
127
 
125
- ```bash
126
- letterstory deploy --domain yourverticalreview.com --buy
127
- # Reserved phantom blog "Yourverticalreview" (7c19c7…)
128
- # Priced yourverticalreview.com
129
- # Registered yourverticalreview.com
130
- # SSL + DNS provisioned · sitemap + schema generated
131
- #
132
- # Your phantom blog is live at https://yourverticalreview.com.
133
-
134
- letterstory deploy --blog 7c19c7… --rebuild # rebuild an existing blog instead of creating one
135
- letterstory deploy --domain demo.com --dry-run # preview without calling the API
136
- ```
128
+ Publishing to the site is a **collection run** on its collection (below). Letterstory's own
129
+ launch recipe from a client domain — positioning inferred, a masthead proposed, the first
130
+ posts seeded — is `phantom-onboard` (see "Propose, then onboard a phantom").
137
131
 
138
132
  ## Custom domains
139
133
 
140
134
  ```bash
141
- letterstory domain check myblog.com # price it (no charge)
142
- letterstory domain buy <deployment-id> myblog.com # buys + attaches the domain
135
+ letterstory phantom domain check myblog.com # price it (no charge)
136
+ letterstory phantom domain buy <phantom-id> myblog.com --yes # buys + attaches (spends money; phantom:domain)
137
+ letterstory phantom domain connect <phantom-id> myblog.com # attach one you already own
143
138
  ```
144
139
 
145
- `blogs` and `domains` are aliases for `deploy` and `domain` — the exact same commands
146
- under Phantomstory's vocabulary (`blogs ls`, `blogs new`, `blogs rm`, `blogs update`,
147
- `domains buy`, etc. all call the identical handler as their `deploy`/`domain`
148
- counterpart). Use whichever set of verbs you prefer; both are always available from
149
- both `letterstory` and `phantom`.
150
-
151
140
  ## Content
152
141
 
153
142
  ```bash
@@ -161,16 +150,41 @@ letterstory published list [--limit <n>] [--collection <uuid>] [--format html|md
161
150
  letterstory published show --slug my-post-slug [--format html|md] # or --id <uuid>
162
151
  ```
163
152
 
164
- ## Collections
153
+ ## Collections — the content engine, one door
154
+
155
+ A collection is a publication with a ONE-FILE configuration (identity, content, cadence,
156
+ publishing, targets, measurement) that produces and publishes posts on a cadence.
157
+ `collection run` produces ONE post: source → write → gate → publish → (hosted) rebuild.
165
158
 
166
159
  ```bash
167
- letterstory collections list
168
- letterstory collections new --name "Launches" --description "…"
160
+ letterstory collection list # public_id = the HTTP door's address
161
+ letterstory collection new --name "Launches" --config-file cfg.json
162
+ letterstory collection config <id> # print the one file
163
+ letterstory collection config <id> --config '{"targets":{"enabled":true,"tier":"ramping"}}' # patch it
164
+
165
+ # one post, followed to the end (20–60 min); stops at the gate under manual approval
166
+ letterstory collection run --collection <id> --spoke <topic-id>
167
+ letterstory collection run --collection <id> --topic "…" --approval manual --no-wait
168
+ letterstory collection run --collection <id> --at 2026-10-01T09:00:00Z # a future slot
169
+ letterstory collection run-status <run-id> # status, steps, constraints, verification
170
+ letterstory collection runs --collection <id> --status ready
171
+ letterstory collection approve <run-id> --now
172
+ letterstory collection cancel <run-id>
173
+ letterstory collection delete <id> --yes
174
+
175
+ # the legacy verbs still work under `collections`
169
176
  letterstory collections update <id> --cadence-target 4 --cadence-period week
170
- letterstory collections delete <id> --yes
171
177
  letterstory collections assign <article-id> <collection-id>
178
+ letterstory collections activate <id> --cadence 6 --approval autopilot
179
+ letterstory collections autopen <id> --kernel-family gazette
172
180
  ```
173
181
 
182
+ A direct run spends one of the collection's weekly cadence slots (its `posts_per_week`,
183
+ capped by the org's cadence cap) and is refused — never queued silently — once the week is
184
+ spent; the planner's own slots are the budget being spent as designed. The same door over
185
+ HTTP: `POST /api/collection/ingest/{public_id}` (202 + `poll_url`), `GET …/runs/{run_id}`,
186
+ `POST …/runs/{run_id}/approve`.
187
+
174
188
  ## Landing pages
175
189
 
176
190
  A landing page is a link plus context describing what lives there. Collections with
@@ -280,6 +294,7 @@ jq '.topicPlans[1]' proposal.json | letterstory phantom-onboard mastheads acme.c
280
294
  # Then onboard with the plan (direction + pillars), the masthead and, if any, the domain.
281
295
  letterstory phantom-onboard acme.com --plan-file proposal.json --plan-id plan-2 \
282
296
  --name "The Compliance Wire" --domain compliancewire.com --domain-mode buy_now --real
297
+ # (drives plan_phantom; the seeded posts are collection runs — poll them with `collection run-status`)
283
298
  ```
284
299
 
285
300
  `propose` reads the client's homepage and returns distinct editorial angles (`topicPlans`), the
@@ -341,6 +356,16 @@ letterstory arena endpoint create --name "Docs research" --defaults '{"depth":"b
341
356
  letterstory arena endpoint config <arena-id> [--file cfg.json]
342
357
  letterstory arena endpoint delete <arena-id> --yes
343
358
 
359
+ # Ledes: a prospect's context in, ready-to-send opening lines out (chosen from a
360
+ # hand-approved library, never generated; the top choice is flagged). Synchronous.
361
+ letterstory lede create --name "Clay outbound table"
362
+ letterstory lede run <endpoint-id> --first-name Jane --company Acme --company-url acme.com --years 3
363
+ letterstory lede run <endpoint-id> --email jane@acme.com --tone direct --candidates 3
364
+ letterstory lede run <endpoint-id> --company Acme --default-lede "Came across {{company}} this week."
365
+ letterstory lede runs --limit 20
366
+ letterstory lede balance
367
+ letterstory lede endpoint config <endpoint-id> [--file cfg.json]
368
+
344
369
  # 2. Have a kernel write the draft
345
370
  letterstory kernel list
346
371
  letterstory kernel run --article <uuid> --kernel <uuid> [--brief "…" | --brief-file brief.md]
@@ -375,8 +400,8 @@ letterstory shredder job <job-id> # poll it your
375
400
  Both follow loops poll every 15–20 s. `LETTERSTORY_POLL_INTERVAL_MS` overrides that — set
376
401
  it to `0` in CI so a test does not wait on a real interval.
377
402
 
378
- `phantom-job create --topic "…" --collection <uuid>` runs topic → draft → publish → rebuild
379
- end to end, with `phantom-job status <job-id>` to follow it.
403
+ `collection run --topic "…" --collection <uuid>` runs topic → draft → publish → rebuild
404
+ end to end, with `collection run-status <job-id>` to follow it.
380
405
 
381
406
  ## Seers — event-driven signals
382
407
 
package/lib/cli.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  // never throws for user-facing problems (those become a printed CliError + code 1).
4
4
 
5
5
  import { LetterstoryClient, CliError, resolveConfig, readConfigFile, writeConfigFile } from "./client.mjs";
6
+ import { resolveDocumentFlags } from "./documents.mjs";
6
7
  import { checkForUpdate } from "./update-check.mjs";
7
8
  import {
8
9
  cmdLogin,
@@ -13,10 +14,8 @@ import {
13
14
  cmdCall,
14
15
  cmdTool,
15
16
  cmdMcp,
16
- cmdDeploy,
17
- cmdDomain,
18
- cmdBlogs,
19
- cmdDomains,
17
+ cmdCollection,
18
+ cmdPhantom,
20
19
  cmdPosts,
21
20
  cmdPublished,
22
21
  cmdReviewers,
@@ -34,17 +33,17 @@ import {
34
33
  cmdAutoPen,
35
34
  cmdCovers,
36
35
  cmdKernel,
37
- cmdPhantomJob,
38
36
  cmdOnboard,
39
37
  cmdSeers,
40
38
  cmdTemplates,
41
39
  cmdShredder,
40
+ cmdLede,
42
41
  cmdLandings,
43
42
  cmdAdmin,
44
43
  } from "./commands.mjs";
45
44
 
46
45
  // Keep in sync with cli/package.json.
47
- export const VERSION = "0.27.1";
46
+ export const VERSION = "0.29.0";
48
47
 
49
48
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
50
49
  // can't accidentally swallow the id as --json's value.
@@ -166,25 +165,42 @@ Auth:
166
165
  logout Forget saved credentials (revokes an OAuth session, if any)
167
166
  config Show the resolved url + credential source
168
167
 
169
- Phantom blogs:
170
- deploy [--domain <d>] [--name <n>] [--theme <t>] [--collection <uuid>]
171
- [--blog <id>] [--buy] [--rebuild] [--dry-run]
172
- One-shot: reserve/locate a blog, price
173
- the domain, optionally buy it, rebuild
174
- deploy create --name <name> [--description <text>] [--theme <theme>]
175
- [--collection <uuid>] [--no-wait] Create a blog; waits until it's live
176
- deploy list [--limit <n>] List your blogs
177
- deploy get <deployment-id> Show one blog's status + URL
178
- deploy rebuild <deployment-id> Rebuild to pull in newly-published content
179
- deploy diagnostics <deployment-id> Why is my blog empty? snapshot
180
- deploy delete <deployment-id> --yes Tear down the blog (keeps the collection)
181
-
182
- Custom domains:
183
- domain check <domain> Price a domain (read-only, no charge)
184
- domain buy <deployment-id> <domain> Buy + attach a custom domain
185
-
186
- "blogs" and "domains" are aliases for "deploy" and "domain" (same commands, ls/new/
187
- show/update/rm verbs also accepted) — use whichever vocabulary you prefer.
168
+ Collections (the content engine as a primitive — one door, 'collection run'):
169
+ collection list Your collections (public_id = the HTTP door's address)
170
+ collection new --name <n> [--description <t>] [--config '<json>'|--config-file cfg.json]
171
+ collection config <id> [--config '<json>'|--config-file cfg.json]
172
+ Print the ONE FILE (identity, content, cadence,
173
+ publishing, targets, measurement) — or PATCH it
174
+ collection run --collection <id> [--topic <t> | --spoke <topic-id> | --headline <suggestion-id>]
175
+ [--source <url> …] [--must-include <t> …] [--depth barebones|fleshed_out]
176
+ [--kernel <id>] [--no-verify] [--shred] [--mention off|subtle|woven]
177
+ [--approval autopilot|review_window|manual] [--no-publish] [--no-rebuild]
178
+ [--at <iso-datetime>] [--no-wait]
179
+ Produce ONE post: source → write → gate →
180
+ publish → (hosted) rebuild. Spends one of the
181
+ week's cadence slots. 20–60 min; followed unless
182
+ --no-wait; stops at the gate under manual approval.
183
+ collection run-status <run-id> Status, steps, constraints, verification
184
+ collection runs [--collection <id>] [--status <s>] [--limit <n>]
185
+ collection approve <run-id> [--now] Answer the gate (--now publishes immediately)
186
+ collection cancel <run-id>
187
+ collection delete <id> --yes
188
+
189
+ Phantomstory (a collection Letterstory hosts as an independent publication):
190
+ phantom list Your phantoms (metered in SITES live at once)
191
+ phantom new --name <n> [--description <t>] [--theme <t>] [--collection <id>] [--customer-ref <r>]
192
+ [--no-configure] [--config '<json>'] [--client-domain <d>] [--dry-run] [--no-wait]
193
+ Provision a site + configure it (strategy,
194
+ visuals, kernel, cadence, topic map). Then
195
+ 'collection run --collection <its collection_id>'.
196
+ phantom get <phantom-id> Status, URL, domain, DNS
197
+ phantom config <phantom-id> [--name … --theme … --collection …] [--collection-config '<json>']
198
+ [--configure [--dry-run]]
199
+ The site's file joined to its collection's
200
+ phantom rebuild <phantom-id> · phantom diagnostics <phantom-id> · phantom balance
201
+ phantom domain check <domain> · phantom domain buy <phantom-id> <domain> --yes
202
+ phantom domain connect <phantom-id> <domain>
203
+ phantom delete <phantom-id> --yes
188
204
 
189
205
  Content:
190
206
  posts list [--limit <n>] [--collection <uuid>] List your articles
@@ -346,12 +362,7 @@ Writing kernels (kernels write the draft; they do not research — run research
346
362
  kernel runs [--endpoint <id>] [--limit <n>] Recent endpoint writes (telemetry)
347
363
  kernel balance Kernel runs left on this org
348
364
 
349
- Phantom orchestrator:
350
- phantom-job create --topic <text> --collection <uuid> [--kernel <uuid>]
351
- Topic -> draft -> publish -> rebuild
352
- phantom-job status <job-id> Check a job's stage
353
-
354
- Phantom onboarding recipe (domain -> live phantom blog with first posts):
365
+ Phantom launch recipe (a CLIENT domain -> a live phantom with its first posts; drives plan_phantom):
355
366
  ${bin === "phantom" ? "onboard" : "phantom-onboard"} <domain> [--about "the beat"] [--name X]
356
367
  [--posts N] [--cadence N] [--cover editorial_image|full_ai|line_art] [--theme T]
357
368
  [--real] [--domain host] [--domain-mode subdomain|buy_now|connect_later]
@@ -538,6 +549,24 @@ Shredder (diversify content produced outside Letterstory; structure preserved):
538
549
  shredder job <job-id> Poll a queued shred and print it when it lands
539
550
  shredder runs [--endpoint <id>] [--limit <n>] Recent shred calls (telemetry), newest first
540
551
 
552
+ Ledes (opening lines for cold email, chosen per prospect from a hand-approved library):
553
+ lede list Your lede endpoints + run counts
554
+ lede create --name <n> New endpoint (returns a callable public id)
555
+ lede delete <endpoint-id> --yes
556
+ lede endpoint config <endpoint-id> [--file cfg.json] Print the endpoint's config, or replace it
557
+ lede run <endpoint-id> [--first-name <n>] [--name <full>] [--email <e>] [--title <t>]
558
+ [--company <c>] [--company-url <url>] [--school <s>] [--city <c>] [--country <c>]
559
+ [--years <n>] [--linkedin <url>] [--notes <text>]
560
+ [--candidates <1..10>] [--tone warm|direct|playful|formal] [--no-enrich]
561
+ Synchronous: prints the candidate lines with the
562
+ top choice flagged. Needs at least one of
563
+ --first-name, --name, --email, --company or
564
+ --company-url. Only a match consumes a lede;
565
+ no_match is free. --no-enrich skips the company
566
+ lookup when latency matters more than fit.
567
+ lede runs [--endpoint <id>] [--limit <n>] Recent lede calls (telemetry), newest first
568
+ lede balance Ledes left on this org
569
+
541
570
  Landings (a link + context; auto-mentioned in articles by collections you enable):
542
571
  landings list Your landing pages + mention counts
543
572
  landings get <landing-id> Show one landing page
@@ -588,11 +617,9 @@ const CLIENT_COMMANDS = {
588
617
  // argument coercion (--arg/--json-args/--stdin) added on top.
589
618
  tool: cmdTool,
590
619
  mcp: cmdMcp,
591
- deploy: cmdDeploy,
592
- domain: cmdDomain,
593
- // Mathew's phantomstory-cli names for the exact same deploy/domain commands.
594
- blogs: cmdBlogs,
595
- domains: cmdDomains,
620
+ // The Collections primitive (one door: `collection run`) and its hosted profile.
621
+ collection: cmdCollection,
622
+ phantom: cmdPhantom,
596
623
  posts: cmdPosts,
597
624
  published: cmdPublished,
598
625
  reviewers: cmdReviewers,
@@ -610,11 +637,12 @@ const CLIENT_COMMANDS = {
610
637
  autopen: cmdAutoPen,
611
638
  covers: cmdCovers,
612
639
  kernel: cmdKernel,
613
- "phantom-job": cmdPhantomJob,
614
640
  "phantom-onboard": cmdOnboard,
615
641
  seers: cmdSeers,
616
642
  templates: cmdTemplates,
617
643
  shredder: cmdShredder,
644
+ lede: cmdLede,
645
+ ledes: cmdLede,
618
646
  landings: cmdLandings,
619
647
  // Platform-scoped (staff key) — the only commands that act BEFORE an org exists.
620
648
  admin: cmdAdmin,
@@ -687,6 +715,15 @@ export async function run(argv, io = defaultIo()) {
687
715
  verboseLog: io.error,
688
716
  bin,
689
717
  });
718
+ // DOCUMENT `--*-file` FLAGS BECOME TEXT BEFORE THE COMMAND RUNS.
719
+ //
720
+ // One interception point rather than per-command handling: `readTextInput` prefers a
721
+ // flag's inline twin over its path, so resolving a document is setting that twin — no
722
+ // change to `argsFromFlags` (which is synchronous, and has 75 call sites) and no command
723
+ // that can forget. `--file notes.md` still reads locally; `--file report.docx` and
724
+ // `--file paper.pdf` go through the app's extractor, which is the only one there is.
725
+ await resolveDocumentFlags(client, flags);
726
+
690
727
  const ctx = { client, config, positionals: positionals.slice(1), flags, io, bin };
691
728
  return await handler(ctx);
692
729
  } catch (err) {
package/lib/client.mjs CHANGED
@@ -155,6 +155,59 @@ export class LetterstoryClient {
155
155
  return body.result;
156
156
  }
157
157
 
158
+ /**
159
+ * One multipart POST to a REST route on the same host, for the routes that take BYTES.
160
+ *
161
+ * The MCP envelope is JSON, so a document would have to be base64'd into it — a 10MB PDF
162
+ * becomes ~13MB of base64 in a single JSON string. `POST /api/documents/extract` takes the
163
+ * file directly, so this exists for it (and any future byte-taking route). Same auth and
164
+ * the same one-shot refresh-and-retry as `rpc`, because an expired session should fail the
165
+ * same way whichever door the command happened to use.
166
+ *
167
+ * Deliberately no `content-type` header: `fetch` sets it from the FormData, including the
168
+ * multipart boundary, and overriding it produces a body the server cannot parse.
169
+ *
170
+ * @param {string} path Route path, e.g. `/api/documents/extract`
171
+ * @param {FormData} form
172
+ * @returns {Promise<any>} the parsed JSON body
173
+ */
174
+ async postForm(path, form, { _retried = false } = {}) {
175
+ const authHeaders = this.authHeaders();
176
+ if (!authHeaders) {
177
+ throw new CliError(
178
+ `Not logged in. Run \`${this.bin} login\` (opens your browser) or \`${this.bin} login --key <ls_…>\`.`
179
+ );
180
+ }
181
+ const url = `${this.url}${path}`;
182
+ if (this.verbose) this.verboseLog(`» POST ${url} (multipart)`);
183
+ let res;
184
+ try {
185
+ res = await this.fetch(url, {
186
+ method: "POST",
187
+ headers: { accept: "application/json", ...authHeaders },
188
+ body: form,
189
+ });
190
+ } catch (err) {
191
+ throw new CliError(`Could not reach ${url}: ${err.message}`);
192
+ }
193
+ if (this.verbose) this.verboseLog(`« ${res.status}`);
194
+ if (res.status === 401 || res.status === 403) {
195
+ if (!_retried && this.oauth?.refresh_token && (await this.tryRefresh())) {
196
+ return this.postForm(path, form, { _retried: true });
197
+ }
198
+ const hint = this.oauth
199
+ ? `Your session has expired. Run \`${this.bin} login\` again.`
200
+ : "Check your API key and its capabilities.";
201
+ throw new CliError(`Authentication failed (HTTP ${res.status}). ${hint}`);
202
+ }
203
+ const body = await res.json().catch(() => null);
204
+ if (!res.ok) {
205
+ throw new CliError(body?.error ? String(body.error) : `Request failed (HTTP ${res.status}) at ${url}`);
206
+ }
207
+ if (!body) throw new CliError(`Unexpected non-JSON response (HTTP ${res.status}) from ${url}`);
208
+ return body;
209
+ }
210
+
158
211
  // Best-effort silent refresh. Returns false (never throws) so rpc() can
159
212
  // fall through to its normal "please log in again" error on any failure.
160
213
  async tryRefresh() {