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:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
node_modules/
|
||||||
169
README.md
Normal file
169
README.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# claude-statusline
|
||||||
|
|
||||||
|
Monitor your Claude API usage — as a **CLI statusline** on headless servers or a **system tray widget** on desktop machines.
|
||||||
|
|
||||||
|
Both components share the same session key and fetcher logic. Install one or both depending on your setup.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### CLI Statusline (Node.js)
|
||||||
|
|
||||||
|
A headless-friendly status bar for Claude Code. Shows context window utilization and token usage as text progress bars, piped into the Claude Code statusline slot.
|
||||||
|
|
||||||
|
```
|
||||||
|
Context ▓▓▓▓░░░░░░ 40% | Token ▓▓░░░░░░░░ 19% 78M
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage Fetcher (Node.js)
|
||||||
|
|
||||||
|
Standalone cron job that fetches token usage from the Claude API and writes a JSON cache file. The CLI statusline reads this cache. Runs independently — no browser or GUI needed.
|
||||||
|
|
||||||
|
### Desktop Widget (Python)
|
||||||
|
|
||||||
|
Cross-platform system tray icon that shows the 5-hour usage window as a circular progress bar overlaid on a Claude starburst logo. Color shifts from green through amber to red as usage increases. Right-click menu shows detailed usage stats, reset timers, and configuration.
|
||||||
|
|
||||||
|
## Topology
|
||||||
|
|
||||||
|
```
|
||||||
|
claude.ai API
|
||||||
|
|
|
||||||
|
┌────────────┴────────────┐
|
||||||
|
| |
|
||||||
|
fetch-usage.js claude_usage_widget/
|
||||||
|
(cron, Node.js) fetcher.py thread
|
||||||
|
| (Python, urllib)
|
||||||
|
| |
|
||||||
|
└────────┬────────────────┘
|
||||||
|
v
|
||||||
|
/tmp/claude_usage.json
|
||||||
|
(shared cache)
|
||||||
|
┌────────┴────────────────┐
|
||||||
|
| |
|
||||||
|
statusline.js claude_usage_widget/
|
||||||
|
(Claude Code) app.py (pystray)
|
||||||
|
| |
|
||||||
|
v v
|
||||||
|
Claude Code System tray icon
|
||||||
|
status bar + right-click menu
|
||||||
|
|
||||||
|
Shared: ~/.config/claude-statusline/session-key
|
||||||
|
/tmp/claude_usage.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Only one fetcher needs to run. Either `fetch-usage.js` (via cron) or the widget's built-in fetcher thread writes to the shared cache at `/tmp/claude_usage.json`. Both consumers read from it:
|
||||||
|
|
||||||
|
- **CLI statusline** reads the cache on every Claude Code render cycle
|
||||||
|
- **Desktop widget** reads the cache on startup for instant display, then either fetches itself (writing back to cache) or detects that the cache is already fresh (from cron) and skips the API call
|
||||||
|
|
||||||
|
If both fetchers happen to run, they write the same format — last writer wins, no conflicts.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Run the installer and follow the wizard:
|
||||||
|
|
||||||
|
**Linux / macOS:**
|
||||||
|
```bash
|
||||||
|
bash install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows (PowerShell):**
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File install.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
The wizard will:
|
||||||
|
1. Ask which components to install (CLI statusline, desktop widget, or both)
|
||||||
|
2. Guide you through session key setup
|
||||||
|
3. Configure autostart (widget) or cron (CLI fetcher) as applicable
|
||||||
|
4. Set up the Claude Code statusline integration
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
| Component | Requires |
|
||||||
|
|-----------|----------|
|
||||||
|
| CLI Statusline + Fetcher | Node.js 18+ |
|
||||||
|
| Desktop Widget | Python 3.9+, pip |
|
||||||
|
| Desktop Widget (Linux) | `python3-gi`, `gir1.2-ayatanaappindicator3-0.1` (installed by wizard) |
|
||||||
|
|
||||||
|
## Session Key
|
||||||
|
|
||||||
|
Both components authenticate via a session cookie from claude.ai:
|
||||||
|
|
||||||
|
1. Log into [claude.ai](https://claude.ai) in any browser
|
||||||
|
2. Open DevTools → Application → Cookies → `claude.ai`
|
||||||
|
3. Copy the value of the `sessionKey` cookie (starts with `sk-ant-`)
|
||||||
|
4. The installer will prompt you to enter it, or set it manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "sk-ant-..." > ~/.config/claude-statusline/session-key
|
||||||
|
chmod 600 ~/.config/claude-statusline/session-key
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively, set the `CLAUDE_SESSION_KEY` environment variable.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### CLI Statusline
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `CLAUDE_USAGE_CACHE` | `/tmp/claude_usage.json` | Cache file path |
|
||||||
|
| `CLAUDE_USAGE_MAX_AGE` | `900` | Max cache age in seconds |
|
||||||
|
| `CLAUDE_SESSION_KEY` | — | Session key (alternative to config file) |
|
||||||
|
| `CLAUDE_STATUSLINE_CONFIG` | `~/.config/claude-statusline` | Config directory |
|
||||||
|
| `CLAUDE_ORG_ID` | — | Organization ID (auto-discovered) |
|
||||||
|
|
||||||
|
### Desktop Widget
|
||||||
|
|
||||||
|
Right-click the tray icon to access:
|
||||||
|
- **Usage stats** — 5-hour and 7-day utilization with reset timers
|
||||||
|
- **Refresh Now** — trigger an immediate fetch
|
||||||
|
- **Refresh Interval** — 1 / 5 / 15 / 30 minutes
|
||||||
|
- **Session Key...** — update the session key via dialog
|
||||||
|
|
||||||
|
Widget settings are stored in `~/.config/claude-statusline/widget-config.json`.
|
||||||
|
|
||||||
|
### Icon Color Scale
|
||||||
|
|
||||||
|
The tray icon arc color indicates usage severity at 10% increments:
|
||||||
|
|
||||||
|
| Range | Color |
|
||||||
|
|-------|-------|
|
||||||
|
| 0–10% | Green |
|
||||||
|
| 10–20% | Dark green |
|
||||||
|
| 20–30% | Light green |
|
||||||
|
| 30–40% | Lime |
|
||||||
|
| 40–50% | Yellow |
|
||||||
|
| 50–60% | Amber |
|
||||||
|
| 60–70% | Dark amber |
|
||||||
|
| 70–80% | Orange |
|
||||||
|
| 80–90% | Deep orange |
|
||||||
|
| 90–100% | Red |
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
claude-statusline/
|
||||||
|
├── statusline.js # CLI status bar (reads stdin + cache)
|
||||||
|
├── fetch-usage.js # Cron-based usage fetcher (writes cache)
|
||||||
|
├── install.sh # Linux/macOS installer wrapper
|
||||||
|
├── install.ps1 # Windows installer wrapper
|
||||||
|
├── install_wizard.py # Cross-platform installer wizard
|
||||||
|
├── package.json
|
||||||
|
├── requirements.txt # Python deps (widget only)
|
||||||
|
├── claude_usage_widget/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── __main__.py # Entry point: python -m claude_usage_widget
|
||||||
|
│ ├── app.py # Tray icon orchestrator
|
||||||
|
│ ├── config.py # Shared config (~/.config/claude-statusline/)
|
||||||
|
│ ├── fetcher.py # Python port of fetch-usage.js (urllib)
|
||||||
|
│ ├── menu.py # Right-click menu builder
|
||||||
|
│ └── renderer.py # Starburst logo + arc icon renderer
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
3
claude_usage_widget/__init__.py
Normal file
3
claude_usage_widget/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""Claude Usage Widget — System tray usage monitor."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
5
claude_usage_widget/__main__.py
Normal file
5
claude_usage_widget/__main__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Entry point: python -m claude_usage_widget"""
|
||||||
|
|
||||||
|
from .app import main
|
||||||
|
|
||||||
|
main()
|
||||||
91
claude_usage_widget/app.py
Normal file
91
claude_usage_widget/app.py
Normal 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()
|
||||||
62
claude_usage_widget/config.py
Normal file
62
claude_usage_widget/config.py
Normal 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)
|
||||||
240
claude_usage_widget/fetcher.py
Normal file
240
claude_usage_widget/fetcher.py
Normal 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)
|
||||||
68
claude_usage_widget/menu.py
Normal file
68
claude_usage_widget/menu.py
Normal 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()),
|
||||||
|
)
|
||||||
124
claude_usage_widget/renderer.py
Normal file
124
claude_usage_widget/renderer.py
Normal 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
|
||||||
26
install.ps1
Normal file
26
install.ps1
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Windows wrapper — launches the cross-platform installer wizard.
|
||||||
|
# Usage: powershell -ExecutionPolicy Bypass -File install.ps1
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
|
||||||
|
# Find Python 3.9+
|
||||||
|
$Python = $null
|
||||||
|
foreach ($candidate in @("python3", "python")) {
|
||||||
|
$bin = Get-Command $candidate -ErrorAction SilentlyContinue
|
||||||
|
if ($bin) {
|
||||||
|
$version = & $bin.Source -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"
|
||||||
|
$parts = $version -split "\."
|
||||||
|
if ([int]$parts[0] -ge 3 -and [int]$parts[1] -ge 9) {
|
||||||
|
$Python = $bin.Source
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $Python) {
|
||||||
|
Write-Error "Python 3.9+ is required to run the installer. Install Python 3.9+ and try again."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
& $Python (Join-Path $ScriptDir "install_wizard.py")
|
||||||
88
install.sh
88
install.sh
@@ -1,78 +1,28 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Install claude-statusline for the current user.
|
# Linux/macOS wrapper — launches the cross-platform installer wizard.
|
||||||
# Usage: bash install.sh
|
# Usage: bash install.sh
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
INSTALL_DIR="${HOME}/.local/share/claude-statusline"
|
|
||||||
CONFIG_DIR="${HOME}/.config/claude-statusline"
|
|
||||||
CLAUDE_DIR="${HOME}/.claude"
|
|
||||||
|
|
||||||
# --- Check Node.js version (need 18+ for built-in fetch) ---
|
# Find Python 3.9+
|
||||||
NODE_BIN="$(which node 2>/dev/null || echo '')"
|
PYTHON=""
|
||||||
if [ -z "$NODE_BIN" ]; then
|
for candidate in python3 python; do
|
||||||
echo "ERROR: Node.js not found. Install Node.js 18+ first."
|
if command -v "$candidate" &>/dev/null; then
|
||||||
|
major_minor=$("$candidate" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
|
||||||
|
major=$("$candidate" -c "import sys; print(sys.version_info.major)")
|
||||||
|
minor=$("$candidate" -c "import sys; print(sys.version_info.minor)")
|
||||||
|
if [ "$major" -ge 3 ] && [ "$minor" -ge 9 ]; then
|
||||||
|
PYTHON="$candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$PYTHON" ]; then
|
||||||
|
echo "ERROR: Python 3.9+ is required to run the installer."
|
||||||
|
echo "Install Python 3.9+ and try again."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
NODE_MAJOR=$("$NODE_BIN" -e "process.stdout.write(String(process.versions.node.split('.')[0]))")
|
|
||||||
if [ "$NODE_MAJOR" -lt 18 ]; then
|
|
||||||
echo "ERROR: Node.js $("$NODE_BIN" --version) found, but 18+ is required (built-in fetch)."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "==> Node.js v${NODE_MAJOR} found at $NODE_BIN"
|
|
||||||
|
|
||||||
echo "==> Installing claude-statusline..."
|
exec "$PYTHON" "$SCRIPT_DIR/install_wizard.py"
|
||||||
|
|
||||||
# Copy scripts
|
|
||||||
mkdir -p "$INSTALL_DIR"
|
|
||||||
cp "$SCRIPT_DIR/statusline.js" "$INSTALL_DIR/"
|
|
||||||
cp "$SCRIPT_DIR/fetch-usage.js" "$INSTALL_DIR/"
|
|
||||||
chmod +x "$INSTALL_DIR"/*.js
|
|
||||||
|
|
||||||
# Create config dir
|
|
||||||
mkdir -p "$CONFIG_DIR"
|
|
||||||
if [ ! -f "$CONFIG_DIR/session-key" ]; then
|
|
||||||
echo "# Paste your claude.ai sessionKey cookie value here" > "$CONFIG_DIR/session-key"
|
|
||||||
chmod 600 "$CONFIG_DIR/session-key"
|
|
||||||
echo " Created $CONFIG_DIR/session-key (edit with your session key)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Configure Claude Code statusline
|
|
||||||
mkdir -p "$CLAUDE_DIR"
|
|
||||||
SETTINGS="$CLAUDE_DIR/settings.json"
|
|
||||||
|
|
||||||
if [ -f "$SETTINGS" ]; then
|
|
||||||
if grep -q '"statusLine"' "$SETTINGS" 2>/dev/null; then
|
|
||||||
echo " statusLine already configured in $SETTINGS — skipping"
|
|
||||||
else
|
|
||||||
"$NODE_BIN" -e "
|
|
||||||
const fs = require('fs');
|
|
||||||
const settings = JSON.parse(fs.readFileSync('$SETTINGS', 'utf8'));
|
|
||||||
settings.statusLine = {
|
|
||||||
type: 'command',
|
|
||||||
command: '$NODE_BIN --no-warnings $INSTALL_DIR/statusline.js'
|
|
||||||
};
|
|
||||||
fs.writeFileSync('$SETTINGS', JSON.stringify(settings, null, 2) + '\n');
|
|
||||||
"
|
|
||||||
echo " Added statusLine to $SETTINGS"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
cat > "$SETTINGS" <<SEOF
|
|
||||||
{
|
|
||||||
"statusLine": {
|
|
||||||
"type": "command",
|
|
||||||
"command": "${NODE_BIN} --no-warnings ${INSTALL_DIR}/statusline.js"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SEOF
|
|
||||||
echo " Created $SETTINGS with statusLine config"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Offer cron setup
|
|
||||||
echo ""
|
|
||||||
echo "==> Optional: set up usage fetcher cron (every 5 min):"
|
|
||||||
echo " crontab -e"
|
|
||||||
echo " */5 * * * * ${NODE_BIN} ${INSTALL_DIR}/fetch-usage.js 2>/dev/null"
|
|
||||||
echo ""
|
|
||||||
echo "==> Done! Restart Claude Code to see the statusline."
|
|
||||||
echo " Don't forget to add your session key to: $CONFIG_DIR/session-key"
|
|
||||||
|
|||||||
421
install_wizard.py
Normal file
421
install_wizard.py
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Cross-platform installer wizard for claude-statusline.
|
||||||
|
|
||||||
|
Installs one or both components:
|
||||||
|
- CLI Statusline + Fetcher (Node.js) — for headless servers / Claude Code
|
||||||
|
- Desktop Widget (Python) — system tray usage monitor
|
||||||
|
|
||||||
|
Uses only Python stdlib — no third-party deps required to run the installer itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
# -- Paths ------------------------------------------------------------------
|
||||||
|
|
||||||
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
IS_WINDOWS = sys.platform == "win32"
|
||||||
|
|
||||||
|
if IS_WINDOWS:
|
||||||
|
INSTALL_DIR = os.path.join(os.environ.get("LOCALAPPDATA", os.path.expanduser("~")), "claude-statusline")
|
||||||
|
CONFIG_DIR = INSTALL_DIR
|
||||||
|
CLAUDE_DIR = os.path.join(os.path.expanduser("~"), ".claude")
|
||||||
|
else:
|
||||||
|
INSTALL_DIR = os.path.join(os.path.expanduser("~"), ".local", "share", "claude-statusline")
|
||||||
|
CONFIG_DIR = os.path.join(
|
||||||
|
os.environ.get("XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config")),
|
||||||
|
"claude-statusline",
|
||||||
|
)
|
||||||
|
CLAUDE_DIR = os.path.join(os.path.expanduser("~"), ".claude")
|
||||||
|
|
||||||
|
SESSION_KEY_PATH = os.path.join(CONFIG_DIR, "session-key")
|
||||||
|
SETTINGS_PATH = os.path.join(CLAUDE_DIR, "settings.json")
|
||||||
|
|
||||||
|
# -- Helpers -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def header(text):
|
||||||
|
width = 60
|
||||||
|
print()
|
||||||
|
print("=" * width)
|
||||||
|
print(f" {text}")
|
||||||
|
print("=" * width)
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def step(text):
|
||||||
|
print(f" -> {text}")
|
||||||
|
|
||||||
|
|
||||||
|
def ask_yes_no(prompt, default=True):
|
||||||
|
suffix = " [Y/n] " if default else " [y/N] "
|
||||||
|
while True:
|
||||||
|
answer = input(prompt + suffix).strip().lower()
|
||||||
|
if answer == "":
|
||||||
|
return default
|
||||||
|
if answer in ("y", "yes"):
|
||||||
|
return True
|
||||||
|
if answer in ("n", "no"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def ask_choice(prompt, options):
|
||||||
|
"""Ask user to pick from numbered options. Returns 0-based index."""
|
||||||
|
print(prompt)
|
||||||
|
for i, opt in enumerate(options, 1):
|
||||||
|
print(f" {i}) {opt}")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
choice = int(input(" Choice: ").strip())
|
||||||
|
if 1 <= choice <= len(options):
|
||||||
|
return choice - 1
|
||||||
|
except (ValueError, EOFError):
|
||||||
|
pass
|
||||||
|
print(f" Please enter a number between 1 and {len(options)}.")
|
||||||
|
|
||||||
|
|
||||||
|
def ask_input(prompt, default=""):
|
||||||
|
suffix = f" [{default}]" if default else ""
|
||||||
|
answer = input(f"{prompt}{suffix}: ").strip()
|
||||||
|
return answer if answer else default
|
||||||
|
|
||||||
|
|
||||||
|
def find_executable(name):
|
||||||
|
return shutil.which(name)
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd, check=True, capture=False):
|
||||||
|
kwargs = {"check": check}
|
||||||
|
if capture:
|
||||||
|
kwargs["stdout"] = subprocess.PIPE
|
||||||
|
kwargs["stderr"] = subprocess.PIPE
|
||||||
|
return subprocess.run(cmd, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# -- Component checks -------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def check_node():
|
||||||
|
node = find_executable("node")
|
||||||
|
if not node:
|
||||||
|
return None, "Node.js not found"
|
||||||
|
try:
|
||||||
|
result = run([node, "-e", "process.stdout.write(String(process.versions.node.split('.')[0]))"],
|
||||||
|
capture=True)
|
||||||
|
major = int(result.stdout.decode().strip())
|
||||||
|
if major < 18:
|
||||||
|
return None, f"Node.js v{major} found, but 18+ is required"
|
||||||
|
return node, f"Node.js v{major}"
|
||||||
|
except Exception as e:
|
||||||
|
return None, f"Node.js check failed: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def check_python():
|
||||||
|
"""Check if Python 3.9+ is available (we're running in it, so yes)."""
|
||||||
|
v = sys.version_info
|
||||||
|
if v >= (3, 9):
|
||||||
|
return sys.executable, f"Python {v.major}.{v.minor}.{v.micro}"
|
||||||
|
return None, f"Python {v.major}.{v.minor} found, but 3.9+ is required"
|
||||||
|
|
||||||
|
|
||||||
|
# -- Installers --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def install_statusline(node_bin):
|
||||||
|
"""Install CLI statusline + fetcher (Node.js)."""
|
||||||
|
header("Installing CLI Statusline + Fetcher")
|
||||||
|
|
||||||
|
os.makedirs(INSTALL_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
# Copy scripts
|
||||||
|
for fname in ("statusline.js", "fetch-usage.js"):
|
||||||
|
src = os.path.join(SCRIPT_DIR, fname)
|
||||||
|
dst = os.path.join(INSTALL_DIR, fname)
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
if not IS_WINDOWS:
|
||||||
|
os.chmod(dst, os.stat(dst).st_mode | stat.S_IXUSR)
|
||||||
|
step(f"Scripts copied to {INSTALL_DIR}")
|
||||||
|
|
||||||
|
# Configure Claude Code statusline
|
||||||
|
os.makedirs(CLAUDE_DIR, exist_ok=True)
|
||||||
|
statusline_cmd = f"{node_bin} --no-warnings {os.path.join(INSTALL_DIR, 'statusline.js')}"
|
||||||
|
|
||||||
|
if os.path.exists(SETTINGS_PATH):
|
||||||
|
with open(SETTINGS_PATH, "r") as f:
|
||||||
|
try:
|
||||||
|
settings = json.load(f)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
settings = {}
|
||||||
|
|
||||||
|
if "statusLine" in settings:
|
||||||
|
step("statusLine already configured in settings.json — skipping")
|
||||||
|
else:
|
||||||
|
settings["statusLine"] = {"type": "command", "command": statusline_cmd}
|
||||||
|
with open(SETTINGS_PATH, "w") as f:
|
||||||
|
json.dump(settings, f, indent=2)
|
||||||
|
f.write("\n")
|
||||||
|
step(f"Added statusLine to {SETTINGS_PATH}")
|
||||||
|
else:
|
||||||
|
settings = {"statusLine": {"type": "command", "command": statusline_cmd}}
|
||||||
|
with open(SETTINGS_PATH, "w") as f:
|
||||||
|
json.dump(settings, f, indent=2)
|
||||||
|
f.write("\n")
|
||||||
|
step(f"Created {SETTINGS_PATH}")
|
||||||
|
|
||||||
|
# Cron setup
|
||||||
|
if not IS_WINDOWS:
|
||||||
|
cron_line = f"*/5 * * * * {node_bin} {os.path.join(INSTALL_DIR, 'fetch-usage.js')} 2>/dev/null"
|
||||||
|
if ask_yes_no(" Set up cron job for usage fetcher (every 5 min)?"):
|
||||||
|
try:
|
||||||
|
result = run(["crontab", "-l"], capture=True, check=False)
|
||||||
|
existing = result.stdout.decode() if result.returncode == 0 else ""
|
||||||
|
if "fetch-usage.js" in existing:
|
||||||
|
step("Cron job already exists — skipping")
|
||||||
|
else:
|
||||||
|
new_crontab = existing.rstrip("\n") + "\n" + cron_line + "\n"
|
||||||
|
proc = subprocess.Popen(["crontab", "-"], stdin=subprocess.PIPE)
|
||||||
|
proc.communicate(input=new_crontab.encode())
|
||||||
|
step("Cron job added")
|
||||||
|
except Exception as e:
|
||||||
|
step(f"Could not set up cron: {e}")
|
||||||
|
print(f" Manual setup: crontab -e, then add:")
|
||||||
|
print(f" {cron_line}")
|
||||||
|
else:
|
||||||
|
print(f"\n To set up manually later:")
|
||||||
|
print(f" crontab -e")
|
||||||
|
print(f" {cron_line}")
|
||||||
|
|
||||||
|
step("CLI Statusline installation complete")
|
||||||
|
|
||||||
|
|
||||||
|
def install_widget(python_bin):
|
||||||
|
"""Install desktop tray widget (Python)."""
|
||||||
|
header("Installing Desktop Widget")
|
||||||
|
|
||||||
|
if not IS_WINDOWS:
|
||||||
|
# Install system deps for AppIndicator (Linux)
|
||||||
|
step("Checking system dependencies...")
|
||||||
|
deps = ["python3-gi", "gir1.2-ayatanaappindicator3-0.1", "python3-venv", "python3-tk"]
|
||||||
|
if ask_yes_no(f" Install system packages ({', '.join(deps)})? (requires sudo)"):
|
||||||
|
try:
|
||||||
|
run(["sudo", "apt-get", "update", "-qq"])
|
||||||
|
run(["sudo", "apt-get", "install", "-y", "-qq"] + deps)
|
||||||
|
step("System packages installed")
|
||||||
|
except Exception as e:
|
||||||
|
step(f"Warning: Could not install system packages: {e}")
|
||||||
|
print(" You may need to install them manually.")
|
||||||
|
|
||||||
|
os.makedirs(INSTALL_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
if IS_WINDOWS:
|
||||||
|
# Install directly into user Python
|
||||||
|
step("Installing Python packages...")
|
||||||
|
run([python_bin, "-m", "pip", "install", "--quiet", "--upgrade", "pystray", "Pillow"])
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
dst = os.path.join(INSTALL_DIR, "claude_usage_widget")
|
||||||
|
if os.path.exists(dst):
|
||||||
|
shutil.rmtree(dst)
|
||||||
|
shutil.copytree(os.path.join(SCRIPT_DIR, "claude_usage_widget"), dst)
|
||||||
|
step(f"Widget source copied to {INSTALL_DIR}")
|
||||||
|
|
||||||
|
# Startup shortcut
|
||||||
|
if ask_yes_no(" Create Windows Startup shortcut (autostart on login)?"):
|
||||||
|
_create_windows_shortcut(python_bin)
|
||||||
|
else:
|
||||||
|
# Create venv with system-site-packages for gi access
|
||||||
|
venv_dir = os.path.join(INSTALL_DIR, "venv")
|
||||||
|
step(f"Creating venv at {venv_dir}...")
|
||||||
|
run([python_bin, "-m", "venv", "--system-site-packages", venv_dir])
|
||||||
|
|
||||||
|
venv_python = os.path.join(venv_dir, "bin", "python")
|
||||||
|
venv_pip = os.path.join(venv_dir, "bin", "pip")
|
||||||
|
|
||||||
|
step("Installing Python packages into venv...")
|
||||||
|
run([venv_pip, "install", "--quiet", "--upgrade", "pip"])
|
||||||
|
run([venv_pip, "install", "--quiet", "pystray", "Pillow"])
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
dst = os.path.join(INSTALL_DIR, "claude_usage_widget")
|
||||||
|
if os.path.exists(dst):
|
||||||
|
shutil.rmtree(dst)
|
||||||
|
shutil.copytree(os.path.join(SCRIPT_DIR, "claude_usage_widget"), dst)
|
||||||
|
step(f"Widget source copied to {INSTALL_DIR}")
|
||||||
|
|
||||||
|
# Autostart .desktop file
|
||||||
|
if ask_yes_no(" Create autostart entry (launch widget on login)?"):
|
||||||
|
autostart_dir = os.path.join(os.path.expanduser("~"), ".config", "autostart")
|
||||||
|
os.makedirs(autostart_dir, exist_ok=True)
|
||||||
|
desktop_path = os.path.join(autostart_dir, "claude-usage-widget.desktop")
|
||||||
|
with open(desktop_path, "w") as f:
|
||||||
|
f.write(textwrap.dedent(f"""\
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Claude Usage Widget
|
||||||
|
Comment=System tray widget showing Claude API usage
|
||||||
|
Exec={venv_python} -m claude_usage_widget
|
||||||
|
Hidden=false
|
||||||
|
NoDisplay=false
|
||||||
|
X-GNOME-Autostart-enabled=true
|
||||||
|
StartupNotify=false
|
||||||
|
Terminal=false
|
||||||
|
"""))
|
||||||
|
step(f"Autostart entry created at {desktop_path}")
|
||||||
|
|
||||||
|
python_bin = venv_python # for the launch hint
|
||||||
|
|
||||||
|
step("Desktop Widget installation complete")
|
||||||
|
|
||||||
|
# Print launch command
|
||||||
|
if IS_WINDOWS:
|
||||||
|
pythonw = find_executable("pythonw") or python_bin
|
||||||
|
print(f"\n Launch now: {pythonw} -m claude_usage_widget")
|
||||||
|
print(f" (from {INSTALL_DIR})")
|
||||||
|
else:
|
||||||
|
print(f"\n Launch now: {python_bin} -m claude_usage_widget")
|
||||||
|
|
||||||
|
|
||||||
|
def _create_windows_shortcut(python_bin):
|
||||||
|
"""Create a .lnk shortcut in Windows Startup folder."""
|
||||||
|
try:
|
||||||
|
startup_dir = os.path.join(
|
||||||
|
os.environ["APPDATA"], "Microsoft", "Windows", "Start Menu", "Programs", "Startup"
|
||||||
|
)
|
||||||
|
pythonw = find_executable("pythonw")
|
||||||
|
if not pythonw:
|
||||||
|
pythonw = python_bin.replace("python.exe", "pythonw.exe")
|
||||||
|
|
||||||
|
# Use PowerShell to create .lnk (no COM dependency)
|
||||||
|
lnk_path = os.path.join(startup_dir, "Claude Usage Widget.lnk")
|
||||||
|
ps_script = textwrap.dedent(f"""\
|
||||||
|
$ws = New-Object -ComObject WScript.Shell
|
||||||
|
$s = $ws.CreateShortcut("{lnk_path}")
|
||||||
|
$s.TargetPath = "{pythonw}"
|
||||||
|
$s.Arguments = "-m claude_usage_widget"
|
||||||
|
$s.WorkingDirectory = "{INSTALL_DIR}"
|
||||||
|
$s.Description = "Claude Usage Widget"
|
||||||
|
$s.Save()
|
||||||
|
""")
|
||||||
|
run(["powershell", "-Command", ps_script])
|
||||||
|
step(f"Startup shortcut created at {lnk_path}")
|
||||||
|
except Exception as e:
|
||||||
|
step(f"Warning: Could not create shortcut: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Session key setup -------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def setup_session_key():
|
||||||
|
"""Prompt user for session key if not already set."""
|
||||||
|
os.makedirs(CONFIG_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
existing = ""
|
||||||
|
try:
|
||||||
|
with open(SESSION_KEY_PATH, "r") as f:
|
||||||
|
existing = f.read().strip()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if existing and not existing.startswith("#"):
|
||||||
|
step(f"Session key already configured ({len(existing)} chars)")
|
||||||
|
if not ask_yes_no(" Update session key?", default=False):
|
||||||
|
return
|
||||||
|
print()
|
||||||
|
|
||||||
|
print(" To get your session key:")
|
||||||
|
print(" 1. Log into https://claude.ai in any browser")
|
||||||
|
print(" 2. Open DevTools -> Application -> Cookies -> claude.ai")
|
||||||
|
print(" 3. Copy the value of the 'sessionKey' cookie")
|
||||||
|
print()
|
||||||
|
key = ask_input(" Paste session key (or leave blank to skip)")
|
||||||
|
|
||||||
|
if key:
|
||||||
|
with open(SESSION_KEY_PATH, "w") as f:
|
||||||
|
f.write(key + "\n")
|
||||||
|
if not IS_WINDOWS:
|
||||||
|
os.chmod(SESSION_KEY_PATH, 0o600)
|
||||||
|
step("Session key saved")
|
||||||
|
else:
|
||||||
|
if not existing or existing.startswith("#"):
|
||||||
|
with open(SESSION_KEY_PATH, "w") as f:
|
||||||
|
f.write("# Paste your claude.ai sessionKey cookie value here\n")
|
||||||
|
if not IS_WINDOWS:
|
||||||
|
os.chmod(SESSION_KEY_PATH, 0o600)
|
||||||
|
step(f"Skipped — edit {SESSION_KEY_PATH} later")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Main wizard -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
header("claude-statusline installer")
|
||||||
|
print(" This wizard installs Claude usage monitoring components.")
|
||||||
|
print(f" OS: {platform.system()} {platform.release()}")
|
||||||
|
print(f" Install dir: {INSTALL_DIR}")
|
||||||
|
print(f" Config dir: {CONFIG_DIR}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Check available runtimes
|
||||||
|
node_bin, node_status = check_node()
|
||||||
|
python_bin, python_status = check_python()
|
||||||
|
|
||||||
|
print(f" Node.js: {node_status}")
|
||||||
|
print(f" Python: {python_status}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Component selection
|
||||||
|
options = []
|
||||||
|
if node_bin and python_bin:
|
||||||
|
choice = ask_choice("What would you like to install?", [
|
||||||
|
"CLI Statusline + Fetcher (for Claude Code / headless servers)",
|
||||||
|
"Desktop Widget (system tray usage monitor)",
|
||||||
|
"Both",
|
||||||
|
])
|
||||||
|
install_cli = choice in (0, 2)
|
||||||
|
install_wid = choice in (1, 2)
|
||||||
|
elif node_bin:
|
||||||
|
print(" Only Node.js available — CLI Statusline + Fetcher will be installed.")
|
||||||
|
print(" (Install Python 3.9+ to also use the Desktop Widget.)")
|
||||||
|
install_cli = True
|
||||||
|
install_wid = False
|
||||||
|
elif python_bin:
|
||||||
|
print(" Only Python available — Desktop Widget will be installed.")
|
||||||
|
print(" (Install Node.js 18+ to also use the CLI Statusline.)")
|
||||||
|
install_cli = False
|
||||||
|
install_wid = True
|
||||||
|
else:
|
||||||
|
print(" ERROR: Neither Node.js 18+ nor Python 3.9+ found.")
|
||||||
|
print(" Install at least one runtime and try again.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Session key
|
||||||
|
header("Session Key Setup")
|
||||||
|
setup_session_key()
|
||||||
|
|
||||||
|
# Install selected components
|
||||||
|
if install_cli:
|
||||||
|
install_statusline(node_bin)
|
||||||
|
|
||||||
|
if install_wid:
|
||||||
|
install_widget(python_bin)
|
||||||
|
|
||||||
|
# Done
|
||||||
|
header("Installation Complete")
|
||||||
|
if install_cli:
|
||||||
|
print(" CLI Statusline: Restart Claude Code to see the status bar.")
|
||||||
|
if install_wid:
|
||||||
|
print(" Desktop Widget: Launch or log out/in to start via autostart.")
|
||||||
|
print()
|
||||||
|
print(" Session key: " + SESSION_KEY_PATH)
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-statusline",
|
"name": "claude-statusline",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Standalone Claude Code statusline for headless Linux servers",
|
"description": "Claude Code usage monitoring — CLI statusline (Node.js) + desktop tray widget (Python)",
|
||||||
"main": "statusline.js",
|
"main": "statusline.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"statusline": "node statusline.js",
|
"statusline": "node statusline.js",
|
||||||
|
|||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pystray>=0.19
|
||||||
|
Pillow>=9.0
|
||||||
Reference in New Issue
Block a user