burnrate-cli 0.0.0-stage → 0.1.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/dist/cli.js ADDED
@@ -0,0 +1,387 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import { localDate, priceAll, summarize } from "./aggregate.js";
5
+ import { loadConfig, PLAN_PRESETS, saveConfig } from "./config.js";
6
+ import { clock, duration, shortProject, tokens, usd, when } from "./format.js";
7
+ import { notify, openBrowser } from "./notify.js";
8
+ import { generateDemo } from "./demo.js";
9
+ import { dataDir } from "./paths.js";
10
+ import { PriceBook } from "./pricing.js";
11
+ import { createServer } from "./server.js";
12
+ import { Store } from "./store.js";
13
+ const pkg = createRequire(import.meta.url)("../package.json");
14
+ // ───────────── args ─────────────
15
+ const argv = process.argv.slice(2);
16
+ const BOOL_FLAGS = new Set(["csv", "json", "clear", "open", "no-open", "no-titles", "no-color", "verbose", "help", "version", "demo", "no-plan"]);
17
+ const flags = {};
18
+ const positional = [];
19
+ const extraPaths = [];
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const a = argv[i];
22
+ if (a.startsWith("--")) {
23
+ const [k, v] = a.slice(2).split("=", 2);
24
+ const next = argv[i + 1];
25
+ const isBool = BOOL_FLAGS.has(k);
26
+ const val = v ?? (!isBool && next !== undefined && !next.startsWith("--") ? (i++, next) : true);
27
+ if (k === "path")
28
+ extraPaths.push(String(val));
29
+ else
30
+ flags[k] = val;
31
+ }
32
+ else if (a === "-h")
33
+ flags.help = true;
34
+ else if (a === "-v")
35
+ flags.version = true;
36
+ else
37
+ positional.push(a);
38
+ }
39
+ const cmd = positional[0] ?? "dashboard";
40
+ let DIR = typeof flags.home === "string" ? flags.home : dataDir();
41
+ let useDefaults = true;
42
+ const titles = !flags["no-titles"];
43
+ // ───────────── colour ─────────────
44
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR && !flags["no-color"];
45
+ const c = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
46
+ const bold = c("1"), dim = c("2"), red = c("31"), green = c("32"), yellow = c("33"), orange = c("38;5;208"), cyan = c("36");
47
+ function meter(frac, width = 24) {
48
+ const f = Math.max(0, Math.min(1, frac));
49
+ const full = Math.round(f * width);
50
+ const col = f >= 1 ? red : f >= 0.8 ? yellow : green;
51
+ return col("█".repeat(full)) + dim("░".repeat(width - full));
52
+ }
53
+ function load(opts = {}) {
54
+ const store = new Store(DIR).load();
55
+ const res = store.scan({ extraPaths, titles, defaults: useDefaults });
56
+ if (!opts.quiet && flags.verbose)
57
+ console.error(dim(`scanned ${res.filesScanned} files, ${res.newEvents} new events`));
58
+ return { store, res };
59
+ }
60
+ function summary(days = 30, now) {
61
+ const { store } = load({ quiet: true });
62
+ const s = summarize(store.events, store.meta.sessions, store.meta.limits, store.meta.rateLimits, loadConfig(DIR), PriceBook.load(DIR), { days, now });
63
+ return { s, store };
64
+ }
65
+ // ───────────── commands ─────────────
66
+ function help() {
67
+ console.log(`${bold("burnrate")} ${dim("v" + pkg.version)} — what your coding agents cost, and when you'll hit the wall.
68
+
69
+ ${bold("Usage")}
70
+ burnrate scan logs and open the dashboard (http://127.0.0.1:4317)
71
+ burnrate status spend summary + current 5-hour block forecast
72
+ burnrate statusline one line for Claude Code's statusLine (reads its JSON on stdin)
73
+ burnrate bar xbar / SwiftBar menu-bar plugin output
74
+ burnrate sessions [--days 30] [--limit 20] most expensive sessions
75
+ burnrate export [--csv|--json] [--days 30] per-message usage with cost
76
+ burnrate budget [--block 40] [--week 300] [--month 1000] [--clear]
77
+ burnrate plan <${Object.keys(PLAN_PRESETS).join("|")}> | --name X --usd N | --clear
78
+ burnrate watch [--interval 60] desktop alerts at 80% / 100% of budgets
79
+ burnrate demo try it with 30 days of synthetic usage (your data is untouched)
80
+ burnrate scan | reset | serve [--port 4317] [--no-open]
81
+
82
+ ${bold("Options")}
83
+ --path <dir> extra log directory (prefix "codex:" for Codex logs); repeatable
84
+ --home <dir> data directory (default ~/.burnrate, or $BURNRATE_HOME)
85
+ --no-titles don't store session titles (first prompt)
86
+ --no-color plain output
87
+
88
+ Logs read: ~/.claude/projects (Claude Code, incl. subagents) and ~/.codex/sessions (Codex CLI).
89
+ Costs are API-equivalent estimates using public per-token prices; override in ~/.burnrate/prices.json.`);
90
+ }
91
+ function printStatus() {
92
+ const { s } = summary(30);
93
+ const b = s.block;
94
+ console.log();
95
+ console.log(` ${orange("🔥")} ${bold("Today")} ${bold(usd(s.totals.today))} ${dim("yesterday")} ${usd(s.totals.yesterday)} ${dim("7d")} ${usd(s.totals.last7)} ${dim("month")} ${usd(s.totals.mtd)} ${dim(`→ ~${usd(s.totals.monthProjection)}`)}`);
96
+ if (s.plan?.valueMultiple) {
97
+ console.log(` ${dim("plan")} ${s.plan.name} ${usd(s.plan.monthlyUsd)}/mo ${dim("→")} ${green(`${s.plan.valueMultiple.toFixed(1)}× value`)} ${dim("(projected API-equivalent)")}`);
98
+ }
99
+ console.log();
100
+ if (b && b.active) {
101
+ console.log(` ${bold("5-hour block")} ${dim(`${clock(b.start)}–${clock(b.end)} · ${duration(b.remainingMs)} left`)}`);
102
+ if (b.budget) {
103
+ console.log(` ${meter(b.percent ?? 0)} ${bold(usd(b.cost))} ${dim("of")} ${usd(b.budget)} ${dim(b.budgetSource === "learned" ? "(learned cap)" : "")}`);
104
+ }
105
+ else {
106
+ console.log(` ${bold(usd(b.cost))} ${dim("spent · no block budget set (burnrate budget --block 40)")}`);
107
+ }
108
+ const rate = `${usd(b.burnPerHour)}/h`;
109
+ if (b.hitsBudgetAt)
110
+ console.log(` ${red("▲")} At ${rate} you hit the budget at ${bold(clock(b.hitsBudgetAt))} ${dim(`(in ${duration(b.hitsBudgetAt - Date.now())})`)}`);
111
+ else
112
+ console.log(` ${dim("burn")} ${rate} ${dim("· projected block total")} ${usd(b.projected)}`);
113
+ }
114
+ else {
115
+ console.log(` ${dim("No active 5-hour block.")}${b ? dim(` Last block ${when(b.start)}: ${usd(b.cost)}`) : ""}`);
116
+ }
117
+ for (const r of s.rateLimits) {
118
+ const parts = [r.primary && `5h ${r.primary.usedPercent.toFixed(0)}%`, r.secondary && `week ${r.secondary.usedPercent.toFixed(0)}%`].filter(Boolean);
119
+ if (parts.length)
120
+ console.log(` ${cyan(r.agent)} ${dim("reported limits:")} ${parts.join(dim(" · "))}${r.primary?.resetsAt ? dim(` · resets ${when(r.primary.resetsAt)}`) : ""}`);
121
+ }
122
+ if (s.week.budget)
123
+ console.log(` ${dim("7 days")} ${meter(s.week.percent ?? 0, 16)} ${usd(s.week.cost)} / ${usd(s.week.budget)}`);
124
+ if (s.month.budget)
125
+ console.log(` ${dim("month")} ${meter(s.month.percent ?? 0, 16)} ${usd(s.month.cost)} / ${usd(s.month.budget)}`);
126
+ console.log();
127
+ const top = s.sessions.slice(0, 5);
128
+ if (top.length) {
129
+ console.log(` ${bold("Top sessions")} ${dim("(30 days)")}`);
130
+ for (const x of top) {
131
+ console.log(` ${usd(x.cost).padStart(9)} ${(x.title ?? x.id.slice(0, 8)).slice(0, 52).padEnd(52)} ${dim(shortProject(x.project))}`);
132
+ }
133
+ console.log();
134
+ }
135
+ if (s.totals.cacheSavings30d > 1)
136
+ console.log(` ${dim("Prompt caching saved")} ${green(usd(s.totals.cacheSavings30d))} ${dim("in 30 days.")}`);
137
+ if (s.unpricedModels.length)
138
+ console.log(` ${yellow("!")} ${dim("unpriced models (add to ~/.burnrate/prices.json):")} ${s.unpricedModels.join(", ")}`);
139
+ if (!s.eventCount)
140
+ console.log(dim(" No agent logs found yet. Run Claude Code or Codex, or pass --path <dir>."));
141
+ console.log();
142
+ }
143
+ async function readStdin(timeoutMs = 300) {
144
+ if (process.stdin.isTTY)
145
+ return "";
146
+ return new Promise((resolve) => {
147
+ let data = "";
148
+ const t = setTimeout(() => resolve(data), timeoutMs);
149
+ process.stdin.setEncoding("utf8");
150
+ process.stdin.on("data", (d) => (data += d));
151
+ process.stdin.on("end", () => {
152
+ clearTimeout(t);
153
+ resolve(data);
154
+ });
155
+ });
156
+ }
157
+ async function statusline() {
158
+ const raw = await readStdin();
159
+ let sessionId = null;
160
+ try {
161
+ sessionId = JSON.parse(raw).session_id ?? null;
162
+ }
163
+ catch {
164
+ /* not JSON */
165
+ }
166
+ const { s, store } = summary(1);
167
+ const parts = [];
168
+ if (sessionId) {
169
+ const prices = PriceBook.load(DIR);
170
+ const sc = store.events.filter((e) => e.session === sessionId).reduce((a, e) => a + (prices.cost(e) ?? 0), 0);
171
+ parts.push(`${orange("◆")} ${usd(sc)} ${dim("session")}`);
172
+ }
173
+ parts.push(`${usd(s.totals.today)} ${dim("today")}`);
174
+ const b = s.block;
175
+ if (b?.active) {
176
+ if (b.percent !== null) {
177
+ const pct = Math.round(b.percent * 100);
178
+ const col = b.percent >= 1 ? red : b.percent >= 0.8 ? yellow : green;
179
+ parts.push(`${col(`${pct}%`)} ${dim("block")}`);
180
+ }
181
+ parts.push(`${usd(b.burnPerHour)}/h`);
182
+ if (b.hitsBudgetAt)
183
+ parts.push(red(`limit ~${clock(b.hitsBudgetAt)}`));
184
+ else
185
+ parts.push(dim(`resets ${clock(b.end)}`));
186
+ }
187
+ process.stdout.write(parts.join(dim(" · ")) + "\n");
188
+ }
189
+ function bar() {
190
+ const { s } = summary(7);
191
+ const b = s.block;
192
+ const pct = b?.active && b.percent !== null ? ` ${Math.round(b.percent * 100)}%` : "";
193
+ const warn = b?.hitsBudgetAt ? " ⚠︎" : "";
194
+ console.log(`🔥 ${usd(s.totals.today)}${pct}${warn}`);
195
+ console.log("---");
196
+ console.log(`Today ${usd(s.totals.today)} · 7 days ${usd(s.totals.last7)} | font=Menlo`);
197
+ console.log(`Month ${usd(s.totals.mtd)} → ~${usd(s.totals.monthProjection)} | font=Menlo`);
198
+ if (b?.active) {
199
+ console.log("---");
200
+ console.log(`Block ${clock(b.start)}–${clock(b.end)}: ${usd(b.cost)}${b.budget ? ` of ${usd(b.budget)}` : ""}`);
201
+ console.log(`Burn ${usd(b.burnPerHour)}/h · ${duration(b.remainingMs)} left`);
202
+ if (b.hitsBudgetAt)
203
+ console.log(`Hits budget at ${clock(b.hitsBudgetAt)} | color=#d03b3b`);
204
+ }
205
+ console.log("---");
206
+ for (const x of s.sessions.slice(0, 5))
207
+ console.log(`${usd(x.cost)} ${(x.title ?? x.id).slice(0, 40).replace(/\|/g, "/")} | font=Menlo size=12`);
208
+ console.log("---");
209
+ console.log("Open dashboard | href=http://127.0.0.1:4317");
210
+ }
211
+ function sessions() {
212
+ const days = Number(flags.days) || 30;
213
+ const limit = Number(flags.limit) || 20;
214
+ const { s } = summary(days);
215
+ console.log(`\n ${bold(`Most expensive sessions — last ${days} days`)}\n`);
216
+ for (const x of s.sessions.slice(0, limit)) {
217
+ console.log(` ${bold(usd(x.cost).padStart(9))} ${dim(when(x.start).padEnd(10))} ${(x.title ?? x.id.slice(0, 8)).slice(0, 56).padEnd(56)} ${dim(`${shortProject(x.project)} · ${tokens(x.tokens)} tok`)}`);
218
+ }
219
+ console.log();
220
+ }
221
+ function exportData() {
222
+ const days = Number(flags.days) || 30;
223
+ const { store } = load({ quiet: true });
224
+ const since = Date.now() - days * 86400_000;
225
+ const rows = priceAll(store.events, PriceBook.load(DIR)).filter((e) => e.ts >= since);
226
+ if (flags.json) {
227
+ process.stdout.write(JSON.stringify(rows, null, 2) + "\n");
228
+ return;
229
+ }
230
+ const cols = ["timestamp", "date", "agent", "session", "title", "project", "model", "input", "output", "cache_read", "cache_write_5m", "cache_write_1h", "web_searches", "cost_usd"];
231
+ const q = (v) => {
232
+ const str = String(v ?? "");
233
+ return /[",\n]/.test(str) ? `"${str.replace(/"/g, '""')}"` : str;
234
+ };
235
+ const out = [cols.join(",")];
236
+ for (const e of rows) {
237
+ out.push([new Date(e.ts).toISOString(), localDate(e.ts), e.agent, e.session, store.meta.sessions[e.session]?.title ?? "", e.project, e.model, e.input, e.output, e.cacheRead, e.cacheWrite5m, e.cacheWrite1h, e.webSearches, e.priced ? e.cost.toFixed(6) : ""]
238
+ .map(q)
239
+ .join(","));
240
+ }
241
+ process.stdout.write(out.join("\n") + "\n");
242
+ }
243
+ function budget() {
244
+ const cfg = loadConfig(DIR);
245
+ if (flags.clear)
246
+ cfg.budgets = {};
247
+ const set = (k, f) => {
248
+ if (flags[f] !== undefined) {
249
+ const v = Number(flags[f]);
250
+ cfg.budgets[k] = v > 0 ? v : undefined;
251
+ }
252
+ };
253
+ set("blockUsd", "block");
254
+ set("weekUsd", "week");
255
+ set("monthUsd", "month");
256
+ saveConfig(DIR, cfg);
257
+ const b = cfg.budgets;
258
+ console.log(`Budgets — 5h block: ${usd(b.blockUsd ?? null)} · 7 days: ${usd(b.weekUsd ?? null)} · month: ${usd(b.monthUsd ?? null)}`);
259
+ if (!b.blockUsd)
260
+ console.log(dim("Tip: without a block budget, Burnrate uses the cap it learned from your last limit hit (if any)."));
261
+ }
262
+ function plan() {
263
+ const cfg = loadConfig(DIR);
264
+ const preset = positional[1];
265
+ if (flags.clear)
266
+ cfg.plan = undefined;
267
+ else if (preset && PLAN_PRESETS[preset])
268
+ cfg.plan = PLAN_PRESETS[preset];
269
+ else if (flags.usd)
270
+ cfg.plan = { name: String(flags.name ?? "My plan"), monthlyUsd: Number(flags.usd) };
271
+ else if (preset) {
272
+ console.error(`Unknown plan "${preset}". Presets: ${Object.keys(PLAN_PRESETS).join(", ")}`);
273
+ process.exit(1);
274
+ }
275
+ saveConfig(DIR, cfg);
276
+ console.log(cfg.plan ? `Plan: ${cfg.plan.name} (${usd(cfg.plan.monthlyUsd)}/month)` : "No plan set.");
277
+ }
278
+ async function watch() {
279
+ const interval = Math.max(Number(flags.interval) || 60, 10) * 1000;
280
+ const fired = new Set();
281
+ console.log(dim(`Watching every ${interval / 1000}s. Ctrl+C to stop.`));
282
+ const tick = () => {
283
+ const { s } = summary(7);
284
+ const cfg = loadConfig(DIR);
285
+ const checks = [
286
+ [`block:${s.block?.start}`, s.block?.active ? s.block.percent : null, "5-hour block"],
287
+ [`week`, s.week.percent, "7-day budget"],
288
+ [`month:${new Date().getMonth()}`, s.month.percent, "monthly budget"],
289
+ ];
290
+ for (const [key, pct, label] of checks) {
291
+ if (pct === null)
292
+ continue;
293
+ for (const t of cfg.alertAt) {
294
+ const k = `${key}:${t}`;
295
+ if (pct >= t && !fired.has(k)) {
296
+ fired.add(k);
297
+ const extra = label === "5-hour block" && s.block?.hitsBudgetAt ? ` Limit ~${clock(s.block.hitsBudgetAt)}.` : "";
298
+ notify(`Burnrate: ${Math.round(pct * 100)}% of ${label}`, `${usd(label === "5-hour block" ? s.block.cost : label === "7-day budget" ? s.week.cost : s.month.cost)} spent.${extra}`);
299
+ }
300
+ }
301
+ }
302
+ };
303
+ tick();
304
+ setInterval(tick, interval);
305
+ }
306
+ function serve(open) {
307
+ const port = Number(flags.port) || 4317;
308
+ const { res } = load();
309
+ const server = createServer({ dataDir: DIR, extraPaths, titles, defaults: useDefaults, port });
310
+ server.on("error", (err) => {
311
+ if (err.code === "EADDRINUSE") {
312
+ console.log(`Burnrate is already running → http://127.0.0.1:${port}`);
313
+ if (open)
314
+ openBrowser(`http://127.0.0.1:${port}`);
315
+ process.exit(0);
316
+ }
317
+ throw err;
318
+ });
319
+ server.listen(port, "127.0.0.1", () => {
320
+ const url = `http://127.0.0.1:${port}`;
321
+ console.log(`\n ${orange("🔥")} ${bold("Burnrate")} ${dim("v" + pkg.version)} ${url}`);
322
+ for (const r of res.roots)
323
+ console.log(dim(` ${r.agent.padEnd(12)} ${r.files} log files ${r.dir}`));
324
+ if (!res.roots.length)
325
+ console.log(yellow(" No Claude Code or Codex logs found yet."));
326
+ console.log(dim(" Ctrl+C to stop\n"));
327
+ if (open)
328
+ openBrowser(url);
329
+ });
330
+ }
331
+ async function main() {
332
+ if (flags.version)
333
+ return console.log(pkg.version);
334
+ if (flags.help || cmd === "help")
335
+ return help();
336
+ if (cmd === "demo" || flags.demo) {
337
+ // Synthetic 30-day history in a temp dir — never touches your real data.
338
+ const demo = generateDemo(Number(flags.days) || 30);
339
+ DIR = `${demo.root}/home`;
340
+ useDefaults = false;
341
+ extraPaths.push(demo.claude, `codex:${demo.codex}`);
342
+ if (!flags["no-plan"])
343
+ saveConfig(DIR, { budgets: { blockUsd: 28, monthUsd: 600 }, plan: PLAN_PRESETS["claude-max-20x"], alertAt: [0.8, 1] });
344
+ if (cmd === "demo")
345
+ return serve(!flags["no-open"]);
346
+ }
347
+ switch (cmd) {
348
+ case "dashboard":
349
+ return serve(!flags["no-open"]);
350
+ case "serve":
351
+ return serve(Boolean(flags.open));
352
+ case "status":
353
+ return printStatus();
354
+ case "statusline":
355
+ return statusline();
356
+ case "bar":
357
+ return bar();
358
+ case "sessions":
359
+ return sessions();
360
+ case "export":
361
+ return exportData();
362
+ case "budget":
363
+ return budget();
364
+ case "plan":
365
+ return plan();
366
+ case "watch":
367
+ return watch();
368
+ case "scan": {
369
+ const { res, store } = load();
370
+ for (const r of res.roots)
371
+ console.log(`${r.agent.padEnd(12)} ${String(r.files).padStart(5)} files ${r.dir}`);
372
+ console.log(`${store.events.length} usage events (${res.newEvents} new) · data in ${DIR}`);
373
+ return;
374
+ }
375
+ case "reset":
376
+ new Store(DIR).reset();
377
+ fs.rmSync(DIR + "/events.ndjson", { force: true });
378
+ return console.log(`Cleared cached usage in ${DIR} (config kept).`);
379
+ default:
380
+ console.error(`Unknown command "${cmd}". Run burnrate --help.`);
381
+ process.exit(1);
382
+ }
383
+ }
384
+ main().catch((err) => {
385
+ console.error(red(String(err?.stack ?? err)));
386
+ process.exit(1);
387
+ });
package/dist/config.js ADDED
@@ -0,0 +1,23 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ export const DEFAULT_CONFIG = { budgets: {}, alertAt: [0.8, 1] };
4
+ export function loadConfig(dir) {
5
+ try {
6
+ const raw = JSON.parse(fs.readFileSync(path.join(dir, "config.json"), "utf8"));
7
+ return { ...DEFAULT_CONFIG, ...raw, budgets: { ...raw.budgets } };
8
+ }
9
+ catch {
10
+ return structuredClone(DEFAULT_CONFIG);
11
+ }
12
+ }
13
+ export function saveConfig(dir, cfg) {
14
+ fs.mkdirSync(dir, { recursive: true });
15
+ fs.writeFileSync(path.join(dir, "config.json"), JSON.stringify(cfg, null, 2) + "\n");
16
+ }
17
+ export const PLAN_PRESETS = {
18
+ "claude-pro": { name: "Claude Pro", monthlyUsd: 20 },
19
+ "claude-max-5x": { name: "Claude Max 5x", monthlyUsd: 100 },
20
+ "claude-max-20x": { name: "Claude Max 20x", monthlyUsd: 200 },
21
+ "chatgpt-plus": { name: "ChatGPT Plus", monthlyUsd: 20 },
22
+ "chatgpt-pro": { name: "ChatGPT Pro", monthlyUsd: 200 },
23
+ };