claude-limits-bar 1.2.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tilbert Balaban
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: claude-limits-bar
3
+ Version: 1.2.0
4
+ Summary: Claude Code limits and reset times in the macOS menu bar
5
+ Author: Tilbert Balaban
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/TilbertBalaban/claude-limits-bar
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: pyobjc-framework-Cocoa>=9.0
12
+ Requires-Dist: certifi
13
+ Dynamic: license-file
14
+
15
+ # claude-limits-bar
16
+
17
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](LICENSE)
18
+
19
+ **Claude Code limits and their reset times, always visible in the macOS menu bar.**
20
+
21
+ If you keep hitting *"Too many requests, please try again later"*, this shows you exactly how close you are to the limit and when it resets — before Claude Code cuts you off.
22
+
23
+ The menu bar shows two ring gauges — the session (5-hour) limit in green and the 7-day limit in purple, percent used inside each — plus the countdown until the session limit resets:
24
+
25
+ ```
26
+ ◔66 ◔12 3h 26m
27
+ ```
28
+
29
+ Click it for the full picture: a large donut for the session limit (weekly as a thin outer arc) and one row per limit with its reset time:
30
+
31
+ ```
32
+ ✳ Claude Limits ($) (↻)
33
+ ╭─── 66% Used ───╮
34
+ ◔66 5-Hour Limit Today 6:10 PM
35
+ ◔12 7-Day Limit Sep 5 1 AM
36
+ ◔18 7-Day (Fable) Sep 5 1 AM
37
+ ```
38
+
39
+ Rings turn orange at 80% or when Anthropic reports elevated severity, and red with `!` at 100% — the session (5-hour) limit is the one that usually triggers "Too many requests".
40
+
41
+ ## How it works
42
+
43
+ Claude Code stores its OAuth token in the macOS Keychain (service `Claude Code-credentials`). This app reads that token and polls the same official endpoint the Claude apps use (`api.anthropic.com/api/oauth/usage`) once a minute. The token is sent **only** to `api.anthropic.com` — nowhere else, nothing is logged or stored.
44
+
45
+ Works on Pro and Max plans. Requires being signed in to Claude Code (`claude` → `/login`).
46
+
47
+ ## Install
48
+
49
+ Requires macOS.
50
+
51
+ ### Homebrew
52
+
53
+ ```
54
+ brew install tilbertbalaban/tap/claude-limits-bar
55
+ ```
56
+
57
+ ### uv / pipx (needs Python 3.9+)
58
+
59
+ ```
60
+ uv tool install claude-limits-bar
61
+ ```
62
+
63
+ (or `pipx install claude-limits-bar`)
64
+
65
+ Then:
66
+
67
+ ```
68
+ claude-limits-bar # run the menu bar app
69
+ claude-limits-bar autostart on # start automatically at login (LaunchAgent)
70
+ claude-limits-bar autostart off # remove the login item
71
+ claude-limits-bar status # print current limits to the terminal
72
+ ```
73
+
74
+ ## Updating
75
+
76
+ The app checks GitHub once a day and shows an **Update available** item in the menu when there's a newer version. To update:
77
+
78
+ ```
79
+ brew upgrade claude-limits-bar # Homebrew
80
+ uv tool upgrade claude-limits-bar # uv
81
+ pipx upgrade claude-limits-bar # pipx
82
+ ```
83
+
84
+ Then quit the app from its menu and start it again (`claude-limits-bar`, or log out/in if you use `autostart on`).
85
+
86
+ ## Support
87
+
88
+ If this app saves you from surprise rate limits, you can support development via the `$` button in the app or at [base.monobank.ua/tilbertbalaban](https://base.monobank.ua/tilbertbalaban).
89
+
90
+ ## Development
91
+
92
+ ```
93
+ git clone https://github.com/TilbertBalaban/claude-limits-bar
94
+ cd claude-limits-bar
95
+ python3 -m unittest discover -s tests -v
96
+ python3 -m claude_limits_bar.cli status
97
+ ```
98
+
99
+ The data layer ([claude_limits_bar/limits.py](claude_limits_bar/limits.py)) is stdlib-only and fully unit-tested; only the menu bar shell ([claude_limits_bar/menubar.py](claude_limits_bar/menubar.py)) depends on PyObjC (AppKit).
100
+
101
+ ## Troubleshooting
102
+
103
+ - **`✳ ?` in the menu bar** — open the menu for the reason: no credentials (sign in to Claude Code), an expired token (use Claude Code once — it refreshes the token itself), or no network.
104
+ - **`Too many requests` still happens at <100%** — the API reports utilization rounded to whole percent and with a small delay; treat the orange ring as "wrap up what you're doing".
@@ -0,0 +1,90 @@
1
+ # claude-limits-bar
2
+
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](LICENSE)
4
+
5
+ **Claude Code limits and their reset times, always visible in the macOS menu bar.**
6
+
7
+ If you keep hitting *"Too many requests, please try again later"*, this shows you exactly how close you are to the limit and when it resets — before Claude Code cuts you off.
8
+
9
+ The menu bar shows two ring gauges — the session (5-hour) limit in green and the 7-day limit in purple, percent used inside each — plus the countdown until the session limit resets:
10
+
11
+ ```
12
+ ◔66 ◔12 3h 26m
13
+ ```
14
+
15
+ Click it for the full picture: a large donut for the session limit (weekly as a thin outer arc) and one row per limit with its reset time:
16
+
17
+ ```
18
+ ✳ Claude Limits ($) (↻)
19
+ ╭─── 66% Used ───╮
20
+ ◔66 5-Hour Limit Today 6:10 PM
21
+ ◔12 7-Day Limit Sep 5 1 AM
22
+ ◔18 7-Day (Fable) Sep 5 1 AM
23
+ ```
24
+
25
+ Rings turn orange at 80% or when Anthropic reports elevated severity, and red with `!` at 100% — the session (5-hour) limit is the one that usually triggers "Too many requests".
26
+
27
+ ## How it works
28
+
29
+ Claude Code stores its OAuth token in the macOS Keychain (service `Claude Code-credentials`). This app reads that token and polls the same official endpoint the Claude apps use (`api.anthropic.com/api/oauth/usage`) once a minute. The token is sent **only** to `api.anthropic.com` — nowhere else, nothing is logged or stored.
30
+
31
+ Works on Pro and Max plans. Requires being signed in to Claude Code (`claude` → `/login`).
32
+
33
+ ## Install
34
+
35
+ Requires macOS.
36
+
37
+ ### Homebrew
38
+
39
+ ```
40
+ brew install tilbertbalaban/tap/claude-limits-bar
41
+ ```
42
+
43
+ ### uv / pipx (needs Python 3.9+)
44
+
45
+ ```
46
+ uv tool install claude-limits-bar
47
+ ```
48
+
49
+ (or `pipx install claude-limits-bar`)
50
+
51
+ Then:
52
+
53
+ ```
54
+ claude-limits-bar # run the menu bar app
55
+ claude-limits-bar autostart on # start automatically at login (LaunchAgent)
56
+ claude-limits-bar autostart off # remove the login item
57
+ claude-limits-bar status # print current limits to the terminal
58
+ ```
59
+
60
+ ## Updating
61
+
62
+ The app checks GitHub once a day and shows an **Update available** item in the menu when there's a newer version. To update:
63
+
64
+ ```
65
+ brew upgrade claude-limits-bar # Homebrew
66
+ uv tool upgrade claude-limits-bar # uv
67
+ pipx upgrade claude-limits-bar # pipx
68
+ ```
69
+
70
+ Then quit the app from its menu and start it again (`claude-limits-bar`, or log out/in if you use `autostart on`).
71
+
72
+ ## Support
73
+
74
+ If this app saves you from surprise rate limits, you can support development via the `$` button in the app or at [base.monobank.ua/tilbertbalaban](https://base.monobank.ua/tilbertbalaban).
75
+
76
+ ## Development
77
+
78
+ ```
79
+ git clone https://github.com/TilbertBalaban/claude-limits-bar
80
+ cd claude-limits-bar
81
+ python3 -m unittest discover -s tests -v
82
+ python3 -m claude_limits_bar.cli status
83
+ ```
84
+
85
+ The data layer ([claude_limits_bar/limits.py](claude_limits_bar/limits.py)) is stdlib-only and fully unit-tested; only the menu bar shell ([claude_limits_bar/menubar.py](claude_limits_bar/menubar.py)) depends on PyObjC (AppKit).
86
+
87
+ ## Troubleshooting
88
+
89
+ - **`✳ ?` in the menu bar** — open the menu for the reason: no credentials (sign in to Claude Code), an expired token (use Claude Code once — it refreshes the token itself), or no network.
90
+ - **`Too many requests` still happens at <100%** — the API reports utilization rounded to whole percent and with a small delay; treat the orange ring as "wrap up what you're doing".
@@ -0,0 +1 @@
1
+ VERSION = "1.2.0"
@@ -0,0 +1,79 @@
1
+ import argparse
2
+ import plistlib
3
+ import subprocess
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from . import VERSION
8
+ from .limits import (
9
+ CredentialsNotFound, TokenRejected, UsageRateLimited, get_limits, limit_line,
10
+ )
11
+
12
+ LAUNCH_AGENT = Path.home() / "Library" / "LaunchAgents" / "com.claude-limits-bar.plist"
13
+
14
+
15
+ def cmd_status() -> int:
16
+ try:
17
+ limits = get_limits()
18
+ except CredentialsNotFound:
19
+ print("No Claude Code credentials found — run `claude` and sign in first.")
20
+ return 1
21
+ except TokenRejected:
22
+ print("Stored token was rejected — use Claude Code once so it refreshes the token.")
23
+ return 1
24
+ except UsageRateLimited:
25
+ print("The usage API is rate-limited right now — try again in a minute.")
26
+ return 1
27
+ if not limits:
28
+ print("No limits reported for this account.")
29
+ return 0
30
+ for limit in limits:
31
+ print(limit_line(limit))
32
+ return 0
33
+
34
+
35
+ def cmd_autostart(state: str) -> int:
36
+ if state == "off":
37
+ if LAUNCH_AGENT.exists():
38
+ subprocess.run(["launchctl", "unload", str(LAUNCH_AGENT)], capture_output=True)
39
+ LAUNCH_AGENT.unlink()
40
+ print("Autostart disabled.")
41
+ return 0
42
+ executable = Path(sys.argv[0]).resolve()
43
+ plist = {
44
+ "Label": "com.claude-limits-bar",
45
+ "ProgramArguments": [str(executable)],
46
+ "RunAtLoad": True,
47
+ }
48
+ LAUNCH_AGENT.parent.mkdir(parents=True, exist_ok=True)
49
+ LAUNCH_AGENT.write_bytes(plistlib.dumps(plist))
50
+ subprocess.run(["launchctl", "unload", str(LAUNCH_AGENT)], capture_output=True)
51
+ subprocess.run(["launchctl", "load", str(LAUNCH_AGENT)], capture_output=True)
52
+ print("Autostart enabled (%s)." % LAUNCH_AGENT)
53
+ return 0
54
+
55
+
56
+ def main() -> int:
57
+ parser = argparse.ArgumentParser(
58
+ prog="claude-limits-bar",
59
+ description="Claude limits and reset times in the macOS menu bar.",
60
+ )
61
+ parser.add_argument("--version", action="version", version=VERSION)
62
+ sub = parser.add_subparsers(dest="command")
63
+ sub.add_parser("status", help="print current limits to the terminal")
64
+ autostart = sub.add_parser("autostart", help="start automatically at login")
65
+ autostart.add_argument("state", choices=["on", "off"])
66
+ args = parser.parse_args()
67
+
68
+ if args.command == "status":
69
+ return cmd_status()
70
+ if args.command == "autostart":
71
+ return cmd_autostart(args.state)
72
+
73
+ from .menubar import main as run_menubar
74
+ run_menubar()
75
+ return 0
76
+
77
+
78
+ if __name__ == "__main__":
79
+ sys.exit(main())
@@ -0,0 +1,234 @@
1
+ """Read Claude Code OAuth credentials and fetch the account's rate limits.
2
+
3
+ Standard library only, so everything here is unit-testable without a Mac GUI.
4
+ """
5
+
6
+ import json
7
+ import os
8
+ import ssl
9
+ import subprocess
10
+ import urllib.error
11
+ import urllib.request
12
+ from dataclasses import dataclass
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import List, Optional
16
+
17
+ USAGE_URL = "https://api.anthropic.com/api/oauth/usage"
18
+ KEYCHAIN_SERVICE = "Claude Code-credentials"
19
+ CREDENTIALS_FILE = Path.home() / ".claude" / ".credentials.json"
20
+ REQUEST_TIMEOUT = 15
21
+
22
+ WARN_PERCENT = 80
23
+
24
+
25
+ class CredentialsNotFound(Exception):
26
+ """No Claude Code OAuth token in the Keychain or ~/.claude/.credentials.json."""
27
+
28
+
29
+ class TokenRejected(Exception):
30
+ """The API returned 401/403 — the stored token is expired or revoked."""
31
+
32
+
33
+ class UsageRateLimited(Exception):
34
+ """The usage endpoint itself returned 429 — back off, keep last data."""
35
+
36
+
37
+ @dataclass
38
+ class Limit:
39
+ kind: str # "session", "weekly_all", "weekly_scoped", ...
40
+ label: str # human-readable, e.g. "Session (5h)"
41
+ percent: float
42
+ severity: str # "normal" | anything else means Anthropic flags it
43
+ resets_at: Optional[datetime]
44
+ is_active: bool
45
+
46
+ @property
47
+ def exhausted(self) -> bool:
48
+ return self.percent >= 100
49
+
50
+ @property
51
+ def warning(self) -> bool:
52
+ return self.percent >= WARN_PERCENT or self.severity not in ("normal", "")
53
+
54
+
55
+ def _read_keychain() -> Optional[str]:
56
+ try:
57
+ out = subprocess.run(
58
+ ["security", "find-generic-password", "-s", KEYCHAIN_SERVICE, "-w"],
59
+ capture_output=True, text=True, timeout=10,
60
+ )
61
+ except (OSError, subprocess.TimeoutExpired):
62
+ return None
63
+ return out.stdout.strip() if out.returncode == 0 and out.stdout.strip() else None
64
+
65
+
66
+ def _read_credentials_file() -> Optional[str]:
67
+ try:
68
+ return CREDENTIALS_FILE.read_text()
69
+ except OSError:
70
+ return None
71
+
72
+
73
+ def get_access_token() -> str:
74
+ raw = _read_keychain() or _read_credentials_file()
75
+ if not raw:
76
+ raise CredentialsNotFound()
77
+ return parse_access_token(raw)
78
+
79
+
80
+ def parse_access_token(raw: str) -> str:
81
+ try:
82
+ token = json.loads(raw).get("claudeAiOauth", {}).get("accessToken")
83
+ except (json.JSONDecodeError, AttributeError):
84
+ raise CredentialsNotFound()
85
+ if not token:
86
+ raise CredentialsNotFound()
87
+ return token
88
+
89
+
90
+ def _ssl_context() -> ssl.SSLContext:
91
+ ctx = ssl.create_default_context()
92
+ if ctx.cert_store_stats().get("x509_ca"):
93
+ return ctx
94
+ # python.org macOS builds ship without a CA bundle unless the user ran
95
+ # "Install Certificates.command" — fall back to certifi or the system bundle.
96
+ try:
97
+ import certifi
98
+ return ssl.create_default_context(cafile=certifi.where())
99
+ except ImportError:
100
+ pass
101
+ if os.path.exists("/etc/ssl/cert.pem"):
102
+ return ssl.create_default_context(cafile="/etc/ssl/cert.pem")
103
+ return ctx
104
+
105
+
106
+ def fetch_usage(token: str) -> dict:
107
+ req = urllib.request.Request(USAGE_URL, headers={
108
+ "Authorization": "Bearer " + token,
109
+ "anthropic-beta": "oauth-2025-04-20",
110
+ "Content-Type": "application/json",
111
+ "User-Agent": "claude-limits-bar",
112
+ })
113
+ try:
114
+ with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT, context=_ssl_context()) as resp:
115
+ return json.loads(resp.read().decode("utf-8"))
116
+ except urllib.error.HTTPError as e:
117
+ if e.code in (401, 403):
118
+ raise TokenRejected() from e
119
+ if e.code == 429:
120
+ raise UsageRateLimited() from e
121
+ raise
122
+
123
+
124
+ def get_limits() -> List[Limit]:
125
+ return parse_limits(fetch_usage(get_access_token()))
126
+
127
+
128
+ def parse_limits(data: dict) -> List[Limit]:
129
+ limits = []
130
+ for item in data.get("limits") or []:
131
+ resets_at = _parse_iso(item.get("resets_at"))
132
+ limits.append(Limit(
133
+ kind=item.get("kind") or "",
134
+ label=_label(item),
135
+ percent=float(item.get("percent") or 0),
136
+ severity=item.get("severity") or "normal",
137
+ resets_at=resets_at,
138
+ is_active=bool(item.get("is_active")),
139
+ ))
140
+ return limits
141
+
142
+
143
+ def _label(item: dict) -> str:
144
+ kind = item.get("kind") or ""
145
+ if kind == "session":
146
+ return "5-Hour Limit"
147
+ if kind == "weekly_all":
148
+ return "7-Day Limit"
149
+ if kind == "weekly_scoped":
150
+ model = ((item.get("scope") or {}).get("model") or {}).get("display_name")
151
+ return "7-Day (%s)" % model if model else "7-Day (scoped)"
152
+ return kind.replace("_", " ").capitalize() or "Limit"
153
+
154
+
155
+ def _parse_iso(value: Optional[str]) -> Optional[datetime]:
156
+ if not value:
157
+ return None
158
+ try:
159
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
160
+ except ValueError:
161
+ return None
162
+
163
+
164
+ # --- display helpers ---------------------------------------------------------
165
+
166
+ def primary_limit(limits: List[Limit]) -> Optional[Limit]:
167
+ """What to show in the menu bar: the session limit by default (it is what
168
+ usually triggers "Too many requests"), unless another limit is worse and
169
+ already in warning territory."""
170
+ if not limits:
171
+ return None
172
+ session = next((l for l in limits if l.kind == "session"), None)
173
+ worst = max(limits, key=lambda l: l.percent)
174
+ if worst.warning and (session is None or worst.percent > session.percent):
175
+ return worst
176
+ return session or worst
177
+
178
+
179
+ def time_until(resets_at: Optional[datetime], now: Optional[datetime] = None) -> str:
180
+ if resets_at is None:
181
+ return "?"
182
+ now = now or datetime.now(timezone.utc)
183
+ seconds = (resets_at - now).total_seconds()
184
+ if seconds <= 0:
185
+ return "now"
186
+ minutes = int(seconds // 60)
187
+ days, minutes = divmod(minutes, 1440)
188
+ hours, minutes = divmod(minutes, 60)
189
+ if days:
190
+ return "%dd %dh" % (days, hours)
191
+ if hours:
192
+ return "%dh %02dm" % (hours, minutes)
193
+ return "%dm" % max(minutes, 1)
194
+
195
+
196
+ def local_reset_time(resets_at: Optional[datetime], now: Optional[datetime] = None) -> str:
197
+ if resets_at is None:
198
+ return "?"
199
+ now = (now or datetime.now(timezone.utc)).astimezone()
200
+ local = resets_at.astimezone()
201
+ if local.date() == now.date():
202
+ return local.strftime("%H:%M")
203
+ return local.strftime("%a %H:%M")
204
+
205
+
206
+ def reset_label(resets_at: Optional[datetime], now: Optional[datetime] = None) -> str:
207
+ """Reset moment the way the menu rows show it: "Today 6:10 PM", "Sep 5 1 AM"."""
208
+ if resets_at is None:
209
+ return "?"
210
+ now_local = (now or datetime.now(timezone.utc)).astimezone()
211
+ local = resets_at.astimezone()
212
+ time_part = local.strftime("%-I %p") if local.minute == 0 else local.strftime("%-I:%M %p")
213
+ if local.date() == now_local.date():
214
+ return "Today " + time_part
215
+ return local.strftime("%b %-d ") + time_part
216
+
217
+
218
+ def bar_title(limits: List[Limit], now: Optional[datetime] = None) -> str:
219
+ p = primary_limit(limits)
220
+ if p is None:
221
+ return "✳ ?"
222
+ if p.exhausted:
223
+ return "✳ ⛔ %s" % time_until(p.resets_at, now)
224
+ prefix = "✳ ⚠️ " if any(l.warning for l in limits) else "✳ "
225
+ return "%s%d%% · %s" % (prefix, round(p.percent), time_until(p.resets_at, now))
226
+
227
+
228
+ def limit_line(limit: Limit, now: Optional[datetime] = None) -> str:
229
+ return "%s: %d%% — resets %s (in %s)" % (
230
+ limit.label,
231
+ round(limit.percent),
232
+ local_reset_time(limit.resets_at, now),
233
+ time_until(limit.resets_at, now),
234
+ )
@@ -0,0 +1,354 @@
1
+ """macOS menu bar app: ring gauges for Claude limits, reset times in the menu.
2
+
3
+ Menu bar shows two mini rings (session + weekly, percent inside) plus the
4
+ time until the 5-hour limit resets. The dropdown shows a large session donut
5
+ with the weekly limit as a thin outer arc, and one row per limit.
6
+ """
7
+
8
+ import threading
9
+ import time
10
+ import webbrowser
11
+
12
+ import objc
13
+ from AppKit import (
14
+ NSApplication, NSApplicationActivationPolicyAccessory,
15
+ NSAttributedString, NSBezierPath, NSButton, NSColor, NSFont,
16
+ NSFontAttributeName, NSForegroundColorAttributeName, NSImage, NSMakeRect,
17
+ NSMenu, NSMenuItem, NSMutableParagraphStyle, NSParagraphStyleAttributeName,
18
+ NSStatusBar, NSTextAlignmentCenter, NSTextAlignmentLeft,
19
+ NSTextAlignmentRight, NSVariableStatusItemLength, NSView,
20
+ )
21
+ from Foundation import NSObject, NSTimer
22
+ from PyObjCTools import AppHelper
23
+
24
+ from .limits import (
25
+ CredentialsNotFound, TokenRejected, UsageRateLimited,
26
+ get_limits, reset_label, time_until,
27
+ )
28
+ from .update import CHECK_INTERVAL_SECONDS, RELEASES_URL, available_update
29
+
30
+ REFRESH_SECONDS = 60
31
+ MENU_WIDTH = 264
32
+ RATE_LIMIT_ERROR = "Usage API rate-limited — showing last known data"
33
+ DONATE_URL = "https://base.monobank.ua/tilbertbalaban"
34
+ USAGE_URL = "https://claude.ai/settings/usage"
35
+
36
+ GREEN = NSColor.systemGreenColor()
37
+ PURPLE = NSColor.systemPurpleColor()
38
+ ORANGE = NSColor.systemOrangeColor()
39
+ RED = NSColor.systemRedColor()
40
+
41
+
42
+ def ring_color(limit):
43
+ if limit.exhausted:
44
+ return RED
45
+ if limit.warning:
46
+ return ORANGE
47
+ return GREEN if limit.kind == "session" else PURPLE
48
+
49
+
50
+ def draw_ring(center, radius, line_width, percent, color, track_color):
51
+ track = NSBezierPath.bezierPath()
52
+ track.appendBezierPathWithArcWithCenter_radius_startAngle_endAngle_(
53
+ center, radius, 0, 360)
54
+ track.setLineWidth_(line_width)
55
+ track_color.setStroke()
56
+ track.stroke()
57
+ fraction = min(max(percent, 0), 100) / 100.0
58
+ if fraction <= 0:
59
+ return
60
+ arc = NSBezierPath.bezierPath()
61
+ # Start at 12 o'clock and sweep clockwise, like a countdown dial.
62
+ arc.appendBezierPathWithArcWithCenter_radius_startAngle_endAngle_clockwise_(
63
+ center, radius, 90, 90 - 360 * fraction, True)
64
+ arc.setLineWidth_(line_width)
65
+ arc.setLineCapStyle_(1) # round
66
+ color.setStroke()
67
+ arc.stroke()
68
+
69
+
70
+ def text_attrs(size, color, bold=False, align=NSTextAlignmentCenter):
71
+ style = NSMutableParagraphStyle.alloc().init()
72
+ style.setAlignment_(align)
73
+ font = NSFont.boldSystemFontOfSize_(size) if bold else NSFont.systemFontOfSize_(size)
74
+ return {
75
+ NSFontAttributeName: font,
76
+ NSForegroundColorAttributeName: color,
77
+ NSParagraphStyleAttributeName: style,
78
+ }
79
+
80
+
81
+ def draw_text(text, rect, attrs):
82
+ s = NSAttributedString.alloc().initWithString_attributes_(text, attrs)
83
+ h = s.size().height
84
+ s.drawInRect_(NSMakeRect(rect.origin.x,
85
+ rect.origin.y + (rect.size.height - h) / 2.0,
86
+ rect.size.width, h))
87
+
88
+
89
+ def status_bar_image(limits, dark):
90
+ """Two mini rings with the percent inside, like the reference app."""
91
+ size = 18
92
+ gap = 3
93
+ count = max(len(limits), 1)
94
+ img = NSImage.alloc().initWithSize_((count * size + (count - 1) * gap, 20))
95
+ img.lockFocus()
96
+ number_color = NSColor.whiteColor() if dark else NSColor.blackColor()
97
+ track = (NSColor.whiteColor() if dark else NSColor.blackColor()).colorWithAlphaComponent_(0.18)
98
+ for i, limit in enumerate(limits):
99
+ x = i * (size + gap)
100
+ center = (x + size / 2.0, 1 + size / 2.0)
101
+ draw_ring(center, size / 2.0 - 1.5, 2.5, limit.percent, ring_color(limit), track)
102
+ pct = round(limit.percent)
103
+ label = "!" if pct >= 100 else str(pct)
104
+ draw_text(label, NSMakeRect(x, 1, size, size),
105
+ text_attrs(7.5, number_color, bold=True))
106
+ img.unlockFocus()
107
+ return img
108
+
109
+
110
+ def _symbol_button(symbol, fallback, tooltip, target, action):
111
+ image = NSImage.imageWithSystemSymbolName_accessibilityDescription_(
112
+ symbol, tooltip)
113
+ if image is not None:
114
+ button = NSButton.buttonWithImage_target_action_(image, target, action)
115
+ else:
116
+ button = NSButton.buttonWithTitle_target_action_(fallback, target, action)
117
+ button.setBordered_(False)
118
+ button.setToolTip_(tooltip)
119
+ return button
120
+
121
+
122
+ class HeaderView(NSView):
123
+ """App title with the donate ($) and refresh icon buttons on the right."""
124
+
125
+ def initWithTarget_(self, target):
126
+ self = objc.super(HeaderView, self).initWithFrame_(
127
+ NSMakeRect(0, 0, MENU_WIDTH, 38))
128
+ if self is None:
129
+ return None
130
+ stats = _symbol_button("chart.bar.xaxis", "📊",
131
+ "Open claude.ai usage stats", target, "openUsage:")
132
+ stats.setFrame_(NSMakeRect(MENU_WIDTH - 100, 7, 26, 24))
133
+ self.addSubview_(stats)
134
+ donate = _symbol_button("dollarsign.circle", "$",
135
+ "Support the developer", target, "donate:")
136
+ donate.setFrame_(NSMakeRect(MENU_WIDTH - 70, 7, 26, 24))
137
+ self.addSubview_(donate)
138
+ refresh = _symbol_button("arrow.clockwise", "↻",
139
+ "Refresh", target, "refresh:")
140
+ refresh.setFrame_(NSMakeRect(MENU_WIDTH - 40, 7, 26, 24))
141
+ self.addSubview_(refresh)
142
+ return self
143
+
144
+ def drawRect_(self, rect):
145
+ draw_text("✳ Claude Limits", NSMakeRect(16, 3, MENU_WIDTH - 120, 32),
146
+ text_attrs(14, NSColor.labelColor(), bold=True,
147
+ align=NSTextAlignmentLeft))
148
+
149
+
150
+ class DonutView(NSView):
151
+ """Large session donut with the weekly limit as a thin outer arc."""
152
+
153
+ def initWithLimits_(self, limits):
154
+ self = objc.super(DonutView, self).initWithFrame_(
155
+ NSMakeRect(0, 0, MENU_WIDTH, 170))
156
+ if self is None:
157
+ return None
158
+ self._limits = limits
159
+ return self
160
+
161
+ def drawRect_(self, rect):
162
+ session = next((l for l in self._limits if l.kind == "session"), None)
163
+ weekly = next((l for l in self._limits if l.kind == "weekly_all"), None)
164
+ primary = session or (self._limits[0] if self._limits else None)
165
+ if primary is None:
166
+ return
167
+ center = (MENU_WIDTH / 2.0, 85)
168
+ track = NSColor.labelColor().colorWithAlphaComponent_(0.12)
169
+ draw_ring(center, 62, 11, primary.percent, ring_color(primary), track)
170
+ if weekly is not None:
171
+ draw_ring(center, 74, 3.5, weekly.percent, ring_color(weekly),
172
+ NSColor.clearColor())
173
+ draw_text("%d%%" % round(primary.percent),
174
+ NSMakeRect(0, 78, MENU_WIDTH, 40),
175
+ text_attrs(28, NSColor.labelColor(), bold=True))
176
+ draw_text("Used", NSMakeRect(0, 54, MENU_WIDTH, 20),
177
+ text_attrs(12, NSColor.secondaryLabelColor()))
178
+
179
+
180
+ class LimitRowView(NSView):
181
+ """Mini ring + limit name + reset time, like the reference app's rows."""
182
+
183
+ def initWithLimit_(self, limit):
184
+ self = objc.super(LimitRowView, self).initWithFrame_(
185
+ NSMakeRect(0, 0, MENU_WIDTH, 36))
186
+ if self is None:
187
+ return None
188
+ self._limit = limit
189
+ return self
190
+
191
+ def drawRect_(self, rect):
192
+ bg = NSBezierPath.bezierPathWithRoundedRect_xRadius_yRadius_(
193
+ NSMakeRect(10, 2, MENU_WIDTH - 20, 32), 8, 8)
194
+ NSColor.labelColor().colorWithAlphaComponent_(0.06).setFill()
195
+ bg.fill()
196
+ limit = self._limit
197
+ track = NSColor.labelColor().colorWithAlphaComponent_(0.15)
198
+ draw_ring((20 + 9, 18), 7.5, 2.5, limit.percent, ring_color(limit), track)
199
+ pct = round(limit.percent)
200
+ draw_text("!" if pct >= 100 else str(pct), NSMakeRect(20, 9, 18, 18),
201
+ text_attrs(6.5, NSColor.labelColor(), bold=True))
202
+ draw_text(limit.label, NSMakeRect(46, 2, 92, 32),
203
+ text_attrs(13, NSColor.secondaryLabelColor(),
204
+ align=NSTextAlignmentLeft))
205
+ draw_text(reset_label(limit.resets_at), NSMakeRect(120, 2, MENU_WIDTH - 136, 32),
206
+ text_attrs(13, NSColor.labelColor(), bold=True,
207
+ align=NSTextAlignmentRight))
208
+
209
+
210
+ class ErrorRowView(NSView):
211
+ """Fixed-width warning row so long messages never widen the menu."""
212
+
213
+ def initWithMessage_(self, message):
214
+ self = objc.super(ErrorRowView, self).initWithFrame_(
215
+ NSMakeRect(0, 0, MENU_WIDTH, 34))
216
+ if self is None:
217
+ return None
218
+ self._message = message
219
+ return self
220
+
221
+ def drawRect_(self, rect):
222
+ attrs = text_attrs(11, NSColor.secondaryLabelColor(),
223
+ align=NSTextAlignmentLeft)
224
+ NSAttributedString.alloc().initWithString_attributes_(
225
+ "⚠️ " + self._message, attrs).drawInRect_(
226
+ NSMakeRect(16, 2, MENU_WIDTH - 32, 30))
227
+
228
+
229
+ class StatusApp(NSObject):
230
+ def applicationDidFinishLaunching_(self, _notification):
231
+ self._limits = []
232
+ self._error = None
233
+ self._skip_ticks = 0
234
+ self._update = None
235
+ self._last_update_check = 0.0
236
+ self.status_item = NSStatusBar.systemStatusBar().statusItemWithLength_(
237
+ NSVariableStatusItemLength)
238
+ self.status_item.button().setImagePosition_(2) # NSImageLeft
239
+ self.menu = NSMenu.alloc().init()
240
+ self.status_item.setMenu_(self.menu)
241
+ self._render()
242
+ NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
243
+ REFRESH_SECONDS, self, "tick:", None, True)
244
+ self.tick_(None)
245
+
246
+ def tick_(self, _timer):
247
+ if self._skip_ticks > 0:
248
+ self._skip_ticks -= 1
249
+ return
250
+ threading.Thread(target=self._fetch, daemon=True).start()
251
+
252
+ @objc.python_method
253
+ def _fetch(self):
254
+ if time.time() - self._last_update_check > CHECK_INTERVAL_SECONDS:
255
+ self._last_update_check = time.time()
256
+ self._update = available_update()
257
+ limits, error = None, None
258
+ try:
259
+ limits = get_limits()
260
+ except CredentialsNotFound:
261
+ error = "No Claude Code credentials — run `claude` and sign in"
262
+ except TokenRejected:
263
+ error = "Token expired — use Claude Code once to refresh it"
264
+ except UsageRateLimited:
265
+ error = RATE_LIMIT_ERROR
266
+ except Exception:
267
+ error = "Could not reach api.anthropic.com"
268
+ AppHelper.callAfter(self._apply, limits, error)
269
+
270
+ @objc.python_method
271
+ def _apply(self, limits, error):
272
+ if limits is not None:
273
+ self._limits = limits
274
+ self._error = error
275
+ # The usage endpoint has its own rate limit; poll gently after a 429.
276
+ self._skip_ticks = 4 if error == RATE_LIMIT_ERROR else 0
277
+ self._render()
278
+
279
+ @objc.python_method
280
+ def _render(self):
281
+ button = self.status_item.button()
282
+ bar_limits = [l for l in self._limits
283
+ if l.kind in ("session", "weekly_all")] or self._limits[:2]
284
+ session = next((l for l in self._limits if l.kind == "session"), None)
285
+ if bar_limits:
286
+ dark = "dark" in str(button.effectiveAppearance().name()).lower()
287
+ button.setImage_(status_bar_image(bar_limits, dark))
288
+ button.setTitle_(" " + time_until(session.resets_at) if session else "")
289
+ else:
290
+ button.setImage_(None)
291
+ button.setTitle_("✳ …" if self._error is None else "✳ ?")
292
+ self._rebuild_menu()
293
+
294
+ @objc.python_method
295
+ def _rebuild_menu(self):
296
+ self.menu.removeAllItems()
297
+ header_item = NSMenuItem.alloc().init()
298
+ header_item.setView_(HeaderView.alloc().initWithTarget_(self))
299
+ self.menu.addItem_(header_item)
300
+ if self._limits:
301
+ donut_item = NSMenuItem.alloc().init()
302
+ donut_item.setView_(DonutView.alloc().initWithLimits_(self._limits))
303
+ self.menu.addItem_(donut_item)
304
+ for limit in self._limits:
305
+ row = NSMenuItem.alloc().init()
306
+ row.setView_(LimitRowView.alloc().initWithLimit_(limit))
307
+ self.menu.addItem_(row)
308
+ if self._error:
309
+ err = NSMenuItem.alloc().init()
310
+ err.setView_(ErrorRowView.alloc().initWithMessage_(self._error))
311
+ self.menu.addItem_(err)
312
+ if self._update:
313
+ self.menu.addItem_(NSMenuItem.separatorItem())
314
+ self._add_action("Update available — v" + self._update, "openReleases:")
315
+ self.menu.addItem_(NSMenuItem.separatorItem())
316
+ self._add_action("Quit", "quit:")
317
+
318
+ @objc.python_method
319
+ def _add_action(self, title, selector):
320
+ item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
321
+ title, selector, "")
322
+ item.setTarget_(self)
323
+ self.menu.addItem_(item)
324
+
325
+ def refresh_(self, _sender):
326
+ self._skip_ticks = 0
327
+ self.tick_(None)
328
+
329
+ def openUsage_(self, _sender):
330
+ self.menu.cancelTracking()
331
+ webbrowser.open(USAGE_URL)
332
+
333
+ def donate_(self, _sender):
334
+ self.menu.cancelTracking()
335
+ webbrowser.open(DONATE_URL)
336
+
337
+ def openReleases_(self, _sender):
338
+ self.menu.cancelTracking()
339
+ webbrowser.open(RELEASES_URL)
340
+
341
+ def quit_(self, _sender):
342
+ NSApplication.sharedApplication().terminate_(None)
343
+
344
+
345
+ def main():
346
+ app = NSApplication.sharedApplication()
347
+ app.setActivationPolicy_(NSApplicationActivationPolicyAccessory)
348
+ delegate = StatusApp.alloc().init()
349
+ app.setDelegate_(delegate)
350
+ AppHelper.runEventLoop()
351
+
352
+
353
+ if __name__ == "__main__":
354
+ main()
@@ -0,0 +1,40 @@
1
+ """Once-a-day check for a newer release on GitHub."""
2
+
3
+ import json
4
+ import urllib.request
5
+ from typing import Optional
6
+
7
+ from . import VERSION
8
+ from .limits import _ssl_context
9
+
10
+ RELEASES_API = ("https://api.github.com/repos/"
11
+ "TilbertBalaban/claude-limits-bar/releases/latest")
12
+ RELEASES_URL = "https://github.com/TilbertBalaban/claude-limits-bar/releases"
13
+ CHECK_INTERVAL_SECONDS = 24 * 3600
14
+
15
+
16
+ def latest_version(timeout: int = 10) -> Optional[str]:
17
+ req = urllib.request.Request(RELEASES_API, headers={
18
+ "User-Agent": "claude-limits-bar",
19
+ "Accept": "application/vnd.github+json",
20
+ })
21
+ with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as resp:
22
+ tag = json.loads(resp.read().decode("utf-8")).get("tag_name") or ""
23
+ return tag.lstrip("v") or None
24
+
25
+
26
+ def is_newer(candidate: Optional[str], current: str = VERSION) -> bool:
27
+ try:
28
+ parse = lambda v: tuple(int(part) for part in v.split("."))
29
+ return parse(candidate) > parse(current)
30
+ except (AttributeError, ValueError):
31
+ return False
32
+
33
+
34
+ def available_update() -> Optional[str]:
35
+ """Version string of a newer release, or None. Never raises."""
36
+ try:
37
+ latest = latest_version()
38
+ except Exception:
39
+ return None
40
+ return latest if is_newer(latest) else None
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: claude-limits-bar
3
+ Version: 1.2.0
4
+ Summary: Claude Code limits and reset times in the macOS menu bar
5
+ Author: Tilbert Balaban
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/TilbertBalaban/claude-limits-bar
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: pyobjc-framework-Cocoa>=9.0
12
+ Requires-Dist: certifi
13
+ Dynamic: license-file
14
+
15
+ # claude-limits-bar
16
+
17
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](LICENSE)
18
+
19
+ **Claude Code limits and their reset times, always visible in the macOS menu bar.**
20
+
21
+ If you keep hitting *"Too many requests, please try again later"*, this shows you exactly how close you are to the limit and when it resets — before Claude Code cuts you off.
22
+
23
+ The menu bar shows two ring gauges — the session (5-hour) limit in green and the 7-day limit in purple, percent used inside each — plus the countdown until the session limit resets:
24
+
25
+ ```
26
+ ◔66 ◔12 3h 26m
27
+ ```
28
+
29
+ Click it for the full picture: a large donut for the session limit (weekly as a thin outer arc) and one row per limit with its reset time:
30
+
31
+ ```
32
+ ✳ Claude Limits ($) (↻)
33
+ ╭─── 66% Used ───╮
34
+ ◔66 5-Hour Limit Today 6:10 PM
35
+ ◔12 7-Day Limit Sep 5 1 AM
36
+ ◔18 7-Day (Fable) Sep 5 1 AM
37
+ ```
38
+
39
+ Rings turn orange at 80% or when Anthropic reports elevated severity, and red with `!` at 100% — the session (5-hour) limit is the one that usually triggers "Too many requests".
40
+
41
+ ## How it works
42
+
43
+ Claude Code stores its OAuth token in the macOS Keychain (service `Claude Code-credentials`). This app reads that token and polls the same official endpoint the Claude apps use (`api.anthropic.com/api/oauth/usage`) once a minute. The token is sent **only** to `api.anthropic.com` — nowhere else, nothing is logged or stored.
44
+
45
+ Works on Pro and Max plans. Requires being signed in to Claude Code (`claude` → `/login`).
46
+
47
+ ## Install
48
+
49
+ Requires macOS.
50
+
51
+ ### Homebrew
52
+
53
+ ```
54
+ brew install tilbertbalaban/tap/claude-limits-bar
55
+ ```
56
+
57
+ ### uv / pipx (needs Python 3.9+)
58
+
59
+ ```
60
+ uv tool install claude-limits-bar
61
+ ```
62
+
63
+ (or `pipx install claude-limits-bar`)
64
+
65
+ Then:
66
+
67
+ ```
68
+ claude-limits-bar # run the menu bar app
69
+ claude-limits-bar autostart on # start automatically at login (LaunchAgent)
70
+ claude-limits-bar autostart off # remove the login item
71
+ claude-limits-bar status # print current limits to the terminal
72
+ ```
73
+
74
+ ## Updating
75
+
76
+ The app checks GitHub once a day and shows an **Update available** item in the menu when there's a newer version. To update:
77
+
78
+ ```
79
+ brew upgrade claude-limits-bar # Homebrew
80
+ uv tool upgrade claude-limits-bar # uv
81
+ pipx upgrade claude-limits-bar # pipx
82
+ ```
83
+
84
+ Then quit the app from its menu and start it again (`claude-limits-bar`, or log out/in if you use `autostart on`).
85
+
86
+ ## Support
87
+
88
+ If this app saves you from surprise rate limits, you can support development via the `$` button in the app or at [base.monobank.ua/tilbertbalaban](https://base.monobank.ua/tilbertbalaban).
89
+
90
+ ## Development
91
+
92
+ ```
93
+ git clone https://github.com/TilbertBalaban/claude-limits-bar
94
+ cd claude-limits-bar
95
+ python3 -m unittest discover -s tests -v
96
+ python3 -m claude_limits_bar.cli status
97
+ ```
98
+
99
+ The data layer ([claude_limits_bar/limits.py](claude_limits_bar/limits.py)) is stdlib-only and fully unit-tested; only the menu bar shell ([claude_limits_bar/menubar.py](claude_limits_bar/menubar.py)) depends on PyObjC (AppKit).
100
+
101
+ ## Troubleshooting
102
+
103
+ - **`✳ ?` in the menu bar** — open the menu for the reason: no credentials (sign in to Claude Code), an expired token (use Claude Code once — it refreshes the token itself), or no network.
104
+ - **`Too many requests` still happens at <100%** — the API reports utilization rounded to whole percent and with a small delay; treat the orange ring as "wrap up what you're doing".
@@ -0,0 +1,16 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ claude_limits_bar/__init__.py
5
+ claude_limits_bar/cli.py
6
+ claude_limits_bar/limits.py
7
+ claude_limits_bar/menubar.py
8
+ claude_limits_bar/update.py
9
+ claude_limits_bar.egg-info/PKG-INFO
10
+ claude_limits_bar.egg-info/SOURCES.txt
11
+ claude_limits_bar.egg-info/dependency_links.txt
12
+ claude_limits_bar.egg-info/entry_points.txt
13
+ claude_limits_bar.egg-info/requires.txt
14
+ claude_limits_bar.egg-info/top_level.txt
15
+ tests/test_limits.py
16
+ tests/test_update.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ claude-limits-bar = claude_limits_bar.cli:main
@@ -0,0 +1,2 @@
1
+ pyobjc-framework-Cocoa>=9.0
2
+ certifi
@@ -0,0 +1 @@
1
+ claude_limits_bar
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "claude-limits-bar"
7
+ version = "1.2.0"
8
+ description = "Claude Code limits and reset times in the macOS menu bar"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Tilbert Balaban" }]
12
+ requires-python = ">=3.9"
13
+ dependencies = ["pyobjc-framework-Cocoa>=9.0", "certifi"]
14
+
15
+ [project.urls]
16
+ Homepage = "https://github.com/TilbertBalaban/claude-limits-bar"
17
+
18
+ [project.scripts]
19
+ claude-limits-bar = "claude_limits_bar.cli:main"
20
+
21
+ [tool.setuptools]
22
+ packages = ["claude_limits_bar"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,126 @@
1
+ import unittest
2
+ from datetime import datetime, timedelta, timezone
3
+
4
+ from claude_limits_bar.limits import (
5
+ CredentialsNotFound, Limit,
6
+ bar_title, limit_line, local_reset_time, parse_access_token,
7
+ parse_limits, primary_limit, reset_label, time_until,
8
+ )
9
+
10
+ NOW = datetime(2026, 9, 2, 12, 30, tzinfo=timezone.utc)
11
+
12
+ SAMPLE_RESPONSE = {
13
+ "limits": [
14
+ {"kind": "session", "group": "session", "percent": 41, "severity": "normal",
15
+ "resets_at": "2026-09-02T15:10:00.026681+00:00", "scope": None, "is_active": True},
16
+ {"kind": "weekly_all", "group": "weekly", "percent": 9, "severity": "normal",
17
+ "resets_at": "2026-09-04T22:00:00.026699+00:00", "scope": None, "is_active": False},
18
+ {"kind": "weekly_scoped", "group": "weekly", "percent": 13, "severity": "normal",
19
+ "resets_at": "2026-09-04T22:00:00.026844+00:00",
20
+ "scope": {"model": {"id": None, "display_name": "Fable"}, "surface": None},
21
+ "is_active": False},
22
+ ],
23
+ }
24
+
25
+
26
+ def make_limit(kind="session", label="5-Hour Limit", percent=41.0, severity="normal",
27
+ resets_at=NOW + timedelta(hours=2, minutes=40), is_active=True):
28
+ return Limit(kind=kind, label=label, percent=percent, severity=severity,
29
+ resets_at=resets_at, is_active=is_active)
30
+
31
+
32
+ class TestParseAccessToken(unittest.TestCase):
33
+ def test_valid(self):
34
+ raw = '{"claudeAiOauth": {"accessToken": "sk-ant-oat01-abc"}}'
35
+ self.assertEqual(parse_access_token(raw), "sk-ant-oat01-abc")
36
+
37
+ def test_missing_token(self):
38
+ with self.assertRaises(CredentialsNotFound):
39
+ parse_access_token('{"claudeAiOauth": {}}')
40
+
41
+ def test_invalid_json(self):
42
+ with self.assertRaises(CredentialsNotFound):
43
+ parse_access_token("not json")
44
+
45
+
46
+ class TestParseLimits(unittest.TestCase):
47
+ def test_sample_response(self):
48
+ limits = parse_limits(SAMPLE_RESPONSE)
49
+ self.assertEqual(len(limits), 3)
50
+ session, weekly, scoped = limits
51
+ self.assertEqual(session.label, "5-Hour Limit")
52
+ self.assertEqual(session.percent, 41)
53
+ self.assertTrue(session.is_active)
54
+ self.assertEqual(session.resets_at.tzinfo.utcoffset(None), timedelta(0))
55
+ self.assertEqual(weekly.label, "7-Day Limit")
56
+ self.assertEqual(scoped.label, "7-Day (Fable)")
57
+
58
+ def test_empty_and_missing(self):
59
+ self.assertEqual(parse_limits({}), [])
60
+ self.assertEqual(parse_limits({"limits": None}), [])
61
+
62
+ def test_unknown_kind_gets_readable_label(self):
63
+ limits = parse_limits({"limits": [{"kind": "weekly_opus", "percent": 5}]})
64
+ self.assertEqual(limits[0].label, "Weekly opus")
65
+ self.assertIsNone(limits[0].resets_at)
66
+
67
+
68
+ class TestPrimaryLimit(unittest.TestCase):
69
+ def test_prefers_session(self):
70
+ session = make_limit(percent=20)
71
+ weekly = make_limit(kind="weekly_all", label="7-Day Limit", percent=50)
72
+ self.assertIs(primary_limit([weekly, session]), session)
73
+
74
+ def test_worse_limit_wins_when_warning(self):
75
+ session = make_limit(percent=20)
76
+ weekly = make_limit(kind="weekly_all", label="7-Day Limit", percent=85)
77
+ self.assertIs(primary_limit([session, weekly]), weekly)
78
+
79
+ def test_no_limits(self):
80
+ self.assertIsNone(primary_limit([]))
81
+
82
+
83
+ class TestTimeFormatting(unittest.TestCase):
84
+ def test_time_until(self):
85
+ self.assertEqual(time_until(NOW + timedelta(hours=2, minutes=40), NOW), "2h 40m")
86
+ self.assertEqual(time_until(NOW + timedelta(minutes=5), NOW), "5m")
87
+ self.assertEqual(time_until(NOW + timedelta(days=2, hours=9, minutes=30), NOW), "2d 9h")
88
+ self.assertEqual(time_until(NOW - timedelta(minutes=1), NOW), "now")
89
+ self.assertEqual(time_until(None, NOW), "?")
90
+
91
+ def test_local_reset_time_same_day_has_no_weekday(self):
92
+ self.assertNotIn(" ", local_reset_time(NOW + timedelta(hours=1), NOW))
93
+ self.assertIn(" ", local_reset_time(NOW + timedelta(days=2), NOW))
94
+
95
+ def test_reset_label(self):
96
+ same_day = NOW.astimezone().replace(hour=18, minute=10)
97
+ self.assertEqual(reset_label(same_day, NOW), "Today 6:10 PM")
98
+ other_day = datetime(2026, 9, 5, 1, 0, tzinfo=NOW.astimezone().tzinfo)
99
+ label = reset_label(other_day, NOW)
100
+ self.assertEqual(label, "Sep 5 1 AM")
101
+ self.assertEqual(reset_label(None, NOW), "?")
102
+
103
+
104
+ class TestBarTitle(unittest.TestCase):
105
+ def test_normal(self):
106
+ self.assertEqual(bar_title([make_limit()], NOW), "✳ 41% · 2h 40m")
107
+
108
+ def test_warning(self):
109
+ self.assertEqual(bar_title([make_limit(percent=85)], NOW), "✳ ⚠️ 85% · 2h 40m")
110
+
111
+ def test_exhausted_shows_time_to_reset(self):
112
+ self.assertEqual(bar_title([make_limit(percent=100)], NOW), "✳ ⛔ 2h 40m")
113
+
114
+ def test_no_data(self):
115
+ self.assertEqual(bar_title([], NOW), "✳ ?")
116
+
117
+
118
+ class TestLimitLine(unittest.TestCase):
119
+ def test_contains_label_percent_and_countdown(self):
120
+ line = limit_line(make_limit(), NOW)
121
+ self.assertIn("5-Hour Limit: 41%", line)
122
+ self.assertIn("in 2h 40m", line)
123
+
124
+
125
+ if __name__ == "__main__":
126
+ unittest.main()
@@ -0,0 +1,23 @@
1
+ import unittest
2
+
3
+ from claude_limits_bar.update import is_newer
4
+
5
+
6
+ class TestIsNewer(unittest.TestCase):
7
+ def test_newer(self):
8
+ self.assertTrue(is_newer("1.1.0", "1.0.5"))
9
+ self.assertTrue(is_newer("2.0.0", "1.9.9"))
10
+ self.assertTrue(is_newer("1.0.10", "1.0.9"))
11
+
12
+ def test_not_newer(self):
13
+ self.assertFalse(is_newer("1.0.5", "1.0.5"))
14
+ self.assertFalse(is_newer("1.0.4", "1.0.5"))
15
+
16
+ def test_garbage(self):
17
+ self.assertFalse(is_newer(None, "1.0.5"))
18
+ self.assertFalse(is_newer("", "1.0.5"))
19
+ self.assertFalse(is_newer("not-a-version", "1.0.5"))
20
+
21
+
22
+ if __name__ == "__main__":
23
+ unittest.main()