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.
- claude_limits_bar-1.2.0/LICENSE +21 -0
- claude_limits_bar-1.2.0/PKG-INFO +104 -0
- claude_limits_bar-1.2.0/README.md +90 -0
- claude_limits_bar-1.2.0/claude_limits_bar/__init__.py +1 -0
- claude_limits_bar-1.2.0/claude_limits_bar/cli.py +79 -0
- claude_limits_bar-1.2.0/claude_limits_bar/limits.py +234 -0
- claude_limits_bar-1.2.0/claude_limits_bar/menubar.py +354 -0
- claude_limits_bar-1.2.0/claude_limits_bar/update.py +40 -0
- claude_limits_bar-1.2.0/claude_limits_bar.egg-info/PKG-INFO +104 -0
- claude_limits_bar-1.2.0/claude_limits_bar.egg-info/SOURCES.txt +16 -0
- claude_limits_bar-1.2.0/claude_limits_bar.egg-info/dependency_links.txt +1 -0
- claude_limits_bar-1.2.0/claude_limits_bar.egg-info/entry_points.txt +2 -0
- claude_limits_bar-1.2.0/claude_limits_bar.egg-info/requires.txt +2 -0
- claude_limits_bar-1.2.0/claude_limits_bar.egg-info/top_level.txt +1 -0
- claude_limits_bar-1.2.0/pyproject.toml +22 -0
- claude_limits_bar-1.2.0/setup.cfg +4 -0
- claude_limits_bar-1.2.0/tests/test_limits.py +126 -0
- claude_limits_bar-1.2.0/tests/test_update.py +23 -0
|
@@ -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)
|
|
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)
|
|
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)
|
|
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 @@
|
|
|
1
|
+
|
|
@@ -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,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()
|