@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 +13 -1
- package/lib/commands/admin.mjs +217 -1
- package/package.json +1 -1
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.
|
|
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
|
package/lib/commands/admin.mjs
CHANGED
|
@@ -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
|
}
|