@letterstory/cli 0.121.7 → 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 +16 -0
- package/lib/cli.mjs +21 -1
- package/lib/commands/admin.mjs +199 -2
- package/package.json +1 -1
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.
|
|
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
|
package/lib/commands/admin.mjs
CHANGED
|
@@ -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,
|
|
@@ -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
|
|