@letterstory/cli 0.4.1 → 0.5.1

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,70 @@ 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 — both providers connect headlessly (no browser required):
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 # no token: prints how to mint one + which pages to share
282
+ letterstory seers connect notion --token ntn_xxx # store a Notion internal-integration secret
283
+ ```
284
+
285
+ **Connecting Notion.** Notion uses an _internal integration_ (the direct analogue of a
286
+ GitHub PAT), so it's fully scriptable:
287
+
288
+ 1. Create one at [notion.so/my-integrations](https://www.notion.so/my-integrations) → **New integration → Internal**, with the **Read content** capability.
289
+ 2. Copy the **Internal Integration Secret** (`ntn_…` / `secret_…`).
290
+ 3. In Notion, open each page/database the seer should watch → **••• → Connections →** add your integration. **A Notion integration only sees pages explicitly shared with it** — this step is required, or the seer connects but detects nothing.
291
+ 4. `letterstory seers connect notion --token ntn_…`
292
+
293
+ The app's browser OAuth flow (Seers tab → Connect Notion) is an equivalent alternative, not a requirement.
294
+
295
+ > **Remaining gap.** `seers run` executes synchronously server-side (up to ~5 minutes for
296
+ > web-scan providers); there is no start-then-poll variant yet. And `create`'s config takes
297
+ > raw JSON — `seers providers` is the schema reference the server validates against.
298
+
299
+ Content templates (the compose structures seers bind via `config.template_key`):
300
+
301
+ ```bash
302
+ letterstory templates list # custom + read-only built-ins
303
+ letterstory templates create --name "Release notes" --angle "What shipped and why" \
304
+ --sections-file sections.json
305
+ letterstory templates update <template-id> --angle "…" # unset fields keep their values
306
+ letterstory templates delete <template-id> --yes # bound seers fall back to defaults
307
+ ```
308
+
239
309
  ## Cover images
240
310
 
241
311
  Which image model draws a post's cover is a CLI decision, not a UI-only one. `variants`
package/lib/cli.mjs CHANGED
@@ -30,10 +30,12 @@ import {
30
30
  cmdCovers,
31
31
  cmdKernel,
32
32
  cmdPhantomJob,
33
+ cmdSeers,
34
+ cmdTemplates,
33
35
  } from "./commands.mjs";
34
36
 
35
37
  // Keep in sync with cli/package.json.
36
- export const VERSION = "0.4.1";
38
+ export const VERSION = "0.5.1";
37
39
 
38
40
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
39
41
  // can't accidentally swallow the id as --json's value.
@@ -226,6 +228,36 @@ Phantom orchestrator:
226
228
  Topic -> draft -> publish -> rebuild
227
229
  phantom-job status <job-id> Check a job's stage
228
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 --token <secret> Store a Notion internal-integration token
249
+ seers connect notion (no token: prints how to mint one + share pages)
250
+ seers export [--file <path>] Dump the org's seers as a manifest
251
+ seers apply --file <path> [--dry-run] Idempotent, name-keyed upsert from a manifest
252
+ (never deletes; --dry-run prints the plan)
253
+
254
+ Content templates (compose structures seers bind via config.template_key):
255
+ templates list Custom + built-in catalog
256
+ templates show <template-id>
257
+ templates create --name <n> --angle <text> (--sections '<json>'|--sections-file <path>)
258
+ templates update <template-id> [--name] [--angle] [--sections '<json>'|--sections-file <path>]
259
+ templates delete <template-id> --yes
260
+
229
261
  Insights:
230
262
  insights site [--period 14d|30d|90d] [--collection <uuid>]
231
263
  insights post <article-id> [--period 14d|30d|90d]
@@ -286,6 +318,8 @@ const CLIENT_COMMANDS = {
286
318
  covers: cmdCovers,
287
319
  kernel: cmdKernel,
288
320
  "phantom-job": cmdPhantomJob,
321
+ seers: cmdSeers,
322
+ templates: cmdTemplates,
289
323
  };
290
324
 
291
325
  // LETTERSTORY_POLL_INTERVAL_MS / LETTERSTORY_MAX_POLLS let an operator (or an
@@ -0,0 +1,691 @@
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
+ // Connections are both headless: `seers connect github --token` (a PAT) and
8
+ // `seers connect notion --token` (a Notion internal-integration secret; run
9
+ // `seers connect notion` with no token for how to mint one and which pages to share).
10
+ // One thing to know: `seers run` executes synchronously server-side (news/regulation
11
+ // web scans can 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${r.notion.configured ? " — connect with `seers connect notion --token <secret>` (run `seers connect notion` for how to get one)" : " (token storage not configured on this deployment)"}`;
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
+ const token = flagStr(rest.flags.token);
314
+ if (!token) {
315
+ // No token yet — print how to mint one and which pages to share. Notion
316
+ // internal integrations only see pages explicitly shared with them, so the
317
+ // share step is load-bearing, not optional.
318
+ rest.io.log("Connect Notion with an internal-integration secret (headless — no browser needed):");
319
+ rest.io.log("");
320
+ rest.io.log(" 1. Go to https://www.notion.so/my-integrations → New integration → Internal.");
321
+ rest.io.log(" 2. Give it the 'Read content' capability, then copy the Internal Integration Secret");
322
+ rest.io.log(" (starts with ntn_ or secret_).");
323
+ rest.io.log(" 3. In Notion, open each page/database the seer should watch → ••• → Connections →");
324
+ rest.io.log(" add your integration. (An integration only sees pages shared with it.)");
325
+ rest.io.log(` 4. Run: ${rest.bin} seers connect notion --token ntn_your_secret`);
326
+ rest.io.log("");
327
+ rest.io.log(
328
+ "The app's browser OAuth flow (Seers tab → Connect Notion) is an alternative, not a requirement."
329
+ );
330
+ return 0;
331
+ }
332
+ const result = await rest.client.callTool("set_notion_connection", { access_token: token });
333
+ ok(rest, `Notion connected${result.workspace_name ? ` to ${result.workspace_name}` : ""}.`);
334
+ ok(rest, "Reminder: the integration only watches pages you've shared with it in Notion.");
335
+ printResult(rest.io, rest.flags, result);
336
+ return 0;
337
+ }
338
+ throw new CliError(`Unknown connect target: ${which ?? "(none)"}. Try: github, notion`);
339
+ }
340
+
341
+ // --- Export / apply (reproducible onboarding) --------------------------------------
342
+
343
+ const MANIFEST_VERSION = 1;
344
+ // The seer fields a manifest owns. Cursor/lease/poll state is deliberately NOT
345
+ // exported — a manifest describes configuration, not runtime progress.
346
+ function manifestEntryFromSeer(s) {
347
+ return {
348
+ name: s.name,
349
+ provider: s.provider,
350
+ mode: s.mode,
351
+ enabled: s.enabled !== false,
352
+ poll_interval_minutes: s.poll_interval_minutes,
353
+ collection_id: s.collection_id,
354
+ collection_name: s.collection_name ?? undefined,
355
+ config: s.config ?? {},
356
+ };
357
+ }
358
+
359
+ async function seersExport(ctx) {
360
+ const { client, flags, io } = ctx;
361
+ const result = await client.callTool("list_seers", {});
362
+ const manifest = {
363
+ version: MANIFEST_VERSION,
364
+ seers: (result.seers ?? []).map(manifestEntryFromSeer),
365
+ };
366
+ const out = JSON.stringify(manifest, null, 2);
367
+ const file = flagStr(flags.file);
368
+ if (file) {
369
+ writeFileSync(file, `${out}\n`);
370
+ ok(ctx, `Exported ${manifest.seers.length} seers to ${file}.`);
371
+ } else {
372
+ io.log(out);
373
+ }
374
+ return 0;
375
+ }
376
+
377
+ // Key-order-independent flat-object equality — the same rule the server uses to
378
+ // decide whether an update actually changed the config (and must reset the cursor).
379
+ function sameFlatObject(a, b) {
380
+ const stable = (o) =>
381
+ JSON.stringify(
382
+ Object.keys(o ?? {})
383
+ .sort()
384
+ .map((k) => [k, o[k]])
385
+ );
386
+ return stable(a) === stable(b);
387
+ }
388
+
389
+ function loadManifest(flags) {
390
+ const file = flagStr(flags.file);
391
+ if (!file) throw new CliError("Missing required --file <manifest.json> (produce one with `seers export`)");
392
+ let parsed;
393
+ try {
394
+ parsed = JSON.parse(readFileOrThrow(file, "--file"));
395
+ } catch (err) {
396
+ throw err instanceof CliError ? err : new CliError(`--file is not valid JSON: ${err.message}`);
397
+ }
398
+ if (parsed?.version !== MANIFEST_VERSION || !Array.isArray(parsed.seers)) {
399
+ throw new CliError(`Manifest must be { "version": ${MANIFEST_VERSION}, "seers": [...] }`);
400
+ }
401
+ for (const [i, entry] of parsed.seers.entries()) {
402
+ if (!entry?.name) throw new CliError(`Manifest seers[${i}] is missing "name" (names key the upsert)`);
403
+ if (!entry.provider) throw new CliError(`Manifest seers[${i}] ("${entry.name}") is missing "provider"`);
404
+ }
405
+ const names = parsed.seers.map((s) => s.name);
406
+ const dupes = names.filter((n, i) => names.indexOf(n) !== i);
407
+ if (dupes.length) throw new CliError(`Manifest has duplicate seer names: ${[...new Set(dupes)].join(", ")}`);
408
+ return parsed;
409
+ }
410
+
411
+ // Resolve each manifest entry's destination collection: an explicit collection_id
412
+ // wins; otherwise collection_name is matched against the org's collections.
413
+ async function resolveCollections(client, entries) {
414
+ const needsLookup = entries.some((e) => !e.collection_id && e.collection_name);
415
+ if (!needsLookup) return;
416
+ let collections;
417
+ try {
418
+ const result = await client.callTool("list_collections", {});
419
+ collections = result.collections ?? result;
420
+ } catch (err) {
421
+ throw new CliError(
422
+ `Manifest entries name collections by collection_name, which needs the list_collections tool (capability article:read): ${err.message}`
423
+ );
424
+ }
425
+ const byName = new Map((collections ?? []).map((c) => [c.name, c.id]));
426
+ for (const entry of entries) {
427
+ if (entry.collection_id || !entry.collection_name) continue;
428
+ const id = byName.get(entry.collection_name);
429
+ if (!id) throw new CliError(`Manifest seer "${entry.name}": no collection named "${entry.collection_name}"`);
430
+ entry.collection_id = id;
431
+ }
432
+ }
433
+
434
+ function planSeerApply(existingSeers, entries) {
435
+ const byName = new Map(existingSeers.map((s) => [s.name, s]));
436
+ const plan = [];
437
+ for (const entry of entries) {
438
+ if (!entry.collection_id) {
439
+ throw new CliError(`Manifest seer "${entry.name}" needs a collection_id or collection_name`);
440
+ }
441
+ const current = byName.get(entry.name);
442
+ if (!current) {
443
+ plan.push({ action: "create", entry });
444
+ continue;
445
+ }
446
+ if (current.provider !== entry.provider) {
447
+ throw new CliError(
448
+ `Manifest seer "${entry.name}" is ${entry.provider} but the existing seer is ${current.provider}. ` +
449
+ "A seer's provider can't change — rename one of them."
450
+ );
451
+ }
452
+ const changes = {};
453
+ if (entry.collection_id !== current.collection_id) changes.collection_id = entry.collection_id;
454
+ if (entry.mode !== undefined && entry.mode !== current.mode) changes.mode = entry.mode;
455
+ if (entry.enabled !== undefined && entry.enabled !== (current.enabled !== false))
456
+ changes.enabled = entry.enabled;
457
+ if (
458
+ entry.poll_interval_minutes !== undefined &&
459
+ entry.poll_interval_minutes !== current.poll_interval_minutes
460
+ ) {
461
+ changes.poll_interval_minutes = entry.poll_interval_minutes;
462
+ }
463
+ if (entry.config !== undefined && !sameFlatObject(entry.config, current.config)) changes.config = entry.config;
464
+ plan.push(
465
+ Object.keys(changes).length
466
+ ? { action: "update", entry, current, changes }
467
+ : { action: "unchanged", entry, current }
468
+ );
469
+ }
470
+ const manifestNames = new Set(entries.map((e) => e.name));
471
+ const extras = existingSeers.filter((s) => !manifestNames.has(s.name));
472
+ return { plan, extras };
473
+ }
474
+
475
+ function describePlan(plan, extras) {
476
+ const lines = [];
477
+ for (const step of plan) {
478
+ if (step.action === "create") {
479
+ lines.push(` + create ${step.entry.name} [${step.entry.provider}/${step.entry.mode ?? "suggest"}]`);
480
+ } else if (step.action === "update") {
481
+ lines.push(` ~ update ${step.entry.name} (${Object.keys(step.changes).join(", ")})`);
482
+ } else {
483
+ lines.push(` = ok ${step.entry.name}`);
484
+ }
485
+ }
486
+ if (extras.length) {
487
+ lines.push("");
488
+ lines.push(" Present in the org but not in the manifest (left untouched — apply never deletes):");
489
+ for (const s of extras) lines.push(` ? extra ${s.name} [${s.provider}]`);
490
+ }
491
+ return lines.join("\n");
492
+ }
493
+
494
+ async function seersApply(ctx) {
495
+ const { client, flags, io } = ctx;
496
+ const manifest = loadManifest(flags);
497
+ await resolveCollections(client, manifest.seers);
498
+
499
+ const current = await client.callTool("list_seers", {});
500
+ const { plan, extras } = planSeerApply(current.seers ?? [], manifest.seers);
501
+
502
+ const creates = plan.filter((p) => p.action === "create");
503
+ const updates = plan.filter((p) => p.action === "update");
504
+
505
+ if (flags.json) {
506
+ // Machine-readable plan; on a real apply the executed flag flips below.
507
+ const payload = { plan, extras: extras.map((s) => ({ name: s.name, id: s.id })), executed: false };
508
+ if (flagBool(flags["dry-run"]) || (!creates.length && !updates.length)) {
509
+ io.log(JSON.stringify(payload, null, 2));
510
+ if (flagBool(flags["dry-run"])) return 0;
511
+ }
512
+ } else {
513
+ io.log(`Plan for ${manifest.seers.length} manifest seers:`);
514
+ io.log(describePlan(plan, extras));
515
+ }
516
+
517
+ if (flagBool(flags["dry-run"])) {
518
+ ok(ctx, "\nDry run — nothing applied.");
519
+ return 0;
520
+ }
521
+ if (!creates.length && !updates.length) {
522
+ ok(ctx, "\nEverything already matches the manifest.");
523
+ return 0;
524
+ }
525
+
526
+ const applied = [];
527
+ for (const step of creates) {
528
+ const { entry } = step;
529
+ const result = await client.callTool("create_seer", {
530
+ name: entry.name,
531
+ provider: entry.provider,
532
+ collection_id: entry.collection_id,
533
+ ...(entry.mode !== undefined ? { mode: entry.mode } : {}),
534
+ ...(entry.poll_interval_minutes !== undefined
535
+ ? { poll_interval_minutes: entry.poll_interval_minutes }
536
+ : {}),
537
+ config: entry.config ?? {},
538
+ });
539
+ applied.push({ action: "created", name: entry.name, id: result.seer?.id });
540
+ ok(ctx, `Created ${entry.name}.`);
541
+ // The create tool has no enabled field; a manifest that wants it paused
542
+ // needs a follow-up update.
543
+ if (entry.enabled === false && result.seer?.id) {
544
+ await client.callTool("update_seer", { seer_id: result.seer.id, enabled: false });
545
+ ok(ctx, ` …and paused it (manifest says enabled: false).`);
546
+ }
547
+ }
548
+ for (const step of updates) {
549
+ await client.callTool("update_seer", { seer_id: step.current.id, ...step.changes });
550
+ applied.push({
551
+ action: "updated",
552
+ name: step.entry.name,
553
+ id: step.current.id,
554
+ changes: Object.keys(step.changes),
555
+ });
556
+ ok(ctx, `Updated ${step.entry.name} (${Object.keys(step.changes).join(", ")}).`);
557
+ }
558
+
559
+ if (flags.json) {
560
+ io.log(
561
+ JSON.stringify(
562
+ { plan, extras: extras.map((s) => ({ name: s.name, id: s.id })), executed: true, applied },
563
+ null,
564
+ 2
565
+ )
566
+ );
567
+ } else {
568
+ ok(
569
+ ctx,
570
+ `\nApplied: ${creates.length} created, ${updates.length} updated, ${plan.length - creates.length - updates.length} unchanged.`
571
+ );
572
+ }
573
+ return 0;
574
+ }
575
+
576
+ // --- Templates ---------------------------------------------------------------------
577
+
578
+ export async function cmdTemplates(ctx) {
579
+ const sub = ctx.positionals[0];
580
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
581
+ switch (sub) {
582
+ case "list":
583
+ case "ls":
584
+ return templatesList(rest);
585
+ case "show":
586
+ case "get":
587
+ return templatesShow(rest);
588
+ case "create":
589
+ case "new":
590
+ return templatesCreate(rest);
591
+ case "update":
592
+ return templatesUpdate(rest);
593
+ case "delete":
594
+ case "rm":
595
+ return templatesDelete(rest);
596
+ default:
597
+ throw new CliError(
598
+ `Unknown templates subcommand: ${sub ?? "(none)"}. Try: list, show, create, update, delete`
599
+ );
600
+ }
601
+ }
602
+
603
+ async function templatesList(ctx) {
604
+ const { client, flags, io } = ctx;
605
+ const result = await client.callTool("list_templates", {});
606
+ printResult(io, flags, result, (r) => {
607
+ const custom = (r.templates ?? []).map((t) => ` ${t.key} ${t.name} — ${t.angle} (id ${t.id})`);
608
+ const builtIn = (r.built_ins ?? []).map((t) => ` ${t.key} ${t.name}`);
609
+ return [
610
+ custom.length ? "Custom templates:" : "No custom templates yet.",
611
+ ...custom,
612
+ "",
613
+ "Built-in templates (read-only):",
614
+ ...builtIn,
615
+ ].join("\n");
616
+ });
617
+ return 0;
618
+ }
619
+
620
+ async function templatesShow(ctx) {
621
+ const { client, positionals, flags, io } = ctx;
622
+ const templateId = requirePositional(positionals, 0, "template-id");
623
+ const result = await client.callTool("get_template", { template_id: templateId });
624
+ printResult(io, flags, result);
625
+ return 0;
626
+ }
627
+
628
+ function templateSectionsFromFlags(flags) {
629
+ const inline = flagStr(flags.sections);
630
+ const file = flagStr(flags["sections-file"]);
631
+ if (inline === undefined && file === undefined) return undefined;
632
+ const raw = inline !== undefined ? inline : readFileOrThrow(file, "--sections-file");
633
+ let parsed;
634
+ try {
635
+ parsed = JSON.parse(raw);
636
+ } catch (err) {
637
+ throw new CliError(`--sections is not valid JSON: ${err.message}`);
638
+ }
639
+ if (!Array.isArray(parsed)) throw new CliError("--sections must be a JSON array of section objects");
640
+ return parsed;
641
+ }
642
+
643
+ async function templatesCreate(ctx) {
644
+ const { client, flags, io } = ctx;
645
+ const name = flagStr(flags.name);
646
+ const angle = flagStr(flags.angle);
647
+ const sections = templateSectionsFromFlags(flags);
648
+ if (!name) throw new CliError("Missing required --name");
649
+ if (!angle) throw new CliError("Missing required --angle");
650
+ if (!sections) throw new CliError("Missing required --sections '<json>' or --sections-file <path>");
651
+ const result = await client.callTool("create_template", { name, angle, sections });
652
+ ok(ctx, `Template created — key ${result.template?.key}.`);
653
+ printResult(io, flags, result);
654
+ return 0;
655
+ }
656
+
657
+ async function templatesUpdate(ctx) {
658
+ const { client, positionals, flags, io } = ctx;
659
+ const templateId = requirePositional(positionals, 0, "template-id");
660
+ const name = flagStr(flags.name);
661
+ const angle = flagStr(flags.angle);
662
+ const sections = templateSectionsFromFlags(flags);
663
+ // update_template replaces the whole template; fetch current values for any
664
+ // field the caller didn't pass so a partial edit doesn't blank the rest.
665
+ let current;
666
+ if (!name || !angle || !sections) {
667
+ const existing = await client.callTool("get_template", { template_id: templateId });
668
+ current = existing.template;
669
+ }
670
+ const result = await client.callTool("update_template", {
671
+ template_id: templateId,
672
+ name: name ?? current.name,
673
+ angle: angle ?? current.angle,
674
+ sections: sections ?? current.sections,
675
+ });
676
+ ok(ctx, "Template updated.");
677
+ printResult(io, flags, result);
678
+ return 0;
679
+ }
680
+
681
+ async function templatesDelete(ctx) {
682
+ const { client, positionals, flags, io } = ctx;
683
+ const templateId = requirePositional(positionals, 0, "template-id");
684
+ if (!flagBool(flags.yes)) {
685
+ throw new CliError("Seers bound to this template fall back to defaults. Re-run with --yes to delete.");
686
+ }
687
+ const result = await client.callTool("delete_template", { template_id: templateId });
688
+ ok(ctx, "Template deleted.");
689
+ printResult(io, flags, result);
690
+ return 0;
691
+ }
package/lib/commands.mjs CHANGED
@@ -19,3 +19,4 @@ export * from "./commands/covers.mjs";
19
19
  export * from "./commands/research.mjs";
20
20
  export * from "./commands/kernel.mjs";
21
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.1",
3
+ "version": "0.5.1",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",