@letterstory/cli 0.73.1 → 0.74.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/lib/cli.mjs CHANGED
@@ -56,7 +56,7 @@ import {
56
56
  } from "./commands.mjs";
57
57
 
58
58
  // Keep in sync with cli/package.json.
59
- export const VERSION = "0.73.1";
59
+ export const VERSION = "0.74.0";
60
60
 
61
61
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
62
62
  // can't accidentally swallow the id as --json's value.
@@ -581,6 +581,18 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
581
581
  session (a trial per arm), linked to its
582
582
  ledger entry. Written by \`npm run lab --
583
583
  publish\` in the kernels repo.
584
+ admin spend summary [--range day|week|month|year | --from D --to D] [--grain day|week|month]
585
+ | entries [--from D] [--to D] [--vendor V] [--area A] [--source invoice|csv_import|api|manual]
586
+ | add --vendor V --amount N --from D [--to D] --area A --category C [--basis billed|estimated] …
587
+ | import <file.csv> [--vendor Concentrate] [--area A] [--keep-ledger-key] [--dry-run]
588
+ | remove <entry-id> | services | service <slug> [--owner …] [--monthly N|none] [--renewal D]
589
+ Where Letter's money goes (spending.letter.dev):
590
+ totals, run rate, area/vendor/provider/model/
591
+ key/operation/org breakdowns, every number's
592
+ source and basis, and the data gaps. Entries
593
+ cover what the app ledger cannot see; a CSV
594
+ import sums Concentrate's log export.
595
+ Needs spend:read (reads) / spend:write (writes).
584
596
  admin crm import <file.csv> [--source <name>] [--tag "…"] [--dry-run]
585
597
  | capture [shot.png …] [--text "…" | --text-file f] [--context "this is for acme.com"]
586
598
  [--account <id>] [--dry-run] screenshots / a thread / a note → a lead
@@ -13,6 +13,7 @@
13
13
  // The old names — evals, eval-add, eval-set, proposals, propose-eval, capture-eval, reject-eval — still work.)
14
14
  // admin evals scoreboard|cases|runs|run|triage|guard|retire … (fleet:read / tuning:experiment)
15
15
  // admin crm import|capture|signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|record-churn|undo-churn|onboard|onboarding|retry-onboarding|onboard-plan|payment-link … (crm:read / crm:write)
16
+ // admin spend summary|entries|add|import|remove|services|service … (spend:read / spend:write)
16
17
  // admin replays [--org <id>] [--target <id>] [--status …] [--limit n] (fleet:read)
17
18
  // admin replay --org <id> [<response-id> | --target <id>] [--surface …] (fleet:replay)
18
19
  // admin create-org --domain acme.com [--name "Acme"] [--test] [--no-key] [--key-name N]
@@ -2998,11 +2999,226 @@ async function cmdAdminReplays(ctx, sub) {
2998
2999
  return 0;
2999
3000
  }
3000
3001
 
3002
+ // ─── admin spend ─────────────────────────────────────────────────────────────
3003
+ // The headless half of spending.letter.dev: the same getSpendSummary, entries and services
3004
+ // the dashboard reads, through /api/superadmin/spend/*, so a background agent can watch
3005
+ // spend 24/7. Reads need spend:read on the staff key, writes spend:write.
3006
+
3007
+ const SPEND = "/api/superadmin/spend";
3008
+ const SPEND_RANGES = ["day", "week", "month", "year"];
3009
+ const SPEND_GRAINS = ["day", "week", "month"];
3010
+ const SPEND_CATEGORIES = [
3011
+ "ai_model",
3012
+ "ai_media",
3013
+ "search_scrape",
3014
+ "data_enrichment",
3015
+ "email_sms",
3016
+ "infra",
3017
+ "analytics",
3018
+ "payments",
3019
+ "devtools",
3020
+ "other",
3021
+ ];
3022
+ const SPEND_BASES = ["billed", "estimated"];
3023
+ const SPEND_SOURCES = ["invoice", "csv_import", "api", "manual"];
3024
+
3025
+ function usd(n) {
3026
+ return `$${Number(n ?? 0).toLocaleString("en-US", { maximumFractionDigits: n >= 100 ? 0 : 2 })}`;
3027
+ }
3028
+
3029
+ function formatSpendSummary(s) {
3030
+ const t = s.totals;
3031
+ const lines = [
3032
+ `${s.range.from} → ${s.range.to} (${s.range.days}d): ${usd(t.total)} · ${usd(t.perDay)}/day · ~${usd(t.projectedMonth)}/mo` +
3033
+ (t.changePct === null ? "" : ` · ${t.changePct > 0 ? "+" : ""}${t.changePct}% vs previous`),
3034
+ ` billed ${usd(t.billed)} · reported ${usd(t.reported)} · estimated (list price) ${usd(t.estimated)}`,
3035
+ "",
3036
+ "By area:",
3037
+ ...s.areas.map((a) => ` ${a.label.padEnd(34)} ${usd(a.total).padStart(10)}`),
3038
+ "",
3039
+ "Top operations (letterstory):",
3040
+ ...s.drill.slice(0, 8).map((d) => ` ${d.operation.padEnd(34)} ${usd(d.total).padStart(10)} ${d.calls} calls`),
3041
+ "",
3042
+ "Top orgs:",
3043
+ ...s.orgs
3044
+ .slice(0, 10)
3045
+ .map(
3046
+ (o) =>
3047
+ ` ${o.name.slice(0, 34).padEnd(34)} ${usd(o.total).padStart(10)}${o.internal ? ` [${o.internalReason}]` : ""}${o.paying === false && !o.internal ? " [not paying]" : ""}`
3048
+ ),
3049
+ ];
3050
+ if (s.gaps.length) lines.push("", "Not in the total:", ...s.gaps.map((g) => ` ${g.label}: ${g.reason}`));
3051
+ return lines.join("\n");
3052
+ }
3053
+
3054
+ function formatSpendServices(r) {
3055
+ return (r.services ?? [])
3056
+ .map(
3057
+ (x) =>
3058
+ `${x.slug.padEnd(22)} ${x.category.padEnd(16)} ${x.live === true ? "live" : x.live === false ? "off " : " ? "} ${x.hasCostData ? usd(x.spend30d).padStart(9) + "/30d" : " no cost data"}${x.owner ? ` owner ${x.owner}` : ""}`
3059
+ )
3060
+ .join("\n");
3061
+ }
3062
+
3063
+ function spendEntryFromFlags(flags) {
3064
+ const vendor = flagStr(flags.vendor);
3065
+ const amount = flagNum(flags.amount);
3066
+ const start = flagStr(flags.from) ?? flagStr(flags.day);
3067
+ const end = flagStr(flags.to) ?? start;
3068
+ if (!vendor || amount === undefined || !start) {
3069
+ throw new CliError(
3070
+ 'Usage: admin spend add --vendor Perplexity --amount 412.50 --from 2026-09-01 [--to 2026-09-30] --area lettertrace --category ai_model [--basis billed|estimated] [--source invoice|manual] [--key "…"] [--model …] [--operation …] [--service <slug>] [--ref "invoice #123"] [--note "…"] [--external-id …]'
3071
+ );
3072
+ }
3073
+ return compact({
3074
+ vendor,
3075
+ amount_usd: amount,
3076
+ period_start: start,
3077
+ period_end: end,
3078
+ area: flagStr(flags.area) ?? "other",
3079
+ category: oneOf(flags, "category", SPEND_CATEGORIES) ?? "other",
3080
+ basis: oneOf(flags, "basis", SPEND_BASES) ?? "billed",
3081
+ source: oneOf(flags, "source", SPEND_SOURCES) ?? "manual",
3082
+ key_label: flagStr(flags.key),
3083
+ model: flagStr(flags.model),
3084
+ operation: flagStr(flags.operation),
3085
+ service_slug: flagStr(flags.service),
3086
+ provider: flagStr(flags.provider),
3087
+ org_id: flagStr(flags.org),
3088
+ source_ref: flagStr(flags.ref),
3089
+ note: flagStr(flags.note),
3090
+ external_id: flagStr(flags["external-id"]),
3091
+ });
3092
+ }
3093
+
3094
+ async function cmdAdminSpend(ctx) {
3095
+ const { flags, positionals, io } = ctx;
3096
+ const sub = positionals[0];
3097
+ switch (sub) {
3098
+ case "summary": {
3099
+ const params = new URLSearchParams(
3100
+ compact({
3101
+ range: oneOf(flags, "range", SPEND_RANGES),
3102
+ from: flagStr(flags.from),
3103
+ to: flagStr(flags.to),
3104
+ grain: oneOf(flags, "grain", SPEND_GRAINS),
3105
+ })
3106
+ );
3107
+ const result = await staffRequest(ctx, "GET", `${SPEND}/summary${params.size ? `?${params}` : ""}`);
3108
+ printResult(io, flags, result, formatSpendSummary);
3109
+ return 0;
3110
+ }
3111
+ case "entries": {
3112
+ const params = new URLSearchParams(
3113
+ compact({
3114
+ from: flagStr(flags.from),
3115
+ to: flagStr(flags.to),
3116
+ vendor: flagStr(flags.vendor),
3117
+ area: flagStr(flags.area),
3118
+ source: oneOf(flags, "source", SPEND_SOURCES),
3119
+ limit: flagNum(flags.limit)?.toString(),
3120
+ })
3121
+ );
3122
+ const result = await staffRequest(ctx, "GET", `${SPEND}/entries${params.size ? `?${params}` : ""}`);
3123
+ printResult(io, flags, result, (r) =>
3124
+ (r.entries ?? [])
3125
+ .map(
3126
+ (e) =>
3127
+ `${e.period_start}→${e.period_end} ${usd(e.amount_usd).padStart(10)} ${e.vendor}${e.key_label ? `: ${e.key_label}` : ""} [${e.area}, ${e.basis}, ${e.source}] ${e.id}`
3128
+ )
3129
+ .join("\n")
3130
+ );
3131
+ return 0;
3132
+ }
3133
+ case "add": {
3134
+ const result = await staffPost(ctx, `${SPEND}/entries`, spendEntryFromFlags(flags));
3135
+ ok(ctx, `Recorded ${result.created} new, ${result.replaced} replaced.`);
3136
+ printResult(io, flags, result);
3137
+ return 0;
3138
+ }
3139
+ case "import": {
3140
+ const path = positionals[1];
3141
+ if (!path)
3142
+ throw new CliError(
3143
+ 'Usage: admin spend import <file.csv> [--vendor Concentrate] [--area kernels] [--category ai_model] [--basis billed|estimated] [--ref "…"] [--keep-ledger-key] [--dry-run]'
3144
+ );
3145
+ const body = compact({
3146
+ csv: readFileSync(path, "utf8"),
3147
+ vendor: flagStr(flags.vendor),
3148
+ area: flagStr(flags.area),
3149
+ category: oneOf(flags, "category", SPEND_CATEGORIES),
3150
+ basis: oneOf(flags, "basis", SPEND_BASES),
3151
+ source_ref: flagStr(flags.ref) ?? `CSV import: ${path.split("/").pop()}`,
3152
+ exclude_ledger_key: flagBool(flags["keep-ledger-key"]) ? null : undefined,
3153
+ dry_run: flagBool(flags["dry-run"]) || undefined,
3154
+ });
3155
+ const result = await staffPost(ctx, `${SPEND}/import`, body);
3156
+ ok(
3157
+ ctx,
3158
+ `${result.used} of ${result.rows} rows → ${result.entries.length} entries, ${usd(result.total)}` +
3159
+ (result.days ? ` (${result.days.from} → ${result.days.to})` : "") +
3160
+ (result.excludedLedgerRows
3161
+ ? `; ${result.excludedLedgerRows} BYOK rows on the ledger's key dropped`
3162
+ : "") +
3163
+ (flagBool(flags["dry-run"]) ? " (dry run, nothing written)" : "")
3164
+ );
3165
+ if (result.overlaps?.length) {
3166
+ ok(
3167
+ ctx,
3168
+ `Already on file for those days (may now double count): ${result.overlaps.map((o) => `${o.vendor} ${o.key_label ?? ""} ${o.id}`).join("; ")}`
3169
+ );
3170
+ }
3171
+ printResult(io, flags, result, () => "");
3172
+ return 0;
3173
+ }
3174
+ case "remove": {
3175
+ const id = positionals[1];
3176
+ if (!id) throw new CliError("Usage: admin spend remove <entry-id>");
3177
+ const result = await staffRequest(ctx, "DELETE", `${SPEND}/entries/${encodeURIComponent(id)}`);
3178
+ ok(ctx, `Removed ${id}.`);
3179
+ printResult(io, flags, result);
3180
+ return 0;
3181
+ }
3182
+ case "services": {
3183
+ const result = await staffRequest(ctx, "GET", `${SPEND}/services`);
3184
+ printResult(io, flags, result, formatSpendServices);
3185
+ return 0;
3186
+ }
3187
+ case "service": {
3188
+ const slug = positionals[1];
3189
+ const monthly = flags.monthly === "none" ? null : flagNum(flags.monthly);
3190
+ const update = compact({
3191
+ owner: flagStr(flags.owner),
3192
+ plan: flagStr(flags.plan),
3193
+ monthly_cost_usd: monthly,
3194
+ renewal_date: flagStr(flags.renewal),
3195
+ notes: flagStr(flags.notes),
3196
+ billing_source: oneOf(flags, "billing", ["api", "invoice", "unknown"]),
3197
+ });
3198
+ if (flags.monthly === "none") update.monthly_cost_usd = null;
3199
+ if (!slug || Object.keys(update).length === 0) {
3200
+ throw new CliError(
3201
+ 'Usage: admin spend service <slug> [--owner "Steve"] [--plan "Pro"] [--monthly 20|none] [--renewal 2026-11-01] [--notes "…"] [--billing api|invoice|unknown]'
3202
+ );
3203
+ }
3204
+ const result = await staffRequest(ctx, "PATCH", `${SPEND}/services/${encodeURIComponent(slug)}`, update);
3205
+ ok(ctx, `Updated ${slug}.`);
3206
+ printResult(io, flags, result);
3207
+ return 0;
3208
+ }
3209
+ default:
3210
+ throw new CliError(
3211
+ "Usage: admin spend summary [--range day|week|month|year | --from D --to D] [--grain day|week|month] | entries [--from D] [--to D] [--vendor V] [--area A] [--source S] | add --vendor V --amount N --from D … | import <file.csv> … | remove <entry-id> | services | service <slug> --owner … --monthly …"
3212
+ );
3213
+ }
3214
+ }
3215
+
3001
3216
  export async function cmdAdmin(ctx) {
3002
3217
  const sub = ctx.positionals[0];
3003
3218
  if (sub === "replays" || sub === "replay")
3004
3219
  return cmdAdminReplays({ ...ctx, positionals: ctx.positionals.slice(1) }, sub);
3005
3220
  if (sub === "crm") return cmdAdminCrm({ ...ctx, positionals: ctx.positionals.slice(1) });
3221
+ if (sub === "spend") return cmdAdminSpend({ ...ctx, positionals: ctx.positionals.slice(1) });
3006
3222
  if (sub === "create-org") return cmdAdminCreateOrg({ ...ctx, positionals: ctx.positionals.slice(1) });
3007
3223
  if (sub === "ask") return cmdAdminAsk({ ...ctx, positionals: ctx.positionals.slice(1) });
3008
3224
  if (sub === "experiments") return cmdAdminExperiments({ ...ctx, positionals: ctx.positionals.slice(1) });
@@ -3011,6 +3227,6 @@ export async function cmdAdmin(ctx) {
3011
3227
  if (sub === "tuning") return cmdAdminTuning({ ...ctx, positionals: ctx.positionals.slice(1) });
3012
3228
  if (sub === "evals") return cmdAdminEvals({ ...ctx, positionals: ctx.positionals.slice(1) });
3013
3229
  throw new CliError(
3014
- 'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" | admin experiments … | admin crm … | admin evals … | admin tuning … | admin tuning-context | admin replays … (see --help)'
3230
+ 'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" | admin experiments … | admin crm … | admin spend … | admin evals … | admin tuning … | admin tuning-context | admin replays … (see --help)'
3015
3231
  );
3016
3232
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.73.1",
3
+ "version": "0.74.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",