@letterstory/cli 0.121.6 → 0.122.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
@@ -423,6 +423,22 @@ The ledger behind Kernel Tuning's Experiments tab
423
423
  (docs/architecture/kernel-experiment-agent.md): every question asked of the kernels,
424
424
  how it was tested, and what we learned and why.
425
425
 
426
+ ### Staff: plan recipes
427
+
428
+ ```bash
429
+ # Needs a STAFF key with recipes:read (reads) and recipes:write (writes): mint it on
430
+ # /superadmin/plan-recipes → "Your agent's key". Recipes are global: one library behind every
431
+ # account's Plan. The same routes and functions as that page.
432
+ export LETTERSTORY_STAFF_API_KEY=<your staff key with recipes:read,recipes:write>
433
+
434
+ letterstory admin recipes vocab # what a recipe can say: fields, goals, kinds, primitives
435
+ letterstory admin recipes export --out recipes.json # the library as a file
436
+ letterstory admin recipes import recipes.json --dry-run # what would be created / changed / retired
437
+ letterstory admin recipes import recipes.json # write it (matched by id, else name)
438
+ letterstory admin recipes save --name "Get cited" --prompt "…" --goals geo --company-types devtools
439
+ letterstory admin recipes save <id> --disable # retire one (delete <id> --yes removes it)
440
+ ```
441
+
426
442
  ### Staff: the CRM pipeline
427
443
 
428
444
  ```bash
package/lib/cli.mjs CHANGED
@@ -61,7 +61,7 @@ import {
61
61
  } from "./commands.mjs";
62
62
 
63
63
  // Keep in sync with cli/package.json.
64
- export const VERSION = "0.121.6";
64
+ export const VERSION = "0.122.0";
65
65
 
66
66
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
67
67
  // can't accidentally swallow the id as --json's value. Every `type: "bool"` flag (and its
@@ -101,6 +101,11 @@ export const BOOLEAN_FLAGS = new Set([
101
101
  "verbose",
102
102
  "no-color",
103
103
  "dry-run",
104
+ // admin recipes list --enabled-only, save --enable|--disable, import --retire-missing
105
+ "enabled-only",
106
+ "enable",
107
+ "disable",
108
+ "retire-missing",
104
109
  // admin crm record-outcome --draft
105
110
  "draft",
106
111
  // admin crm reach-outs --fresh
@@ -757,6 +762,21 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
757
762
  read-only SQL across every org; --sql prints
758
763
  the queries behind the answer. Needs the
759
764
  fleet:query capability on the staff key.
765
+ admin recipes list [--enabled-only] | show <id|name> | vocab [--json]
766
+ Plan recipes: Letterstory's one global library behind
767
+ every account's Plan (/superadmin/plan-recipes). vocab
768
+ says what a recipe can say (fields, goals, company
769
+ types, primitives). Needs recipes:read.
770
+ admin recipes export [--out recipes.json] The library as a file import reads back.
771
+ admin recipes import <file> [--dry-run] [--retire-missing]
772
+ Write a library in one call: each recipe matched by id,
773
+ else name, then changed or created. --dry-run writes
774
+ nothing; --retire-missing retires every enabled recipe
775
+ the file leaves out. Exits 1 if any row failed.
776
+ admin recipes save [<id>] [--file <recipe.json>] [--name …] [--prompt …] [--audience …] [--example …]...
777
+ [--primitives a,b|none] [--goals a,b|none] [--company-types a,b|none] [--enable|--disable] [--position n]
778
+ Create one (no id) or change one. Needs recipes:write.
779
+ admin recipes delete <id> --yes Delete for good (save --disable retires and keeps it).
760
780
  admin brand-detection <org-id> [--term <t>]... [--exact <t>]... [--exclude <phrase>]... [--note "…"] | --clear [--note "…"]
761
781
  How a client's brand is detected in AI answers, with
762
782
  every earlier setting; with flags, set the exact set
@@ -18,6 +18,7 @@
18
18
  // admin spend summary|reconcile|pnl|entries|add|import|usage|remove|reference|stripe-sync|services|service … (spend:read / spend:write)
19
19
  // admin brand-detection <org-id> (fleet:read)
20
20
  // admin brand-detection <org-id> [--term t]... [--exact t]... [--exclude p]... [--note "…"] | --clear [--note "…"] (fleet:instrument)
21
+ // admin recipes list|show|vocab|export|import|save|delete … (recipes:read / recipes:write)
21
22
  // admin replays [--org <id>] [--target <id>] [--status …] [--limit n] (fleet:read)
22
23
  // admin replay --org <id> [<response-id> | --target <id>] [--surface …] (fleet:replay)
23
24
  // admin create-org --domain acme.com [--name "Acme"] [--test] [--no-key] [--key-name N]
@@ -42,7 +43,7 @@
42
43
  // The staff key comes from --staff-key or LETTERSTORY_STAFF_API_KEY; the base URL from the
43
44
  // usual --url / LETTERSTORY_API_URL / config resolution.
44
45
 
45
- import { readFileSync } from "node:fs";
46
+ import { readFileSync, writeFileSync } from "node:fs";
46
47
  import { CliError, LetterstoryClient } from "../client.mjs";
47
48
  import {
48
49
  flagStr,
@@ -3543,7 +3544,7 @@ async function cmdAdminCrm(ctx) {
3543
3544
  c
3544
3545
  ? `\n like ${c.name} (${c.kind} ${c.on}${c.primaryReason ? `, ${c.primaryReason}` : ""}): ${c.matched}/5 matched — ${c.features
3545
3546
  .filter((f) => f.matched)
3546
- .map((f) => `${f.feature} ${f.now}`)
3547
+ .map((f) => `${f.label ?? f.feature}: ${f.nowLabel ?? f.now}`)
3547
3548
  .join(", ")}`
3548
3549
  : "\n no past case matches 3 of 5";
3549
3550
  return out(await staffRequest(ctx, "GET", `${CRM}/at-risk`), (r) =>
@@ -3564,7 +3565,7 @@ async function cmdAdminCrm(ctx) {
3564
3565
  ? r.signals
3565
3566
  .map(
3566
3567
  (x) =>
3567
- `${x.score.toFixed(2)} ${x.name} → ${x.product} (${x.rule}) ${x.reasons.join("; ")} ${x.accountId}${x.beforePastExpansions.length ? `\n before past expansions: ${x.beforePastExpansions.map((p) => `${p.name} ${p.on}`).join(", ")}` : ""}`
3568
+ `${x.score.toFixed(2)} ${x.name} → ${x.product} (${x.ruleLabel ?? x.rule}) ${x.reasons.join("; ")} ${x.accountId}${x.beforePastExpansions.length ? `\n before past expansions: ${x.beforePastExpansions.map((p) => `${p.name} ${p.on}`).join(", ")}` : ""}`
3568
3569
  )
3569
3570
  .join("\n")
3570
3571
  : "(no next-product verdicts)"
@@ -4538,6 +4539,201 @@ async function cmdAdminRepublish(ctx) {
4538
4539
  return failed.length ? 1 : 0;
4539
4540
  }
4540
4541
 
4542
+ const RECIPES = "/api/superadmin/plan-recipes";
4543
+ /** The fields a recipe file carries: what import_plan_recipes reads back. */
4544
+ const RECIPE_FILE_FIELDS = [
4545
+ "id",
4546
+ "name",
4547
+ "audience",
4548
+ "examples",
4549
+ "prompt",
4550
+ "primitives",
4551
+ "goals",
4552
+ "company_types",
4553
+ "enabled",
4554
+ "position",
4555
+ ];
4556
+ const RECIPES_USAGE =
4557
+ "Usage: admin recipes list [--enabled-only] | show <id|name> | vocab | export [--out <file>] | import <file> [--dry-run] [--retire-missing] | save [<id>] [--file <recipe.json>] [--name …] [--prompt …] [--audience …] [--example …]… [--primitives a,b|none] [--goals a,b|none] [--company-types a,b|none] [--enable|--disable] [--position n] | delete <id> --yes";
4558
+
4559
+ const recipeForFile = (r) => Object.fromEntries(RECIPE_FILE_FIELDS.map((k) => [k, r[k]]));
4560
+
4561
+ function recipeLine(r) {
4562
+ const scope = [
4563
+ r.goals?.length ? `goals ${r.goals.join(",")}` : "every goal",
4564
+ r.company_types?.length ? `kinds ${r.company_types.join(",")}` : "every kind",
4565
+ `stands up ${r.primitives?.length ? r.primitives.join(",") : "nothing"}`,
4566
+ ].join(" · ");
4567
+ return `${String(r.position).padStart(4)} ${r.enabled ? "on " : "off"} ${r.name} [${scope}] ${r.id}`;
4568
+ }
4569
+
4570
+ function recipeDetail(r) {
4571
+ return [
4572
+ `${r.name} ${r.enabled ? "(enabled)" : "(retired)"} ${r.id}`,
4573
+ ` position: ${r.position}`,
4574
+ ` goals: ${r.goals?.join(", ") || "every goal"}`,
4575
+ ` kinds: ${r.company_types?.join(", ") || "every kind"}`,
4576
+ ` stands up: ${r.primitives?.join(", ") || "nothing"}`,
4577
+ ` audience: ${r.audience || "—"}`,
4578
+ ` examples: ${r.examples?.join("; ") || "—"}`,
4579
+ ` prompt:\n ${r.prompt.split("\n").join("\n ")}`,
4580
+ ].join("\n");
4581
+ }
4582
+
4583
+ function readJson(path) {
4584
+ try {
4585
+ return JSON.parse(readFileSync(path, "utf8"));
4586
+ } catch (err) {
4587
+ throw new CliError(`${path}: ${err.message}`);
4588
+ }
4589
+ }
4590
+
4591
+ /** A recipes file: `{ recipes: [...] }` (what export writes) or a bare array. */
4592
+ function readRecipesFile(path) {
4593
+ const parsed = readJson(path);
4594
+ const recipes = Array.isArray(parsed) ? parsed : parsed?.recipes;
4595
+ if (!Array.isArray(recipes)) throw new CliError(`${path}: expected { "recipes": [...] } or an array of recipes`);
4596
+ return recipes.map((r) => compact(recipeForFile(r)));
4597
+ }
4598
+
4599
+ /** A list flag for save: `a,b` (repeatable), `none` to clear it; a bare flag is a mistake. */
4600
+ function recipeListFlag(flags, name) {
4601
+ const v = flags[name];
4602
+ if (v === undefined) return undefined;
4603
+ if (v === true) throw new CliError(`--${name} needs a value: a,b or none`);
4604
+ return flagStr(v) === "none" ? [] : flagCsv(v);
4605
+ }
4606
+
4607
+ /** The fields `admin recipes save` sets: one recipe from --file (or show --json's output), then flags over it. */
4608
+ function recipeFromFlags(flags) {
4609
+ const path = flagStr(flags.file);
4610
+ const parsed = path ? readJson(path) : {};
4611
+ const base = parsed?.recipe ?? parsed;
4612
+ if (!base || typeof base !== "object" || Array.isArray(base) || Array.isArray(base.recipes)) {
4613
+ throw new CliError("--file holds one recipe object; use admin recipes import for several");
4614
+ }
4615
+ const examples = flagList(flags.example);
4616
+ return compact({
4617
+ ...compact(recipeForFile(base)),
4618
+ name: flagStr(flags.name),
4619
+ audience: flagStr(flags.audience),
4620
+ prompt: flagStr(flags.prompt),
4621
+ examples: examples.length ? examples : undefined,
4622
+ primitives: recipeListFlag(flags, "primitives"),
4623
+ goals: recipeListFlag(flags, "goals"),
4624
+ company_types: recipeListFlag(flags, "company-types"),
4625
+ enabled: flagBool(flags.disable) ? false : flagBool(flags.enable) ? true : undefined,
4626
+ position: flagNum(flags.position),
4627
+ });
4628
+ }
4629
+
4630
+ /**
4631
+ * `admin recipes …` — the Plan recipes page (/superadmin/plan-recipes) for an agent: Letterstory's
4632
+ * one global library of recipes behind every account's Plan. The loop for building it out is
4633
+ * `vocab` (what a recipe can say) → `export --out recipes.json` → edit → `import recipes.json
4634
+ * --dry-run` → `import recipes.json`. Reads need recipes:read on the staff key, writes
4635
+ * recipes:write; every write is recorded as the key holder's.
4636
+ */
4637
+ async function cmdAdminRecipes(ctx) {
4638
+ const { flags, positionals, io } = ctx;
4639
+ const sub = positionals[0];
4640
+ const all = async () => (await staffRequest(ctx, "GET", RECIPES)).recipes ?? [];
4641
+ switch (sub) {
4642
+ case "list": {
4643
+ const recipes = (await all()).filter((r) => !flagBool(flags["enabled-only"]) || r.enabled);
4644
+ printResult(io, flags, { recipes }, (r) =>
4645
+ r.recipes.length ? r.recipes.map(recipeLine).join("\n") : "No recipes yet."
4646
+ );
4647
+ return 0;
4648
+ }
4649
+ case "show": {
4650
+ const key = requirePositional(positionals, 1, "id|name");
4651
+ const recipe = (await all()).find((r) => r.id === key || r.name === key);
4652
+ if (!recipe) throw new CliError(`No recipe with id or name "${key}".`);
4653
+ printResult(io, flags, { recipe }, (r) => recipeDetail(r.recipe));
4654
+ return 0;
4655
+ }
4656
+ case "vocab": {
4657
+ const v = await staffRequest(ctx, "GET", `${RECIPES}/vocabulary`);
4658
+ printResult(io, flags, v, (r) =>
4659
+ [
4660
+ ...r.how_recipes_are_read.map((line) => `· ${line}`),
4661
+ "",
4662
+ "Fields:",
4663
+ ...r.fields.map((f) => ` ${f.name}: ${f.description}`),
4664
+ "",
4665
+ "Goals:",
4666
+ ...r.goals.map((g) => ` ${g.id}${g.available ? "" : " (not available yet)"}: ${g.brief}`),
4667
+ "",
4668
+ `Company types: ${r.company_types.map((t) => `${t.id} (${t.label})`).join(", ")}`,
4669
+ `Primitives: ${r.primitives.map((p) => `${p.id} (${p.label})`).join(", ")}`,
4670
+ ].join("\n")
4671
+ );
4672
+ return 0;
4673
+ }
4674
+ case "export": {
4675
+ const file = { recipes: (await all()).map(recipeForFile) };
4676
+ const out = flagStr(flags.out);
4677
+ if (out) {
4678
+ writeFileSync(out, `${JSON.stringify(file, null, 2)}\n`);
4679
+ ok(ctx, `Wrote ${file.recipes.length} recipe(s) to ${out}.`);
4680
+ } else io.log(JSON.stringify(file, null, 2));
4681
+ return 0;
4682
+ }
4683
+ case "import": {
4684
+ const path = requirePositional(positionals, 1, "file");
4685
+ const result = await staffPost(ctx, `${RECIPES}/import`, {
4686
+ recipes: readRecipesFile(path),
4687
+ dry_run: flagBool(flags["dry-run"]),
4688
+ retire_missing: flagBool(flags["retire-missing"]),
4689
+ });
4690
+ const verb = {
4691
+ create: "create",
4692
+ update: "update",
4693
+ unchanged: "same ",
4694
+ retire: "retire",
4695
+ failed: "FAILED",
4696
+ };
4697
+ printResult(io, flags, result, (r) =>
4698
+ [
4699
+ ...r.results.map(
4700
+ (row) =>
4701
+ `${verb[row.action] ?? row.action} ${row.name}${row.changed.length && row.action !== "create" ? ` (${row.changed.join(", ")})` : ""}${row.error ? ` → ${row.error}` : ""}`
4702
+ ),
4703
+ `${r.dry_run ? "Would create" : "Created"} ${r.counts.created}, ${r.dry_run ? "update" : "updated"} ${r.counts.updated}, retire${r.dry_run ? "" : "d"} ${r.counts.retired}; ${r.counts.unchanged} unchanged, ${r.counts.failed} failed.${r.dry_run ? " Nothing was written (--dry-run)." : ""}`,
4704
+ ].join("\n")
4705
+ );
4706
+ return result.counts?.failed ? 1 : 0;
4707
+ }
4708
+ case "save": {
4709
+ const fields = recipeFromFlags(flags);
4710
+ const id = positionals[1] ?? fields.id;
4711
+ delete fields.id;
4712
+ const result = id
4713
+ ? await staffRequest(ctx, "PATCH", `${RECIPES}/${encodeURIComponent(id)}`, fields)
4714
+ : await staffPost(ctx, RECIPES, fields);
4715
+ ok(ctx, `${result.created ? "Created" : "Saved"} "${result.recipe.name}" (${result.recipe.id}).`);
4716
+ printResult(io, flags, result, (r) => recipeDetail(r.recipe));
4717
+ return 0;
4718
+ }
4719
+ case "delete": {
4720
+ const id = requirePositional(positionals, 1, "id");
4721
+ if (!flagBool(flags.yes)) {
4722
+ io.error(
4723
+ `Re-run with --yes to delete for good: ${ctx.bin} admin recipes delete ${id} --yes (save --disable retires one and keeps it)`
4724
+ );
4725
+ return 1;
4726
+ }
4727
+ const result = await staffRequest(ctx, "DELETE", `${RECIPES}/${encodeURIComponent(id)}`);
4728
+ ok(ctx, `Deleted ${id}.`);
4729
+ printResult(io, flags, result);
4730
+ return 0;
4731
+ }
4732
+ default:
4733
+ throw new CliError(RECIPES_USAGE);
4734
+ }
4735
+ }
4736
+
4541
4737
  export async function cmdAdmin(ctx) {
4542
4738
  const sub = ctx.positionals[0];
4543
4739
  if (sub === "replays" || sub === "replay")
@@ -4555,8 +4751,9 @@ export async function cmdAdmin(ctx) {
4555
4751
  if (sub === "evals") return cmdAdminEvals({ ...ctx, positionals: ctx.positionals.slice(1) });
4556
4752
  if (sub === "brand-detection") return cmdAdminBrandDetection({ ...ctx, positionals: ctx.positionals.slice(1) });
4557
4753
  if (sub === "republish") return cmdAdminRepublish({ ...ctx, positionals: ctx.positionals.slice(1) });
4754
+ if (sub === "recipes") return cmdAdminRecipes({ ...ctx, positionals: ctx.positionals.slice(1) });
4558
4755
  throw new CliError(
4559
- 'Usage: 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)'
4756
+ '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)'
4560
4757
  );
4561
4758
  }
4562
4759
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.121.6",
3
+ "version": "0.122.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",