tokenmaxxing 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.
@@ -0,0 +1,7 @@
1
+ """tokenmaxxing — menu bar app for Claude Code session and weekly usage."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from tokenmaxxing.app import main
6
+
7
+ __all__ = ["main", "__version__"]
@@ -0,0 +1,6 @@
1
+ """Allows `python -m tokenmaxxing` to launch the menu bar app."""
2
+
3
+ from tokenmaxxing.app import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
tokenmaxxing/app.py ADDED
@@ -0,0 +1,956 @@
1
+ #!/usr/bin/env python3
2
+ """Claude Session Monitor — menu bar app.
3
+
4
+ Sits in the menu bar (left of the clock) and shows a progress bar for your
5
+ Claude Code 5-hour session OR rolling 7-day usage. Click for details and
6
+ to switch which window the bar tracks.
7
+
8
+ Polls Anthropic's OAuth usage endpoint directly — same call Claude Code
9
+ uses internally. Authoritative numbers, zero tokens consumed per poll.
10
+
11
+ GET https://api.anthropic.com/api/oauth/usage
12
+ Authorization: Bearer <token from system keychain or ~/.claude/.credentials.json>
13
+ anthropic-beta: oauth-2025-04-20
14
+
15
+ Install:
16
+ pip install rumps
17
+ Run:
18
+ python3 claude_limit_app.py
19
+ Detached:
20
+ nohup python3 claude_limit_app.py >/dev/null 2>&1 &
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import shlex
28
+ import subprocess
29
+ import sys
30
+ import tempfile
31
+ import threading
32
+ import time
33
+ import urllib.error
34
+ import urllib.request
35
+ from datetime import datetime, timezone
36
+ from pathlib import Path
37
+ from typing import Optional
38
+
39
+ import rumps
40
+
41
+ try:
42
+ from AppKit import (
43
+ NSColor,
44
+ NSMutableAttributedString,
45
+ NSForegroundColorAttributeName,
46
+ )
47
+ _HAS_APPKIT = True
48
+ except ImportError: # pragma: no cover
49
+ _HAS_APPKIT = False
50
+
51
+ USAGE_URL = "https://api.anthropic.com/api/oauth/usage"
52
+ OAUTH_API_VERSION = "oauth-2025-04-20"
53
+ KEYCHAIN_SERVICE = "Claude Code-credentials"
54
+ CREDENTIALS_FILE = Path.home() / ".claude" / ".credentials.json"
55
+ DASHBOARD_URL = "https://claude.ai/settings/usage"
56
+ ENV_DASHBOARD_URL = "CLAUDE_DASHBOARD_URL"
57
+
58
+ # App UI
59
+ APP_NAME = "Claude"
60
+ LOADING_TEXT = "loading…"
61
+ TIMER_INTERVAL = 1
62
+
63
+ REFRESH_SECONDS = 300 # 5 min: OAuth usage endpoint has aggressive undocumented rate limits
64
+ REFRESH_MAX_BACKOFF = 1800 # Max backoff: 30 minutes
65
+ BACKOFF_EXP_CAP = 11 # 2^11 = 2048s, lets REFRESH_MAX_BACKOFF (1800s) become the binding cap
66
+ HTTP_TIMEOUT = 10
67
+ KEYCHAIN_TIMEOUT = 3
68
+ HTTP_STATUS_RATE_LIMITED = 429
69
+ HTTP_STATUS_UNAUTHORIZED = 401
70
+ BAR_WIDTH = 5
71
+
72
+ # Time units (seconds)
73
+ SECONDS_PER_DAY = 86400
74
+ SECONDS_PER_HOUR = 3600
75
+ SECONDS_PER_MINUTE = 60
76
+
77
+ # UI formatting
78
+ SEPARATOR = " · "
79
+ UNAVAILABLE = "—"
80
+ MARKER_SELECTED = "✓ "
81
+ MARKER_UNSELECTED = " "
82
+ STATUS_RATE_LIMITED = " [rate limited]"
83
+ STATUS_STALE = " (stale)"
84
+ MENU_ICON = "◌ "
85
+
86
+ # Menu item labels
87
+ EXTRA_USAGE_LABEL = "Extra usage: "
88
+ PLAN_LABEL = "Plan: "
89
+ UPDATE_LABEL = "Updated: "
90
+
91
+ # View status strings
92
+ VIEW_UNAVAILABLE = "n/a"
93
+ NEVER_UPDATED = "never"
94
+ EXTRA_USAGE_OFF = "off"
95
+ EXTRA_USAGE_ENABLED = "enabled"
96
+
97
+ # Status emojis
98
+ EMOJI_UNKNOWN = "⚪"
99
+ EMOJI_OK = "🟢"
100
+ EMOJI_WARN = "🟡"
101
+ EMOJI_CRITICAL = "🔴"
102
+
103
+ # Color thresholds (utilization %)
104
+ WARN_PCT = 50.0 # green → yellow
105
+ CRIT_PCT = 80.0 # yellow → red
106
+
107
+ # Preference menu labels
108
+ PREF_LABEL_RESET_TIME = 'Show "resets at Fri 07:00"'
109
+ PREF_LABEL_ELAPSED_TIME = 'Show "resets in 4h10m"'
110
+ PREF_LABEL_HISTORY = "Show 7-day history"
111
+ PREF_LABEL_LAUNCH_AT_STARTUP = "Launch at login"
112
+
113
+ # LaunchAgent (auto-start at user login)
114
+ LAUNCH_AGENT_LABEL = "com.tokenmaxxing.app"
115
+ LAUNCH_AGENT_DIR = Path.home() / "Library" / "LaunchAgents"
116
+ LAUNCH_AGENT_FILE = LAUNCH_AGENT_DIR / f"{LAUNCH_AGENT_LABEL}.plist"
117
+ LAUNCH_AGENT_PLIST = """<?xml version="1.0" encoding="UTF-8"?>
118
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
119
+ <plist version="1.0">
120
+ <dict>
121
+ <key>Label</key>
122
+ <string>{label}</string>
123
+ <key>ProgramArguments</key>
124
+ <array>
125
+ {program_args}
126
+ </array>
127
+ <key>RunAtLoad</key>
128
+ <true/>
129
+ <key>KeepAlive</key>
130
+ <false/>
131
+ <key>ProcessType</key>
132
+ <string>Interactive</string>
133
+ <key>StandardOutPath</key>
134
+ <string>/tmp/tokenmaxxing.out</string>
135
+ <key>StandardErrorPath</key>
136
+ <string>/tmp/tokenmaxxing.err</string>
137
+ </dict>
138
+ </plist>
139
+ """
140
+
141
+ # Time display labels
142
+ TIME_DISPLAY_RESETS = "resets at "
143
+ TIME_DISPLAY_IN = "in "
144
+ UPDATE_TIME_FORMAT = "%H:%M:%S"
145
+ DETAIL_ITEM_SPACER = " " # spacing between sections in detail list items
146
+
147
+ # Plan tier mappings
148
+ SUB_TYPE_MAP = {"pro": "Claude Pro", "max": "Claude Max", "free": "Free"}
149
+ RATE_LIMIT_TIER_MAP = {"tier1": "Tier 1", "tier2": "Tier 2", "tier3": "Tier 3", "tier4": "Tier 4"}
150
+
151
+ # (label, key in usage payload, default visible in detail list)
152
+ VIEWS = [
153
+ ("Session (5h)", "five_hour"),
154
+ ("Weekly (7d)", "seven_day"),
155
+ ("Weekly Sonnet", "seven_day_sonnet"),
156
+ ]
157
+ VIEW_LABEL = {key: label for label, key in VIEWS}
158
+ VIEW_PREFIX = {
159
+ "five_hour": "Session ",
160
+ "seven_day": "Weekly ",
161
+ "seven_day_sonnet": "Weekly Sonnet ",
162
+ }
163
+ DEFAULT_VIEW = "five_hour"
164
+
165
+ # Per-user app data dir (history + launcher wrapper script)
166
+ APP_DATA_DIR = Path.home() / ".tokenmaxxing"
167
+ LAUNCHER_SCRIPT = APP_DATA_DIR / "tokenmaxxing" # wrapper named "tokenmaxxing" so Login Items shows the friendly name
168
+
169
+ # History feature
170
+ HISTORY_DIR = APP_DATA_DIR
171
+ HISTORY_FILE = HISTORY_DIR / "history.json"
172
+ HISTORY_MAX_ENTRIES = 2000
173
+ HISTORY_DEDUPE_SECONDS = 300 # skip identical snapshot if <5min since last
174
+ HISTORY_BUCKETS = 7 # one per day for the 7-day sparkline
175
+ HISTORY_KEY_BY_VIEW = {
176
+ "five_hour": "session_pct",
177
+ "seven_day": "weekly_pct",
178
+ "seven_day_sonnet": "weekly_sonnet_pct",
179
+ }
180
+ SPARK_CHARS = "▁▂▃▄▅▆▇█"
181
+ SPARK_GAP = " "
182
+ TREND_UP = "↑"
183
+ TREND_DOWN = "↓"
184
+ TREND_FLAT = "→"
185
+ TREND_THRESHOLD = 3.0 # pct points
186
+ HISTORY_LABEL = "7d: "
187
+ HISTORY_COLLECTING = "7d: collecting…"
188
+
189
+
190
+ def _load_history() -> list:
191
+ """Read the on-disk history file. Returns [] on missing/corrupt file."""
192
+ if not HISTORY_FILE.exists():
193
+ return []
194
+ try:
195
+ with open(HISTORY_FILE, "r", encoding="utf-8") as f:
196
+ data = json.load(f)
197
+ if isinstance(data, list):
198
+ return data[-HISTORY_MAX_ENTRIES:]
199
+ print(f"history: {HISTORY_FILE} not a list; starting fresh", file=sys.stderr)
200
+ return []
201
+ except (OSError, json.JSONDecodeError) as e:
202
+ print(f"history: load failed ({e}); starting fresh", file=sys.stderr)
203
+ return []
204
+
205
+
206
+ def _save_history_atomic(entries: list) -> None:
207
+ """Write entries to HISTORY_FILE atomically via tempfile + os.replace."""
208
+ HISTORY_DIR.mkdir(parents=True, exist_ok=True)
209
+ fd, tmp_path = tempfile.mkstemp(dir=str(HISTORY_DIR), prefix=".history.", suffix=".tmp")
210
+ try:
211
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
212
+ json.dump(entries, f)
213
+ os.replace(tmp_path, str(HISTORY_FILE))
214
+ except Exception:
215
+ try:
216
+ os.unlink(tmp_path)
217
+ except OSError:
218
+ pass
219
+ raise
220
+
221
+
222
+ def _launchagent_installed() -> bool:
223
+ """True iff the LaunchAgent plist is on disk."""
224
+ return LAUNCH_AGENT_FILE.exists()
225
+
226
+
227
+ def _resolve_python_invocation() -> list:
228
+ """Build the python argv for launching tokenmaxxing.
229
+
230
+ Adapts to how the app is currently being run:
231
+ - installed via pip (`__package__` set): python -m tokenmaxxing
232
+ - standalone script: python /path/to/app.py
233
+ """
234
+ if __package__:
235
+ return [sys.executable, "-m", "tokenmaxxing"]
236
+ return [sys.executable, str(Path(__file__).resolve())]
237
+
238
+
239
+ def _write_launcher_script() -> Path:
240
+ """Write a wrapper script literally named "tokenmaxxing" so
241
+ System Settings → Login Items shows the friendly name instead of "python3.9"."""
242
+ APP_DATA_DIR.mkdir(parents=True, exist_ok=True)
243
+ quoted = " ".join(shlex.quote(a) for a in _resolve_python_invocation())
244
+ LAUNCHER_SCRIPT.write_text(f"#!/bin/sh\nexec {quoted}\n", encoding="utf-8")
245
+ LAUNCHER_SCRIPT.chmod(0o755)
246
+ return LAUNCHER_SCRIPT
247
+
248
+
249
+ def _install_launchagent() -> None:
250
+ """Write the LaunchAgent plist so tokenmaxxing starts on next login.
251
+
252
+ We deliberately do NOT `launchctl load` here — that would spawn a duplicate
253
+ instance immediately while the user already has one running. The plist is
254
+ auto-loaded by launchd on next user login.
255
+ """
256
+ LAUNCH_AGENT_DIR.mkdir(parents=True, exist_ok=True)
257
+ launcher = _write_launcher_script()
258
+ program_args = f" <string>{launcher}</string>"
259
+ plist = LAUNCH_AGENT_PLIST.format(label=LAUNCH_AGENT_LABEL, program_args=program_args)
260
+ LAUNCH_AGENT_FILE.write_text(plist, encoding="utf-8")
261
+
262
+
263
+ def _uninstall_launchagent() -> None:
264
+ """Unload (if loaded) and delete the LaunchAgent plist."""
265
+ if not LAUNCH_AGENT_FILE.exists():
266
+ return
267
+ try:
268
+ subprocess.run(
269
+ ["launchctl", "unload", str(LAUNCH_AGENT_FILE)],
270
+ check=False, capture_output=True, timeout=5,
271
+ )
272
+ except (OSError, subprocess.SubprocessError):
273
+ pass
274
+ try:
275
+ LAUNCH_AGENT_FILE.unlink()
276
+ except OSError:
277
+ pass
278
+
279
+
280
+ def _extract_pcts(payload: Optional[dict]):
281
+ """Extract (session_pct, weekly_pct, weekly_sonnet_pct) from a usage payload."""
282
+ def _pct(view):
283
+ if isinstance(view, dict):
284
+ v = view.get("utilization")
285
+ if v is not None:
286
+ try:
287
+ return float(v)
288
+ except (TypeError, ValueError):
289
+ return None
290
+ return None
291
+ if not isinstance(payload, dict):
292
+ return None, None, None
293
+ return (_pct(payload.get("five_hour")),
294
+ _pct(payload.get("seven_day")),
295
+ _pct(payload.get("seven_day_sonnet")))
296
+
297
+
298
+ def _bucket_snapshots(snapshots: list, key: str, now_ts: int):
299
+ """Return HISTORY_BUCKETS daily buckets ending at now_ts; each is a list of pct floats."""
300
+ bucket_secs = SECONDS_PER_DAY
301
+ cutoff = now_ts - HISTORY_BUCKETS * bucket_secs
302
+ buckets = [[] for _ in range(HISTORY_BUCKETS)]
303
+ for s in snapshots:
304
+ ts = s.get("ts") if isinstance(s, dict) else None
305
+ if not isinstance(ts, (int, float)) or ts < cutoff or ts > now_ts:
306
+ continue
307
+ idx = int((ts - cutoff) / bucket_secs)
308
+ idx = max(0, min(HISTORY_BUCKETS - 1, idx))
309
+ v = s.get(key)
310
+ if v is None:
311
+ continue
312
+ try:
313
+ buckets[idx].append(float(v))
314
+ except (TypeError, ValueError):
315
+ continue
316
+ return buckets
317
+
318
+
319
+ def _render_sparkline(snapshots: list, key: str, now_ts: int) -> str:
320
+ """Render last 7 daily buckets as a sparkline; trims leading empty buckets."""
321
+ buckets = _bucket_snapshots(snapshots, key, now_ts)
322
+ first_with_data = next((i for i, b in enumerate(buckets) if b), None)
323
+ if first_with_data is None:
324
+ return ""
325
+ chars = []
326
+ for b in buckets[first_with_data:]:
327
+ if not b:
328
+ chars.append(SPARK_GAP)
329
+ continue
330
+ avg = max(0.0, min(100.0, sum(b) / len(b)))
331
+ idx = int(avg / 100.0 * (len(SPARK_CHARS) - 1) + 0.5)
332
+ idx = max(0, min(len(SPARK_CHARS) - 1, idx))
333
+ chars.append(SPARK_CHARS[idx])
334
+ return "".join(chars)
335
+
336
+
337
+ def _compute_trend(snapshots: list, key: str, now_ts: int) -> str:
338
+ """Compare avg of last 24h vs prior 24h. Returns ↑/↓/→, or '' if insufficient data."""
339
+ last_start = now_ts - SECONDS_PER_DAY
340
+ prior_start = now_ts - 2 * SECONDS_PER_DAY
341
+ last_vals = []
342
+ prior_vals = []
343
+ for s in snapshots:
344
+ if not isinstance(s, dict):
345
+ continue
346
+ ts = s.get("ts")
347
+ v = s.get(key)
348
+ if not isinstance(ts, (int, float)) or v is None:
349
+ continue
350
+ try:
351
+ v = float(v)
352
+ except (TypeError, ValueError):
353
+ continue
354
+ if last_start <= ts <= now_ts:
355
+ last_vals.append(v)
356
+ elif prior_start <= ts < last_start:
357
+ prior_vals.append(v)
358
+ if not last_vals or not prior_vals:
359
+ return ""
360
+ delta = sum(last_vals) / len(last_vals) - sum(prior_vals) / len(prior_vals)
361
+ if delta > TREND_THRESHOLD:
362
+ return TREND_UP
363
+ if delta < -TREND_THRESHOLD:
364
+ return TREND_DOWN
365
+ return TREND_FLAT
366
+
367
+
368
+ def _compute_min_max_avg(snapshots: list, key: str, now_ts: int):
369
+ """Return (min, avg, max) over last 7 days, or (None, None, None) if no data."""
370
+ cutoff = now_ts - HISTORY_BUCKETS * SECONDS_PER_DAY
371
+ vals = []
372
+ for s in snapshots:
373
+ if not isinstance(s, dict):
374
+ continue
375
+ ts = s.get("ts")
376
+ v = s.get(key)
377
+ if not isinstance(ts, (int, float)) or v is None or ts < cutoff or ts > now_ts:
378
+ continue
379
+ try:
380
+ vals.append(float(v))
381
+ except (TypeError, ValueError):
382
+ continue
383
+ if not vals:
384
+ return None, None, None
385
+ return min(vals), sum(vals) / len(vals), max(vals)
386
+
387
+
388
+ def _get_oauth_data() -> Optional[dict]:
389
+ """Read Claude Code's OAuth credentials from keychain or file. Returns the full claudeAiOauth dict."""
390
+ user = os.environ.get("USER", "")
391
+ try:
392
+ out = subprocess.check_output(
393
+ ["security", "find-generic-password",
394
+ "-s", KEYCHAIN_SERVICE, "-a", user, "-w"],
395
+ stderr=subprocess.DEVNULL,
396
+ timeout=KEYCHAIN_TIMEOUT,
397
+ )
398
+ data = json.loads(out)
399
+ oauth_data = data.get("claudeAiOauth")
400
+ if oauth_data:
401
+ return oauth_data
402
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired,
403
+ json.JSONDecodeError):
404
+ pass
405
+ if CREDENTIALS_FILE.exists():
406
+ try:
407
+ with open(CREDENTIALS_FILE, "r", encoding="utf-8") as f:
408
+ data = json.load(f)
409
+ return data.get("claudeAiOauth")
410
+ except (OSError, json.JSONDecodeError):
411
+ return None
412
+ return None
413
+
414
+
415
+ def get_access_token() -> Optional[str]:
416
+ """Read Claude Code's OAuth access token. Keychain wins; file is fallback."""
417
+ oauth_data = _get_oauth_data()
418
+ return oauth_data.get("accessToken") if oauth_data else None
419
+
420
+
421
+ def fetch_usage(oauth_data: Optional[dict] = None):
422
+ """Returns (payload_dict, error_str, is_rate_limited). One of payload/error is always None."""
423
+ token = oauth_data.get("accessToken") if oauth_data else None
424
+ if not token:
425
+ return None, "no Claude Code token", False
426
+ req = urllib.request.Request(
427
+ USAGE_URL,
428
+ headers={
429
+ "Authorization": f"Bearer {token}",
430
+ "anthropic-beta": OAUTH_API_VERSION,
431
+ "User-Agent": "claude-limit-app/1.0",
432
+ },
433
+ )
434
+ try:
435
+ with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp:
436
+ return json.loads(resp.read().decode("utf-8")), None, False
437
+ except urllib.error.HTTPError as e:
438
+ if e.code == HTTP_STATUS_RATE_LIMITED:
439
+ return None, "rate limited — data is stale, retrying soon", True
440
+ if e.code == HTTP_STATUS_UNAUTHORIZED:
441
+ return None, "auth expired (run `claude` to refresh)", False
442
+ return None, f"HTTP {e.code}", False
443
+ except urllib.error.URLError as e:
444
+ return None, f"net: {e.reason}", False
445
+ except Exception as e: # noqa: BLE001
446
+ return None, str(e), False
447
+
448
+
449
+ def bar(pct: Optional[float], width: int = BAR_WIDTH) -> str:
450
+ """Solid Unicode progress bar."""
451
+ if pct is None:
452
+ return "─" * width
453
+ pct = max(0.0, min(100.0, float(pct)))
454
+ full = int(round(pct / 100.0 * width))
455
+ return "█" * full + "░" * (width - full)
456
+
457
+
458
+ def status_emoji(pct: Optional[float]) -> str:
459
+ """Colored circle matching the title-bar tint."""
460
+ if pct is None:
461
+ return EMOJI_UNKNOWN
462
+ if pct < WARN_PCT:
463
+ return EMOJI_OK
464
+ if pct < CRIT_PCT:
465
+ return EMOJI_WARN
466
+ return EMOJI_CRITICAL
467
+
468
+
469
+ def status_color(pct: Optional[float]):
470
+ """NSColor for the menu-bar bar segment. Adapts to light/dark mode."""
471
+ if not _HAS_APPKIT:
472
+ return None
473
+ if pct is None:
474
+ return NSColor.labelColor()
475
+ if pct < WARN_PCT:
476
+ return NSColor.systemGreenColor()
477
+ if pct < CRIT_PCT:
478
+ return NSColor.systemYellowColor()
479
+ return NSColor.systemRedColor()
480
+
481
+
482
+ def _utc_now() -> datetime:
483
+ """Return current time in UTC timezone."""
484
+ return datetime.now(timezone.utc)
485
+
486
+
487
+ def _parse_reset_time(reset_iso: Optional[str]) -> Optional[datetime]:
488
+ """Parse ISO datetime string (with Z suffix) to timezone-aware datetime, or None."""
489
+ if not reset_iso:
490
+ return None
491
+ try:
492
+ dt = datetime.fromisoformat(reset_iso.replace("Z", "+00:00"))
493
+ if dt.tzinfo is None:
494
+ dt = dt.replace(tzinfo=timezone.utc)
495
+ return dt
496
+ except (ValueError, AttributeError):
497
+ return None
498
+
499
+
500
+ def fmt_remaining(reset_iso: Optional[str]) -> str:
501
+ reset = _parse_reset_time(reset_iso)
502
+ if reset is None:
503
+ return UNAVAILABLE
504
+ secs = int((reset - _utc_now()).total_seconds())
505
+ if secs <= 0:
506
+ return "ready"
507
+ d, secs = divmod(secs, SECONDS_PER_DAY)
508
+ h, secs = divmod(secs, SECONDS_PER_HOUR)
509
+ m = secs // SECONDS_PER_MINUTE
510
+ if d:
511
+ return f"{d}d{h}h"
512
+ if h:
513
+ return f"{h}h{m:02d}m"
514
+ return f"{m}m"
515
+
516
+
517
+ def fmt_reset_clock(reset_iso: Optional[str]) -> str:
518
+ reset = _parse_reset_time(reset_iso)
519
+ if reset is None:
520
+ return UNAVAILABLE
521
+ if (reset - _utc_now()).total_seconds() <= 0:
522
+ return "ready"
523
+ return reset.astimezone().strftime("%a %H:%M")
524
+
525
+
526
+ def _calc_backoff(consecutive_failures: int) -> int:
527
+ """Exponential backoff capped at REFRESH_MAX_BACKOFF (default 30 minutes)."""
528
+ return min(2 ** min(consecutive_failures, BACKOFF_EXP_CAP), REFRESH_MAX_BACKOFF)
529
+
530
+
531
+ def _fmt_pct(pct: float) -> str:
532
+ """Format percentage with no decimal places (e.g., '45%')."""
533
+ return f"{pct:.0f}%"
534
+
535
+
536
+ def _fmt_credits(used: float, cap: float, currency: str) -> str:
537
+ """Format credit display (e.g., '5.00/10.00 USD')."""
538
+ if currency:
539
+ return f"{used:.2f}/{cap:.2f} {currency}"
540
+ return f"{used:.2f}/{cap:.2f}"
541
+
542
+
543
+ class ClaudeMonitorApp(rumps.App):
544
+ def __init__(self):
545
+ super().__init__(APP_NAME, title=f"{MENU_ICON}{APP_NAME}")
546
+
547
+ self.current_view = DEFAULT_VIEW
548
+ self.show_reset_time = True # toggle between reset time vs elapsed time
549
+ self.show_history = False # opt-in: 7-day sparkline + min/avg/max rows
550
+ self._latest = None # last good payload
551
+ self._latest_error = None # last error string
552
+ self._latest_at = None # datetime of last good payload
553
+ self._latest_plan = "" # cached plan info (don't re-read on rate limit)
554
+ self._is_rate_limited = False # distinguish 429 from other errors
555
+ self._pending = None # (payload, error, is_rate_limited) staged from worker
556
+ self._pending_lock = threading.Lock()
557
+ self._wake = threading.Event()
558
+ self._backoff_until = 0 # unix timestamp; worker won't poll until after this
559
+ self._history_lock = threading.Lock()
560
+ self._history = _load_history()
561
+
562
+ # Detail rows: one per known view (clickable to switch)
563
+ self._detail_items = {}
564
+ for label, key in VIEWS:
565
+ item = rumps.MenuItem(label, callback=self._make_switcher(key))
566
+ self._detail_items[key] = item
567
+
568
+ self.extra_item = rumps.MenuItem(f"{EXTRA_USAGE_LABEL}{UNAVAILABLE}")
569
+ self.tier_item = rumps.MenuItem(f"{PLAN_LABEL}{UNAVAILABLE}")
570
+
571
+ # Preference submenu for time display format + history toggle + autostart
572
+ self.pref_reset = rumps.MenuItem(PREF_LABEL_RESET_TIME, callback=self._set_show_reset_time)
573
+ self.pref_elapsed = rumps.MenuItem(PREF_LABEL_ELAPSED_TIME, callback=self._set_show_elapsed_time)
574
+ self.pref_history = rumps.MenuItem(PREF_LABEL_HISTORY, callback=self._toggle_show_history)
575
+ self.pref_launch = rumps.MenuItem(PREF_LABEL_LAUNCH_AT_STARTUP, callback=self._toggle_launch_at_startup)
576
+ self.pref_reset.state = 1 # selected by default
577
+ self.pref_history.state = 0 # off by default
578
+ self.pref_launch.state = 1 if _launchagent_installed() else 0
579
+ self._pref_menu = rumps.MenuItem("Preference")
580
+ self._pref_menu.add(self.pref_reset)
581
+ self._pref_menu.add(self.pref_elapsed)
582
+ self._pref_menu.add(rumps.separator)
583
+ self._pref_menu.add(self.pref_history)
584
+ self._pref_menu.add(self.pref_launch)
585
+
586
+ self.last_update_item = rumps.MenuItem(f"{UPDATE_LABEL}{UNAVAILABLE}")
587
+ self.history_item = rumps.MenuItem(HISTORY_COLLECTING)
588
+ self.stats_item = rumps.MenuItem(HISTORY_COLLECTING)
589
+ self._dashboard_item = rumps.MenuItem("Open Claude dashboard…", callback=self._open_dashboard)
590
+ self._refresh_item = rumps.MenuItem("Refresh now", callback=self._manual_refresh)
591
+
592
+ self.menu = self._build_menu_items()
593
+
594
+ self.title = f"{MENU_ICON}{LOADING_TEXT}"
595
+
596
+ self._worker = threading.Thread(target=self._worker_loop, daemon=True)
597
+ self._worker.start()
598
+
599
+ self._apply_timer = rumps.Timer(self._apply_pending, TIMER_INTERVAL)
600
+ self._apply_timer.start()
601
+
602
+ # ----- worker / dispatch -----
603
+
604
+ def _worker_loop(self):
605
+ consecutive_failures = 0
606
+ while True:
607
+ now = time.time()
608
+ if now < self._backoff_until:
609
+ self._wake.wait(timeout=min(REFRESH_SECONDS, self._backoff_until - now))
610
+ self._wake.clear()
611
+ continue
612
+
613
+ oauth_data = _get_oauth_data()
614
+ payload, err, is_rate_limited = fetch_usage(oauth_data)
615
+ # Drop any Refresh-now clicks that arrived during the poll — the
616
+ # in-flight poll already satisfies them, and re-polling immediately
617
+ # can trip the OAuth endpoint's rate limit.
618
+ self._wake.clear()
619
+ with self._pending_lock:
620
+ self._pending = (payload, err, is_rate_limited, oauth_data)
621
+
622
+ if payload is not None:
623
+ consecutive_failures = 0
624
+ self._backoff_until = 0
625
+ self._record_snapshot(payload)
626
+ elif is_rate_limited:
627
+ consecutive_failures += 1
628
+ backoff = _calc_backoff(consecutive_failures)
629
+ self._backoff_until = time.time() + backoff
630
+
631
+ self._wake.wait(timeout=REFRESH_SECONDS)
632
+ self._wake.clear()
633
+
634
+ def _apply_pending(self, _sender):
635
+ with self._pending_lock:
636
+ staged = self._pending
637
+ self._pending = None
638
+ if staged is not None:
639
+ payload, err, is_rate_limited, oauth_data = staged
640
+ # Plan info comes from the keychain oauth_data, not the API
641
+ # response — refresh it on every poll where we have keychain
642
+ # data, even if the API fetch failed (e.g. 429 on first poll).
643
+ if oauth_data:
644
+ self._latest_plan = self._read_plan_info(oauth_data)
645
+ if payload is not None:
646
+ self._latest = payload
647
+ self._latest_error = None
648
+ self._is_rate_limited = False
649
+ self._latest_at = _utc_now()
650
+ else:
651
+ self._latest_error = err
652
+ self._is_rate_limited = is_rate_limited
653
+ # Re-render every tick so elapsed-time displays ("in 5h10m") tick down
654
+ # between polls instead of freezing for the full REFRESH_SECONDS interval.
655
+ self._render()
656
+
657
+ def _manual_refresh(self, _sender):
658
+ self._wake.set()
659
+
660
+ def _record_snapshot(self, payload):
661
+ """Append a snapshot of the just-fetched payload, dedupe, persist atomically."""
662
+ s, w, ws = _extract_pcts(payload)
663
+ snapshot = {
664
+ "ts": int(time.time()),
665
+ "session_pct": s,
666
+ "weekly_pct": w,
667
+ "weekly_sonnet_pct": ws,
668
+ }
669
+ with self._history_lock:
670
+ if self._history:
671
+ last = self._history[-1] if isinstance(self._history[-1], dict) else {}
672
+ last_ts = last.get("ts")
673
+ if (last.get("session_pct") == s
674
+ and last.get("weekly_pct") == w
675
+ and last.get("weekly_sonnet_pct") == ws
676
+ and isinstance(last_ts, (int, float))
677
+ and snapshot["ts"] - int(last_ts) < HISTORY_DEDUPE_SECONDS):
678
+ return
679
+ self._history.append(snapshot)
680
+ if len(self._history) > HISTORY_MAX_ENTRIES:
681
+ del self._history[:len(self._history) - HISTORY_MAX_ENTRIES]
682
+ entries_to_save = list(self._history)
683
+ try:
684
+ _save_history_atomic(entries_to_save)
685
+ except OSError as e:
686
+ print(f"history: write failed: {e}", file=sys.stderr)
687
+
688
+ # ----- view switching -----
689
+
690
+ def _make_switcher(self, key):
691
+ def _cb(_sender):
692
+ self.current_view = key
693
+ self._render()
694
+ return _cb
695
+
696
+ def _open_dashboard(self, _sender):
697
+ url = os.environ.get(ENV_DASHBOARD_URL, DASHBOARD_URL)
698
+ try:
699
+ subprocess.Popen(["open", url])
700
+ except OSError:
701
+ pass
702
+
703
+ def _set_show_reset_time(self, _sender):
704
+ self.show_reset_time = True
705
+ self._update_pref_states(True)
706
+ self._render()
707
+
708
+ def _set_show_elapsed_time(self, _sender):
709
+ self.show_reset_time = False
710
+ self._update_pref_states(False)
711
+ self._render()
712
+
713
+ def _update_pref_states(self, reset_selected: bool):
714
+ """Update checkbox states for reset time / elapsed time preferences."""
715
+ self.pref_reset.state = 1 if reset_selected else 0
716
+ self.pref_elapsed.state = 0 if reset_selected else 1
717
+
718
+ def _build_menu_items(self):
719
+ """Build the menu list, conditionally including the 7-day history rows."""
720
+ items = [
721
+ self._detail_items["five_hour"],
722
+ self._detail_items["seven_day"],
723
+ self._detail_items["seven_day_sonnet"],
724
+ None,
725
+ ]
726
+ if self.show_history:
727
+ items.extend([self.history_item, self.stats_item, None])
728
+ items.extend([
729
+ self.tier_item,
730
+ self.extra_item,
731
+ None,
732
+ self._pref_menu,
733
+ self._dashboard_item,
734
+ None,
735
+ self.last_update_item,
736
+ self._refresh_item,
737
+ ])
738
+ return items
739
+
740
+ def _toggle_show_history(self, _sender):
741
+ self.show_history = not self.show_history
742
+ self.pref_history.state = 1 if self.show_history else 0
743
+ self.menu.clear()
744
+ for item in self._build_menu_items():
745
+ self.menu.add(rumps.separator if item is None else item)
746
+ self._render()
747
+
748
+ def _toggle_launch_at_startup(self, _sender):
749
+ if _launchagent_installed():
750
+ _uninstall_launchagent()
751
+ msg = "tokenmaxxing will no longer start automatically on login."
752
+ else:
753
+ _install_launchagent()
754
+ msg = "tokenmaxxing will start automatically on next login."
755
+ self.pref_launch.state = 1 if _launchagent_installed() else 0
756
+ # Notification is best-effort: fails silently in unbundled apps
757
+ try:
758
+ rumps.notification("tokenmaxxing", "Launch at login", msg)
759
+ except Exception: # noqa: BLE001
760
+ pass
761
+
762
+ # ----- rendering -----
763
+
764
+ def _get_time_value(self, resets_at: Optional[str]) -> str:
765
+ """Format time display based on preference: reset time or remaining time."""
766
+ if self.show_reset_time:
767
+ return fmt_reset_clock(resets_at)
768
+ return fmt_remaining(resets_at)
769
+
770
+ def _fmt_time_display(self, resets_at: Optional[str]) -> str:
771
+ """Format time display with prefix: 'resets at HH:MM' or 'in Xh Xm'."""
772
+ time_value = self._get_time_value(resets_at)
773
+ if time_value in (UNAVAILABLE, "ready"):
774
+ return time_value
775
+ if self.show_reset_time:
776
+ return f"{TIME_DISPLAY_RESETS}{time_value}"
777
+ return f"{TIME_DISPLAY_IN}{time_value}"
778
+
779
+ def _fmt_update_time(self) -> str:
780
+ """Format the last update timestamp or unavailable marker."""
781
+ if self._latest_at:
782
+ return self._latest_at.astimezone().strftime(UPDATE_TIME_FORMAT)
783
+ return UNAVAILABLE
784
+
785
+ def _fmt_extra_usage(self, extra: Optional[dict]) -> str:
786
+ """Format extra usage menu item from payload dict."""
787
+ extra = extra if isinstance(extra, dict) else {}
788
+ if not extra.get("is_enabled"):
789
+ return f"{EXTRA_USAGE_LABEL}{EXTRA_USAGE_OFF}"
790
+ parts = []
791
+ util = extra.get("utilization")
792
+ if util is not None:
793
+ parts.append(_fmt_pct(util))
794
+ used = extra.get("used_credits")
795
+ cap = extra.get("monthly_limit")
796
+ if used is not None and cap is not None:
797
+ currency = extra.get("currency") or ""
798
+ parts.append(_fmt_credits(used, cap, currency))
799
+ return EXTRA_USAGE_LABEL + (SEPARATOR.join(parts) or EXTRA_USAGE_ENABLED)
800
+
801
+ def _get_status_suffix(self) -> str:
802
+ """Return status suffix: ' [rate limited]', ' (stale)', or ''."""
803
+ if self._is_rate_limited:
804
+ return STATUS_RATE_LIMITED
805
+ if self._latest_error:
806
+ return STATUS_STALE
807
+ return ""
808
+
809
+ def _view_is_available(self, view) -> bool:
810
+ """Check if a view dict has valid utilization data."""
811
+ return isinstance(view, dict) and view.get("utilization") is not None
812
+
813
+ def _status_button(self):
814
+ """Reach the underlying NSStatusBarButton through rumps' wrapper."""
815
+ try:
816
+ return self._nsapp.nsstatusitem.button()
817
+ except Exception: # noqa: BLE001
818
+ return None
819
+
820
+ def _set_title(self, prefix: str, bar_text: str, tail: str, pct: Optional[float]):
821
+ """Set the menu-bar title with the bar segment tinted by `pct`.
822
+
823
+ Falls back to plain title if AppKit / the status button isn't ready
824
+ yet (e.g. before `run()` finishes wiring up).
825
+ """
826
+ full = f"{prefix}{bar_text}{tail}"
827
+ if _HAS_APPKIT:
828
+ button = self._status_button()
829
+ if button is not None:
830
+ try:
831
+ attr = NSMutableAttributedString.alloc().initWithString_(full)
832
+ color = status_color(pct)
833
+ filled_len = bar_text.count("█")
834
+ if color is not None and filled_len > 0:
835
+ attr.addAttribute_value_range_(
836
+ NSForegroundColorAttributeName,
837
+ color,
838
+ (len(prefix), filled_len),
839
+ )
840
+ # The empty `░` segment inherits the default label color
841
+ # (white on dark menu bar, black on light) — no override.
842
+ button.setAttributedTitle_(attr)
843
+ return
844
+ except Exception: # noqa: BLE001
845
+ pass
846
+ self.title = full
847
+
848
+ def _render_history(self):
849
+ """Refresh the sparkline+trend row and the min/avg/max row from in-memory history."""
850
+ with self._history_lock:
851
+ snapshots = list(self._history)
852
+ key = HISTORY_KEY_BY_VIEW.get(self.current_view, "session_pct")
853
+ now_ts = int(time.time())
854
+ spark = _render_sparkline(snapshots, key, now_ts) if snapshots else ""
855
+ if spark:
856
+ trend = _compute_trend(snapshots, key, now_ts)
857
+ self.history_item.title = (
858
+ f"{HISTORY_LABEL}{spark} {trend}".rstrip() if trend
859
+ else f"{HISTORY_LABEL}{spark}"
860
+ )
861
+ else:
862
+ self.history_item.title = HISTORY_COLLECTING
863
+ mn, avg, mx = _compute_min_max_avg(snapshots, key, now_ts)
864
+ if mn is None:
865
+ # Differentiate from history_item's "7d: collecting…" so the two
866
+ # empty-state rows don't render identically next to each other.
867
+ self.stats_item.title = (
868
+ f"{HISTORY_LABEL}min {UNAVAILABLE}{SEPARATOR}"
869
+ f"avg {UNAVAILABLE}{SEPARATOR}max {UNAVAILABLE}"
870
+ )
871
+ else:
872
+ self.stats_item.title = (
873
+ f"{HISTORY_LABEL}min {_fmt_pct(mn)}{SEPARATOR}"
874
+ f"avg {_fmt_pct(avg)}{SEPARATOR}max {_fmt_pct(mx)}"
875
+ )
876
+
877
+ def _render(self):
878
+ payload = self._latest
879
+ err = self._latest_error
880
+ status_suffix = self._get_status_suffix()
881
+ if self.show_history:
882
+ self._render_history()
883
+
884
+ # No cached data yet: loading state (err is None) or error state (err is not None).
885
+ # Either way, render markers/emoji on detail items so the user can see which view
886
+ # is being tracked even before the first poll lands.
887
+ if payload is None:
888
+ if err is not None:
889
+ self._set_title(MENU_ICON, "", err, None)
890
+ for key, item in self._detail_items.items():
891
+ marker = MARKER_SELECTED if key == self.current_view else MARKER_UNSELECTED
892
+ item.title = f"{marker}{status_emoji(None)} {VIEW_LABEL[key]}: {UNAVAILABLE}"
893
+ self.extra_item.title = f"{EXTRA_USAGE_LABEL}{UNAVAILABLE}"
894
+ self.tier_item.title = f"{PLAN_LABEL}{self._latest_plan or UNAVAILABLE}"
895
+ self.last_update_item.title = f"{UPDATE_LABEL}{NEVER_UPDATED}"
896
+ return
897
+
898
+ # menu-bar title from current_view
899
+ view = payload.get(self.current_view)
900
+ prefix = VIEW_PREFIX.get(self.current_view, "")
901
+
902
+ if self._view_is_available(view):
903
+ pct = float(view["utilization"])
904
+ time_display = self._get_time_value(view.get("resets_at"))
905
+ bar_text = bar(pct)
906
+ tail = f" {_fmt_pct(pct)}{SEPARATOR}{time_display}{status_suffix}"
907
+ self._set_title(prefix, bar_text, tail, pct)
908
+ else:
909
+ self._set_title(prefix, bar(None), f" {VIEW_UNAVAILABLE}{status_suffix}", None)
910
+
911
+ # Detail rows for every known view
912
+ for key, item in self._detail_items.items():
913
+ v = payload.get(key)
914
+ label = VIEW_LABEL[key]
915
+ marker = MARKER_SELECTED if key == self.current_view else MARKER_UNSELECTED
916
+ if not self._view_is_available(v):
917
+ item.title = f"{marker}{status_emoji(None)} {label}: {VIEW_UNAVAILABLE}{status_suffix}"
918
+ continue
919
+ pct = v.get("utilization")
920
+ resets = v.get("resets_at")
921
+ emoji = status_emoji(pct)
922
+ time_info = self._fmt_time_display(resets)
923
+ item.title = (
924
+ f"{marker}{emoji} {label}{DETAIL_ITEM_SPACER}{bar(pct)} {_fmt_pct(pct)}{DETAIL_ITEM_SPACER}"
925
+ f"{time_info}{status_suffix}"
926
+ )
927
+
928
+ self.extra_item.title = self._fmt_extra_usage(payload.get("extra_usage")) + status_suffix
929
+
930
+ # Plan info — use cached value (only updated on successful fetch)
931
+ plan_text = self._latest_plan or UNAVAILABLE
932
+ self.tier_item.title = f"{PLAN_LABEL}{plan_text}{status_suffix}"
933
+
934
+ self.last_update_item.title = f"{UPDATE_LABEL}{self._fmt_update_time()}{status_suffix}"
935
+
936
+ def _read_plan_info(self, oauth_data: Optional[dict] = None) -> str:
937
+ """Format plan and tier info from OAuth data. Returns empty string if unavailable."""
938
+ if not oauth_data:
939
+ return ""
940
+
941
+ sub = oauth_data.get("subscriptionType") or ""
942
+ tier = oauth_data.get("rateLimitTier") or ""
943
+
944
+ sub_name = SUB_TYPE_MAP.get(sub, sub)
945
+ tier_name = RATE_LIMIT_TIER_MAP.get(tier, tier)
946
+
947
+ return SEPARATOR.join(p for p in (sub_name, tier_name) if p)
948
+
949
+
950
+ def main():
951
+ """Entry point for the `tokenmaxxing` console script."""
952
+ ClaudeMonitorApp().run()
953
+
954
+
955
+ if __name__ == "__main__":
956
+ main()
@@ -0,0 +1,171 @@
1
+ Metadata-Version: 2.4
2
+ Name: tokenmaxxing
3
+ Version: 0.1.0
4
+ Summary: Menu bar app showing your live Claude Code session and weekly usage as a colored progress bar.
5
+ Project-URL: Homepage, https://github.com/alvations/tokenmaxxing
6
+ Project-URL: Repository, https://github.com/alvations/tokenmaxxing
7
+ Project-URL: Issues, https://github.com/alvations/tokenmaxxing/issues
8
+ Author-email: alvations <alvations@gmail.com>
9
+ License-Expression: Apache-2.0
10
+ License-File: LICENSE
11
+ Keywords: anthropic,claude,claude-code,menu-bar,rate-limit,usage
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Desktop Environment
21
+ Classifier: Topic :: Software Development
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.9
24
+ Requires-Dist: rumps>=0.4.0
25
+ Description-Content-Type: text/markdown
26
+
27
+ # tokenmaxxing
28
+
29
+ A menu bar app that shows your live Claude Code session and weekly usage as a colored progress bar — sitting next to the system clock so you always know how close you are to a rate-limit reset.
30
+
31
+ ```
32
+ Session ███░░░░░ 38% · resets at Fri 07:00
33
+ Weekly ███████░ 87% · resets at Fri 09:00
34
+ ```
35
+
36
+ The bar is tinted by utilization:
37
+
38
+ | color | range | meaning |
39
+ | ----- | ------ | ----------------------------------- |
40
+ | ⚪ | n/a | no active window / data unavailable |
41
+ | 🟢 | <50% | plenty of headroom |
42
+ | 🟡 | 50–79% | getting close |
43
+ | 🔴 | ≥80% | reaching the limit very soon |
44
+
45
+ Only the filled `█` segment is colored; the empty `░` segment stays in the menu bar's default text color (white on dark, dark on light).
46
+
47
+ ## Install
48
+
49
+ Requires **Python 3.9+**.
50
+
51
+ ```sh
52
+ pip install tokenmaxxing
53
+ ```
54
+
55
+ This pulls in [`rumps`](https://github.com/jaredks/rumps) (which in turn pulls in PyObjC).
56
+
57
+ ## Run
58
+
59
+ ```sh
60
+ tokenmaxxing
61
+ ```
62
+
63
+ Or as a module:
64
+
65
+ ```sh
66
+ python -m tokenmaxxing
67
+ ```
68
+
69
+ Detached (so the menu bar app survives the launching shell):
70
+
71
+ ```sh
72
+ nohup tokenmaxxing >/dev/null 2>&1 </dev/null &!
73
+ ```
74
+
75
+ ## Autostart
76
+
77
+ Pick **one** of the two options below. Don't enable both — you'll get two instances of the app.
78
+
79
+ ### Option 1 — Launch at login
80
+
81
+ Open the menu bar dropdown → **Preference** → check **Launch at login**.
82
+
83
+ This writes a `~/Library/LaunchAgents/com.tokenmaxxing.app.plist` and a tiny wrapper script at `~/.tokenmaxxing/tokenmaxxing` (so System Settings → Login Items shows the friendly name, not "python3.9"). The system's `launchd` reads the plist on every login and starts the app. Uncheck the menu item to remove both files.
84
+
85
+ > **Heads up — Gatekeeper warning.** Your system will show "tokenmaxxing is an item from an unidentified developer" the first time it's added to login items. This is unavoidable for any unsigned tool (code signing requires a paid Apple Developer account). To allow it: **System Settings → Privacy & Security → scroll to the bottom → click "Allow Anyway"**. Subsequent boots skip the prompt. If that friction bothers you, use Option 2 instead — launching from a shell is treated as user-initiated and never triggers Gatekeeper.
86
+
87
+ ### Option 2 — Launch on first interactive shell (terminal-driven)
88
+
89
+ Append this guarded launcher to `~/.zshrc`:
90
+
91
+ ```sh
92
+ # tokenmaxxing — menu bar app showing Claude Code session/weekly usage
93
+ if [[ -o interactive ]] && ! pgrep -f "tokenmaxxing" >/dev/null 2>&1; then
94
+ nohup tokenmaxxing >/dev/null 2>&1 </dev/null &!
95
+ fi
96
+ ```
97
+
98
+ The `pgrep` guard keeps it singleton across nested shells. The `&!` (zsh) detaches and disowns. The first interactive shell of the day starts it; subsequent shells skip via the guard.
99
+
100
+ ## How it works
101
+
102
+ It polls Anthropic's OAuth usage endpoint — the same call Claude Code makes internally:
103
+
104
+ ```
105
+ GET https://api.anthropic.com/api/oauth/usage
106
+ Authorization: Bearer <token>
107
+ anthropic-beta: oauth-2025-04-20
108
+ ```
109
+
110
+ The OAuth access token is read from your system keychain (service `Claude Code-credentials`) with `~/.claude/.credentials.json` as fallback. Polling consumes **zero tokens** — it returns metadata only:
111
+
112
+ ```json
113
+ {
114
+ "five_hour": {"utilization": 38, "resets_at": "2026-05-01T11:00:00Z"},
115
+ "seven_day": {"utilization": 87, "resets_at": "2026-05-01T13:00:00Z"},
116
+ "seven_day_opus": null,
117
+ "seven_day_sonnet": {"utilization": 100, "resets_at": "2026-05-01T13:00:00Z"},
118
+ "extra_usage": {"is_enabled": false}
119
+ }
120
+ ```
121
+
122
+ Refresh runs on a background thread every 5 minutes (the OAuth endpoint has aggressive undocumented rate limits, so polling more frequently triggers `429`s). Token refresh is handled by Claude Code itself — when `claude` runs, it rotates the keychain entry and the next poll picks it up automatically.
123
+
124
+ ## Dropdown menu
125
+
126
+ ```
127
+ ✓ 🟢 Session (5h) ███░░░░░ 38% resets at Fri 07:00
128
+ 🔴 Weekly (7d) ███████░ 87% resets at Fri 09:00
129
+ 🔴 Weekly Sonnet ████████ 100% resets at Fri 09:00
130
+ ─────────────
131
+ Plan: Claude Max · Tier 2
132
+ Extra usage: off
133
+ ─────────────
134
+ Preference ▸ ← reset-time vs elapsed-time, opt-in 7-day history
135
+ Open Claude dashboard…
136
+ ─────────────
137
+ Updated: 14:32:01
138
+ Refresh now
139
+ Quit
140
+ ```
141
+
142
+ Click any view row to switch which window the menu-bar bar tracks. The `✓` marker shows the active view.
143
+
144
+ ## Preferences
145
+
146
+ Open the **Preference** submenu:
147
+
148
+ - **Show "resets at Fri 07:00"** / **Show "resets in 4h10m"** — radio toggle for time format
149
+ - **Show 7-day history** — opt-in checkbox; when on, two extra rows appear in the dropdown:
150
+ - `7d: ▁▂▃▄▅▆▇█ ↑` — sparkline of utilization over the last 7 days, plus a trend arrow comparing the last 24h vs the prior 24h (±3pp threshold)
151
+ - `7d: min 12% · avg 47% · max 89%` — min / avg / max for the same window
152
+ - **Launch at login** — opt-in checkbox; installs/removes a LaunchAgent plist at `~/Library/LaunchAgents/com.tokenmaxxing.app.plist`. Takes effect on next login.
153
+
154
+ The history is recorded continuously to `~/.tokenmaxxing/history.json` (one snapshot per successful poll, capped at 2000 rolling entries) regardless of whether you display it — so when you turn it on, data is already there.
155
+
156
+ When the most recent poll has failed (network blip, transient error) but a previous successful poll's data is still cached, every row shows a `(stale)` suffix. When the OAuth endpoint is rate-limiting, you see `[rate limited]` instead.
157
+
158
+ ## Configuration (env vars)
159
+
160
+ - `CLAUDE_DASHBOARD_URL` — URL for the "Open Claude dashboard…" item (default `https://claude.ai/settings/usage`)
161
+
162
+ ## Implementation notes
163
+
164
+ - **Why not parse `~/.claude/projects/*.jsonl`?** Tried first, scrapped. Even with `os.scandir` + per-file mtime cache, parsing was lossy (no auth signal, no plan-aware caps) and stale (only sees what Claude Code chose to log). The OAuth endpoint is authoritative and free.
165
+ - **Why two threads?** AppKit calls (setting menu/title) must happen on the main thread; HTTP calls shouldn't block it. A daemon worker fetches usage every 5 min and stages the result; a 1s `rumps.Timer` on the main thread applies whatever's staged so countdowns tick smoothly.
166
+ - **Coloring:** `NSMutableAttributedString` with `NSForegroundColorAttributeName` applied only to the leading `█` chars. `NSColor.system{Green,Yellow,Red}Color` adapt to dark/light mode. The empty `░` segment is left untouched so it inherits the default `labelColor`.
167
+ - **Backoff:** consecutive 429s back off exponentially up to 30 minutes between polls before retrying.
168
+
169
+ ## License
170
+
171
+ Apache-2.0
@@ -0,0 +1,8 @@
1
+ tokenmaxxing/__init__.py,sha256=MjbnSULsYGhfRHrncNj81PfQi7n7K4h1dygD3iSCH9s,171
2
+ tokenmaxxing/__main__.py,sha256=FYsLMI6trGBYzcOXIMU1FWTtg2UeqC1EykaSJF8xv9g,140
3
+ tokenmaxxing/app.py,sha256=cCQde-DqkAwDMaSk7s8k6I_xTlwC6QUNJEtSkSlL87o,35325
4
+ tokenmaxxing-0.1.0.dist-info/METADATA,sha256=vBT4yymqbW7YH3eAZAhr1QuJ7UemVaDkw9taT4rcaNA,7970
5
+ tokenmaxxing-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
6
+ tokenmaxxing-0.1.0.dist-info/entry_points.txt,sha256=GIYXbLZcBSlPmXKzWfTOgHQDAwl0t1h4ORFnYdt_inM,55
7
+ tokenmaxxing-0.1.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
8
+ tokenmaxxing-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tokenmaxxing = tokenmaxxing.app:main
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.