@letterstory/cli 0.5.1 → 0.7.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
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,
@@ -32,10 +33,11 @@ import {
32
33
  cmdPhantomJob,
33
34
  cmdSeers,
34
35
  cmdTemplates,
36
+ cmdShredder,
35
37
  } from "./commands.mjs";
36
38
 
37
39
  // Keep in sync with cli/package.json.
38
- export const VERSION = "0.5.1";
40
+ export const VERSION = "0.7.0";
39
41
 
40
42
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
41
43
  // can't accidentally swallow the id as --json's value.
@@ -198,6 +200,14 @@ Strategy & onboarding:
198
200
  strategy topics set --collection <uuid> (--topic <topic-id> | --suggestion <suggestion-id>)
199
201
  onboarding status Show the onboarding checklist
200
202
  onboarding step [--current <step>] [--complete <step>] [--skip <step>] [--status <status>]
203
+ onboarding magic [--domain <domain>] [--sitemap <url>] [--collection <uuid>] [--dry-run]
204
+ Enter a domain -> strategy filled out:
205
+ manifesto+stances, optional history import,
206
+ clusters, detected series (see below)
207
+ series list The org's content series + coverage state
208
+ series propose [--collection <uuid>] Detect series existing posts already imply
209
+ series create --name <n> --description <d> [--coverage a,b,c] [--collection <uuid>] [--template <key>]
210
+ series backfill <series-id> [--collection <uuid>] Reconstruct a series from existing posts
201
211
 
202
212
  Research agent (deep research -> outline written into the post):
203
213
  research start --article <uuid> [--topic <text>] [--url <url> …] [--must-include <text> …]
@@ -258,6 +268,14 @@ Content templates (compose structures seers bind via config.template_key):
258
268
  templates update <template-id> [--name] [--angle] [--sections '<json>'|--sections-file <path>]
259
269
  templates delete <template-id> --yes
260
270
 
271
+ Shredder (diversify content produced outside Letterstory; structure preserved):
272
+ shredder list Your shredder endpoints + run counts
273
+ shredder create --name <n> New endpoint (returns a callable public id)
274
+ shredder delete <endpoint-id> --yes
275
+ shredder run <endpoint-id> (--body <text>|--file <path>|--file -) [--coverage <0..1>] [--attempts <n>]
276
+ Shred prose through an endpoint (logs telemetry)
277
+ shredder runs [--endpoint <id>] [--limit <n>] Recent shred calls (telemetry), newest first
278
+
261
279
  Insights:
262
280
  insights site [--period 14d|30d|90d] [--collection <uuid>]
263
281
  insights post <article-id> [--period 14d|30d|90d]
@@ -313,6 +331,7 @@ const CLIENT_COMMANDS = {
313
331
  connectors: cmdConnectors,
314
332
  strategy: cmdStrategy,
315
333
  onboarding: cmdOnboarding,
334
+ series: cmdSeries,
316
335
  insights: cmdInsights,
317
336
  research: cmdResearch,
318
337
  covers: cmdCovers,
@@ -320,6 +339,7 @@ const CLIENT_COMMANDS = {
320
339
  "phantom-job": cmdPhantomJob,
321
340
  seers: cmdSeers,
322
341
  templates: cmdTemplates,
342
+ shredder: cmdShredder,
323
343
  };
324
344
 
325
345
  // LETTERSTORY_POLL_INTERVAL_MS / LETTERSTORY_MAX_POLLS let an operator (or an
@@ -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
+ }
@@ -0,0 +1,99 @@
1
+ // `shredder` — create shredder endpoints and run content produced OUTSIDE Letterstory
2
+ // through the cross-provider shredder. A shred rewrites each sentence across multiple
3
+ // LLM providers to diversify, leaving structure (headings, lists, tables) untouched, and
4
+ // logs every call to telemetry.
5
+
6
+ import { CliError } from "../client.mjs";
7
+ import {
8
+ flagStr,
9
+ flagNum,
10
+ requireFlag,
11
+ requirePositional,
12
+ readBodyInput,
13
+ printResult,
14
+ compact,
15
+ ok,
16
+ } from "./shared.mjs";
17
+
18
+ export async function cmdShredder(ctx) {
19
+ const sub = ctx.positionals[0];
20
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
21
+ switch (sub) {
22
+ case "list":
23
+ case "ls":
24
+ return shredderList(rest);
25
+ case "new":
26
+ case "create":
27
+ return shredderCreate(rest);
28
+ case "delete":
29
+ case "rm":
30
+ return shredderDelete(rest);
31
+ case "run":
32
+ case "shred":
33
+ return shredderRun(rest);
34
+ case "runs":
35
+ case "telemetry":
36
+ return shredderRuns(rest);
37
+ default:
38
+ throw new CliError(`Unknown shredder subcommand: ${sub ?? "(none)"}. Try: list, create, delete, run, runs`);
39
+ }
40
+ }
41
+
42
+ async function shredderList(ctx) {
43
+ const { client, flags, io } = ctx;
44
+ const result = await client.callTool("list_shredder_endpoints", {});
45
+ printResult(io, flags, result);
46
+ return 0;
47
+ }
48
+
49
+ async function shredderCreate(ctx) {
50
+ const { client, flags, io } = ctx;
51
+ const name = requireFlag(flags, "name");
52
+ const result = await client.callTool("create_shredder_endpoint", { name });
53
+ ok(ctx, `Created shredder endpoint "${name}".`);
54
+ printResult(io, flags, result);
55
+ return 0;
56
+ }
57
+
58
+ async function shredderDelete(ctx) {
59
+ const { client, positionals, flags, io } = ctx;
60
+ const id = requirePositional(positionals, 0, "endpoint-id");
61
+ if (!flags.yes) {
62
+ io.error(`Re-run with --yes to confirm: ${ctx.bin} shredder delete ${id} --yes`);
63
+ return 1;
64
+ }
65
+ const result = await client.callTool("delete_shredder_endpoint", { endpoint_id: id });
66
+ ok(ctx, "Shredder endpoint deleted.");
67
+ printResult(io, flags, result);
68
+ return 0;
69
+ }
70
+
71
+ async function shredderRun(ctx) {
72
+ const { client, positionals, flags, io } = ctx;
73
+ const id = requirePositional(positionals, 0, "endpoint-id");
74
+ const text = readBodyInput(flags);
75
+ if (text === undefined) {
76
+ throw new CliError("Provide the prose to shred with --body <text>, --file <path>, or --file - (stdin).");
77
+ }
78
+ const args = compact({
79
+ endpoint_id: id,
80
+ text,
81
+ coverage: flagNum(flags.coverage),
82
+ attempts_per_unit: flagNum(flags.attempts),
83
+ });
84
+ const result = await client.callTool("run_shred", args);
85
+ ok(ctx, "Shred complete.");
86
+ printResult(io, flags, result);
87
+ return 0;
88
+ }
89
+
90
+ async function shredderRuns(ctx) {
91
+ const { client, flags, io } = ctx;
92
+ const args = compact({
93
+ endpoint_id: flagStr(flags.endpoint),
94
+ limit: flagNum(flags.limit),
95
+ });
96
+ const result = await client.callTool("list_shred_runs", args);
97
+ printResult(io, flags, result);
98
+ return 0;
99
+ }
@@ -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,9 +14,11 @@ 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";
20
21
  export * from "./commands/kernel.mjs";
21
22
  export * from "./commands/phantom-job.mjs";
22
23
  export * from "./commands/seers.mjs";
24
+ export * from "./commands/shredder.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",