cctrack 0.4.0__tar.gz → 0.4.1__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cctrack
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Claude Code cost tracker — scans JSONL logs and reports token usage + estimated cost
5
5
  Project-URL: Homepage, https://github.com/rappdw/cctrack
6
6
  Project-URL: Repository, https://github.com/rappdw/cctrack
@@ -86,11 +86,14 @@ Daily breakdown:
86
86
 
87
87
  ## Remote aggregation
88
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.
89
+ With `--remote`, cctrack SSHs to each host and streams back both the JSONL logs and the host's statusline hook data (`~/.claude/cctrack/`) for local parsing, so authoritative hook costs from every machine are summed per day. Requires SSH key auth.
90
90
 
91
91
  ```bash
92
92
  # Aggregate this machine + DGX server + laptop
93
93
  cctrack --remote dgx macbook-air
94
+
95
+ # For accurate aggregate costs, install the hook on each host too
96
+ cctrack --install-hook --remote dgx macbook-air
94
97
  ```
95
98
 
96
99
  ## Rate card
@@ -61,11 +61,14 @@ Daily breakdown:
61
61
 
62
62
  ## Remote aggregation
63
63
 
64
- With `--remote`, cctrack SSHs to each host (one call per host) and streams back all JSONL content for local parsing. Requires SSH key auth.
64
+ With `--remote`, cctrack SSHs to each host and streams back both the JSONL logs and the host's statusline hook data (`~/.claude/cctrack/`) for local parsing, so authoritative hook costs from every machine are summed per day. Requires SSH key auth.
65
65
 
66
66
  ```bash
67
67
  # Aggregate this machine + DGX server + laptop
68
68
  cctrack --remote dgx macbook-air
69
+
70
+ # For accurate aggregate costs, install the hook on each host too
71
+ cctrack --install-hook --remote dgx macbook-air
69
72
  ```
70
73
 
71
74
  ## Rate card
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cctrack"
7
- version = "0.4.0"
7
+ version = "0.4.1"
8
8
  description = "Claude Code cost tracker — scans JSONL logs and reports token usage + estimated cost"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -126,6 +126,84 @@ def parse_remote_events(host: str) -> list[dict]:
126
126
  return all_events
127
127
 
128
128
 
129
+ HOOK_FILE_SEP = "___CCTRACK_HOOK_FILE___"
130
+ MONTHLY_FILE_SEP = "___CCTRACK_MONTHLY_FILE___"
131
+
132
+ # Hook data locations on remote hosts: the host's own cctrack dir plus any
133
+ # Sandy sandbox equivalents (mirrors discover_hook_data_dirs).
134
+ REMOTE_HOOK_DIRS = "~/.claude/cctrack ~/.sandy/sandboxes"
135
+
136
+
137
+ def fetch_remote_hook_data(host: str) -> str:
138
+ """SSH to host and cat all statusline JSONL + monthly summary files.
139
+ Returns raw output with file-type separators."""
140
+ remote_cmd = (
141
+ f"find {REMOTE_HOOK_DIRS} -name 'statusline*.jsonl' "
142
+ f"-exec echo '{HOOK_FILE_SEP}' \\; -exec cat {{}} \\; 2>/dev/null; "
143
+ f"find {REMOTE_HOOK_DIRS} -name 'monthly-*.json' "
144
+ f"-exec echo '{MONTHLY_FILE_SEP}' \\; -exec cat {{}} \\; 2>/dev/null; "
145
+ f"true"
146
+ )
147
+ cmd = ["ssh", "-o", "ConnectTimeout=10", "-o", "BatchMode=yes", host, remote_cmd]
148
+ try:
149
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
150
+ if result.returncode != 0 and not result.stdout:
151
+ print(f" Warning: SSH to {host} failed: {result.stderr.strip()}", file=sys.stderr)
152
+ return ""
153
+ return result.stdout
154
+ except subprocess.TimeoutExpired:
155
+ print(f" Warning: SSH to {host} timed out", file=sys.stderr)
156
+ return ""
157
+ except FileNotFoundError:
158
+ print(f" Warning: ssh command not found", file=sys.stderr)
159
+ return ""
160
+
161
+
162
+ def parse_remote_hook_data(host: str) -> tuple[list[dict], dict]:
163
+ """Fetch and parse hook data from a remote host.
164
+ Returns (hook_events, monthly_summaries) in the same shapes as
165
+ parse_hook_events and load_monthly_hook_summaries."""
166
+ raw = fetch_remote_hook_data(host)
167
+ if not raw:
168
+ return [], {}
169
+
170
+ # Statusline chunks come first, monthly chunks after (find order above).
171
+ head, _, monthly_raw = raw.partition(MONTHLY_FILE_SEP)
172
+
173
+ lines: list[str] = []
174
+ for chunk in head.split(HOOK_FILE_SEP):
175
+ lines.extend(chunk.splitlines())
176
+ events = _hook_events_from_lines(lines)
177
+
178
+ monthly: dict[str, dict] = {}
179
+ for chunk in monthly_raw.split(MONTHLY_FILE_SEP):
180
+ chunk = chunk.strip()
181
+ if not chunk:
182
+ continue
183
+ try:
184
+ data = json.loads(chunk)
185
+ except (json.JSONDecodeError, ValueError):
186
+ continue
187
+ for date_str, day_data in data.get("days", {}).items():
188
+ entry = {
189
+ "cost": float(day_data.get("cost", 0.0)),
190
+ "sessions": int(day_data.get("sessions", 0)),
191
+ }
192
+ merge_monthly_summaries(monthly, {date_str: entry})
193
+ return events, monthly
194
+
195
+
196
+ def merge_monthly_summaries(target: dict, source: dict) -> None:
197
+ """Merge per-date {cost, sessions} summaries from source into target,
198
+ summing where dates overlap (e.g. same day on multiple hosts)."""
199
+ for date_str, data in source.items():
200
+ if date_str in target:
201
+ target[date_str]["cost"] += data["cost"]
202
+ target[date_str]["sessions"] += data["sessions"]
203
+ else:
204
+ target[date_str] = dict(data)
205
+
206
+
129
207
  # ── Hook infrastructure ───────────────────────────────────────────────
130
208
 
131
209
  HOOK_SCRIPT = """\
@@ -485,59 +563,42 @@ def parse_events(files: list[str]) -> list[dict]:
485
563
 
486
564
  # ── Hook data parsing ──────────────────────────────────────────────────
487
565
 
488
- def parse_hook_events(cctrack_dir: Path | None = None) -> list[dict]:
489
- """Parse statusline-*.jsonl files. For each (session_id, date) pair, keeps the
566
+ def _hook_events_from_lines(lines) -> list[dict]:
567
+ """Parse statusline JSONL lines. For each (session_id, date) pair, keeps the
490
568
  entry with the latest _ts — the session's cumulative values as of end-of-day
491
- for that date. Also reads legacy statusline.jsonl for migration.
492
- Returns list of {date_str, session_id, cost_usd, input_tokens, output_tokens,
493
- cache_read, cache_write, model}."""
494
- if cctrack_dir is None:
495
- cctrack_dir = CCTRACK_DIR
496
- if not cctrack_dir.exists():
497
- return []
498
-
569
+ for that date. Returns list of {date_str, session_id, cost_usd, input_tokens,
570
+ output_tokens, cache_read, cache_write, model}."""
499
571
  by_session_day: dict[tuple[str, str], dict] = {}
500
572
 
501
- files = sorted(cctrack_dir.glob("statusline-*.jsonl"))
502
- legacy = cctrack_dir / "statusline.jsonl"
503
- if legacy.exists():
504
- files.append(legacy)
505
-
506
- for filepath in files:
573
+ for line in lines:
574
+ line = line.strip()
575
+ if not line:
576
+ continue
507
577
  try:
508
- with open(filepath, "r", errors="replace") as f:
509
- for line in f:
510
- line = line.strip()
511
- if not line:
512
- continue
513
- try:
514
- entry = json.loads(line)
515
- except (json.JSONDecodeError, ValueError):
516
- continue
578
+ entry = json.loads(line)
579
+ except (json.JSONDecodeError, ValueError):
580
+ continue
517
581
 
518
- session_id = entry.get("session_id", "")
519
- if not session_id:
520
- continue
582
+ session_id = entry.get("session_id", "")
583
+ if not session_id:
584
+ continue
521
585
 
522
- ts = entry.get("_ts", "")
523
- if not ts:
524
- continue
586
+ ts = entry.get("_ts", "")
587
+ if not ts:
588
+ continue
525
589
 
526
- try:
527
- dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
528
- except (ValueError, AttributeError):
529
- continue
530
- date_str = dt.strftime("%Y-%m-%d")
590
+ try:
591
+ dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
592
+ except (ValueError, AttributeError):
593
+ continue
594
+ date_str = dt.strftime("%Y-%m-%d")
531
595
 
532
- key = (session_id, date_str)
533
- if key in by_session_day:
534
- if ts <= by_session_day[key].get("_ts", ""):
535
- continue
596
+ key = (session_id, date_str)
597
+ if key in by_session_day:
598
+ if ts <= by_session_day[key].get("_ts", ""):
599
+ continue
536
600
 
537
- entry["_date_str"] = date_str
538
- by_session_day[key] = entry
539
- except (OSError, IOError):
540
- continue
601
+ by_session_day[key] = entry
541
602
 
542
603
  results = []
543
604
  for (session_id, date_str), entry in by_session_day.items():
@@ -555,6 +616,31 @@ def parse_hook_events(cctrack_dir: Path | None = None) -> list[dict]:
555
616
  return results
556
617
 
557
618
 
619
+ def parse_hook_events(cctrack_dir: Path | None = None) -> list[dict]:
620
+ """Parse statusline-*.jsonl files from a local hook data directory.
621
+ Also reads legacy statusline.jsonl for migration. See _hook_events_from_lines
622
+ for the per-(session, date) dedup semantics."""
623
+ if cctrack_dir is None:
624
+ cctrack_dir = CCTRACK_DIR
625
+ if not cctrack_dir.exists():
626
+ return []
627
+
628
+ files = sorted(cctrack_dir.glob("statusline-*.jsonl"))
629
+ legacy = cctrack_dir / "statusline.jsonl"
630
+ if legacy.exists():
631
+ files.append(legacy)
632
+
633
+ lines: list[str] = []
634
+ for filepath in files:
635
+ try:
636
+ with open(filepath, "r", errors="replace") as f:
637
+ lines.extend(f.readlines())
638
+ except (OSError, IOError):
639
+ continue
640
+
641
+ return _hook_events_from_lines(lines)
642
+
643
+
558
644
  def compute_hook_deltas(events: list[dict]) -> list[dict]:
559
645
  """Convert per-(session, date) cumulative records into per-(session, date)
560
646
  daily deltas. For each session, sorts its records by date and computes
@@ -1138,21 +1224,33 @@ def main():
1138
1224
  except (ValueError, AttributeError):
1139
1225
  pass
1140
1226
 
1141
- hook_dirs = discover_hook_data_dirs()
1142
- if hook_dirs:
1143
- all_hook_events: list[dict] = []
1144
- all_monthly: dict[str, dict] = {}
1145
- for hdir in hook_dirs:
1146
- rollup_old_hook_files(hdir)
1147
- all_hook_events.extend(compute_hook_deltas(parse_hook_events(hdir)))
1148
- for date_str, data in load_monthly_hook_summaries(hdir).items():
1149
- if date_str in all_monthly:
1150
- all_monthly[date_str]["cost"] += data["cost"]
1151
- all_monthly[date_str]["sessions"] += data["sessions"]
1152
- else:
1153
- all_monthly[date_str] = dict(data)
1154
- if all_hook_events or all_monthly:
1155
- hook_daily = aggregate_hook_data(all_hook_events, all_monthly)
1227
+ all_hook_events: list[dict] = []
1228
+ all_monthly: dict[str, dict] = {}
1229
+
1230
+ for hdir in discover_hook_data_dirs():
1231
+ rollup_old_hook_files(hdir)
1232
+ all_hook_events.extend(compute_hook_deltas(parse_hook_events(hdir)))
1233
+ merge_monthly_summaries(all_monthly, load_monthly_hook_summaries(hdir))
1234
+
1235
+ # Remote hook data: each host's deltas are computed independently so a
1236
+ # session's cumulative history never mixes across hosts.
1237
+ if args.remote:
1238
+ for host in args.remote:
1239
+ remote_hook_events, remote_monthly = parse_remote_hook_data(host)
1240
+ all_hook_events.extend(compute_hook_deltas(remote_hook_events))
1241
+ merge_monthly_summaries(all_monthly, remote_monthly)
1242
+
1243
+ if all_hook_events or all_monthly:
1244
+ hook_daily = aggregate_hook_data(all_hook_events, all_monthly)
1245
+
1246
+ # Hook coverage may start earlier than the local install date (e.g. a
1247
+ # remote host installed the hook first). Treat the earliest observed
1248
+ # hook date as the effective install date so those days count as
1249
+ # authoritative rather than being scaled by the accuracy factor.
1250
+ if hook_daily:
1251
+ earliest_hook_date = min(hook_daily.keys())
1252
+ if hook_install_date is None or earliest_hook_date < hook_install_date:
1253
+ hook_install_date = earliest_hook_date
1156
1254
 
1157
1255
  # Suggest hook if not installed
1158
1256
  if not is_hook_installed():
@@ -259,6 +259,107 @@ def test_compute_hook_deltas_multiple_sessions_independent():
259
259
  assert by_key[("2026-04-15", "sB")] == 4.0
260
260
 
261
261
 
262
+ # ── Remote hook data tests ─────────────────────────────────────────────
263
+
264
+ def _remote_raw(hook_chunks: list[list[dict]], monthly_chunks: list[dict]) -> str:
265
+ """Build a raw SSH output stream the way fetch_remote_hook_data returns it."""
266
+ parts = []
267
+ for entries in hook_chunks:
268
+ parts.append(cctrack.HOOK_FILE_SEP)
269
+ for e in entries:
270
+ parts.append(json.dumps(e))
271
+ for doc in monthly_chunks:
272
+ parts.append(cctrack.MONTHLY_FILE_SEP)
273
+ parts.append(json.dumps(doc))
274
+ return "\n".join(parts) + "\n"
275
+
276
+
277
+ def test_parse_remote_hook_data_basic():
278
+ """Statusline entries and monthly summaries are both parsed from the stream."""
279
+ raw = _remote_raw(
280
+ hook_chunks=[
281
+ [make_hook_entry(session_id="r1", ts="2026-04-15T10:00:00+00:00", cost_usd=3.00)],
282
+ [make_hook_entry(session_id="r2", ts="2026-04-16T10:00:00+00:00", cost_usd=7.00)],
283
+ ],
284
+ monthly_chunks=[
285
+ {"month": "2026-01", "days": {"2026-01-10": {"cost": 10.0, "sessions": 2}}},
286
+ ],
287
+ )
288
+ with patch("cctrack.fetch_remote_hook_data", return_value=raw):
289
+ events, monthly = cctrack.parse_remote_hook_data("fakehost")
290
+ assert len(events) == 2
291
+ by_session = {e["session_id"]: e for e in events}
292
+ assert by_session["r1"]["cost_usd"] == 3.00
293
+ assert by_session["r2"]["date_str"] == "2026-04-16"
294
+ assert monthly == {"2026-01-10": {"cost": 10.0, "sessions": 2}}
295
+
296
+
297
+ def test_parse_remote_hook_data_dedup_across_files():
298
+ """Same (session, date) in multiple remote files: latest _ts wins."""
299
+ raw = _remote_raw(
300
+ hook_chunks=[
301
+ [make_hook_entry(session_id="rA", ts="2026-04-15T10:00:00+00:00", cost_usd=2.00)],
302
+ [make_hook_entry(session_id="rA", ts="2026-04-15T18:00:00+00:00", cost_usd=6.00)],
303
+ ],
304
+ monthly_chunks=[],
305
+ )
306
+ with patch("cctrack.fetch_remote_hook_data", return_value=raw):
307
+ events, monthly = cctrack.parse_remote_hook_data("fakehost")
308
+ assert len(events) == 1
309
+ assert events[0]["cost_usd"] == 6.00
310
+
311
+
312
+ def test_parse_remote_hook_data_empty():
313
+ with patch("cctrack.fetch_remote_hook_data", return_value=""):
314
+ events, monthly = cctrack.parse_remote_hook_data("fakehost")
315
+ assert events == []
316
+ assert monthly == {}
317
+
318
+
319
+ def test_parse_remote_hook_data_malformed_monthly_skipped():
320
+ raw = (cctrack.HOOK_FILE_SEP + "\n"
321
+ + json.dumps(make_hook_entry(session_id="ok", cost_usd=1.0)) + "\n"
322
+ + cctrack.MONTHLY_FILE_SEP + "\n"
323
+ + "not json\n")
324
+ with patch("cctrack.fetch_remote_hook_data", return_value=raw):
325
+ events, monthly = cctrack.parse_remote_hook_data("fakehost")
326
+ assert len(events) == 1
327
+ assert monthly == {}
328
+
329
+
330
+ def test_fetch_remote_hook_data_timeout():
331
+ with patch("subprocess.run", side_effect=cctrack.subprocess.TimeoutExpired("ssh", 60)):
332
+ result = cctrack.fetch_remote_hook_data("fakehost")
333
+ assert result == ""
334
+
335
+
336
+ def test_merge_monthly_summaries_sums_overlapping_dates():
337
+ """Same date on two hosts sums cost and sessions."""
338
+ target = {"2026-01-10": {"cost": 10.0, "sessions": 2}}
339
+ cctrack.merge_monthly_summaries(target, {
340
+ "2026-01-10": {"cost": 5.0, "sessions": 1},
341
+ "2026-01-11": {"cost": 3.0, "sessions": 1},
342
+ })
343
+ assert target["2026-01-10"] == {"cost": 15.0, "sessions": 3}
344
+ assert target["2026-01-11"] == {"cost": 3.0, "sessions": 1}
345
+
346
+
347
+ def test_remote_and_local_hook_costs_sum_per_date():
348
+ """Regression: a date covered by hooks on two hosts must sum both hosts'
349
+ costs, not keep just one host's value."""
350
+ local_events = cctrack.compute_hook_deltas([
351
+ {"date_str": "2026-06-11", "session_id": "local_s", "cost_usd": 9.05,
352
+ "input_tokens": 0, "output_tokens": 0, "cache_read": 0, "cache_write": 0, "model": "m"},
353
+ ])
354
+ remote_events = cctrack.compute_hook_deltas([
355
+ {"date_str": "2026-06-11", "session_id": "air_s", "cost_usd": 340.90,
356
+ "input_tokens": 0, "output_tokens": 0, "cache_read": 0, "cache_write": 0, "model": "m"},
357
+ ])
358
+ hook_daily = cctrack.aggregate_hook_data(local_events + remote_events)
359
+ assert abs(hook_daily["2026-06-11"]["cost"] - 349.95) < 0.001
360
+ assert hook_daily["2026-06-11"]["sessions"] == 2
361
+
362
+
262
363
  # ── aggregate_hook_data tests ──────────────────────────────────────────
263
364
 
264
365
  def test_aggregate_hook_data_single_day():
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes