ccburn 0.3.1__tar.gz → 0.4.0__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.
- {ccburn-0.3.1/src/ccburn.egg-info → ccburn-0.4.0}/PKG-INFO +1 -1
- {ccburn-0.3.1 → ccburn-0.4.0}/pyproject.toml +1 -1
- {ccburn-0.3.1 → ccburn-0.4.0}/src/ccburn/app.py +132 -6
- {ccburn-0.3.1 → ccburn-0.4.0}/src/ccburn/cli.py +38 -0
- {ccburn-0.3.1 → ccburn-0.4.0}/src/ccburn/data/history.py +56 -3
- {ccburn-0.3.1 → ccburn-0.4.0}/src/ccburn/data/models.py +92 -9
- {ccburn-0.3.1 → ccburn-0.4.0}/src/ccburn/display/chart.py +14 -9
- {ccburn-0.3.1 → ccburn-0.4.0}/src/ccburn/display/gauges.py +50 -16
- {ccburn-0.3.1 → ccburn-0.4.0}/src/ccburn/display/layout.py +11 -5
- {ccburn-0.3.1 → ccburn-0.4.0}/src/ccburn/main.py +44 -7
- {ccburn-0.3.1 → ccburn-0.4.0}/src/ccburn/utils/calculator.py +11 -4
- {ccburn-0.3.1 → ccburn-0.4.0/src/ccburn.egg-info}/PKG-INFO +1 -1
- {ccburn-0.3.1 → ccburn-0.4.0}/LICENSE +0 -0
- {ccburn-0.3.1 → ccburn-0.4.0}/README.md +0 -0
- {ccburn-0.3.1 → ccburn-0.4.0}/setup.cfg +0 -0
- {ccburn-0.3.1 → ccburn-0.4.0}/src/ccburn/__init__.py +0 -0
- {ccburn-0.3.1 → ccburn-0.4.0}/src/ccburn/data/__init__.py +0 -0
- {ccburn-0.3.1 → ccburn-0.4.0}/src/ccburn/data/credentials.py +0 -0
- {ccburn-0.3.1 → ccburn-0.4.0}/src/ccburn/data/usage_client.py +0 -0
- {ccburn-0.3.1 → ccburn-0.4.0}/src/ccburn/display/__init__.py +0 -0
- {ccburn-0.3.1 → ccburn-0.4.0}/src/ccburn/utils/__init__.py +0 -0
- {ccburn-0.3.1 → ccburn-0.4.0}/src/ccburn/utils/formatting.py +0 -0
- {ccburn-0.3.1 → ccburn-0.4.0}/src/ccburn.egg-info/SOURCES.txt +0 -0
- {ccburn-0.3.1 → ccburn-0.4.0}/src/ccburn.egg-info/dependency_links.txt +0 -0
- {ccburn-0.3.1 → ccburn-0.4.0}/src/ccburn.egg-info/entry_points.txt +0 -0
- {ccburn-0.3.1 → ccburn-0.4.0}/src/ccburn.egg-info/requires.txt +0 -0
- {ccburn-0.3.1 → ccburn-0.4.0}/src/ccburn.egg-info/top_level.txt +0 -0
- {ccburn-0.3.1 → ccburn-0.4.0}/tests/test_calculator.py +0 -0
- {ccburn-0.3.1 → ccburn-0.4.0}/tests/test_cli.py +0 -0
- {ccburn-0.3.1 → ccburn-0.4.0}/tests/test_formatting.py +0 -0
- {ccburn-0.3.1 → ccburn-0.4.0}/tests/test_history.py +0 -0
- {ccburn-0.3.1 → ccburn-0.4.0}/tests/test_models.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ccburn"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Terminal-based Claude Code usage limit visualizer with real-time burn-up charts"
|
|
9
9
|
authors = [{name = "JuanjoFuchs"}]
|
|
10
10
|
readme = "README.md"
|
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
import os
|
|
4
4
|
import signal
|
|
5
5
|
import threading
|
|
6
|
+
import time as _time
|
|
6
7
|
from datetime import datetime, timedelta, timezone
|
|
7
8
|
from typing import Any
|
|
8
9
|
|
|
9
10
|
from rich.console import Console
|
|
10
11
|
from rich.live import Live
|
|
12
|
+
from rich.text import Text
|
|
11
13
|
|
|
12
14
|
try:
|
|
13
15
|
from .data.credentials import CredentialsNotFoundError, TokenExpiredError
|
|
@@ -137,6 +139,7 @@ class CCBurnApp:
|
|
|
137
139
|
Returns:
|
|
138
140
|
True if fetch succeeded
|
|
139
141
|
"""
|
|
142
|
+
_ft0 = _time.perf_counter()
|
|
140
143
|
try:
|
|
141
144
|
snapshot = None
|
|
142
145
|
|
|
@@ -155,10 +158,16 @@ class CCBurnApp:
|
|
|
155
158
|
self.limit_type,
|
|
156
159
|
since=self._get_since_datetime(),
|
|
157
160
|
)
|
|
161
|
+
if self.debug:
|
|
162
|
+
self.console.print(f"[dim] fetch: used cache (age={age:.1f}s) in {_time.perf_counter()-_ft0:.3f}s[/dim]")
|
|
158
163
|
return True
|
|
159
164
|
|
|
165
|
+
_ft1 = _time.perf_counter()
|
|
160
166
|
# No fresh cached data, fetch from API
|
|
161
167
|
snapshot = self.client.fetch_usage()
|
|
168
|
+
_ft2 = _time.perf_counter()
|
|
169
|
+
if self.debug:
|
|
170
|
+
self.console.print(f"[dim] fetch: API call took {_ft2-_ft1:.3f}s[/dim]")
|
|
162
171
|
self.last_snapshot = snapshot
|
|
163
172
|
self.last_fetch_time = datetime.now(timezone.utc)
|
|
164
173
|
self.last_error = None
|
|
@@ -197,26 +206,123 @@ class CCBurnApp:
|
|
|
197
206
|
self.last_error = f"Unexpected error: {e}"
|
|
198
207
|
return False
|
|
199
208
|
|
|
209
|
+
def _fetch_with_loading(self) -> bool:
|
|
210
|
+
"""Fetch data while showing a loading message with elapsed time.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
True if fetch succeeded
|
|
214
|
+
"""
|
|
215
|
+
result_holder = {"result": False, "done": False}
|
|
216
|
+
|
|
217
|
+
def fetch_thread():
|
|
218
|
+
result_holder["result"] = self._fetch_and_update()
|
|
219
|
+
result_holder["done"] = True
|
|
220
|
+
|
|
221
|
+
thread = threading.Thread(target=fetch_thread, daemon=True)
|
|
222
|
+
thread.start()
|
|
223
|
+
|
|
224
|
+
start_time = _time.perf_counter()
|
|
225
|
+
with Live(console=self.console, refresh_per_second=10, transient=True) as live:
|
|
226
|
+
while not result_holder["done"]:
|
|
227
|
+
elapsed = _time.perf_counter() - start_time
|
|
228
|
+
text = Text()
|
|
229
|
+
text.append("🔥 ", style="yellow")
|
|
230
|
+
text.append("Fetching data from Anthropic API... ", style="dim")
|
|
231
|
+
text.append(f"{elapsed:.1f}s", style="cyan")
|
|
232
|
+
live.update(text)
|
|
233
|
+
_time.sleep(0.1)
|
|
234
|
+
|
|
235
|
+
thread.join(timeout=1.0)
|
|
236
|
+
return result_holder["result"]
|
|
237
|
+
|
|
238
|
+
def _check_limit_available(self) -> bool:
|
|
239
|
+
"""Check if the requested limit type is available in the fetched data.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
True if limit data is available
|
|
243
|
+
"""
|
|
244
|
+
if self.last_snapshot is None:
|
|
245
|
+
return False
|
|
246
|
+
return self.last_snapshot.get_limit(self.limit_type) is not None
|
|
247
|
+
|
|
248
|
+
def _show_unavailable_error(self) -> None:
|
|
249
|
+
"""Show helpful error when requested limit type is not available."""
|
|
250
|
+
self.console.print(
|
|
251
|
+
f"[red]{self.limit_type.display_name} data not available.[/red]"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# List what IS available
|
|
255
|
+
available = []
|
|
256
|
+
if self.last_snapshot:
|
|
257
|
+
if self.last_snapshot.session:
|
|
258
|
+
session = self.last_snapshot.session
|
|
259
|
+
available.append(
|
|
260
|
+
f" - [cyan]session[/cyan] - 5-hour limit "
|
|
261
|
+
f"({session.utilization_percent:.0f}% used)"
|
|
262
|
+
)
|
|
263
|
+
if self.last_snapshot.monthly:
|
|
264
|
+
monthly = self.last_snapshot.monthly
|
|
265
|
+
available.append(
|
|
266
|
+
f" - [cyan]monthly[/cyan] - Credits usage "
|
|
267
|
+
f"(${monthly.used_credits_dollars:.2f} / ${monthly.monthly_limit_dollars:.2f})"
|
|
268
|
+
)
|
|
269
|
+
if self.last_snapshot.weekly:
|
|
270
|
+
weekly = self.last_snapshot.weekly
|
|
271
|
+
available.append(
|
|
272
|
+
f" - [cyan]weekly[/cyan] - 7-day limit "
|
|
273
|
+
f"({weekly.utilization_percent:.0f}% used)"
|
|
274
|
+
)
|
|
275
|
+
if self.last_snapshot.weekly_sonnet:
|
|
276
|
+
sonnet = self.last_snapshot.weekly_sonnet
|
|
277
|
+
available.append(
|
|
278
|
+
f" - [cyan]weekly-sonnet[/cyan] - 7-day Sonnet limit "
|
|
279
|
+
f"({sonnet.utilization_percent:.0f}% used)"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
if available:
|
|
283
|
+
self.console.print("\n[dim]Available:[/dim]")
|
|
284
|
+
for item in available:
|
|
285
|
+
self.console.print(item)
|
|
286
|
+
self.console.print("\n[dim]Tip: Run 'ccburn' to auto-detect.[/dim]")
|
|
287
|
+
else:
|
|
288
|
+
self.console.print(
|
|
289
|
+
"\n[dim]No usage data available. Check Claude Code authentication.[/dim]"
|
|
290
|
+
)
|
|
291
|
+
|
|
200
292
|
def run(self) -> int:
|
|
201
293
|
"""Run the application.
|
|
202
294
|
|
|
203
295
|
Returns:
|
|
204
296
|
Exit code (0 for success, 1 for error)
|
|
205
297
|
"""
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
self.console.print("[dim]🔥 Loading ccburn...[/dim]", end="\r")
|
|
298
|
+
_t0 = _time.perf_counter()
|
|
299
|
+
show_loading = not self.json_output and not self.compact
|
|
209
300
|
|
|
301
|
+
_t1 = _time.perf_counter()
|
|
210
302
|
if not self._initialize():
|
|
211
303
|
return 1
|
|
304
|
+
_t2 = _time.perf_counter()
|
|
212
305
|
|
|
213
|
-
# Initial fetch
|
|
214
|
-
if
|
|
306
|
+
# Initial fetch - show loading timer for interactive modes
|
|
307
|
+
if show_loading:
|
|
308
|
+
fetch_success = self._fetch_with_loading()
|
|
309
|
+
else:
|
|
310
|
+
fetch_success = self._fetch_and_update()
|
|
311
|
+
|
|
312
|
+
if not fetch_success:
|
|
215
313
|
if self.last_snapshot is None:
|
|
216
|
-
# No cached data available - error should have been printed in _fetch_and_update
|
|
217
314
|
self.console.print("[red]Failed to fetch usage data. Check your credentials.[/red]")
|
|
218
315
|
return 1
|
|
219
316
|
|
|
317
|
+
# Check if requested limit type is available
|
|
318
|
+
if not self._check_limit_available():
|
|
319
|
+
self._show_unavailable_error()
|
|
320
|
+
return 1
|
|
321
|
+
|
|
322
|
+
_t3 = _time.perf_counter()
|
|
323
|
+
if self.debug:
|
|
324
|
+
self.console.print(f"[dim]Timing: init={_t2-_t1:.3f}s fetch={_t3-_t2:.3f}s total={_t3-_t0:.3f}s[/dim]")
|
|
325
|
+
|
|
220
326
|
# Handle different output modes
|
|
221
327
|
if self.json_output:
|
|
222
328
|
return self._run_json()
|
|
@@ -258,6 +364,7 @@ class CCBurnApp:
|
|
|
258
364
|
self.last_snapshot.session,
|
|
259
365
|
self.last_snapshot.weekly,
|
|
260
366
|
self.last_snapshot.weekly_sonnet,
|
|
367
|
+
self.last_snapshot.monthly,
|
|
261
368
|
budget_pace,
|
|
262
369
|
)
|
|
263
370
|
self.console.print(output)
|
|
@@ -418,6 +525,25 @@ class CCBurnApp:
|
|
|
418
525
|
"status": metrics.status,
|
|
419
526
|
}
|
|
420
527
|
|
|
528
|
+
# Add monthly credits if available
|
|
529
|
+
monthly_data = self.last_snapshot.monthly
|
|
530
|
+
if monthly_data:
|
|
531
|
+
metrics = calculate_burn_metrics(monthly_data, self.snapshots)
|
|
532
|
+
days_left = int(
|
|
533
|
+
(monthly_data.resets_at - datetime.now(timezone.utc)).total_seconds() / 86400
|
|
534
|
+
)
|
|
535
|
+
output["limits"]["monthly"] = {
|
|
536
|
+
"utilization": monthly_data.utilization,
|
|
537
|
+
"budget_pace": metrics.budget_pace,
|
|
538
|
+
"resets_at": monthly_data.resets_at.isoformat(),
|
|
539
|
+
"resets_in_days": days_left,
|
|
540
|
+
"window_hours": monthly_data.window_hours,
|
|
541
|
+
"status": metrics.status,
|
|
542
|
+
"used_credits_dollars": monthly_data.used_credits_dollars,
|
|
543
|
+
"monthly_limit_dollars": monthly_data.monthly_limit_dollars,
|
|
544
|
+
"remaining_dollars": monthly_data.remaining_dollars,
|
|
545
|
+
}
|
|
546
|
+
|
|
421
547
|
# Add burn rate and projection for selected limit
|
|
422
548
|
limit_data = self.last_snapshot.get_limit(self.limit_type)
|
|
423
549
|
if limit_data:
|
|
@@ -99,6 +99,15 @@ WeeklyIntervalOption = typer.Option(
|
|
|
99
99
|
max=300,
|
|
100
100
|
)
|
|
101
101
|
|
|
102
|
+
MonthlyIntervalOption = typer.Option(
|
|
103
|
+
60,
|
|
104
|
+
"--interval",
|
|
105
|
+
"-i",
|
|
106
|
+
help="Refresh interval in seconds (default: 60 for monthly).",
|
|
107
|
+
min=1,
|
|
108
|
+
max=300,
|
|
109
|
+
)
|
|
110
|
+
|
|
102
111
|
DebugOption = typer.Option(
|
|
103
112
|
False,
|
|
104
113
|
"--debug",
|
|
@@ -236,6 +245,34 @@ def create_weekly_sonnet_command(app: typer.Typer) -> None:
|
|
|
236
245
|
)
|
|
237
246
|
|
|
238
247
|
|
|
248
|
+
def create_monthly_command(app: typer.Typer) -> None:
|
|
249
|
+
"""Create the monthly subcommand."""
|
|
250
|
+
|
|
251
|
+
@app.command()
|
|
252
|
+
def monthly(
|
|
253
|
+
json_output: bool = JsonOption,
|
|
254
|
+
once: bool = OnceOption,
|
|
255
|
+
compact: bool = CompactOption,
|
|
256
|
+
since: str | None = SinceOption,
|
|
257
|
+
interval: int = MonthlyIntervalOption,
|
|
258
|
+
debug: bool = DebugOption,
|
|
259
|
+
) -> None:
|
|
260
|
+
"""Display monthly credit usage (enterprise accounts).
|
|
261
|
+
|
|
262
|
+
Shows your monthly credit budget and current usage.
|
|
263
|
+
Resets on the 1st of each month.
|
|
264
|
+
"""
|
|
265
|
+
run_app(
|
|
266
|
+
LimitType.MONTHLY,
|
|
267
|
+
json_output=json_output,
|
|
268
|
+
once=once,
|
|
269
|
+
compact=compact,
|
|
270
|
+
since=since,
|
|
271
|
+
interval=interval,
|
|
272
|
+
debug=debug,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
239
276
|
def create_clear_history_command(app: typer.Typer) -> None:
|
|
240
277
|
"""Create the clear-history command."""
|
|
241
278
|
|
|
@@ -281,4 +318,5 @@ def register_commands(app: typer.Typer) -> None:
|
|
|
281
318
|
create_session_command(app)
|
|
282
319
|
create_weekly_command(app)
|
|
283
320
|
create_weekly_sonnet_command(app)
|
|
321
|
+
create_monthly_command(app)
|
|
284
322
|
create_clear_history_command(app)
|
|
@@ -6,9 +6,9 @@ from datetime import datetime, timedelta, timezone
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
8
|
try:
|
|
9
|
-
from .models import LimitData, LimitType, UsageSnapshot
|
|
9
|
+
from .models import LimitData, LimitType, MonthlyLimitData, UsageSnapshot
|
|
10
10
|
except ImportError:
|
|
11
|
-
from ccburn.data.models import LimitData, LimitType, UsageSnapshot
|
|
11
|
+
from ccburn.data.models import LimitData, LimitType, MonthlyLimitData, UsageSnapshot
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
logger = logging.getLogger(__name__)
|
|
@@ -39,6 +39,12 @@ class HistoryDB:
|
|
|
39
39
|
seven_day_opus_utilization REAL,
|
|
40
40
|
seven_day_opus_resets_at TEXT,
|
|
41
41
|
|
|
42
|
+
-- Monthly credits (enterprise)
|
|
43
|
+
monthly_limit_cents INTEGER,
|
|
44
|
+
monthly_used_credits_cents REAL,
|
|
45
|
+
monthly_utilization REAL,
|
|
46
|
+
monthly_resets_at TEXT,
|
|
47
|
+
|
|
42
48
|
-- Raw API response for debugging
|
|
43
49
|
raw_response TEXT
|
|
44
50
|
);
|
|
@@ -84,6 +90,7 @@ class HistoryDB:
|
|
|
84
90
|
str(self.db_path) if isinstance(self.db_path, Path) else self.db_path,
|
|
85
91
|
detect_types=sqlite3.PARSE_DECLTYPES,
|
|
86
92
|
timeout=10.0, # Wait up to 10 seconds for locks
|
|
93
|
+
check_same_thread=False, # Allow cross-thread access for loading spinner
|
|
87
94
|
)
|
|
88
95
|
self._conn.row_factory = sqlite3.Row
|
|
89
96
|
# Enable WAL mode for better concurrent read/write performance
|
|
@@ -104,6 +111,19 @@ class HistoryDB:
|
|
|
104
111
|
conn.executescript(self.SCHEMA)
|
|
105
112
|
conn.commit()
|
|
106
113
|
|
|
114
|
+
# Schema migration: Add monthly columns if they don't exist
|
|
115
|
+
cursor = conn.execute("PRAGMA table_info(usage_snapshots)")
|
|
116
|
+
columns = {row[1] for row in cursor.fetchall()}
|
|
117
|
+
|
|
118
|
+
if "monthly_utilization" not in columns:
|
|
119
|
+
conn.executescript("""
|
|
120
|
+
ALTER TABLE usage_snapshots ADD COLUMN monthly_limit_cents INTEGER;
|
|
121
|
+
ALTER TABLE usage_snapshots ADD COLUMN monthly_used_credits_cents REAL;
|
|
122
|
+
ALTER TABLE usage_snapshots ADD COLUMN monthly_utilization REAL;
|
|
123
|
+
ALTER TABLE usage_snapshots ADD COLUMN monthly_resets_at TEXT;
|
|
124
|
+
""")
|
|
125
|
+
conn.commit()
|
|
126
|
+
|
|
107
127
|
def save_snapshot(self, snapshot: UsageSnapshot) -> None:
|
|
108
128
|
"""Save a usage snapshot to the database.
|
|
109
129
|
|
|
@@ -152,6 +172,18 @@ class HistoryDB:
|
|
|
152
172
|
snapshot.weekly_opus.resets_at.isoformat() if snapshot.weekly_opus else None
|
|
153
173
|
)
|
|
154
174
|
|
|
175
|
+
# Monthly credits data
|
|
176
|
+
monthly_limit = (
|
|
177
|
+
snapshot.monthly.monthly_limit_cents if snapshot.monthly else None
|
|
178
|
+
)
|
|
179
|
+
monthly_used = (
|
|
180
|
+
snapshot.monthly.used_credits_cents if snapshot.monthly else None
|
|
181
|
+
)
|
|
182
|
+
monthly_util = snapshot.monthly.utilization if snapshot.monthly else None
|
|
183
|
+
monthly_resets = (
|
|
184
|
+
snapshot.monthly.resets_at.isoformat() if snapshot.monthly else None
|
|
185
|
+
)
|
|
186
|
+
|
|
155
187
|
conn.execute(
|
|
156
188
|
"""
|
|
157
189
|
INSERT INTO usage_snapshots (
|
|
@@ -160,8 +192,10 @@ class HistoryDB:
|
|
|
160
192
|
seven_day_all_utilization, seven_day_all_resets_at,
|
|
161
193
|
seven_day_sonnet_utilization, seven_day_sonnet_resets_at,
|
|
162
194
|
seven_day_opus_utilization, seven_day_opus_resets_at,
|
|
195
|
+
monthly_limit_cents, monthly_used_credits_cents,
|
|
196
|
+
monthly_utilization, monthly_resets_at,
|
|
163
197
|
raw_response
|
|
164
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
198
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
165
199
|
""",
|
|
166
200
|
(
|
|
167
201
|
snapshot.timestamp.isoformat(),
|
|
@@ -173,6 +207,10 @@ class HistoryDB:
|
|
|
173
207
|
sonnet_resets,
|
|
174
208
|
opus_util,
|
|
175
209
|
opus_resets,
|
|
210
|
+
monthly_limit,
|
|
211
|
+
monthly_used,
|
|
212
|
+
monthly_util,
|
|
213
|
+
monthly_resets,
|
|
176
214
|
snapshot.raw_response,
|
|
177
215
|
),
|
|
178
216
|
)
|
|
@@ -324,12 +362,27 @@ class HistoryDB:
|
|
|
324
362
|
limit_type=LimitType.WEEKLY, # Same window as weekly
|
|
325
363
|
)
|
|
326
364
|
|
|
365
|
+
# Parse monthly credits
|
|
366
|
+
monthly = None
|
|
367
|
+
monthly_util = row["monthly_utilization"] if "monthly_utilization" in row.keys() else None
|
|
368
|
+
if monthly_util is not None:
|
|
369
|
+
resets_at = datetime.fromisoformat(row["monthly_resets_at"])
|
|
370
|
+
if resets_at.tzinfo is None:
|
|
371
|
+
resets_at = resets_at.replace(tzinfo=timezone.utc)
|
|
372
|
+
monthly = MonthlyLimitData(
|
|
373
|
+
monthly_limit_cents=row["monthly_limit_cents"],
|
|
374
|
+
used_credits_cents=row["monthly_used_credits_cents"],
|
|
375
|
+
utilization=monthly_util,
|
|
376
|
+
resets_at=resets_at,
|
|
377
|
+
)
|
|
378
|
+
|
|
327
379
|
return UsageSnapshot(
|
|
328
380
|
timestamp=timestamp,
|
|
329
381
|
session=session,
|
|
330
382
|
weekly=weekly,
|
|
331
383
|
weekly_sonnet=weekly_sonnet,
|
|
332
384
|
weekly_opus=weekly_opus,
|
|
385
|
+
monthly=monthly,
|
|
333
386
|
raw_response=row["raw_response"],
|
|
334
387
|
)
|
|
335
388
|
except (ValueError, KeyError, TypeError) as e:
|
|
@@ -6,16 +6,22 @@ from enum import Enum
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class LimitType(str, Enum):
|
|
9
|
-
"""The
|
|
9
|
+
"""The usage limits we track."""
|
|
10
10
|
|
|
11
11
|
SESSION = "session" # 5-hour rolling
|
|
12
12
|
WEEKLY = "weekly" # 7-day all models
|
|
13
13
|
WEEKLY_SONNET = "weekly-sonnet" # 7-day sonnet only
|
|
14
|
+
MONTHLY = "monthly" # Monthly credits (enterprise)
|
|
14
15
|
|
|
15
16
|
@property
|
|
16
|
-
def window_hours(self) -> int:
|
|
17
|
-
"""Get the window duration in hours."""
|
|
18
|
-
|
|
17
|
+
def window_hours(self) -> int | None:
|
|
18
|
+
"""Get the window duration in hours. None for variable-length windows."""
|
|
19
|
+
if self == LimitType.SESSION:
|
|
20
|
+
return 5
|
|
21
|
+
elif self == LimitType.MONTHLY:
|
|
22
|
+
return None # Variable - calculated at runtime based on month
|
|
23
|
+
else:
|
|
24
|
+
return 168 # 7 * 24
|
|
19
25
|
|
|
20
26
|
@property
|
|
21
27
|
def display_name(self) -> str:
|
|
@@ -24,16 +30,18 @@ class LimitType(str, Enum):
|
|
|
24
30
|
LimitType.SESSION: "Session (5h)",
|
|
25
31
|
LimitType.WEEKLY: "Weekly",
|
|
26
32
|
LimitType.WEEKLY_SONNET: "Weekly Sonnet",
|
|
33
|
+
LimitType.MONTHLY: "Monthly Credits",
|
|
27
34
|
}[self]
|
|
28
35
|
|
|
29
36
|
@property
|
|
30
|
-
def api_field(self) -> str:
|
|
31
|
-
"""Get the corresponding API field name."""
|
|
37
|
+
def api_field(self) -> str | None:
|
|
38
|
+
"""Get the corresponding API field name. None for extra_usage-based types."""
|
|
32
39
|
return {
|
|
33
40
|
LimitType.SESSION: "five_hour",
|
|
34
41
|
LimitType.WEEKLY: "seven_day",
|
|
35
42
|
LimitType.WEEKLY_SONNET: "seven_day_sonnet",
|
|
36
|
-
|
|
43
|
+
LimitType.MONTHLY: None, # Uses extra_usage block
|
|
44
|
+
}.get(self)
|
|
37
45
|
|
|
38
46
|
|
|
39
47
|
@dataclass
|
|
@@ -47,7 +55,8 @@ class LimitData:
|
|
|
47
55
|
@property
|
|
48
56
|
def window_hours(self) -> int:
|
|
49
57
|
"""Get the window duration in hours."""
|
|
50
|
-
|
|
58
|
+
hours = self.limit_type.window_hours
|
|
59
|
+
return hours if hours is not None else 168 # Fallback for type safety
|
|
51
60
|
|
|
52
61
|
@property
|
|
53
62
|
def window_start(self) -> datetime:
|
|
@@ -60,6 +69,51 @@ class LimitData:
|
|
|
60
69
|
return self.utilization * 100
|
|
61
70
|
|
|
62
71
|
|
|
72
|
+
@dataclass
|
|
73
|
+
class MonthlyLimitData:
|
|
74
|
+
"""Data for monthly credit usage (enterprise accounts)."""
|
|
75
|
+
|
|
76
|
+
monthly_limit_cents: int # e.g., 30000 = $300.00
|
|
77
|
+
used_credits_cents: float # e.g., 7475.0 = $74.75
|
|
78
|
+
utilization: float # 0.0 to 1.0 normalized
|
|
79
|
+
resets_at: datetime # 1st of next month UTC
|
|
80
|
+
limit_type: LimitType = LimitType.MONTHLY
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def monthly_limit_dollars(self) -> float:
|
|
84
|
+
"""Get monthly limit in dollars."""
|
|
85
|
+
return self.monthly_limit_cents / 100.0
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def used_credits_dollars(self) -> float:
|
|
89
|
+
"""Get used credits in dollars."""
|
|
90
|
+
return self.used_credits_cents / 100.0
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def remaining_dollars(self) -> float:
|
|
94
|
+
"""Get remaining credits in dollars."""
|
|
95
|
+
return self.monthly_limit_dollars - self.used_credits_dollars
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def window_hours(self) -> int:
|
|
99
|
+
"""Get days in current month * 24."""
|
|
100
|
+
now = datetime.now(timezone.utc)
|
|
101
|
+
first_of_month = datetime(now.year, now.month, 1, tzinfo=timezone.utc)
|
|
102
|
+
days_in_month = (self.resets_at - first_of_month).days
|
|
103
|
+
return days_in_month * 24
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def window_start(self) -> datetime:
|
|
107
|
+
"""1st of current month at 00:00 UTC."""
|
|
108
|
+
now = datetime.now(timezone.utc)
|
|
109
|
+
return datetime(now.year, now.month, 1, tzinfo=timezone.utc)
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def utilization_percent(self) -> float:
|
|
113
|
+
"""Get utilization as a percentage (0-100)."""
|
|
114
|
+
return self.utilization * 100
|
|
115
|
+
|
|
116
|
+
|
|
63
117
|
@dataclass
|
|
64
118
|
class UsageSnapshot:
|
|
65
119
|
"""A point-in-time snapshot of all usage limits."""
|
|
@@ -69,14 +123,16 @@ class UsageSnapshot:
|
|
|
69
123
|
weekly: LimitData | None # seven_day from API
|
|
70
124
|
weekly_sonnet: LimitData | None # seven_day_sonnet from API
|
|
71
125
|
weekly_opus: LimitData | None # seven_day_opus from API (tracked but not displayed)
|
|
126
|
+
monthly: MonthlyLimitData | None = None # extra_usage from API (enterprise)
|
|
72
127
|
raw_response: str | None = None # Original JSON for debugging
|
|
73
128
|
|
|
74
|
-
def get_limit(self, limit_type: LimitType) -> LimitData | None:
|
|
129
|
+
def get_limit(self, limit_type: LimitType) -> LimitData | MonthlyLimitData | None:
|
|
75
130
|
"""Get limit data by type."""
|
|
76
131
|
return {
|
|
77
132
|
LimitType.SESSION: self.session,
|
|
78
133
|
LimitType.WEEKLY: self.weekly,
|
|
79
134
|
LimitType.WEEKLY_SONNET: self.weekly_sonnet,
|
|
135
|
+
LimitType.MONTHLY: self.monthly,
|
|
80
136
|
}.get(limit_type)
|
|
81
137
|
|
|
82
138
|
@classmethod
|
|
@@ -125,12 +181,39 @@ class UsageSnapshot:
|
|
|
125
181
|
except (ValueError, TypeError):
|
|
126
182
|
pass
|
|
127
183
|
|
|
184
|
+
# Parse monthly credits from extra_usage (enterprise accounts)
|
|
185
|
+
monthly = None
|
|
186
|
+
extra_usage = data.get("extra_usage")
|
|
187
|
+
if extra_usage and isinstance(extra_usage, dict):
|
|
188
|
+
if extra_usage.get("is_enabled") and extra_usage.get("monthly_limit"):
|
|
189
|
+
try:
|
|
190
|
+
monthly_limit = int(extra_usage["monthly_limit"])
|
|
191
|
+
used_credits = float(extra_usage.get("used_credits", 0))
|
|
192
|
+
utilization_pct = float(extra_usage.get("utilization", 0))
|
|
193
|
+
|
|
194
|
+
# Calculate resets_at as 1st of next month UTC
|
|
195
|
+
now = datetime.now(timezone.utc)
|
|
196
|
+
if now.month == 12:
|
|
197
|
+
resets_at = datetime(now.year + 1, 1, 1, tzinfo=timezone.utc)
|
|
198
|
+
else:
|
|
199
|
+
resets_at = datetime(now.year, now.month + 1, 1, tzinfo=timezone.utc)
|
|
200
|
+
|
|
201
|
+
monthly = MonthlyLimitData(
|
|
202
|
+
monthly_limit_cents=monthly_limit,
|
|
203
|
+
used_credits_cents=used_credits,
|
|
204
|
+
utilization=utilization_pct / 100.0, # Normalize to 0-1
|
|
205
|
+
resets_at=resets_at,
|
|
206
|
+
)
|
|
207
|
+
except (ValueError, TypeError, KeyError):
|
|
208
|
+
pass
|
|
209
|
+
|
|
128
210
|
return cls(
|
|
129
211
|
timestamp=timestamp,
|
|
130
212
|
session=parse_limit(data.get("five_hour"), LimitType.SESSION),
|
|
131
213
|
weekly=parse_limit(data.get("seven_day"), LimitType.WEEKLY),
|
|
132
214
|
weekly_sonnet=parse_limit(data.get("seven_day_sonnet"), LimitType.WEEKLY_SONNET),
|
|
133
215
|
weekly_opus=weekly_opus,
|
|
216
|
+
monthly=monthly,
|
|
134
217
|
raw_response=json.dumps(data),
|
|
135
218
|
)
|
|
136
219
|
|
|
@@ -9,10 +9,10 @@ from rich.console import Console, ConsoleOptions, Group, RenderableType
|
|
|
9
9
|
from rich.jupyter import JupyterMixin
|
|
10
10
|
|
|
11
11
|
try:
|
|
12
|
-
from ..data.models import BurnMetrics, LimitData, UsageSnapshot
|
|
12
|
+
from ..data.models import BurnMetrics, LimitData, MonthlyLimitData, UsageSnapshot
|
|
13
13
|
from ..utils.formatting import get_utilization_color
|
|
14
14
|
except ImportError:
|
|
15
|
-
from ccburn.data.models import BurnMetrics, LimitData, UsageSnapshot
|
|
15
|
+
from ccburn.data.models import BurnMetrics, LimitData, MonthlyLimitData, UsageSnapshot
|
|
16
16
|
from ccburn.utils.formatting import get_utilization_color
|
|
17
17
|
|
|
18
18
|
|
|
@@ -21,7 +21,7 @@ class BurnupChart(JupyterMixin):
|
|
|
21
21
|
|
|
22
22
|
def __init__(
|
|
23
23
|
self,
|
|
24
|
-
limit_data: LimitData | None,
|
|
24
|
+
limit_data: LimitData | MonthlyLimitData | None,
|
|
25
25
|
snapshots: list[UsageSnapshot],
|
|
26
26
|
since_duration: timedelta | None = None,
|
|
27
27
|
explicit_height: int | None = None,
|
|
@@ -253,8 +253,6 @@ class BurnupChart(JupyterMixin):
|
|
|
253
253
|
num_ticks = 5
|
|
254
254
|
tick_positions = []
|
|
255
255
|
tick_labels = []
|
|
256
|
-
# Use date format for windows > 24 hours
|
|
257
|
-
use_date_format = display_hours > 24
|
|
258
256
|
for i in range(num_ticks):
|
|
259
257
|
hours = i * display_hours / (num_ticks - 1)
|
|
260
258
|
tick_positions.append(hours)
|
|
@@ -267,7 +265,10 @@ class BurnupChart(JupyterMixin):
|
|
|
267
265
|
else:
|
|
268
266
|
local_time = local_time.replace(second=0, microsecond=0)
|
|
269
267
|
# Format based on window size
|
|
270
|
-
if
|
|
268
|
+
if display_hours > 168: # > 7 days (monthly)
|
|
269
|
+
# Show month/day for multi-week windows (e.g., "2/8" for Feb 8)
|
|
270
|
+
tick_labels.append(f"{local_time.month}/{local_time.day}")
|
|
271
|
+
elif display_hours > 24:
|
|
271
272
|
# Show day and time for multi-day windows (e.g., "Mon 15h")
|
|
272
273
|
tick_labels.append(local_time.strftime("%a %Hh"))
|
|
273
274
|
else:
|
|
@@ -291,7 +292,9 @@ class BurnupChart(JupyterMixin):
|
|
|
291
292
|
local_now = local_now.replace(second=0, microsecond=0) + timedelta(minutes=1)
|
|
292
293
|
else:
|
|
293
294
|
local_now = local_now.replace(second=0, microsecond=0)
|
|
294
|
-
if
|
|
295
|
+
if display_hours > 168:
|
|
296
|
+
tick_labels.append(f"{local_now.month}/{local_now.day} {local_now.hour}h")
|
|
297
|
+
elif display_hours > 24:
|
|
295
298
|
tick_labels.append(local_now.strftime("%a %Hh"))
|
|
296
299
|
else:
|
|
297
300
|
tick_labels.append(local_now.strftime("%H:%M"))
|
|
@@ -313,7 +316,9 @@ class BurnupChart(JupyterMixin):
|
|
|
313
316
|
else:
|
|
314
317
|
local_hits_100 = local_hits_100.replace(second=0, microsecond=0)
|
|
315
318
|
tick_positions.append(hits_100_hours_for_tick)
|
|
316
|
-
if
|
|
319
|
+
if display_hours > 168:
|
|
320
|
+
tick_labels.append(f"{local_hits_100.month}/{local_hits_100.day} {local_hits_100.hour}h")
|
|
321
|
+
elif display_hours > 24:
|
|
317
322
|
tick_labels.append(local_hits_100.strftime("%a %Hh"))
|
|
318
323
|
else:
|
|
319
324
|
tick_labels.append(local_hits_100.strftime("%H:%M"))
|
|
@@ -358,7 +363,7 @@ class BurnupChart(JupyterMixin):
|
|
|
358
363
|
|
|
359
364
|
|
|
360
365
|
def create_simple_chart(
|
|
361
|
-
limit_data: LimitData | None,
|
|
366
|
+
limit_data: LimitData | MonthlyLimitData | None,
|
|
362
367
|
snapshots: list[UsageSnapshot],
|
|
363
368
|
width: int = 80,
|
|
364
369
|
height: int = 15,
|
|
@@ -9,11 +9,11 @@ from rich.table import Table
|
|
|
9
9
|
from rich.text import Text
|
|
10
10
|
|
|
11
11
|
try:
|
|
12
|
-
from ..data.models import LimitData, LimitType
|
|
12
|
+
from ..data.models import LimitData, LimitType, MonthlyLimitData
|
|
13
13
|
from ..utils.calculator import calculate_budget_pace
|
|
14
14
|
from ..utils.formatting import format_reset_time, get_utilization_color
|
|
15
15
|
except ImportError:
|
|
16
|
-
from ccburn.data.models import LimitData, LimitType
|
|
16
|
+
from ccburn.data.models import LimitData, LimitType, MonthlyLimitData
|
|
17
17
|
from ccburn.utils.calculator import calculate_budget_pace
|
|
18
18
|
from ccburn.utils.formatting import format_reset_time, get_utilization_color
|
|
19
19
|
|
|
@@ -64,6 +64,23 @@ def _supports_emoji() -> bool:
|
|
|
64
64
|
return _emoji_support_cache
|
|
65
65
|
|
|
66
66
|
|
|
67
|
+
def format_credits(dollars: float) -> str:
|
|
68
|
+
"""Format dollar amount for display.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
dollars: Amount in dollars
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Formatted string like "$74.75" or "$1,234"
|
|
75
|
+
"""
|
|
76
|
+
if dollars >= 1000:
|
|
77
|
+
return f"${dollars:,.0f}"
|
|
78
|
+
elif dollars >= 100:
|
|
79
|
+
return f"${dollars:.0f}"
|
|
80
|
+
else:
|
|
81
|
+
return f"${dollars:.2f}"
|
|
82
|
+
|
|
83
|
+
|
|
67
84
|
def get_pace_emoji(utilization: float, budget_pace: float, ascii_fallback: bool = False) -> str:
|
|
68
85
|
"""Get emoji indicator based on utilization vs budget pace.
|
|
69
86
|
|
|
@@ -90,7 +107,9 @@ def get_pace_emoji(utilization: float, budget_pace: float, ascii_fallback: bool
|
|
|
90
107
|
return "[=]" if use_ascii else "🔥" # On pace - normal burn
|
|
91
108
|
|
|
92
109
|
|
|
93
|
-
def create_header(
|
|
110
|
+
def create_header(
|
|
111
|
+
limit_type: LimitType, limit_data: LimitData | MonthlyLimitData | None
|
|
112
|
+
) -> Table:
|
|
94
113
|
"""Create the header line with limit name and reset countdown.
|
|
95
114
|
|
|
96
115
|
Args:
|
|
@@ -129,13 +148,13 @@ def create_header(limit_type: LimitType, limit_data: LimitData | None) -> Table:
|
|
|
129
148
|
|
|
130
149
|
|
|
131
150
|
def create_gauge_section(
|
|
132
|
-
limit_data: LimitData | None,
|
|
151
|
+
limit_data: LimitData | MonthlyLimitData | None,
|
|
133
152
|
budget_pace: float,
|
|
134
153
|
) -> Table:
|
|
135
154
|
"""Create the 2-bar gauge section for a limit.
|
|
136
155
|
|
|
137
156
|
Args:
|
|
138
|
-
limit_data: Current limit data
|
|
157
|
+
limit_data: Current limit data (LimitData or MonthlyLimitData)
|
|
139
158
|
budget_pace: Percentage of window elapsed (0-1)
|
|
140
159
|
|
|
141
160
|
Returns:
|
|
@@ -144,18 +163,18 @@ def create_gauge_section(
|
|
|
144
163
|
table = Table.grid(padding=(0, 1), expand=True)
|
|
145
164
|
table.add_column(width=14) # Label
|
|
146
165
|
table.add_column(ratio=1) # Bar
|
|
147
|
-
table.add_column(width=
|
|
166
|
+
table.add_column(width=18, justify="right") # Value (wider for dollar amounts)
|
|
148
167
|
|
|
149
168
|
if limit_data is None:
|
|
150
169
|
# Show empty/loading state
|
|
151
170
|
table.add_row(
|
|
152
171
|
Text("📊 Usage", style="dim"),
|
|
153
|
-
ProgressBar(total=100, completed=0, style=Style(color="
|
|
172
|
+
ProgressBar(total=100, completed=0, style=Style(color="grey37")),
|
|
154
173
|
Text("--%", style="dim"),
|
|
155
174
|
)
|
|
156
175
|
table.add_row(
|
|
157
176
|
Text("⏳ Elapsed", style="dim"),
|
|
158
|
-
ProgressBar(total=100, completed=0, style=Style(color="
|
|
177
|
+
ProgressBar(total=100, completed=0, style=Style(color="grey37")),
|
|
159
178
|
Text("--%", style="dim"),
|
|
160
179
|
)
|
|
161
180
|
return table
|
|
@@ -178,10 +197,18 @@ def create_gauge_section(
|
|
|
178
197
|
usage_label.append("📊 ", style="")
|
|
179
198
|
usage_label.append("Usage", style=f"bold {usage_color}")
|
|
180
199
|
|
|
200
|
+
# Format value text - dollars for monthly, percentage for others
|
|
201
|
+
if isinstance(limit_data, MonthlyLimitData):
|
|
202
|
+
used = format_credits(limit_data.used_credits_dollars)
|
|
203
|
+
total = format_credits(limit_data.monthly_limit_dollars)
|
|
204
|
+
value_text = Text(f"{used} / {total}", style=usage_color)
|
|
205
|
+
else:
|
|
206
|
+
value_text = Text(f"{utilization_percent:.0f}%", style=usage_color)
|
|
207
|
+
|
|
181
208
|
table.add_row(
|
|
182
209
|
usage_label,
|
|
183
210
|
usage_bar,
|
|
184
|
-
|
|
211
|
+
value_text,
|
|
185
212
|
)
|
|
186
213
|
|
|
187
214
|
# Time Elapsed bar - always blue
|
|
@@ -210,17 +237,19 @@ def create_compact_output(
|
|
|
210
237
|
session: LimitData | None,
|
|
211
238
|
weekly: LimitData | None,
|
|
212
239
|
weekly_sonnet: LimitData | None,
|
|
240
|
+
monthly: MonthlyLimitData | None,
|
|
213
241
|
budget_pace_session: float,
|
|
214
242
|
) -> str:
|
|
215
243
|
"""Create compact single-line output for status bars.
|
|
216
244
|
|
|
217
|
-
Format: Session: 🧊 62% (2h14m) | Weekly: 🔥 29% | Sonnet: 🧊 1%
|
|
245
|
+
Format: Session: 🧊 62% (2h14m) | Weekly: 🔥 29% | Sonnet: 🧊 1% | Monthly: 🧊 $74.75
|
|
218
246
|
Emojis indicate pace: 🧊 (behind), 🔥 (on pace), 🚨 (ahead)
|
|
219
247
|
|
|
220
248
|
Args:
|
|
221
249
|
session: Session limit data
|
|
222
250
|
weekly: Weekly limit data
|
|
223
251
|
weekly_sonnet: Weekly sonnet limit data
|
|
252
|
+
monthly: Monthly credits data
|
|
224
253
|
budget_pace_session: Budget pace for session (for status indicator)
|
|
225
254
|
|
|
226
255
|
Returns:
|
|
@@ -240,24 +269,29 @@ def create_compact_output(
|
|
|
240
269
|
pace = calculate_budget_pace(session.resets_at, session.window_hours)
|
|
241
270
|
emoji = get_pace_emoji(session.utilization, pace)
|
|
242
271
|
parts.append(f"Session: {emoji} {session.utilization*100:.0f}% {time_str}".strip())
|
|
243
|
-
else:
|
|
244
|
-
parts.append("Session: --")
|
|
245
272
|
|
|
246
273
|
# Weekly
|
|
247
274
|
if weekly:
|
|
248
275
|
pace = calculate_budget_pace(weekly.resets_at, weekly.window_hours)
|
|
249
276
|
emoji = get_pace_emoji(weekly.utilization, pace)
|
|
250
277
|
parts.append(f"Weekly: {emoji} {weekly.utilization*100:.0f}%")
|
|
251
|
-
else:
|
|
252
|
-
parts.append("Weekly: --")
|
|
253
278
|
|
|
254
279
|
# Sonnet
|
|
255
280
|
if weekly_sonnet:
|
|
256
281
|
pace = calculate_budget_pace(weekly_sonnet.resets_at, weekly_sonnet.window_hours)
|
|
257
282
|
emoji = get_pace_emoji(weekly_sonnet.utilization, pace)
|
|
258
283
|
parts.append(f"Sonnet: {emoji} {weekly_sonnet.utilization*100:.0f}%")
|
|
259
|
-
|
|
260
|
-
|
|
284
|
+
|
|
285
|
+
# Monthly credits
|
|
286
|
+
if monthly:
|
|
287
|
+
pace = calculate_budget_pace(monthly.resets_at, monthly.window_hours)
|
|
288
|
+
emoji = get_pace_emoji(monthly.utilization, pace)
|
|
289
|
+
dollars = format_credits(monthly.used_credits_dollars)
|
|
290
|
+
parts.append(f"Monthly: {emoji} {dollars}")
|
|
291
|
+
|
|
292
|
+
# If nothing available, show placeholder
|
|
293
|
+
if not parts:
|
|
294
|
+
parts.append("No data available")
|
|
261
295
|
|
|
262
296
|
return " | ".join(parts)
|
|
263
297
|
|
|
@@ -8,12 +8,18 @@ from rich.layout import Layout
|
|
|
8
8
|
from rich.text import Text
|
|
9
9
|
|
|
10
10
|
try:
|
|
11
|
-
from ..data.models import BurnMetrics, LimitData, LimitType, UsageSnapshot
|
|
11
|
+
from ..data.models import BurnMetrics, LimitData, LimitType, MonthlyLimitData, UsageSnapshot
|
|
12
12
|
from ..utils.calculator import calculate_budget_pace, calculate_burn_metrics
|
|
13
13
|
from .chart import BurnupChart
|
|
14
14
|
from .gauges import create_gauge_section, create_header
|
|
15
15
|
except ImportError:
|
|
16
|
-
from ccburn.data.models import
|
|
16
|
+
from ccburn.data.models import (
|
|
17
|
+
BurnMetrics,
|
|
18
|
+
LimitData,
|
|
19
|
+
LimitType,
|
|
20
|
+
MonthlyLimitData,
|
|
21
|
+
UsageSnapshot,
|
|
22
|
+
)
|
|
17
23
|
from ccburn.display.chart import BurnupChart
|
|
18
24
|
from ccburn.display.gauges import create_gauge_section, create_header
|
|
19
25
|
from ccburn.utils.calculator import calculate_budget_pace, calculate_burn_metrics
|
|
@@ -24,7 +30,7 @@ class BurnupLayout:
|
|
|
24
30
|
|
|
25
31
|
MIN_WIDTH = 40
|
|
26
32
|
MIN_HEIGHT = 10
|
|
27
|
-
COMPACT_WIDTH =
|
|
33
|
+
COMPACT_WIDTH = 40
|
|
28
34
|
COMPACT_HEIGHT = 15
|
|
29
35
|
|
|
30
36
|
def __init__(self, console: Console | None = None):
|
|
@@ -34,7 +40,7 @@ class BurnupLayout:
|
|
|
34
40
|
console: Rich Console instance (creates one if not provided)
|
|
35
41
|
"""
|
|
36
42
|
self.console = console or Console()
|
|
37
|
-
self._last_limit_data: LimitData | None = None
|
|
43
|
+
self._last_limit_data: LimitData | MonthlyLimitData | None = None
|
|
38
44
|
self._last_snapshots: list[UsageSnapshot] = []
|
|
39
45
|
self._last_metrics: BurnMetrics | None = None
|
|
40
46
|
self._error_message: str | None = None
|
|
@@ -64,7 +70,7 @@ class BurnupLayout:
|
|
|
64
70
|
def update(
|
|
65
71
|
self,
|
|
66
72
|
limit_type: LimitType,
|
|
67
|
-
limit_data: LimitData | None,
|
|
73
|
+
limit_data: LimitData | MonthlyLimitData | None,
|
|
68
74
|
snapshots: list[UsageSnapshot],
|
|
69
75
|
error: str | None = None,
|
|
70
76
|
stale_since: datetime | None = None,
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
import typer
|
|
5
|
+
from rich.console import Console
|
|
5
6
|
|
|
6
7
|
try:
|
|
7
8
|
from .cli import (
|
|
@@ -15,6 +16,7 @@ try:
|
|
|
15
16
|
run_app,
|
|
16
17
|
)
|
|
17
18
|
from .data.models import LimitType
|
|
19
|
+
from .data.usage_client import UsageClient
|
|
18
20
|
except ImportError:
|
|
19
21
|
from ccburn.cli import (
|
|
20
22
|
CompactOption,
|
|
@@ -27,6 +29,31 @@ except ImportError:
|
|
|
27
29
|
run_app,
|
|
28
30
|
)
|
|
29
31
|
from ccburn.data.models import LimitType
|
|
32
|
+
from ccburn.data.usage_client import UsageClient
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def auto_detect_limit_type() -> LimitType | None:
|
|
36
|
+
"""Auto-detect which limit type to use based on available data.
|
|
37
|
+
|
|
38
|
+
Priority: session -> monthly -> weekly
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Best available LimitType, or None if no data available.
|
|
42
|
+
"""
|
|
43
|
+
try:
|
|
44
|
+
client = UsageClient()
|
|
45
|
+
snapshot = client.fetch_usage()
|
|
46
|
+
|
|
47
|
+
if snapshot.session is not None:
|
|
48
|
+
return LimitType.SESSION
|
|
49
|
+
if snapshot.monthly is not None:
|
|
50
|
+
return LimitType.MONTHLY
|
|
51
|
+
if snapshot.weekly is not None:
|
|
52
|
+
return LimitType.WEEKLY
|
|
53
|
+
|
|
54
|
+
return None
|
|
55
|
+
except Exception:
|
|
56
|
+
return None
|
|
30
57
|
|
|
31
58
|
|
|
32
59
|
app = typer.Typer(
|
|
@@ -60,14 +87,14 @@ def main(
|
|
|
60
87
|
"""ccburn - Claude Code usage limit visualizer.
|
|
61
88
|
|
|
62
89
|
Visualize your Claude Code usage limits with real-time burn-up charts.
|
|
63
|
-
|
|
90
|
+
Auto-detects available data (session or monthly credits).
|
|
64
91
|
|
|
65
92
|
\b
|
|
66
93
|
Examples:
|
|
67
|
-
ccburn #
|
|
68
|
-
ccburn session #
|
|
69
|
-
ccburn weekly #
|
|
70
|
-
ccburn
|
|
94
|
+
ccburn # Auto-detect best available data
|
|
95
|
+
ccburn session # Explicit: 5-hour rolling session limit
|
|
96
|
+
ccburn weekly # Explicit: 7-day weekly limit
|
|
97
|
+
ccburn monthly # Explicit: Monthly credits (enterprise)
|
|
71
98
|
ccburn --json # JSON output
|
|
72
99
|
ccburn --compact # Single-line for status bars
|
|
73
100
|
ccburn --once # Print once and exit
|
|
@@ -81,10 +108,20 @@ def main(
|
|
|
81
108
|
typer.echo("ccburn 1.0.0")
|
|
82
109
|
raise typer.Exit()
|
|
83
110
|
|
|
84
|
-
# If no subcommand,
|
|
111
|
+
# If no subcommand, auto-detect best available limit type
|
|
85
112
|
if ctx.invoked_subcommand is None:
|
|
113
|
+
console = Console()
|
|
114
|
+
|
|
115
|
+
# Auto-detect which data is available
|
|
116
|
+
limit_type = auto_detect_limit_type()
|
|
117
|
+
|
|
118
|
+
if limit_type is None:
|
|
119
|
+
console.print("[red]No usage data available.[/red]")
|
|
120
|
+
console.print("\n[dim]Check that Claude Code is running and authenticated.[/dim]")
|
|
121
|
+
raise typer.Exit(1)
|
|
122
|
+
|
|
86
123
|
run_app(
|
|
87
|
-
|
|
124
|
+
limit_type,
|
|
88
125
|
json_output=json_output,
|
|
89
126
|
once=once,
|
|
90
127
|
compact=compact,
|
|
@@ -3,9 +3,15 @@
|
|
|
3
3
|
from datetime import datetime, timedelta, timezone
|
|
4
4
|
|
|
5
5
|
try:
|
|
6
|
-
from ..data.models import BurnMetrics, LimitData, LimitType, UsageSnapshot
|
|
6
|
+
from ..data.models import BurnMetrics, LimitData, LimitType, MonthlyLimitData, UsageSnapshot
|
|
7
7
|
except ImportError:
|
|
8
|
-
from ccburn.data.models import
|
|
8
|
+
from ccburn.data.models import (
|
|
9
|
+
BurnMetrics,
|
|
10
|
+
LimitData,
|
|
11
|
+
LimitType,
|
|
12
|
+
MonthlyLimitData,
|
|
13
|
+
UsageSnapshot,
|
|
14
|
+
)
|
|
9
15
|
|
|
10
16
|
|
|
11
17
|
def calculate_budget_pace(resets_at: datetime, window_hours: float) -> float:
|
|
@@ -93,9 +99,10 @@ def calculate_burn_rate(
|
|
|
93
99
|
# Check that data spans at least min_span_pct of the window
|
|
94
100
|
# e.g., for 5h session at 10%, need 30 min of data
|
|
95
101
|
# e.g., for 168h weekly at 10%, need ~17 hours of data
|
|
102
|
+
# Cap at 6 hours max so monthly (744h) doesn't require 3+ days of data
|
|
96
103
|
if first_timestamp and last_timestamp:
|
|
97
104
|
span_hours = (last_timestamp - first_timestamp).total_seconds() / 3600
|
|
98
|
-
min_span_hours = window_hours * min_span_pct
|
|
105
|
+
min_span_hours = min(window_hours * min_span_pct, 6.0)
|
|
99
106
|
if span_hours < min_span_hours:
|
|
100
107
|
return 0.0
|
|
101
108
|
|
|
@@ -208,7 +215,7 @@ def get_status(utilization: float, budget_pace: float) -> str:
|
|
|
208
215
|
|
|
209
216
|
|
|
210
217
|
def calculate_burn_metrics(
|
|
211
|
-
limit_data: LimitData,
|
|
218
|
+
limit_data: LimitData | MonthlyLimitData,
|
|
212
219
|
snapshots: list[UsageSnapshot],
|
|
213
220
|
) -> BurnMetrics:
|
|
214
221
|
"""Calculate all burn metrics for a limit.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|