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/cli.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""CLI command definitions for ccburn."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from .data.models import LimitType
|
|
11
|
+
except ImportError:
|
|
12
|
+
from ccburn.data.models import LimitType
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_duration(value: str) -> timedelta:
|
|
16
|
+
"""Parse duration string like '2h', '30m', '1d', '24h'.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
value: Duration string
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
timedelta object
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
typer.BadParameter: If format is invalid
|
|
26
|
+
"""
|
|
27
|
+
match = re.match(r"^(\d+)([mhdw])$", value.lower())
|
|
28
|
+
if not match:
|
|
29
|
+
raise typer.BadParameter(
|
|
30
|
+
f"Invalid duration format: {value}. Use format like '2h', '30m', '1d', '1w'."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
amount = int(match.group(1))
|
|
34
|
+
unit = match.group(2)
|
|
35
|
+
|
|
36
|
+
if unit == "m":
|
|
37
|
+
return timedelta(minutes=amount)
|
|
38
|
+
elif unit == "h":
|
|
39
|
+
return timedelta(hours=amount)
|
|
40
|
+
elif unit == "d":
|
|
41
|
+
return timedelta(days=amount)
|
|
42
|
+
elif unit == "w":
|
|
43
|
+
return timedelta(weeks=amount)
|
|
44
|
+
|
|
45
|
+
raise typer.BadParameter(f"Unknown time unit: {unit}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def duration_callback(value: str | None) -> str | None:
|
|
49
|
+
"""Typer callback for parsing duration strings."""
|
|
50
|
+
if value is None:
|
|
51
|
+
return None
|
|
52
|
+
return parse_duration(value)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Common options used by all subcommands
|
|
56
|
+
JsonOption = typer.Option(
|
|
57
|
+
False,
|
|
58
|
+
"--json",
|
|
59
|
+
"-j",
|
|
60
|
+
help="Output JSON instead of TUI.",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
OnceOption = typer.Option(
|
|
64
|
+
False,
|
|
65
|
+
"--once",
|
|
66
|
+
"-1",
|
|
67
|
+
help="Print once and exit (no live updates).",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
CompactOption = typer.Option(
|
|
71
|
+
False,
|
|
72
|
+
"--compact",
|
|
73
|
+
"-c",
|
|
74
|
+
help="Single-line output for status bars/tmux.",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
SinceOption = typer.Option(
|
|
78
|
+
None,
|
|
79
|
+
"--since",
|
|
80
|
+
"-s",
|
|
81
|
+
help="Time window to display (e.g., '2h', '24h', '7d'). Default: full window.",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
SessionIntervalOption = typer.Option(
|
|
85
|
+
5,
|
|
86
|
+
"--interval",
|
|
87
|
+
"-i",
|
|
88
|
+
help="Refresh interval in seconds (default: 5 for session).",
|
|
89
|
+
min=1,
|
|
90
|
+
max=300,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
WeeklyIntervalOption = typer.Option(
|
|
94
|
+
30,
|
|
95
|
+
"--interval",
|
|
96
|
+
"-i",
|
|
97
|
+
help="Refresh interval in seconds (default: 30 for weekly).",
|
|
98
|
+
min=1,
|
|
99
|
+
max=300,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
DebugOption = typer.Option(
|
|
103
|
+
False,
|
|
104
|
+
"--debug",
|
|
105
|
+
"-d",
|
|
106
|
+
help="Show debug information including raw API response.",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def run_app(
|
|
111
|
+
limit_type: LimitType,
|
|
112
|
+
json_output: bool = False,
|
|
113
|
+
once: bool = False,
|
|
114
|
+
compact: bool = False,
|
|
115
|
+
since: str | None = None,
|
|
116
|
+
interval: int = 5,
|
|
117
|
+
debug: bool = False,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Run the ccburn application with the specified options.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
limit_type: Which limit to display
|
|
123
|
+
json_output: Output JSON instead of TUI
|
|
124
|
+
once: Print once and exit
|
|
125
|
+
compact: Single-line output
|
|
126
|
+
since: Time window string (e.g., '2h', '24h')
|
|
127
|
+
interval: Refresh interval in seconds
|
|
128
|
+
debug: Show debug info
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
from .app import CCBurnApp
|
|
132
|
+
except ImportError:
|
|
133
|
+
from ccburn.app import CCBurnApp
|
|
134
|
+
|
|
135
|
+
# Calculate since datetime if provided
|
|
136
|
+
since_dt = None
|
|
137
|
+
if since:
|
|
138
|
+
try:
|
|
139
|
+
since_delta = parse_duration(since)
|
|
140
|
+
since_dt = datetime.now(timezone.utc) - since_delta
|
|
141
|
+
except typer.BadParameter as e:
|
|
142
|
+
typer.echo(f"Error: {e}", err=True)
|
|
143
|
+
raise typer.Exit(1) from None
|
|
144
|
+
|
|
145
|
+
app = CCBurnApp(
|
|
146
|
+
limit_type=limit_type,
|
|
147
|
+
interval=interval,
|
|
148
|
+
since=since_dt,
|
|
149
|
+
json_output=json_output,
|
|
150
|
+
once=once,
|
|
151
|
+
compact=compact,
|
|
152
|
+
debug=debug,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
exit_code = app.run()
|
|
156
|
+
raise typer.Exit(exit_code)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def create_session_command(app: typer.Typer) -> None:
|
|
160
|
+
"""Create the session subcommand."""
|
|
161
|
+
|
|
162
|
+
@app.command()
|
|
163
|
+
def session(
|
|
164
|
+
json_output: bool = JsonOption,
|
|
165
|
+
once: bool = OnceOption,
|
|
166
|
+
compact: bool = CompactOption,
|
|
167
|
+
since: str | None = SinceOption,
|
|
168
|
+
interval: int = SessionIntervalOption,
|
|
169
|
+
debug: bool = DebugOption,
|
|
170
|
+
) -> None:
|
|
171
|
+
"""Display 5-hour rolling session limit.
|
|
172
|
+
|
|
173
|
+
This is the default limit that blocks you immediately when exceeded.
|
|
174
|
+
"""
|
|
175
|
+
run_app(
|
|
176
|
+
LimitType.SESSION,
|
|
177
|
+
json_output=json_output,
|
|
178
|
+
once=once,
|
|
179
|
+
compact=compact,
|
|
180
|
+
since=since,
|
|
181
|
+
interval=interval,
|
|
182
|
+
debug=debug,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def create_weekly_command(app: typer.Typer) -> None:
|
|
187
|
+
"""Create the weekly subcommand."""
|
|
188
|
+
|
|
189
|
+
@app.command()
|
|
190
|
+
def weekly(
|
|
191
|
+
json_output: bool = JsonOption,
|
|
192
|
+
once: bool = OnceOption,
|
|
193
|
+
compact: bool = CompactOption,
|
|
194
|
+
since: str | None = SinceOption,
|
|
195
|
+
interval: int = WeeklyIntervalOption,
|
|
196
|
+
debug: bool = DebugOption,
|
|
197
|
+
) -> None:
|
|
198
|
+
"""Display 7-day weekly limit (all models).
|
|
199
|
+
|
|
200
|
+
Shows your weekly budget across all model types.
|
|
201
|
+
"""
|
|
202
|
+
run_app(
|
|
203
|
+
LimitType.WEEKLY,
|
|
204
|
+
json_output=json_output,
|
|
205
|
+
once=once,
|
|
206
|
+
compact=compact,
|
|
207
|
+
since=since,
|
|
208
|
+
interval=interval,
|
|
209
|
+
debug=debug,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def create_weekly_sonnet_command(app: typer.Typer) -> None:
|
|
214
|
+
"""Create the weekly-sonnet subcommand."""
|
|
215
|
+
|
|
216
|
+
@app.command("weekly-sonnet")
|
|
217
|
+
def weekly_sonnet(
|
|
218
|
+
json_output: bool = JsonOption,
|
|
219
|
+
once: bool = OnceOption,
|
|
220
|
+
compact: bool = CompactOption,
|
|
221
|
+
since: str | None = SinceOption,
|
|
222
|
+
interval: int = WeeklyIntervalOption,
|
|
223
|
+
debug: bool = DebugOption,
|
|
224
|
+
) -> None:
|
|
225
|
+
"""Display 7-day weekly limit (Sonnet only).
|
|
226
|
+
|
|
227
|
+
Shows your weekly Sonnet-class model budget.
|
|
228
|
+
"""
|
|
229
|
+
run_app(
|
|
230
|
+
LimitType.WEEKLY_SONNET,
|
|
231
|
+
json_output=json_output,
|
|
232
|
+
once=once,
|
|
233
|
+
compact=compact,
|
|
234
|
+
since=since,
|
|
235
|
+
interval=interval,
|
|
236
|
+
debug=debug,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def create_clear_history_command(app: typer.Typer) -> None:
|
|
241
|
+
"""Create the clear-history command."""
|
|
242
|
+
|
|
243
|
+
@app.command("clear-history")
|
|
244
|
+
def clear_history(
|
|
245
|
+
force: bool = typer.Option(
|
|
246
|
+
False,
|
|
247
|
+
"--force",
|
|
248
|
+
"-f",
|
|
249
|
+
help="Skip confirmation prompt.",
|
|
250
|
+
),
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Clear all stored usage history.
|
|
253
|
+
|
|
254
|
+
This removes all snapshots from the SQLite database at ~/.ccburn/history.db
|
|
255
|
+
"""
|
|
256
|
+
console = Console()
|
|
257
|
+
|
|
258
|
+
if not force:
|
|
259
|
+
confirm = typer.confirm("Are you sure you want to clear all history?")
|
|
260
|
+
if not confirm:
|
|
261
|
+
console.print("[yellow]Cancelled.[/yellow]")
|
|
262
|
+
raise typer.Exit(0)
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
from .data.history import HistoryDB
|
|
266
|
+
except ImportError:
|
|
267
|
+
from ccburn.data.history import HistoryDB
|
|
268
|
+
|
|
269
|
+
with HistoryDB() as db:
|
|
270
|
+
count = db.clear_history()
|
|
271
|
+
console.print(f"[green]Cleared {count} snapshots from history.[/green]")
|
|
272
|
+
|
|
273
|
+
raise typer.Exit(0)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def register_commands(app: typer.Typer) -> None:
|
|
277
|
+
"""Register all subcommands with the Typer app.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
app: Typer application instance
|
|
281
|
+
"""
|
|
282
|
+
create_session_command(app)
|
|
283
|
+
create_weekly_command(app)
|
|
284
|
+
create_weekly_sonnet_command(app)
|
|
285
|
+
create_clear_history_command(app)
|
ccburn/data/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Data layer for ccburn - API client, credentials, history storage."""
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
from .credentials import CredentialsError, get_access_token
|
|
5
|
+
from .history import HistoryDB
|
|
6
|
+
from .models import BurnMetrics, LimitData, LimitType, UsageSnapshot
|
|
7
|
+
from .usage_client import UsageClient, UsageClientError
|
|
8
|
+
except ImportError:
|
|
9
|
+
from ccburn.data.credentials import CredentialsError, get_access_token
|
|
10
|
+
from ccburn.data.history import HistoryDB
|
|
11
|
+
from ccburn.data.models import BurnMetrics, LimitData, LimitType, UsageSnapshot
|
|
12
|
+
from ccburn.data.usage_client import UsageClient, UsageClientError
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"LimitType",
|
|
16
|
+
"LimitData",
|
|
17
|
+
"UsageSnapshot",
|
|
18
|
+
"BurnMetrics",
|
|
19
|
+
"get_access_token",
|
|
20
|
+
"CredentialsError",
|
|
21
|
+
"UsageClient",
|
|
22
|
+
"UsageClientError",
|
|
23
|
+
"HistoryDB",
|
|
24
|
+
]
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""OAuth credentials reader for Claude Code."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CredentialsError(Exception):
|
|
10
|
+
"""Base exception for credentials errors."""
|
|
11
|
+
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CredentialsNotFoundError(CredentialsError):
|
|
16
|
+
"""Credentials file not found."""
|
|
17
|
+
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TokenExpiredError(CredentialsError):
|
|
22
|
+
"""OAuth token has expired."""
|
|
23
|
+
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class InvalidCredentialsError(CredentialsError):
|
|
28
|
+
"""Credentials file is malformed."""
|
|
29
|
+
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_credentials_path() -> Path:
|
|
34
|
+
"""Get the path to Claude credentials file.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Path to ~/.claude/.credentials.json
|
|
38
|
+
"""
|
|
39
|
+
# Check environment variable first
|
|
40
|
+
env_path = os.environ.get("CREDENTIALS_PATH")
|
|
41
|
+
if env_path:
|
|
42
|
+
return Path(env_path)
|
|
43
|
+
|
|
44
|
+
return Path.home() / ".claude" / ".credentials.json"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def read_credentials() -> dict:
|
|
48
|
+
"""Read the raw credentials file.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Parsed credentials dictionary
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
CredentialsNotFoundError: If file doesn't exist
|
|
55
|
+
InvalidCredentialsError: If file is malformed
|
|
56
|
+
"""
|
|
57
|
+
creds_path = get_credentials_path()
|
|
58
|
+
|
|
59
|
+
if not creds_path.exists():
|
|
60
|
+
raise CredentialsNotFoundError(
|
|
61
|
+
f"Credentials file not found at {creds_path}\n"
|
|
62
|
+
"Please ensure Claude Code is installed and you are logged in.\n"
|
|
63
|
+
"Run 'claude' to log in if needed."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
with open(creds_path) as f:
|
|
68
|
+
return json.load(f)
|
|
69
|
+
except json.JSONDecodeError as e:
|
|
70
|
+
raise InvalidCredentialsError(f"Invalid JSON in credentials file: {e}") from e
|
|
71
|
+
except PermissionError as e:
|
|
72
|
+
raise CredentialsError(f"Permission denied reading {creds_path}") from e
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def check_token_expired(credentials: dict) -> bool:
|
|
76
|
+
"""Check if the OAuth token has expired.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
credentials: Parsed credentials dictionary
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
True if token is expired, False otherwise
|
|
83
|
+
"""
|
|
84
|
+
oauth = credentials.get("claudeAiOauth", {})
|
|
85
|
+
expires_at = oauth.get("expiresAt")
|
|
86
|
+
|
|
87
|
+
if not expires_at:
|
|
88
|
+
return False # No expiry info, assume valid
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
# Parse ISO timestamp
|
|
92
|
+
if isinstance(expires_at, str):
|
|
93
|
+
expiry = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
|
|
94
|
+
elif isinstance(expires_at, (int, float)):
|
|
95
|
+
expiry = datetime.fromtimestamp(expires_at, tz=timezone.utc)
|
|
96
|
+
else:
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
now = datetime.now(timezone.utc)
|
|
100
|
+
return now >= expiry
|
|
101
|
+
except (ValueError, TypeError):
|
|
102
|
+
return False # Can't parse, assume valid
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_access_token() -> str:
|
|
106
|
+
"""Get the OAuth access token for API calls.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Access token string
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
CredentialsNotFoundError: If credentials file doesn't exist
|
|
113
|
+
TokenExpiredError: If token has expired
|
|
114
|
+
InvalidCredentialsError: If token not found in credentials
|
|
115
|
+
"""
|
|
116
|
+
credentials = read_credentials()
|
|
117
|
+
|
|
118
|
+
if check_token_expired(credentials):
|
|
119
|
+
raise TokenExpiredError(
|
|
120
|
+
"OAuth token has expired.\n"
|
|
121
|
+
"Please restart Claude Code to refresh your token.\n"
|
|
122
|
+
"Run 'claude' to refresh authentication."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
oauth = credentials.get("claudeAiOauth", {})
|
|
126
|
+
token = oauth.get("accessToken")
|
|
127
|
+
|
|
128
|
+
if not token:
|
|
129
|
+
raise InvalidCredentialsError(
|
|
130
|
+
"No access token found in credentials.\n"
|
|
131
|
+
"Please ensure you are logged in to Claude Code.\n"
|
|
132
|
+
"Run 'claude' to log in."
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return token
|