cctrack-ai 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
File without changes
cctrack/config.py ADDED
@@ -0,0 +1,38 @@
1
+ import json
2
+ import platform
3
+ from pathlib import Path
4
+
5
+ _BUNDLED_PRICES = Path(__file__).parent / "data" / "prices.json"
6
+ _USER_PRICES = Path.home() / ".config" / "cctrack" / "prices.json"
7
+
8
+
9
+ def load_prices() -> dict:
10
+ path = _USER_PRICES if _USER_PRICES.exists() else _BUNDLED_PRICES
11
+ with open(path) as f:
12
+ data = json.load(f)
13
+ data["_source"] = str(path)
14
+ return data
15
+
16
+
17
+ def get_pricing(model: str) -> tuple[dict[str, float], str]:
18
+ """Return (price_dict, source_path) for a model name."""
19
+ data = load_prices()
20
+ models: dict = data.get("models", {})
21
+ for key, prices in models.items():
22
+ if model.startswith(key):
23
+ return prices, data["_source"]
24
+ return data["default"], data["_source"]
25
+
26
+
27
+ def get_claude_projects_dir() -> Path:
28
+ if platform.system() == "Windows":
29
+ return Path.home() / "AppData" / "Roaming" / "Claude" / "projects"
30
+ return Path.home() / ".claude" / "projects"
31
+
32
+
33
+ def get_user_prices_path() -> Path:
34
+ return _USER_PRICES
35
+
36
+
37
+ def get_bundled_prices_path() -> Path:
38
+ return _BUNDLED_PRICES
@@ -0,0 +1,30 @@
1
+ {
2
+ "_comment": "Prices in USD per 1,000,000 tokens. Source: https://anthropic.com/pricing",
3
+ "_updated": "2026-06-24",
4
+ "models": {
5
+ "claude-sonnet-4-6": {
6
+ "input": 3.00,
7
+ "output": 15.00,
8
+ "cache_write": 3.75,
9
+ "cache_read": 0.30
10
+ },
11
+ "claude-opus-4-8": {
12
+ "input": 15.00,
13
+ "output": 75.00,
14
+ "cache_write": 18.75,
15
+ "cache_read": 1.50
16
+ },
17
+ "claude-haiku-4-5": {
18
+ "input": 0.80,
19
+ "output": 4.00,
20
+ "cache_write": 1.00,
21
+ "cache_read": 0.08
22
+ }
23
+ },
24
+ "default": {
25
+ "input": 3.00,
26
+ "output": 15.00,
27
+ "cache_write": 3.75,
28
+ "cache_read": 0.30
29
+ }
30
+ }
cctrack/display.py ADDED
@@ -0,0 +1,443 @@
1
+ from datetime import date
2
+
3
+ import plotext as plt
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+ from rich.text import Text
7
+ from rich.panel import Panel
8
+ from rich.columns import Columns
9
+ from rich import box
10
+
11
+ from .stats import ModelStats, DaySummary
12
+
13
+ console = Console()
14
+
15
+ # plotext color names that map to our cost thresholds
16
+ _PLT_GREEN = "green"
17
+ _PLT_YELLOW = "yellow"
18
+ _PLT_RED = "red"
19
+
20
+
21
+ def _plt_color(cost: float) -> str:
22
+ if cost >= 1.0:
23
+ return _PLT_RED
24
+ if cost >= 0.25:
25
+ return _PLT_YELLOW
26
+ return _PLT_GREEN
27
+
28
+
29
+ def chart_weekly_trend(days: list[DaySummary]):
30
+ """Line chart of daily cost over the 7-day window."""
31
+ labels = [d.day.strftime("%a\n%m/%d") for d in days]
32
+ costs = [round(d.total_cost, 4) for d in days]
33
+
34
+ width = min(console.width, 100)
35
+ plt.clf()
36
+ plt.plotsize(width, 14)
37
+ plt.theme("dark")
38
+ plt.title("Daily Spend (USD)")
39
+ plt.xlabel("Day")
40
+ plt.ylabel("Cost $")
41
+
42
+ # color the line by the max cost of the week
43
+ max_cost = max(costs) if any(costs) else 0
44
+ line_color = _plt_color(max_cost)
45
+
46
+ plt.plot(costs, color=line_color, marker="braille")
47
+ plt.xticks(list(range(1, len(labels) + 1)), labels)
48
+ plt.yfrequency(5)
49
+ plt.show()
50
+
51
+
52
+ def chart_model_bars(days: list[DaySummary]):
53
+ """Horizontal bar chart of cost per model (all-time or period)."""
54
+ combined: dict[str, float] = {}
55
+ for day in days:
56
+ for model, s in day.models.items():
57
+ combined[model] = combined.get(model, 0.0) + s.cost
58
+
59
+ if not combined:
60
+ return
61
+
62
+ sorted_models = sorted(combined.items(), key=lambda x: x[1])
63
+ names = [m.replace("claude-", "") for m, _ in sorted_models]
64
+ costs = [round(c, 4) for _, c in sorted_models]
65
+ colors = [_plt_color(c) for c in costs]
66
+
67
+ width = min(console.width, 100)
68
+ plt.clf()
69
+ plt.plotsize(width, max(6, len(names) * 3 + 4))
70
+ plt.theme("dark")
71
+ plt.title("Cost by Model (USD)")
72
+ plt.bar(names, costs, orientation="horizontal", color=colors)
73
+ plt.show()
74
+
75
+
76
+ def chart_projects_bars(rows: list[dict]):
77
+ """Horizontal bar chart of cost per project, top 10."""
78
+ from .stats import calc_cost
79
+
80
+ projects: dict[str, float] = {}
81
+ for row in rows:
82
+ p = row["project"]
83
+ projects[p] = projects.get(p, 0.0) + calc_cost(
84
+ row["model"],
85
+ row["input_tokens"], row["output_tokens"],
86
+ row["cache_write_tokens"], row["cache_read_tokens"],
87
+ )
88
+
89
+ top = sorted(projects.items(), key=lambda x: x[1])[-10:]
90
+ names = [p.split("/")[-1] or p for p, _ in top]
91
+ costs = [round(c, 4) for _, c in top]
92
+ colors = [_plt_color(c) for c in costs]
93
+
94
+ width = min(console.width, 100)
95
+ plt.clf()
96
+ plt.plotsize(width, max(6, len(names) * 3 + 4))
97
+ plt.theme("dark")
98
+ plt.title("Cost by Project — Top 10 (USD)")
99
+ plt.bar(names, costs, orientation="horizontal", color=colors)
100
+ plt.show()
101
+
102
+
103
+ def chart_today_tokens(stats: dict[str, ModelStats]):
104
+ """Stacked bar showing token type breakdown per model."""
105
+ if not stats:
106
+ return
107
+
108
+ models = sorted(stats.keys())
109
+ short = [m.replace("claude-", "") for m in models]
110
+
111
+ inputs = [stats[m].input_tokens for m in models]
112
+ outputs = [stats[m].output_tokens for m in models]
113
+ cache_writes = [stats[m].cache_write_tokens for m in models]
114
+ cache_reads = [stats[m].cache_read_tokens for m in models]
115
+
116
+ width = min(console.width, 100)
117
+ plt.clf()
118
+ plt.plotsize(width, 16)
119
+ plt.theme("dark")
120
+ plt.title("Token Breakdown by Type")
121
+ plt.multiple_bar(
122
+ short,
123
+ [inputs, outputs, cache_writes, cache_reads],
124
+ labels=["Input", "Output", "Cache Write", "Cache Read"],
125
+ color=["cyan", "green", "yellow", "magenta"],
126
+ )
127
+ plt.show()
128
+
129
+ _BAR_WIDTH = 28
130
+ _BAR_CHAR = "█"
131
+ _EMPTY_CHAR = "░"
132
+
133
+
134
+ def fmt_tokens(n: int) -> str:
135
+ if n >= 1_000_000:
136
+ return f"{n/1_000_000:.1f}M"
137
+ if n >= 1_000:
138
+ return f"{n/1_000:.1f}k"
139
+ return str(n)
140
+
141
+
142
+ def fmt_cost(cost: float) -> str:
143
+ if cost >= 10:
144
+ return f"${cost:.2f}"
145
+ return f"${cost:.4f}"
146
+
147
+
148
+ def cost_style(cost: float) -> str:
149
+ if cost >= 1.0:
150
+ return "red"
151
+ if cost >= 0.25:
152
+ return "yellow"
153
+ return "green"
154
+
155
+
156
+ def _bar(value: float, max_value: float, width: int = _BAR_WIDTH) -> Text:
157
+ if max_value == 0:
158
+ filled = 0
159
+ else:
160
+ filled = round((value / max_value) * width)
161
+ filled = max(0, min(filled, width))
162
+
163
+ style = cost_style(value)
164
+ bar = Text()
165
+ bar.append(_BAR_CHAR * filled, style=style)
166
+ bar.append(_EMPTY_CHAR * (width - filled), style="dim")
167
+ return bar
168
+
169
+
170
+ def render_today_table(stats: dict[str, ModelStats], day: date) -> Table:
171
+ table = Table(
172
+ title=f"Today's Usage — {day.strftime('%A, %B %d %Y')}",
173
+ box=box.ROUNDED,
174
+ show_footer=True,
175
+ )
176
+ table.add_column("Model", footer="[bold]TOTAL[/bold]")
177
+ table.add_column("Input", justify="right", footer="")
178
+ table.add_column("Output", justify="right", footer="")
179
+ table.add_column("Cache Write", justify="right", footer="")
180
+ table.add_column("Cache Read", justify="right", footer="")
181
+ table.add_column("Total Tokens", justify="right")
182
+ table.add_column("Cost (USD)", justify="right")
183
+
184
+ total_cost = sum(s.cost for s in stats.values())
185
+ total_tokens = sum(s.total_tokens for s in stats.values())
186
+
187
+ for model, s in sorted(stats.items()):
188
+ style = cost_style(s.cost)
189
+ table.add_row(
190
+ model,
191
+ fmt_tokens(s.input_tokens),
192
+ fmt_tokens(s.output_tokens),
193
+ fmt_tokens(s.cache_write_tokens),
194
+ fmt_tokens(s.cache_read_tokens),
195
+ fmt_tokens(s.total_tokens),
196
+ Text(fmt_cost(s.cost), style=style),
197
+ )
198
+
199
+ # patch footer for the last two columns
200
+ table.columns[5].footer = Text(fmt_tokens(total_tokens), style="bold")
201
+ table.columns[6].footer = Text(fmt_cost(total_cost), style=f"bold {cost_style(total_cost)}")
202
+
203
+ return table
204
+
205
+
206
+ def render_weekly_table(days: list[DaySummary]) -> Table:
207
+ max_cost = max((d.total_cost for d in days), default=0)
208
+
209
+ table = Table(
210
+ title=f"Weekly Usage — {days[0].day.strftime('%b %d')} to {days[-1].day.strftime('%b %d, %Y')}",
211
+ box=box.ROUNDED,
212
+ show_footer=True,
213
+ )
214
+ table.add_column("Date", footer="[bold]TOTAL[/bold]")
215
+ table.add_column("Day", style="dim", footer="")
216
+ table.add_column("Tokens", justify="right", footer="")
217
+ table.add_column("Cost (USD)", justify="right")
218
+ table.add_column("Spend", no_wrap=True, footer="")
219
+
220
+ grand_cost = sum(d.total_cost for d in days)
221
+ grand_tokens = sum(d.total_tokens for d in days)
222
+
223
+ for day in days:
224
+ if not day.models:
225
+ table.add_row(
226
+ day.day.strftime("%Y-%m-%d"),
227
+ day.day.strftime("%a"),
228
+ Text("—", style="dim"),
229
+ Text("—", style="dim"),
230
+ Text(_EMPTY_CHAR * _BAR_WIDTH, style="dim"),
231
+ )
232
+ continue
233
+
234
+ style = cost_style(day.total_cost)
235
+ table.add_row(
236
+ day.day.strftime("%Y-%m-%d"),
237
+ day.day.strftime("%a"),
238
+ fmt_tokens(day.total_tokens),
239
+ Text(fmt_cost(day.total_cost), style=style),
240
+ _bar(day.total_cost, max_cost),
241
+ )
242
+
243
+ table.columns[2].footer = Text(fmt_tokens(grand_tokens), style="bold")
244
+ table.columns[3].footer = Text(fmt_cost(grand_cost), style=f"bold {cost_style(grand_cost)}")
245
+
246
+ return table
247
+
248
+
249
+ def render_model_breakdown(days: list[DaySummary]) -> Panel:
250
+ """Side panel showing per-model totals across the period."""
251
+ combined: dict[str, ModelStats] = {}
252
+ for day in days:
253
+ for model, s in day.models.items():
254
+ if model not in combined:
255
+ from .stats import ModelStats as MS
256
+ combined[model] = MS(model=model)
257
+ c = combined[model]
258
+ c.input_tokens += s.input_tokens
259
+ c.output_tokens += s.output_tokens
260
+ c.cache_write_tokens += s.cache_write_tokens
261
+ c.cache_read_tokens += s.cache_read_tokens
262
+ c.cost += s.cost
263
+
264
+ if not combined:
265
+ return Panel("[dim]No data[/dim]", title="By Model")
266
+
267
+ lines = Text()
268
+ for model, s in sorted(combined.items(), key=lambda x: x[1].cost, reverse=True):
269
+ style = cost_style(s.cost)
270
+ lines.append(f" {model}\n", style="bold")
271
+ lines.append(f" tokens : {fmt_tokens(s.total_tokens)}\n", style="dim")
272
+ lines.append(f" cost : ", style="dim")
273
+ lines.append(f"{fmt_cost(s.cost)}\n", style=style)
274
+
275
+ return Panel(lines, title="[bold]By Model[/bold]", box=box.ROUNDED, padding=(0, 1))
276
+
277
+
278
+ def render_stats_table(days: list[DaySummary], earliest: str | None, latest: str | None) -> Table:
279
+ combined: dict[str, ModelStats] = {}
280
+ for day in days:
281
+ for model, s in day.models.items():
282
+ if model not in combined:
283
+ from .stats import ModelStats as MS
284
+ combined[model] = MS(model=model)
285
+ c = combined[model]
286
+ c.input_tokens += s.input_tokens
287
+ c.output_tokens += s.output_tokens
288
+ c.cache_write_tokens += s.cache_write_tokens
289
+ c.cache_read_tokens += s.cache_read_tokens
290
+ c.cost += s.cost
291
+
292
+ span = f"{earliest} → {latest}" if earliest and latest else "no data"
293
+ table = Table(
294
+ title=f"All-Time Usage [{span}]",
295
+ box=box.ROUNDED,
296
+ show_footer=True,
297
+ )
298
+ table.add_column("Model", footer="[bold]TOTAL[/bold]")
299
+ table.add_column("Input", justify="right", footer="")
300
+ table.add_column("Output", justify="right", footer="")
301
+ table.add_column("Cache Write", justify="right", footer="")
302
+ table.add_column("Cache Read", justify="right", footer="")
303
+ table.add_column("Total Tokens", justify="right")
304
+ table.add_column("Cost (USD)", justify="right")
305
+
306
+ total_cost = sum(s.cost for s in combined.values())
307
+ total_tokens = sum(s.total_tokens for s in combined.values())
308
+
309
+ for model, s in sorted(combined.items(), key=lambda x: x[1].cost, reverse=True):
310
+ style = cost_style(s.cost)
311
+ table.add_row(
312
+ model,
313
+ fmt_tokens(s.input_tokens),
314
+ fmt_tokens(s.output_tokens),
315
+ fmt_tokens(s.cache_write_tokens),
316
+ fmt_tokens(s.cache_read_tokens),
317
+ fmt_tokens(s.total_tokens),
318
+ Text(fmt_cost(s.cost), style=style),
319
+ )
320
+
321
+ table.columns[5].footer = Text(fmt_tokens(total_tokens), style="bold")
322
+ table.columns[6].footer = Text(fmt_cost(total_cost), style=f"bold {cost_style(total_cost)}")
323
+ return table
324
+
325
+
326
+ def render_projects_table(rows: list[dict]) -> Table:
327
+ from .stats import calc_cost
328
+
329
+ # aggregate per project across models
330
+ projects: dict[str, dict] = {}
331
+ for row in rows:
332
+ p = row["project"]
333
+ if p not in projects:
334
+ projects[p] = {
335
+ "input": 0, "output": 0, "cache_write": 0, "cache_read": 0,
336
+ "cost": 0.0, "first_seen": row["first_seen"], "last_seen": row["last_seen"],
337
+ "models": set(),
338
+ }
339
+ d = projects[p]
340
+ d["input"] += row["input_tokens"]
341
+ d["output"] += row["output_tokens"]
342
+ d["cache_write"] += row["cache_write_tokens"]
343
+ d["cache_read"] += row["cache_read_tokens"]
344
+ d["cost"] += calc_cost(
345
+ row["model"],
346
+ row["input_tokens"], row["output_tokens"],
347
+ row["cache_write_tokens"], row["cache_read_tokens"],
348
+ )
349
+ d["models"].add(row["model"])
350
+ if row["first_seen"] < d["first_seen"]:
351
+ d["first_seen"] = row["first_seen"]
352
+ if row["last_seen"] > d["last_seen"]:
353
+ d["last_seen"] = row["last_seen"]
354
+
355
+ sorted_projects = sorted(projects.items(), key=lambda x: x[1]["cost"], reverse=True)
356
+
357
+ table = Table(title="Usage by Project", box=box.ROUNDED, show_footer=True)
358
+ table.add_column("Project", footer="[bold]TOTAL[/bold]")
359
+ table.add_column("Tokens", justify="right")
360
+ table.add_column("Cost (USD)", justify="right")
361
+ table.add_column("Last Active", style="dim")
362
+ table.add_column("Models", style="dim")
363
+
364
+ grand_cost = sum(d["cost"] for d in projects.values())
365
+ grand_tokens = sum(
366
+ d["input"] + d["output"] + d["cache_write"] + d["cache_read"]
367
+ for d in projects.values()
368
+ )
369
+
370
+ for project, d in sorted_projects:
371
+ total = d["input"] + d["output"] + d["cache_write"] + d["cache_read"]
372
+ style = cost_style(d["cost"])
373
+ model_names = ", ".join(
374
+ m.split("-")[1] if "-" in m else m for m in sorted(d["models"])
375
+ )
376
+ table.add_row(
377
+ project[:50],
378
+ fmt_tokens(total),
379
+ Text(fmt_cost(d["cost"]), style=style),
380
+ d["last_seen"][:10],
381
+ model_names,
382
+ )
383
+
384
+ table.columns[1].footer = Text(fmt_tokens(grand_tokens), style="bold")
385
+ table.columns[2].footer = Text(fmt_cost(grand_cost), style=f"bold {cost_style(grand_cost)}")
386
+ return table
387
+
388
+
389
+ def render_setup_guide(projects_dir: str, db_path: str, prices_path: str, user_prices_path: str):
390
+ from rich.padding import Padding
391
+
392
+ console.print()
393
+ console.print(Panel(
394
+ Text("cctrack is ready to use!", style="bold green", justify="center"),
395
+ box=box.ROUNDED,
396
+ ))
397
+ console.print()
398
+
399
+ console.print("[bold]Data Sources[/bold]")
400
+ console.print(f" Claude Code sessions : [cyan]{projects_dir}[/cyan]")
401
+ console.print(f" SQLite database : [cyan]{db_path}[/cyan]")
402
+ console.print(f" Bundled prices : [cyan]{prices_path}[/cyan]")
403
+ console.print(f" Custom prices (opt.) : [cyan]{user_prices_path}[/cyan]")
404
+ console.print()
405
+
406
+ console.print("[bold]Commands[/bold]")
407
+ cmds = [
408
+ ("cctrack today", "Token usage for today"),
409
+ ("cctrack weekly", "Last 7 days breakdown + bar chart"),
410
+ ("cctrack stats", "All-time summary by model"),
411
+ ("cctrack projects", "Usage broken down by project"),
412
+ ("cctrack prices", "Show model pricing"),
413
+ ("cctrack prompt", "Compact string for shell prompt"),
414
+ ]
415
+ for cmd, desc in cmds:
416
+ console.print(f" [bold cyan]{cmd:<24}[/bold cyan] {desc}")
417
+ console.print()
418
+
419
+ console.print("[bold]Shell Prompt Integration[/bold]")
420
+ console.print(" Add one line to your shell config to see today's spend on every prompt:\n")
421
+ console.print(" [dim]# bash (~/.bashrc) or zsh (~/.zshrc)[/dim]")
422
+ console.print(' [bold green]export PS1="$(cctrack-prompt) $PS1"[/bold green]\n')
423
+ console.print(" [dim]# fish (~/.config/fish/config.fish)[/dim]")
424
+ console.print(' [bold green]function fish_prompt; echo (cctrack-prompt) (fish_prompt); end[/bold green]\n')
425
+ console.print(" [dim]# Starship (~/.config/starship.toml)[/dim]")
426
+ console.print(" [bold green][custom.cctrack][/bold green]")
427
+ console.print(' [bold green]command = "cctrack-prompt"[/bold green]')
428
+ console.print(' [bold green]when = "true"[/bold green]\n')
429
+ console.print(" Output format options: [cyan]--format tokens[/cyan] [cyan]--format cost[/cyan] [cyan]--format both[/cyan] (default)\n")
430
+
431
+ console.print("[bold]Override Prices[/bold]")
432
+ console.print(f" Copy the bundled prices.json to [cyan]{user_prices_path}[/cyan]")
433
+ console.print(" and edit the values — cctrack will prefer your copy automatically.")
434
+ console.print()
435
+
436
+
437
+ def print_no_data(period: str):
438
+ console.print(f"[yellow]No usage found for {period}.[/yellow]")
439
+
440
+
441
+ def print_sync_note(new: int):
442
+ if new:
443
+ console.print(f"[dim]Synced {new} new entries.[/dim]")