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 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,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,7 @@
1
+ OAuth_credentials
2
+ config
3
+ context_menus
4
+ draw_icon
5
+ format_util
6
+ logging
7
+ usage_fetch
config.py ADDED
@@ -0,0 +1,5 @@
1
+ MESSAGES_URL = "https://api.anthropic.com/v1/messages"
2
+ OAUTH_CLIENT_ID = "9d1c250a-e61b-48ad-a7e6-0b5d4eb16f10"
3
+ PLATFORM_URL = "https://platform.claude.com"
4
+ POLL_INTERVAL = 60 # seconds
5
+
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
+ }