@letterstory/cli 0.4.0 → 0.5.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
@@ -11,9 +11,15 @@ The CLI is plain ESM with **zero dependencies** and needs no build step (Node
11
11
  ```bash
12
12
  npm install -g @letterstory/cli # puts `letterstory` (and `phantom`) on your PATH
13
13
  # …or run it without installing:
14
- npx @letterstory/cli --help
14
+ npx -p @letterstory/cli letterstory --help
15
15
  ```
16
16
 
17
+ The `-p` is not optional. This package ships two commands, `letterstory` and `phantom`,
18
+ and npx only infers one for you when a package has a single command or one named after
19
+ the package — neither is true here, so a bare `npx @letterstory/cli` exits with
20
+ `could not determine executable to run`. Name the command you want (`letterstory` or
21
+ `phantom`) and pass the package with `-p`.
22
+
17
23
  Contributing to the CLI itself? Run it straight from a checkout instead:
18
24
 
19
25
  ```bash
@@ -236,6 +242,80 @@ letterstory kernel status <kernel-job-id>
236
242
  `phantom-job create --topic "…" --collection <uuid>` runs topic → draft → publish → rebuild
237
243
  end to end, with `phantom-job status <job-id>` to follow it.
238
244
 
245
+ ## Seers — event-driven signals
246
+
247
+ A **seer** watches an external source (a GitHub repo, a Notion workspace, a spec URL, a
248
+ news or regulation query) and turns what it detects into headline suggestions — or, in
249
+ `auto` mode, straight into drafts. Needs `seer:read` / `seer:write` capabilities
250
+ (`template:read` / `template:write` for the templates group).
251
+
252
+ ```bash
253
+ letterstory seers providers # the self-describing config catalog — start here
254
+ letterstory seers list
255
+ letterstory seers create --name "Ship notes" --collection <uuid> --provider github_pr \
256
+ --config '{"owner":"letterstory","repo":"letterbrace"}'
257
+ letterstory seers update <seer-id> --mode auto --interval 60
258
+ letterstory seers run <seer-id> # poll + produce now (minutes; spends AI budget)
259
+ letterstory seers events <seer-id> # detected signals, dedup aliases, relevance verdicts
260
+ letterstory seers dismiss <seer-id> <event-id>
261
+ letterstory seers headlines [--collection <uuid>] # the seer-sourced suggestion feed
262
+ letterstory seers held # drafts parked pending human review
263
+ letterstory seers release <article-id> # …or: seers discard <article-id>
264
+ ```
265
+
266
+ Reproducible setup — the onboarding path. `export` writes the org's seers as a
267
+ manifest; `apply` is an idempotent, **name-keyed** upsert from one (it never deletes;
268
+ seers in the org but missing from the manifest are listed as extras and left alone):
269
+
270
+ ```bash
271
+ letterstory seers export --file seers.json
272
+ letterstory seers apply --file seers.json --dry-run # print the create/update/ok plan
273
+ letterstory seers apply --file seers.json # apply it
274
+ ```
275
+
276
+ Connections, and one honest gap:
277
+
278
+ ```bash
279
+ letterstory seers connections # GitHub + Notion status
280
+ letterstory seers connect github --token <pat> # org-wide token; unlocks private repos
281
+ letterstory seers connect notion # prints instructions — see below
282
+ ```
283
+
284
+ > **Known gaps.** Notion connects via browser OAuth in the app only — the CLI/API can
285
+ > read connection status but cannot complete the OAuth flow. `seers run` executes
286
+ > synchronously server-side (up to ~5 minutes for web-scan providers); there is no
287
+ > start-then-poll variant yet. And `create`'s config takes raw JSON — `seers providers`
288
+ > is the schema reference the server validates against.
289
+
290
+ Content templates (the compose structures seers bind via `config.template_key`):
291
+
292
+ ```bash
293
+ letterstory templates list # custom + read-only built-ins
294
+ letterstory templates create --name "Release notes" --angle "What shipped and why" \
295
+ --sections-file sections.json
296
+ letterstory templates update <template-id> --angle "…" # unset fields keep their values
297
+ letterstory templates delete <template-id> --yes # bound seers fall back to defaults
298
+ ```
299
+
300
+ ## Cover images
301
+
302
+ Which image model draws a post's cover is a CLI decision, not a UI-only one. `variants`
303
+ fans one headline out across up to four models and hands back a preview per model;
304
+ `select` commits the one you want:
305
+
306
+ ```bash
307
+ letterstory covers models # image models connected to this org
308
+ letterstory covers variants --article <uuid> --model bloom --model openai
309
+ letterstory covers select --article <uuid> --url <variant-url> --provider bloom
310
+
311
+ # …or skip the comparison and let the server pick, using the collection's cover template:
312
+ letterstory covers generate <article-id> [--regenerate] [--stock-only]
313
+ ```
314
+
315
+ `--ref-url <url>` (repeatable) conditions the look on existing images — pair it with
316
+ `extract_blog_reference_images` to match a site you're mirroring. Each variant costs one
317
+ image generation, which is why the fan-out is capped at four models.
318
+
239
319
  ## Insights
240
320
 
241
321
  Search Console performance, network-wide, per post, or top posts:
package/lib/cli.mjs CHANGED
@@ -27,12 +27,15 @@ import {
27
27
  cmdOnboarding,
28
28
  cmdInsights,
29
29
  cmdResearch,
30
+ cmdCovers,
30
31
  cmdKernel,
31
32
  cmdPhantomJob,
33
+ cmdSeers,
34
+ cmdTemplates,
32
35
  } from "./commands.mjs";
33
36
 
34
37
  // Keep in sync with cli/package.json.
35
- export const VERSION = "0.4.0";
38
+ export const VERSION = "0.5.0";
36
39
 
37
40
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
38
41
  // can't accidentally swallow the id as --json's value.
@@ -50,6 +53,13 @@ const BOOLEAN_FLAGS = new Set([
50
53
  "rebuild",
51
54
  "print-key",
52
55
  "stdin",
56
+ // `covers generate <id> --regenerate` and `research start --gate --article <id>`
57
+ // both put a value-less flag next to a value: without these entries the parser
58
+ // hands the following token to the flag and the real argument disappears.
59
+ "regenerate",
60
+ "stock-only",
61
+ "gate",
62
+ "skip",
53
63
  ]);
54
64
 
55
65
  // Tiny argv parser: `--flag value`, `--flag=value`, boolean `--flag`, and positionals.
@@ -198,6 +208,15 @@ Research agent (deep research -> outline written into the post):
198
208
  Answer the --gate pause
199
209
  research cancel --article <uuid> Cancel a run in flight
200
210
 
211
+ Cover images (which image model draws the post's cover):
212
+ covers models Image models connected right now
213
+ covers variants --article <uuid> [--model <id> …] [--ref-url <url> …]
214
+ One preview per model (max 4)
215
+ covers select --article <uuid> --url <url> --provider <p> [--model <id>]
216
+ Commit a previewed variant
217
+ covers generate <article-id> [--regenerate] [--stock-only]
218
+ Single-shot mint, default provider
219
+
201
220
  Writing kernels (kernels write the draft; they do not research — run research first):
202
221
  kernel list List available writing kernels
203
222
  kernel run --article <uuid> --kernel <uuid> [--brief <text>|--brief-file <path>]
@@ -209,6 +228,35 @@ Phantom orchestrator:
209
228
  Topic -> draft -> publish -> rebuild
210
229
  phantom-job status <job-id> Check a job's stage
211
230
 
231
+ Seers (event-driven signals -> drafts):
232
+ seers list List seers with recent activity
233
+ seers get <seer-id> One seer's full config + poll state
234
+ seers providers Provider catalog: config fields + behavior keys
235
+ seers create --name <n> --collection <uuid> --provider <p>
236
+ [--mode suggest|auto] [--interval <min>] [--config '<json>'|--config-file <path>]
237
+ seers update <seer-id> [--name] [--collection] [--mode] [--enabled true|false]
238
+ [--interval <min>] [--config '<json>'|--config-file <path>]
239
+ seers delete <seer-id> --yes
240
+ seers run <seer-id> Poll + produce now (can take minutes; spends AI budget)
241
+ seers events <seer-id> [--limit <n>] [--offset <n>] Detected signals w/ dedup + relevance
242
+ seers dismiss <seer-id> <event-id> Dismiss a signal (feeds the relevance judge)
243
+ seers headlines [--collection <uuid>] Seer-sourced suggestion feed
244
+ seers held [--collection <uuid>] Drafts parked pending human review
245
+ seers release <article-id> | seers discard <article-id>
246
+ seers connections GitHub/Notion connection status
247
+ seers connect github --token <pat> Store an org-wide GitHub token
248
+ seers connect notion (browser-only OAuth — prints instructions)
249
+ seers export [--file <path>] Dump the org's seers as a manifest
250
+ seers apply --file <path> [--dry-run] Idempotent, name-keyed upsert from a manifest
251
+ (never deletes; --dry-run prints the plan)
252
+
253
+ Content templates (compose structures seers bind via config.template_key):
254
+ templates list Custom + built-in catalog
255
+ templates show <template-id>
256
+ templates create --name <n> --angle <text> (--sections '<json>'|--sections-file <path>)
257
+ templates update <template-id> [--name] [--angle] [--sections '<json>'|--sections-file <path>]
258
+ templates delete <template-id> --yes
259
+
212
260
  Insights:
213
261
  insights site [--period 14d|30d|90d] [--collection <uuid>]
214
262
  insights post <article-id> [--period 14d|30d|90d]
@@ -266,8 +314,11 @@ const CLIENT_COMMANDS = {
266
314
  onboarding: cmdOnboarding,
267
315
  insights: cmdInsights,
268
316
  research: cmdResearch,
317
+ covers: cmdCovers,
269
318
  kernel: cmdKernel,
270
319
  "phantom-job": cmdPhantomJob,
320
+ seers: cmdSeers,
321
+ templates: cmdTemplates,
271
322
  };
272
323
 
273
324
  // LETTERSTORY_POLL_INTERVAL_MS / LETTERSTORY_MAX_POLLS let an operator (or an
@@ -0,0 +1,89 @@
1
+ // `covers` — a post's cover image, including WHICH image model draws it. `models`
2
+ // lists what's connected, `variants` fans a headline out across models and returns a
3
+ // preview per model, `select` commits the one you want, and `generate` is the
4
+ // single-shot headless mint (collection's template + the server's default provider).
5
+ // Each variant costs one image generation, so the fan-out is capped at 4 models.
6
+
7
+ import { CliError } from "../client.mjs";
8
+ import { flagStr, flagBool, flagList, requireFlag, requirePositional, printResult, compact, ok } from "./shared.mjs";
9
+
10
+ export async function cmdCovers(ctx) {
11
+ const sub = ctx.positionals[0];
12
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
13
+ switch (sub) {
14
+ case "models":
15
+ return coverModels(rest);
16
+ case "variants":
17
+ case "compare":
18
+ return coverVariants(rest);
19
+ case "select":
20
+ case "pick":
21
+ return coverSelect(rest);
22
+ case "generate":
23
+ return coverGenerate(rest);
24
+ default:
25
+ throw new CliError(
26
+ `Unknown covers subcommand: ${sub ?? "(none)"}. Try: models, variants, select, generate`
27
+ );
28
+ }
29
+ }
30
+
31
+ async function coverModels(ctx) {
32
+ const { client, flags, io } = ctx;
33
+ const result = await client.callTool("list_image_models", {});
34
+ printResult(io, flags, result);
35
+ return 0;
36
+ }
37
+
38
+ async function coverVariants(ctx) {
39
+ const { client, flags, io } = ctx;
40
+ const article = requireFlag(flags, "article");
41
+ const modelIds = flagList(flags.model);
42
+ const refs = flagList(flags["ref-url"]);
43
+ const args = compact({
44
+ article_id: article,
45
+ model_ids: modelIds.length ? modelIds : undefined,
46
+ reference_image_urls: refs.length ? refs : undefined,
47
+ });
48
+ const result = await client.callTool("generate_cover_variants", args);
49
+ const first = (result?.variants ?? []).find((v) => v.url);
50
+ ok(
51
+ ctx,
52
+ first
53
+ ? `Generated ${result.variants.length} variant(s). Commit one with: covers select --article ${article} --url <url> --provider <provider>`
54
+ : "No variant produced an image — see the per-model errors below."
55
+ );
56
+ printResult(io, flags, result);
57
+ return 0;
58
+ }
59
+
60
+ async function coverSelect(ctx) {
61
+ const { client, flags, io } = ctx;
62
+ const article = requireFlag(flags, "article");
63
+ const args = compact({
64
+ article_id: article,
65
+ image_url: requireFlag(flags, "url"),
66
+ provider: requireFlag(flags, "provider"),
67
+ model: flagStr(flags.model),
68
+ });
69
+ const result = await client.callTool("select_cover_variant", args);
70
+ ok(ctx, "Cover set on the post.");
71
+ printResult(io, flags, result);
72
+ return 0;
73
+ }
74
+
75
+ async function coverGenerate(ctx) {
76
+ const { client, positionals, flags, io } = ctx;
77
+ // `covers generate <article-id>` reads naturally, but --article keeps it
78
+ // consistent with every other post-scoped command; accept both.
79
+ const article = flagStr(flags.article) ?? requirePositional(positionals, 0, "article-id");
80
+ const args = compact({
81
+ article_id: article,
82
+ regenerate: flagBool(flags.regenerate) ? true : undefined,
83
+ stock_only: flagBool(flags["stock-only"]) ? true : undefined,
84
+ });
85
+ const result = await client.callTool("generate_article_cover", args);
86
+ ok(ctx, result?.generated ? "Cover generated and attached." : `No cover generated: ${result?.reason ?? "unknown"}`);
87
+ printResult(io, flags, result);
88
+ return 0;
89
+ }
@@ -0,0 +1,673 @@
1
+ // `seers` + `templates` — event-driven signal watchers and the content templates
2
+ // that shape what they compose. Wraps the seer:*/template:* tools 1:1 with the
3
+ // app's Seers tab, plus two CLI-only compositions for reproducible onboarding:
4
+ // `seers export` (dump the org's seers as a manifest) and `seers apply` (idempotent,
5
+ // name-keyed diff-then-upsert from a manifest, with --dry-run).
6
+ //
7
+ // Known surface gaps, stated here because this is where operators hit them:
8
+ // - Notion connects via browser OAuth in the app only. `seers connect notion`
9
+ // prints instructions; there is no headless flow.
10
+ // - `seers run` executes synchronously server-side (news/regulation web scans can
11
+ // take minutes) and spends model budget — it is usage-gated per org.
12
+
13
+ import { readFileSync, writeFileSync } from "node:fs";
14
+ import { CliError } from "../client.mjs";
15
+ import { flagStr, flagNum, flagBool, requirePositional, printResult, compact, ok } from "./shared.mjs";
16
+
17
+ export async function cmdSeers(ctx) {
18
+ const sub = ctx.positionals[0];
19
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
20
+ switch (sub) {
21
+ case "list":
22
+ case "ls":
23
+ return seersList(rest);
24
+ case "get":
25
+ case "show":
26
+ return seersGet(rest);
27
+ case "create":
28
+ case "new":
29
+ return seersCreate(rest);
30
+ case "update":
31
+ return seersUpdate(rest);
32
+ case "delete":
33
+ case "rm":
34
+ return seersDelete(rest);
35
+ case "run":
36
+ return seersRun(rest);
37
+ case "events":
38
+ return seersEvents(rest);
39
+ case "dismiss":
40
+ return seersDismiss(rest);
41
+ case "headlines":
42
+ return seersHeadlines(rest);
43
+ case "held":
44
+ return seersHeld(rest);
45
+ case "release":
46
+ return seersResolveHeld(rest, "release");
47
+ case "discard":
48
+ return seersResolveHeld(rest, "discard");
49
+ case "providers":
50
+ return seersProviders(rest);
51
+ case "connections":
52
+ return seersConnections(rest);
53
+ case "connect":
54
+ return seersConnect(rest);
55
+ case "export":
56
+ return seersExport(rest);
57
+ case "apply":
58
+ return seersApply(rest);
59
+ default:
60
+ throw new CliError(
61
+ `Unknown seers subcommand: ${sub ?? "(none)"}. Try: list, get, create, update, delete, run, events, dismiss, headlines, held, release, discard, providers, connections, connect, export, apply`
62
+ );
63
+ }
64
+ }
65
+
66
+ // --- JSON input helpers ------------------------------------------------------------
67
+
68
+ // Read a JSON object from --<name> '<json>' or --<name>-file <path>.
69
+ function readJsonObjectFlag(flags, name) {
70
+ const inline = flagStr(flags[name]);
71
+ const file = flagStr(flags[`${name}-file`]);
72
+ if (inline === undefined && file === undefined) return undefined;
73
+ const raw = inline !== undefined ? inline : readFileOrThrow(file, `--${name}-file`);
74
+ let parsed;
75
+ try {
76
+ parsed = JSON.parse(raw);
77
+ } catch (err) {
78
+ throw new CliError(`--${name} is not valid JSON: ${err.message}`);
79
+ }
80
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
81
+ throw new CliError(`--${name} must be a JSON object`);
82
+ }
83
+ return parsed;
84
+ }
85
+
86
+ function readFileOrThrow(path, label) {
87
+ try {
88
+ return readFileSync(path, "utf8");
89
+ } catch (err) {
90
+ throw new CliError(`Could not read ${label} ${path}: ${err.message}`);
91
+ }
92
+ }
93
+
94
+ // --- Seer CRUD ---------------------------------------------------------------------
95
+
96
+ function formatSeerLine(s) {
97
+ const state = s.enabled === false ? "paused" : "enabled";
98
+ const events = s.event_count !== undefined ? ` · ${s.event_count} events, ${s.produced_count} produced` : "";
99
+ const coll = s.collection_name ? ` → ${s.collection_name}` : "";
100
+ return `${s.id} [${s.provider}/${s.mode}, ${state}, every ${s.poll_interval_minutes}m] ${s.name}${coll}${events}`;
101
+ }
102
+
103
+ async function seersList(ctx) {
104
+ const { client, flags, io } = ctx;
105
+ const result = await client.callTool("list_seers", {});
106
+ printResult(io, flags, result, (r) => {
107
+ const lines = (r.seers ?? []).map(formatSeerLine);
108
+ if (!lines.length) lines.push("No seers yet. Create one with `seers create` (see `seers providers`).");
109
+ if (r.positioning_status && !r.positioning_status.configured) {
110
+ lines.push("");
111
+ lines.push("Note: org positioning is not configured — relevance judging has nothing to gate on.");
112
+ }
113
+ return lines.join("\n");
114
+ });
115
+ return 0;
116
+ }
117
+
118
+ async function seersGet(ctx) {
119
+ const { client, positionals, flags, io } = ctx;
120
+ const seerId = requirePositional(positionals, 0, "seer-id");
121
+ const result = await client.callTool("get_seer", { seer_id: seerId });
122
+ printResult(io, flags, result);
123
+ return 0;
124
+ }
125
+
126
+ function seerWriteArgsFromFlags(flags) {
127
+ const enabledRaw = flagStr(flags.enabled);
128
+ if (enabledRaw !== undefined && enabledRaw !== "true" && enabledRaw !== "false") {
129
+ throw new CliError('--enabled must be "true" or "false"');
130
+ }
131
+ return compact({
132
+ name: flagStr(flags.name),
133
+ collection_id: flagStr(flags.collection),
134
+ mode: flagStr(flags.mode),
135
+ poll_interval_minutes: flagNum(flags.interval),
136
+ enabled: enabledRaw === undefined ? undefined : enabledRaw === "true",
137
+ config: readJsonObjectFlag(flags, "config"),
138
+ });
139
+ }
140
+
141
+ async function seersCreate(ctx) {
142
+ const { client, flags, io } = ctx;
143
+ const args = seerWriteArgsFromFlags(flags);
144
+ if (!args.name) throw new CliError("Missing required --name");
145
+ if (!args.collection_id) throw new CliError("Missing required --collection <uuid>");
146
+ const provider = flagStr(flags.provider);
147
+ if (!provider) throw new CliError("Missing required --provider (run `seers providers` for the catalog)");
148
+ delete args.enabled; // create has no enabled field; new seers start enabled
149
+ const result = await client.callTool("create_seer", { ...args, provider, config: args.config ?? {} });
150
+ ok(ctx, "Seer created.");
151
+ printResult(io, flags, result, (r) => formatSeerLine(r.seer));
152
+ return 0;
153
+ }
154
+
155
+ async function seersUpdate(ctx) {
156
+ const { client, positionals, flags, io } = ctx;
157
+ const seerId = requirePositional(positionals, 0, "seer-id");
158
+ const args = seerWriteArgsFromFlags(flags);
159
+ if (Object.keys(args).length === 0) {
160
+ throw new CliError("Nothing to update — pass --name/--collection/--mode/--enabled/--interval/--config");
161
+ }
162
+ const result = await client.callTool("update_seer", { seer_id: seerId, ...args });
163
+ ok(ctx, "Seer updated.");
164
+ printResult(io, flags, result, (r) => formatSeerLine(r.seer));
165
+ return 0;
166
+ }
167
+
168
+ async function seersDelete(ctx) {
169
+ const { client, positionals, flags, io } = ctx;
170
+ const seerId = requirePositional(positionals, 0, "seer-id");
171
+ if (!flagBool(flags.yes)) throw new CliError("Deleting a seer removes its detected events. Re-run with --yes.");
172
+ const result = await client.callTool("delete_seer", { seer_id: seerId });
173
+ ok(ctx, "Seer deleted.");
174
+ printResult(io, flags, result);
175
+ return 0;
176
+ }
177
+
178
+ async function seersRun(ctx) {
179
+ const { client, positionals, flags, io } = ctx;
180
+ const seerId = requirePositional(positionals, 0, "seer-id");
181
+ ok(ctx, "Polling now — web-scan providers can take a few minutes…");
182
+ const result = await client.callTool("run_seer", { seer_id: seerId });
183
+ printResult(
184
+ io,
185
+ flags,
186
+ result,
187
+ (r) =>
188
+ `Detected ${r.detected}, produced ${r.produced} (auto-drafted ${r.auto_drafted}, suggested-only ${r.suggested_only}, dismissed ${r.dismissed}).`
189
+ );
190
+ return 0;
191
+ }
192
+
193
+ // --- Events / headlines / holding pen ----------------------------------------------
194
+
195
+ async function seersEvents(ctx) {
196
+ const { client, positionals, flags, io } = ctx;
197
+ const seerId = requirePositional(positionals, 0, "seer-id");
198
+ const args = compact({ seer_id: seerId, limit: flagNum(flags.limit), offset: flagNum(flags.offset) });
199
+ const result = await client.callTool("list_seer_events", args);
200
+ printResult(io, flags, result, (r) => {
201
+ const lines = (r.events ?? []).map((e) => {
202
+ const blocked = e.auto_production_blocked_reason ? ` [blocked: ${e.auto_production_blocked_reason}]` : "";
203
+ const aliases = e.aliases?.length ? ` (+${e.aliases.length} aliases)` : "";
204
+ return `${e.id} [${e.status}] ${e.detected_at} ${e.title}${aliases}${blocked}`;
205
+ });
206
+ if (!lines.length) lines.push("No events detected yet.");
207
+ if (r.has_more) lines.push(`… more available (--offset ${r.next_offset})`);
208
+ return lines.join("\n");
209
+ });
210
+ return 0;
211
+ }
212
+
213
+ async function seersDismiss(ctx) {
214
+ const { client, positionals, flags, io } = ctx;
215
+ const seerId = requirePositional(positionals, 0, "seer-id");
216
+ const eventId = requirePositional(positionals, 1, "event-id");
217
+ const result = await client.callTool("dismiss_seer_event", { seer_id: seerId, event_id: eventId });
218
+ ok(ctx, "Event dismissed.");
219
+ printResult(io, flags, result);
220
+ return 0;
221
+ }
222
+
223
+ async function seersHeadlines(ctx) {
224
+ const { client, flags, io } = ctx;
225
+ const args = compact({
226
+ collection_id: flagStr(flags.collection),
227
+ limit: flagNum(flags.limit),
228
+ offset: flagNum(flags.offset),
229
+ });
230
+ const result = await client.callTool("list_seer_headlines", args);
231
+ printResult(io, flags, result, (r) => {
232
+ const lines = (r.headlines ?? []).map(
233
+ (h) => `${h.id} [${h.status}] ${h.title ?? h.headline ?? "(untitled)"}`
234
+ );
235
+ if (!lines.length) lines.push("No seer-sourced headlines right now.");
236
+ return lines.join("\n");
237
+ });
238
+ return 0;
239
+ }
240
+
241
+ async function seersHeld(ctx) {
242
+ const { client, flags, io } = ctx;
243
+ const args = compact({ collection_id: flagStr(flags.collection) });
244
+ const result = await client.callTool("list_held_drafts", args);
245
+ printResult(io, flags, result, (r) => {
246
+ const lines = (r.drafts ?? []).map(
247
+ (d) => `${d.articleId} [${d.kind}] ${d.title} — ${d.reason} (dropped ${d.dropped}, unsure ${d.unsure})`
248
+ );
249
+ if (!lines.length) lines.push("Holding pen is empty.");
250
+ else lines.push("", "Release with `seers release <article-id>`, or drop with `seers discard <article-id>`.");
251
+ return lines.join("\n");
252
+ });
253
+ return 0;
254
+ }
255
+
256
+ async function seersResolveHeld(ctx, action) {
257
+ const { client, positionals, flags, io } = ctx;
258
+ const articleId = requirePositional(positionals, 0, "article-id");
259
+ const result = await client.callTool("resolve_held_draft", { article_id: articleId, action });
260
+ ok(ctx, action === "release" ? "Draft released — it can publish through the normal path." : "Draft discarded.");
261
+ printResult(io, flags, result);
262
+ return 0;
263
+ }
264
+
265
+ // --- Providers & connections -------------------------------------------------------
266
+
267
+ async function seersProviders(ctx) {
268
+ const { client, flags, io } = ctx;
269
+ const result = await client.callTool("list_seer_providers", {});
270
+ printResult(io, flags, result, (r) => {
271
+ const blocks = (r.providers ?? []).map((p) => {
272
+ const fields = p.fields
273
+ .map((f) => ` ${f.key}${f.required ? " (required)" : ""} — ${f.help ?? f.label}`)
274
+ .join("\n");
275
+ const conn = p.requires_connection ? ` connection: ${p.requires_connection}\n` : "";
276
+ return ` ${p.id} — ${p.tagline}\n${conn}${fields}`;
277
+ });
278
+ const behavior = Object.entries(r.behavior_keys ?? {})
279
+ .map(([k, v]) => ` ${k} — ${v}`)
280
+ .join("\n");
281
+ return `Providers:\n${blocks.join("\n\n")}\n\nShared config behavior keys (all providers):\n${behavior}`;
282
+ });
283
+ return 0;
284
+ }
285
+
286
+ async function seersConnections(ctx) {
287
+ const { client, flags, io } = ctx;
288
+ const result = await client.callTool("get_seer_connections", {});
289
+ printResult(io, flags, result, (r) => {
290
+ const gh = r.github.connected
291
+ ? `GitHub: connected as ${r.github.account_login} (${r.github.connected_at})`
292
+ : `GitHub: not connected${r.github.configured ? " — connect with `seers connect github --token <pat>`" : " (token storage not configured on this deployment)"}`;
293
+ const notion = r.notion.connected
294
+ ? `Notion: connected to ${r.notion.workspace_name} (${r.notion.connected_at})`
295
+ : "Notion: not connected — connect via browser in the app (Seers tab → Connect Notion); there is no headless flow";
296
+ return `${gh}\n${notion}`;
297
+ });
298
+ return 0;
299
+ }
300
+
301
+ async function seersConnect(ctx) {
302
+ const which = ctx.positionals[0];
303
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
304
+ if (which === "github") {
305
+ const token = flagStr(rest.flags.token);
306
+ if (!token) throw new CliError("Missing required --token <github-pat> (repo read access)");
307
+ const result = await rest.client.callTool("set_github_connection", { access_token: token });
308
+ ok(rest, "GitHub connected.");
309
+ printResult(rest.io, rest.flags, result);
310
+ return 0;
311
+ }
312
+ if (which === "notion") {
313
+ // Deliberate gap: Notion's OAuth flow needs a browser session; the integrations
314
+ // surface can read status but can't complete the dance.
315
+ rest.io.log("Notion connects via browser OAuth and can't be completed from the CLI.");
316
+ rest.io.log(`Open ${rest.config.url.replace(/\/$/, "")}/seers and use "Connect Notion", then verify with:`);
317
+ rest.io.log(` ${rest.bin} seers connections`);
318
+ return 0;
319
+ }
320
+ throw new CliError(`Unknown connect target: ${which ?? "(none)"}. Try: github, notion`);
321
+ }
322
+
323
+ // --- Export / apply (reproducible onboarding) --------------------------------------
324
+
325
+ const MANIFEST_VERSION = 1;
326
+ // The seer fields a manifest owns. Cursor/lease/poll state is deliberately NOT
327
+ // exported — a manifest describes configuration, not runtime progress.
328
+ function manifestEntryFromSeer(s) {
329
+ return {
330
+ name: s.name,
331
+ provider: s.provider,
332
+ mode: s.mode,
333
+ enabled: s.enabled !== false,
334
+ poll_interval_minutes: s.poll_interval_minutes,
335
+ collection_id: s.collection_id,
336
+ collection_name: s.collection_name ?? undefined,
337
+ config: s.config ?? {},
338
+ };
339
+ }
340
+
341
+ async function seersExport(ctx) {
342
+ const { client, flags, io } = ctx;
343
+ const result = await client.callTool("list_seers", {});
344
+ const manifest = {
345
+ version: MANIFEST_VERSION,
346
+ seers: (result.seers ?? []).map(manifestEntryFromSeer),
347
+ };
348
+ const out = JSON.stringify(manifest, null, 2);
349
+ const file = flagStr(flags.file);
350
+ if (file) {
351
+ writeFileSync(file, `${out}\n`);
352
+ ok(ctx, `Exported ${manifest.seers.length} seers to ${file}.`);
353
+ } else {
354
+ io.log(out);
355
+ }
356
+ return 0;
357
+ }
358
+
359
+ // Key-order-independent flat-object equality — the same rule the server uses to
360
+ // decide whether an update actually changed the config (and must reset the cursor).
361
+ function sameFlatObject(a, b) {
362
+ const stable = (o) =>
363
+ JSON.stringify(
364
+ Object.keys(o ?? {})
365
+ .sort()
366
+ .map((k) => [k, o[k]])
367
+ );
368
+ return stable(a) === stable(b);
369
+ }
370
+
371
+ function loadManifest(flags) {
372
+ const file = flagStr(flags.file);
373
+ if (!file) throw new CliError("Missing required --file <manifest.json> (produce one with `seers export`)");
374
+ let parsed;
375
+ try {
376
+ parsed = JSON.parse(readFileOrThrow(file, "--file"));
377
+ } catch (err) {
378
+ throw err instanceof CliError ? err : new CliError(`--file is not valid JSON: ${err.message}`);
379
+ }
380
+ if (parsed?.version !== MANIFEST_VERSION || !Array.isArray(parsed.seers)) {
381
+ throw new CliError(`Manifest must be { "version": ${MANIFEST_VERSION}, "seers": [...] }`);
382
+ }
383
+ for (const [i, entry] of parsed.seers.entries()) {
384
+ if (!entry?.name) throw new CliError(`Manifest seers[${i}] is missing "name" (names key the upsert)`);
385
+ if (!entry.provider) throw new CliError(`Manifest seers[${i}] ("${entry.name}") is missing "provider"`);
386
+ }
387
+ const names = parsed.seers.map((s) => s.name);
388
+ const dupes = names.filter((n, i) => names.indexOf(n) !== i);
389
+ if (dupes.length) throw new CliError(`Manifest has duplicate seer names: ${[...new Set(dupes)].join(", ")}`);
390
+ return parsed;
391
+ }
392
+
393
+ // Resolve each manifest entry's destination collection: an explicit collection_id
394
+ // wins; otherwise collection_name is matched against the org's collections.
395
+ async function resolveCollections(client, entries) {
396
+ const needsLookup = entries.some((e) => !e.collection_id && e.collection_name);
397
+ if (!needsLookup) return;
398
+ let collections;
399
+ try {
400
+ const result = await client.callTool("list_collections", {});
401
+ collections = result.collections ?? result;
402
+ } catch (err) {
403
+ throw new CliError(
404
+ `Manifest entries name collections by collection_name, which needs the list_collections tool (capability article:read): ${err.message}`
405
+ );
406
+ }
407
+ const byName = new Map((collections ?? []).map((c) => [c.name, c.id]));
408
+ for (const entry of entries) {
409
+ if (entry.collection_id || !entry.collection_name) continue;
410
+ const id = byName.get(entry.collection_name);
411
+ if (!id) throw new CliError(`Manifest seer "${entry.name}": no collection named "${entry.collection_name}"`);
412
+ entry.collection_id = id;
413
+ }
414
+ }
415
+
416
+ function planSeerApply(existingSeers, entries) {
417
+ const byName = new Map(existingSeers.map((s) => [s.name, s]));
418
+ const plan = [];
419
+ for (const entry of entries) {
420
+ if (!entry.collection_id) {
421
+ throw new CliError(`Manifest seer "${entry.name}" needs a collection_id or collection_name`);
422
+ }
423
+ const current = byName.get(entry.name);
424
+ if (!current) {
425
+ plan.push({ action: "create", entry });
426
+ continue;
427
+ }
428
+ if (current.provider !== entry.provider) {
429
+ throw new CliError(
430
+ `Manifest seer "${entry.name}" is ${entry.provider} but the existing seer is ${current.provider}. ` +
431
+ "A seer's provider can't change — rename one of them."
432
+ );
433
+ }
434
+ const changes = {};
435
+ if (entry.collection_id !== current.collection_id) changes.collection_id = entry.collection_id;
436
+ if (entry.mode !== undefined && entry.mode !== current.mode) changes.mode = entry.mode;
437
+ if (entry.enabled !== undefined && entry.enabled !== (current.enabled !== false))
438
+ changes.enabled = entry.enabled;
439
+ if (
440
+ entry.poll_interval_minutes !== undefined &&
441
+ entry.poll_interval_minutes !== current.poll_interval_minutes
442
+ ) {
443
+ changes.poll_interval_minutes = entry.poll_interval_minutes;
444
+ }
445
+ if (entry.config !== undefined && !sameFlatObject(entry.config, current.config)) changes.config = entry.config;
446
+ plan.push(
447
+ Object.keys(changes).length
448
+ ? { action: "update", entry, current, changes }
449
+ : { action: "unchanged", entry, current }
450
+ );
451
+ }
452
+ const manifestNames = new Set(entries.map((e) => e.name));
453
+ const extras = existingSeers.filter((s) => !manifestNames.has(s.name));
454
+ return { plan, extras };
455
+ }
456
+
457
+ function describePlan(plan, extras) {
458
+ const lines = [];
459
+ for (const step of plan) {
460
+ if (step.action === "create") {
461
+ lines.push(` + create ${step.entry.name} [${step.entry.provider}/${step.entry.mode ?? "suggest"}]`);
462
+ } else if (step.action === "update") {
463
+ lines.push(` ~ update ${step.entry.name} (${Object.keys(step.changes).join(", ")})`);
464
+ } else {
465
+ lines.push(` = ok ${step.entry.name}`);
466
+ }
467
+ }
468
+ if (extras.length) {
469
+ lines.push("");
470
+ lines.push(" Present in the org but not in the manifest (left untouched — apply never deletes):");
471
+ for (const s of extras) lines.push(` ? extra ${s.name} [${s.provider}]`);
472
+ }
473
+ return lines.join("\n");
474
+ }
475
+
476
+ async function seersApply(ctx) {
477
+ const { client, flags, io } = ctx;
478
+ const manifest = loadManifest(flags);
479
+ await resolveCollections(client, manifest.seers);
480
+
481
+ const current = await client.callTool("list_seers", {});
482
+ const { plan, extras } = planSeerApply(current.seers ?? [], manifest.seers);
483
+
484
+ const creates = plan.filter((p) => p.action === "create");
485
+ const updates = plan.filter((p) => p.action === "update");
486
+
487
+ if (flags.json) {
488
+ // Machine-readable plan; on a real apply the executed flag flips below.
489
+ const payload = { plan, extras: extras.map((s) => ({ name: s.name, id: s.id })), executed: false };
490
+ if (flagBool(flags["dry-run"]) || (!creates.length && !updates.length)) {
491
+ io.log(JSON.stringify(payload, null, 2));
492
+ if (flagBool(flags["dry-run"])) return 0;
493
+ }
494
+ } else {
495
+ io.log(`Plan for ${manifest.seers.length} manifest seers:`);
496
+ io.log(describePlan(plan, extras));
497
+ }
498
+
499
+ if (flagBool(flags["dry-run"])) {
500
+ ok(ctx, "\nDry run — nothing applied.");
501
+ return 0;
502
+ }
503
+ if (!creates.length && !updates.length) {
504
+ ok(ctx, "\nEverything already matches the manifest.");
505
+ return 0;
506
+ }
507
+
508
+ const applied = [];
509
+ for (const step of creates) {
510
+ const { entry } = step;
511
+ const result = await client.callTool("create_seer", {
512
+ name: entry.name,
513
+ provider: entry.provider,
514
+ collection_id: entry.collection_id,
515
+ ...(entry.mode !== undefined ? { mode: entry.mode } : {}),
516
+ ...(entry.poll_interval_minutes !== undefined
517
+ ? { poll_interval_minutes: entry.poll_interval_minutes }
518
+ : {}),
519
+ config: entry.config ?? {},
520
+ });
521
+ applied.push({ action: "created", name: entry.name, id: result.seer?.id });
522
+ ok(ctx, `Created ${entry.name}.`);
523
+ // The create tool has no enabled field; a manifest that wants it paused
524
+ // needs a follow-up update.
525
+ if (entry.enabled === false && result.seer?.id) {
526
+ await client.callTool("update_seer", { seer_id: result.seer.id, enabled: false });
527
+ ok(ctx, ` …and paused it (manifest says enabled: false).`);
528
+ }
529
+ }
530
+ for (const step of updates) {
531
+ await client.callTool("update_seer", { seer_id: step.current.id, ...step.changes });
532
+ applied.push({
533
+ action: "updated",
534
+ name: step.entry.name,
535
+ id: step.current.id,
536
+ changes: Object.keys(step.changes),
537
+ });
538
+ ok(ctx, `Updated ${step.entry.name} (${Object.keys(step.changes).join(", ")}).`);
539
+ }
540
+
541
+ if (flags.json) {
542
+ io.log(
543
+ JSON.stringify(
544
+ { plan, extras: extras.map((s) => ({ name: s.name, id: s.id })), executed: true, applied },
545
+ null,
546
+ 2
547
+ )
548
+ );
549
+ } else {
550
+ ok(
551
+ ctx,
552
+ `\nApplied: ${creates.length} created, ${updates.length} updated, ${plan.length - creates.length - updates.length} unchanged.`
553
+ );
554
+ }
555
+ return 0;
556
+ }
557
+
558
+ // --- Templates ---------------------------------------------------------------------
559
+
560
+ export async function cmdTemplates(ctx) {
561
+ const sub = ctx.positionals[0];
562
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
563
+ switch (sub) {
564
+ case "list":
565
+ case "ls":
566
+ return templatesList(rest);
567
+ case "show":
568
+ case "get":
569
+ return templatesShow(rest);
570
+ case "create":
571
+ case "new":
572
+ return templatesCreate(rest);
573
+ case "update":
574
+ return templatesUpdate(rest);
575
+ case "delete":
576
+ case "rm":
577
+ return templatesDelete(rest);
578
+ default:
579
+ throw new CliError(
580
+ `Unknown templates subcommand: ${sub ?? "(none)"}. Try: list, show, create, update, delete`
581
+ );
582
+ }
583
+ }
584
+
585
+ async function templatesList(ctx) {
586
+ const { client, flags, io } = ctx;
587
+ const result = await client.callTool("list_templates", {});
588
+ printResult(io, flags, result, (r) => {
589
+ const custom = (r.templates ?? []).map((t) => ` ${t.key} ${t.name} — ${t.angle} (id ${t.id})`);
590
+ const builtIn = (r.built_ins ?? []).map((t) => ` ${t.key} ${t.name}`);
591
+ return [
592
+ custom.length ? "Custom templates:" : "No custom templates yet.",
593
+ ...custom,
594
+ "",
595
+ "Built-in templates (read-only):",
596
+ ...builtIn,
597
+ ].join("\n");
598
+ });
599
+ return 0;
600
+ }
601
+
602
+ async function templatesShow(ctx) {
603
+ const { client, positionals, flags, io } = ctx;
604
+ const templateId = requirePositional(positionals, 0, "template-id");
605
+ const result = await client.callTool("get_template", { template_id: templateId });
606
+ printResult(io, flags, result);
607
+ return 0;
608
+ }
609
+
610
+ function templateSectionsFromFlags(flags) {
611
+ const inline = flagStr(flags.sections);
612
+ const file = flagStr(flags["sections-file"]);
613
+ if (inline === undefined && file === undefined) return undefined;
614
+ const raw = inline !== undefined ? inline : readFileOrThrow(file, "--sections-file");
615
+ let parsed;
616
+ try {
617
+ parsed = JSON.parse(raw);
618
+ } catch (err) {
619
+ throw new CliError(`--sections is not valid JSON: ${err.message}`);
620
+ }
621
+ if (!Array.isArray(parsed)) throw new CliError("--sections must be a JSON array of section objects");
622
+ return parsed;
623
+ }
624
+
625
+ async function templatesCreate(ctx) {
626
+ const { client, flags, io } = ctx;
627
+ const name = flagStr(flags.name);
628
+ const angle = flagStr(flags.angle);
629
+ const sections = templateSectionsFromFlags(flags);
630
+ if (!name) throw new CliError("Missing required --name");
631
+ if (!angle) throw new CliError("Missing required --angle");
632
+ if (!sections) throw new CliError("Missing required --sections '<json>' or --sections-file <path>");
633
+ const result = await client.callTool("create_template", { name, angle, sections });
634
+ ok(ctx, `Template created — key ${result.template?.key}.`);
635
+ printResult(io, flags, result);
636
+ return 0;
637
+ }
638
+
639
+ async function templatesUpdate(ctx) {
640
+ const { client, positionals, flags, io } = ctx;
641
+ const templateId = requirePositional(positionals, 0, "template-id");
642
+ const name = flagStr(flags.name);
643
+ const angle = flagStr(flags.angle);
644
+ const sections = templateSectionsFromFlags(flags);
645
+ // update_template replaces the whole template; fetch current values for any
646
+ // field the caller didn't pass so a partial edit doesn't blank the rest.
647
+ let current;
648
+ if (!name || !angle || !sections) {
649
+ const existing = await client.callTool("get_template", { template_id: templateId });
650
+ current = existing.template;
651
+ }
652
+ const result = await client.callTool("update_template", {
653
+ template_id: templateId,
654
+ name: name ?? current.name,
655
+ angle: angle ?? current.angle,
656
+ sections: sections ?? current.sections,
657
+ });
658
+ ok(ctx, "Template updated.");
659
+ printResult(io, flags, result);
660
+ return 0;
661
+ }
662
+
663
+ async function templatesDelete(ctx) {
664
+ const { client, positionals, flags, io } = ctx;
665
+ const templateId = requirePositional(positionals, 0, "template-id");
666
+ if (!flagBool(flags.yes)) {
667
+ throw new CliError("Seers bound to this template fall back to defaults. Re-run with --yes to delete.");
668
+ }
669
+ const result = await client.callTool("delete_template", { template_id: templateId });
670
+ ok(ctx, "Template deleted.");
671
+ printResult(io, flags, result);
672
+ return 0;
673
+ }
package/lib/commands.mjs CHANGED
@@ -15,6 +15,8 @@ export * from "./commands/flows.mjs";
15
15
  export * from "./commands/connectors.mjs";
16
16
  export * from "./commands/strategy.mjs";
17
17
  export * from "./commands/insights.mjs";
18
+ export * from "./commands/covers.mjs";
18
19
  export * from "./commands/research.mjs";
19
20
  export * from "./commands/kernel.mjs";
20
21
  export * from "./commands/phantom-job.mjs";
22
+ export * from "./commands/seers.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",