guilt-max 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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -0
  3. package/dist/cli.js +643 -0
  4. package/package.json +53 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 shobhit99
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 ADDED
@@ -0,0 +1,41 @@
1
+ # Guilt Max 😇🔥
2
+
3
+ Feel bad about your Claude Code token spend — beautifully, in your terminal.
4
+
5
+ Guilt Max reads your local Claude Code usage logs and shows you exactly how many
6
+ trees you've torched, litres you've boiled, dollars you've evaporated, and how
7
+ much of a 2am gremlin you really are. Then it lets an AI roast you.
8
+
9
+ ## Usage
10
+
11
+ ```bash
12
+ npx guilt-max # launch the interactive TUI
13
+ guilt-max --no-ai # skip the claude-CLI roast (use local fallback)
14
+ guilt-max --json # dump computed facts + stats as JSON
15
+ guilt-max --data-dir <path> # read logs from a specific directory
16
+ ```
17
+
18
+ Navigate with `1`–`4` / `←` `→`, regenerate the roast with `r`, quit with `q`.
19
+
20
+ ## How it works
21
+
22
+ - Reads `~/.claude/projects/**/*.jsonl` (dedupes by message+request id).
23
+ - Computes cost from a bundled model-price snapshot.
24
+ - Derives playful "guilt" facts and behavioral stats.
25
+ - The Roast tab shells out to your installed `claude` CLI (no API key). If it's
26
+ unavailable, a local rule-based roast steps in.
27
+
28
+ ## Disclaimer
29
+
30
+ The environmental and money numbers are **deliberately rough, playful
31
+ approximations** — tunable in `src/factors.ts`. This is a fun project, not a
32
+ carbon audit.
33
+
34
+ ## Development
35
+
36
+ ```bash
37
+ npm install
38
+ npm test
39
+ npm run dev # tsx src/cli.ts
40
+ npm run build # bundle to dist/ with tsup
41
+ ```
package/dist/cli.js ADDED
@@ -0,0 +1,643 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import React from "react";
5
+ import { render } from "ink";
6
+
7
+ // src/loader.ts
8
+ import { readdir, readFile, stat } from "fs/promises";
9
+ import { homedir } from "os";
10
+ import { join, basename } from "path";
11
+ var DEFAULT_DIRS = [
12
+ join(homedir(), ".claude", "projects"),
13
+ join(homedir(), ".config", "claude", "projects")
14
+ ];
15
+ async function findDataDir(dirs = DEFAULT_DIRS) {
16
+ for (const d of dirs) {
17
+ try {
18
+ if ((await stat(d)).isDirectory()) return d;
19
+ } catch {
20
+ }
21
+ }
22
+ return null;
23
+ }
24
+ function parseLine(line) {
25
+ let obj;
26
+ try {
27
+ obj = JSON.parse(line);
28
+ } catch {
29
+ return { malformed: true };
30
+ }
31
+ const msg = obj?.message;
32
+ const usage = msg?.usage;
33
+ if (!usage || msg?.role !== "assistant") return {};
34
+ const ts = obj.timestamp ? new Date(obj.timestamp) : null;
35
+ if (!ts || Number.isNaN(ts.getTime())) return {};
36
+ const tool = usage.server_tool_use ?? {};
37
+ return {
38
+ record: {
39
+ timestamp: ts,
40
+ model: msg.model ?? "unknown",
41
+ project: obj.cwd ? basename(obj.cwd) : "unknown",
42
+ sessionId: obj.sessionId ?? "unknown",
43
+ inputTokens: usage.input_tokens ?? 0,
44
+ outputTokens: usage.output_tokens ?? 0,
45
+ cacheCreationTokens: usage.cache_creation_input_tokens ?? 0,
46
+ cacheReadTokens: usage.cache_read_input_tokens ?? 0,
47
+ webSearches: tool.web_search_requests ?? 0,
48
+ webFetches: tool.web_fetch_requests ?? 0,
49
+ messageId: msg.id,
50
+ requestId: obj.requestId
51
+ }
52
+ };
53
+ }
54
+ async function loadUsage(opts) {
55
+ const dataDir = opts?.dataDir ?? await findDataDir();
56
+ if (!dataDir) return { records: [], skipped: 0, dataDir: null };
57
+ let entries;
58
+ try {
59
+ entries = await readdir(dataDir, { recursive: true });
60
+ } catch {
61
+ return { records: [], skipped: 0, dataDir: null };
62
+ }
63
+ const files = entries.filter((e) => e.endsWith(".jsonl")).map((e) => join(dataDir, e));
64
+ const seen = /* @__PURE__ */ new Set();
65
+ const records = [];
66
+ let skipped = 0;
67
+ for (const file of files) {
68
+ let content;
69
+ try {
70
+ content = await readFile(file, "utf8");
71
+ } catch {
72
+ continue;
73
+ }
74
+ for (const line of content.split("\n")) {
75
+ if (!line.trim()) continue;
76
+ const { record, malformed } = parseLine(line);
77
+ if (malformed) {
78
+ skipped++;
79
+ continue;
80
+ }
81
+ if (!record) continue;
82
+ if (record.messageId && record.requestId) {
83
+ const key = `${record.messageId}:${record.requestId}`;
84
+ if (seen.has(key)) continue;
85
+ seen.add(key);
86
+ }
87
+ records.push(record);
88
+ }
89
+ }
90
+ return { records, skipped, dataDir };
91
+ }
92
+
93
+ // src/pricing.ts
94
+ var PRICES = {
95
+ "claude-opus-4": { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
96
+ "claude-sonnet-4": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
97
+ "claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
98
+ "claude-haiku-3-5": { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
99
+ "claude-3-5-sonnet": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
100
+ "claude-3-opus": { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
101
+ "claude-3-haiku": { input: 0.25, output: 1.25, cacheRead: 0.03, cacheWrite: 0.3 }
102
+ };
103
+ function priceFor(model) {
104
+ let best = null;
105
+ for (const key of Object.keys(PRICES)) {
106
+ if (model.startsWith(key) && (best === null || key.length > best.length)) best = key;
107
+ }
108
+ return best ? PRICES[best] : null;
109
+ }
110
+ function isKnownModel(model) {
111
+ return priceFor(model) !== null;
112
+ }
113
+ function costOf(rec) {
114
+ const p = priceFor(rec.model);
115
+ if (!p) return 0;
116
+ const M = 1e6;
117
+ return (rec.inputTokens * p.input + rec.outputTokens * p.output + rec.cacheReadTokens * p.cacheRead + rec.cacheCreationTokens * p.cacheWrite) / M;
118
+ }
119
+
120
+ // src/aggregate.ts
121
+ function pad(n) {
122
+ return String(n).padStart(2, "0");
123
+ }
124
+ function localDayKey(d) {
125
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
126
+ }
127
+ function aggregate(records) {
128
+ const byModel = {};
129
+ const byDay = {};
130
+ const byHour = new Array(24).fill(0);
131
+ const byDow = new Array(7).fill(0);
132
+ const byProject = {};
133
+ const sessionMap = /* @__PURE__ */ new Map();
134
+ let totalTokens = 0, inputTokens = 0, outputTokens = 0;
135
+ let cacheCreationTokens = 0, cacheReadTokens = 0;
136
+ let totalCostUsd = 0, unknownModelTokens = 0, webSearches = 0, webFetches = 0;
137
+ let firstSeen = null;
138
+ let lastSeen = null;
139
+ for (const r of records) {
140
+ const t = r.inputTokens + r.outputTokens + r.cacheCreationTokens + r.cacheReadTokens;
141
+ totalTokens += t;
142
+ inputTokens += r.inputTokens;
143
+ outputTokens += r.outputTokens;
144
+ cacheCreationTokens += r.cacheCreationTokens;
145
+ cacheReadTokens += r.cacheReadTokens;
146
+ webSearches += r.webSearches;
147
+ webFetches += r.webFetches;
148
+ const cost = costOf(r);
149
+ totalCostUsd += cost;
150
+ if (!isKnownModel(r.model)) unknownModelTokens += t;
151
+ byModel[r.model] ??= { tokens: 0, costUsd: 0 };
152
+ byModel[r.model].tokens += t;
153
+ byModel[r.model].costUsd += cost;
154
+ byDay[localDayKey(r.timestamp)] = (byDay[localDayKey(r.timestamp)] ?? 0) + t;
155
+ byHour[r.timestamp.getHours()] += t;
156
+ byDow[r.timestamp.getDay()] += t;
157
+ byProject[r.project] = (byProject[r.project] ?? 0) + t;
158
+ const s = sessionMap.get(r.sessionId);
159
+ if (!s) {
160
+ sessionMap.set(r.sessionId, {
161
+ id: r.sessionId,
162
+ start: r.timestamp,
163
+ end: r.timestamp,
164
+ tokens: t,
165
+ project: r.project
166
+ });
167
+ } else {
168
+ if (r.timestamp < s.start) s.start = r.timestamp;
169
+ if (r.timestamp > s.end) s.end = r.timestamp;
170
+ s.tokens += t;
171
+ }
172
+ if (!firstSeen || r.timestamp < firstSeen) firstSeen = r.timestamp;
173
+ if (!lastSeen || r.timestamp > lastSeen) lastSeen = r.timestamp;
174
+ }
175
+ return {
176
+ recordCount: records.length,
177
+ totalTokens,
178
+ inputTokens,
179
+ outputTokens,
180
+ cacheCreationTokens,
181
+ cacheReadTokens,
182
+ totalCostUsd,
183
+ unknownModelTokens,
184
+ webSearches,
185
+ webFetches,
186
+ byModel,
187
+ byDay,
188
+ byHour,
189
+ byDow,
190
+ byProject,
191
+ sessions: [...sessionMap.values()],
192
+ firstSeen,
193
+ lastSeen
194
+ };
195
+ }
196
+
197
+ // src/format.ts
198
+ function fmtNum(n, decimals = 1) {
199
+ if (!Number.isFinite(n)) return "0";
200
+ const abs = Math.abs(n);
201
+ if (abs >= 1e3) return Math.round(n).toLocaleString("en-US");
202
+ if (abs >= 10) return n.toFixed(0);
203
+ return n.toFixed(decimals);
204
+ }
205
+ function fmtInt(n) {
206
+ return Math.round(n).toLocaleString("en-US");
207
+ }
208
+ function fmtUsd(n) {
209
+ return `$${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
210
+ }
211
+ function fmtDuration(ms) {
212
+ const totalMin = Math.max(0, Math.round(ms / 6e4));
213
+ const h = Math.floor(totalMin / 60);
214
+ const m = totalMin % 60;
215
+ return h > 0 ? `${h}h ${m}m` : `${m}m`;
216
+ }
217
+
218
+ // src/factors.ts
219
+ var FACTORS = {
220
+ ENERGY_WH_PER_1K_TOKENS: 0.4,
221
+ // Wh per 1,000 tokens
222
+ GRID_CO2_KG_PER_KWH: 0.4,
223
+ // grid carbon intensity
224
+ DC_WATER_L_PER_KWH: 1.8,
225
+ // data-center water per kWh
226
+ TREE_CO2_KG_PER_YEAR: 21,
227
+ // CO2 a tree sequesters per year
228
+ PHONE_KWH_PER_CHARGE: 0.019,
229
+ // energy to charge a phone
230
+ LED_WATTS: 10,
231
+ // LED bulb wattage
232
+ CAR_CO2_KG_PER_MILE: 0.25,
233
+ // gas car emissions per mile
234
+ LATTE_USD: 5,
235
+ NETFLIX_USD: 15.49,
236
+ PS5_USD: 499,
237
+ AVOCADO_TOAST_USD: 12,
238
+ WAR_AND_PEACE_TOKENS: 78e4,
239
+ TOKENS_PER_WORD: 1.33,
240
+ LIFETIME_SPOKEN_WORDS: 86e7
241
+ };
242
+ function energyKwh(totalTokens) {
243
+ return totalTokens / 1e3 * FACTORS.ENERGY_WH_PER_1K_TOKENS / 1e3;
244
+ }
245
+ function fact(key, emoji, value, unit, display, headline) {
246
+ return { key, emoji, value, unit, display, headline };
247
+ }
248
+ function guiltFacts(agg) {
249
+ const t = agg.totalTokens;
250
+ const kwh = energyKwh(t);
251
+ const co2 = kwh * FACTORS.GRID_CO2_KG_PER_KWH;
252
+ const water = kwh * FACTORS.DC_WATER_L_PER_KWH;
253
+ const trees = FACTORS.TREE_CO2_KG_PER_YEAR > 0 ? co2 / FACTORS.TREE_CO2_KG_PER_YEAR : 0;
254
+ const phones = FACTORS.PHONE_KWH_PER_CHARGE > 0 ? kwh / FACTORS.PHONE_KWH_PER_CHARGE : 0;
255
+ const ledHours = kwh / (FACTORS.LED_WATTS / 1e3);
256
+ const miles = FACTORS.CAR_CO2_KG_PER_MILE > 0 ? co2 / FACTORS.CAR_CO2_KG_PER_MILE : 0;
257
+ const cost = agg.totalCostUsd;
258
+ const lattes = cost / FACTORS.LATTE_USD;
259
+ const netflix = cost / FACTORS.NETFLIX_USD;
260
+ const ps5 = cost / FACTORS.PS5_USD;
261
+ const toasts = cost / FACTORS.AVOCADO_TOAST_USD;
262
+ const wap = t / FACTORS.WAR_AND_PEACE_TOKENS;
263
+ const words = t / FACTORS.TOKENS_PER_WORD;
264
+ const lifetimes = words / FACTORS.LIFETIME_SPOKEN_WORDS;
265
+ const opusTokens = Object.entries(agg.byModel).filter(([m]) => m.includes("opus")).reduce((s, [, v]) => s + v.tokens, 0);
266
+ const opusPct = t > 0 ? opusTokens / t * 100 : 0;
267
+ const freshInput = agg.inputTokens + agg.cacheCreationTokens;
268
+ const inputSide = freshInput + agg.cacheReadTokens;
269
+ const cacheMissPct = inputSide > 0 ? freshInput / inputSide * 100 : 0;
270
+ const environmental = [
271
+ fact("trees", "\u{1F333}", trees, "trees", fmtNum(trees), `\u{1F333} You've torched ${fmtNum(trees)} trees. The planet sends its regards.`),
272
+ fact("water", "\u{1F4A7}", water, "L", fmtNum(water), `\u{1F4A7} ${fmtNum(water)} L of water boiled off \u2014 a spa day for the data center.`),
273
+ fact("co2", "\u2601\uFE0F", co2, "kg", fmtNum(co2), `\u2601\uFE0F ${fmtNum(co2)} kg of CO\u2082. Your keyboard has a tailpipe now.`),
274
+ fact("kwh", "\u26A1", kwh, "kWh", fmtNum(kwh), `\u26A1 ${fmtNum(kwh)} kWh incinerated on your behalf.`),
275
+ fact("phones", "\u{1F4F1}", phones, "charges", fmtInt(phones), `\u{1F4F1} Enough juice to charge a phone ${fmtInt(phones)} times.`),
276
+ fact("ledHours", "\u{1F4A1}", ledHours, "hours", fmtInt(ledHours), `\u{1F4A1} ${fmtInt(ledHours)} hours of a lightbulb, gone.`),
277
+ fact("carMiles", "\u{1F697}", miles, "miles", fmtNum(miles), `\u{1F697} ${fmtNum(miles)} miles of driving in emissions.`)
278
+ ];
279
+ const money = [
280
+ fact("cost", "\u{1F4B8}", cost, "USD", fmtUsd(cost), `\u{1F4B8} ${fmtUsd(cost)} of cold, hard, evaporated cash.`),
281
+ fact("lattes", "\u2615", lattes, "lattes", fmtInt(lattes), `\u2615 That's ${fmtInt(lattes)} oat-milk lattes you'll never drink.`),
282
+ fact("netflix", "\u{1F4FA}", netflix, "months", fmtNum(netflix), `\u{1F4FA} ${fmtNum(netflix)} months of Netflix, up in smoke.`),
283
+ fact("ps5", "\u{1F3AE}", ps5, "PS5s", fmtNum(ps5), `\u{1F3AE} ${fmtNum(ps5)} PlayStation 5s. The physical kind.`),
284
+ fact("toasts", "\u{1F951}", toasts, "toasts", fmtInt(toasts), `\u{1F951} ${fmtInt(toasts)} avocado toasts. Millennials weep.`)
285
+ ];
286
+ const volume = [
287
+ fact("warAndPeace", "\u{1F4DA}", wap, "\xD7 War & Peace", fmtNum(wap), `\u{1F4DA} You've generated ${fmtNum(wap)}\xD7 War & Peace.`),
288
+ fact("lifetimes", "\u{1F5E3}\uFE0F", lifetimes, "lifetimes", fmtNum(lifetimes), `\u{1F5E3}\uFE0F ${fmtNum(lifetimes)} human lifetimes of non-stop talking.`),
289
+ fact("opusPct", "\u{1F451}", opusPct, "%", fmtNum(opusPct), `\u{1F451} ${fmtNum(opusPct)}% of your tokens were Opus. Champagne taste.`),
290
+ fact("cacheMissPct", "\u{1F5D1}\uFE0F", cacheMissPct, "%", fmtNum(cacheMissPct), `\u{1F5D1}\uFE0F ${fmtNum(cacheMissPct)}% cache-miss rate. You love paying full price.`),
291
+ fact("webSearches", "\u{1F50E}", agg.webSearches, "searches", fmtInt(agg.webSearches), `\u{1F50E} ${fmtInt(agg.webSearches)} web searches you could've Googled yourself.`)
292
+ ];
293
+ const byKey = {};
294
+ for (const f of [...environmental, ...money, ...volume]) byKey[f.key] = f;
295
+ return { environmental, money, volume, byKey };
296
+ }
297
+
298
+ // src/stats.ts
299
+ var NIGHT_HOURS = /* @__PURE__ */ new Set([22, 23, 0, 1, 2, 3, 4]);
300
+ function argmaxIndex(arr) {
301
+ let best = 0;
302
+ for (let i = 1; i < arr.length; i++) if (arr[i] > arr[best]) best = i;
303
+ return best;
304
+ }
305
+ function longestStreak(dayKeys) {
306
+ if (dayKeys.length === 0) return 0;
307
+ const days = [...new Set(dayKeys)].sort();
308
+ const toNum = (k) => {
309
+ const [y, m, d] = k.split("-").map(Number);
310
+ return Date.UTC(y, m - 1, d) / 864e5;
311
+ };
312
+ let best = 1;
313
+ let run = 1;
314
+ for (let i = 1; i < days.length; i++) {
315
+ if (toNum(days[i]) - toNum(days[i - 1]) === 1) run++;
316
+ else run = 1;
317
+ if (run > best) best = run;
318
+ }
319
+ return best;
320
+ }
321
+ function behaviorStats(records, agg) {
322
+ const total = agg.totalTokens;
323
+ let nightTokens = 0;
324
+ for (let h = 0; h < 24; h++) if (NIGHT_HOURS.has(h)) nightTokens += agg.byHour[h];
325
+ const nightOwlPct = total > 0 ? nightTokens / total * 100 : 0;
326
+ const peakHour = argmaxIndex(agg.byHour);
327
+ const weekendTokens = agg.byDow[0] + agg.byDow[6];
328
+ const weekendPct = total > 0 ? weekendTokens / total * 100 : 0;
329
+ let longest = { ms: 0, project: "\u2014" };
330
+ for (const s of agg.sessions) {
331
+ const ms = s.end.getTime() - s.start.getTime();
332
+ if (ms > longest.ms) longest = { ms, project: s.project };
333
+ }
334
+ let topProject = "\u2014";
335
+ let topProjectTokens = 0;
336
+ for (const [proj, tok] of Object.entries(agg.byProject)) {
337
+ if (tok > topProjectTokens) {
338
+ topProjectTokens = tok;
339
+ topProject = proj;
340
+ }
341
+ }
342
+ const streak = longestStreak(Object.keys(agg.byDay));
343
+ const isNightOwl = nightOwlPct >= 30 || NIGHT_HOURS.has(peakHour);
344
+ const headlines = {
345
+ nightOwl: `\u{1F989} ${fmtNum(nightOwlPct)}% of your tokens burned between 10pm and 5am.`,
346
+ peakHour: `\u23F0 Peak guilt hour: ${String(peakHour).padStart(2, "0")}:00.`,
347
+ weekend: `\u{1F4C6} ${fmtNum(weekendPct)}% on weekends. Rest is for the weak.`,
348
+ longestSession: `\u{1F525} Longest binge: ${fmtDuration(longest.ms)} in "${longest.project}".`,
349
+ mostAbused: `\u{1F3AF} Most-abused project: "${topProject}" (${fmtInt(topProjectTokens)} tokens).`,
350
+ streak: `\u{1F4C8} Longest daily streak: ${streak} day${streak === 1 ? "" : "s"}. Touch grass.`,
351
+ verdict: isNightOwl ? "\u{1F319} Verdict: certified night owl." : "\u{1F31E} Verdict: suspiciously well-adjusted."
352
+ };
353
+ return {
354
+ nightOwlPct,
355
+ peakHour,
356
+ isNightOwl,
357
+ weekendPct,
358
+ longestSessionMs: longest.ms,
359
+ longestSessionProject: longest.project,
360
+ mostAbusedProject: topProject,
361
+ mostAbusedProjectTokens: topProjectTokens,
362
+ longestStreakDays: streak,
363
+ headlines
364
+ };
365
+ }
366
+
367
+ // src/ai.ts
368
+ import { execa } from "execa";
369
+ var ALLOWED_COLORS = ["red", "green", "yellow", "blue", "magenta", "cyan"];
370
+ var PROMPT = [
371
+ 'You are a witty, playful roast-master for "Guilt Max", a tool that makes',
372
+ "developers feel (lightly) guilty about their AI token spend. You will receive",
373
+ "a JSON summary of one user's usage. Respond with ONLY a single JSON object,",
374
+ "no prose, of the exact shape:",
375
+ '{"roast": "<2-4 sentence playful, self-aware roast about their habits and spend>",',
376
+ ' "preset": {"persona": "<a fun 1-3 word persona title>",',
377
+ ` "accentColor": "<one of: ${ALLOWED_COLORS.join(", ")}>",`,
378
+ ' "headlineFactKey": "<one fact key from the payload factKeys array>"}}',
379
+ "Keep it funny, never cruel. British-tabloid energy."
380
+ ].join(" ");
381
+ function buildPayload(agg, facts, stats) {
382
+ return {
383
+ totalTokens: agg.totalTokens,
384
+ costUsd: Number(agg.totalCostUsd.toFixed(2)),
385
+ topFacts: [...facts.environmental, ...facts.money, ...facts.volume].slice(0, 8).map((f) => f.headline),
386
+ nightOwlPct: Number(stats.nightOwlPct.toFixed(1)),
387
+ peakHour: stats.peakHour,
388
+ weekendPct: Number(stats.weekendPct.toFixed(1)),
389
+ opusPct: Number(facts.byKey["opusPct"]?.value.toFixed(1) ?? 0),
390
+ cacheMissPct: Number(facts.byKey["cacheMissPct"]?.value.toFixed(1) ?? 0),
391
+ mostAbusedProject: stats.mostAbusedProject,
392
+ factKeys: Object.keys(facts.byKey)
393
+ };
394
+ }
395
+ function parseRoast(raw) {
396
+ const start = raw.indexOf("{");
397
+ const end = raw.lastIndexOf("}");
398
+ if (start === -1 || end <= start) return null;
399
+ try {
400
+ const obj = JSON.parse(raw.slice(start, end + 1));
401
+ if (typeof obj?.roast !== "string" || typeof obj?.preset?.persona !== "string") return null;
402
+ return {
403
+ roast: obj.roast,
404
+ preset: {
405
+ persona: obj.preset.persona,
406
+ accentColor: obj.preset.accentColor,
407
+ headlineFactKey: obj.preset.headlineFactKey
408
+ }
409
+ };
410
+ } catch {
411
+ return null;
412
+ }
413
+ }
414
+ function sanitizePreset(p, facts) {
415
+ return {
416
+ persona: p.persona?.slice(0, 40) || "Token Gremlin",
417
+ accentColor: ALLOWED_COLORS.includes(p.accentColor) ? p.accentColor : "cyan",
418
+ headlineFactKey: Object.prototype.hasOwnProperty.call(facts.byKey, p.headlineFactKey) ? p.headlineFactKey : "trees"
419
+ };
420
+ }
421
+ function localPreset(agg, facts, stats) {
422
+ const opusPct = facts.byKey["opusPct"]?.value ?? 0;
423
+ const cacheMiss = facts.byKey["cacheMissPct"]?.value ?? 0;
424
+ if (stats.nightOwlPct >= 50) return { persona: "Night Owl", accentColor: "magenta", headlineFactKey: "kwh" };
425
+ if (opusPct >= 40) return { persona: "Opus Aristocrat", accentColor: "yellow", headlineFactKey: "cost" };
426
+ if (agg.totalCostUsd >= 200) return { persona: "Big Spender", accentColor: "red", headlineFactKey: "cost" };
427
+ if (cacheMiss >= 60) return { persona: "Cache Criminal", accentColor: "blue", headlineFactKey: "cacheMissPct" };
428
+ return { persona: "Token Gremlin", accentColor: "green", headlineFactKey: "trees" };
429
+ }
430
+ function localRoast(preset, facts, stats) {
431
+ const trees = facts.byKey["trees"]?.display ?? "0";
432
+ const cost = fmtUsd(facts.byKey["cost"]?.value ?? 0);
433
+ return [
434
+ `${preset.persona}, is it? ${cost} and ${trees} trees later, here we are.`,
435
+ stats.isNightOwl ? "The 2am sessions are showing." : "At least you sleep.",
436
+ "The models thank you for your service. The planet, less so."
437
+ ].join(" ");
438
+ }
439
+ var defaultRunner = async (input) => {
440
+ const { stdout } = await execa("claude", ["-p", PROMPT], { input, timeout: 6e4 });
441
+ return stdout;
442
+ };
443
+ async function generateRoast(ctx, opts) {
444
+ const fallback = () => {
445
+ const preset = localPreset(ctx.agg, ctx.facts, ctx.stats);
446
+ return { roast: localRoast(preset, ctx.facts, ctx.stats), preset, offline: true };
447
+ };
448
+ if (opts?.noAi) return fallback();
449
+ try {
450
+ const raw = await (opts?.runner ?? defaultRunner)(JSON.stringify(buildPayload(ctx.agg, ctx.facts, ctx.stats)));
451
+ const parsed = parseRoast(raw);
452
+ if (!parsed) return fallback();
453
+ return { roast: parsed.roast, preset: sanitizePreset(parsed.preset, ctx.facts), offline: false };
454
+ } catch {
455
+ return fallback();
456
+ }
457
+ }
458
+
459
+ // src/tui/App.tsx
460
+ import { useState, useEffect, useRef, useCallback } from "react";
461
+ import { Box as Box5, Text as Text5, useApp, useInput, useStdout } from "ink";
462
+
463
+ // src/tui/tabs/Guilt.tsx
464
+ import { Box, Text } from "ink";
465
+ import { jsx, jsxs } from "react/jsx-runtime";
466
+ function GuiltTab({ facts, accent }) {
467
+ const rows = [...facts.environmental, ...facts.money, ...facts.volume];
468
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
469
+ /* @__PURE__ */ jsx(Text, { color: accent, bold: true, children: "Your crimes against the planet & your wallet:" }),
470
+ rows.map((f) => /* @__PURE__ */ jsx(Text, { children: f.headline }, f.key))
471
+ ] });
472
+ }
473
+
474
+ // src/tui/tabs/Charts.tsx
475
+ import { Box as Box2, Text as Text2 } from "ink";
476
+
477
+ // src/charts.ts
478
+ var BLOCKS = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
479
+ function level(value, max) {
480
+ if (max <= 0) return BLOCKS[0];
481
+ const idx = Math.round(value / max * (BLOCKS.length - 1));
482
+ return BLOCKS[Math.min(BLOCKS.length - 1, Math.max(0, idx))];
483
+ }
484
+ function sparkline(values) {
485
+ const max = Math.max(0, ...values);
486
+ return values.map((v) => level(v, max)).join("");
487
+ }
488
+ function hourHeatmap(byHour) {
489
+ const max = Math.max(0, ...byHour);
490
+ return byHour.map((v) => level(v, max)).join("");
491
+ }
492
+ function barChart(data, width = 24) {
493
+ const max = Math.max(0, ...data.map(([, v]) => v));
494
+ const labelW = Math.max(0, ...data.map(([l]) => l.length));
495
+ return data.map(([label, val]) => {
496
+ const len = max > 0 ? Math.round(val / max * width) : 0;
497
+ return `${label.padEnd(labelW)} ${"\u2588".repeat(len)}`;
498
+ });
499
+ }
500
+
501
+ // src/tui/tabs/Charts.tsx
502
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
503
+ function ChartsTab({ agg }) {
504
+ const days = Object.keys(agg.byDay).sort();
505
+ const recentDays = days.slice(-60);
506
+ const daily = recentDays.map((d) => agg.byDay[d]);
507
+ const models = Object.entries(agg.byModel).map(([m, v]) => [m.replace("claude-", ""), v.tokens]).sort((a, b) => b[1] - a[1]);
508
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
509
+ /* @__PURE__ */ jsxs2(Text2, { bold: true, children: [
510
+ "Daily tokens",
511
+ days.length > 60 ? " (last 60 days)" : ""
512
+ ] }),
513
+ /* @__PURE__ */ jsx2(Text2, { children: daily.length ? sparkline(daily) : "(no data)" }),
514
+ /* @__PURE__ */ jsx2(Box2, { marginTop: 1, children: /* @__PURE__ */ jsx2(Text2, { bold: true, children: "Hour of day (local)" }) }),
515
+ /* @__PURE__ */ jsx2(Text2, { children: hourHeatmap(agg.byHour) }),
516
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "0 6 12 18" }),
517
+ /* @__PURE__ */ jsx2(Box2, { marginTop: 1, children: /* @__PURE__ */ jsx2(Text2, { bold: true, children: "By model (tokens)" }) }),
518
+ barChart(models, 20).map((line, i) => /* @__PURE__ */ jsx2(Text2, { children: line }, i))
519
+ ] });
520
+ }
521
+
522
+ // src/tui/tabs/Behavior.tsx
523
+ import { Box as Box3, Text as Text3 } from "ink";
524
+ import { jsx as jsx3 } from "react/jsx-runtime";
525
+ function BehaviorTab({ stats }) {
526
+ const order = ["verdict", "nightOwl", "peakHour", "weekend", "longestSession", "mostAbused", "streak"];
527
+ return /* @__PURE__ */ jsx3(Box3, { flexDirection: "column", children: order.map((k) => /* @__PURE__ */ jsx3(Text3, { children: stats.headlines[k] }, k)) });
528
+ }
529
+
530
+ // src/tui/tabs/Roast.tsx
531
+ import { Box as Box4, Text as Text4 } from "ink";
532
+ import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
533
+ function RoastTab({ result, loading }) {
534
+ if (loading || !result) {
535
+ return /* @__PURE__ */ jsx4(Text4, { color: "yellow", children: "\u{1F525} Summoning your personalized roast\u2026" });
536
+ }
537
+ return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", children: [
538
+ /* @__PURE__ */ jsxs3(Text4, { color: result.preset.accentColor, bold: true, children: [
539
+ "\u258D ",
540
+ result.preset.persona
541
+ ] }),
542
+ /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(Text4, { children: result.roast }) }),
543
+ result.offline && /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "(offline roast \u2014 claude CLI unavailable)" }) }),
544
+ /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "press r to regenerate" }) })
545
+ ] });
546
+ }
547
+
548
+ // src/tui/App.tsx
549
+ import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
550
+ var TABS = ["Guilt", "Charts", "Behavior", "Roast"];
551
+ function App({ agg, facts, stats, accent = "cyan", noAi }) {
552
+ const { exit } = useApp();
553
+ const { stdout } = useStdout();
554
+ const [tab, setTab] = useState(0);
555
+ const [roast, setRoast] = useState(null);
556
+ const [roastLoading, setRoastLoading] = useState(false);
557
+ const roastStarted = useRef(false);
558
+ const runRoast = useCallback(() => {
559
+ setRoastLoading(true);
560
+ generateRoast({ agg, facts, stats }, { noAi }).then((r) => {
561
+ setRoast(r);
562
+ setRoastLoading(false);
563
+ });
564
+ }, [agg, facts, stats, noAi]);
565
+ useEffect(() => {
566
+ if (tab === 3 && !roastStarted.current) {
567
+ roastStarted.current = true;
568
+ runRoast();
569
+ }
570
+ }, [tab, runRoast]);
571
+ useInput((input, key) => {
572
+ if (input === "q" || key.ctrl && input === "c") exit();
573
+ else if (key.leftArrow) setTab((t) => (t + TABS.length - 1) % TABS.length);
574
+ else if (key.rightArrow || key.tab) setTab((t) => (t + 1) % TABS.length);
575
+ else if (["1", "2", "3", "4"].includes(input)) setTab(Number(input) - 1);
576
+ else if (input === "r" && tab === 3) runRoast();
577
+ });
578
+ return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", borderStyle: "round", paddingX: 1, minHeight: stdout?.rows ?? 24, children: [
579
+ /* @__PURE__ */ jsxs4(Box5, { children: [
580
+ /* @__PURE__ */ jsx5(Text5, { bold: true, children: "GUILT MAX " }),
581
+ TABS.map((label, i) => /* @__PURE__ */ jsxs4(Text5, { color: i === tab ? accent : "gray", bold: i === tab, children: [
582
+ "[",
583
+ i + 1,
584
+ "]",
585
+ label,
586
+ " "
587
+ ] }, label))
588
+ ] }),
589
+ /* @__PURE__ */ jsxs4(Box5, { marginTop: 1, flexDirection: "column", children: [
590
+ tab === 0 && /* @__PURE__ */ jsx5(GuiltTab, { facts, accent }),
591
+ tab === 1 && /* @__PURE__ */ jsx5(ChartsTab, { agg }),
592
+ tab === 2 && /* @__PURE__ */ jsx5(BehaviorTab, { stats }),
593
+ tab === 3 && /* @__PURE__ */ jsx5(RoastTab, { result: roast, loading: roastLoading })
594
+ ] }),
595
+ /* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "\u2190\u2192 / 1-4 switch \xB7 r roast \xB7 q quit" }) })
596
+ ] });
597
+ }
598
+
599
+ // src/cli.ts
600
+ function getFlag(args, name) {
601
+ const eq = args.find((a) => a.startsWith(`--${name}=`));
602
+ if (eq) return eq.slice(name.length + 3);
603
+ const idx = args.indexOf(`--${name}`);
604
+ if (idx !== -1 && args[idx + 1] && !args[idx + 1].startsWith("--")) return args[idx + 1];
605
+ return void 0;
606
+ }
607
+ async function main() {
608
+ const args = process.argv.slice(2);
609
+ const noAi = args.includes("--no-ai");
610
+ const dataDir = getFlag(args, "data-dir");
611
+ const jsonOut = args.includes("--json") || !process.stdout.isTTY;
612
+ const { records, skipped, dataDir: found } = await loadUsage(dataDir ? { dataDir } : void 0);
613
+ if (!found || records.length === 0) {
614
+ console.error("\u{1F607} No Claude usage found. Looked in ~/.claude/projects (and ~/.config/claude/projects).");
615
+ console.error(" Use Claude Code a bit, then come back to feel bad about it.");
616
+ process.exit(0);
617
+ }
618
+ const agg = aggregate(records);
619
+ const facts = guiltFacts(agg);
620
+ const stats = behaviorStats(records, agg);
621
+ if (jsonOut) {
622
+ process.stdout.write(JSON.stringify({ agg, facts, stats, skipped }, null, 2) + "\n");
623
+ return;
624
+ }
625
+ const accent = localPreset(agg, facts, stats).accentColor;
626
+ const enterAltScreen = "\x1B[?1049h";
627
+ const leaveAltScreen = "\x1B[?1049l";
628
+ let restored = false;
629
+ const restore = () => {
630
+ if (restored) return;
631
+ restored = true;
632
+ process.stdout.write(leaveAltScreen);
633
+ };
634
+ process.stdout.write(enterAltScreen);
635
+ process.on("exit", restore);
636
+ const { waitUntilExit } = render(React.createElement(App, { agg, facts, stats, accent, noAi }));
637
+ await waitUntilExit();
638
+ restore();
639
+ }
640
+ main().catch((err) => {
641
+ console.error("guilt-max crashed:", err?.message ?? err);
642
+ process.exit(1);
643
+ });
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "guilt-max",
3
+ "version": "0.1.0",
4
+ "description": "Feel bad about your Claude Code token spend, beautifully, in your terminal.",
5
+ "type": "module",
6
+ "bin": { "guilt-max": "dist/cli.js" },
7
+ "files": ["dist"],
8
+ "keywords": [
9
+ "claude",
10
+ "claude-code",
11
+ "anthropic",
12
+ "cli",
13
+ "tui",
14
+ "ink",
15
+ "tokens",
16
+ "usage",
17
+ "cost",
18
+ "guilt"
19
+ ],
20
+ "author": "shobhit99 <shobhitbhosure7@gmail.com>",
21
+ "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/shobhit99/guilt-max.git"
25
+ },
26
+ "homepage": "https://github.com/shobhit99/guilt-max#readme",
27
+ "bugs": {
28
+ "url": "https://github.com/shobhit99/guilt-max/issues"
29
+ },
30
+ "scripts": {
31
+ "dev": "tsx src/cli.ts",
32
+ "build": "tsup src/cli.ts --format esm --clean --target node18",
33
+ "test": "vitest run",
34
+ "test:watch": "vitest",
35
+ "typecheck": "tsc --noEmit",
36
+ "prepublishOnly": "npm run build"
37
+ },
38
+ "dependencies": {
39
+ "execa": "^9.5.0",
40
+ "ink": "^5.1.0",
41
+ "react": "^18.3.1"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^22.9.0",
45
+ "@types/react": "^18.3.12",
46
+ "ink-testing-library": "^4.0.0",
47
+ "tsup": "^8.3.5",
48
+ "tsx": "^4.19.2",
49
+ "typescript": "^5.6.3",
50
+ "vitest": "^2.1.5"
51
+ },
52
+ "engines": { "node": ">=18" }
53
+ }