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 +0 -0
- cctrack/config.py +38 -0
- cctrack/data/prices.json +30 -0
- cctrack/display.py +443 -0
- cctrack/main.py +222 -0
- cctrack/parser.py +83 -0
- cctrack/prompt_cmd.py +92 -0
- cctrack/stats.py +87 -0
- cctrack/storage.py +157 -0
- cctrack_ai-0.1.0.dist-info/METADATA +272 -0
- cctrack_ai-0.1.0.dist-info/RECORD +13 -0
- cctrack_ai-0.1.0.dist-info/WHEEL +4 -0
- cctrack_ai-0.1.0.dist-info/entry_points.txt +3 -0
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
|
cctrack/data/prices.json
ADDED
|
@@ -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]")
|