@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 +45 -7
- package/lib/cli.mjs +13 -2
- package/lib/commands/seers.mjs +29 -11
- package/lib/commands/series.mjs +101 -0
- package/lib/commands/strategy.mjs +110 -1
- package/lib/commands.mjs +1 -0
- package/package.json +1 -1
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
|
|
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
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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.
|
|
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
|
|
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,
|
package/lib/commands/seers.mjs
CHANGED
|
@@ -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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
:
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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";
|