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/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)
@@ -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