claude-code-usage 1.0.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.
src/config.py ADDED
@@ -0,0 +1,173 @@
1
+ """Configuration module for loading Claude OAuth credentials."""
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ from dataclasses import asdict, dataclass
7
+ from pathlib import Path
8
+
9
+
10
+ CREDENTIALS_PATH = Path.home() / ".claude" / ".credentials.json"
11
+
12
+
13
+ def load_credentials() -> dict:
14
+ """Load Claude OAuth credentials from ~/.claude/.credentials.json.
15
+
16
+ Returns:
17
+ dict: The claudeAiOauth object containing accessToken, expiresAt, etc.
18
+
19
+ Raises:
20
+ FileNotFoundError: If credentials file doesn't exist
21
+ ValueError: If credentials file format is invalid
22
+ """
23
+ if not CREDENTIALS_PATH.exists():
24
+ raise FileNotFoundError(
25
+ "Claude credentials not found. Run `claude` first to authenticate."
26
+ )
27
+
28
+ try:
29
+ with open(CREDENTIALS_PATH, 'r') as f:
30
+ data = json.load(f)
31
+ except json.JSONDecodeError as e:
32
+ raise ValueError(f"Invalid credentials file format: {e}")
33
+
34
+ if "claudeAiOauth" not in data:
35
+ raise ValueError("Invalid credentials file format")
36
+
37
+ return data["claudeAiOauth"]
38
+
39
+
40
+ def is_token_expired(credentials: dict) -> bool:
41
+ """Check if the OAuth token has expired.
42
+
43
+ Args:
44
+ credentials: The claudeAiOauth object with expiresAt field
45
+
46
+ Returns:
47
+ bool: True if expired or expiresAt is None
48
+ """
49
+ expires_at = credentials.get("expiresAt")
50
+
51
+ if expires_at is None:
52
+ return True
53
+
54
+ # expiresAt is in milliseconds since epoch
55
+ current_time_ms = time.time() * 1000
56
+ return current_time_ms >= expires_at
57
+
58
+
59
+ def get_access_token() -> str:
60
+ """Get a valid OAuth access token.
61
+
62
+ Returns:
63
+ str: The access token string
64
+
65
+ Raises:
66
+ FileNotFoundError: If credentials file doesn't exist
67
+ ValueError: If credentials are invalid or token expired
68
+ """
69
+ credentials = load_credentials()
70
+
71
+ if is_token_expired(credentials):
72
+ raise ValueError("OAuth token has expired")
73
+
74
+ return credentials["accessToken"]
75
+
76
+
77
+ def get_config_path() -> Path:
78
+ """Get XDG-compliant config file path.
79
+
80
+ Returns:
81
+ Path: Path to config.json in XDG config directory
82
+ """
83
+ config_home = os.environ.get('XDG_CONFIG_HOME')
84
+ if config_home:
85
+ base = Path(config_home)
86
+ else:
87
+ base = Path.home() / '.config'
88
+
89
+ config_dir = base / 'claude-usage-overlay'
90
+ config_dir.mkdir(parents=True, exist_ok=True)
91
+
92
+ return config_dir / 'config.json'
93
+
94
+
95
+ @dataclass
96
+ class UserConfig:
97
+ """User configuration settings for Claude Usage Overlay."""
98
+
99
+ session_thresholds: list[int] = None
100
+ weekly_thresholds: list[int] = None
101
+ polling_interval: int = 300
102
+ pause_notifications: bool = False
103
+ autostart_enabled: bool = False
104
+
105
+ def __post_init__(self):
106
+ """Validate configuration values and set defaults."""
107
+ # Set defaults for None fields (avoid mutable default trap)
108
+ if self.session_thresholds is None:
109
+ self.session_thresholds = [50, 75, 90]
110
+ if self.weekly_thresholds is None:
111
+ self.weekly_thresholds = [50, 75, 90]
112
+
113
+ # Validate thresholds
114
+ for threshold in self.session_thresholds:
115
+ if not isinstance(threshold, int) or not (0 <= threshold <= 100):
116
+ raise ValueError(
117
+ f"Invalid session threshold: {threshold}. Must be integer 0-100."
118
+ )
119
+ for threshold in self.weekly_thresholds:
120
+ if not isinstance(threshold, int) or not (0 <= threshold <= 100):
121
+ raise ValueError(
122
+ f"Invalid weekly threshold: {threshold}. Must be integer 0-100."
123
+ )
124
+
125
+ # Validate polling_interval
126
+ if not isinstance(self.polling_interval, int) or not (30 <= self.polling_interval <= 3600):
127
+ raise ValueError(
128
+ f"Invalid polling_interval: {self.polling_interval}. Must be integer 30-3600."
129
+ )
130
+
131
+ # Validate boolean fields
132
+ if not isinstance(self.pause_notifications, bool):
133
+ raise ValueError(
134
+ f"Invalid pause_notifications: {self.pause_notifications}. Must be bool."
135
+ )
136
+ if not isinstance(self.autostart_enabled, bool):
137
+ raise ValueError(
138
+ f"Invalid autostart_enabled: {self.autostart_enabled}. Must be bool."
139
+ )
140
+
141
+ @classmethod
142
+ def load(cls) -> 'UserConfig':
143
+ """Load configuration from XDG config file.
144
+
145
+ Returns:
146
+ UserConfig: Configuration object with values from file or defaults
147
+
148
+ Raises:
149
+ ValueError: If config file format is invalid
150
+ """
151
+ config_path = get_config_path()
152
+
153
+ if not config_path.exists():
154
+ return cls()
155
+
156
+ try:
157
+ with open(config_path, 'r') as f:
158
+ data = json.load(f)
159
+ except json.JSONDecodeError as e:
160
+ raise ValueError(f"Invalid config file format: {e}")
161
+
162
+ try:
163
+ return cls(**data)
164
+ except TypeError as e:
165
+ raise ValueError(f"Invalid config file structure: {e}")
166
+
167
+ def save(self):
168
+ """Save configuration to XDG config file."""
169
+ config_path = get_config_path()
170
+ config_path.parent.mkdir(parents=True, exist_ok=True)
171
+
172
+ with open(config_path, 'w') as f:
173
+ json.dump(asdict(self), f, indent=2)
src/icon_generator.py ADDED
@@ -0,0 +1,92 @@
1
+ """
2
+ Icon generation utilities for Claude Code usage overlay.
3
+
4
+ Generates circular gauge icons using Cairo for system tray display.
5
+ """
6
+ import cairo
7
+ import math
8
+ import os
9
+ from typing import Tuple
10
+
11
+
12
+ def get_color_for_percentage(percentage: float) -> Tuple[float, float, float]:
13
+ """
14
+ Return RGB color tuple based on usage percentage.
15
+
16
+ Args:
17
+ percentage: Usage percentage (0-100)
18
+
19
+ Returns:
20
+ RGB tuple in Cairo format (0.0-1.0 range)
21
+ - Green for < 50%
22
+ - Yellow for 50-75%
23
+ - Red for >= 75%
24
+ """
25
+ if percentage < 50:
26
+ return (0.2, 0.8, 0.2) # Green
27
+ elif percentage < 75:
28
+ return (0.9, 0.7, 0.0) # Yellow
29
+ else:
30
+ return (0.9, 0.2, 0.2) # Red
31
+
32
+
33
+ def generate_gauge_icon(percentage: float, color_percentage: float, size: int = 22) -> str:
34
+ """
35
+ Generate a circular gauge icon showing usage percentage.
36
+
37
+ Args:
38
+ percentage: Usage level to display as arc fill (0-100)
39
+ color_percentage: Percentage used to determine color (0-100)
40
+ size: Icon size in pixels (default: 22)
41
+
42
+ Returns:
43
+ Absolute path to generated PNG file in /tmp
44
+
45
+ Notes:
46
+ - percentage controls the arc fill amount (how full the gauge is)
47
+ - color_percentage controls the color (via get_color_for_percentage)
48
+ - This separation allows showing session usage while coloring by worst-case urgency
49
+ """
50
+ # Get color based on color_percentage parameter
51
+ fill_color = get_color_for_percentage(color_percentage)
52
+
53
+ # Determine color name for filename (to bust icon cache)
54
+ if color_percentage < 50:
55
+ color_name = "green"
56
+ elif color_percentage < 75:
57
+ color_name = "yellow"
58
+ else:
59
+ color_name = "red"
60
+
61
+ # Create Cairo surface with transparent background
62
+ surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, size, size)
63
+ ctx = cairo.Context(surface)
64
+
65
+ # Clear background (transparent)
66
+ ctx.set_source_rgba(0, 0, 0, 0)
67
+ ctx.paint()
68
+
69
+ # Calculate center and radius (leave 2px margin)
70
+ center = size / 2
71
+ radius = (size / 2) - 2
72
+
73
+ # Draw gray outline (full circle)
74
+ ctx.set_source_rgb(0.5, 0.5, 0.5)
75
+ ctx.set_line_width(2)
76
+ ctx.arc(center, center, radius, 0, 2 * math.pi)
77
+ ctx.stroke()
78
+
79
+ # Draw colored fill arc (based on percentage)
80
+ if percentage > 0:
81
+ ctx.set_source_rgb(*fill_color)
82
+ ctx.set_line_width(2)
83
+ # Start at 12 o'clock (-pi/2), go clockwise by percentage amount
84
+ end_angle = -math.pi / 2 + (2 * math.pi * percentage / 100)
85
+ ctx.arc(center, center, radius, -math.pi / 2, end_angle)
86
+ ctx.stroke()
87
+
88
+ # Save to unique filename per percentage and color state (bust GTK icon cache)
89
+ icon_path = f"/tmp/claude-usage-{int(percentage)}-{color_name}.png"
90
+ surface.write_to_png(icon_path)
91
+
92
+ return os.path.abspath(icon_path)
src/main.py ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env python3
2
+ """Claude Code Usage Overlay - System tray application."""
3
+
4
+ import sys
5
+
6
+
7
+ def main():
8
+ """Start the Claude Code Usage Overlay application."""
9
+ try:
10
+ from src.tray import TrayIndicator
11
+
12
+ indicator = TrayIndicator()
13
+ indicator.run()
14
+
15
+ except KeyboardInterrupt:
16
+ print("\nShutting down...")
17
+ sys.exit(0)
18
+ except Exception as e:
19
+ print(f"Fatal error: {e}", file=sys.stderr)
20
+ sys.exit(1)
21
+
22
+
23
+ if __name__ == "__main__":
24
+ main()
src/notifier.py ADDED
@@ -0,0 +1,289 @@
1
+ """Desktop notification manager for usage threshold alerts."""
2
+
3
+ import subprocess
4
+ import time
5
+
6
+ import gi
7
+
8
+ gi.require_version('Notify', '0.7')
9
+ from gi.repository import Notify
10
+
11
+
12
+ class UsageNotifier:
13
+ """Manages threshold-based usage notifications.
14
+
15
+ Displays popup notifications when session or weekly usage crosses
16
+ thresholds (50%, 75%, 90%) with escalating urgency and advice.
17
+ Tracks alerted thresholds to prevent notification spam.
18
+ """
19
+
20
+ def __init__(self, app_name="Claude Code Usage Monitor",
21
+ session_thresholds=None, weekly_thresholds=None):
22
+ """Initialize the notifier.
23
+
24
+ Args:
25
+ app_name: Name shown in notification server
26
+ session_thresholds: List of threshold percentages for session (default [50, 75, 90])
27
+ weekly_thresholds: List of threshold percentages for weekly (default to session_thresholds)
28
+ """
29
+ # Initialize libnotify once
30
+ Notify.init(app_name)
31
+
32
+ # Configure thresholds
33
+ self.session_thresholds = session_thresholds if session_thresholds is not None else [50, 75, 90]
34
+ self.weekly_thresholds = weekly_thresholds if weekly_thresholds is not None else self.session_thresholds
35
+
36
+ # Track alerted thresholds: {(metric, threshold): True}
37
+ # metric = 'session' or 'weekly'
38
+ # threshold = 50, 75, or 90
39
+ self.alerted = {}
40
+
41
+ # Grace period flag - skip first poll to avoid startup spam
42
+ self.first_poll = True
43
+
44
+ # Pause notifications mode
45
+ self.pause_notifications = False
46
+
47
+ # Cache server capabilities
48
+ self._actions_supported = None
49
+
50
+ def check_and_notify(self, session_pct, weekly_pct, session_reset, weekly_reset):
51
+ """Check thresholds and show notifications if needed.
52
+
53
+ Main entry point called by TrayIndicator after each usage update.
54
+
55
+ Args:
56
+ session_pct: Session usage percentage (0-100)
57
+ weekly_pct: Weekly usage percentage (0-100)
58
+ session_reset: Unix timestamp when session resets
59
+ weekly_reset: Unix timestamp when weekly resets
60
+ """
61
+ # Skip first poll (grace period)
62
+ if self.first_poll:
63
+ self.first_poll = False
64
+ return
65
+
66
+ # Respect pause mode
67
+ if self.pause_notifications:
68
+ return
69
+
70
+ # Find highest threshold crossed for each metric
71
+ session_threshold = self._highest_crossed(session_pct, self.session_thresholds)
72
+ weekly_threshold = self._highest_crossed(weekly_pct, self.weekly_thresholds)
73
+
74
+ # Determine if we should alert
75
+ session_needs_alert = (
76
+ session_threshold and
77
+ ('session', session_threshold) not in self.alerted
78
+ )
79
+ weekly_needs_alert = (
80
+ weekly_threshold and
81
+ ('weekly', weekly_threshold) not in self.alerted
82
+ )
83
+
84
+ # Handle combined case first (both need alerts)
85
+ if session_needs_alert and weekly_needs_alert:
86
+ if session_threshold == weekly_threshold:
87
+ # Same threshold - use that urgency
88
+ self._show_combined_notification(
89
+ session_threshold, session_pct, weekly_pct,
90
+ session_reset, weekly_reset
91
+ )
92
+ else:
93
+ # Different thresholds - use highest urgency
94
+ max_threshold = max(session_threshold, weekly_threshold)
95
+ self._show_combined_notification(
96
+ max_threshold, session_pct, weekly_pct,
97
+ session_reset, weekly_reset
98
+ )
99
+ # Mark both as alerted
100
+ self.alerted[('session', session_threshold)] = True
101
+ self.alerted[('weekly', weekly_threshold)] = True
102
+
103
+ # Individual alerts
104
+ elif session_needs_alert:
105
+ self._show_notification(
106
+ 'session', session_pct, session_threshold, session_reset
107
+ )
108
+ self.alerted[('session', session_threshold)] = True
109
+
110
+ elif weekly_needs_alert:
111
+ self._show_notification(
112
+ 'weekly', weekly_pct, weekly_threshold, weekly_reset
113
+ )
114
+ self.alerted[('weekly', weekly_threshold)] = True
115
+
116
+ def _highest_crossed(self, percentage, thresholds):
117
+ """Return highest threshold crossed, or None.
118
+
119
+ Args:
120
+ percentage: Current usage percentage (0-100)
121
+ thresholds: List of threshold values to check
122
+
123
+ Returns:
124
+ int: Highest crossed threshold, or None if no thresholds crossed
125
+ """
126
+ crossed = [t for t in thresholds if percentage >= t]
127
+ return max(crossed) if crossed else None
128
+
129
+ def _show_notification(self, metric, percentage, threshold, reset_time):
130
+ """Show notification for single metric threshold.
131
+
132
+ Args:
133
+ metric: 'session' or 'weekly'
134
+ percentage: Current usage percentage (0-100)
135
+ threshold: Threshold that was crossed (50, 75, or 90)
136
+ reset_time: Unix timestamp when metric resets
137
+ """
138
+ # Map threshold to urgency and advice
139
+ urgency_map = {
140
+ 50: (Notify.Urgency.LOW, "Heads up"),
141
+ 75: (Notify.Urgency.NORMAL, "Consider saving your work"),
142
+ 90: (Notify.Urgency.CRITICAL, "Save your work now")
143
+ }
144
+
145
+ urgency, advice = urgency_map[threshold]
146
+
147
+ # Format title
148
+ metric_name = "Session Usage" if metric == 'session' else "Weekly Usage"
149
+ title = f"{metric_name}: {percentage:.0f}%"
150
+
151
+ # Format body with reset time
152
+ reset_str = self._format_reset_time(reset_time)
153
+ body = f"{advice}\n\nResets in {reset_str}"
154
+
155
+ # Create notification
156
+ notification = Notify.Notification.new(
157
+ title,
158
+ body,
159
+ "dialog-information"
160
+ )
161
+
162
+ # Set urgency level
163
+ notification.set_urgency(urgency)
164
+
165
+ # Add action button if server supports it
166
+ if self._server_supports_actions():
167
+ notification.add_action(
168
+ "open-claude",
169
+ "Open Claude Code",
170
+ self._on_open_claude,
171
+ None
172
+ )
173
+
174
+ # Show notification
175
+ notification.show()
176
+
177
+ def _show_combined_notification(self, threshold, session_pct, weekly_pct,
178
+ session_reset, weekly_reset):
179
+ """Show combined notification for both metrics.
180
+
181
+ Args:
182
+ threshold: Threshold to use for urgency (highest of both)
183
+ session_pct: Session usage percentage (0-100)
184
+ weekly_pct: Weekly usage percentage (0-100)
185
+ session_reset: Unix timestamp when session resets
186
+ weekly_reset: Unix timestamp when weekly resets
187
+ """
188
+ # Map threshold to urgency and advice
189
+ urgency_map = {
190
+ 50: (Notify.Urgency.LOW, "Heads up"),
191
+ 75: (Notify.Urgency.NORMAL, "Consider saving your work"),
192
+ 90: (Notify.Urgency.CRITICAL, "Save your work now")
193
+ }
194
+
195
+ urgency, advice = urgency_map[threshold]
196
+
197
+ # Format title (show threshold, not individual percentages)
198
+ title = f"Session & Weekly Usage: {threshold}%"
199
+
200
+ # Format body with both reset times
201
+ session_reset_str = self._format_reset_time(session_reset)
202
+ weekly_reset_str = self._format_reset_time(weekly_reset)
203
+ body = (f"{advice}\n\n"
204
+ f"Session resets in {session_reset_str}\n"
205
+ f"Weekly resets in {weekly_reset_str}")
206
+
207
+ # Create notification
208
+ notification = Notify.Notification.new(
209
+ title,
210
+ body,
211
+ "dialog-information"
212
+ )
213
+
214
+ # Set urgency level
215
+ notification.set_urgency(urgency)
216
+
217
+ # Add action button if server supports it
218
+ if self._server_supports_actions():
219
+ notification.add_action(
220
+ "open-claude",
221
+ "Open Claude Code",
222
+ self._on_open_claude,
223
+ None
224
+ )
225
+
226
+ # Show notification
227
+ notification.show()
228
+
229
+ def _format_reset_time(self, reset_timestamp):
230
+ """Format Unix timestamp to human-readable time until reset.
231
+
232
+ Args:
233
+ reset_timestamp: Unix timestamp (seconds since epoch)
234
+
235
+ Returns:
236
+ str: Human-readable time string (e.g., "2h", "3d", "45m")
237
+ """
238
+ now = time.time()
239
+ seconds = int(reset_timestamp - now)
240
+
241
+ if seconds <= 0:
242
+ return "shortly"
243
+
244
+ # Convert to hours and days using divmod
245
+ hours, remainder = divmod(seconds, 3600)
246
+ days, hours = divmod(hours, 24)
247
+
248
+ if days > 0:
249
+ return f"{days}d"
250
+ elif hours > 0:
251
+ return f"{hours}h"
252
+ else:
253
+ minutes = remainder // 60
254
+ return f"{minutes}m" if minutes > 0 else "shortly"
255
+
256
+ def _server_supports_actions(self):
257
+ """Check if notification server supports action buttons.
258
+
259
+ Returns:
260
+ bool: True if server supports actions, False otherwise
261
+ """
262
+ if self._actions_supported is None:
263
+ caps = Notify.get_server_caps()
264
+ self._actions_supported = caps and 'actions' in caps
265
+ return self._actions_supported
266
+
267
+ def _on_open_claude(self, notification, action, user_data):
268
+ """Callback when 'Open Claude Code' button is clicked.
269
+
270
+ Args:
271
+ notification: The notification that triggered the action
272
+ action: Action identifier string
273
+ user_data: User data passed to add_action (unused)
274
+ """
275
+ # Close the notification
276
+ notification.close()
277
+
278
+ # Open Claude Code in browser
279
+ subprocess.Popen(["/usr/bin/xdg-open", "https://claude.ai"])
280
+
281
+ def set_thresholds(self, session_thresholds, weekly_thresholds=None):
282
+ """Update threshold configuration.
283
+
284
+ Args:
285
+ session_thresholds: List of session threshold percentages
286
+ weekly_thresholds: List of weekly threshold percentages (defaults to session)
287
+ """
288
+ self.session_thresholds = session_thresholds
289
+ self.weekly_thresholds = weekly_thresholds if weekly_thresholds is not None else session_thresholds