@letterstory/cli 0.28.0 → 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 +66 -51
- package/lib/cli.mjs +53 -37
- package/lib/client.mjs +53 -0
- package/lib/commands/collection.mjs +279 -0
- package/lib/commands/collections.mjs +12 -42
- package/lib/commands/onboard.mjs +11 -11
- package/lib/commands/phantom.mjs +217 -0
- package/lib/commands.mjs +4 -3
- package/lib/documents.mjs +141 -0
- package/package.json +1 -1
- package/lib/commands/deploy.mjs +0 -389
- package/lib/commands/phantom-job.mjs +0 -41
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 `
|
|
83
|
-
`
|
|
84
|
-
Add `
|
|
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
|
-
##
|
|
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
|
-
#
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
#
|
|
106
|
-
# … provisioning ·
|
|
107
|
-
#
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
letterstory
|
|
111
|
-
letterstory
|
|
112
|
-
letterstory
|
|
113
|
-
letterstory
|
|
114
|
-
letterstory
|
|
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 `
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
### One-shot: bare `deploy`
|
|
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.
|
|
121
127
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
142
|
-
letterstory domain buy <
|
|
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
|
|
168
|
-
letterstory
|
|
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
|
|
@@ -385,8 +400,8 @@ letterstory shredder job <job-id> # poll it your
|
|
|
385
400
|
Both follow loops poll every 15–20 s. `LETTERSTORY_POLL_INTERVAL_MS` overrides that — set
|
|
386
401
|
it to `0` in CI so a test does not wait on a real interval.
|
|
387
402
|
|
|
388
|
-
`
|
|
389
|
-
end to end, with `
|
|
403
|
+
`collection run --topic "…" --collection <uuid>` runs topic → draft → publish → rebuild
|
|
404
|
+
end to end, with `collection run-status <job-id>` to follow it.
|
|
390
405
|
|
|
391
406
|
## Seers — event-driven signals
|
|
392
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
|
-
|
|
17
|
-
|
|
18
|
-
cmdBlogs,
|
|
19
|
-
cmdDomains,
|
|
17
|
+
cmdCollection,
|
|
18
|
+
cmdPhantom,
|
|
20
19
|
cmdPosts,
|
|
21
20
|
cmdPublished,
|
|
22
21
|
cmdReviewers,
|
|
@@ -34,7 +33,6 @@ import {
|
|
|
34
33
|
cmdAutoPen,
|
|
35
34
|
cmdCovers,
|
|
36
35
|
cmdKernel,
|
|
37
|
-
cmdPhantomJob,
|
|
38
36
|
cmdOnboard,
|
|
39
37
|
cmdSeers,
|
|
40
38
|
cmdTemplates,
|
|
@@ -45,7 +43,7 @@ import {
|
|
|
45
43
|
} from "./commands.mjs";
|
|
46
44
|
|
|
47
45
|
// Keep in sync with cli/package.json.
|
|
48
|
-
export const VERSION = "0.
|
|
46
|
+
export const VERSION = "0.29.0";
|
|
49
47
|
|
|
50
48
|
// Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
|
|
51
49
|
// can't accidentally swallow the id as --json's value.
|
|
@@ -167,25 +165,42 @@ Auth:
|
|
|
167
165
|
logout Forget saved credentials (revokes an OAuth session, if any)
|
|
168
166
|
config Show the resolved url + credential source
|
|
169
167
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
189
204
|
|
|
190
205
|
Content:
|
|
191
206
|
posts list [--limit <n>] [--collection <uuid>] List your articles
|
|
@@ -347,12 +362,7 @@ Writing kernels (kernels write the draft; they do not research — run research
|
|
|
347
362
|
kernel runs [--endpoint <id>] [--limit <n>] Recent endpoint writes (telemetry)
|
|
348
363
|
kernel balance Kernel runs left on this org
|
|
349
364
|
|
|
350
|
-
Phantom
|
|
351
|
-
phantom-job create --topic <text> --collection <uuid> [--kernel <uuid>]
|
|
352
|
-
Topic -> draft -> publish -> rebuild
|
|
353
|
-
phantom-job status <job-id> Check a job's stage
|
|
354
|
-
|
|
355
|
-
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):
|
|
356
366
|
${bin === "phantom" ? "onboard" : "phantom-onboard"} <domain> [--about "the beat"] [--name X]
|
|
357
367
|
[--posts N] [--cadence N] [--cover editorial_image|full_ai|line_art] [--theme T]
|
|
358
368
|
[--real] [--domain host] [--domain-mode subdomain|buy_now|connect_later]
|
|
@@ -607,11 +617,9 @@ const CLIENT_COMMANDS = {
|
|
|
607
617
|
// argument coercion (--arg/--json-args/--stdin) added on top.
|
|
608
618
|
tool: cmdTool,
|
|
609
619
|
mcp: cmdMcp,
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
blogs: cmdBlogs,
|
|
614
|
-
domains: cmdDomains,
|
|
620
|
+
// The Collections primitive (one door: `collection run`) and its hosted profile.
|
|
621
|
+
collection: cmdCollection,
|
|
622
|
+
phantom: cmdPhantom,
|
|
615
623
|
posts: cmdPosts,
|
|
616
624
|
published: cmdPublished,
|
|
617
625
|
reviewers: cmdReviewers,
|
|
@@ -629,7 +637,6 @@ const CLIENT_COMMANDS = {
|
|
|
629
637
|
autopen: cmdAutoPen,
|
|
630
638
|
covers: cmdCovers,
|
|
631
639
|
kernel: cmdKernel,
|
|
632
|
-
"phantom-job": cmdPhantomJob,
|
|
633
640
|
"phantom-onboard": cmdOnboard,
|
|
634
641
|
seers: cmdSeers,
|
|
635
642
|
templates: cmdTemplates,
|
|
@@ -708,6 +715,15 @@ export async function run(argv, io = defaultIo()) {
|
|
|
708
715
|
verboseLog: io.error,
|
|
709
716
|
bin,
|
|
710
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
|
+
|
|
711
727
|
const ctx = { client, config, positionals: positionals.slice(1), flags, io, bin };
|
|
712
728
|
return await handler(ctx);
|
|
713
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() {
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// `collection` — the content engine as a primitive, on both of its doors.
|
|
2
|
+
//
|
|
3
|
+
// A collection is a publication with a one-file configuration that produces and publishes
|
|
4
|
+
// posts on a cadence; `collection run` produces ONE post on it (sourced, written, gated,
|
|
5
|
+
// published, and — when Letterstory hosts the site — rebuilt). Runs are 20–60 minutes, so
|
|
6
|
+
// `collection run` submits and then follows the run unless you tell it not to
|
|
7
|
+
// (`--no-wait`); `collection run-status` picks it up later. A direct run spends the
|
|
8
|
+
// collection's weekly cadence budget (its posts_per_week); the planner's own slots are the
|
|
9
|
+
// budget being spent as designed.
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { CliError } from "../client.mjs";
|
|
13
|
+
import { flagStr, printResult, ok, toolField, followJob, argsFromFlags } from "./shared.mjs";
|
|
14
|
+
|
|
15
|
+
function readJsonFileFlag(flags, flag) {
|
|
16
|
+
const path = flagStr(flags[flag]);
|
|
17
|
+
if (path === undefined) return undefined;
|
|
18
|
+
let text;
|
|
19
|
+
try {
|
|
20
|
+
text = readFileSync(path, "utf8");
|
|
21
|
+
} catch (error) {
|
|
22
|
+
throw new CliError(
|
|
23
|
+
`Could not read --${flag} ${path}: ${error instanceof Error ? error.message : String(error)}`
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(text);
|
|
28
|
+
} catch {
|
|
29
|
+
throw new CliError(`--${flag} ${path} is not valid JSON.`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function readJsonObjectFlag(flags, name) {
|
|
33
|
+
const inline = flagStr(flags[name]);
|
|
34
|
+
if (inline !== undefined) {
|
|
35
|
+
let parsed;
|
|
36
|
+
try {
|
|
37
|
+
parsed = JSON.parse(inline);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
throw new CliError(`--${name} is not valid JSON: ${err.message}`);
|
|
40
|
+
}
|
|
41
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
42
|
+
throw new CliError(`--${name} must be a JSON object`);
|
|
43
|
+
return parsed;
|
|
44
|
+
}
|
|
45
|
+
return readJsonFileFlag(flags, `${name}-file`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const TERMINAL = new Set(["published", "failed", "skipped", "cancelled"]);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* How every command in this file sources its tool inputs — see `argsFromFlags` in
|
|
52
|
+
* shared.mjs and `scripts/parity-check.ts`, which compares these keys against each tool's
|
|
53
|
+
* published input schema.
|
|
54
|
+
*/
|
|
55
|
+
export const ARGS = {
|
|
56
|
+
list_collections: {},
|
|
57
|
+
create_collection: {
|
|
58
|
+
name: { flag: "name", required: true },
|
|
59
|
+
description: "description",
|
|
60
|
+
config: { value: (flags) => readJsonObjectFlag(flags, "config") },
|
|
61
|
+
},
|
|
62
|
+
get_collection_config: {
|
|
63
|
+
collection_id: { positional: 0, label: "collection-id", required: true },
|
|
64
|
+
},
|
|
65
|
+
set_collection_config: {
|
|
66
|
+
collection_id: { positional: 0, label: "collection-id", required: true },
|
|
67
|
+
config: { value: (flags) => readJsonObjectFlag(flags, "config") },
|
|
68
|
+
},
|
|
69
|
+
delete_collection: {
|
|
70
|
+
collection_id: { positional: 0, label: "collection-id", required: true },
|
|
71
|
+
},
|
|
72
|
+
run_collection: {
|
|
73
|
+
collection_id: { flag: "collection", required: true },
|
|
74
|
+
topic: "topic",
|
|
75
|
+
topic_id: { flag: "spoke" },
|
|
76
|
+
suggestion_id: { flag: "headline" },
|
|
77
|
+
source_urls: { flag: "source", type: "list" },
|
|
78
|
+
must_include: { flag: "must-include", type: "list" },
|
|
79
|
+
research_depth: { flag: "depth", enum: ["barebones", "fleshed_out"] },
|
|
80
|
+
kernel_id: { flag: "kernel" },
|
|
81
|
+
verify: { value: (flags) => (flags["no-verify"] === true ? false : undefined) },
|
|
82
|
+
shred: { flag: "shred", type: "bool" },
|
|
83
|
+
mention_degree: { flag: "mention", enum: ["off", "subtle", "woven"] },
|
|
84
|
+
approval_mode: { flag: "approval", enum: ["autopilot", "review_window", "manual"] },
|
|
85
|
+
publish: { value: (flags) => (flags["no-publish"] === true ? false : undefined) },
|
|
86
|
+
rebuild: { value: (flags) => (flags["no-rebuild"] === true ? false : undefined) },
|
|
87
|
+
schedule_for: { flag: "at" },
|
|
88
|
+
},
|
|
89
|
+
get_collection_run: {
|
|
90
|
+
run_id: { positional: 0, label: "run-id", required: true },
|
|
91
|
+
},
|
|
92
|
+
list_collection_runs: {
|
|
93
|
+
collection_id: { flag: "collection" },
|
|
94
|
+
status: {
|
|
95
|
+
flag: "status",
|
|
96
|
+
enum: [
|
|
97
|
+
"scheduled",
|
|
98
|
+
"sourcing",
|
|
99
|
+
"writing",
|
|
100
|
+
"ready",
|
|
101
|
+
"publishing",
|
|
102
|
+
"published",
|
|
103
|
+
"failed",
|
|
104
|
+
"skipped",
|
|
105
|
+
"cancelled",
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
limit: { flag: "limit", type: "num" },
|
|
109
|
+
},
|
|
110
|
+
approve_collection_run: {
|
|
111
|
+
run_id: { positional: 0, label: "run-id", required: true },
|
|
112
|
+
publish_now: { flag: "now", type: "bool" },
|
|
113
|
+
},
|
|
114
|
+
cancel_collection_run: {
|
|
115
|
+
run_id: { positional: 0, label: "run-id", required: true },
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export async function cmdCollection(ctx) {
|
|
120
|
+
const sub = ctx.positionals[0];
|
|
121
|
+
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
122
|
+
switch (sub) {
|
|
123
|
+
case "list":
|
|
124
|
+
case "ls":
|
|
125
|
+
return collectionList(rest);
|
|
126
|
+
case "new":
|
|
127
|
+
case "create":
|
|
128
|
+
return collectionCreate(rest);
|
|
129
|
+
case "config":
|
|
130
|
+
return collectionConfig(rest);
|
|
131
|
+
case "delete":
|
|
132
|
+
case "rm":
|
|
133
|
+
return collectionDelete(rest);
|
|
134
|
+
case "run":
|
|
135
|
+
return collectionRun(rest);
|
|
136
|
+
case "run-status":
|
|
137
|
+
case "status":
|
|
138
|
+
return collectionRunStatus(rest);
|
|
139
|
+
case "runs":
|
|
140
|
+
return collectionRuns(rest);
|
|
141
|
+
case "approve":
|
|
142
|
+
return collectionApprove(rest);
|
|
143
|
+
case "cancel":
|
|
144
|
+
return collectionCancel(rest);
|
|
145
|
+
default:
|
|
146
|
+
throw new CliError(
|
|
147
|
+
`Unknown collection subcommand: ${sub ?? "(none)"}. Try: list, new, config, delete, run, run-status, runs, approve, cancel`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function collectionList(ctx) {
|
|
153
|
+
const result = await ctx.client.callTool("list_collections", argsFromFlags(ARGS.list_collections, ctx.flags));
|
|
154
|
+
printResult(ctx.io, ctx.flags, result);
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function collectionCreate(ctx) {
|
|
159
|
+
const result = await ctx.client.callTool(
|
|
160
|
+
"create_collection",
|
|
161
|
+
argsFromFlags(ARGS.create_collection, ctx.flags, ctx.positionals)
|
|
162
|
+
);
|
|
163
|
+
ok(
|
|
164
|
+
ctx,
|
|
165
|
+
`Collection ${toolField(result, "collection.name") ?? ""} created (${toolField(result, "collection.collection_id") ?? "?"}).`
|
|
166
|
+
);
|
|
167
|
+
printResult(ctx.io, ctx.flags, result);
|
|
168
|
+
return 0;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// `collection config <id>` prints the one file; `--config '<json>'` or `--config-file cfg.json` patches it.
|
|
172
|
+
async function collectionConfig(ctx) {
|
|
173
|
+
const { client, flags, io, positionals } = ctx;
|
|
174
|
+
const patch = readJsonObjectFlag(flags, "config");
|
|
175
|
+
if (patch === undefined) {
|
|
176
|
+
const result = await client.callTool(
|
|
177
|
+
"get_collection_config",
|
|
178
|
+
argsFromFlags(ARGS.get_collection_config, flags, positionals)
|
|
179
|
+
);
|
|
180
|
+
printResult(io, flags, result);
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
const result = await client.callTool(
|
|
184
|
+
"set_collection_config",
|
|
185
|
+
argsFromFlags(ARGS.set_collection_config, flags, positionals)
|
|
186
|
+
);
|
|
187
|
+
ok(ctx, `Applied: ${(toolField(result, "applied") ?? []).join(", ") || "nothing"}.`);
|
|
188
|
+
printResult(io, flags, result);
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function collectionDelete(ctx) {
|
|
193
|
+
if (ctx.flags.yes !== true) {
|
|
194
|
+
ctx.io.error("Refusing to delete without --yes.");
|
|
195
|
+
return 1;
|
|
196
|
+
}
|
|
197
|
+
const result = await ctx.client.callTool(
|
|
198
|
+
"delete_collection",
|
|
199
|
+
argsFromFlags(ARGS.delete_collection, ctx.flags, ctx.positionals)
|
|
200
|
+
);
|
|
201
|
+
printResult(ctx.io, ctx.flags, result);
|
|
202
|
+
return 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function collectionRun(ctx) {
|
|
206
|
+
const { client, flags, io, bin } = ctx;
|
|
207
|
+
const args = argsFromFlags(ARGS.run_collection, flags, ctx.positionals);
|
|
208
|
+
const result = await client.callTool("run_collection", args);
|
|
209
|
+
const runId = toolField(result, "run_id");
|
|
210
|
+
if (flags["no-wait"] === true || !runId) {
|
|
211
|
+
ok(ctx, `Run started as ${runId ?? "?"}. Follow it with: ${bin} collection run-status ${runId ?? "<run-id>"}`);
|
|
212
|
+
printResult(io, flags, result);
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
215
|
+
io.log(`Run ${runId} started — following (20–60 min; Ctrl-C and use run-status to pick it up later)…`);
|
|
216
|
+
let lastLine = "";
|
|
217
|
+
const followed = await followJob(
|
|
218
|
+
io,
|
|
219
|
+
async () => {
|
|
220
|
+
const r = await client.callTool("get_collection_run", { run_id: runId });
|
|
221
|
+
const run = toolField(r, "run") ?? r;
|
|
222
|
+
const line = `${run.status}${run.phase ? ` · ${run.phase}` : ""}${run.phase_detail ? ` — ${run.phase_detail}` : ""}`;
|
|
223
|
+
if (line !== lastLine) {
|
|
224
|
+
io.log(` … ${line}`);
|
|
225
|
+
lastLine = line;
|
|
226
|
+
}
|
|
227
|
+
if (run.status === "ready" && run.approval?.mode !== "autopilot" && !run.approval?.approved_at)
|
|
228
|
+
return { done: true, value: run };
|
|
229
|
+
return { done: TERMINAL.has(run.status), value: run };
|
|
230
|
+
},
|
|
231
|
+
{ intervalMs: 30000, ceilingMs: 90 * 60 * 1000 }
|
|
232
|
+
);
|
|
233
|
+
const run = followed.value;
|
|
234
|
+
if (run?.status === "ready") {
|
|
235
|
+
ok(ctx, `Written and waiting for approval. Approve with: ${bin} collection approve ${runId} [--now]`);
|
|
236
|
+
} else if (!followed.settled) {
|
|
237
|
+
io.log(`Still running. Pick it up with: ${bin} collection run-status ${runId}`);
|
|
238
|
+
}
|
|
239
|
+
printResult(io, flags, run ?? result);
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function collectionRunStatus(ctx) {
|
|
244
|
+
const result = await ctx.client.callTool(
|
|
245
|
+
"get_collection_run",
|
|
246
|
+
argsFromFlags(ARGS.get_collection_run, ctx.flags, ctx.positionals)
|
|
247
|
+
);
|
|
248
|
+
printResult(ctx.io, ctx.flags, result);
|
|
249
|
+
return 0;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function collectionRuns(ctx) {
|
|
253
|
+
const result = await ctx.client.callTool(
|
|
254
|
+
"list_collection_runs",
|
|
255
|
+
argsFromFlags(ARGS.list_collection_runs, ctx.flags)
|
|
256
|
+
);
|
|
257
|
+
printResult(ctx.io, ctx.flags, result);
|
|
258
|
+
return 0;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function collectionApprove(ctx) {
|
|
262
|
+
const result = await ctx.client.callTool(
|
|
263
|
+
"approve_collection_run",
|
|
264
|
+
argsFromFlags(ARGS.approve_collection_run, ctx.flags, ctx.positionals)
|
|
265
|
+
);
|
|
266
|
+
ok(ctx, `Approved${ctx.flags.now === true ? " — publishing now" : ""}.`);
|
|
267
|
+
printResult(ctx.io, ctx.flags, result);
|
|
268
|
+
return 0;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function collectionCancel(ctx) {
|
|
272
|
+
const result = await ctx.client.callTool(
|
|
273
|
+
"cancel_collection_run",
|
|
274
|
+
argsFromFlags(ARGS.cancel_collection_run, ctx.flags, ctx.positionals)
|
|
275
|
+
);
|
|
276
|
+
ok(ctx, toolField(result, "cancelled") ? "Cancelled." : "Nothing was in flight.");
|
|
277
|
+
printResult(ctx.io, ctx.flags, result);
|
|
278
|
+
return 0;
|
|
279
|
+
}
|