ccburn 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.
- ccburn/__init__.py +8 -0
- ccburn/app.py +465 -0
- ccburn/cli.py +285 -0
- ccburn/data/__init__.py +24 -0
- ccburn/data/credentials.py +135 -0
- ccburn/data/history.py +397 -0
- ccburn/data/models.py +148 -0
- ccburn/data/usage_client.py +141 -0
- ccburn/display/__init__.py +17 -0
- ccburn/display/chart.py +300 -0
- ccburn/display/gauges.py +275 -0
- ccburn/display/layout.py +246 -0
- ccburn/main.py +98 -0
- ccburn/utils/__init__.py +45 -0
- ccburn/utils/calculator.py +207 -0
- ccburn/utils/formatting.py +127 -0
- ccburn-0.1.0.dist-info/METADATA +197 -0
- ccburn-0.1.0.dist-info/RECORD +22 -0
- ccburn-0.1.0.dist-info/WHEEL +5 -0
- ccburn-0.1.0.dist-info/entry_points.txt +2 -0
- ccburn-0.1.0.dist-info/licenses/LICENSE +21 -0
- ccburn-0.1.0.dist-info/top_level.txt +1 -0
ccburn/__init__.py
ADDED
ccburn/app.py
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
"""Main application class for ccburn."""
|
|
2
|
+
|
|
3
|
+
import signal
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.live import Live
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from .data.credentials import CredentialsNotFoundError, TokenExpiredError
|
|
14
|
+
from .data.history import HistoryDB
|
|
15
|
+
from .data.models import LimitType, UsageSnapshot
|
|
16
|
+
from .data.usage_client import APIError, NetworkError, UsageClient
|
|
17
|
+
from .display.gauges import create_compact_output, get_pace_emoji
|
|
18
|
+
from .display.layout import BurnupLayout
|
|
19
|
+
from .utils.calculator import calculate_budget_pace, calculate_burn_metrics
|
|
20
|
+
except ImportError:
|
|
21
|
+
from ccburn.data.credentials import CredentialsNotFoundError, TokenExpiredError
|
|
22
|
+
from ccburn.data.history import HistoryDB
|
|
23
|
+
from ccburn.data.models import LimitType, UsageSnapshot
|
|
24
|
+
from ccburn.data.usage_client import APIError, NetworkError, UsageClient
|
|
25
|
+
from ccburn.display.gauges import create_compact_output, get_pace_emoji
|
|
26
|
+
from ccburn.display.layout import BurnupLayout
|
|
27
|
+
from ccburn.utils.calculator import calculate_budget_pace, calculate_burn_metrics
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CCBurnApp:
|
|
31
|
+
"""Main application class for ccburn."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
limit_type: LimitType = LimitType.SESSION,
|
|
36
|
+
interval: int = 5,
|
|
37
|
+
since: datetime | None = None,
|
|
38
|
+
json_output: bool = False,
|
|
39
|
+
once: bool = False,
|
|
40
|
+
compact: bool = False,
|
|
41
|
+
debug: bool = False,
|
|
42
|
+
):
|
|
43
|
+
"""Initialize the application.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
limit_type: Which limit to display
|
|
47
|
+
interval: Refresh interval in seconds
|
|
48
|
+
since: Only show data since this time (zoom view)
|
|
49
|
+
json_output: Output JSON instead of TUI
|
|
50
|
+
once: Print once and exit
|
|
51
|
+
compact: Single-line output for status bars
|
|
52
|
+
debug: Show debug information
|
|
53
|
+
"""
|
|
54
|
+
self.limit_type = limit_type
|
|
55
|
+
self.interval = interval
|
|
56
|
+
self.since = since
|
|
57
|
+
self.json_output = json_output
|
|
58
|
+
self.once = once
|
|
59
|
+
self.compact = compact
|
|
60
|
+
self.debug = debug
|
|
61
|
+
|
|
62
|
+
self.console = Console()
|
|
63
|
+
self.client = UsageClient()
|
|
64
|
+
self.history: HistoryDB | None = None
|
|
65
|
+
self.layout = BurnupLayout(self.console)
|
|
66
|
+
|
|
67
|
+
# State
|
|
68
|
+
self.running = threading.Event()
|
|
69
|
+
self.last_snapshot: UsageSnapshot | None = None
|
|
70
|
+
self.last_fetch_time: datetime | None = None
|
|
71
|
+
self.last_error: str | None = None
|
|
72
|
+
self.snapshots: list[UsageSnapshot] = []
|
|
73
|
+
|
|
74
|
+
def _setup_signal_handlers(self) -> None:
|
|
75
|
+
"""Setup signal handlers for graceful shutdown."""
|
|
76
|
+
|
|
77
|
+
def signal_handler(signum: int, frame: Any) -> None:
|
|
78
|
+
self.stop()
|
|
79
|
+
|
|
80
|
+
signal.signal(signal.SIGINT, signal_handler)
|
|
81
|
+
if hasattr(signal, "SIGTERM"):
|
|
82
|
+
signal.signal(signal.SIGTERM, signal_handler)
|
|
83
|
+
|
|
84
|
+
def _initialize(self) -> bool:
|
|
85
|
+
"""Initialize resources.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
True if initialization succeeded
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
# Initialize history database
|
|
92
|
+
try:
|
|
93
|
+
self.history = HistoryDB()
|
|
94
|
+
# Prune old data on startup
|
|
95
|
+
self.history.prune_old_data()
|
|
96
|
+
# Load existing snapshots
|
|
97
|
+
self.snapshots = self.history.get_snapshots_for_limit(
|
|
98
|
+
self.limit_type,
|
|
99
|
+
since=self.since,
|
|
100
|
+
)
|
|
101
|
+
except Exception as e:
|
|
102
|
+
# Fall back to in-memory if SQLite fails
|
|
103
|
+
self.console.print(
|
|
104
|
+
f"[yellow]Warning: Using in-memory storage ({e})[/yellow]"
|
|
105
|
+
)
|
|
106
|
+
self.history = HistoryDB(in_memory=True)
|
|
107
|
+
self.snapshots = []
|
|
108
|
+
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
except Exception as e:
|
|
112
|
+
self.console.print(f"[red]Initialization failed: {e}[/red]")
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
def _fetch_and_update(self) -> bool:
|
|
116
|
+
"""Fetch new data and update state.
|
|
117
|
+
|
|
118
|
+
Uses shared database as cache - if another instance recently fetched,
|
|
119
|
+
use that data instead of hitting the API again.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
True if fetch succeeded
|
|
123
|
+
"""
|
|
124
|
+
try:
|
|
125
|
+
snapshot = None
|
|
126
|
+
|
|
127
|
+
# Check if fresh data exists in database (from another instance)
|
|
128
|
+
if self.history:
|
|
129
|
+
age = self.history.get_latest_snapshot_age_seconds()
|
|
130
|
+
# If data is fresh (less than half our interval), use cached data
|
|
131
|
+
if age is not None and age < (self.interval / 2):
|
|
132
|
+
snapshot = self.history.get_latest_snapshot()
|
|
133
|
+
if snapshot:
|
|
134
|
+
self.last_snapshot = snapshot
|
|
135
|
+
self.last_fetch_time = snapshot.timestamp
|
|
136
|
+
self.last_error = None
|
|
137
|
+
# Reload snapshots from database
|
|
138
|
+
self.snapshots = self.history.get_snapshots_for_limit(
|
|
139
|
+
self.limit_type,
|
|
140
|
+
since=self.since,
|
|
141
|
+
)
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
# No fresh cached data, fetch from API
|
|
145
|
+
snapshot = self.client.fetch_usage()
|
|
146
|
+
self.last_snapshot = snapshot
|
|
147
|
+
self.last_fetch_time = datetime.now(timezone.utc)
|
|
148
|
+
self.last_error = None
|
|
149
|
+
|
|
150
|
+
# Save to history
|
|
151
|
+
if self.history:
|
|
152
|
+
self.history.save_snapshot(snapshot)
|
|
153
|
+
|
|
154
|
+
# Add to local list
|
|
155
|
+
self.snapshots.append(snapshot)
|
|
156
|
+
|
|
157
|
+
# Keep only relevant snapshots (based on since or window)
|
|
158
|
+
if self.since:
|
|
159
|
+
self.snapshots = [s for s in self.snapshots if s.timestamp >= self.since]
|
|
160
|
+
else:
|
|
161
|
+
# Keep last 24 hours of data for calculations
|
|
162
|
+
cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
|
|
163
|
+
self.snapshots = [s for s in self.snapshots if s.timestamp >= cutoff]
|
|
164
|
+
|
|
165
|
+
return True
|
|
166
|
+
|
|
167
|
+
except CredentialsNotFoundError as e:
|
|
168
|
+
self.console.print(f"[red]{e}[/red]")
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
except TokenExpiredError as e:
|
|
172
|
+
self.console.print(f"[red]{e}[/red]")
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
except (APIError, NetworkError) as e:
|
|
176
|
+
self.last_error = str(e)
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
except Exception as e:
|
|
180
|
+
self.last_error = f"Unexpected error: {e}"
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
def _should_refresh(self) -> bool:
|
|
184
|
+
"""Check if we should fetch new data.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
True if it's time to refresh
|
|
188
|
+
"""
|
|
189
|
+
if self.last_fetch_time is None:
|
|
190
|
+
return True
|
|
191
|
+
|
|
192
|
+
elapsed = (datetime.now(timezone.utc) - self.last_fetch_time).total_seconds()
|
|
193
|
+
return elapsed >= self.interval
|
|
194
|
+
|
|
195
|
+
def run(self) -> int:
|
|
196
|
+
"""Run the application.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Exit code (0 for success, 1 for error)
|
|
200
|
+
"""
|
|
201
|
+
# Show loading message for TUI mode
|
|
202
|
+
if not self.json_output and not self.compact and not self.once:
|
|
203
|
+
self.console.print("[dim]🔥 Loading ccburn...[/dim]", end="\r")
|
|
204
|
+
|
|
205
|
+
if not self._initialize():
|
|
206
|
+
return 1
|
|
207
|
+
|
|
208
|
+
# Initial fetch
|
|
209
|
+
if not self._fetch_and_update():
|
|
210
|
+
if self.last_snapshot is None:
|
|
211
|
+
# No cached data available - error should have been printed in _fetch_and_update
|
|
212
|
+
self.console.print("[red]Failed to fetch usage data. Check your credentials.[/red]")
|
|
213
|
+
return 1
|
|
214
|
+
|
|
215
|
+
# Handle different output modes
|
|
216
|
+
if self.json_output:
|
|
217
|
+
return self._run_json()
|
|
218
|
+
elif self.compact:
|
|
219
|
+
return self._run_compact()
|
|
220
|
+
elif self.once:
|
|
221
|
+
return self._run_once()
|
|
222
|
+
else:
|
|
223
|
+
return self._run_tui()
|
|
224
|
+
|
|
225
|
+
def _run_json(self) -> int:
|
|
226
|
+
"""Run in JSON output mode.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Exit code
|
|
230
|
+
"""
|
|
231
|
+
output = self._create_json_output()
|
|
232
|
+
self.console.print_json(data=output)
|
|
233
|
+
return 0
|
|
234
|
+
|
|
235
|
+
def _run_compact(self) -> int:
|
|
236
|
+
"""Run in compact single-line mode.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
Exit code
|
|
240
|
+
"""
|
|
241
|
+
if self.last_snapshot is None:
|
|
242
|
+
self.console.print("No data available")
|
|
243
|
+
return 1
|
|
244
|
+
|
|
245
|
+
budget_pace = 0.0
|
|
246
|
+
if self.last_snapshot.session:
|
|
247
|
+
budget_pace = calculate_budget_pace(
|
|
248
|
+
self.last_snapshot.session.resets_at,
|
|
249
|
+
self.last_snapshot.session.window_hours,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
output = create_compact_output(
|
|
253
|
+
self.last_snapshot.session,
|
|
254
|
+
self.last_snapshot.weekly,
|
|
255
|
+
self.last_snapshot.weekly_sonnet,
|
|
256
|
+
budget_pace,
|
|
257
|
+
)
|
|
258
|
+
self.console.print(output)
|
|
259
|
+
return 0
|
|
260
|
+
|
|
261
|
+
def _run_once(self) -> int:
|
|
262
|
+
"""Run once and exit (no live updates).
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
Exit code
|
|
266
|
+
"""
|
|
267
|
+
if self.last_snapshot is None:
|
|
268
|
+
self.console.print("[red]No data available[/red]")
|
|
269
|
+
return 1
|
|
270
|
+
|
|
271
|
+
limit_data = self.last_snapshot.get_limit(self.limit_type)
|
|
272
|
+
|
|
273
|
+
# Create and print the layout
|
|
274
|
+
layout = self.layout.update(
|
|
275
|
+
self.limit_type,
|
|
276
|
+
limit_data,
|
|
277
|
+
self.snapshots,
|
|
278
|
+
error=self.last_error,
|
|
279
|
+
since=self.since,
|
|
280
|
+
)
|
|
281
|
+
self.console.print(layout)
|
|
282
|
+
|
|
283
|
+
# Show debug info if requested
|
|
284
|
+
if self.debug and self.client.get_last_response():
|
|
285
|
+
self.console.print("\n[dim]Raw API Response:[/dim]")
|
|
286
|
+
self.console.print_json(data=self.client.get_last_response())
|
|
287
|
+
|
|
288
|
+
return 0
|
|
289
|
+
|
|
290
|
+
def _run_tui(self) -> int:
|
|
291
|
+
"""Run the live TUI.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
Exit code
|
|
295
|
+
"""
|
|
296
|
+
self._setup_signal_handlers()
|
|
297
|
+
self.running.set()
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
# Create initial display
|
|
301
|
+
limit_data = None
|
|
302
|
+
if self.last_snapshot:
|
|
303
|
+
limit_data = self.last_snapshot.get_limit(self.limit_type)
|
|
304
|
+
|
|
305
|
+
initial_layout = self.layout.update(
|
|
306
|
+
self.limit_type,
|
|
307
|
+
limit_data,
|
|
308
|
+
self.snapshots,
|
|
309
|
+
error=self.last_error,
|
|
310
|
+
since=self.since,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
# Set initial window title
|
|
314
|
+
self._update_window_title()
|
|
315
|
+
|
|
316
|
+
with Live(
|
|
317
|
+
initial_layout,
|
|
318
|
+
console=self.console,
|
|
319
|
+
refresh_per_second=1,
|
|
320
|
+
transient=False,
|
|
321
|
+
screen=True,
|
|
322
|
+
vertical_overflow="visible",
|
|
323
|
+
) as live:
|
|
324
|
+
self._main_loop(live)
|
|
325
|
+
|
|
326
|
+
return 0
|
|
327
|
+
|
|
328
|
+
except KeyboardInterrupt:
|
|
329
|
+
return 0
|
|
330
|
+
|
|
331
|
+
except Exception as e:
|
|
332
|
+
self.console.print(f"[red]Error: {e}[/red]")
|
|
333
|
+
return 1
|
|
334
|
+
|
|
335
|
+
finally:
|
|
336
|
+
self._cleanup()
|
|
337
|
+
|
|
338
|
+
def _main_loop(self, live: Live) -> None:
|
|
339
|
+
"""Main TUI loop.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
live: Rich Live instance
|
|
343
|
+
"""
|
|
344
|
+
last_update = 0.0
|
|
345
|
+
|
|
346
|
+
while self.running.is_set():
|
|
347
|
+
try:
|
|
348
|
+
current_time = time.time()
|
|
349
|
+
|
|
350
|
+
# Check if we should refresh data
|
|
351
|
+
if self._should_refresh():
|
|
352
|
+
self._fetch_and_update()
|
|
353
|
+
|
|
354
|
+
# Update display
|
|
355
|
+
if current_time - last_update >= 1.0: # Update display every second
|
|
356
|
+
limit_data = None
|
|
357
|
+
if self.last_snapshot:
|
|
358
|
+
limit_data = self.last_snapshot.get_limit(self.limit_type)
|
|
359
|
+
|
|
360
|
+
stale_since = None
|
|
361
|
+
if self.last_error and self.last_fetch_time:
|
|
362
|
+
stale_since = self.last_fetch_time
|
|
363
|
+
|
|
364
|
+
updated_layout = self.layout.update(
|
|
365
|
+
self.limit_type,
|
|
366
|
+
limit_data,
|
|
367
|
+
self.snapshots,
|
|
368
|
+
error=self.last_error,
|
|
369
|
+
stale_since=stale_since,
|
|
370
|
+
since=self.since,
|
|
371
|
+
)
|
|
372
|
+
live.update(updated_layout)
|
|
373
|
+
self._update_window_title()
|
|
374
|
+
last_update = current_time
|
|
375
|
+
|
|
376
|
+
# Small sleep to prevent busy waiting
|
|
377
|
+
time.sleep(0.05)
|
|
378
|
+
|
|
379
|
+
except Exception:
|
|
380
|
+
# Log but continue
|
|
381
|
+
time.sleep(0.5)
|
|
382
|
+
|
|
383
|
+
def _create_json_output(self) -> dict:
|
|
384
|
+
"""Create JSON output structure.
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
Dictionary matching the spec JSON format
|
|
388
|
+
"""
|
|
389
|
+
output: dict = {
|
|
390
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
391
|
+
"limits": {},
|
|
392
|
+
"burn_rate": None,
|
|
393
|
+
"recommendation": None,
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if self.last_snapshot is None:
|
|
397
|
+
return output
|
|
398
|
+
|
|
399
|
+
# Add all limits
|
|
400
|
+
for lt in [LimitType.SESSION, LimitType.WEEKLY, LimitType.WEEKLY_SONNET]:
|
|
401
|
+
limit_data = self.last_snapshot.get_limit(lt)
|
|
402
|
+
if limit_data:
|
|
403
|
+
metrics = calculate_burn_metrics(limit_data, self.snapshots)
|
|
404
|
+
minutes_left = int(
|
|
405
|
+
(limit_data.resets_at - datetime.now(timezone.utc)).total_seconds() / 60
|
|
406
|
+
)
|
|
407
|
+
hours_left = minutes_left / 60
|
|
408
|
+
|
|
409
|
+
output["limits"][lt.value] = {
|
|
410
|
+
"utilization": limit_data.utilization,
|
|
411
|
+
"budget_pace": metrics.budget_pace,
|
|
412
|
+
"resets_at": limit_data.resets_at.isoformat(),
|
|
413
|
+
"resets_in_minutes": minutes_left if lt == LimitType.SESSION else None,
|
|
414
|
+
"resets_in_hours": hours_left if lt != LimitType.SESSION else None,
|
|
415
|
+
"window_hours": limit_data.window_hours,
|
|
416
|
+
"status": metrics.status,
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
# Add burn rate for selected limit
|
|
420
|
+
limit_data = self.last_snapshot.get_limit(self.limit_type)
|
|
421
|
+
if limit_data:
|
|
422
|
+
metrics = calculate_burn_metrics(limit_data, self.snapshots)
|
|
423
|
+
output["burn_rate"] = {
|
|
424
|
+
"limit": self.limit_type.value,
|
|
425
|
+
"percent_per_hour": round(metrics.percent_per_hour, 2),
|
|
426
|
+
"trend": metrics.trend,
|
|
427
|
+
"estimated_minutes_to_100": metrics.estimated_minutes_to_100,
|
|
428
|
+
}
|
|
429
|
+
output["recommendation"] = metrics.recommendation
|
|
430
|
+
|
|
431
|
+
return output
|
|
432
|
+
|
|
433
|
+
def stop(self) -> None:
|
|
434
|
+
"""Stop the application."""
|
|
435
|
+
self.running.clear()
|
|
436
|
+
|
|
437
|
+
def _update_window_title(self) -> None:
|
|
438
|
+
"""Update terminal window title with current status."""
|
|
439
|
+
if self.last_snapshot is None:
|
|
440
|
+
self.console.set_window_title("ccburn")
|
|
441
|
+
return
|
|
442
|
+
|
|
443
|
+
limit_data = self.last_snapshot.get_limit(self.limit_type)
|
|
444
|
+
if limit_data is None:
|
|
445
|
+
self.console.set_window_title("ccburn")
|
|
446
|
+
return
|
|
447
|
+
|
|
448
|
+
# Calculate pace and get emoji
|
|
449
|
+
budget_pace = calculate_budget_pace(
|
|
450
|
+
limit_data.resets_at,
|
|
451
|
+
limit_data.window_hours,
|
|
452
|
+
)
|
|
453
|
+
emoji = get_pace_emoji(limit_data.utilization, budget_pace)
|
|
454
|
+
percent = int(limit_data.utilization * 100)
|
|
455
|
+
|
|
456
|
+
# Format: 🔥 45% - ccburn Session
|
|
457
|
+
title = f"{emoji} {percent}% - ccburn {self.limit_type.display_name}"
|
|
458
|
+
self.console.set_window_title(title)
|
|
459
|
+
|
|
460
|
+
def _cleanup(self) -> None:
|
|
461
|
+
"""Clean up resources."""
|
|
462
|
+
# Reset window title
|
|
463
|
+
self.console.set_window_title("")
|
|
464
|
+
if self.history:
|
|
465
|
+
self.history.close()
|