@letterstory/cli 0.134.1 → 0.136.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
@@ -254,6 +254,7 @@ letterstory strategy company set --name "Acme" --domain acme.com --manifesto "
254
254
 
255
255
  letterstory strategy positioning get
256
256
  letterstory strategy positioning set --topic seo --topic ai --stance "…" --clear-avoid
257
+ letterstory strategy positioning set --company-kind devtools --stage seed --motion product-led --category "AI visibility" # the company model; `none` clears a field
257
258
 
258
259
  letterstory strategy competitors list
259
260
  letterstory strategy competitors add "Rival Co" rival.com
package/lib/cli.mjs CHANGED
@@ -62,7 +62,7 @@ import {
62
62
  } from "./commands.mjs";
63
63
 
64
64
  // Keep in sync with cli/package.json.
65
- export const VERSION = "0.134.1";
65
+ export const VERSION = "0.136.0";
66
66
 
67
67
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
68
68
  // can't accidentally swallow the id as --json's value. Every `type: "bool"` flag (and its
@@ -288,6 +288,8 @@ export const BOOLEAN_FLAGS = new Set([
288
288
  "todo",
289
289
  // admin spend margin: list nested work (never billed twice) beside its parent.
290
290
  "nested",
291
+ // admin billing lago catalog --apply --include-drafts
292
+ "include-drafts",
291
293
  ]);
292
294
 
293
295
  // Tiny argv parser: `--flag value`, `--flag=value`, boolean `--flag`, and positionals.
@@ -908,6 +910,13 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
908
910
  cover what the app ledger cannot see; a CSV
909
911
  import sums Concentrate's log export.
910
912
  Needs spend:read (reads) / spend:write (writes).
913
+ admin billing lago catalog [--apply] [--include-drafts]
914
+ | provision <org-id> --plan starter|growth|scale [--ratio 1|2|4|8 (8 needs --why "…")] [--complimentary N] [--stripe-customer cus_…] [--start ISO] [--review D]
915
+ | reconcile [--org <org-id>] | forward
916
+ Usage billing on Lago: the plan catalog, an org's
917
+ plan and gifted ratio, Lago's tokens against ours,
918
+ and forwarding usage now. Needs spend:read (reads)
919
+ / billing:write (writes).
911
920
  admin crm import <file.csv> [--source <name>] [--tag "…"] [--dry-run]
912
921
  | capture [shot.png …] [--text "…" | --text-file f] [--context "this is for acme.com"]
913
922
  [--account <id>] [--dry-run] [--retry] screenshots / a thread / a note → a lead
@@ -1372,11 +1381,13 @@ Repo changes (Claude Code edits your repo in a sandbox; the Letterstory GitHub A
1372
1381
  [--branch <base>] [--attach <path>]... [--attach-url <name>=<https url>]...
1373
1382
  [--attach-ref <path>]... [--attach-url-ref <name>=<https url>]...
1374
1383
  [--action build|build_pr|build_merge|slack|linear] [--with-preview]
1375
- [--harness claude-code|letterpress-1] [--wait]
1384
+ [--harness claude-code|letterpress-1] [--verifier|--no-verifier] [--wait]
1376
1385
  Start a change (attached files go to the sandbox;
1377
1386
  -ref ones are context, never put in the repo);
1378
1387
  --with-preview keeps a preview up next to the PR;
1379
1388
  --harness picks the coding agent (default: the deployment's);
1389
+ --verifier reviews the change and has the agent fix
1390
+ what's blocking before it goes out;
1380
1391
  --wait follows it to the PR
1381
1392
  repo fix-audit --issue <issue-type>... [--audit <id>] [--branch <base>] [--action build|build_pr|build_merge|slack|linear] [--wait]
1382
1393
  One PR fixing site-audit issues (repeat --issue to bundle) on every affected page,
@@ -4996,12 +4996,88 @@ async function cmdAdminRecipes(ctx) {
4996
4996
  }
4997
4997
  }
4998
4998
 
4999
+ const LAGO = "/api/superadmin/billing/lago";
5000
+ const LAGO_USAGE = `Usage: admin billing lago <command>
5001
+ catalog [--apply] [--include-drafts] what the plan catalog would change in Lago; --apply makes it so
5002
+ provision <org-id> --plan starter [--ratio 4] [--why "…" above 4x] [--complimentary 20000] [--stripe-customer cus_…] [--start 2026-11-02T00:00:00Z] [--review 2027-05-01]
5003
+ reconcile [--org <org-id>] Lago's tokens this period against what we rated and forwarded
5004
+ forward forward billable usage to Lago now (the cron's pass)`;
5005
+
5006
+ /** admin billing lago … — staff operations on Lago (docs/billing/lago-integration.md). */
5007
+ async function cmdAdminBilling(ctx) {
5008
+ const { flags, positionals, io } = ctx;
5009
+ if (positionals[0] !== "lago") throw new CliError(LAGO_USAGE);
5010
+ const sub = positionals[1];
5011
+ switch (sub) {
5012
+ case "catalog": {
5013
+ const result = flagBool(flags.apply)
5014
+ ? await staffPost(
5015
+ ctx,
5016
+ `${LAGO}/catalog`,
5017
+ compact({ includeDrafts: flagBool(flags["include-drafts"]) || undefined })
5018
+ )
5019
+ : await staffRequest(ctx, "GET", `${LAGO}/catalog`);
5020
+ const changed = (result.changes ?? []).filter((c) => c.action === "create" || c.action === "update");
5021
+ ok(
5022
+ ctx,
5023
+ `${result.dryRun ? "Would change" : "Changed"} ${changed.length} of ${(result.changes ?? []).length}.`
5024
+ );
5025
+ printResult(io, flags, result);
5026
+ return 0;
5027
+ }
5028
+ case "provision": {
5029
+ const orgId = requirePositional(positionals, 2, "org-id");
5030
+ const plan = flagStr(flags.plan);
5031
+ if (!plan) throw new CliError(LAGO_USAGE);
5032
+ const result = await staffPost(
5033
+ ctx,
5034
+ `${LAGO}/provision`,
5035
+ compact({
5036
+ orgId,
5037
+ planCode: plan,
5038
+ ratio: flagNum(flags.ratio),
5039
+ exceptionReason: flagStr(flags.why),
5040
+ complimentaryTokens: flagNum(flags.complimentary),
5041
+ stripeCustomerId: flagStr(flags["stripe-customer"]),
5042
+ startAt: flagStr(flags.start),
5043
+ reviewAt: flagStr(flags.review),
5044
+ })
5045
+ );
5046
+ ok(
5047
+ ctx,
5048
+ `${result.action === "created" ? "On Lago" : "Already on Lago"}: ${result.subscriptionId}, ${result.includedTokens} tokens included, overage $${result.overageUsdPerToken} a token, from ${result.startsAt}.`
5049
+ );
5050
+ printResult(io, flags, result);
5051
+ return 0;
5052
+ }
5053
+ case "reconcile": {
5054
+ const params = new URLSearchParams(compact({ orgId: flagStr(flags.org) }));
5055
+ const result = await staffRequest(ctx, "GET", `${LAGO}/reconcile${params.size ? `?${params}` : ""}`);
5056
+ ok(ctx, result.ok ? "Lago agrees with us." : "Lago and we disagree: see the orgs below.");
5057
+ printResult(io, flags, result);
5058
+ return result.ok ? 0 : 1;
5059
+ }
5060
+ case "forward": {
5061
+ const result = await staffPost(ctx, `${LAGO}/forward`, {});
5062
+ ok(
5063
+ ctx,
5064
+ `Sent ${result.sent}, already there ${result.duplicates}, held ${result.held}, refused ${result.refused}.`
5065
+ );
5066
+ printResult(io, flags, result);
5067
+ return 0;
5068
+ }
5069
+ default:
5070
+ throw new CliError(LAGO_USAGE);
5071
+ }
5072
+ }
5073
+
4999
5074
  export async function cmdAdmin(ctx) {
5000
5075
  const sub = ctx.positionals[0];
5001
5076
  if (sub === "replays" || sub === "replay")
5002
5077
  return cmdAdminReplays({ ...ctx, positionals: ctx.positionals.slice(1) }, sub);
5003
5078
  if (sub === "crm") return cmdAdminCrm({ ...ctx, positionals: ctx.positionals.slice(1) });
5004
5079
  if (sub === "spend") return cmdAdminSpend({ ...ctx, positionals: ctx.positionals.slice(1) });
5080
+ if (sub === "billing") return cmdAdminBilling({ ...ctx, positionals: ctx.positionals.slice(1) });
5005
5081
  if (sub === "models") return cmdAdminModels({ ...ctx, positionals: ctx.positionals.slice(1) });
5006
5082
  if (sub === "create-org") return cmdAdminCreateOrg({ ...ctx, positionals: ctx.positionals.slice(1) });
5007
5083
  if (sub === "ask") return cmdAdminAsk({ ...ctx, positionals: ctx.positionals.slice(1) });
@@ -110,6 +110,10 @@ export const ARGS = {
110
110
  ...fields,
111
111
  },
112
112
  list_generator_starters: {},
113
+ write_generator_steps: {
114
+ generator_id: { positional: 0, label: "enclosure-id", required: true },
115
+ prospect_id: "prospect",
116
+ },
113
117
  get_generator_version: {
114
118
  generator_id: { positional: 0, label: "enclosure-id", required: true },
115
119
  version: { value: (_flags, positionals) => Number(requirePositional(positionals, 1, "version")) },
@@ -177,6 +181,7 @@ const USAGE = `Usage: enclosures <list|show|create|update|prints>
177
181
  enclosures prints <enclosure-id> [--status done|failed|no_fit|…] [--limit <n>]
178
182
  enclosures starters
179
183
  enclosures create --starter site_audit --login <app-id> Begin from a starter (its version 1 included)
184
+ enclosures write-steps <enclosure-id> [--prospect <id>] Claude Code writes its steps from its description
180
185
  enclosures version <enclosure-id> <n> One version in full: its steps and the script prints replay
181
186
  enclosures add-version <enclosure-id> (--script <code>|--script-file <path>) --step <text>… [--host <h>…] [--summary <s>]
182
187
  enclosures approve <enclosure-id> --version <n>
@@ -208,6 +213,8 @@ export async function cmdEnclosures(ctx) {
208
213
  return enclosuresStarters(rest);
209
214
  case "version":
210
215
  return enclosuresVersion(rest);
216
+ case "write-steps":
217
+ return enclosuresWriteSteps(rest);
211
218
  case "add-version":
212
219
  return enclosuresAddVersion(rest);
213
220
  case "approve":
@@ -353,6 +360,18 @@ async function enclosuresStarters(ctx) {
353
360
  return 0;
354
361
  }
355
362
 
363
+ async function enclosuresWriteSteps(ctx) {
364
+ const { client, flags, positionals, io } = ctx;
365
+ refuseUnknownFlags(ctx, ARGS.write_generator_steps, []);
366
+ const result = await client.callTool(
367
+ "write_generator_steps",
368
+ argsFromFlags(ARGS.write_generator_steps, flags, positionals)
369
+ );
370
+ ok(ctx, "Claude Code is writing the steps. It takes a few minutes: watch it with `enclosures show`.");
371
+ printResult(io, flags, result, (r) => `run ${r.run.id} (${r.run.phase}), budget $${r.run.budget_usd}`);
372
+ return 0;
373
+ }
374
+
356
375
  async function enclosuresVersion(ctx) {
357
376
  const { client, flags, positionals, io } = ctx;
358
377
  refuseUnknownFlags(ctx, ARGS.get_generator_version, []);
@@ -68,6 +68,8 @@ const ARGS = {
68
68
  with_preview: { flag: "with-preview", type: "bool" },
69
69
  // --harness claude-code|letterpress-1: the coding agent; omitted, the deployment's default.
70
70
  harness: { flag: "harness", enum: ["claude-code", "letterpress-1"] },
71
+ // --verifier / --no-verifier: review the change before it goes out; omitted, the deployment's choice.
72
+ verifier: { value: (flags) => (flags["no-verifier"] ? false : flags.verifier ? true : undefined) },
71
73
  },
72
74
  create_repo_change_upload: { file_names: { internal: "the basenames of the --attach files" } },
73
75
  list_repo_sites: {},
@@ -341,6 +343,8 @@ async function repoStart(ctx) {
341
343
  "preview",
342
344
  "with-preview",
343
345
  "harness",
346
+ "verifier",
347
+ "no-verifier",
344
348
  "wait",
345
349
  ]);
346
350
  // One upload for every file (names must be unique across both), then the references marked.
@@ -2,6 +2,7 @@
2
2
  // (src/lib/site). One subcommand per tool:
3
3
  // site list → list_sites site map <host> → map_site
4
4
  // site tree <host> [path] → get_site_tree site page <host> <path> → get_site_page
5
+ // site builds <host> → list_build_suggestions (--limit n)
5
6
  // site metrics <host> [path] → get_site_metrics (--window 7d|28d|90d, --page <path>…, --no-compare)
6
7
  // site observations <host> → list_site_observations site observe <host> → observe_site
7
8
  // site dismiss <host> <id> → dismiss_site_observation
@@ -78,6 +79,10 @@ const ARGS = {
78
79
  },
79
80
  },
80
81
  },
82
+ list_build_suggestions: {
83
+ host: { positional: 0, required: true, label: "host" },
84
+ limit: { flag: "limit", type: "num" },
85
+ },
81
86
  get_site_metrics: {
82
87
  host: { positional: 0, required: true, label: "host" },
83
88
  path: { positional: 1, label: "path" },
@@ -199,6 +204,8 @@ export async function cmdSite(ctx) {
199
204
  return siteUnmap(rest);
200
205
  case "tree":
201
206
  return siteTree(rest);
207
+ case "builds":
208
+ return siteBuilds(rest);
202
209
  case "metrics":
203
210
  return siteMetrics(rest);
204
211
  case "page":
@@ -229,7 +236,7 @@ export async function cmdSite(ctx) {
229
236
  return siteHighlight(rest);
230
237
  default:
231
238
  throw new CliError(
232
- `Unknown site subcommand: ${sub ?? "(none)"}. Try: list, map, refresh, unmap, tree, metrics, page, observations, observe, dismiss, families, decide-family, proposals, propose, decide, fix, competitors, watch-competitors, highlight`
239
+ `Unknown site subcommand: ${sub ?? "(none)"}. Try: list, map, refresh, unmap, tree, builds, metrics, page, observations, observe, dismiss, families, decide-family, proposals, propose, decide, fix, competitors, watch-competitors, highlight`
233
240
  );
234
241
  }
235
242
  }
@@ -243,7 +250,26 @@ function describeNode(node, indent = "") {
243
250
  : "";
244
251
  const changed = `${node.rollup.added ? ` +${node.rollup.added}` : ""}${node.rollup.removed ? ` -${node.rollup.removed}` : ""}`;
245
252
  const counts = node.hasChildren ? ` (${node.rollup.pages} pages${changed})` : node.page?.added ? " (new)" : "";
246
- const out = [`${indent}${glyph.padEnd(2)} ${node.name}${counts}${line ? ` ${line}` : ""}`];
253
+ // A folder's measured pages, only where the source is measured (null draws nothing: never zero).
254
+ const m = node.rollup.measured ?? {};
255
+ const measured = [
256
+ m.indexed != null ? `${m.indexed} indexed` : null,
257
+ m.cited != null ? `${m.cited} cited` : null,
258
+ m.read != null ? `${m.read} read` : null,
259
+ ].filter(Boolean);
260
+ const coverage = node.rollup.coverage
261
+ ? `${node.rollup.coverage.covered} of ${node.rollup.coverage.total} covered`
262
+ : null;
263
+ const rollup =
264
+ node.hasChildren && (measured.length || coverage)
265
+ ? ` [${[...measured, coverage].filter(Boolean).join(" · ")}]`
266
+ : "";
267
+ const out = [`${indent}${glyph.padEnd(2)} ${node.name}${counts}${rollup}${line ? ` ${line}` : ""}`];
268
+ // What is next in the pattern the folder keeps: violet on the Site, its state the letterbase's.
269
+ if (node.next) {
270
+ const owner = node.next.owner ? `${node.next.owner.name} · ${node.next.state}` : "next in the pattern";
271
+ out.push(`${indent} ◇ ${node.next.path ?? node.next.title} ${node.next.why} (${owner})`);
272
+ }
247
273
  for (const child of node.children ?? []) out.push(describeNode(child, `${indent} `));
248
274
  return out.join("\n");
249
275
  }
@@ -366,6 +392,25 @@ async function siteTree(ctx) {
366
392
  return 0;
367
393
  }
368
394
 
395
+ async function siteBuilds(ctx) {
396
+ const { client, flags, io } = ctx;
397
+ refuseUnknownFlags(ctx, ARGS.list_build_suggestions);
398
+ const result = await client.callTool(
399
+ "list_build_suggestions",
400
+ argsFromFlags(ARGS.list_build_suggestions, flags, ctx.positionals)
401
+ );
402
+ printResult(io, flags, result, (r) => {
403
+ const lines = r.suggestions.map(
404
+ (s) =>
405
+ `${s.summary} [${s.sources.join(", ")}]${s.action === "article" ? " (an article: the letterbase's)" : ""}\n ${s.relevance}\n ${s.id}`
406
+ );
407
+ if (!lines.length) lines.push("Nothing to build: no source makes a surface this site's right now.");
408
+ if (r.skipped.length) lines.push(`Not read: ${r.skipped.join(", ")}`);
409
+ return lines.join("\n");
410
+ });
411
+ return 0;
412
+ }
413
+
369
414
  async function siteMetrics(ctx) {
370
415
  const { client, flags, io } = ctx;
371
416
  refuseUnknownFlags(ctx, ARGS.get_site_metrics);
@@ -16,6 +16,18 @@ const clearableList = (flag, clearFlag) => ({
16
16
  },
17
17
  });
18
18
 
19
+ // A scalar the API clears with an explicit null: `--stage none` sends null, a value sends it
20
+ // (one of `values` when given), nothing sends nothing.
21
+ const clearable = (flag, values) => ({
22
+ value: (flags) => {
23
+ const v = flagStr(flags[flag]);
24
+ if (v === undefined || v === null || v === "") return undefined;
25
+ if (v === "none") return null;
26
+ if (values && !values.includes(v)) throw new CliError(`--${flag} must be one of: ${values.join(", ")}, none`);
27
+ return v;
28
+ },
29
+ });
30
+
19
31
  // How every tool this module calls gets its arguments — see `argsFromFlags` in shared.mjs.
20
32
  // `scripts/parity-check.ts` compares these keys against each tool's published input schema.
21
33
  export const ARGS = {
@@ -40,6 +52,21 @@ export const ARGS = {
40
52
  priority_topics: clearableList("topic", "clear-topics"),
41
53
  stances: clearableList("stance", "clear-stances"),
42
54
  avoid_topics: clearableList("avoid", "clear-avoid"),
55
+ // The company model (lib/positioning/company-model.ts): a vocabulary word, or "none" to clear.
56
+ company_kind: clearable("company-kind", [
57
+ "b2b_saas",
58
+ "devtools",
59
+ "ai_product",
60
+ "ecommerce",
61
+ "marketplace",
62
+ "agency",
63
+ "consumer",
64
+ "media",
65
+ "local_business",
66
+ ]),
67
+ stage: clearable("stage", ["pre-seed", "seed", "series-a-plus", "established"]),
68
+ motion: clearable("motion", ["self-serve", "sales-led", "product-led", "open-source"]),
69
+ category: clearable("category"),
43
70
  },
44
71
  list_competitors: {},
45
72
  add_competitor: {
@@ -155,7 +182,9 @@ async function positioningSet(ctx) {
155
182
  const { client, flags, io } = ctx;
156
183
  const args = argsFromFlags(ARGS.set_positioning, flags);
157
184
  if (Object.keys(args).length === 0) {
158
- throw new CliError("Pass at least one --topic/--stance/--avoid (or a --clear-* flag).");
185
+ throw new CliError(
186
+ "Pass at least one --topic/--stance/--avoid (or a --clear-* flag), or --company-kind/--stage/--motion/--category (none clears)."
187
+ );
159
188
  }
160
189
  const result = await client.callTool("set_positioning", args);
161
190
  ok(ctx, "Positioning updated.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.134.1",
3
+ "version": "0.136.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",