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 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,3 @@
1
+ from cctrack import main
2
+
3
+ main()
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cctrack = cctrack:main
@@ -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.