@letterstory/cli 0.5.0 → 0.6.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
@@ -213,6 +213,35 @@ letterstory onboarding status
213
213
  letterstory onboarding step --complete connect_domain
214
214
  ```
215
215
 
216
+ ### Magical onboarding
217
+
218
+ One command from a domain to a filled-out strategy: it infers and saves the manifesto,
219
+ stances, and identity guardrail from the client's website (never clobbering anything
220
+ hand-written), optionally imports the blog's post history from a sitemap, infers keywords
221
+ and builds topic clusters, then detects the content series those posts already imply and
222
+ adopts them — coverage reconstructed from existing posts, gaps left for the queue to write.
223
+ Idempotent; safe to re-run.
224
+
225
+ ```bash
226
+ letterstory onboarding magic # use the saved company domain
227
+ letterstory onboarding magic --domain acme.com --sitemap https://acme.com/sitemap.xml
228
+ letterstory onboarding magic --dry-run # preview series proposals only
229
+ ```
230
+
231
+ ## Content series
232
+
233
+ A series is a coverage obligation, not a keyword bet: "content that looks like this",
234
+ one post per declared item. Series feed the same planner queue as topic clusters.
235
+
236
+ ```bash
237
+ letterstory series list
238
+ letterstory series propose # detect series in existing posts
239
+ letterstory series create --name "Secrets, tool by tool" \
240
+ --description "One practical secrets guide per IaC tool." \
241
+ --coverage "Pulumi,Terraform,Ansible" --template <key>
242
+ letterstory series backfill <series-id> --collection <uuid>
243
+ ```
244
+
216
245
  ## Research and writing kernels
217
246
 
218
247
  Two different steps, in this order. The **research agent** reads the live web and writes a
@@ -273,19 +302,28 @@ letterstory seers apply --file seers.json --dry-run # print the create/update/
273
302
  letterstory seers apply --file seers.json # apply it
274
303
  ```
275
304
 
276
- Connections, and one honest gap:
305
+ Connections both providers connect headlessly (no browser required):
277
306
 
278
307
  ```bash
279
308
  letterstory seers connections # GitHub + Notion status
280
309
  letterstory seers connect github --token <pat> # org-wide token; unlocks private repos
281
- letterstory seers connect notion # prints instructions see below
310
+ letterstory seers connect notion # no token: prints how to mint one + which pages to share
311
+ letterstory seers connect notion --token ntn_xxx # store a Notion internal-integration secret
282
312
  ```
283
313
 
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.
314
+ **Connecting Notion.** Notion uses an _internal integration_ (the direct analogue of a
315
+ GitHub PAT), so it's fully scriptable:
316
+
317
+ 1. Create one at [notion.so/my-integrations](https://www.notion.so/my-integrations) **New integration Internal**, with the **Read content** capability.
318
+ 2. Copy the **Internal Integration Secret** (`ntn_…` / `secret_…`).
319
+ 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.
320
+ 4. `letterstory seers connect notion --token ntn_…`
321
+
322
+ The app's browser OAuth flow (Seers tab → Connect Notion) is an equivalent alternative, not a requirement.
323
+
324
+ > **Remaining gap.** `seers run` executes synchronously server-side (up to ~5 minutes for
325
+ > web-scan providers); there is no start-then-poll variant yet. And `create`'s config takes
326
+ > raw JSON — `seers providers` is the schema reference the server validates against.
289
327
 
290
328
  Content templates (the compose structures seers bind via `config.template_key`):
291
329
 
package/lib/cli.mjs CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  cmdConnectors,
26
26
  cmdStrategy,
27
27
  cmdOnboarding,
28
+ cmdSeries,
28
29
  cmdInsights,
29
30
  cmdResearch,
30
31
  cmdCovers,
@@ -35,7 +36,7 @@ import {
35
36
  } from "./commands.mjs";
36
37
 
37
38
  // Keep in sync with cli/package.json.
38
- export const VERSION = "0.5.0";
39
+ export const VERSION = "0.6.0";
39
40
 
40
41
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
41
42
  // can't accidentally swallow the id as --json's value.
@@ -198,6 +199,14 @@ Strategy & onboarding:
198
199
  strategy topics set --collection <uuid> (--topic <topic-id> | --suggestion <suggestion-id>)
199
200
  onboarding status Show the onboarding checklist
200
201
  onboarding step [--current <step>] [--complete <step>] [--skip <step>] [--status <status>]
202
+ onboarding magic [--domain <domain>] [--sitemap <url>] [--collection <uuid>] [--dry-run]
203
+ Enter a domain -> strategy filled out:
204
+ manifesto+stances, optional history import,
205
+ clusters, detected series (see below)
206
+ series list The org's content series + coverage state
207
+ series propose [--collection <uuid>] Detect series existing posts already imply
208
+ series create --name <n> --description <d> [--coverage a,b,c] [--collection <uuid>] [--template <key>]
209
+ series backfill <series-id> [--collection <uuid>] Reconstruct a series from existing posts
201
210
 
202
211
  Research agent (deep research -> outline written into the post):
203
212
  research start --article <uuid> [--topic <text>] [--url <url> …] [--must-include <text> …]
@@ -245,7 +254,8 @@ Seers (event-driven signals -> drafts):
245
254
  seers release <article-id> | seers discard <article-id>
246
255
  seers connections GitHub/Notion connection status
247
256
  seers connect github --token <pat> Store an org-wide GitHub token
248
- seers connect notion (browser-only OAuth prints instructions)
257
+ seers connect notion --token <secret> Store a Notion internal-integration token
258
+ seers connect notion (no token: prints how to mint one + share pages)
249
259
  seers export [--file <path>] Dump the org's seers as a manifest
250
260
  seers apply --file <path> [--dry-run] Idempotent, name-keyed upsert from a manifest
251
261
  (never deletes; --dry-run prints the plan)
@@ -312,6 +322,7 @@ const CLIENT_COMMANDS = {
312
322
  connectors: cmdConnectors,
313
323
  strategy: cmdStrategy,
314
324
  onboarding: cmdOnboarding,
325
+ series: cmdSeries,
315
326
  insights: cmdInsights,
316
327
  research: cmdResearch,
317
328
  covers: cmdCovers,
@@ -4,11 +4,11 @@
4
4
  // `seers export` (dump the org's seers as a manifest) and `seers apply` (idempotent,
5
5
  // name-keyed diff-then-upsert from a manifest, with --dry-run).
6
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.
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
12
 
13
13
  import { readFileSync, writeFileSync } from "node:fs";
14
14
  import { CliError } from "../client.mjs";
@@ -292,7 +292,7 @@ async function seersConnections(ctx) {
292
292
  : `GitHub: not connected${r.github.configured ? " — connect with `seers connect github --token <pat>`" : " (token storage not configured on this deployment)"}`;
293
293
  const notion = r.notion.connected
294
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";
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
296
  return `${gh}\n${notion}`;
297
297
  });
298
298
  return 0;
@@ -310,11 +310,29 @@ async function seersConnect(ctx) {
310
310
  return 0;
311
311
  }
312
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`);
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);
318
336
  return 0;
319
337
  }
320
338
  throw new CliError(`Unknown connect target: ${which ?? "(none)"}. Try: github, notion`);
@@ -0,0 +1,101 @@
1
+ // `series` — content series management (the coverage-driven strategy entry
2
+ // point beside topic clusters, hierarchy revamp D/G). A series is a coverage
3
+ // obligation: "content that looks like this", one post per declared item; the
4
+ // planner queue writes the gaps and backfill reconstructs what existing posts
5
+ // already fulfill.
6
+
7
+ import { CliError } from "../client.mjs";
8
+ import { compact, flagStr, ok, printResult, requireFlag, requirePositional } from "./shared.mjs";
9
+
10
+ export async function cmdSeries(ctx) {
11
+ const sub = ctx.positionals[0];
12
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
13
+ switch (sub) {
14
+ case "list":
15
+ return seriesList(rest);
16
+ case "create":
17
+ return seriesCreate(rest);
18
+ case "backfill":
19
+ return seriesBackfill(rest);
20
+ case "propose":
21
+ return seriesPropose(rest);
22
+ default:
23
+ throw new CliError(`Unknown series subcommand: ${sub ?? "(none)"}. Try: list, create, backfill, propose`);
24
+ }
25
+ }
26
+
27
+ function formatSeriesList(result) {
28
+ const list = result?.series ?? [];
29
+ if (list.length === 0) return "No series yet. Create one, or run `series propose` to detect them.";
30
+ return list
31
+ .map((s) => {
32
+ const state = s.coverage_state ?? {};
33
+ const covered = state.covered?.length ?? 0;
34
+ const declared = s.coverage?.length ?? 0;
35
+ const missing = state.missing?.length ? ` — still to cover: ${state.missing.join(", ")}` : "";
36
+ return `${s.id} ${s.name} [${s.status}] ${covered}/${declared || "∞"} covered${missing}`;
37
+ })
38
+ .join("\n");
39
+ }
40
+
41
+ async function seriesList(ctx) {
42
+ const { client, flags, io } = ctx;
43
+ const result = await client.callTool("list_series", {});
44
+ printResult(io, flags, result, formatSeriesList);
45
+ return 0;
46
+ }
47
+
48
+ async function seriesCreate(ctx) {
49
+ const { client, flags, io } = ctx;
50
+ const coverage = flagStr(flags.coverage);
51
+ const args = compact({
52
+ name: requireFlag(flags, "name"),
53
+ description: requireFlag(flags, "description"),
54
+ collection_id: flagStr(flags.collection),
55
+ template_key: flagStr(flags.template),
56
+ coverage: coverage
57
+ ? coverage
58
+ .split(",")
59
+ .map((c) => c.trim())
60
+ .filter(Boolean)
61
+ : undefined,
62
+ });
63
+ const result = await client.callTool("create_series", args);
64
+ ok(ctx, "Series created — the queue starts covering its items.");
65
+ printResult(io, flags, result);
66
+ return 0;
67
+ }
68
+
69
+ async function seriesBackfill(ctx) {
70
+ const { client, flags, io } = ctx;
71
+ const args = compact({
72
+ series_id: requirePositional(ctx.positionals, 0, "series-id"),
73
+ collection_id: flagStr(flags.collection),
74
+ });
75
+ const result = await client.callTool("backfill_series", args);
76
+ ok(ctx, `Reconstructed ${result?.matched ?? 0} existing post(s) into the series.`);
77
+ printResult(io, flags, result);
78
+ return 0;
79
+ }
80
+
81
+ function formatProposals(result) {
82
+ const proposals = result?.proposals ?? [];
83
+ if (proposals.length === 0) return "No series detected — this blog has no clearly repeating shapes.";
84
+ return proposals
85
+ .map((p) => {
86
+ const matched = p.matchedTitles?.length ?? 0;
87
+ return [
88
+ `${p.name} — ${p.description}`,
89
+ ` covers ${matched} existing post(s); items: ${p.coverage.join(", ")}`,
90
+ ].join("\n");
91
+ })
92
+ .join("\n");
93
+ }
94
+
95
+ async function seriesPropose(ctx) {
96
+ const { client, flags, io } = ctx;
97
+ const args = compact({ collection_id: flagStr(flags.collection) });
98
+ const result = await client.callTool("propose_series", args);
99
+ printResult(io, flags, result, formatProposals);
100
+ return 0;
101
+ }
@@ -220,11 +220,120 @@ export async function cmdOnboarding(ctx) {
220
220
  return onboardingStatus(rest);
221
221
  case "step":
222
222
  return onboardingStep(rest);
223
+ case "magic":
224
+ return onboardingMagic(rest);
223
225
  default:
224
- throw new CliError(`Unknown onboarding subcommand: ${sub ?? "(none)"}. Try: status, step`);
226
+ throw new CliError(`Unknown onboarding subcommand: ${sub ?? "(none)"}. Try: status, step, magic`);
225
227
  }
226
228
  }
227
229
 
230
+ // --- magic: enter a domain → strategy filled out -------------------------
231
+ //
232
+ // The G1 "magical onboarding": one command that composes the platform's
233
+ // existing inference into a filled-out strategy — manifesto + stances +
234
+ // identity (infer_org_strategy), optional post-history import from the
235
+ // sitemap, keywords + topic clusters (infer_client_keywords), and detected
236
+ // content series (propose_series → create_series → backfill_series). Every
237
+ // step is idempotent and never clobbers hand-authored data, so re-running is
238
+ // always safe. --dry-run previews series proposals instead of adopting them.
239
+
240
+ async function onboardingMagic(ctx) {
241
+ const { client, flags, io } = ctx;
242
+ const dryRun = flags["dry-run"] === true;
243
+ const summary = {};
244
+
245
+ // 1. Company profile → the domain everything reads from.
246
+ ok(ctx, "Reading company profile…");
247
+ const company = await client.callTool("get_company_info", {});
248
+ const domain = flagStr(flags.domain) ?? company.domain ?? undefined;
249
+ summary.domain = domain ?? null;
250
+
251
+ // 2. Strategy: manifesto, stances, identity — saved with never-clobber guards.
252
+ ok(ctx, domain ? `Inferring strategy from ${domain}…` : "Inferring strategy…");
253
+ const strategy = await client.callTool("infer_org_strategy", compact({ domain }));
254
+ summary.strategy = strategy;
255
+ if (strategy.status === "skipped") {
256
+ ok(ctx, `Strategy skipped (${strategy.reason}): ${strategy.detail}`);
257
+ } else {
258
+ ok(ctx, strategy.status === "reused" ? "Strategy already in place — kept as-is." : "Strategy saved.");
259
+ }
260
+
261
+ // 3. Post history: optionally import the blog's existing posts from a sitemap
262
+ // so clustering/series have an inventory to read.
263
+ let inventory = await client.callTool("get_content_inventory", {});
264
+ if (flagStr(flags.sitemap)) {
265
+ const collectionId = flagStr(flags.collection) ?? (await resolveMainCollection(client));
266
+ ok(ctx, "Importing existing posts from the sitemap (this can take a while)…");
267
+ const imported = await client.callTool("import_sitemap", {
268
+ collection_id: collectionId,
269
+ sitemap_url: flagStr(flags.sitemap),
270
+ });
271
+ summary.sitemap_import = imported;
272
+ inventory = await client.callTool("get_content_inventory", {});
273
+ } else if ((inventory.total ?? 0) === 0) {
274
+ ok(ctx, "No posts in the inventory yet — pass --sitemap <url> to import the blog's history.");
275
+ }
276
+ summary.inventory = inventory;
277
+
278
+ // 4. Keywords + topic clusters over whatever inventory exists.
279
+ if ((inventory.total ?? 0) > 0) {
280
+ ok(ctx, "Inferring keywords and building topic clusters…");
281
+ summary.keywords = await client.callTool("infer_client_keywords", {});
282
+ }
283
+
284
+ // 5. Series detection — adopt each grounded proposal (create + backfill),
285
+ // or just show them under --dry-run.
286
+ ok(ctx, "Detecting content series…");
287
+ const proposed = await client.callTool("propose_series", compact({ collection_id: flagStr(flags.collection) }));
288
+ const proposals = proposed.proposals ?? [];
289
+ summary.series = [];
290
+ for (const p of proposals) {
291
+ if (dryRun) {
292
+ summary.series.push({ name: p.name, coverage: p.coverage, adopted: false });
293
+ continue;
294
+ }
295
+ const created = await client.callTool("create_series", {
296
+ name: p.name,
297
+ description: p.description,
298
+ coverage: p.coverage,
299
+ });
300
+ const backfilled = await client.callTool("backfill_series", {
301
+ series_id: created.id,
302
+ collection_id: proposed.collection_id,
303
+ });
304
+ summary.series.push({ name: p.name, coverage: p.coverage, adopted: true, backfilled: backfilled.matched });
305
+ ok(ctx, `Series "${p.name}" created — ${backfilled.matched} existing post(s) reconstructed into it.`);
306
+ }
307
+ if (proposals.length === 0) ok(ctx, "No series detected — this blog has no clearly repeating shapes.");
308
+
309
+ printResult(io, flags, summary, (s) => {
310
+ const lines = [];
311
+ lines.push(`Strategy: ${s.strategy.status}${s.strategy.reason ? ` (${s.strategy.reason})` : ""}`);
312
+ lines.push(`Inventory: ${s.inventory.total ?? 0} post(s)${s.inventory.lowInventory ? " (low)" : ""}`);
313
+ if (s.keywords) {
314
+ lines.push(
315
+ `Clusters: ${s.keywords.assigned ?? 0} post(s) assigned, ${s.keywords.newClusters ?? 0} new cluster(s)`
316
+ );
317
+ }
318
+ lines.push(
319
+ s.series.length === 0
320
+ ? "Series: none detected"
321
+ : `Series: ${s.series.map((x) => `${x.name}${x.adopted ? "" : " (proposed)"}`).join("; ")}`
322
+ );
323
+ return lines.join("\n");
324
+ });
325
+ return 0;
326
+ }
327
+
328
+ // The org's main (non-phantom) collection — where history imports and
329
+ // org-level series live. list_collections includes `kind` for exactly this.
330
+ async function resolveMainCollection(client) {
331
+ const result = await client.callTool("list_collections", {});
332
+ const main = (result.items ?? []).find((c) => c.kind !== "phantom");
333
+ if (!main) throw new CliError("No main collection found — create one first, or pass --collection <id>.");
334
+ return main.collection_id;
335
+ }
336
+
228
337
  async function onboardingStatus(ctx) {
229
338
  const { client, flags, io } = ctx;
230
339
  const result = await client.callTool("get_onboarding_status", {});
package/lib/commands.mjs CHANGED
@@ -14,6 +14,7 @@ export * from "./commands/authors.mjs";
14
14
  export * from "./commands/flows.mjs";
15
15
  export * from "./commands/connectors.mjs";
16
16
  export * from "./commands/strategy.mjs";
17
+ export * from "./commands/series.mjs";
17
18
  export * from "./commands/insights.mjs";
18
19
  export * from "./commands/covers.mjs";
19
20
  export * from "./commands/research.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",