guilt-max 0.1.0 → 0.1.1

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 (3) hide show
  1. package/README.md +27 -0
  2. package/dist/cli.js +148 -32
  3. package/package.json +10 -4
package/README.md CHANGED
@@ -17,6 +17,33 @@ guilt-max --data-dir <path> # read logs from a specific directory
17
17
 
18
18
  Navigate with `1`–`4` / `←` `→`, regenerate the roast with `r`, quit with `q`.
19
19
 
20
+ ## `usage` — day-wise token & cost breakdown
21
+
22
+ Prints a `ccusage`-style table (date · input · output · cache · total · cost) with a
23
+ grand total, then exits — no TUI:
24
+
25
+ ```bash
26
+ guilt-max usage # daily breakdown + total (table)
27
+ guilt-max usage --json # same data as JSON (daily[], totals, machines[])
28
+ ```
29
+
30
+ ### Across all your machines
31
+
32
+ guilt-max reads local Claude Code logs (`~/.claude/projects`), so a single machine
33
+ only sees its own usage. To get your **combined** usage, copy each machine's
34
+ `~/.claude/projects` into its own folder and point `--data-dir` at each (repeatable,
35
+ or comma-separated). Records are merged and de-duplicated by message id, and each is
36
+ tagged with its folder for the per-machine summary:
37
+
38
+ ```bash
39
+ guilt-max usage --data-dir ~/claude-logs/laptop --data-dir ~/claude-logs/desktop
40
+ ```
41
+
42
+ > Cost is estimated from a bundled price snapshot. On a Pro/Max subscription you pay a
43
+ > flat fee, so the dollar figure is the **notional API-equivalent** value, not what you
44
+ > were billed. (There is no Anthropic API that reports aggregate subscription usage, so
45
+ > merging local logs is the only way to see all machines together.)
46
+
20
47
  ## How it works
21
48
 
22
49
  - Reads `~/.claude/projects/**/*.jsonl` (dedupes by message+request id).
package/dist/cli.js CHANGED
@@ -52,42 +52,53 @@ function parseLine(line) {
52
52
  };
53
53
  }
54
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 };
55
+ let dirs;
56
+ if (opts?.dataDirs?.length) dirs = opts.dataDirs;
57
+ else if (opts?.dataDir) dirs = [opts.dataDir];
58
+ else {
59
+ const found = await findDataDir();
60
+ dirs = found ? [found] : [];
62
61
  }
63
- const files = entries.filter((e) => e.endsWith(".jsonl")).map((e) => join(dataDir, e));
64
62
  const seen = /* @__PURE__ */ new Set();
65
63
  const records = [];
64
+ const used = [];
66
65
  let skipped = 0;
67
- for (const file of files) {
68
- let content;
66
+ for (const dir of dirs) {
67
+ let entries;
69
68
  try {
70
- content = await readFile(file, "utf8");
69
+ entries = await readdir(dir, { recursive: true });
71
70
  } catch {
72
71
  continue;
73
72
  }
74
- for (const line of content.split("\n")) {
75
- if (!line.trim()) continue;
76
- const { record, malformed } = parseLine(line);
77
- if (malformed) {
78
- skipped++;
73
+ used.push(dir);
74
+ const source = basename(dir);
75
+ const files = entries.filter((e) => e.endsWith(".jsonl")).map((e) => join(dir, e));
76
+ for (const file of files) {
77
+ let content;
78
+ try {
79
+ content = await readFile(file, "utf8");
80
+ } catch {
79
81
  continue;
80
82
  }
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);
83
+ for (const line of content.split("\n")) {
84
+ if (!line.trim()) continue;
85
+ const { record, malformed } = parseLine(line);
86
+ if (malformed) {
87
+ skipped++;
88
+ continue;
89
+ }
90
+ if (!record) continue;
91
+ if (record.messageId && record.requestId) {
92
+ const key = `${record.messageId}:${record.requestId}`;
93
+ if (seen.has(key)) continue;
94
+ seen.add(key);
95
+ }
96
+ record.source = source;
97
+ records.push(record);
86
98
  }
87
- records.push(record);
88
99
  }
89
100
  }
90
- return { records, skipped, dataDir };
101
+ return { records, skipped, dataDir: used[0] ?? null, dataDirs: used };
91
102
  }
92
103
 
93
104
  // src/pricing.ts
@@ -456,6 +467,82 @@ async function generateRoast(ctx, opts) {
456
467
  }
457
468
  }
458
469
 
470
+ // src/usage.ts
471
+ function dailyUsage(records) {
472
+ const byDay = /* @__PURE__ */ new Map();
473
+ for (const r of records) {
474
+ const date = localDayKey(r.timestamp);
475
+ let d = byDay.get(date);
476
+ if (!d) {
477
+ d = { date, inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, totalTokens: 0, costUsd: 0 };
478
+ byDay.set(date, d);
479
+ }
480
+ d.inputTokens += r.inputTokens;
481
+ d.outputTokens += r.outputTokens;
482
+ d.cacheCreationTokens += r.cacheCreationTokens;
483
+ d.cacheReadTokens += r.cacheReadTokens;
484
+ d.totalTokens += r.inputTokens + r.outputTokens + r.cacheCreationTokens + r.cacheReadTokens;
485
+ d.costUsd += costOf(r);
486
+ }
487
+ return [...byDay.values()].sort((a, b) => a.date < b.date ? -1 : a.date > b.date ? 1 : 0);
488
+ }
489
+ function usageTotals(rows) {
490
+ return rows.reduce(
491
+ (t, r) => {
492
+ t.inputTokens += r.inputTokens;
493
+ t.outputTokens += r.outputTokens;
494
+ t.cacheCreationTokens += r.cacheCreationTokens;
495
+ t.cacheReadTokens += r.cacheReadTokens;
496
+ t.totalTokens += r.totalTokens;
497
+ t.costUsd += r.costUsd;
498
+ return t;
499
+ },
500
+ { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, totalTokens: 0, costUsd: 0 }
501
+ );
502
+ }
503
+ function byMachine(records) {
504
+ const map = /* @__PURE__ */ new Map();
505
+ for (const r of records) {
506
+ const source = r.source ?? "local";
507
+ let m = map.get(source);
508
+ if (!m) {
509
+ m = { source, records: 0, totalTokens: 0, costUsd: 0 };
510
+ map.set(source, m);
511
+ }
512
+ m.records++;
513
+ m.totalTokens += r.inputTokens + r.outputTokens + r.cacheCreationTokens + r.cacheReadTokens;
514
+ m.costUsd += costOf(r);
515
+ }
516
+ return [...map.values()].sort((a, b) => b.totalTokens - a.totalTokens);
517
+ }
518
+ var COLUMNS = [
519
+ { header: "Date", align: "left", cell: (r) => r.date },
520
+ { header: "Input", align: "right", cell: (r) => fmtInt(r.inputTokens) },
521
+ { header: "Output", align: "right", cell: (r) => fmtInt(r.outputTokens) },
522
+ { header: "Cache Wr", align: "right", cell: (r) => fmtInt(r.cacheCreationTokens) },
523
+ { header: "Cache Rd", align: "right", cell: (r) => fmtInt(r.cacheReadTokens) },
524
+ { header: "Total", align: "right", cell: (r) => fmtInt(r.totalTokens) },
525
+ { header: "Cost", align: "right", cell: (r) => fmtUsd(r.costUsd) }
526
+ ];
527
+ function padCell(s, width, align) {
528
+ return align === "right" ? s.padStart(width) : s.padEnd(width);
529
+ }
530
+ function renderUsageTable(rows, totals) {
531
+ const totalRow = { date: "TOTAL", ...totals };
532
+ const body = rows.map((r) => COLUMNS.map((c) => c.cell(r)));
533
+ const totalCells = COLUMNS.map((c) => c.cell(totalRow));
534
+ const widths = COLUMNS.map(
535
+ (c, i) => Math.max(c.header.length, totalCells[i].length, ...body.map((cells) => cells[i].length))
536
+ );
537
+ const gap = " ";
538
+ const line = (cells) => cells.map((s, i) => padCell(s, widths[i], COLUMNS[i].align)).join(gap);
539
+ const sep = "-".repeat(widths.reduce((a, b) => a + b, 0) + gap.length * (COLUMNS.length - 1));
540
+ const out = [line(COLUMNS.map((c) => c.header)), sep];
541
+ for (const cells of body) out.push(line(cells));
542
+ out.push(sep, line(totalCells));
543
+ return out.join("\n");
544
+ }
545
+
459
546
  // src/tui/App.tsx
460
547
  import { useState, useEffect, useRef, useCallback } from "react";
461
548
  import { Box as Box5, Text as Text5, useApp, useInput, useStdout } from "ink";
@@ -597,24 +684,53 @@ function App({ agg, facts, stats, accent = "cyan", noAi }) {
597
684
  }
598
685
 
599
686
  // 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;
687
+ function getFlagAll(args, name) {
688
+ const out = [];
689
+ for (let i = 0; i < args.length; i++) {
690
+ const a = args[i];
691
+ if (a.startsWith(`--${name}=`)) out.push(a.slice(name.length + 3));
692
+ else if (a === `--${name}` && args[i + 1] && !args[i + 1].startsWith("--")) out.push(args[i + 1]);
693
+ }
694
+ return out.flatMap((v) => v.split(",")).map((s) => s.trim()).filter(Boolean);
606
695
  }
607
696
  async function main() {
608
697
  const args = process.argv.slice(2);
609
698
  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);
699
+ const dataDirs = getFlagAll(args, "data-dir");
700
+ const jsonFlag = args.includes("--json");
701
+ const jsonOut = jsonFlag || !process.stdout.isTTY;
702
+ const usageMode = args.includes("usage") || args.includes("daily") || args.includes("--usage");
703
+ const { records, skipped, dataDir: found, dataDirs: usedDirs } = await loadUsage(
704
+ dataDirs.length ? { dataDirs } : void 0
705
+ );
613
706
  if (!found || records.length === 0) {
614
707
  console.error("\u{1F607} No Claude usage found. Looked in ~/.claude/projects (and ~/.config/claude/projects).");
615
708
  console.error(" Use Claude Code a bit, then come back to feel bad about it.");
616
709
  process.exit(0);
617
710
  }
711
+ if (usageMode) {
712
+ const rows = dailyUsage(records);
713
+ const totals = usageTotals(rows);
714
+ const machines = byMachine(records);
715
+ if (jsonFlag) {
716
+ process.stdout.write(JSON.stringify({ daily: rows, totals, machines, sources: usedDirs, skipped }, null, 2) + "\n");
717
+ return;
718
+ }
719
+ const out = [renderUsageTable(rows, totals)];
720
+ if (machines.length > 1) {
721
+ out.push("", "By machine:");
722
+ for (const m of machines) {
723
+ out.push(` ${m.source.padEnd(14)}${fmtInt(m.totalTokens).padStart(16)} ${fmtUsd(m.costUsd)}`);
724
+ }
725
+ }
726
+ out.push(
727
+ "",
728
+ `Merged ${usedDirs.length} folder${usedDirs.length === 1 ? "" : "s"} \xB7 ${records.length.toLocaleString("en-US")} messages.`,
729
+ "Cost is estimated from a bundled price snapshot \u2014 for subscription usage it is the notional API-equivalent value, not what you paid."
730
+ );
731
+ process.stdout.write(out.join("\n") + "\n");
732
+ return;
733
+ }
618
734
  const agg = aggregate(records);
619
735
  const facts = guiltFacts(agg);
620
736
  const stats = behaviorStats(records, agg);
package/package.json CHANGED
@@ -1,10 +1,14 @@
1
1
  {
2
2
  "name": "guilt-max",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Feel bad about your Claude Code token spend, beautifully, in your terminal.",
5
5
  "type": "module",
6
- "bin": { "guilt-max": "dist/cli.js" },
7
- "files": ["dist"],
6
+ "bin": {
7
+ "guilt-max": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
8
12
  "keywords": [
9
13
  "claude",
10
14
  "claude-code",
@@ -49,5 +53,7 @@
49
53
  "typescript": "^5.6.3",
50
54
  "vitest": "^2.1.5"
51
55
  },
52
- "engines": { "node": ">=18" }
56
+ "engines": {
57
+ "node": ">=18"
58
+ }
53
59
  }