claude-usage 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- OAuth_credentials.py +73 -0
- claude_usage-0.0.1.dist-info/METADATA +11 -0
- claude_usage-0.0.1.dist-info/RECORD +11 -0
- claude_usage-0.0.1.dist-info/WHEEL +5 -0
- claude_usage-0.0.1.dist-info/top_level.txt +7 -0
- config.py +5 -0
- context_menus.py +0 -0
- draw_icon.py +99 -0
- format_util.py +15 -0
- logging.py +12 -0
- usage_fetch.py +56 -0
OAuth_credentials.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
import subprocess
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from .config import PLATFORM_URL, OAUTH_CLIENT_ID
|
|
9
|
+
|
|
10
|
+
def read_claude_code_creds() -> dict:
|
|
11
|
+
result = subprocess.run(
|
|
12
|
+
["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"],
|
|
13
|
+
capture_output=True, text=True,
|
|
14
|
+
)
|
|
15
|
+
if result.returncode != 0:
|
|
16
|
+
raise RuntimeError("Claude Code-credentials not found in keychain. Is Claude Code installed and logged in?")
|
|
17
|
+
return json.loads(result.stdout.strip())
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def write_claude_code_creds(creds: dict) -> None:
|
|
21
|
+
blob = json.dumps(creds)
|
|
22
|
+
subprocess.run(["security", "delete-generic-password", "-s", "Claude Code-credentials"],
|
|
23
|
+
capture_output=True)
|
|
24
|
+
subprocess.run(
|
|
25
|
+
["security", "add-generic-password", "-s", "Claude Code-credentials",
|
|
26
|
+
"-a", os.environ.get("USER", ""), "-w", blob],
|
|
27
|
+
capture_output=True, check=True,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_valid_token() -> str:
|
|
32
|
+
"""Return a valid access token, refreshing via OAuth if expired."""
|
|
33
|
+
creds = read_claude_code_creds()
|
|
34
|
+
oauth = creds["claudeAiOauth"]
|
|
35
|
+
|
|
36
|
+
expires_at_ms = oauth.get("expiresAt", 0)
|
|
37
|
+
now_ms = time.time() * 1000
|
|
38
|
+
|
|
39
|
+
if now_ms < expires_at_ms - 60_000:
|
|
40
|
+
return oauth["accessToken"]
|
|
41
|
+
|
|
42
|
+
logging.info("OAuth token expired - refreshing")
|
|
43
|
+
r = requests.post(
|
|
44
|
+
f"{PLATFORM_URL}/v1/oauth/token",
|
|
45
|
+
json={
|
|
46
|
+
"grant_type": "refresh_token",
|
|
47
|
+
"refresh_token": oauth["refreshToken"],
|
|
48
|
+
"client_id": OAUTH_CLIENT_ID,
|
|
49
|
+
},
|
|
50
|
+
timeout=15,
|
|
51
|
+
)
|
|
52
|
+
if not r.ok:
|
|
53
|
+
raise RuntimeError(f"Token refresh failed: HTTP {r.status_code} {r.text[:200]}")
|
|
54
|
+
|
|
55
|
+
new_token = r.json()
|
|
56
|
+
oauth["accessToken"] = new_token.get("access_token", oauth["accessToken"])
|
|
57
|
+
if "refresh_token" in new_token:
|
|
58
|
+
oauth["refreshToken"] = new_token["refresh_token"]
|
|
59
|
+
if "expires_in" in new_token:
|
|
60
|
+
oauth["expiresAt"] = int((time.time() + new_token["expires_in"]) * 1000)
|
|
61
|
+
creds["claudeAiOauth"] = oauth
|
|
62
|
+
write_claude_code_creds(creds)
|
|
63
|
+
logging.info("Keychain updated with refreshed token")
|
|
64
|
+
return oauth["accessToken"]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def is_logged_in() -> bool:
|
|
69
|
+
try:
|
|
70
|
+
read_claude_code_creds()
|
|
71
|
+
return True
|
|
72
|
+
except Exception:
|
|
73
|
+
return False
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: claude-usage
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: macOS menu bar app for Anthropic API usage and cost tracking
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: rumps>=0.4.0
|
|
7
|
+
Requires-Dist: requests>=2.32.3
|
|
8
|
+
Requires-Dist: keyring>=25.6.0
|
|
9
|
+
Requires-Dist: python-dotenv>=1.0.1
|
|
10
|
+
Requires-Dist: pyobjc-framework-quartz>=12.1
|
|
11
|
+
Requires-Dist: pyobjc-framework-coretext>=12.1
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
OAuth_credentials.py,sha256=va1gyVbq-3_kDswEKNkl_93MPTD-Sn0_D1oQDqmVMjE,2315
|
|
2
|
+
config.py,sha256=vcfM5SZ8lxjhrT-ebOgjWNvxd5CGoUxexLUSF_epyFU,196
|
|
3
|
+
context_menus.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
draw_icon.py,sha256=tBySU3uapOD7L8_Gy4ar2llukrna4GgB4dgBcIL1DOc,3545
|
|
5
|
+
format_util.py,sha256=tKmJfPlTHLjpWlHIxjAQ1EYsfQnSPnP-Rvw9dRwZS8g,446
|
|
6
|
+
logging.py,sha256=tqTYj0aWgxxbmwa2f77_MiRJy04QBBu0Y0Sj6NFu55A,318
|
|
7
|
+
usage_fetch.py,sha256=4dukUXKZ5Id1x4HW4hzTt3m-RN7mcfuZJBAdNvPxhHY,1934
|
|
8
|
+
claude_usage-0.0.1.dist-info/METADATA,sha256=NMqJqImKYbDX2SEqEiXNpx1ZkyNnnjNVHpSQlpga_88,369
|
|
9
|
+
claude_usage-0.0.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
+
claude_usage-0.0.1.dist-info/top_level.txt,sha256=NL2whnCrYeeYYewsrTUiZ10hpBlNH6Gp9zyMblFbcQI,81
|
|
11
|
+
claude_usage-0.0.1.dist-info/RECORD,,
|
config.py
ADDED
context_menus.py
ADDED
|
File without changes
|
draw_icon.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from AppKit import (
|
|
2
|
+
NSImage, NSColor, NSBezierPath, NSFont,
|
|
3
|
+
NSMutableAttributedString, NSAttributedString,
|
|
4
|
+
NSTextAttachment, NSFontAttributeName,
|
|
5
|
+
NSForegroundColorAttributeName,
|
|
6
|
+
NSGraphicsContext,
|
|
7
|
+
)
|
|
8
|
+
from Foundation import NSMakeRect, NSOperationQueue
|
|
9
|
+
from CoreText import (
|
|
10
|
+
CTLineCreateWithAttributedString,
|
|
11
|
+
CTLineDraw,
|
|
12
|
+
CTLineGetTypographicBounds,
|
|
13
|
+
)
|
|
14
|
+
from Quartz.CoreGraphics import (
|
|
15
|
+
CGContextSetTextMatrix,
|
|
16
|
+
CGContextSetTextPosition,
|
|
17
|
+
CGAffineTransformIdentity,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_BAT_W = 62
|
|
21
|
+
_BAT_H = 18
|
|
22
|
+
_BAT_BASELINE = -3 # vertical nudge to align battery with menu bar text baseline
|
|
23
|
+
|
|
24
|
+
def battery_image(fraction: float | None, label: str) -> NSImage:
|
|
25
|
+
"""
|
|
26
|
+
fraction - 0.0-1.0 fill level (amount used); None = unknown (grey outline only)
|
|
27
|
+
label - time-remaining string drawn centred inside the battery body
|
|
28
|
+
"""
|
|
29
|
+
image = NSImage.alloc().initWithSize_((_BAT_W, _BAT_H))
|
|
30
|
+
image.lockFocus()
|
|
31
|
+
|
|
32
|
+
pad = 1
|
|
33
|
+
bump_w = 4
|
|
34
|
+
bump_h = int(_BAT_H * 0.4)
|
|
35
|
+
body_w = _BAT_W - bump_w - pad * 2
|
|
36
|
+
body_h = _BAT_H - pad * 2
|
|
37
|
+
bx, by = float(pad), float(pad)
|
|
38
|
+
r = 3.0
|
|
39
|
+
inner_pad = 2
|
|
40
|
+
|
|
41
|
+
NSColor.clearColor().set()
|
|
42
|
+
NSBezierPath.fillRect_(NSMakeRect(0, 0, _BAT_W, _BAT_H))
|
|
43
|
+
|
|
44
|
+
fill_frac = fraction if fraction is not None else 0.0
|
|
45
|
+
fill_w = max(0.0, (body_w - inner_pad * 2) * min(fill_frac, 1.0))
|
|
46
|
+
if fill_w > 0:
|
|
47
|
+
NSColor.whiteColor().colorWithAlphaComponent_(0.6).setFill()
|
|
48
|
+
NSBezierPath.bezierPathWithRoundedRect_xRadius_yRadius_(
|
|
49
|
+
NSMakeRect(bx + inner_pad, by + inner_pad, fill_w, body_h - inner_pad * 2),
|
|
50
|
+
max(1.0, r - 1), max(1.0, r - 1),
|
|
51
|
+
).fill()
|
|
52
|
+
|
|
53
|
+
outline_color = NSColor.systemOrangeColor()
|
|
54
|
+
|
|
55
|
+
outline_path = NSBezierPath.bezierPathWithRoundedRect_xRadius_yRadius_(
|
|
56
|
+
NSMakeRect(bx, by, float(body_w), float(body_h)), r, r
|
|
57
|
+
)
|
|
58
|
+
outline_path.setLineWidth_(1.5)
|
|
59
|
+
outline_color.setStroke()
|
|
60
|
+
outline_path.stroke()
|
|
61
|
+
|
|
62
|
+
outline_color.setFill()
|
|
63
|
+
NSBezierPath.fillRect_(NSMakeRect(
|
|
64
|
+
bx + body_w, by + (body_h - bump_h) / 2, float(bump_w), float(bump_h),
|
|
65
|
+
))
|
|
66
|
+
|
|
67
|
+
if label:
|
|
68
|
+
font_size = 8.5
|
|
69
|
+
font = NSFont.systemFontOfSize_(font_size)
|
|
70
|
+
text_color = NSColor.systemOrangeColor()
|
|
71
|
+
attrs = {NSFontAttributeName: font, NSForegroundColorAttributeName: text_color}
|
|
72
|
+
attr_str = NSAttributedString.alloc().initWithString_attributes_(label, attrs)
|
|
73
|
+
|
|
74
|
+
ct_line = CTLineCreateWithAttributedString(attr_str)
|
|
75
|
+
adv_w = CTLineGetTypographicBounds(ct_line, None, None, None)
|
|
76
|
+
if not isinstance(adv_w, (int, float)):
|
|
77
|
+
adv_w = adv_w[0]
|
|
78
|
+
|
|
79
|
+
cg_ctx = NSGraphicsContext.currentContext().CGContext()
|
|
80
|
+
CGContextSetTextMatrix(cg_ctx, CGAffineTransformIdentity)
|
|
81
|
+
|
|
82
|
+
ascender = font.ascender()
|
|
83
|
+
descender = font.descender()
|
|
84
|
+
cap_h = ascender - descender
|
|
85
|
+
text_x = bx + inner_pad + (body_w - inner_pad * 2 - adv_w) / 2
|
|
86
|
+
text_y = by + (body_h - cap_h) / 2 - descender
|
|
87
|
+
CGContextSetTextPosition(cg_ctx, text_x, text_y)
|
|
88
|
+
CTLineDraw(ct_line, cg_ctx)
|
|
89
|
+
|
|
90
|
+
image.unlockFocus()
|
|
91
|
+
image.setSize_((_BAT_W, _BAT_H))
|
|
92
|
+
return image
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def battery_attachment(fraction: float | None, label: str) -> NSAttributedString:
|
|
96
|
+
attachment = NSTextAttachment.alloc().init()
|
|
97
|
+
attachment.setImage_(battery_image(fraction, label))
|
|
98
|
+
attachment.setBounds_(NSMakeRect(0, _BAT_BASELINE, _BAT_W, _BAT_H))
|
|
99
|
+
return NSAttributedString.attributedStringWithAttachment_(attachment)
|
format_util.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
|
|
3
|
+
def format_reset_window(reset_at: datetime | None) -> str:
|
|
4
|
+
if reset_at is None:
|
|
5
|
+
return "?"
|
|
6
|
+
delta = reset_at - datetime.now(timezone.utc)
|
|
7
|
+
total = int(delta.total_seconds())
|
|
8
|
+
if total <= 0:
|
|
9
|
+
return "now"
|
|
10
|
+
days = total // 86400
|
|
11
|
+
hours = (total % 86400) // 3600
|
|
12
|
+
mins = (total % 3600) // 60
|
|
13
|
+
if days >= 1:
|
|
14
|
+
return f"{days}d"
|
|
15
|
+
return f"{hours}h{mins:02d}m"
|
logging.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
LOG_FILE = Path.home() / "Library" / "Logs" / "claude_usage.log"
|
|
5
|
+
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
6
|
+
logging.basicConfig(
|
|
7
|
+
level=logging.DEBUG,
|
|
8
|
+
format="%(asctime)s %(levelname)s %(message)s",
|
|
9
|
+
handlers=[
|
|
10
|
+
logging.FileHandler(LOG_FILE),
|
|
11
|
+
],
|
|
12
|
+
)
|
usage_fetch.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
import logging
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
from .config import MESSAGES_URL, OAUTH_CLIENT_ID, PLATFORM_URL
|
|
6
|
+
from .OAuth_credentials import get_valid_token, read_claude_code_creds, write_claude_code_creds
|
|
7
|
+
|
|
8
|
+
def fetch_utilization() -> dict:
|
|
9
|
+
"""
|
|
10
|
+
Send a minimal 1-token message to api.anthropic.com and read the rate-limit
|
|
11
|
+
utilization headers that claude.ai subscribers receive.
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
{
|
|
15
|
+
"five_hour": {"utilization": 0.19, "reset_at": <datetime>},
|
|
16
|
+
"seven_day": {"utilization": 0.94, "reset_at": <datetime>},
|
|
17
|
+
}
|
|
18
|
+
"""
|
|
19
|
+
token = get_valid_token()
|
|
20
|
+
r = requests.post(
|
|
21
|
+
MESSAGES_URL,
|
|
22
|
+
headers={
|
|
23
|
+
"x-api-key": token,
|
|
24
|
+
"anthropic-version": "2023-06-01",
|
|
25
|
+
"content-type": "application/json",
|
|
26
|
+
},
|
|
27
|
+
json={
|
|
28
|
+
"model": "claude-haiku-4-5-20251001",
|
|
29
|
+
"max_tokens": 1,
|
|
30
|
+
"messages": [{"role": "user", "content": "hi"}],
|
|
31
|
+
},
|
|
32
|
+
timeout=20,
|
|
33
|
+
)
|
|
34
|
+
logging.debug("Messages API %s headers=%s", r.status_code, dict(r.headers))
|
|
35
|
+
if not r.ok:
|
|
36
|
+
raise RuntimeError(f"Messages API HTTP {r.status_code}: {r.text[:200]}")
|
|
37
|
+
|
|
38
|
+
def _reset_dt(unix_str: str | None) -> datetime | None:
|
|
39
|
+
if not unix_str:
|
|
40
|
+
return None
|
|
41
|
+
try:
|
|
42
|
+
return datetime.fromtimestamp(int(unix_str), tz=timezone.utc)
|
|
43
|
+
except Exception:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
h = r.headers
|
|
47
|
+
return {
|
|
48
|
+
"five_hour": {
|
|
49
|
+
"utilization": float(h.get("anthropic-ratelimit-unified-5h-utilization", 0)),
|
|
50
|
+
"reset_at": _reset_dt(h.get("anthropic-ratelimit-unified-5h-reset")),
|
|
51
|
+
},
|
|
52
|
+
"seven_day": {
|
|
53
|
+
"utilization": float(h.get("anthropic-ratelimit-unified-7d-utilization", 0)),
|
|
54
|
+
"reset_at": _reset_dt(h.get("anthropic-ratelimit-unified-7d-reset")),
|
|
55
|
+
},
|
|
56
|
+
}
|