cctrack 0.1.0__py3-none-any.whl
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.
- cctrack/__init__.py +342 -0
- cctrack/__main__.py +3 -0
- cctrack-0.1.0.dist-info/METADATA +116 -0
- cctrack-0.1.0.dist-info/RECORD +7 -0
- cctrack-0.1.0.dist-info/WHEEL +4 -0
- cctrack-0.1.0.dist-info/entry_points.txt +2 -0
- cctrack-0.1.0.dist-info/licenses/LICENSE +21 -0
cctrack/__init__.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""cctrack — Claude Code cost reporter.
|
|
3
|
+
|
|
4
|
+
Scans JSONL logs from Claude Code and Sandy sandboxes,
|
|
5
|
+
tallies token usage, and prints daily + monthly cost summaries.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
python cctrack.py # local only
|
|
9
|
+
python cctrack.py --days 7 # last 7 days
|
|
10
|
+
python cctrack.py --remote dgx macbook-air # aggregate with remote hosts
|
|
11
|
+
python cctrack.py --dirs ~/custom # custom log directory
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
from collections import defaultdict
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
# ── Rate card (per million tokens) ──────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
# Longer/more-specific prefixes MUST come first — "claude-opus-4-6" would
|
|
26
|
+
# otherwise match "claude-opus-4" and get the wrong rate.
|
|
27
|
+
RATES = [
|
|
28
|
+
{"family": "claude-opus-4-6", "input": 5.0, "output": 25.0, "cache_read": 0.50, "cache_write": 6.25},
|
|
29
|
+
{"family": "claude-opus-4-5", "input": 5.0, "output": 25.0, "cache_read": 0.50, "cache_write": 6.25},
|
|
30
|
+
{"family": "claude-opus-4", "input": 15.0, "output": 75.0, "cache_read": 1.50, "cache_write": 18.75},
|
|
31
|
+
{"family": "claude-sonnet-4", "input": 3.0, "output": 15.0, "cache_read": 0.30, "cache_write": 3.75},
|
|
32
|
+
{"family": "claude-haiku-4-5", "input": 1.0, "output": 5.0, "cache_read": 0.10, "cache_write": 1.25},
|
|
33
|
+
{"family": "claude-haiku-3", "input": 0.80, "output": 4.0, "cache_read": 0.08, "cache_write": 1.00},
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
SONNET_RATES = RATES[3] # fallback
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_rates(model: str) -> dict:
|
|
40
|
+
"""Match model name by prefix. Unknown models fall back to Sonnet."""
|
|
41
|
+
for r in RATES:
|
|
42
|
+
if model.startswith(r["family"]):
|
|
43
|
+
return r
|
|
44
|
+
return SONNET_RATES
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def calculate_cost(model: str, input_t: int, output_t: int, cache_read: int, cache_write: int) -> float:
|
|
48
|
+
r = get_rates(model)
|
|
49
|
+
return (
|
|
50
|
+
input_t / 1_000_000 * r["input"]
|
|
51
|
+
+ output_t / 1_000_000 * r["output"]
|
|
52
|
+
+ cache_read / 1_000_000 * r["cache_read"]
|
|
53
|
+
+ cache_write / 1_000_000 * r["cache_write"]
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ── File discovery ──────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
def default_dirs() -> list[str]:
|
|
60
|
+
home = Path.home()
|
|
61
|
+
dirs = [str(home / ".claude" / "projects")]
|
|
62
|
+
sandy = home / ".sandy" / "sandboxes"
|
|
63
|
+
if sandy.exists():
|
|
64
|
+
dirs.append(str(sandy))
|
|
65
|
+
return dirs
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def discover_files(dirs: list[str]) -> list[str]:
|
|
69
|
+
"""Walk directories, return all .jsonl file paths."""
|
|
70
|
+
files = []
|
|
71
|
+
for d in dirs:
|
|
72
|
+
p = Path(d)
|
|
73
|
+
if not p.exists():
|
|
74
|
+
continue
|
|
75
|
+
for f in p.rglob("*.jsonl"):
|
|
76
|
+
files.append(str(f))
|
|
77
|
+
return files
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ── Remote host support ────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
REMOTE_DIRS = "~/.claude/projects ~/.sandy/sandboxes"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def fetch_remote_jsonl(host: str) -> str:
|
|
86
|
+
"""SSH to host and cat all JSONL files from default dirs. Returns raw output."""
|
|
87
|
+
# Single SSH call: find all .jsonl files and cat them with a separator
|
|
88
|
+
# so we can split per-file for dedup. We use a marker line between files.
|
|
89
|
+
cmd = [
|
|
90
|
+
"ssh", "-o", "ConnectTimeout=10", "-o", "BatchMode=yes", host,
|
|
91
|
+
f"find {REMOTE_DIRS} -name '*.jsonl' -exec echo '___CCTRACK_FILE_SEP___' \\; -exec cat {{}} \\; 2>/dev/null"
|
|
92
|
+
]
|
|
93
|
+
try:
|
|
94
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
|
95
|
+
if result.returncode != 0 and not result.stdout:
|
|
96
|
+
print(f" Warning: SSH to {host} failed: {result.stderr.strip()}", file=sys.stderr)
|
|
97
|
+
return ""
|
|
98
|
+
return result.stdout
|
|
99
|
+
except subprocess.TimeoutExpired:
|
|
100
|
+
print(f" Warning: SSH to {host} timed out", file=sys.stderr)
|
|
101
|
+
return ""
|
|
102
|
+
except FileNotFoundError:
|
|
103
|
+
print(f" Warning: ssh command not found", file=sys.stderr)
|
|
104
|
+
return ""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_remote_events(host: str) -> list[dict]:
|
|
108
|
+
"""Fetch and parse JSONL from a remote host via SSH."""
|
|
109
|
+
print(f" Fetching from {host}...", file=sys.stderr)
|
|
110
|
+
raw = fetch_remote_jsonl(host)
|
|
111
|
+
if not raw:
|
|
112
|
+
return []
|
|
113
|
+
|
|
114
|
+
# Split into per-file chunks for correct dedup (per-file, not global)
|
|
115
|
+
chunks = raw.split("___CCTRACK_FILE_SEP___")
|
|
116
|
+
all_events = []
|
|
117
|
+
for chunk in chunks:
|
|
118
|
+
events = parse_lines(chunk.splitlines())
|
|
119
|
+
all_events.extend(events)
|
|
120
|
+
return all_events
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ── JSONL parsing + dedup ───────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
def parse_lines(lines: list[str]) -> list[dict]:
|
|
126
|
+
"""Parse JSONL lines from a single file. Dedup by request_id (last wins)."""
|
|
127
|
+
by_request_id: dict[str, dict] = {}
|
|
128
|
+
no_request_id: list[dict] = []
|
|
129
|
+
|
|
130
|
+
for line in lines:
|
|
131
|
+
line = line.strip()
|
|
132
|
+
if not line:
|
|
133
|
+
continue
|
|
134
|
+
try:
|
|
135
|
+
event = json.loads(line)
|
|
136
|
+
except (json.JSONDecodeError, ValueError):
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
if event.get("type") != "assistant":
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
usage = event.get("message", {}).get("usage", {})
|
|
143
|
+
input_t = usage.get("input_tokens", 0)
|
|
144
|
+
output_t = usage.get("output_tokens", 0)
|
|
145
|
+
cache_read = usage.get("cache_read_input_tokens", 0)
|
|
146
|
+
cache_write = usage.get("cache_creation_input_tokens", 0)
|
|
147
|
+
|
|
148
|
+
if input_t == 0 and output_t == 0 and cache_read == 0 and cache_write == 0:
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
entry = {
|
|
152
|
+
"model": event.get("message", {}).get("model", ""),
|
|
153
|
+
"timestamp": event.get("timestamp", ""),
|
|
154
|
+
"input": input_t,
|
|
155
|
+
"output": output_t,
|
|
156
|
+
"cache_read": cache_read,
|
|
157
|
+
"cache_write": cache_write,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
req_id = event.get("requestId", "")
|
|
161
|
+
if req_id:
|
|
162
|
+
by_request_id[req_id] = entry
|
|
163
|
+
else:
|
|
164
|
+
no_request_id.append(entry)
|
|
165
|
+
|
|
166
|
+
return list(by_request_id.values()) + no_request_id
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def parse_events(files: list[str]) -> list[dict]:
|
|
170
|
+
"""Parse all local JSONL files. Dedup by request_id (last event wins per file)."""
|
|
171
|
+
all_events = []
|
|
172
|
+
for path in files:
|
|
173
|
+
try:
|
|
174
|
+
with open(path, "r", errors="replace") as f:
|
|
175
|
+
events = parse_lines(f.readlines())
|
|
176
|
+
all_events.extend(events)
|
|
177
|
+
except (OSError, IOError):
|
|
178
|
+
continue
|
|
179
|
+
return all_events
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ── Aggregation ─────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
def new_bucket() -> dict:
|
|
185
|
+
return {"input": 0, "output": 0, "cache_read": 0, "cache_write": 0, "cost": 0.0}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def aggregate(events: list[dict]) -> tuple[dict, dict]:
|
|
189
|
+
"""
|
|
190
|
+
Returns:
|
|
191
|
+
daily: {date_str: {input, output, cache_read, cache_write, cost}}
|
|
192
|
+
monthly: {month_str: {input, output, cache_read, cache_write, cost}}
|
|
193
|
+
"""
|
|
194
|
+
daily = defaultdict(new_bucket)
|
|
195
|
+
monthly = defaultdict(new_bucket)
|
|
196
|
+
|
|
197
|
+
for e in events:
|
|
198
|
+
ts = e["timestamp"]
|
|
199
|
+
if not ts:
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
204
|
+
except (ValueError, AttributeError):
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
date_str = dt.strftime("%Y-%m-%d")
|
|
208
|
+
month_str = dt.strftime("%Y-%m")
|
|
209
|
+
|
|
210
|
+
cost = calculate_cost(e["model"], e["input"], e["output"], e["cache_read"], e["cache_write"])
|
|
211
|
+
|
|
212
|
+
for bucket in (daily[date_str], monthly[month_str]):
|
|
213
|
+
bucket["input"] += e["input"]
|
|
214
|
+
bucket["output"] += e["output"]
|
|
215
|
+
bucket["cache_read"] += e["cache_read"]
|
|
216
|
+
bucket["cache_write"] += e["cache_write"]
|
|
217
|
+
bucket["cost"] += cost
|
|
218
|
+
|
|
219
|
+
return dict(daily), dict(monthly)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ── Output ──────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
def format_tokens(n: int) -> str:
|
|
225
|
+
return f"{n:,}"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def total_tokens(b: dict) -> int:
|
|
229
|
+
return b["input"] + b["output"] + b["cache_read"] + b["cache_write"]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def print_report(daily: dict, monthly: dict, days: int | None = None):
|
|
233
|
+
if not daily and not monthly:
|
|
234
|
+
print("cctrack — no log data found")
|
|
235
|
+
print()
|
|
236
|
+
print("Looked in:")
|
|
237
|
+
for d in default_dirs():
|
|
238
|
+
print(f" {d}")
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
now = datetime.now(timezone.utc)
|
|
242
|
+
current_month = now.strftime("%Y-%m")
|
|
243
|
+
current_month_name = now.strftime("%B %Y")
|
|
244
|
+
|
|
245
|
+
sorted_days = sorted(daily.keys(), reverse=True)
|
|
246
|
+
sorted_months = sorted(monthly.keys(), reverse=True)
|
|
247
|
+
|
|
248
|
+
# Header
|
|
249
|
+
print("cctrack — Claude Code Cost Report")
|
|
250
|
+
print("══════════════════════════════════")
|
|
251
|
+
print()
|
|
252
|
+
|
|
253
|
+
# Current month-to-date
|
|
254
|
+
if current_month in monthly:
|
|
255
|
+
m = monthly[current_month]
|
|
256
|
+
current_days = [d for d in sorted_days if d.startswith(current_month)]
|
|
257
|
+
days_active = len(current_days)
|
|
258
|
+
day_of_month = now.day
|
|
259
|
+
print(f"{current_month_name} — month to date (day {day_of_month}, {days_active} active)")
|
|
260
|
+
print("───────────────────────────────────────")
|
|
261
|
+
print(f" Input tokens: {format_tokens(m['input']):>15}")
|
|
262
|
+
print(f" Output tokens: {format_tokens(m['output']):>15}")
|
|
263
|
+
if m['cache_read'] or m['cache_write']:
|
|
264
|
+
print(f" Cache read: {format_tokens(m['cache_read']):>15}")
|
|
265
|
+
print(f" Cache write: {format_tokens(m['cache_write']):>15}")
|
|
266
|
+
print(f" Total tokens: {format_tokens(total_tokens(m)):>15}")
|
|
267
|
+
print(f" Total cost: ${m['cost']:.2f}")
|
|
268
|
+
if days_active > 0:
|
|
269
|
+
avg_daily = m['cost'] / days_active
|
|
270
|
+
projected = avg_daily * 30
|
|
271
|
+
print(f" Avg/day: ${avg_daily:.2f}")
|
|
272
|
+
print(f" Projected/mo: ${projected:.2f} (based on {days_active}-day avg)")
|
|
273
|
+
print()
|
|
274
|
+
|
|
275
|
+
# Daily breakdown (current month first, then previous)
|
|
276
|
+
current_month_days = [d for d in sorted_days if d.startswith(current_month)]
|
|
277
|
+
prev_month_days = [d for d in sorted_days if not d.startswith(current_month)]
|
|
278
|
+
|
|
279
|
+
if current_month_days:
|
|
280
|
+
print("Daily breakdown:")
|
|
281
|
+
print(f" {'Date':<12} {'Input':>12} {'Output':>12} {'Cache R':>12} {'Cache W':>12} {'Cost':>10}")
|
|
282
|
+
print(f" {'─'*12} {'─'*12} {'─'*12} {'─'*12} {'─'*12} {'─'*10}")
|
|
283
|
+
display_days = current_month_days
|
|
284
|
+
if days is not None:
|
|
285
|
+
display_days = display_days[:days]
|
|
286
|
+
for date_str in display_days:
|
|
287
|
+
d = daily[date_str]
|
|
288
|
+
print(f" {date_str:<12} {format_tokens(d['input']):>12} {format_tokens(d['output']):>12} {format_tokens(d['cache_read']):>12} {format_tokens(d['cache_write']):>12} ${d['cost']:>8.2f}")
|
|
289
|
+
|
|
290
|
+
# Previous months summary
|
|
291
|
+
prev_months = [m for m in sorted_months if m != current_month]
|
|
292
|
+
if prev_months:
|
|
293
|
+
print()
|
|
294
|
+
print("Previous months:")
|
|
295
|
+
print(f" {'Month':<12} {'Input':>12} {'Output':>12} {'Cache R':>12} {'Cache W':>12} {'Cost':>10}")
|
|
296
|
+
print(f" {'─'*12} {'─'*12} {'─'*12} {'─'*12} {'─'*12} {'─'*10}")
|
|
297
|
+
for month_str in prev_months:
|
|
298
|
+
m = monthly[month_str]
|
|
299
|
+
month_days = [d for d in prev_month_days if d.startswith(month_str)]
|
|
300
|
+
days_str = f" ({len(month_days)}d)" if month_days else ""
|
|
301
|
+
print(f" {month_str + days_str:<12} {format_tokens(m['input']):>12} {format_tokens(m['output']):>12} {format_tokens(m['cache_read']):>12} {format_tokens(m['cache_write']):>12} ${m['cost']:>8.2f}")
|
|
302
|
+
|
|
303
|
+
print()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ── CLI ─────────────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
def main():
|
|
309
|
+
parser = argparse.ArgumentParser(
|
|
310
|
+
description="Claude Code cost reporter — scans JSONL logs and reports token usage + cost"
|
|
311
|
+
)
|
|
312
|
+
parser.add_argument(
|
|
313
|
+
"--days", type=int, default=None,
|
|
314
|
+
help="Limit daily breakdown to last N days"
|
|
315
|
+
)
|
|
316
|
+
parser.add_argument(
|
|
317
|
+
"--dirs", nargs="+", default=None,
|
|
318
|
+
help="Custom log directories to scan (default: ~/.claude/projects and ~/.sandy/sandboxes)"
|
|
319
|
+
)
|
|
320
|
+
parser.add_argument(
|
|
321
|
+
"--remote", nargs="+", default=None, metavar="HOST",
|
|
322
|
+
help="SSH hosts to fetch logs from (e.g. dgx macbook-air user@server)"
|
|
323
|
+
)
|
|
324
|
+
args = parser.parse_args()
|
|
325
|
+
|
|
326
|
+
# Local events
|
|
327
|
+
dirs = args.dirs if args.dirs else default_dirs()
|
|
328
|
+
files = discover_files(dirs)
|
|
329
|
+
events = parse_events(files)
|
|
330
|
+
|
|
331
|
+
# Remote events
|
|
332
|
+
if args.remote:
|
|
333
|
+
for host in args.remote:
|
|
334
|
+
remote_events = parse_remote_events(host)
|
|
335
|
+
events.extend(remote_events)
|
|
336
|
+
|
|
337
|
+
daily, monthly = aggregate(events)
|
|
338
|
+
print_report(daily, monthly, days=args.days)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
if __name__ == "__main__":
|
|
342
|
+
main()
|
cctrack/__main__.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cctrack
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Claude Code cost tracker — scans JSONL logs and reports token usage + estimated cost
|
|
5
|
+
Project-URL: Homepage, https://github.com/ksred/cctrack
|
|
6
|
+
Project-URL: Repository, https://github.com/ksred/cctrack
|
|
7
|
+
Project-URL: Issues, https://github.com/ksred/cctrack/issues
|
|
8
|
+
Author: Dan Rapp
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: anthropic,claude,claude-code,cost,tokens
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
22
|
+
Classifier: Topic :: System :: Monitoring
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# cctrack
|
|
27
|
+
|
|
28
|
+
A lightweight CLI tool that scans [Claude Code](https://docs.anthropic.com/en/docs/claude-code) JSONL logs and reports token usage and estimated cost. Zero dependencies, runs anywhere Python 3.10+ is available.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# Run directly (no install needed)
|
|
34
|
+
uvx cctrack
|
|
35
|
+
|
|
36
|
+
# Or install globally
|
|
37
|
+
uv tool install cctrack
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# Scan local logs and print report
|
|
44
|
+
cctrack
|
|
45
|
+
|
|
46
|
+
# Last 7 days only
|
|
47
|
+
cctrack --days 7
|
|
48
|
+
|
|
49
|
+
# Aggregate with remote machines via SSH
|
|
50
|
+
cctrack --remote dgx macbook-air
|
|
51
|
+
|
|
52
|
+
# Custom log directories
|
|
53
|
+
cctrack --dirs ~/.claude/projects ~/.sandy/sandboxes
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Example output
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
cctrack — Claude Code Cost Report
|
|
60
|
+
══════════════════════════════════
|
|
61
|
+
|
|
62
|
+
April 2026 — month to date (day 1, 1 active)
|
|
63
|
+
───────────────────────────────────────
|
|
64
|
+
Input tokens: 23,381
|
|
65
|
+
Output tokens: 199,069
|
|
66
|
+
Cache read: 30,862,977
|
|
67
|
+
Cache write: 1,051,023
|
|
68
|
+
Total tokens: 32,136,450
|
|
69
|
+
Total cost: $25.85
|
|
70
|
+
Avg/day: $25.85
|
|
71
|
+
Projected/mo: $775.50 (based on 1-day avg)
|
|
72
|
+
|
|
73
|
+
Daily breakdown:
|
|
74
|
+
Date Input Output Cache R Cache W Cost
|
|
75
|
+
──────────── ──────────── ──────────── ──────────── ──────────── ──────────
|
|
76
|
+
2026-04-01 23,381 199,069 30,862,977 1,051,023 $ 25.85
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## What it does
|
|
80
|
+
|
|
81
|
+
1. Walks `~/.claude/projects/` and `~/.sandy/sandboxes/` for JSONL log files
|
|
82
|
+
2. Parses `assistant` events with token usage
|
|
83
|
+
3. Deduplicates by `requestId` (last event wins, matching Claude Code's semantics)
|
|
84
|
+
4. Calculates cost using Anthropic's published rates per model
|
|
85
|
+
5. Prints daily breakdown with input/output/cache token splits and monthly summaries
|
|
86
|
+
|
|
87
|
+
## Remote aggregation
|
|
88
|
+
|
|
89
|
+
With `--remote`, cctrack SSHs to each host (one call per host) and streams back all JSONL content for local parsing. Requires SSH key auth.
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
# Aggregate this machine + DGX server + laptop
|
|
93
|
+
cctrack --remote dgx macbook-air
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Rate card
|
|
97
|
+
|
|
98
|
+
Prices per million tokens, from [Anthropic's pricing page](https://www.anthropic.com/pricing):
|
|
99
|
+
|
|
100
|
+
| Model | Input | Output | Cache Read | Cache Write |
|
|
101
|
+
|---|---|---|---|---|
|
|
102
|
+
| Opus 4.6 / 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
|
103
|
+
| Opus 4 / 4.1 | $15.00 | $75.00 | $1.50 | $18.75 |
|
|
104
|
+
| Sonnet 4.x | $3.00 | $15.00 | $0.30 | $3.75 |
|
|
105
|
+
| Haiku 4.5 | $1.00 | $5.00 | $0.10 | $1.25 |
|
|
106
|
+
| Haiku 3.x | $0.80 | $4.00 | $0.08 | $1.00 |
|
|
107
|
+
|
|
108
|
+
Unknown models fall back to Sonnet rates.
|
|
109
|
+
|
|
110
|
+
## Origins
|
|
111
|
+
|
|
112
|
+
This is a Python rewrite of the Go-based [cctrack dashboard](https://github.com/ksred/cctrack). The Go version provides a full web dashboard with real-time updates, session explorer, and project breakdown. This Python version strips it down to the essentials: a single command that parses logs and prints a cost report. The JSONL parsing logic, deduplication strategy, and rate card are ported directly from the Go implementation.
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
cctrack/__init__.py,sha256=IrSOjvION4Qj-zI1mNnkGV6cukuHTVqXFMN6vqXxo_M,13307
|
|
2
|
+
cctrack/__main__.py,sha256=md2QSn4SrSK-4i-y-APpj12Tb6eXTEYErUirBmjPB1I,33
|
|
3
|
+
cctrack-0.1.0.dist-info/METADATA,sha256=EpWhNuubXV1v59oW1TxFfN7_RZwf4v0-zdx-85fWe5U,4195
|
|
4
|
+
cctrack-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
5
|
+
cctrack-0.1.0.dist-info/entry_points.txt,sha256=7VbGGKr38GwJ0YjzqepgpL1RIbeVj6lK0sSdJ3md_Gs,41
|
|
6
|
+
cctrack-0.1.0.dist-info/licenses/LICENSE,sha256=m_p7Y3rYS5a2MD93VXSfeWsXVfi9E6gdShfcll5HYI8,1065
|
|
7
|
+
cctrack-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dan Rapp
|
|
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.
|