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 ADDED
@@ -0,0 +1,8 @@
1
+ # ccburn - Terminal-based Claude Code usage limit visualizer
2
+
3
+ try:
4
+ from importlib.metadata import version
5
+
6
+ __version__ = version("ccburn")
7
+ except Exception:
8
+ __version__ = "0.0.0"
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()