@letterstory/cli 0.5.1 → 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 +29 -0
- package/lib/cli.mjs +11 -1
- 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
|
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> …]
|
|
@@ -313,6 +322,7 @@ const CLIENT_COMMANDS = {
|
|
|
313
322
|
connectors: cmdConnectors,
|
|
314
323
|
strategy: cmdStrategy,
|
|
315
324
|
onboarding: cmdOnboarding,
|
|
325
|
+
series: cmdSeries,
|
|
316
326
|
insights: cmdInsights,
|
|
317
327
|
research: cmdResearch,
|
|
318
328
|
covers: cmdCovers,
|
|
@@ -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";
|