@letterstory/cli 0.121.7 → 0.123.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.7";
64
+ export const VERSION = "0.123.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
@@ -831,7 +851,8 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
831
851
  | add --vendor V --amount N --from D [--to D] --area A --category C [--basis billed|estimated] …
832
852
  | import <file.csv> [--vendor Concentrate] [--area A] [--ledger-key K] [--dry-run]
833
853
  | import <file.csv> [--vendor Concentrate] [--area A] [--keep-ledger-key] [--dry-run]
834
- | usage <events.json> per-call costs as kernels/lettertrace push them
854
+ | usage <events.json> per-call costs as kernels/lettertrace/models push them
855
+ | usage list --source models --from D --to D one source's calls back, newest first
835
856
  | remove <entry-id> | services | service <slug> [--owner …] [--monthly N|none] [--renewal D] [--units N|none]
836
857
  Where Letter's money goes (spending.letter.dev):
837
858
  totals, run rate, area/vendor/provider/model/
@@ -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,
@@ -4042,7 +4043,34 @@ async function cmdAdminSpend(ctx) {
4042
4043
  return 0;
4043
4044
  }
4044
4045
  case "usage": {
4045
- // Per-call model costs, as kernels / lettertrace push them (POST /api/superadmin/spend/usage):
4046
+ if (positionals[1] === "list") {
4047
+ // One source's calls back, newest first (GET /api/superadmin/spend/usage).
4048
+ const source = flagStr(flags.source);
4049
+ const from = flagStr(flags.from);
4050
+ const to = flagStr(flags.to);
4051
+ if (!source || !from || !to)
4052
+ throw new CliError(
4053
+ "Usage: admin spend usage list --source models --from YYYY-MM-DD --to YYYY-MM-DD [--limit N]"
4054
+ );
4055
+ const params = new URLSearchParams(
4056
+ compact({ source, from, to, limit: flagNum(flags.limit)?.toString() })
4057
+ );
4058
+ const result = await staffRequest(ctx, "GET", `${SPEND}/usage?${params}`);
4059
+ printResult(
4060
+ io,
4061
+ flags,
4062
+ result,
4063
+ (r) =>
4064
+ (r.events ?? [])
4065
+ .map(
4066
+ (e) =>
4067
+ `${e.occurred_at} ${e.model.padEnd(16)} ${e.operation.padEnd(10)} ${(e.outcome ?? "").padEnd(8)} ${String(e.latency_ms ?? "").padStart(7)}ms ${usd(e.cost_usd).padStart(9)} ${e.key_label}`
4068
+ )
4069
+ .join("\n") + (r.next ? "\n… more (narrow --from/--to or raise --limit)" : "")
4070
+ );
4071
+ return 0;
4072
+ }
4073
+ // Per-call model costs, as kernels / lettertrace / models push them (POST /api/superadmin/spend/usage):
4046
4074
  // a JSON file holding { events: [...] } or a bare array. Idempotent on each event_id.
4047
4075
  const path = positionals[1];
4048
4076
  if (!path) throw new CliError("Usage: admin spend usage <events.json> ({ events: [...] } or an array)");
@@ -4192,7 +4220,7 @@ async function cmdAdminSpend(ctx) {
4192
4220
  }
4193
4221
  default:
4194
4222
  throw new CliError(
4195
- "Usage: admin spend summary [--range day|week|month|year | --from D --to D] [--grain day|week|month] | reconcile [--from D --to D] | pnl [--range …] | reference add … | stripe-sync --from D --to D | entries [--from D] [--to D] [--vendor V] [--area A] [--source S] | add --vendor V --amount N --from D … | import <file.csv> … | usage <events.json> | remove <entry-id> | services | service <slug> --owner … --monthly …"
4223
+ "Usage: admin spend summary [--range day|week|month|year | --from D --to D] [--grain day|week|month] | reconcile [--from D --to D] | pnl [--range …] | reference add … | stripe-sync --from D --to D | entries [--from D] [--to D] [--vendor V] [--area A] [--source S] | add --vendor V --amount N --from D … | import <file.csv> … | usage <events.json> | usage list --source S --from D --to D | remove <entry-id> | services | service <slug> --owner … --monthly …"
4196
4224
  );
4197
4225
  }
4198
4226
  }
@@ -4538,6 +4566,201 @@ async function cmdAdminRepublish(ctx) {
4538
4566
  return failed.length ? 1 : 0;
4539
4567
  }
4540
4568
 
4569
+ const RECIPES = "/api/superadmin/plan-recipes";
4570
+ /** The fields a recipe file carries: what import_plan_recipes reads back. */
4571
+ const RECIPE_FILE_FIELDS = [
4572
+ "id",
4573
+ "name",
4574
+ "audience",
4575
+ "examples",
4576
+ "prompt",
4577
+ "primitives",
4578
+ "goals",
4579
+ "company_types",
4580
+ "enabled",
4581
+ "position",
4582
+ ];
4583
+ const RECIPES_USAGE =
4584
+ "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";
4585
+
4586
+ const recipeForFile = (r) => Object.fromEntries(RECIPE_FILE_FIELDS.map((k) => [k, r[k]]));
4587
+
4588
+ function recipeLine(r) {
4589
+ const scope = [
4590
+ r.goals?.length ? `goals ${r.goals.join(",")}` : "every goal",
4591
+ r.company_types?.length ? `kinds ${r.company_types.join(",")}` : "every kind",
4592
+ `stands up ${r.primitives?.length ? r.primitives.join(",") : "nothing"}`,
4593
+ ].join(" · ");
4594
+ return `${String(r.position).padStart(4)} ${r.enabled ? "on " : "off"} ${r.name} [${scope}] ${r.id}`;
4595
+ }
4596
+
4597
+ function recipeDetail(r) {
4598
+ return [
4599
+ `${r.name} ${r.enabled ? "(enabled)" : "(retired)"} ${r.id}`,
4600
+ ` position: ${r.position}`,
4601
+ ` goals: ${r.goals?.join(", ") || "every goal"}`,
4602
+ ` kinds: ${r.company_types?.join(", ") || "every kind"}`,
4603
+ ` stands up: ${r.primitives?.join(", ") || "nothing"}`,
4604
+ ` audience: ${r.audience || "—"}`,
4605
+ ` examples: ${r.examples?.join("; ") || "—"}`,
4606
+ ` prompt:\n ${r.prompt.split("\n").join("\n ")}`,
4607
+ ].join("\n");
4608
+ }
4609
+
4610
+ function readJson(path) {
4611
+ try {
4612
+ return JSON.parse(readFileSync(path, "utf8"));
4613
+ } catch (err) {
4614
+ throw new CliError(`${path}: ${err.message}`);
4615
+ }
4616
+ }
4617
+
4618
+ /** A recipes file: `{ recipes: [...] }` (what export writes) or a bare array. */
4619
+ function readRecipesFile(path) {
4620
+ const parsed = readJson(path);
4621
+ const recipes = Array.isArray(parsed) ? parsed : parsed?.recipes;
4622
+ if (!Array.isArray(recipes)) throw new CliError(`${path}: expected { "recipes": [...] } or an array of recipes`);
4623
+ return recipes.map((r) => compact(recipeForFile(r)));
4624
+ }
4625
+
4626
+ /** A list flag for save: `a,b` (repeatable), `none` to clear it; a bare flag is a mistake. */
4627
+ function recipeListFlag(flags, name) {
4628
+ const v = flags[name];
4629
+ if (v === undefined) return undefined;
4630
+ if (v === true) throw new CliError(`--${name} needs a value: a,b or none`);
4631
+ return flagStr(v) === "none" ? [] : flagCsv(v);
4632
+ }
4633
+
4634
+ /** The fields `admin recipes save` sets: one recipe from --file (or show --json's output), then flags over it. */
4635
+ function recipeFromFlags(flags) {
4636
+ const path = flagStr(flags.file);
4637
+ const parsed = path ? readJson(path) : {};
4638
+ const base = parsed?.recipe ?? parsed;
4639
+ if (!base || typeof base !== "object" || Array.isArray(base) || Array.isArray(base.recipes)) {
4640
+ throw new CliError("--file holds one recipe object; use admin recipes import for several");
4641
+ }
4642
+ const examples = flagList(flags.example);
4643
+ return compact({
4644
+ ...compact(recipeForFile(base)),
4645
+ name: flagStr(flags.name),
4646
+ audience: flagStr(flags.audience),
4647
+ prompt: flagStr(flags.prompt),
4648
+ examples: examples.length ? examples : undefined,
4649
+ primitives: recipeListFlag(flags, "primitives"),
4650
+ goals: recipeListFlag(flags, "goals"),
4651
+ company_types: recipeListFlag(flags, "company-types"),
4652
+ enabled: flagBool(flags.disable) ? false : flagBool(flags.enable) ? true : undefined,
4653
+ position: flagNum(flags.position),
4654
+ });
4655
+ }
4656
+
4657
+ /**
4658
+ * `admin recipes …` — the Plan recipes page (/superadmin/plan-recipes) for an agent: Letterstory's
4659
+ * one global library of recipes behind every account's Plan. The loop for building it out is
4660
+ * `vocab` (what a recipe can say) → `export --out recipes.json` → edit → `import recipes.json
4661
+ * --dry-run` → `import recipes.json`. Reads need recipes:read on the staff key, writes
4662
+ * recipes:write; every write is recorded as the key holder's.
4663
+ */
4664
+ async function cmdAdminRecipes(ctx) {
4665
+ const { flags, positionals, io } = ctx;
4666
+ const sub = positionals[0];
4667
+ const all = async () => (await staffRequest(ctx, "GET", RECIPES)).recipes ?? [];
4668
+ switch (sub) {
4669
+ case "list": {
4670
+ const recipes = (await all()).filter((r) => !flagBool(flags["enabled-only"]) || r.enabled);
4671
+ printResult(io, flags, { recipes }, (r) =>
4672
+ r.recipes.length ? r.recipes.map(recipeLine).join("\n") : "No recipes yet."
4673
+ );
4674
+ return 0;
4675
+ }
4676
+ case "show": {
4677
+ const key = requirePositional(positionals, 1, "id|name");
4678
+ const recipe = (await all()).find((r) => r.id === key || r.name === key);
4679
+ if (!recipe) throw new CliError(`No recipe with id or name "${key}".`);
4680
+ printResult(io, flags, { recipe }, (r) => recipeDetail(r.recipe));
4681
+ return 0;
4682
+ }
4683
+ case "vocab": {
4684
+ const v = await staffRequest(ctx, "GET", `${RECIPES}/vocabulary`);
4685
+ printResult(io, flags, v, (r) =>
4686
+ [
4687
+ ...r.how_recipes_are_read.map((line) => `· ${line}`),
4688
+ "",
4689
+ "Fields:",
4690
+ ...r.fields.map((f) => ` ${f.name}: ${f.description}`),
4691
+ "",
4692
+ "Goals:",
4693
+ ...r.goals.map((g) => ` ${g.id}${g.available ? "" : " (not available yet)"}: ${g.brief}`),
4694
+ "",
4695
+ `Company types: ${r.company_types.map((t) => `${t.id} (${t.label})`).join(", ")}`,
4696
+ `Primitives: ${r.primitives.map((p) => `${p.id} (${p.label})`).join(", ")}`,
4697
+ ].join("\n")
4698
+ );
4699
+ return 0;
4700
+ }
4701
+ case "export": {
4702
+ const file = { recipes: (await all()).map(recipeForFile) };
4703
+ const out = flagStr(flags.out);
4704
+ if (out) {
4705
+ writeFileSync(out, `${JSON.stringify(file, null, 2)}\n`);
4706
+ ok(ctx, `Wrote ${file.recipes.length} recipe(s) to ${out}.`);
4707
+ } else io.log(JSON.stringify(file, null, 2));
4708
+ return 0;
4709
+ }
4710
+ case "import": {
4711
+ const path = requirePositional(positionals, 1, "file");
4712
+ const result = await staffPost(ctx, `${RECIPES}/import`, {
4713
+ recipes: readRecipesFile(path),
4714
+ dry_run: flagBool(flags["dry-run"]),
4715
+ retire_missing: flagBool(flags["retire-missing"]),
4716
+ });
4717
+ const verb = {
4718
+ create: "create",
4719
+ update: "update",
4720
+ unchanged: "same ",
4721
+ retire: "retire",
4722
+ failed: "FAILED",
4723
+ };
4724
+ printResult(io, flags, result, (r) =>
4725
+ [
4726
+ ...r.results.map(
4727
+ (row) =>
4728
+ `${verb[row.action] ?? row.action} ${row.name}${row.changed.length && row.action !== "create" ? ` (${row.changed.join(", ")})` : ""}${row.error ? ` → ${row.error}` : ""}`
4729
+ ),
4730
+ `${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)." : ""}`,
4731
+ ].join("\n")
4732
+ );
4733
+ return result.counts?.failed ? 1 : 0;
4734
+ }
4735
+ case "save": {
4736
+ const fields = recipeFromFlags(flags);
4737
+ const id = positionals[1] ?? fields.id;
4738
+ delete fields.id;
4739
+ const result = id
4740
+ ? await staffRequest(ctx, "PATCH", `${RECIPES}/${encodeURIComponent(id)}`, fields)
4741
+ : await staffPost(ctx, RECIPES, fields);
4742
+ ok(ctx, `${result.created ? "Created" : "Saved"} "${result.recipe.name}" (${result.recipe.id}).`);
4743
+ printResult(io, flags, result, (r) => recipeDetail(r.recipe));
4744
+ return 0;
4745
+ }
4746
+ case "delete": {
4747
+ const id = requirePositional(positionals, 1, "id");
4748
+ if (!flagBool(flags.yes)) {
4749
+ io.error(
4750
+ `Re-run with --yes to delete for good: ${ctx.bin} admin recipes delete ${id} --yes (save --disable retires one and keeps it)`
4751
+ );
4752
+ return 1;
4753
+ }
4754
+ const result = await staffRequest(ctx, "DELETE", `${RECIPES}/${encodeURIComponent(id)}`);
4755
+ ok(ctx, `Deleted ${id}.`);
4756
+ printResult(io, flags, result);
4757
+ return 0;
4758
+ }
4759
+ default:
4760
+ throw new CliError(RECIPES_USAGE);
4761
+ }
4762
+ }
4763
+
4541
4764
  export async function cmdAdmin(ctx) {
4542
4765
  const sub = ctx.positionals[0];
4543
4766
  if (sub === "replays" || sub === "replay")
@@ -4555,8 +4778,9 @@ export async function cmdAdmin(ctx) {
4555
4778
  if (sub === "evals") return cmdAdminEvals({ ...ctx, positionals: ctx.positionals.slice(1) });
4556
4779
  if (sub === "brand-detection") return cmdAdminBrandDetection({ ...ctx, positionals: ctx.positionals.slice(1) });
4557
4780
  if (sub === "republish") return cmdAdminRepublish({ ...ctx, positionals: ctx.positionals.slice(1) });
4781
+ if (sub === "recipes") return cmdAdminRecipes({ ...ctx, positionals: ctx.positionals.slice(1) });
4558
4782
  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)'
4783
+ '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
4784
  );
4561
4785
  }
4562
4786
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.121.7",
3
+ "version": "0.123.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",