Rewrite in Go: static binaries, zero runtime dependencies
Some checks failed
Release / build (push) Failing after 21s

Replace Node.js + Python codebase with three Go binaries:
- claude-statusline: CLI status bar for Claude Code
- claude-fetcher: standalone cron job for API usage
- claude-widget: system tray icon (fyne-io/systray + fogleman/gg)

All CGO-free for trivial cross-compilation. Add nfpm .deb packaging
with autostart and cron. CI pipeline produces Linux + Windows binaries,
.deb, .tar.gz, and .zip release assets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Axel Meyer
2026-02-26 15:27:10 +00:00
parent 59afabd65a
commit 7f17a40b7c
33 changed files with 1275 additions and 1512 deletions

View File

@@ -6,76 +6,74 @@ on:
- 'v*' - 'v*'
jobs: jobs:
release: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: golang:1.23
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Extract version from tag - name: Extract version
id: version run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Build source archives - name: Install nfpm
run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@latest
- name: Build Linux binaries
run: | run: |
PROJECT="claude-statusline-${{ steps.version.outputs.VERSION }}" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o claude-statusline ./cmd/statusline
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o claude-fetcher ./cmd/fetcher
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o claude-widget ./cmd/widget
# Files to include in the release archives - name: Build Windows binaries
INCLUDE=( run: |
statusline.js CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o claude-statusline.exe ./cmd/statusline
fetch-usage.js CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o claude-fetcher.exe ./cmd/fetcher
install.sh CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H=windowsgui" -o claude-widget.exe ./cmd/widget
install.ps1
install_wizard.py
package.json
requirements.txt
README.md
CHANGELOG.md
LICENSE
.gitattributes
claude_usage_widget/__init__.py
claude_usage_widget/__main__.py
claude_usage_widget/app.py
claude_usage_widget/config.py
claude_usage_widget/fetcher.py
claude_usage_widget/menu.py
claude_usage_widget/renderer.py
)
mkdir -p "dist/${PROJECT}/claude_usage_widget" - name: Build .deb package
run: |
export PATH=$PATH:$(go env GOPATH)/bin
VERSION=${{ env.VERSION }} nfpm package --config packaging/nfpm.yaml --packager deb --target claude-statusline_${{ env.VERSION }}_amd64.deb
for f in "${INCLUDE[@]}"; do - name: Create Linux tarball
[ -f "$f" ] && cp "$f" "dist/${PROJECT}/$f" run: |
done mkdir -p dist/claude-statusline-${{ env.VERSION }}
cp claude-statusline claude-fetcher claude-widget dist/claude-statusline-${{ env.VERSION }}/
cp README.md CHANGELOG.md dist/claude-statusline-${{ env.VERSION }}/
cp packaging/linux/claude-widget.desktop dist/claude-statusline-${{ env.VERSION }}/
cp packaging/linux/claude-statusline-fetch dist/claude-statusline-${{ env.VERSION }}/
tar -czf claude-statusline_${{ env.VERSION }}_linux_amd64.tar.gz -C dist claude-statusline-${{ env.VERSION }}
# tar.gz for Linux / macOS - name: Create Windows zip
tar -czf "dist/${PROJECT}.tar.gz" -C dist "${PROJECT}" run: |
apt-get update -qq && apt-get install -y -qq zip >/dev/null 2>&1
mkdir -p dist-win/claude-statusline-${{ env.VERSION }}
cp claude-statusline.exe claude-fetcher.exe claude-widget.exe dist-win/claude-statusline-${{ env.VERSION }}/
cp README.md CHANGELOG.md dist-win/claude-statusline-${{ env.VERSION }}/
cd dist-win && zip -r ../claude-statusline_${{ env.VERSION }}_windows_amd64.zip claude-statusline-${{ env.VERSION }}
# zip for Windows - name: Create Gitea release
cd dist && zip -r "${PROJECT}.zip" "${PROJECT}" && cd ..
- name: Create release and upload assets
env: env:
TAG: ${{ github.ref_name }}
VERSION: ${{ steps.version.outputs.VERSION }}
TOKEN: ${{ secrets.RELEASE_TOKEN }} TOKEN: ${{ secrets.RELEASE_TOKEN }}
GITEA_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
run: | run: |
API="${GITEA_URL}/api/v1/repos/${REPO}" API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="${GITHUB_REF_NAME}"
# Create the release # Create release
RELEASE_ID=$(curl -s -X POST "${API}/releases" \ RELEASE_ID=$(curl -s -X POST "${API}/releases" \
-H "Authorization: token ${TOKEN}" \ -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":\"See [CHANGELOG.md](CHANGELOG.md) for details.\",\"draft\":false,\"prerelease\":false}" \ -d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":\"See CHANGELOG.md for details.\",\"draft\":false,\"prerelease\":false}" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") | grep -o '"id":[0-9]*' | grep -m1 -o '[0-9]*')
# Upload archives # Upload assets
for file in dist/claude-statusline-${VERSION}.tar.gz dist/claude-statusline-${VERSION}.zip; do for FILE in \
FILENAME=$(basename "$file") claude-statusline_${{ env.VERSION }}_amd64.deb \
curl -s -X POST "${API}/releases/${RELEASE_ID}/assets?name=${FILENAME}" \ claude-statusline_${{ env.VERSION }}_linux_amd64.tar.gz \
claude-statusline_${{ env.VERSION }}_windows_amd64.zip; do
curl -s -X POST "${API}/releases/${RELEASE_ID}/assets?name=${FILE}" \
-H "Authorization: token ${TOKEN}" \ -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \ -F "attachment=@${FILE}"
--data-binary "@${file}"
done done

23
.gitignore vendored
View File

@@ -1,9 +1,16 @@
__pycache__/ # Go binaries
*.pyc claude-statusline
*.pyo claude-fetcher
*.egg-info/ claude-widget
*.exe
# Build output
dist/ dist/
build/ dist-win/
.venv/ *.deb
venv/ *.tar.gz
node_modules/ *.zip
# IDE
.idea/
.vscode/

View File

@@ -4,6 +4,29 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
## [0.3.0] — 2026-02-26
Full rewrite from Node.js + Python to Go. Each platform gets a single static binary — no runtime dependencies.
### Changed
- **Complete Go rewrite** — replaced Node.js statusline/fetcher and Python widget with three Go binaries
- **Zero dependencies** — no Node.js, Python, pip, pystray, or system packages needed
- **Native packaging** — `.deb` for Debian/Ubuntu, `.zip` with static binaries for Windows, `.tar.gz` for Linux
- **CI/CD** — pipeline now cross-compiles Linux + Windows binaries and builds .deb via nfpm
### Added
- `claude-widget` — system tray icon (fyne-io/systray) with built-in fetcher and icon renderer (fogleman/gg)
- `claude-statusline` — CLI one-liner for Claude Code status bar (reads stdin + cache)
- `claude-fetcher` — standalone cron job (reads session key, fetches usage, writes cache)
- `.deb` package with autostart .desktop file and cron.d entry
- Windows cross-compilation with `-H=windowsgui` for console-free widget
### Removed
- `statusline.js`, `fetch-usage.js` (replaced by Go binaries)
- `claude_usage_widget/` Python package (replaced by Go binary)
- `install_wizard.py`, `install.sh`, `install.ps1` (replaced by .deb and .zip)
- `package.json`, `requirements.txt` (no runtime dependencies)
## [0.2.0] — 2026-02-26 ## [0.2.0] — 2026-02-26
First tagged release. Includes the CLI statusline, standalone usage fetcher, cross-platform desktop widget, and installer wizard. First tagged release. Includes the CLI statusline, standalone usage fetcher, cross-platform desktop widget, and installer wizard.
@@ -21,4 +44,5 @@ First tagged release. Includes the CLI statusline, standalone usage fetcher, cro
- Tray icon visibility — switched to Claude orange with full opacity at larger size - Tray icon visibility — switched to Claude orange with full opacity at larger size
- Block comment syntax error in cron example - Block comment syntax error in cron example
[0.3.0]: https://git.davoryn.de/calic/claude-statusline/releases/tag/v0.3.0
[0.2.0]: https://git.davoryn.de/calic/claude-statusline/releases/tag/v0.2.0 [0.2.0]: https://git.davoryn.de/calic/claude-statusline/releases/tag/v0.2.0

243
README.md
View File

@@ -5,9 +5,9 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/Node.js-18+-339933?logo=node.js&logoColor=white" alt="Node.js 18+" /> <img src="https://img.shields.io/badge/Go-1.21+-00ADD8?logo=go&logoColor=white" alt="Go 1.21+" />
<img src="https://img.shields.io/badge/Python-3.9+-3776AB?logo=python&logoColor=white" alt="Python 3.9+" /> <img src="https://img.shields.io/badge/Platform-Linux%20|%20Windows-informational" alt="Platform: Linux | Windows" />
<img src="https://img.shields.io/badge/Platform-Linux%20|%20macOS%20|%20Windows-informational" alt="Platform: Linux | macOS | Windows" /> <img src="https://img.shields.io/badge/Dependencies-zero-brightgreen" alt="Zero dependencies" />
<img src="https://img.shields.io/badge/license-MIT-green" alt="MIT License" /> <img src="https://img.shields.io/badge/license-MIT-green" alt="MIT License" />
<br /> <br />
<a href="https://git.davoryn.de/calic/claude-statusline/releases"><img src="https://img.shields.io/badge/releases-Gitea-blue?logo=gitea&logoColor=white" alt="Releases" /></a> <a href="https://git.davoryn.de/calic/claude-statusline/releases"><img src="https://img.shields.io/badge/releases-Gitea-blue?logo=gitea&logoColor=white" alt="Releases" /></a>
@@ -17,156 +17,90 @@
## Overview ## Overview
Both components share the same session key and fetcher logic. Install one or both depending on your setup. Three static binaries built from one Go codebase. No runtime dependencies — no Node.js, Python, or system packages needed.
### CLI Statusline (Node.js) ### CLI Statusline
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. Headless status bar for Claude Code. Shows context window utilization and token usage as text progress bars.
``` ```
Context ▓▓▓▓░░░░░░ 40% | Token ▓▓░░░░░░░░ 19% 78M Context ▓▓▓▓░░░░░░ 40% | Token ▓▓░░░░░░░░ 19% 78M
``` ```
### Usage Fetcher (Node.js) ### Usage Fetcher
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. Standalone binary for cron. Fetches token usage from the Claude API and writes a shared JSON cache.
### Desktop Widget (Python) ### Desktop Widget
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. System tray icon showing 5-hour usage as a circular progress bar on a Claude starburst logo. Color shifts from green through amber to red as usage increases. Right-click menu shows detailed stats and configuration.
## Topology ## Topology
```mermaid ```
graph TD claude.ai API
API["claude.ai API"]
FetchJS["fetch-usage.js<br/>(cron / Node.js)"] ├──► claude-fetcher (cron) ──► /tmp/claude_usage.json ──► claude-statusline (Claude Code)
FetchPy["fetcher.py thread<br/>(Python / urllib)"] │ │
Cache["/tmp/claude_usage.json<br/>(shared cache)"] └──► claude-widget (built-in fetcher) ──┘──► System tray icon
SL["statusline.js<br/>(Claude Code)"]
Widget["app.py<br/>(pystray tray icon)"]
CC["Claude Code status bar"]
Tray["System tray icon<br/>+ right-click menu"]
SK["~/.config/claude-statusline/session-key"]
API --> FetchJS
API --> FetchPy
FetchJS --> Cache
FetchPy --> Cache
Cache --> SL
Cache --> Widget
SL --> CC
Widget --> Tray
SK -.->|auth| FetchJS
SK -.->|auth| FetchPy
``` ```
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: Only one fetcher needs to run. The widget has a built-in fetcher; the standalone `claude-fetcher` is for headless/cron setups. Both write the same cache format.
- **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 ## Installation
### Quick Install (from release) ### Debian/Ubuntu (.deb)
Download the latest archive from the [Releases page](https://git.davoryn.de/calic/claude-statusline/releases) for your platform:
| Platform | Download | Install |
|----------|----------|---------|
| **Linux** | `.tar.gz` source archive | Extract, then `bash install.sh` |
| **macOS** | `.tar.gz` source archive | Extract, then `bash install.sh` |
| **Windows** | `.zip` source archive | Extract, then run `install.ps1` (see [Windows Quick Start](#windows-quick-start) below) |
> **Planned:** Native installers (`.deb`, `.msi`) are on the roadmap. For now, releases contain source archives.
### Developer Install (from source)
Clone the repository and run the installer wizard:
**Linux / macOS:**
```bash
git clone https://git.davoryn.de/calic/claude-statusline.git
cd claude-statusline
bash install.sh
```
**Windows (PowerShell):**
```powershell
git clone https://git.davoryn.de/calic/claude-statusline.git
cd claude-statusline
powershell -ExecutionPolicy Bypass -File install.ps1
```
### What the wizard does
1. Asks which components to install (CLI statusline, desktop widget, or both)
2. Guides you through session key setup
3. Configures autostart (widget) or cron (CLI fetcher) as applicable
4. Sets up the Claude Code statusline integration
### Windows Quick Start
If you're new to the command line, follow these steps:
1. Go to the [Releases page](https://git.davoryn.de/calic/claude-statusline/releases) and download the latest `.zip` file
2. Extract the zip to any folder (e.g. `C:\Users\YourName\claude-statusline`)
3. Open the extracted folder in File Explorer
4. Right-click `install.ps1` and select **Run with PowerShell**
- If you see a security prompt, choose **Run anyway** or **Open**
- Alternatively, open PowerShell manually and run:
```powershell
powershell -ExecutionPolicy Bypass -File install.ps1
```
5. The wizard will walk you through component selection and session key setup
6. To find your session key, see [Session Key](#session-key) below
### 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.
```mermaid
sequenceDiagram
participant User
participant Browser
participant Installer
participant Config
User->>Browser: Log into claude.ai
User->>Browser: DevTools → Cookies → sessionKey
User->>Installer: Paste session key when prompted
Installer->>Config: ~/.config/claude-statusline/session-key
```
**Step by step:**
1. Log into [claude.ai](https://claude.ai) in any browser
2. Open DevTools (press `F12`), go to **Application** → **Cookies** → `https://claude.ai`
3. Copy the value of the `sessionKey` cookie (it starts with `sk-ant-`)
4. The installer will prompt you to enter it, or set it manually:
```bash ```bash
sudo dpkg -i claude-statusline_0.3.0_amd64.deb
```
Installs all three binaries to `/usr/bin/`, sets up autostart for the widget, and adds a cron job for the fetcher.
### Linux (tar.gz)
```bash
tar xzf claude-statusline_0.3.0_linux_amd64.tar.gz
sudo cp claude-statusline-0.3.0/claude-{statusline,fetcher,widget} /usr/local/bin/
```
### Windows
Extract the `.zip` and place the `.exe` files anywhere on your PATH.
### Session Key Setup
After installing, paste your claude.ai session key:
```bash
mkdir -p ~/.config/claude-statusline
echo "sk-ant-..." > ~/.config/claude-statusline/session-key echo "sk-ant-..." > ~/.config/claude-statusline/session-key
chmod 600 ~/.config/claude-statusline/session-key chmod 600 ~/.config/claude-statusline/session-key
``` ```
Alternatively, set the `CLAUDE_SESSION_KEY` environment variable. **To find your session key:**
1. Log into [claude.ai](https://claude.ai)
2. Open DevTools (`F12`) → **Application****Cookies**`https://claude.ai`
3. Copy the `sessionKey` cookie value
Or set `CLAUDE_SESSION_KEY` as an environment variable.
### Claude Code Integration
Add to your Claude Code settings (`~/.claude/settings.json`):
```json
{
"statusLine": {
"type": "command",
"command": "claude-statusline"
}
}
```
## Configuration ## Configuration
### CLI Statusline ### Environment Variables
Environment variables:
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
@@ -174,22 +108,17 @@ Environment variables:
| `CLAUDE_USAGE_MAX_AGE` | `900` | Max cache age in seconds | | `CLAUDE_USAGE_MAX_AGE` | `900` | Max cache age in seconds |
| `CLAUDE_SESSION_KEY` | — | Session key (alternative to config file) | | `CLAUDE_SESSION_KEY` | — | Session key (alternative to config file) |
| `CLAUDE_STATUSLINE_CONFIG` | `~/.config/claude-statusline` | Config directory | | `CLAUDE_STATUSLINE_CONFIG` | `~/.config/claude-statusline` | Config directory |
| `CLAUDE_ORG_ID` | — | Organization ID (auto-discovered) |
### Desktop Widget ### Widget Menu
Right-click the tray icon to access: Right-click the tray icon to access:
- **Usage stats** — 5-hour and 7-day utilization with reset timers - **Usage stats** — 5-hour and 7-day utilization with reset timers
- **Refresh Now** — trigger an immediate fetch - **Refresh Now** — trigger an immediate fetch
- **Refresh Interval** — 1 / 5 / 15 / 30 minutes - **Refresh Interval** — 1 / 5 / 15 / 30 minutes
- **Session Key...** — update the session key via dialog - **Session Key...** — open session key file in editor
Widget settings are stored in `~/.config/claude-statusline/widget-config.json`.
### Icon Color Scale ### Icon Color Scale
The tray icon arc color indicates usage severity at 10% increments:
| Range | Color | | Range | Color |
|-------|-------| |-------|-------|
| 010% | Green | | 010% | Green |
@@ -203,35 +132,37 @@ The tray icon arc color indicates usage severity at 10% increments:
| 8090% | Deep orange | | 8090% | Deep orange |
| 90100% | Red | | 90100% | Red |
## Releases ## Building from Source
Tagged releases are published on [Gitea](https://git.davoryn.de/calic/claude-statusline/releases) following semver (`v0.2.0`, `v0.3.0`, etc.). Each release includes per-OS source archives. ```bash
# All binaries
go build ./cmd/statusline && go build ./cmd/fetcher && go build ./cmd/widget
**Planned future additions:** # Cross-compile for Windows
- `.deb` package for Debian/Ubuntu GOOS=windows GOARCH=amd64 go build -ldflags "-H=windowsgui" -o claude-widget.exe ./cmd/widget
- `.msi` / `.exe` installer for Windows GOOS=windows GOARCH=amd64 go build -o claude-statusline.exe ./cmd/statusline
- Homebrew tap for macOS GOOS=windows GOARCH=amd64 go build -o claude-fetcher.exe ./cmd/fetcher
# Build .deb
VERSION=0.3.0 nfpm package --config packaging/nfpm.yaml --packager deb
```
## Project Structure ## Project Structure
``` ```
claude-statusline/ cmd/
├── statusline.js # CLI status bar (reads stdin + cache) statusline/main.go # CLI statusline (reads stdin + cache)
├── fetch-usage.js # Cron-based usage fetcher (writes cache) fetcher/main.go # Standalone cron fetcher (writes cache)
├── install.sh # Linux/macOS installer wrapper widget/main.go # Desktop tray widget entry point
├── install.ps1 # Windows installer wrapper internal/
├── install_wizard.py # Cross-platform installer wizard config/config.go # Shared config (session key, org ID, intervals)
├── package.json fetcher/fetcher.go # HTTP fetch logic (shared between widget + standalone)
├── requirements.txt # Python deps (widget only) fetcher/cache.go # JSON cache read/write (/tmp/claude_usage.json)
├── claude_usage_widget/ renderer/renderer.go # Icon rendering: starburst + arc (fogleman/gg)
│ ├── __init__.py tray/tray.go # System tray setup + menu (fyne-io/systray)
│ ├── __main__.py # Entry point: python -m claude_usage_widget packaging/
│ ├── app.py # Tray icon orchestrator nfpm.yaml # .deb packaging config
│ ├── config.py # Shared config (~/.config/claude-statusline/) linux/ # .desktop file, cron, install scripts
│ ├── 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 ## License

View File

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

View File

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

View File

@@ -1,91 +0,0 @@
"""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

@@ -1,62 +0,0 @@
"""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

@@ -1,240 +0,0 @@
"""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

@@ -1,68 +0,0 @@
"""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

@@ -1,122 +0,0 @@
"""Icon renderer: Claude starburst logo + circular usage arc."""
import math
from PIL import Image, ImageDraw
SIZE = 256
CENTER = SIZE // 2
ARC_WIDTH = 28
# Claude brand orange
_CLAUDE_ORANGE = (224, 123, 83) # #E07B53
# 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 in Claude orange, full opacity."""
layer = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
color = (*_CLAUDE_ORANGE, 255)
num_petals = 8
petal_length = SIZE * 0.38 # fill most of the icon
petal_width = SIZE * 0.10
for i in range(num_petals):
angle = math.radians(i * (360 / num_petals))
# 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 = SIZE * 0.05
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) * (SIZE * 0.02)
inner_y = CENTER - math.sin(angle) * (SIZE * 0.02)
polygon = [(inner_x, inner_y), (left_x, left_y), (tip_x, tip_y), (right_x, right_y)]
draw.polygon(polygon, fill=color)
# Center dot
dot_r = SIZE * 0.04
draw.ellipse(
[CENTER - dot_r, CENTER - dot_r, CENTER + dot_r, CENTER + dot_r],
fill=color,
)
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)
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 256x256 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

55
cmd/fetcher/main.go Normal file
View File

@@ -0,0 +1,55 @@
package main
import (
"fmt"
"os"
"git.davoryn.de/calic/claude-statusline/internal/config"
"git.davoryn.de/calic/claude-statusline/internal/fetcher"
)
func main() {
sessionKey := config.GetSessionKey()
if sessionKey == "" {
fmt.Fprintln(os.Stderr, "error: no session key (set CLAUDE_SESSION_KEY or write to "+config.SessionKeyPath()+")")
os.Exit(1)
}
cfg := config.Load()
data, orgID, err := fetcher.FetchUsage(sessionKey, cfg.OrgID)
if err != nil {
if data != nil {
// Write error state to cache so statusline can display it
_ = fetcher.WriteCache(data)
}
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if err := fetcher.WriteCache(data); err != nil {
fmt.Fprintf(os.Stderr, "error writing cache: %v\n", err)
os.Exit(1)
}
// Persist discovered org ID
if orgID != "" && orgID != cfg.OrgID {
cfg.OrgID = orgID
_ = config.Save(cfg)
}
parsed := fetcher.ParseUsage(data)
if parsed.FiveHourPct > 0 {
fmt.Printf("5h: %d%%", parsed.FiveHourPct)
if parsed.FiveHourResetsIn != "" {
fmt.Printf(" (resets in %s)", parsed.FiveHourResetsIn)
}
fmt.Println()
}
if parsed.SevenDayPct > 0 {
fmt.Printf("7d: %d%%", parsed.SevenDayPct)
if parsed.SevenDayResetsIn != "" {
fmt.Printf(" (resets in %s)", parsed.SevenDayResetsIn)
}
fmt.Println()
}
}

120
cmd/statusline/main.go Normal file
View File

@@ -0,0 +1,120 @@
package main
import (
"encoding/json"
"fmt"
"math"
"os"
"strconv"
"strings"
"time"
"git.davoryn.de/calic/claude-statusline/internal/fetcher"
)
const (
filledBlock = "\u2593"
lightBlock = "\u2591"
)
// stdinData is the JSON structure from Claude Code's stdin.
type stdinData struct {
ContextWindow struct {
UsedPercentage float64 `json:"used_percentage"`
} `json:"context_window"`
}
func bar(pct float64, width int) string {
pct = math.Max(0, math.Min(100, pct))
filled := int(math.Floor(pct / (100.0 / float64(width))))
return strings.Repeat(filledBlock, filled) + strings.Repeat(lightBlock, width-filled)
}
func getContextPart(data *stdinData) string {
if data == nil {
return ""
}
pct := data.ContextWindow.UsedPercentage
return fmt.Sprintf("Context %s %d%%", bar(pct, 10), int(pct))
}
func formatMinutes(isoStr string) string {
if isoStr == "" {
return ""
}
t, err := time.Parse(time.RFC3339, isoStr)
if err != nil {
return ""
}
mins := int(math.Max(0, time.Until(t).Minutes()))
return fmt.Sprintf("%dM", mins)
}
func getUsagePart(data *fetcher.CacheData) string {
if data == nil {
return ""
}
if data.Error != "" {
switch data.Error {
case "auth_expired":
return "Token: session expired"
case "no_session_key":
return ""
default:
return "Token: error"
}
}
var parts []string
if data.FiveHour != nil && data.FiveHour.Utilization > 0 {
pct := data.FiveHour.Utilization
s := fmt.Sprintf("Token %s %d%%", bar(pct, 10), int(math.Round(pct)))
if mins := formatMinutes(data.FiveHour.ResetsAt); mins != "" {
s += " " + mins
}
parts = append(parts, s)
}
if data.SevenDay != nil && data.SevenDay.Utilization > 20 {
parts = append(parts, fmt.Sprintf("7d %d%%", int(math.Round(data.SevenDay.Utilization))))
}
return strings.Join(parts, " | ")
}
func main() {
// Read stdin (context window data from Claude Code)
var input stdinData
var hasInput bool
dec := json.NewDecoder(os.Stdin)
if err := dec.Decode(&input); err == nil {
hasInput = true
}
// Read cache
maxAge := 900 * time.Second
if v := os.Getenv("CLAUDE_USAGE_MAX_AGE"); v != "" {
if secs, err := strconv.Atoi(v); err == nil {
maxAge = time.Duration(secs) * time.Second
}
}
cache := fetcher.ReadCacheIfFresh(maxAge)
// Build output
var parts []string
if hasInput {
if ctx := getContextPart(&input); ctx != "" {
parts = append(parts, ctx)
}
}
if usage := getUsagePart(cache); usage != "" {
parts = append(parts, usage)
}
if len(parts) > 0 {
fmt.Println(strings.Join(parts, " | "))
}
}

9
cmd/widget/main.go Normal file
View File

@@ -0,0 +1,9 @@
package main
import (
"git.davoryn.de/calic/claude-statusline/internal/tray"
)
func main() {
tray.Run()
}

View File

@@ -1,115 +0,0 @@
#!/usr/bin/env node
/**
* Claude Usage Fetcher (Standalone)
*
* Fetches token usage from claude.ai API using a session key.
* Designed to run as a cron job on headless servers.
*
* Session key source (checked in order):
* 1. CLAUDE_SESSION_KEY env var
* 2. ~/.config/claude-statusline/session-key (plain text file)
*
* To get your session key:
* 1. Log into claude.ai in any browser
* 2. Open DevTools → Application → Cookies → claude.ai
* 3. Copy the value of the "sessionKey" cookie
* 4. Save it: echo "sk-ant-..." > ~/.config/claude-statusline/session-key
*
* Output: Writes JSON to $CLAUDE_USAGE_CACHE or /tmp/claude_usage.json
*
* Cron: every 5 min (see install.sh or README for crontab line)
*/
const fs = require('fs');
const path = require('path');
// --- Config ---
const CONFIG_DIR = process.env.CLAUDE_STATUSLINE_CONFIG
|| path.join(process.env.HOME || '/root', '.config', 'claude-statusline');
const CACHE_FILE = process.env.CLAUDE_USAGE_CACHE
|| path.join(process.env.TMPDIR || '/tmp', 'claude_usage.json');
const ORG_ID = process.env.CLAUDE_ORG_ID || '';
// --- Cache writer ---
function writeCache(data) {
const cacheDir = path.dirname(CACHE_FILE);
if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2));
}
// --- Session Key ---
function getSessionKey() {
// env var first
if (process.env.CLAUDE_SESSION_KEY) return process.env.CLAUDE_SESSION_KEY.trim();
// config file
const keyFile = path.join(CONFIG_DIR, 'session-key');
try {
return fs.readFileSync(keyFile, 'utf8').trim();
} catch {
return null;
}
}
// --- Discover org ID ---
async function getOrgId(sessionKey) {
if (ORG_ID) return ORG_ID;
const resp = await fetch('https://claude.ai/api/organizations', {
headers: headers(sessionKey),
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) {
if (resp.status === 401 || resp.status === 403) {
writeCache({ _error: 'auth_expired', _status: resp.status });
}
throw new Error('Failed to list orgs: ' + resp.status);
}
const orgs = await resp.json();
if (!orgs.length) throw new Error('No organizations found');
return orgs[0].uuid;
}
function headers(sessionKey) {
return {
'Cookie': 'sessionKey=' + sessionKey,
'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',
};
}
// --- Fetch ---
async function main() {
const sessionKey = getSessionKey();
if (!sessionKey) {
console.error('No session key found. Set CLAUDE_SESSION_KEY or create ' +
path.join(CONFIG_DIR, 'session-key'));
process.exit(1);
}
try {
const orgId = await getOrgId(sessionKey);
const resp = await fetch('https://claude.ai/api/organizations/' + orgId + '/usage', {
headers: headers(sessionKey),
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) {
writeCache({ _error: resp.status === 401 || resp.status === 403
? 'auth_expired' : 'api_error', _status: resp.status });
console.error('API error: ' + resp.status);
process.exit(1);
}
const data = await resp.json();
writeCache(data);
console.log('OK \u2014 wrote ' + CACHE_FILE);
} catch (e) {
writeCache({ _error: 'fetch_failed', _message: e.message });
console.error('Fetch error: ' + e.message);
process.exit(1);
}
}
main();

15
go.mod Normal file
View File

@@ -0,0 +1,15 @@
module git.davoryn.de/calic/claude-statusline
go 1.21
require (
fyne.io/systray v1.11.0
github.com/fogleman/gg v1.3.0
)
require (
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/sys v0.15.0 // indirect
)

12
go.sum Normal file
View File

@@ -0,0 +1,12 @@
fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg=
fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

View File

@@ -1,26 +0,0 @@
# 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")

View File

@@ -1,28 +0,0 @@
#!/usr/bin/env bash
# Linux/macOS wrapper — launches the cross-platform installer wizard.
# Usage: bash install.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Find Python 3.9+
PYTHON=""
for candidate in python3 python; do
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
fi
exec "$PYTHON" "$SCRIPT_DIR/install_wizard.py"

View File

@@ -1,421 +0,0 @@
#!/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()

93
internal/config/config.go Normal file
View File

@@ -0,0 +1,93 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
)
// WidgetConfig holds the widget configuration.
type WidgetConfig struct {
RefreshInterval int `json:"refresh_interval"`
OrgID string `json:"org_id"`
}
var defaults = WidgetConfig{
RefreshInterval: 300,
OrgID: "",
}
// ConfigDir returns the platform-specific config directory.
func ConfigDir() string {
if v := os.Getenv("CLAUDE_STATUSLINE_CONFIG"); v != "" {
return v
}
if runtime.GOOS == "windows" {
return filepath.Join(os.Getenv("LOCALAPPDATA"), "claude-statusline")
}
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, "claude-statusline")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "claude-statusline")
}
// ConfigPath returns the path to widget-config.json.
func ConfigPath() string {
return filepath.Join(ConfigDir(), "widget-config.json")
}
// SessionKeyPath returns the path to the session-key file.
func SessionKeyPath() string {
return filepath.Join(ConfigDir(), "session-key")
}
// Load reads widget-config.json and merges with defaults.
func Load() WidgetConfig {
cfg := defaults
data, err := os.ReadFile(ConfigPath())
if err != nil {
return cfg
}
_ = json.Unmarshal(data, &cfg)
if cfg.RefreshInterval < 60 {
cfg.RefreshInterval = 60
}
return cfg
}
// Save writes widget-config.json.
func Save(cfg WidgetConfig) error {
dir := ConfigDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(ConfigPath(), data, 0o644)
}
// GetSessionKey returns the session key from env or file.
func GetSessionKey() string {
if v := os.Getenv("CLAUDE_SESSION_KEY"); v != "" {
return strings.TrimSpace(v)
}
data, err := os.ReadFile(SessionKeyPath())
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
// SetSessionKey writes the session key to file.
func SetSessionKey(key string) error {
dir := ConfigDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
return os.WriteFile(SessionKeyPath(), []byte(strings.TrimSpace(key)+"\n"), 0o600)
}

85
internal/fetcher/cache.go Normal file
View File

@@ -0,0 +1,85 @@
package fetcher
import (
"encoding/json"
"os"
"runtime"
"time"
)
// CacheData represents the JSON structure written to the cache file.
// It mirrors the raw API response (five_hour, seven_day) plus error fields.
type CacheData struct {
FiveHour *UsageWindow `json:"five_hour,omitempty"`
SevenDay *UsageWindow `json:"seven_day,omitempty"`
Error string `json:"_error,omitempty"`
Status int `json:"_status,omitempty"`
Message string `json:"_message,omitempty"`
}
// UsageWindow represents a single usage window from the API.
type UsageWindow struct {
Utilization float64 `json:"utilization"`
ResetsAt string `json:"resets_at"`
}
// CachePath returns the cache file path.
func CachePath() string {
if v := os.Getenv("CLAUDE_USAGE_CACHE"); v != "" {
return v
}
if runtime.GOOS == "windows" {
if tmp := os.Getenv("TEMP"); tmp != "" {
return tmp + `\claude_usage.json`
}
return os.TempDir() + `\claude_usage.json`
}
return "/tmp/claude_usage.json"
}
// WriteCache writes data to the cache file as JSON.
func WriteCache(data *CacheData) error {
b, err := json.MarshalIndent(data, "", " ")
if err != nil {
return err
}
return os.WriteFile(CachePath(), b, 0o644)
}
// ReadCache reads and parses the cache file. Returns data and file age.
// Returns nil if file doesn't exist or can't be parsed.
func ReadCache() (*CacheData, time.Duration) {
path := CachePath()
info, err := os.Stat(path)
if err != nil {
return nil, 0
}
age := time.Since(info.ModTime())
raw, err := os.ReadFile(path)
if err != nil {
return nil, 0
}
var data CacheData
if err := json.Unmarshal(raw, &data); err != nil {
return nil, 0
}
return &data, age
}
// ReadCacheIfFresh reads cache only if it's younger than maxAge.
// Error caches are always returned regardless of age.
func ReadCacheIfFresh(maxAge time.Duration) *CacheData {
data, age := ReadCache()
if data == nil {
return nil
}
if data.Error != "" {
return data
}
if age > maxAge {
return nil
}
return data
}

292
internal/fetcher/fetcher.go Normal file
View File

@@ -0,0 +1,292 @@
package fetcher
import (
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"sync"
"time"
"git.davoryn.de/calic/claude-statusline/internal/config"
)
const (
apiBase = "https://claude.ai"
userAgent = "Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0"
)
// ParsedUsage is the display-friendly usage data passed to callbacks.
type ParsedUsage struct {
FiveHourPct int
FiveHourResetsAt string
FiveHourResetsIn string
SevenDayPct int
SevenDayResetsAt string
SevenDayResetsIn string
Error string
}
// UpdateCallback is called when new usage data is available.
type UpdateCallback func(ParsedUsage)
// doRequest performs an authenticated HTTP GET to the Claude API.
func doRequest(url, sessionKey string) ([]byte, int, error) {
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, 0, err
}
req.Header.Set("Cookie", "sessionKey="+sessionKey)
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", "application/json")
req.Header.Set("Referer", "https://claude.ai/")
req.Header.Set("Origin", "https://claude.ai")
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, err
}
return body, resp.StatusCode, nil
}
// DiscoverOrgID fetches the first organization UUID from the API.
func DiscoverOrgID(sessionKey string) (string, error) {
body, status, err := doRequest(apiBase+"/api/organizations", sessionKey)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
}
if status == 401 || status == 403 {
return "", fmt.Errorf("auth_expired")
}
if status != 200 {
return "", fmt.Errorf("HTTP %d", status)
}
var orgs []struct {
UUID string `json:"uuid"`
}
if err := json.Unmarshal(body, &orgs); err != nil {
return "", fmt.Errorf("invalid response: %w", err)
}
if len(orgs) == 0 {
return "", fmt.Errorf("no organizations found")
}
return orgs[0].UUID, nil
}
// FetchUsage fetches usage data from the API. If orgID is empty, discovers it.
// Returns the raw cache data and the resolved org ID.
func FetchUsage(sessionKey, orgID string) (*CacheData, string, error) {
if orgID == "" {
var err error
orgID, err = DiscoverOrgID(sessionKey)
if err != nil {
if err.Error() == "auth_expired" {
return &CacheData{Error: "auth_expired", Status: 401}, "", err
}
return nil, "", err
}
}
url := fmt.Sprintf("%s/api/organizations/%s/usage", apiBase, orgID)
body, status, err := doRequest(url, sessionKey)
if err != nil {
return &CacheData{Error: "fetch_failed", Message: err.Error()}, orgID, err
}
if status == 401 || status == 403 {
return &CacheData{Error: "auth_expired", Status: status}, orgID, fmt.Errorf("auth_expired")
}
if status != 200 {
return &CacheData{Error: "api_error", Status: status}, orgID, fmt.Errorf("HTTP %d", status)
}
var data CacheData
if err := json.Unmarshal(body, &data); err != nil {
return nil, orgID, fmt.Errorf("invalid JSON: %w", err)
}
return &data, orgID, nil
}
// ParseUsage converts raw cache data into display-friendly format.
func ParseUsage(data *CacheData) ParsedUsage {
if data == nil {
return ParsedUsage{Error: "no data"}
}
if data.Error != "" {
msg := data.Error
if msg == "auth_expired" {
msg = "session expired"
}
return ParsedUsage{Error: msg}
}
p := ParsedUsage{}
if data.FiveHour != nil {
p.FiveHourPct = int(math.Round(data.FiveHour.Utilization))
p.FiveHourResetsAt = data.FiveHour.ResetsAt
p.FiveHourResetsIn = formatResetsIn(data.FiveHour.ResetsAt)
}
if data.SevenDay != nil {
p.SevenDayPct = int(math.Round(data.SevenDay.Utilization))
p.SevenDayResetsAt = data.SevenDay.ResetsAt
p.SevenDayResetsIn = formatResetsIn(data.SevenDay.ResetsAt)
}
return p
}
// formatResetsIn converts an ISO 8601 timestamp to a human-readable duration.
func formatResetsIn(isoStr string) string {
if isoStr == "" {
return ""
}
t, err := time.Parse(time.RFC3339, isoStr)
if err != nil {
return ""
}
total := int(math.Max(0, time.Until(t).Seconds()))
days := total / 86400
hours := (total % 86400) / 3600
minutes := (total % 3600) / 60
if days > 0 {
return fmt.Sprintf("%dd %dh", days, hours)
}
if hours > 0 {
return fmt.Sprintf("%dh %dm", hours, minutes)
}
return fmt.Sprintf("%dm", minutes)
}
// BackgroundFetcher runs periodic usage fetches in a goroutine.
type BackgroundFetcher struct {
onUpdate UpdateCallback
mu sync.Mutex
interval time.Duration
orgID string
stopCh chan struct{}
forceCh chan struct{}
}
// NewBackgroundFetcher creates a new background fetcher.
func NewBackgroundFetcher(onUpdate UpdateCallback) *BackgroundFetcher {
cfg := config.Load()
return &BackgroundFetcher{
onUpdate: onUpdate,
interval: time.Duration(cfg.RefreshInterval) * time.Second,
orgID: cfg.OrgID,
stopCh: make(chan struct{}),
forceCh: make(chan struct{}, 1),
}
}
// Start begins the background fetch loop.
func (bf *BackgroundFetcher) Start() {
go bf.loop()
}
// Stop signals the background fetcher to stop.
func (bf *BackgroundFetcher) Stop() {
close(bf.stopCh)
}
// Refresh forces an immediate fetch.
func (bf *BackgroundFetcher) Refresh() {
select {
case bf.forceCh <- struct{}{}:
default:
}
}
// SetInterval changes the refresh interval.
func (bf *BackgroundFetcher) SetInterval(seconds int) {
bf.mu.Lock()
bf.interval = time.Duration(seconds) * time.Second
bf.mu.Unlock()
cfg := config.Load()
cfg.RefreshInterval = seconds
_ = config.Save(cfg)
bf.Refresh()
}
// Interval returns the current refresh interval in seconds.
func (bf *BackgroundFetcher) Interval() int {
bf.mu.Lock()
defer bf.mu.Unlock()
return int(bf.interval.Seconds())
}
func (bf *BackgroundFetcher) loop() {
// Load from cache immediately for instant display
if data, _ := ReadCache(); data != nil {
bf.onUpdate(ParseUsage(data))
}
// Initial fetch
bf.doFetch(false)
for {
bf.mu.Lock()
interval := bf.interval
bf.mu.Unlock()
timer := time.NewTimer(interval)
select {
case <-bf.stopCh:
timer.Stop()
return
case <-bf.forceCh:
timer.Stop()
bf.doFetch(true)
case <-timer.C:
bf.doFetch(false)
}
}
}
func (bf *BackgroundFetcher) doFetch(force bool) {
bf.mu.Lock()
halfInterval := bf.interval / 2
bf.mu.Unlock()
if !force {
if cached := ReadCacheIfFresh(halfInterval); cached != nil && cached.Error == "" {
bf.onUpdate(ParseUsage(cached))
return
}
}
sessionKey := config.GetSessionKey()
if sessionKey == "" {
errData := &CacheData{Error: "no_session_key"}
_ = WriteCache(errData)
bf.onUpdate(ParseUsage(errData))
return
}
data, orgID, _ := FetchUsage(sessionKey, bf.orgID)
if data == nil {
return
}
_ = WriteCache(data)
if orgID != "" && orgID != bf.orgID {
bf.mu.Lock()
bf.orgID = orgID
bf.mu.Unlock()
cfg := config.Load()
cfg.OrgID = orgID
_ = config.Save(cfg)
}
bf.onUpdate(ParseUsage(data))
}

View File

@@ -0,0 +1,125 @@
package renderer
import (
"bytes"
"image"
"image/color"
"image/png"
"math"
"github.com/fogleman/gg"
)
const iconSize = 256
// Claude orange for the starburst logo.
var claudeOrange = color.RGBA{224, 123, 83, 255}
// arcColors maps usage percentage thresholds to colors.
var arcColors = []struct {
threshold int
color color.RGBA
}{
{10, color.RGBA{76, 175, 80, 255}}, // green
{20, color.RGBA{67, 160, 71, 255}}, // darker green
{30, color.RGBA{124, 179, 66, 255}}, // light green
{40, color.RGBA{192, 202, 51, 255}}, // lime
{50, color.RGBA{253, 216, 53, 255}}, // yellow
{60, color.RGBA{255, 193, 7, 255}}, // amber
{70, color.RGBA{255, 179, 0, 255}}, // darker amber
{80, color.RGBA{255, 152, 0, 255}}, // orange
{90, color.RGBA{255, 87, 34, 255}}, // deep orange
{100, color.RGBA{244, 67, 54, 255}}, // red
}
func getArcColor(pct int) color.RGBA {
for _, ac := range arcColors {
if pct <= ac.threshold {
return ac.color
}
}
return arcColors[len(arcColors)-1].color
}
// drawStarburst draws the 8-petal Claude logo.
func drawStarburst(dc *gg.Context) {
cx := float64(iconSize) / 2
cy := float64(iconSize) / 2
petalLen := float64(iconSize) * 0.38
petalWidth := float64(iconSize) * 0.10
centerRadius := float64(iconSize) * 0.04
dc.SetColor(claudeOrange)
for i := 0; i < 8; i++ {
angle := float64(i) * (2 * math.Pi / 8)
// Tip of the petal
tipX := cx + petalLen*math.Cos(angle)
tipY := cy + petalLen*math.Sin(angle)
// Base points (perpendicular to angle)
perpAngle := angle + math.Pi/2
baseX1 := cx + petalWidth*math.Cos(perpAngle)
baseY1 := cy + petalWidth*math.Sin(perpAngle)
baseX2 := cx - petalWidth*math.Cos(perpAngle)
baseY2 := cy - petalWidth*math.Sin(perpAngle)
// Inner point (slightly behind center for petal shape)
innerX := cx - petalWidth*0.5*math.Cos(angle)
innerY := cy - petalWidth*0.5*math.Sin(angle)
dc.MoveTo(innerX, innerY)
dc.LineTo(baseX1, baseY1)
dc.LineTo(tipX, tipY)
dc.LineTo(baseX2, baseY2)
dc.ClosePath()
dc.Fill()
}
// Center dot
dc.DrawCircle(cx, cy, centerRadius)
dc.Fill()
}
// drawArc draws a circular progress arc.
func drawArc(dc *gg.Context, pct int) {
if pct <= 0 {
return
}
if pct > 100 {
pct = 100
}
cx := float64(iconSize) / 2
cy := float64(iconSize) / 2
radius := float64(iconSize)/2 - 14 // inset from edge
arcWidth := 28.0
startAngle := -math.Pi / 2 // 12 o'clock
endAngle := startAngle + (float64(pct)/100)*2*math.Pi
dc.SetColor(getArcColor(pct))
dc.SetLineWidth(arcWidth)
dc.SetLineCap(gg.LineCapButt)
dc.DrawArc(cx, cy, radius, startAngle, endAngle)
dc.Stroke()
}
// RenderIcon generates a 256x256 PNG icon with starburst and usage arc.
func RenderIcon(pct int) image.Image {
dc := gg.NewContext(iconSize, iconSize)
drawStarburst(dc)
drawArc(dc, pct)
return dc.Image()
}
// RenderIconPNG generates the icon as PNG bytes (for systray).
func RenderIconPNG(pct int) ([]byte, error) {
img := RenderIcon(pct)
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, err
}
return buf.Bytes(), nil
}

215
internal/tray/tray.go Normal file
View File

@@ -0,0 +1,215 @@
package tray
import (
"fmt"
"os/exec"
"runtime"
"sync"
"fyne.io/systray"
"git.davoryn.de/calic/claude-statusline/internal/config"
"git.davoryn.de/calic/claude-statusline/internal/fetcher"
"git.davoryn.de/calic/claude-statusline/internal/renderer"
)
type interval struct {
label string
seconds int
}
var intervals = []interval{
{"1 min", 60},
{"5 min", 300},
{"15 min", 900},
{"30 min", 1800},
}
// App manages the tray icon, fetcher, and menu state.
type App struct {
mu sync.Mutex
usage fetcher.ParsedUsage
bf *fetcher.BackgroundFetcher
menuItems struct {
fiveHourText *systray.MenuItem
fiveHourReset *systray.MenuItem
sevenDayText *systray.MenuItem
sevenDayReset *systray.MenuItem
intervalRadio []*systray.MenuItem
}
}
// Run starts the tray application (blocking).
func Run() {
app := &App{}
systray.Run(app.onReady, app.onExit)
}
func (a *App) onReady() {
systray.SetTitle("Claude Usage")
systray.SetTooltip("Claude Usage: loading...")
// Set initial icon (0%)
if iconData, err := renderer.RenderIconPNG(0); err == nil {
systray.SetIcon(iconData)
}
// Usage display items (non-clickable info)
a.menuItems.fiveHourText = systray.AddMenuItem("5h Usage: loading...", "")
a.menuItems.fiveHourText.Disable()
a.menuItems.fiveHourReset = systray.AddMenuItem("Resets in: —", "")
a.menuItems.fiveHourReset.Disable()
systray.AddSeparator()
a.menuItems.sevenDayText = systray.AddMenuItem("7d Usage: —", "")
a.menuItems.sevenDayText.Disable()
a.menuItems.sevenDayReset = systray.AddMenuItem("Resets in: —", "")
a.menuItems.sevenDayReset.Disable()
systray.AddSeparator()
// Refresh button
mRefresh := systray.AddMenuItem("Refresh Now", "Force refresh usage data")
// Interval submenu
mInterval := systray.AddMenuItem("Refresh Interval", "Change refresh interval")
currentInterval := a.getCurrentInterval()
for _, iv := range intervals {
item := mInterval.AddSubMenuItem(iv.label, fmt.Sprintf("Refresh every %s", iv.label))
if iv.seconds == currentInterval {
item.Check()
}
a.menuItems.intervalRadio = append(a.menuItems.intervalRadio, item)
}
// Session key
mSessionKey := systray.AddMenuItem("Session Key...", "Open session key config file")
systray.AddSeparator()
mQuit := systray.AddMenuItem("Quit", "Exit Claude Usage Widget")
// Start background fetcher
a.bf = fetcher.NewBackgroundFetcher(a.onUsageUpdate)
a.bf.Start()
// Handle menu clicks
go func() {
for {
select {
case <-mRefresh.ClickedCh:
a.bf.Refresh()
case <-mSessionKey.ClickedCh:
a.openSessionKeyFile()
case <-mQuit.ClickedCh:
systray.Quit()
return
}
}
}()
// Handle interval radio clicks
for i, item := range a.menuItems.intervalRadio {
go func(idx int, mi *systray.MenuItem) {
for range mi.ClickedCh {
a.setInterval(idx)
}
}(i, item)
}
}
func (a *App) onExit() {
if a.bf != nil {
a.bf.Stop()
}
}
func (a *App) onUsageUpdate(data fetcher.ParsedUsage) {
a.mu.Lock()
a.usage = data
a.mu.Unlock()
// Update icon
pct := 0
if data.Error == "" {
pct = data.FiveHourPct
}
if iconData, err := renderer.RenderIconPNG(pct); err == nil {
systray.SetIcon(iconData)
}
// Update tooltip
if data.Error != "" {
systray.SetTooltip(fmt.Sprintf("Claude Usage: %s", data.Error))
} else {
systray.SetTooltip(fmt.Sprintf("Claude Usage: %d%%", data.FiveHourPct))
}
// Update menu text
a.updateMenuText(data)
}
func (a *App) updateMenuText(data fetcher.ParsedUsage) {
if data.Error != "" {
a.menuItems.fiveHourText.SetTitle(fmt.Sprintf("5h Usage: %s", data.Error))
a.menuItems.fiveHourReset.SetTitle("Resets in: —")
a.menuItems.sevenDayText.SetTitle("7d Usage: —")
a.menuItems.sevenDayReset.SetTitle("Resets in: —")
return
}
if data.FiveHourPct > 0 {
a.menuItems.fiveHourText.SetTitle(fmt.Sprintf("5h Usage: %d%%", data.FiveHourPct))
} else {
a.menuItems.fiveHourText.SetTitle("5h Usage: 0%")
}
if data.FiveHourResetsIn != "" {
a.menuItems.fiveHourReset.SetTitle(fmt.Sprintf("Resets in: %s", data.FiveHourResetsIn))
} else {
a.menuItems.fiveHourReset.SetTitle("Resets in: —")
}
if data.SevenDayPct > 0 {
a.menuItems.sevenDayText.SetTitle(fmt.Sprintf("7d Usage: %d%%", data.SevenDayPct))
} else {
a.menuItems.sevenDayText.SetTitle("7d Usage: 0%")
}
if data.SevenDayResetsIn != "" {
a.menuItems.sevenDayReset.SetTitle(fmt.Sprintf("Resets in: %s", data.SevenDayResetsIn))
} else {
a.menuItems.sevenDayReset.SetTitle("Resets in: —")
}
}
func (a *App) getCurrentInterval() int {
cfg := config.Load()
return cfg.RefreshInterval
}
func (a *App) setInterval(idx int) {
if idx < 0 || idx >= len(intervals) {
return
}
// Update radio check marks
for i, item := range a.menuItems.intervalRadio {
if i == idx {
item.Check()
} else {
item.Uncheck()
}
}
a.bf.SetInterval(intervals[idx].seconds)
}
func (a *App) openSessionKeyFile() {
path := config.SessionKeyPath()
var cmd *exec.Cmd
switch runtime.GOOS {
case "windows":
cmd = exec.Command("notepad", path)
case "darwin":
cmd = exec.Command("open", "-t", path)
default:
cmd = exec.Command("xdg-open", path)
}
_ = cmd.Start()
}

View File

@@ -1,13 +0,0 @@
{
"name": "claude-statusline",
"version": "0.2.0",
"description": "Claude Code usage monitoring — CLI statusline (Node.js) + desktop tray widget (Python)",
"main": "statusline.js",
"scripts": {
"statusline": "node statusline.js",
"fetch": "node fetch-usage.js",
"install-statusline": "bash install.sh"
},
"keywords": ["claude", "statusline", "cli"],
"license": "MIT"
}

View File

@@ -0,0 +1,3 @@
# Fetch Claude API usage every 5 minutes (all users with a session key)
# Installed by claude-statusline package
*/5 * * * * root /usr/bin/claude-fetcher 2>/dev/null

View File

@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Claude Usage Widget
Comment=System tray widget showing Claude API token usage
Exec=/usr/bin/claude-widget
Terminal=false
Categories=Utility;
StartupNotify=false
X-GNOME-Autostart-enabled=true

21
packaging/linux/postinstall.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/bin/sh
set -e
CONFIG_DIR="${HOME}/.config/claude-statusline"
mkdir -p "$CONFIG_DIR"
if [ ! -f "$CONFIG_DIR/session-key" ]; then
touch "$CONFIG_DIR/session-key"
chmod 600 "$CONFIG_DIR/session-key"
echo ""
echo "=== Claude Statusline ==="
echo "Paste your claude.ai session key into:"
echo " $CONFIG_DIR/session-key"
echo ""
echo "To get your session key:"
echo " 1. Open claude.ai in your browser"
echo " 2. Open DevTools (F12) → Application → Cookies"
echo " 3. Copy the 'sessionKey' cookie value"
echo "========================="
echo ""
fi

3
packaging/linux/preremove.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/sh
# Kill running widget if any
pkill -f claude-widget 2>/dev/null || true

43
packaging/nfpm.yaml Normal file
View File

@@ -0,0 +1,43 @@
name: claude-statusline
arch: amd64
platform: linux
version: "${VERSION}"
section: utils
priority: optional
maintainer: Axel Meyer <axel.meyer@durania.net>
description: |
Claude API usage monitor — CLI statusline, cron fetcher, and desktop tray widget.
Shows 5-hour and 7-day token usage from claude.ai in your terminal or system tray.
vendor: davoryn.de
homepage: https://git.davoryn.de/calic/claude-statusline
license: MIT
contents:
- src: ./claude-statusline
dst: /usr/bin/claude-statusline
file_info:
mode: 0755
- src: ./claude-fetcher
dst: /usr/bin/claude-fetcher
file_info:
mode: 0755
- src: ./claude-widget
dst: /usr/bin/claude-widget
file_info:
mode: 0755
- src: ./packaging/linux/claude-widget.desktop
dst: /etc/xdg/autostart/claude-widget.desktop
file_info:
mode: 0644
- src: ./packaging/linux/claude-statusline-fetch
dst: /etc/cron.d/claude-statusline-fetch
file_info:
mode: 0644
scripts:
postinstall: ./packaging/linux/postinstall.sh
preremove: ./packaging/linux/preremove.sh

View File

@@ -1,2 +0,0 @@
pystray>=0.19
Pillow>=9.0

View File

@@ -1,101 +0,0 @@
#!/usr/bin/env node
/**
* Claude Code Status Line (Standalone)
*
* Designed for headless Linux servers — no browser, no tray app needed.
*
* Context window: Always shown, read from stdin (JSON piped by Claude Code).
* Token usage: Optional. Reads from a cache file that can be populated by
* the included fetcher (cron) or any external source.
*
* Output: Context ▓▓▓▓░░░░░░ 40% | Token ░░░░░░░░░░ 10% 267M
*/
const fs = require('fs');
const path = require('path');
// --- Config ---
const CACHE_FILE = process.env.CLAUDE_USAGE_CACHE
|| path.join(process.env.TMPDIR || process.env.TEMP || '/tmp', 'claude_usage.json');
const CACHE_MAX_AGE_S = parseInt(process.env.CLAUDE_USAGE_MAX_AGE || '900', 10); // 15 min default
// --- Bar renderer ---
function bar(pct, width) {
width = width || 10;
const filled = Math.floor(Math.min(100, Math.max(0, pct)) / (100 / width));
return '\u2593'.repeat(filled) + '\u2591'.repeat(width - filled);
}
// --- Context Window ---
function getContextPart(data) {
try {
const pct = Math.round(parseFloat(data?.context_window?.used_percentage) || 0);
return 'Context ' + bar(pct) + ' ' + pct + '%';
} catch {
return 'Context ' + '\u2591'.repeat(10);
}
}
// --- Usage Cache ---
function readCache() {
try {
if (!fs.existsSync(CACHE_FILE)) return null;
const stat = fs.statSync(CACHE_FILE);
const ageS = (Date.now() - stat.mtimeMs) / 1000;
const data = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
// Error state written by fetcher — always show regardless of age
if (data._error) return data;
// Normal data — respect max age
if (ageS > CACHE_MAX_AGE_S) return null;
return data;
} catch {
return null;
}
}
function formatMinutes(isoStr) {
if (!isoStr) return '';
try {
const remaining = Math.max(0, Math.round((new Date(isoStr).getTime() - Date.now()) / 60000));
return remaining + 'M';
} catch {
return '';
}
}
function getUsagePart(data) {
if (!data) return '';
// Error state from fetcher
if (data._error === 'auth_expired') return 'Token: session expired — refresh cookie';
if (data._error) return 'Token: fetch error';
const parts = [];
if (data.five_hour && data.five_hour.utilization > 0) {
const pct = Math.round(data.five_hour.utilization);
const remaining = formatMinutes(data.five_hour.resets_at);
parts.push('Token ' + bar(pct) + ' ' + pct + '%' + (remaining ? ' ' + remaining : ''));
}
if (data.seven_day && data.seven_day.utilization > 20) {
const pct = Math.round(data.seven_day.utilization);
parts.push('7d ' + pct + '%');
}
return parts.join(' | ');
}
// --- Main ---
function main() {
let stdinData = {};
try {
stdinData = JSON.parse(fs.readFileSync(0, 'utf8'));
} catch { /* no stdin or invalid JSON */ }
const ctx = getContextPart(stdinData);
const usage = getUsagePart(readCache());
console.log(usage ? ctx + ' | ' + usage : ctx);
}
main();