@letterstory/cli 0.73.1 → 0.75.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.75.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,19 @@ 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
+ | usage <events.json> per-call costs as kernels/lettertrace push them
589
+ | remove <entry-id> | services | service <slug> [--owner …] [--monthly N|none] [--renewal D]
590
+ Where Letter's money goes (spending.letter.dev):
591
+ totals, run rate, area/vendor/provider/model/
592
+ key/operation/org breakdowns, every number's
593
+ source and basis, and the data gaps. Entries
594
+ cover what the app ledger cannot see; a CSV
595
+ import sums Concentrate's log export.
596
+ Needs spend:read (reads) / spend:write (writes).
584
597
  admin crm import <file.csv> [--source <name>] [--tag "…"] [--dry-run]
585
598
  | capture [shot.png …] [--text "…" | --text-file f] [--context "this is for acme.com"]
586
599
  [--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|usage|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,258 @@ 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 "usage": {
3175
+ // Per-call model costs, as kernels / lettertrace push them (POST /api/superadmin/spend/usage):
3176
+ // a JSON file holding { events: [...] } or a bare array. Idempotent on each event_id.
3177
+ const path = positionals[1];
3178
+ if (!path) throw new CliError("Usage: admin spend usage <events.json> ({ events: [...] } or an array)");
3179
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
3180
+ const events = Array.isArray(parsed) ? parsed : parsed?.events;
3181
+ if (!Array.isArray(events) || events.length === 0) throw new CliError(`${path} holds no events.`);
3182
+ let inserted = 0;
3183
+ let duplicates = 0;
3184
+ let cost = 0;
3185
+ const rejected = [];
3186
+ for (let i = 0; i < events.length; i += 1000) {
3187
+ const r = await staffPost(ctx, `${SPEND}/usage`, { events: events.slice(i, i + 1000) });
3188
+ inserted += r.inserted;
3189
+ duplicates += r.duplicates;
3190
+ cost += r.cost_usd;
3191
+ rejected.push(...(r.rejected ?? []));
3192
+ }
3193
+ ok(
3194
+ ctx,
3195
+ `${events.length} events: ${inserted} new (${usd(cost)}), ${duplicates} already on file` +
3196
+ (rejected.length ? `, ${rejected.length} refused` : "")
3197
+ );
3198
+ printResult(
3199
+ io,
3200
+ flags,
3201
+ { received: events.length, inserted, duplicates, cost_usd: cost, rejected },
3202
+ () => ""
3203
+ );
3204
+ return 0;
3205
+ }
3206
+ case "remove": {
3207
+ const id = positionals[1];
3208
+ if (!id) throw new CliError("Usage: admin spend remove <entry-id>");
3209
+ const result = await staffRequest(ctx, "DELETE", `${SPEND}/entries/${encodeURIComponent(id)}`);
3210
+ ok(ctx, `Removed ${id}.`);
3211
+ printResult(io, flags, result);
3212
+ return 0;
3213
+ }
3214
+ case "services": {
3215
+ const result = await staffRequest(ctx, "GET", `${SPEND}/services`);
3216
+ printResult(io, flags, result, formatSpendServices);
3217
+ return 0;
3218
+ }
3219
+ case "service": {
3220
+ const slug = positionals[1];
3221
+ const monthly = flags.monthly === "none" ? null : flagNum(flags.monthly);
3222
+ const update = compact({
3223
+ owner: flagStr(flags.owner),
3224
+ plan: flagStr(flags.plan),
3225
+ monthly_cost_usd: monthly,
3226
+ renewal_date: flagStr(flags.renewal),
3227
+ notes: flagStr(flags.notes),
3228
+ billing_source: oneOf(flags, "billing", ["api", "invoice", "unknown"]),
3229
+ });
3230
+ if (flags.monthly === "none") update.monthly_cost_usd = null;
3231
+ if (!slug || Object.keys(update).length === 0) {
3232
+ throw new CliError(
3233
+ 'Usage: admin spend service <slug> [--owner "Steve"] [--plan "Pro"] [--monthly 20|none] [--renewal 2026-11-01] [--notes "…"] [--billing api|invoice|unknown]'
3234
+ );
3235
+ }
3236
+ const result = await staffRequest(ctx, "PATCH", `${SPEND}/services/${encodeURIComponent(slug)}`, update);
3237
+ ok(ctx, `Updated ${slug}.`);
3238
+ printResult(io, flags, result);
3239
+ return 0;
3240
+ }
3241
+ default:
3242
+ throw new CliError(
3243
+ "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> … | usage <events.json> | remove <entry-id> | services | service <slug> --owner … --monthly …"
3244
+ );
3245
+ }
3246
+ }
3247
+
3001
3248
  export async function cmdAdmin(ctx) {
3002
3249
  const sub = ctx.positionals[0];
3003
3250
  if (sub === "replays" || sub === "replay")
3004
3251
  return cmdAdminReplays({ ...ctx, positionals: ctx.positionals.slice(1) }, sub);
3005
3252
  if (sub === "crm") return cmdAdminCrm({ ...ctx, positionals: ctx.positionals.slice(1) });
3253
+ if (sub === "spend") return cmdAdminSpend({ ...ctx, positionals: ctx.positionals.slice(1) });
3006
3254
  if (sub === "create-org") return cmdAdminCreateOrg({ ...ctx, positionals: ctx.positionals.slice(1) });
3007
3255
  if (sub === "ask") return cmdAdminAsk({ ...ctx, positionals: ctx.positionals.slice(1) });
3008
3256
  if (sub === "experiments") return cmdAdminExperiments({ ...ctx, positionals: ctx.positionals.slice(1) });
@@ -3011,6 +3259,6 @@ export async function cmdAdmin(ctx) {
3011
3259
  if (sub === "tuning") return cmdAdminTuning({ ...ctx, positionals: ctx.positionals.slice(1) });
3012
3260
  if (sub === "evals") return cmdAdminEvals({ ...ctx, positionals: ctx.positionals.slice(1) });
3013
3261
  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)'
3262
+ '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
3263
  );
3016
3264
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.73.1",
3
+ "version": "0.75.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",