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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Emmanuel Idoko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,130 @@
1
- # Temporary Holding Version
1
+ # 🔥 Burnrate
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **See what your coding agents really cost, and when you'll hit the wall.**
4
+
5
+ Burnrate is a local, zero-dependency spend and quota meter for **Claude Code** and **Codex CLI**. It reads the logs your agents already write and shows:
6
+
7
+ - a live **5-hour block forecast**: *"At $5.96/h you'll hit your budget at 17:44, 15 minutes before the reset."*
8
+ - **attribution** by session (titled by your first prompt), project, model and agent: *"that refactor cost $14.23"*
9
+ - **value for money** on subscriptions: *"Claude Max 20x → 3.5× value ($708 of API-equivalent usage for $200)"*
10
+ - a **status line** for Claude Code, a **menu-bar** plugin, a terminal summary and CSV export
11
+
12
+ ```bash
13
+ npx burnrate-cli # opens http://127.0.0.1:4317
14
+ npx burnrate-cli demo # try it with 30 days of synthetic data
15
+ ```
16
+
17
+ ![Burnrate dashboard](docs/screenshots/dashboard-light.png)
18
+
19
+ > Nothing leaves your machine: no account, no API key, no telemetry. The dashboard binds to `127.0.0.1` only.
20
+
21
+ ---
22
+
23
+ ## Why the numbers are right
24
+
25
+ Most usage calculators over-count. Claude Code writes **one log line per content block, each repeating the same `usage`**. In a real session we measured 228 assistant lines for 92 actual API responses, so naive summing inflates cost about 2.5×. Prompt-cache writes also cost **1.25× input for the 5-minute TTL and 2× for the 1-hour TTL**, and web searches cost $10 per 1,000.
26
+
27
+ Burnrate deduplicates by `message.id + requestId` and prices every component. Its test suite reproduces Claude Code's own recorded `cost-state` for a real session **to the 7th decimal place** ($15.0607934 Opus + $1.1404568 Haiku). See [`tests/pricing.test.ts`](tests/pricing.test.ts).
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ npm i -g burnrate-cli # installs the `burnrate` command (or use npx burnrate-cli)
33
+ ```
34
+
35
+ Requires Node 20+. Works on macOS, Linux and Windows.
36
+
37
+ ## Commands
38
+
39
+ | Command | What it does |
40
+ |---|---|
41
+ | `burnrate` | Scan logs and open the dashboard |
42
+ | `burnrate status` | Today, 7 days, month and projection, current block meter and forecast, top sessions |
43
+ | `burnrate statusline` | One line for Claude Code's status line (reads its JSON on stdin) |
44
+ | `burnrate bar` | xbar / SwiftBar menu-bar output |
45
+ | `burnrate sessions [--days 30] [--limit 20]` | Most expensive sessions |
46
+ | `burnrate export [--csv\|--json] [--days 30]` | Per-message usage with cost |
47
+ | `burnrate budget --block 40 --week 300 --month 1000` | Set budgets (`--clear` to remove) |
48
+ | `burnrate plan claude-max-20x` | Set your subscription to see the value multiple (`--name X --usd N` for custom) |
49
+ | `burnrate watch` | Desktop notifications at 80% and 100% of any budget |
50
+ | `burnrate demo` | Dashboard on synthetic data; your real data is untouched |
51
+ | `burnrate scan` / `reset` / `serve --port N` | Utilities |
52
+
53
+ Global options: `--path <dir>` (repeatable; prefix `codex:` for Codex logs), `--home <dir>`, `--no-titles`, `--no-color`.
54
+
55
+ ### Claude Code status line
56
+
57
+ Add to `~/.claude/settings.json`:
58
+
59
+ ```json
60
+ { "statusLine": { "type": "command", "command": "burnrate statusline" } }
61
+ ```
62
+
63
+ ```
64
+ ◆ $3.12 session · $20.97 today · 75% block · $5.96/h · limit ~17:44
65
+ ```
66
+
67
+ ### macOS menu bar (xbar / SwiftBar)
68
+
69
+ ```bash
70
+ cp integrations/burnrate.1m.sh ~/Library/Application\ Support/xbar/plugins/
71
+ ```
72
+
73
+ ```
74
+ 🔥 $20.97 75% ⚠︎
75
+ ```
76
+
77
+ ## How limits work
78
+
79
+ Vendors don't publish exact subscription quotas, so Burnrate combines three signals:
80
+
81
+ 1. **Your budget:** `burnrate budget --block 40` or *Budgets & plan* in the dashboard.
82
+ 2. **Learned caps:** when a "limit reached" message appears in your Claude Code logs, Burnrate records how much you had spent in the 5 hours before it. With no budget set, it uses the smallest of your last three hits as the cap.
83
+ 3. **Reported limits:** Codex logs its own 5-hour and weekly used-percentages and reset times, and Burnrate shows them as-is.
84
+
85
+ **Blocks** start at your first message after the previous block ended, floored to the hour, and last 5 hours. **Burn rate** is the spend over the last hour of the active block. The forecast is `now + (budget − spent) / burn rate`.
86
+
87
+ ## Data & privacy
88
+
89
+ | What | Where |
90
+ |---|---|
91
+ | Claude Code logs (read-only) | `~/.claude/projects/**/*.jsonl` (+ `$CLAUDE_CONFIG_DIR`), subagents included |
92
+ | Codex CLI logs (read-only) | `~/.codex/sessions/**/*.jsonl` (+ `$CODEX_HOME`) |
93
+ | Burnrate's cache & config | `~/.burnrate/` (`$BURNRATE_HOME`): `events.ndjson`, `state.json`, `meta.json`, `config.json`, optional `prices.json` |
94
+
95
+ Burnrate stores token counts, timestamps, model, project path and the **first 80 characters of each session's first prompt** (as its title). Use `--no-titles` to skip titles. Scans are incremental (byte offsets per file), so refreshing costs milliseconds.
96
+
97
+ **Custom prices.** Costs are *API-equivalent estimates* from public per-token prices (see [`src/pricing.ts`](src/pricing.ts)). To add or override a price, create `~/.burnrate/prices.json`:
98
+
99
+ ```json
100
+ { "my-model": { "input": 1, "output": 4, "cacheRead": 0.1 } }
101
+ ```
102
+
103
+ ## Development
104
+
105
+ ```bash
106
+ npm install
107
+ npm test # vitest: pricing, parsers, incremental store, blocks & forecast
108
+ npm run build # tsc → dist/, copies the dashboard
109
+ node dist/cli.js demo
110
+ ```
111
+
112
+ ```
113
+ src/
114
+ cli.ts commands
115
+ parsers/ claude.ts · codex.ts — defensive line parsers
116
+ store.ts incremental ingest, dedupe, ~/.burnrate cache
117
+ pricing.ts price table + cost engine
118
+ aggregate.ts totals, 5-hour blocks, forecast, learned caps, breakdowns
119
+ server.ts localhost dashboard API (DNS-rebinding + CSRF guarded)
120
+ dashboard/ index.html · styles.css · app.js (vanilla, hand-built SVG charts)
121
+ demo.ts deterministic synthetic logs in both formats
122
+ ```
123
+
124
+ Docs: [Product requirements](docs/PRD.md) · [Design](docs/DESIGN.md)
125
+
126
+ **Releasing.** Bump `version` in `package.json` and push to `main`. The release workflow publishes to npm with provenance (trusted publishing, or an `NPM_TOKEN` secret), then creates the tag and GitHub release.
127
+
128
+ ## License
129
+
130
+ MIT
@@ -0,0 +1,213 @@
1
+ export const BLOCK_MS = 5 * 3600_000;
2
+ const HOUR = 3600_000;
3
+ const DAY = 86400_000;
4
+ export function localDate(ts) {
5
+ const d = new Date(ts);
6
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
7
+ }
8
+ function startOfDay(ts) {
9
+ const d = new Date(ts);
10
+ d.setHours(0, 0, 0, 0);
11
+ return d.getTime();
12
+ }
13
+ export function priceAll(events, prices) {
14
+ return events
15
+ .map((e) => {
16
+ const c = prices.cost(e);
17
+ return {
18
+ ...e,
19
+ cost: c ?? 0,
20
+ priced: c !== null,
21
+ tokens: e.input + e.output + e.cacheRead + e.cacheWrite5m + e.cacheWrite1h,
22
+ };
23
+ })
24
+ .sort((a, b) => a.ts - b.ts);
25
+ }
26
+ /**
27
+ * Splits activity into 5-hour blocks the way subscription windows work:
28
+ * a block opens at the first message after the previous block ended (floored to the hour).
29
+ */
30
+ export function computeBlocks(events) {
31
+ const blocks = [];
32
+ let cur = null;
33
+ for (const e of events) {
34
+ if (!cur || e.ts >= cur.end) {
35
+ const start = Math.floor(e.ts / HOUR) * HOUR;
36
+ cur = { start, end: start + BLOCK_MS, cost: 0, tokens: 0, events: 0, firstTs: e.ts, lastTs: e.ts };
37
+ blocks.push(cur);
38
+ }
39
+ cur.cost += e.cost;
40
+ cur.tokens += e.tokens;
41
+ cur.events++;
42
+ cur.lastTs = e.ts;
43
+ }
44
+ return blocks;
45
+ }
46
+ export function summarize(events, sessions, limits, rateLimits, cfg, prices, opts = {}) {
47
+ const now = opts.now ?? Date.now();
48
+ const days = opts.days ?? 30;
49
+ const priced = priceAll(events, prices).filter((e) => e.ts <= now + 60_000);
50
+ const today0 = startOfDay(now);
51
+ const month0 = new Date(new Date(now).getFullYear(), new Date(now).getMonth(), 1).getTime();
52
+ const daysInMonth = new Date(new Date(now).getFullYear(), new Date(now).getMonth() + 1, 0).getDate();
53
+ const dayOfMonth = new Date(now).getDate();
54
+ let today = 0, yesterday = 0, last7 = 0, mtd = 0, allTime = 0, tokensToday = 0, savings = 0;
55
+ const since30 = now - 30 * DAY;
56
+ for (const e of priced) {
57
+ allTime += e.cost;
58
+ if (e.ts >= today0) {
59
+ today += e.cost;
60
+ tokensToday += e.tokens;
61
+ }
62
+ else if (e.ts >= today0 - DAY)
63
+ yesterday += e.cost;
64
+ if (e.ts >= now - 7 * DAY)
65
+ last7 += e.cost;
66
+ if (e.ts >= month0)
67
+ mtd += e.cost;
68
+ if (e.ts >= since30 && e.priced) {
69
+ const p = prices.lookup(e.model);
70
+ savings += (e.cacheRead * (p.input - p.cacheRead)) / 1e6;
71
+ }
72
+ }
73
+ const avgDaily7 = last7 / 7;
74
+ const monthProjection = mtd + avgDaily7 * (daysInMonth - dayOfMonth + (1 - (now - today0) / DAY));
75
+ // Blocks & forecast
76
+ const blocks = computeBlocks(priced);
77
+ const learnedCaps = [];
78
+ // The vendor's window isn't visible to us, so estimate the cap as spend in the 5 hours before the hit.
79
+ const limitRows = limits.slice(-20).map((l) => {
80
+ const window = priced.filter((e) => e.ts > l.ts - BLOCK_MS && e.ts <= l.ts);
81
+ const blockCost = window.length ? window.reduce((s, e) => s + e.cost, 0) : null;
82
+ if (blockCost && l.agent === "claude-code")
83
+ learnedCaps.push(blockCost);
84
+ return { ...l, blockCost };
85
+ });
86
+ // Conservative: the smallest observed cap among the most recent three hits.
87
+ const learnedCap = learnedCaps.length ? Math.min(...learnedCaps.slice(-3)) : null;
88
+ const last = blocks[blocks.length - 1];
89
+ let block = null;
90
+ if (last) {
91
+ const active = now < last.end && now >= last.start;
92
+ const blockEvents = priced.filter((e) => e.ts >= last.start && e.ts < last.end);
93
+ const windowStart = Math.max(last.start, now - HOUR);
94
+ const recent = blockEvents.filter((e) => e.ts >= windowStart).reduce((s, e) => s + e.cost, 0);
95
+ const elapsedH = Math.max((now - windowStart) / HOUR, 1 / 6);
96
+ const burnPerHour = active ? recent / elapsedH : 0;
97
+ const remainingMs = active ? last.end - now : 0;
98
+ const budget = cfg.budgets.blockUsd ?? learnedCap ?? null;
99
+ const projected = last.cost + burnPerHour * (remainingMs / HOUR);
100
+ let hitsBudgetAt = null;
101
+ if (active && budget && burnPerHour > 0) {
102
+ const t = last.cost >= budget ? now : now + ((budget - last.cost) / burnPerHour) * HOUR;
103
+ if (t < last.end)
104
+ hitsBudgetAt = t;
105
+ }
106
+ const series = [];
107
+ let cum = 0, idx = 0;
108
+ const stop = active ? now : last.lastTs;
109
+ for (let t = last.start; t <= stop + 10 * 60_000; t += 10 * 60_000) {
110
+ while (idx < blockEvents.length && blockEvents[idx].ts < t)
111
+ cum += blockEvents[idx++].cost;
112
+ series.push({ t: Math.min(t, stop), cost: cum });
113
+ if (t >= stop)
114
+ break;
115
+ }
116
+ block = {
117
+ start: last.start,
118
+ end: last.end,
119
+ active,
120
+ cost: last.cost,
121
+ tokens: last.tokens,
122
+ burnPerHour,
123
+ projected,
124
+ budget,
125
+ budgetSource: cfg.budgets.blockUsd ? "user" : learnedCap ? "learned" : null,
126
+ percent: budget ? last.cost / budget : null,
127
+ hitsBudgetAt,
128
+ remainingMs,
129
+ series,
130
+ };
131
+ }
132
+ // Daily series
133
+ const dayMap = new Map();
134
+ for (let i = days - 1; i >= 0; i--)
135
+ dayMap.set(localDate(now - i * DAY), { total: 0, tokens: 0, byAgent: {} });
136
+ for (const e of priced) {
137
+ const row = dayMap.get(localDate(e.ts));
138
+ if (!row)
139
+ continue;
140
+ row.total += e.cost;
141
+ row.tokens += e.tokens;
142
+ row.byAgent[e.agent] = (row.byAgent[e.agent] ?? 0) + e.cost;
143
+ }
144
+ // Breakdown tables (within the selected range)
145
+ const rangeStart = startOfDay(now - (days - 1) * DAY);
146
+ const inRange = priced.filter((e) => e.ts >= rangeStart);
147
+ const agents = new Map();
148
+ const models = new Map();
149
+ const sess = new Map();
150
+ for (const e of inRange) {
151
+ const a = agents.get(e.agent) ?? { cost: 0, tokens: 0, events: 0 };
152
+ a.cost += e.cost;
153
+ a.tokens += e.tokens;
154
+ a.events++;
155
+ agents.set(e.agent, a);
156
+ const m = models.get(e.model) ?? { model: e.model, cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, priced: e.priced };
157
+ m.cost += e.cost;
158
+ m.input += e.input;
159
+ m.output += e.output;
160
+ m.cacheRead += e.cacheRead;
161
+ m.cacheWrite += e.cacheWrite5m + e.cacheWrite1h;
162
+ models.set(e.model, m);
163
+ const meta = sessions[e.session];
164
+ const s = sess.get(e.session) ?? {
165
+ id: e.session, title: meta?.title ?? null, agent: e.agent, project: meta?.project || e.project,
166
+ cost: 0, tokens: 0, start: e.ts, end: e.ts, models: [], modelSet: new Set(),
167
+ };
168
+ s.cost += e.cost;
169
+ s.tokens += e.tokens;
170
+ s.end = Math.max(s.end, e.ts);
171
+ s.start = Math.min(s.start, e.ts);
172
+ s.modelSet.add(e.model);
173
+ sess.set(e.session, s);
174
+ }
175
+ const sessionList = [...sess.values()].map(({ modelSet, ...s }) => ({ ...s, models: [...modelSet] }));
176
+ const projects = new Map();
177
+ for (const s of sessionList) {
178
+ const p = projects.get(s.project) ?? { project: s.project, cost: 0, tokens: 0, sessions: 0, lastTs: 0 };
179
+ p.cost += s.cost;
180
+ p.tokens += s.tokens;
181
+ p.sessions++;
182
+ p.lastTs = Math.max(p.lastTs, s.end);
183
+ projects.set(s.project, p);
184
+ }
185
+ const weekBudget = cfg.budgets.weekUsd ?? null;
186
+ const monthBudget = cfg.budgets.monthUsd ?? null;
187
+ const plan = cfg.plan
188
+ ? { ...cfg.plan, valueMultiple: cfg.plan.monthlyUsd > 0 ? monthProjection / cfg.plan.monthlyUsd : null }
189
+ : null;
190
+ return {
191
+ generatedAt: now,
192
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
193
+ totals: {
194
+ today, yesterday, last7, mtd, monthProjection, allTime, tokensToday,
195
+ cacheSavings30d: savings,
196
+ firstEventTs: priced[0]?.ts ?? null,
197
+ },
198
+ plan,
199
+ block,
200
+ week: { cost: last7, budget: weekBudget, percent: weekBudget ? last7 / weekBudget : null },
201
+ month: { cost: mtd, budget: monthBudget, percent: monthBudget ? mtd / monthBudget : null, projected: monthProjection },
202
+ learnedCap,
203
+ daily: [...dayMap.entries()].map(([date, v]) => ({ date, ...v })),
204
+ agents: [...agents.entries()].map(([agent, v]) => ({ agent, ...v })).sort((a, b) => b.cost - a.cost),
205
+ models: [...models.values()].sort((a, b) => b.cost - a.cost),
206
+ projects: [...projects.values()].sort((a, b) => b.cost - a.cost),
207
+ sessions: sessionList.sort((a, b) => b.cost - a.cost).slice(0, 100),
208
+ limits: limitRows.reverse(),
209
+ rateLimits: Object.values(rateLimits),
210
+ unpricedModels: [...new Set(priced.filter((e) => !e.priced).map((e) => e.model))],
211
+ eventCount: priced.length,
212
+ };
213
+ }