claude-usage 0.0.1__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,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,33 @@
1
+ # Claude Usage Mac Menu
2
+
3
+ [![](https://img.shields.io/pypi/v/claude_usage.svg)](https://pypi.org/pypi/claude_usage/)
4
+
5
+
6
+ <img src="https://raw.githubusercontent.com/ailabexperiments/claude-usage/main/assets/menu_item_example.png" alt="doctordoc logo" width="150" >
7
+
8
+
9
+ A macOS menu bar app that shows your Anthropic API spend and token usage in real time - like a battery indicator for your API budget.
10
+
11
+ ---
12
+
13
+ ## Get started
14
+
15
+ ```bash
16
+ $ pip install claude_usage
17
+ $ claude-usage
18
+ ```
19
+
20
+ Pre-requsite: Having authenticated with Claude Code:
21
+
22
+ ```bash
23
+ $ claude-code login
24
+ ```
25
+
26
+ ---
27
+
28
+ ## How it works
29
+
30
+ The data is retrieved from Anthropic's Usage API with the OAuth token saved by Claude Code in your macOS Keychain.
31
+
32
+ which provides up-to-date information on your organization's API usage and costs. The app polls the API every 60 seconds and updates the menu bar title and dropdown with your current token usage and session / weekly reset window.
33
+
@@ -0,0 +1,14 @@
1
+ [project]
2
+ name = "claude-usage"
3
+ version = "0.0.1"
4
+ description = "macOS menu bar app for Anthropic API usage and cost tracking"
5
+ requires-python = ">=3.11"
6
+
7
+ dependencies = [
8
+ "rumps>=0.4.0",
9
+ "requests>=2.32.3",
10
+ "keyring>=25.6.0",
11
+ "python-dotenv>=1.0.1",
12
+ "pyobjc-framework-quartz>=12.1",
13
+ "pyobjc-framework-coretext>=12.1",
14
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/OAuth_credentials.py
4
+ src/config.py
5
+ src/context_menus.py
6
+ src/draw_icon.py
7
+ src/format_util.py
8
+ src/logging.py
9
+ src/usage_fetch.py
10
+ src/claude_usage.egg-info/PKG-INFO
11
+ src/claude_usage.egg-info/SOURCES.txt
12
+ src/claude_usage.egg-info/dependency_links.txt
13
+ src/claude_usage.egg-info/requires.txt
14
+ src/claude_usage.egg-info/top_level.txt
@@ -0,0 +1,6 @@
1
+ rumps>=0.4.0
2
+ requests>=2.32.3
3
+ keyring>=25.6.0
4
+ python-dotenv>=1.0.1
5
+ pyobjc-framework-quartz>=12.1
6
+ pyobjc-framework-coretext>=12.1
@@ -0,0 +1,7 @@
1
+ OAuth_credentials
2
+ config
3
+ context_menus
4
+ draw_icon
5
+ format_util
6
+ logging
7
+ usage_fetch
@@ -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
+
File without changes
@@ -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)
@@ -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"
@@ -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
+ )
@@ -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
+ }