Add desktop tray widget + installer wizard

- Desktop widget (Python/pystray): system tray icon showing 5h usage as
  circular progress bar with Claude starburst logo, 10-step green-to-red
  color scale, right-click menu with usage stats and configuration
- Shared cache: both widget and CLI statusline read/write the same
  /tmp/claude_usage.json — only one fetcher needs to run
- Installer wizard (install_wizard.py): interactive cross-platform setup
  with component selection, session key prompt, cron/autostart config
- OS wrappers: install.sh (Linux/macOS) and install.ps1 (Windows) find
  Python 3.9+ and launch the wizard
- README with topology diagram, usage docs, and configuration reference

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Axel Meyer
2026-02-26 12:54:32 +00:00
parent f308a8105e
commit 6a1b4bd022
14 changed files with 1241 additions and 71 deletions

View File

@@ -0,0 +1,3 @@
"""Claude Usage Widget — System tray usage monitor."""
__version__ = "0.1.0"

View File

@@ -0,0 +1,5 @@
"""Entry point: python -m claude_usage_widget"""
from .app import main
main()

View File

@@ -0,0 +1,91 @@
"""Main orchestrator: tray icon, state, threads."""
import logging
import sys
import threading
import tkinter as tk
from tkinter import simpledialog
import pystray
from . import config
from .fetcher import UsageFetcher
from .menu import build_menu
from .renderer import render_icon
log = logging.getLogger(__name__)
class App:
def __init__(self):
self.usage_data = {}
self._lock = threading.Lock()
self.fetcher = UsageFetcher(on_update=self._on_usage_update)
self._icon = pystray.Icon(
name="claude-usage",
icon=render_icon(0),
title="Claude Usage Widget",
menu=build_menu(self),
)
def run(self):
"""Start fetcher and run tray icon (blocks until quit)."""
self.fetcher.start()
self._icon.run()
def _on_usage_update(self, data):
"""Callback from fetcher thread — update icon + menu."""
with self._lock:
self.usage_data = data
pct = data.get("five_hour_pct", 0) if not data.get("error") else 0
try:
self._icon.icon = render_icon(pct)
# Force menu rebuild for dynamic text
self._icon.update_menu()
except Exception:
pass # icon not yet visible
# Update tooltip
if data.get("error"):
self._icon.title = f"Claude Usage: {data['error']}"
else:
self._icon.title = f"Claude Usage: {data.get('five_hour_pct', 0)}%"
def on_refresh(self):
self.fetcher.refresh()
def on_set_interval(self, seconds):
self.fetcher.set_interval(seconds)
def on_session_key(self):
"""Show a simple dialog to enter/update the session key."""
threading.Thread(target=self._session_key_dialog, daemon=True).start()
def _session_key_dialog(self):
root = tk.Tk()
root.withdraw()
current = config.get_session_key()
key = simpledialog.askstring(
"Claude Session Key",
"Paste your claude.ai sessionKey cookie value:",
initialvalue=current,
parent=root,
)
root.destroy()
if key is not None and key.strip():
config.set_session_key(key.strip())
self.fetcher.refresh()
def on_quit(self):
self.fetcher.stop()
self._icon.stop()
def main():
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
app = App()
app.run()

View File

@@ -0,0 +1,62 @@
"""JSON config management for Claude Usage Widget."""
import json
import os
import sys
# All config lives under claude-statusline (shared with the CLI statusline)
if sys.platform == "win32":
_CONFIG_DIR = os.path.join(os.environ.get("LOCALAPPDATA", os.path.expanduser("~")), "claude-statusline")
else:
_CONFIG_DIR = os.environ.get("CLAUDE_STATUSLINE_CONFIG") or os.path.join(
os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "claude-statusline"
)
CONFIG_PATH = os.path.join(_CONFIG_DIR, "widget-config.json")
SESSION_KEY_PATH = os.path.join(_CONFIG_DIR, "session-key")
DEFAULTS = {
"refresh_interval": 300, # seconds
"org_id": "",
}
def load():
"""Load config, merging with defaults for missing keys."""
cfg = dict(DEFAULTS)
try:
with open(CONFIG_PATH, "r") as f:
cfg.update(json.load(f))
except (FileNotFoundError, json.JSONDecodeError):
pass
return cfg
def save(cfg):
"""Persist config to disk."""
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
with open(CONFIG_PATH, "w") as f:
json.dump(cfg, f, indent=2)
def get_session_key():
"""Return session key from env var or shared config file."""
key = os.environ.get("CLAUDE_SESSION_KEY", "").strip()
if key:
return key
try:
with open(SESSION_KEY_PATH, "r") as f:
key = f.read().strip()
if key and not key.startswith("#"):
return key
except FileNotFoundError:
pass
return ""
def set_session_key(key):
"""Write session key to shared config file."""
os.makedirs(os.path.dirname(SESSION_KEY_PATH), exist_ok=True)
with open(SESSION_KEY_PATH, "w") as f:
f.write(key.strip() + "\n")
os.chmod(SESSION_KEY_PATH, 0o600)

View File

@@ -0,0 +1,240 @@
"""Usage fetcher — Python port of claude-statusline/fetch-usage.js (urllib only).
Writes to the same shared cache file as fetch-usage.js so both the CLI
statusline and the desktop widget see the same data from a single fetch.
"""
import json
import logging
import os
import sys
import threading
import time
import urllib.request
from datetime import datetime, timezone
from . import config
log = logging.getLogger(__name__)
_HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0",
"Accept": "application/json",
"Referer": "https://claude.ai/",
"Origin": "https://claude.ai",
}
# Shared cache path — identical to fetch-usage.js default
if sys.platform == "win32":
_DEFAULT_CACHE = os.path.join(os.environ.get("TEMP", os.path.expanduser("~")), "claude_usage.json")
else:
_DEFAULT_CACHE = os.path.join(os.environ.get("TMPDIR", "/tmp"), "claude_usage.json")
CACHE_PATH = os.environ.get("CLAUDE_USAGE_CACHE", _DEFAULT_CACHE)
# -- Cache I/O (compatible with fetch-usage.js format) -----------------------
def write_cache(data):
"""Write raw API response (or error dict) to the shared cache file."""
cache_dir = os.path.dirname(CACHE_PATH)
if cache_dir:
os.makedirs(cache_dir, exist_ok=True)
with open(CACHE_PATH, "w") as f:
json.dump(data, f, indent=2)
def read_cache():
"""Read the shared cache file. Returns (data_dict, age_seconds) or (None, inf)."""
try:
mtime = os.path.getmtime(CACHE_PATH)
age = time.time() - mtime
with open(CACHE_PATH, "r") as f:
data = json.load(f)
return data, age
except (FileNotFoundError, json.JSONDecodeError, OSError):
return None, float("inf")
# -- API requests ------------------------------------------------------------
def _request(url, session_key):
req = urllib.request.Request(url, headers={
**_HEADERS,
"Cookie": f"sessionKey={session_key}",
})
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read().decode())
def _discover_org_id(session_key):
orgs = _request("https://claude.ai/api/organizations", session_key)
if not orgs:
raise RuntimeError("No organizations found")
return orgs[0]["uuid"]
def fetch_usage(session_key, org_id=""):
"""Fetch usage data once. Returns (data_dict, org_id) or raises."""
if not org_id:
org_id = _discover_org_id(session_key)
data = _request(f"https://claude.ai/api/organizations/{org_id}/usage", session_key)
return data, org_id
# -- Parsing -----------------------------------------------------------------
def parse_usage(data):
"""Extract display-friendly usage info from raw API response.
Returns dict with keys:
five_hour_pct, five_hour_resets_at, five_hour_resets_in,
seven_day_pct, seven_day_resets_at, seven_day_resets_in,
error
"""
if data is None:
return {"error": "no data"}
# Error states written by either fetcher
if isinstance(data.get("_error"), str):
err = data["_error"]
if err == "auth_expired":
return {"error": "session expired"}
return {"error": err}
result = {}
now = datetime.now(timezone.utc)
for window, prefix in [("five_hour", "five_hour"), ("seven_day", "seven_day")]:
block = data.get(window, {})
pct = round(block.get("utilization", 0))
resets_at = block.get("resets_at", "")
resets_in = ""
if resets_at:
try:
dt = datetime.fromisoformat(resets_at)
delta = dt - now
total_seconds = max(0, int(delta.total_seconds()))
days = total_seconds // 86400
hours = (total_seconds % 86400) // 3600
minutes = (total_seconds % 3600) // 60
if days > 0:
resets_in = f"{days}d {hours}h"
elif hours > 0:
resets_in = f"{hours}h {minutes}m"
else:
resets_in = f"{minutes}m"
except (ValueError, TypeError):
pass
result[f"{prefix}_pct"] = pct
result[f"{prefix}_resets_at"] = resets_at
result[f"{prefix}_resets_in"] = resets_in
return result
# -- Background fetcher thread -----------------------------------------------
class UsageFetcher:
"""Background daemon thread that periodically fetches usage data.
Writes every fetch result to the shared cache file so the CLI statusline
(and any other consumer) sees the same data. Before fetching, checks if
the cache is already fresh enough (e.g. populated by the Node.js cron
fetcher) and skips the API call if so.
"""
def __init__(self, on_update):
"""on_update(parsed_dict) called on each successful or failed fetch."""
self._on_update = on_update
self._stop = threading.Event()
self._refresh_now = threading.Event()
self._thread = threading.Thread(target=self._loop, daemon=True)
self._cfg = config.load()
self._org_id = self._cfg.get("org_id", "")
def start(self):
self._thread.start()
def stop(self):
self._stop.set()
self._refresh_now.set() # unblock wait
def refresh(self):
"""Trigger an immediate fetch (bypasses cache freshness check)."""
self._refresh_now.set()
def set_interval(self, seconds):
"""Change refresh interval and trigger immediate fetch."""
self._cfg["refresh_interval"] = seconds
config.save(self._cfg)
self._refresh_now.set()
@property
def interval(self):
return self._cfg.get("refresh_interval", 300)
def _loop(self):
# On startup, try to show cached data immediately
self._load_from_cache()
while not self._stop.is_set():
forced = self._refresh_now.is_set()
self._refresh_now.clear()
self._do_fetch(force=forced)
self._refresh_now.wait(timeout=self._cfg.get("refresh_interval", 300))
def _load_from_cache(self):
"""Read existing cache for instant display on startup."""
data, age = read_cache()
if data and age < self._cfg.get("refresh_interval", 300):
parsed = parse_usage(data)
self._on_update(parsed)
def _do_fetch(self, force=False):
"""Fetch from API, or use cache if fresh enough.
If force=True (manual refresh), always hit the API.
Otherwise, skip if cache is younger than half the refresh interval
(meaning another fetcher like the Node.js cron job already updated it).
"""
if not force:
data, age = read_cache()
freshness_threshold = self._cfg.get("refresh_interval", 300) / 2
if data and not data.get("_error") and age < freshness_threshold:
parsed = parse_usage(data)
self._on_update(parsed)
log.debug("Cache is fresh (%.0fs old), skipping API call", age)
return
session_key = config.get_session_key()
if not session_key:
error_data = {"_error": "no_session_key"}
write_cache(error_data)
self._on_update({"error": "no session key"})
return
try:
data, org_id = fetch_usage(session_key, self._org_id)
if org_id and org_id != self._org_id:
self._org_id = org_id
self._cfg["org_id"] = org_id
config.save(self._cfg)
write_cache(data)
parsed = parse_usage(data)
self._on_update(parsed)
except urllib.error.HTTPError as e:
error_key = "auth_expired" if e.code in (401, 403) else "api_error"
error_data = {"_error": error_key, "_status": e.code}
write_cache(error_data)
self._on_update({"error": "session expired" if error_key == "auth_expired" else f"HTTP {e.code}"})
log.warning("Fetch failed: HTTP %d", e.code)
except Exception as e:
error_data = {"_error": "fetch_failed", "_message": str(e)}
write_cache(error_data)
self._on_update({"error": str(e)})
log.warning("Fetch failed: %s", e)

View File

@@ -0,0 +1,68 @@
"""pystray Menu builder with dynamic text and radio interval selection."""
import pystray
_INTERVALS = [
(60, "1 min"),
(300, "5 min"),
(900, "15 min"),
(1800, "30 min"),
]
def build_menu(app):
"""Build the right-click menu. `app` must expose:
- app.usage_data (dict from fetcher.parse_usage)
- app.fetcher (UsageFetcher instance)
- app.on_refresh()
- app.on_set_interval(seconds)
- app.on_session_key()
- app.on_quit()
"""
def _five_hour_text(_):
d = app.usage_data
if not d or d.get("error"):
return f"5h Usage: {d.get('error', '?')}" if d else "5h Usage: loading..."
return f"5h Usage: {d.get('five_hour_pct', 0)}%"
def _five_hour_reset(_):
d = app.usage_data
r = d.get("five_hour_resets_in", "") if d else ""
return f"Resets in: {r}" if r else "Resets in: —"
def _seven_day_text(_):
d = app.usage_data
if not d or d.get("error"):
return "7d Usage: —"
return f"7d Usage: {d.get('seven_day_pct', 0)}%"
def _seven_day_reset(_):
d = app.usage_data
r = d.get("seven_day_resets_in", "") if d else ""
return f"Resets in: {r}" if r else "Resets in: —"
def _make_interval_item(seconds, label):
return pystray.MenuItem(
label,
lambda _, s=seconds: app.on_set_interval(s),
checked=lambda _, s=seconds: app.fetcher.interval == s,
radio=True,
)
interval_items = [_make_interval_item(s, l) for s, l in _INTERVALS]
return pystray.Menu(
pystray.MenuItem(_five_hour_text, None, enabled=False),
pystray.MenuItem(_five_hour_reset, None, enabled=False),
pystray.Menu.SEPARATOR,
pystray.MenuItem(_seven_day_text, None, enabled=False),
pystray.MenuItem(_seven_day_reset, None, enabled=False),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Refresh Now", lambda _: app.on_refresh()),
pystray.MenuItem("Refresh Interval", pystray.Menu(*interval_items)),
pystray.MenuItem("Session Key...", lambda _: app.on_session_key()),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Quit", lambda _: app.on_quit()),
)

View File

@@ -0,0 +1,124 @@
"""Icon renderer: Claude starburst logo + circular usage arc."""
import math
from PIL import Image, ImageDraw
SIZE = 128
CENTER = SIZE // 2
ARC_WIDTH = 16
ARC_RADIUS = (SIZE - ARC_WIDTH) // 2 # inner edge of arc
# Color thresholds for the usage arc (10% increments)
_COLORS = [
(10, "#4CAF50"), # green
(20, "#43A047"), # green (darker)
(30, "#7CB342"), # light green
(40, "#C0CA33"), # lime
(50, "#FDD835"), # yellow
(60, "#FFC107"), # amber
(70, "#FFB300"), # amber (darker)
(80, "#FF9800"), # orange
(90, "#FF5722"), # deep orange
(100, "#F44336"), # red
]
def _hex_to_rgba(hex_color, alpha=255):
h = hex_color.lstrip("#")
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), alpha)
def _arc_color(pct):
for threshold, color in _COLORS:
if pct <= threshold:
return color
return _COLORS[-1][1]
def _draw_starburst(img):
"""Draw 8-petal starburst logo at 50% opacity."""
layer = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
num_petals = 8
# Petal geometry: elongated ellipse approximated as polygon
petal_length = 38
petal_width = 10
for i in range(num_petals):
angle = math.radians(i * (360 / num_petals))
# Build petal as 4-point diamond shape
# Tip of petal (far from center)
tip_x = CENTER + math.cos(angle) * petal_length
tip_y = CENTER + math.sin(angle) * petal_length
# Base points (perpendicular to petal direction, near center)
perp = angle + math.pi / 2
base_offset = 6 # distance from center to base of petal
bx = CENTER + math.cos(angle) * base_offset
by = CENTER + math.sin(angle) * base_offset
left_x = bx + math.cos(perp) * (petal_width / 2)
left_y = by + math.sin(perp) * (petal_width / 2)
right_x = bx - math.cos(perp) * (petal_width / 2)
right_y = by - math.sin(perp) * (petal_width / 2)
# Inner point (towards center, slight taper)
inner_x = CENTER - math.cos(angle) * 2
inner_y = CENTER - math.sin(angle) * 2
polygon = [(inner_x, inner_y), (left_x, left_y), (tip_x, tip_y), (right_x, right_y)]
draw.polygon(polygon, fill=(255, 255, 255, 128))
# Center dot
dot_r = 4
draw.ellipse(
[CENTER - dot_r, CENTER - dot_r, CENTER + dot_r, CENTER + dot_r],
fill=(255, 255, 255, 128),
)
img = Image.alpha_composite(img, layer)
return img
def _draw_arc(img, pct):
"""Draw circular progress arc from 12 o'clock, clockwise."""
if pct <= 0:
return img
layer = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
color = _hex_to_rgba(_arc_color(pct))
pct = min(pct, 100)
# Pillow arc: 0 = 3 o'clock, angles go counter-clockwise for arc()
# We want: start at 12 o'clock (-90), sweep clockwise
# Pillow draws counter-clockwise from start to end, so we use:
# start = -90, end = -90 + (pct/100)*360
start_angle = -90
end_angle = -90 + (pct / 100) * 360
margin = ARC_WIDTH // 2
bbox = [margin, margin, SIZE - margin, SIZE - margin]
draw.arc(bbox, start_angle, end_angle, fill=color, width=ARC_WIDTH)
img = Image.alpha_composite(img, layer)
return img
def render_icon(pct):
"""Render a 128x128 RGBA icon with starburst + usage arc.
Args:
pct: Usage percentage (0-100). Arc is invisible at 0.
Returns:
PIL.Image.Image (RGBA)
"""
img = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
img = _draw_starburst(img)
img = _draw_arc(img, pct)
return img