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.
- package/README.md +27 -0
- package/dist/cli.js +148 -32
- 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
|
-
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
68
|
-
let
|
|
66
|
+
for (const dir of dirs) {
|
|
67
|
+
let entries;
|
|
69
68
|
try {
|
|
70
|
-
|
|
69
|
+
entries = await readdir(dir, { recursive: true });
|
|
71
70
|
} catch {
|
|
72
71
|
continue;
|
|
73
72
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
if (
|
|
85
|
-
|
|
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
|
|
601
|
-
const
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
|
611
|
-
const
|
|
612
|
-
const
|
|
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.
|
|
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": {
|
|
7
|
-
|
|
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": {
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=18"
|
|
58
|
+
}
|
|
53
59
|
}
|