@letterstory/cli 0.146.0 → 0.147.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
@@ -442,6 +442,18 @@ letterstory admin recipes save --name "Get cited" --prompt "…" --goals geo --c
442
442
  letterstory admin recipes save <id> --disable # retire one (delete <id> --yes removes it)
443
443
  ```
444
444
 
445
+ ### Staff: Build's suggestion recipes
446
+
447
+ ```bash
448
+ # The same staff key (recipes:read / recipes:write). Build's suggestion recipes are plain-language
449
+ # ways Letterpress finds what a site should build: what to look at, what an obvious yes is, what is
450
+ # not one, an example. Guidance, never rules: each proposal still cites evidence and passes Jev.
451
+ letterstory admin suggestion-recipes export --out suggestion-recipes.json
452
+ letterstory admin suggestion-recipes import suggestion-recipes.json --dry-run
453
+ letterstory admin suggestion-recipes save --name "Docs it already writes" --look-at "…" --obvious-yes "…"
454
+ letterstory admin suggestion-recipes stats # per recipe: shown, dismissed, and why
455
+ ```
456
+
445
457
  ### Staff: the CRM pipeline
446
458
 
447
459
  ```bash
package/lib/cli.mjs CHANGED
@@ -65,7 +65,7 @@ import {
65
65
 
66
66
  // Matches cli/package.json; publish-cli.yml rewrites both to the published version.
67
67
  // Matches cli/package.json; publish-cli.yml rewrites both to the published version.
68
- export const VERSION = "0.146.0";
68
+ export const VERSION = "0.147.0";
69
69
 
70
70
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
71
71
  // can't accidentally swallow the id as --json's value. Every `type: "bool"` flag (and its
@@ -844,6 +844,15 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
844
844
  [--primitives a,b|none] [--goals a,b|none] [--company-types a,b|none] [--enable|--disable] [--position n]
845
845
  Create one (no id) or change one. Needs recipes:write.
846
846
  admin recipes delete <id> --yes Delete for good (save --disable retires and keeps it).
847
+ admin suggestion-recipes list|export [--out f.json]|import <f.json> [--dry-run] [--retire-missing]|stats
848
+ Build's suggestion recipes: plain-language ways
849
+ Letterpress finds what a site should build (look at,
850
+ obvious yes, not a yes, example). stats: per recipe,
851
+ suggestions shown and dismissed, with the reasons.
852
+ admin suggestion-recipes save [<id>] [--file r.json] [--name …] [--look-at …] [--obvious-yes …] [--not-a-yes …]
853
+ [--example …] [--company-types a,b|none] [--enable|--disable] [--position n]
854
+ Create one (no id) or change one. Needs recipes:write.
855
+ admin suggestion-recipes delete <id> --yes Delete for good (save --disable retires and keeps it).
847
856
  admin brand-detection <org-id> [--term <t>]... [--exact <t>]... [--exclude <phrase>]... [--note "…"] | --clear [--note "…"]
848
857
  How a client's brand is detected in AI answers, with
849
858
  every earlier setting; with flags, set the exact set
@@ -936,7 +945,7 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
936
945
  | reconcile [--org <org-id>] | forward
937
946
  | payments [--org <org-id>] [--open] | payments retry <lago-invoice-id> | payments run [--dry]
938
947
  | signups [--status open|card_saved|provisioned|paid|abandoned]
939
- | cap <org-id> <dollars|off> [--why "…"]
948
+ | cap <org-id> <dollars|off> [--why "…"] | cancel <org-id> --why "…" --yes
940
949
  Usage billing on Lago: the plan catalog, an org's
941
950
  plan and gifted ratio, Lago's tokens against ours,
942
951
  forwarding usage now, failed payments and the
@@ -1094,7 +1103,7 @@ Seers (event-driven signals -> drafts):
1094
1103
  org export --split <dir> …as one file per record (for keeping config in git)
1095
1104
  org join <dir> [--file <path>] Rebuild the bundle from a --split directory
1096
1105
  org integrations Every integration and whether it's connected
1097
- org billing [cap <dollars|off> [--why "…"] | request [--note "…"]]
1106
+ org billing [cap <dollars|off> [--why "…"] | request [--note "…"] | cancel [--why "…"] --yes]
1098
1107
  Usage billing: plan, this period against the
1099
1108
  allowance and spend cap, failed payments; set
1100
1109
  the cap (admins) or ask the admins for more
@@ -4885,6 +4885,129 @@ async function cmdAdminRepublish(ctx) {
4885
4885
  return failed.length ? 1 : 0;
4886
4886
  }
4887
4887
 
4888
+ const SUGGESTION_RECIPES = "/api/superadmin/suggestion-recipes";
4889
+ const SUGGESTION_RECIPE_FIELDS = [
4890
+ "id",
4891
+ "name",
4892
+ "look_at",
4893
+ "obvious_yes",
4894
+ "not_a_yes",
4895
+ "example",
4896
+ "company_types",
4897
+ "enabled",
4898
+ "position",
4899
+ ];
4900
+ const SUGGESTION_RECIPES_USAGE =
4901
+ "Usage: admin suggestion-recipes list | export [--out <file>] | import <file> [--dry-run] [--retire-missing] | save [<id>] [--name …] [--look-at …] [--obvious-yes …] [--not-a-yes …] [--example …] [--company-types a,b|none] [--enable|--disable] [--position n] | delete <id> --yes | stats";
4902
+
4903
+ const suggestionRecipeForFile = (r) => Object.fromEntries(SUGGESTION_RECIPE_FIELDS.map((k) => [k, r[k]]));
4904
+
4905
+ /**
4906
+ * `admin suggestion-recipes …` — the ways Build's suggestion pass looks for what a site should
4907
+ * build (src/lib/suggestion-recipes): guidance Letterpress reads, never rules. The loop is
4908
+ * `export --out recipes.json` → edit → `import recipes.json --dry-run` → `import recipes.json`,
4909
+ * and `stats` says how each recipe's suggestions fare (shown, dismissed, why). Reads need
4910
+ * recipes:read on the staff key, writes recipes:write.
4911
+ */
4912
+ async function cmdAdminSuggestionRecipes(ctx) {
4913
+ const { flags, positionals, io } = ctx;
4914
+ const all = async () => (await staffRequest(ctx, "GET", SUGGESTION_RECIPES)).recipes ?? [];
4915
+ switch (positionals[0]) {
4916
+ case "list": {
4917
+ const recipes = await all();
4918
+ printResult(io, flags, { recipes }, (r) =>
4919
+ r.recipes.length
4920
+ ? r.recipes
4921
+ .map(
4922
+ (x) =>
4923
+ `${String(x.position).padStart(4)} ${x.enabled ? "on " : "off"} ${x.name} [${x.company_types?.length ? x.company_types.join(",") : "every kind"}] ${x.id}`
4924
+ )
4925
+ .join("\n")
4926
+ : "No recipes yet."
4927
+ );
4928
+ return 0;
4929
+ }
4930
+ case "export": {
4931
+ const file = { recipes: (await all()).map(suggestionRecipeForFile) };
4932
+ const out = flagStr(flags.out);
4933
+ if (out) {
4934
+ writeFileSync(out, `${JSON.stringify(file, null, 2)}\n`);
4935
+ ok(ctx, `Wrote ${file.recipes.length} recipe(s) to ${out}.`);
4936
+ } else io.log(JSON.stringify(file, null, 2));
4937
+ return 0;
4938
+ }
4939
+ case "import": {
4940
+ const path = requirePositional(positionals, 1, "file");
4941
+ const parsed = readJson(path);
4942
+ const recipes = Array.isArray(parsed) ? parsed : parsed?.recipes;
4943
+ if (!Array.isArray(recipes)) throw new CliError(`${path}: expected { "recipes": [...] } or an array`);
4944
+ const result = await staffPost(ctx, `${SUGGESTION_RECIPES}/import`, {
4945
+ recipes: recipes.map((r) => compact(suggestionRecipeForFile(r))),
4946
+ dry_run: flagBool(flags["dry-run"]),
4947
+ retire_missing: flagBool(flags["retire-missing"]),
4948
+ });
4949
+ printResult(io, flags, result, (r) =>
4950
+ [
4951
+ ...r.results.map(
4952
+ (row) =>
4953
+ `${row.action.padEnd(9)} ${row.name}${row.changed.length && row.action !== "create" ? ` (${row.changed.join(", ")})` : ""}${row.error ? ` → ${row.error}` : ""}`
4954
+ ),
4955
+ r.dry_run ? "Nothing was written (--dry-run)." : "",
4956
+ ]
4957
+ .filter(Boolean)
4958
+ .join("\n")
4959
+ );
4960
+ return result.results?.some((r) => r.action === "failed") ? 1 : 0;
4961
+ }
4962
+ case "save": {
4963
+ const fields = compact({
4964
+ name: flagStr(flags.name),
4965
+ look_at: flagStr(flags["look-at"]),
4966
+ obvious_yes: flagStr(flags["obvious-yes"]),
4967
+ not_a_yes: flagStr(flags["not-a-yes"]),
4968
+ example: flagStr(flags.example),
4969
+ company_types: recipeListFlag(flags, "company-types"),
4970
+ enabled: flagBool(flags.disable) ? false : flagBool(flags.enable) ? true : undefined,
4971
+ position: flagNum(flags.position),
4972
+ });
4973
+ const id = positionals[1];
4974
+ const result = id
4975
+ ? await staffRequest(ctx, "PATCH", `${SUGGESTION_RECIPES}/${encodeURIComponent(id)}`, fields)
4976
+ : await staffPost(ctx, SUGGESTION_RECIPES, fields);
4977
+ ok(ctx, `${result.created ? "Created" : "Saved"} "${result.recipe.name}" (${result.recipe.id}).`);
4978
+ printResult(io, flags, result);
4979
+ return 0;
4980
+ }
4981
+ case "delete": {
4982
+ const id = requirePositional(positionals, 1, "id");
4983
+ if (!flagBool(flags.yes)) {
4984
+ io.error(`Re-run with --yes to delete for good (save ${id} --disable retires one and keeps it).`);
4985
+ return 1;
4986
+ }
4987
+ const result = await staffRequest(ctx, "DELETE", `${SUGGESTION_RECIPES}/${encodeURIComponent(id)}`);
4988
+ ok(ctx, `Deleted ${id}.`);
4989
+ printResult(io, flags, result);
4990
+ return 0;
4991
+ }
4992
+ case "stats": {
4993
+ const result = await staffRequest(ctx, "GET", `${SUGGESTION_RECIPES}/stats`);
4994
+ printResult(io, flags, result, (r) =>
4995
+ r.stats.length
4996
+ ? r.stats
4997
+ .map(
4998
+ (s) =>
4999
+ `${String(s.shown).padStart(5)} shown ${String(s.dismissed).padStart(4)} dismissed ${s.recipe}${s.reasons.length ? `\n why: ${s.reasons.slice(0, 3).join(" · ")}` : ""}`
5000
+ )
5001
+ .join("\n")
5002
+ : "No suggestions from the pass yet."
5003
+ );
5004
+ return 0;
5005
+ }
5006
+ default:
5007
+ throw new CliError(SUGGESTION_RECIPES_USAGE);
5008
+ }
5009
+ }
5010
+
4888
5011
  const RECIPES = "/api/superadmin/plan-recipes";
4889
5012
  /** The fields a recipe file carries: what import_plan_recipes reads back. */
4890
5013
  const RECIPE_FILE_FIELDS = [
@@ -5090,7 +5213,8 @@ const LAGO_USAGE = `Usage: admin billing lago <command>
5090
5213
  payments retry <lago-invoice-id> charge one failed invoice again now
5091
5214
  payments run [--dry] one pass of the failed-payment loop now (--dry: report only)
5092
5215
  signups [--status open|card_saved|provisioned|paid|abandoned] new paying orgs on their way onto Lago
5093
- cap <org-id> <dollars|off> [--why "…"] set an account's monthly spend cap on overage (off: no cap)`;
5216
+ cap <org-id> <dollars|off> [--why "…"] set an account's monthly spend cap on overage (off: no cap)
5217
+ cancel <org-id> --why "…" --yes end an account's Lago plan now and refund its first invoice`;
5094
5218
 
5095
5219
  /** admin billing lago … — staff operations on Lago (docs/billing/lago-integration.md). */
5096
5220
  async function cmdAdminBilling(ctx) {
@@ -5213,6 +5337,23 @@ async function cmdAdminBilling(ctx) {
5213
5337
  );
5214
5338
  return 0;
5215
5339
  }
5340
+ case "cancel": {
5341
+ const orgId = positionals[2];
5342
+ if (!orgId || !flagStr(flags.why)) throw new CliError(LAGO_USAGE);
5343
+ if (!flagBool(flags.yes))
5344
+ throw new CliError(
5345
+ "This ends the plan now and refunds its first invoice. Run again with --yes to do it."
5346
+ );
5347
+ const result = await staffRequest(ctx, "POST", `${LAGO}/cancel`, { orgId, reason: flagStr(flags.why) });
5348
+ printResult(
5349
+ io,
5350
+ flags,
5351
+ result,
5352
+ (r) =>
5353
+ `Cancelled ${orgId}: first invoice ${r.firstInvoice}${r.refundedCents ? `, $${(r.refundedCents / 100).toFixed(2)} refunded` : ""}.`
5354
+ );
5355
+ return 0;
5356
+ }
5216
5357
  case "cap": {
5217
5358
  const orgId = positionals[2];
5218
5359
  const value = positionals[3];
@@ -5259,8 +5400,10 @@ export async function cmdAdmin(ctx) {
5259
5400
  if (sub === "brand-detection") return cmdAdminBrandDetection({ ...ctx, positionals: ctx.positionals.slice(1) });
5260
5401
  if (sub === "republish") return cmdAdminRepublish({ ...ctx, positionals: ctx.positionals.slice(1) });
5261
5402
  if (sub === "recipes") return cmdAdminRecipes({ ...ctx, positionals: ctx.positionals.slice(1) });
5403
+ if (sub === "suggestion-recipes")
5404
+ return cmdAdminSuggestionRecipes({ ...ctx, positionals: ctx.positionals.slice(1) });
5262
5405
  throw new CliError(
5263
- 'Usage: admin recipes … | admin republish … | admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" | admin patterns … | admin experiments … | admin fleet-experiments … | admin crm … | admin spend … | admin evals … | admin brand-detection … | admin tuning … | admin tuning-context | admin replays … (see --help)'
5406
+ 'Usage: admin recipes … | admin suggestion-recipes … | admin republish … | admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" | admin patterns … | admin experiments … | admin fleet-experiments … | admin crm … | admin spend … | admin evals … | admin brand-detection … | admin tuning … | admin tuning-context | admin replays … (see --help)'
5264
5407
  );
5265
5408
  }
5266
5409
 
@@ -33,6 +33,7 @@ export const ARGS = {
33
33
  reason: { flag: "why" },
34
34
  },
35
35
  request_billing_tokens: { note: { flag: "note" } },
36
+ cancel_billing_plan: { reason: { flag: "why" } },
36
37
  list_integrations: {},
37
38
  get_linear_connection: {},
38
39
  list_linear_targets: { team_id: { flag: "team" } },
@@ -125,6 +126,20 @@ async function orgBilling(ctx) {
125
126
  );
126
127
  return 0;
127
128
  }
129
+ if (positionals[0] === "cancel") {
130
+ refuseUnknownFlags(ctx, ARGS.cancel_billing_plan, ["yes"]);
131
+ if (!flagBool(flags.yes))
132
+ throw new CliError("This ends the plan now and refunds its first invoice. Run again with --yes to do it.");
133
+ const result = await client.callTool("cancel_billing_plan", argsFromFlags(ARGS.cancel_billing_plan, flags, []));
134
+ printResult(io, flags, result, (r) =>
135
+ r.firstInvoice === "refunded"
136
+ ? `Plan cancelled; $${(r.refundedCents / 100).toFixed(2)} refunded to the card.`
137
+ : r.firstInvoice === "voided"
138
+ ? "Plan cancelled; its unpaid first invoice is voided."
139
+ : "Plan cancelled."
140
+ );
141
+ return 0;
142
+ }
128
143
  if (positionals[0] === "request") {
129
144
  refuseUnknownFlags(ctx, ARGS.request_billing_tokens, []);
130
145
  const result = await client.callTool(
@@ -3,6 +3,7 @@
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
5
  // site builds <host> → list_build_suggestions (--limit n)
6
+ // site research-builds <host> → research_builds (Build's suggestion pass: propose, check, Jev, store)
6
7
  // site metrics <host> [path] → get_site_metrics (--window 7d|28d|90d, --page <path>…, --no-compare)
7
8
  // site observations <host> → list_site_observations site observe <host> → observe_site
8
9
  // site dismiss <host> <id> → dismiss_site_observation
@@ -83,6 +84,9 @@ const ARGS = {
83
84
  host: { positional: 0, required: true, label: "host" },
84
85
  limit: { flag: "limit", type: "num" },
85
86
  },
87
+ research_builds: {
88
+ host: { positional: 0, required: true, label: "host" },
89
+ },
86
90
  get_site_metrics: {
87
91
  host: { positional: 0, required: true, label: "host" },
88
92
  path: { positional: 1, label: "path" },
@@ -204,6 +208,8 @@ export async function cmdSite(ctx) {
204
208
  return siteUnmap(rest);
205
209
  case "tree":
206
210
  return siteTree(rest);
211
+ case "research-builds":
212
+ return siteResearchBuilds(rest);
207
213
  case "builds":
208
214
  return siteBuilds(rest);
209
215
  case "metrics":
@@ -406,6 +412,28 @@ async function siteBuilds(ctx) {
406
412
  );
407
413
  if (!lines.length) lines.push("Nothing to build: no source makes a surface this site's right now.");
408
414
  if (r.skipped.length) lines.push(`Not read: ${r.skipped.join(", ")}`);
415
+ lines.push(
416
+ r.pass
417
+ ? `Suggestion pass ${r.pass.ranAt}: kept ${r.pass.kept}, dropped ${r.pass.rejected}.`
418
+ : "Suggestion pass: never run (site research-builds <host>)."
419
+ );
420
+ return lines.join("\n");
421
+ });
422
+ return 0;
423
+ }
424
+
425
+ async function siteResearchBuilds(ctx) {
426
+ const { client, flags, io } = ctx;
427
+ refuseUnknownFlags(ctx, ARGS.research_builds);
428
+ const result = await client.callTool(
429
+ "research_builds",
430
+ argsFromFlags(ARGS.research_builds, flags, ctx.positionals)
431
+ );
432
+ printResult(io, flags, result, (r) => {
433
+ if (!r.ran) return `Not run (${r.reason ?? "fresh"}); list_build_suggestions shows the stored pass.`;
434
+ const lines = r.suggestions.map((s) => `KEPT ${s.summary}\n ${s.relevance}\n ${s.id}`);
435
+ for (const x of r.rejected) lines.push(`DROPPED ${x.summary || x.surface}: ${x.reason}`);
436
+ if (!r.suggestions.length) lines.push("Nothing passed: zero is a valid result.");
409
437
  return lines.join("\n");
410
438
  });
411
439
  return 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.146.0",
3
+ "version": "0.147.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",