Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5abdee06ff | ||
|
|
2cb89d3c54 | ||
|
|
ba3b73c3dd | ||
|
|
47165ce02c | ||
|
|
5b0366f16b | ||
|
|
7f17a40b7c |
@@ -6,76 +6,78 @@ on:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||
- name: Extract version
|
||||
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV
|
||||
|
||||
- name: Build source archives
|
||||
- name: Install Go
|
||||
run: |
|
||||
PROJECT="claude-statusline-${{ steps.version.outputs.VERSION }}"
|
||||
curl -sSL https://go.dev/dl/go1.23.6.linux-amd64.tar.gz -o /tmp/go.tar.gz
|
||||
tar -C /usr/local -xzf /tmp/go.tar.gz
|
||||
echo "/usr/local/go/bin" >> $GITHUB_PATH
|
||||
echo "$(go env GOPATH)/bin" >> $GITHUB_PATH
|
||||
|
||||
# Files to include in the release archives
|
||||
INCLUDE=(
|
||||
statusline.js
|
||||
fetch-usage.js
|
||||
install.sh
|
||||
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
|
||||
)
|
||||
- name: Install nfpm
|
||||
run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@latest
|
||||
|
||||
mkdir -p "dist/${PROJECT}/claude_usage_widget"
|
||||
- name: Build Linux binaries
|
||||
run: |
|
||||
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-widget ./cmd/widget
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o setup ./cmd/setup
|
||||
|
||||
for f in "${INCLUDE[@]}"; do
|
||||
[ -f "$f" ] && cp "$f" "dist/${PROJECT}/$f"
|
||||
done
|
||||
- name: Build Windows binaries
|
||||
run: |
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o claude-statusline.exe ./cmd/statusline
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H=windowsgui" -o claude-widget.exe ./cmd/widget
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o setup.exe ./cmd/setup
|
||||
|
||||
# tar.gz for Linux / macOS
|
||||
tar -czf "dist/${PROJECT}.tar.gz" -C dist "${PROJECT}"
|
||||
- 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
|
||||
|
||||
# zip for Windows
|
||||
cd dist && zip -r "${PROJECT}.zip" "${PROJECT}" && cd ..
|
||||
- name: Create Linux tarball
|
||||
run: |
|
||||
mkdir -p dist/claude-statusline-${{ env.VERSION }}
|
||||
cp claude-statusline claude-widget setup 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 }}/
|
||||
tar -czf claude-statusline_${{ env.VERSION }}_linux_amd64.tar.gz -C dist claude-statusline-${{ env.VERSION }}
|
||||
|
||||
- name: Create release and upload assets
|
||||
- name: Create Windows zip
|
||||
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-widget.exe setup.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 }}
|
||||
|
||||
- name: Create Gitea release
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
VERSION: ${{ steps.version.outputs.VERSION }}
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
GITEA_URL: ${{ github.server_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
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" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":\"See [CHANGELOG.md](CHANGELOG.md) for details.\",\"draft\":false,\"prerelease\":false}" \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":\"See CHANGELOG.md for details.\",\"draft\":false,\"prerelease\":false}" \
|
||||
| grep -o '"id":[0-9]*' | grep -m1 -o '[0-9]*')
|
||||
|
||||
# Upload archives
|
||||
for file in dist/claude-statusline-${VERSION}.tar.gz dist/claude-statusline-${VERSION}.zip; do
|
||||
FILENAME=$(basename "$file")
|
||||
curl -s -X POST "${API}/releases/${RELEASE_ID}/assets?name=${FILENAME}" \
|
||||
# Upload assets
|
||||
for FILE in \
|
||||
claude-statusline_${{ env.VERSION }}_amd64.deb \
|
||||
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 "Content-Type: application/octet-stream" \
|
||||
--data-binary "@${file}"
|
||||
-F "attachment=@${FILE}"
|
||||
done
|
||||
|
||||
23
.gitignore
vendored
23
.gitignore
vendored
@@ -1,9 +1,16 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
# Go binaries
|
||||
claude-statusline
|
||||
claude-widget
|
||||
/setup
|
||||
*.exe
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
.venv/
|
||||
venv/
|
||||
node_modules/
|
||||
dist-win/
|
||||
*.deb
|
||||
*.tar.gz
|
||||
*.zip
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
34
CHANGELOG.md
34
CHANGELOG.md
@@ -4,6 +4,38 @@ 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/).
|
||||
|
||||
## [0.5.0] — 2026-03-21
|
||||
|
||||
### Fixed
|
||||
- **Cloudflare 403 bypass** — API requests blocked by Cloudflare JS challenges now fall back to headless Chrome with the persistent browser profile, which can solve the challenges natively
|
||||
|
||||
### Added
|
||||
- `internal/browser/fetch.go` — headless Chrome API fetcher using chromedp with the existing browser profile (reuses Cloudflare clearance cookies)
|
||||
- `fetchWithFallback()` in fetcher — tries plain HTTP first, falls back to headless Chrome on 403
|
||||
|
||||
## [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
|
||||
|
||||
First tagged release. Includes the CLI statusline, standalone usage fetcher, cross-platform desktop widget, and installer wizard.
|
||||
@@ -21,4 +53,6 @@ First tagged release. Includes the CLI statusline, standalone usage fetcher, cro
|
||||
- Tray icon visibility — switched to Claude orange with full opacity at larger size
|
||||
- Block comment syntax error in cron example
|
||||
|
||||
[0.5.0]: https://git.davoryn.de/calic/claude-statusline/releases/tag/v0.5.0
|
||||
[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
|
||||
|
||||
269
README.md
269
README.md
@@ -5,9 +5,9 @@
|
||||
</p>
|
||||
|
||||
<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/Python-3.9+-3776AB?logo=python&logoColor=white" alt="Python 3.9+" />
|
||||
<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/Go-1.21+-00ADD8?logo=go&logoColor=white" alt="Go 1.21+" />
|
||||
<img src="https://img.shields.io/badge/Platform-Linux%20|%20Windows-informational" alt="Platform: Linux | 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" />
|
||||
<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>
|
||||
@@ -17,156 +17,112 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Both components share the same session key and fetcher logic. Install one or both depending on your setup.
|
||||
Two static binaries built from one Go codebase. No runtime dependencies — no Node.js, Python, or system packages needed. A `setup` tool handles installation and uninstallation on both platforms.
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
### Usage Fetcher (Node.js)
|
||||
### Desktop Widget
|
||||
|
||||
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.
|
||||
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. Has a built-in background fetcher that writes a shared JSON cache. Right-click menu shows detailed stats and configuration.
|
||||
|
||||
## Topology
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
API["claude.ai API"]
|
||||
FetchJS["fetch-usage.js<br/>(cron / Node.js)"]
|
||||
FetchPy["fetcher.py thread<br/>(Python / urllib)"]
|
||||
Cache["/tmp/claude_usage.json<br/>(shared cache)"]
|
||||
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:
|
||||
|
||||
- **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.
|
||||
claude.ai API
|
||||
│
|
||||
└──► claude-widget (background fetcher) ──► /tmp/claude_usage.json ──► claude-statusline (Claude Code)
|
||||
│
|
||||
└──► System tray icon
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
### Quick Install (from release)
|
||||
### Windows / Linux (setup tool)
|
||||
|
||||
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:
|
||||
Extract the archive and run the setup tool. It copies binaries to the install directory, enables autostart for the widget, and configures Claude Code's statusline setting.
|
||||
|
||||
```bash
|
||||
# Windows — double-click setup.exe, or from a terminal:
|
||||
setup.exe
|
||||
|
||||
# Linux
|
||||
./setup
|
||||
```
|
||||
|
||||
### Debian/Ubuntu (.deb)
|
||||
|
||||
```bash
|
||||
sudo dpkg -i claude-statusline_0.3.0_amd64.deb
|
||||
```
|
||||
|
||||
Installs binaries to `/usr/bin/` and sets up autostart for the widget.
|
||||
|
||||
### Linux (manual)
|
||||
|
||||
```bash
|
||||
tar xzf claude-statusline_0.3.0_linux_amd64.tar.gz
|
||||
cp claude-statusline-0.3.0/claude-{statusline,widget} ~/.local/bin/
|
||||
```
|
||||
|
||||
### 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
|
||||
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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Uninstall
|
||||
|
||||
### Windows / Linux (setup tool)
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
setup.exe --uninstall
|
||||
|
||||
# Linux
|
||||
./setup --uninstall
|
||||
```
|
||||
|
||||
Stops the widget, removes binaries and autostart entry, and cleans the `statusLine` setting from Claude Code. Optionally removes the config directory (interactive prompt, default: keep).
|
||||
|
||||
### Debian/Ubuntu
|
||||
|
||||
```bash
|
||||
sudo dpkg -r claude-statusline
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### CLI Statusline
|
||||
|
||||
Environment variables:
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
@@ -174,22 +130,17 @@ Environment variables:
|
||||
| `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
|
||||
### Widget Menu
|
||||
|
||||
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`.
|
||||
- **Session Key...** — open session key file in editor
|
||||
|
||||
### Icon Color Scale
|
||||
|
||||
The tray icon arc color indicates usage severity at 10% increments:
|
||||
|
||||
| Range | Color |
|
||||
|-------|-------|
|
||||
| 0–10% | Green |
|
||||
@@ -203,35 +154,37 @@ The tray icon arc color indicates usage severity at 10% increments:
|
||||
| 80–90% | Deep orange |
|
||||
| 90–100% | 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/widget && go build ./cmd/setup
|
||||
|
||||
**Planned future additions:**
|
||||
- `.deb` package for Debian/Ubuntu
|
||||
- `.msi` / `.exe` installer for Windows
|
||||
- Homebrew tap for macOS
|
||||
# Cross-compile for Windows
|
||||
GOOS=windows GOARCH=amd64 go build -ldflags "-H=windowsgui" -o claude-widget.exe ./cmd/widget
|
||||
GOOS=windows GOARCH=amd64 go build -o claude-statusline.exe ./cmd/statusline
|
||||
GOOS=windows GOARCH=amd64 go build -o setup.exe ./cmd/setup
|
||||
|
||||
# Build .deb
|
||||
VERSION=0.3.0 nfpm package --config packaging/nfpm.yaml --packager deb
|
||||
```
|
||||
|
||||
## 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
|
||||
cmd/
|
||||
statusline/main.go # CLI statusline (reads stdin + cache)
|
||||
widget/main.go # Desktop tray widget with built-in fetcher
|
||||
setup/main.go # Cross-platform install/uninstall tool
|
||||
internal/
|
||||
config/config.go # Shared config (session key, org ID, intervals)
|
||||
fetcher/fetcher.go # HTTP fetch logic (used by widget)
|
||||
fetcher/cache.go # JSON cache read/write (/tmp/claude_usage.json)
|
||||
renderer/renderer.go # Icon rendering: starburst + arc (fogleman/gg)
|
||||
tray/tray.go # System tray setup + menu (fyne-io/systray)
|
||||
packaging/
|
||||
nfpm.yaml # .deb packaging config
|
||||
linux/ # .desktop file, install scripts
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
"""Claude Usage Widget — System tray usage monitor."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Entry point: python -m claude_usage_widget"""
|
||||
|
||||
from .app import main
|
||||
|
||||
main()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()),
|
||||
)
|
||||
@@ -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
|
||||
479
cmd/setup/main.go
Normal file
479
cmd/setup/main.go
Normal file
@@ -0,0 +1,479 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"git.davoryn.de/calic/claude-statusline/internal/config"
|
||||
)
|
||||
|
||||
func main() {
|
||||
uninstall := flag.Bool("uninstall", false, "Remove installed files and autostart entry")
|
||||
flag.Parse()
|
||||
|
||||
if *uninstall {
|
||||
runUninstall()
|
||||
} else {
|
||||
runInstall()
|
||||
}
|
||||
}
|
||||
|
||||
// installDir returns the platform-specific install directory.
|
||||
func installDir() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return filepath.Join(os.Getenv("LOCALAPPDATA"), "claude-statusline")
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".local", "bin")
|
||||
}
|
||||
|
||||
// binaryNames returns the binaries to install (excluding setup itself).
|
||||
func binaryNames() []string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return []string{"claude-widget.exe", "claude-statusline.exe"}
|
||||
}
|
||||
return []string{"claude-widget", "claude-statusline"}
|
||||
}
|
||||
|
||||
// sourceDir returns the directory where the setup binary resides.
|
||||
func sourceDir() string {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: cannot determine executable path: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return filepath.Dir(exe)
|
||||
}
|
||||
|
||||
// findSourceBinary looks for a binary in dir by its canonical install name
|
||||
// (e.g. "claude-widget.exe"), falling back to the bare name without the
|
||||
// "claude-" prefix (e.g. "widget.exe") for local go build output.
|
||||
func findSourceBinary(dir, name string) string {
|
||||
p := filepath.Join(dir, name)
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
bare := strings.TrimPrefix(name, "claude-")
|
||||
p = filepath.Join(dir, bare)
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// askYesNo prompts the user with a Y/n question. Default is yes.
|
||||
func askYesNo(prompt string) bool {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
fmt.Printf("%s [Y/n] ", prompt)
|
||||
answer, _ := reader.ReadString('\n')
|
||||
answer = strings.TrimSpace(strings.ToLower(answer))
|
||||
return answer == "" || answer == "y" || answer == "yes"
|
||||
}
|
||||
|
||||
// askNo prompts the user with a y/N question. Default is no.
|
||||
func askNo(prompt string) bool {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
fmt.Printf("%s [y/N] ", prompt)
|
||||
answer, _ := reader.ReadString('\n')
|
||||
answer = strings.TrimSpace(strings.ToLower(answer))
|
||||
return answer == "y" || answer == "yes"
|
||||
}
|
||||
|
||||
func runInstall() {
|
||||
fmt.Println("claude-statusline setup")
|
||||
fmt.Println(strings.Repeat("=", 40))
|
||||
fmt.Println()
|
||||
|
||||
src := sourceDir()
|
||||
dst := installDir()
|
||||
var errors []string
|
||||
|
||||
fmt.Printf("Install directory: %s\n\n", dst)
|
||||
|
||||
// Step 1 — Create install directory
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
msg := "Could not create install directory: " + err.Error()
|
||||
fmt.Fprintln(os.Stderr, msg)
|
||||
showMessage("Claude Statusline — Setup", "Something went wrong.\n\n"+msg, true)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Step 2 — Copy binaries
|
||||
fmt.Println("Installing binaries...")
|
||||
binaries := binaryNames()
|
||||
installed := 0
|
||||
for _, name := range binaries {
|
||||
srcPath := findSourceBinary(src, name)
|
||||
dstPath := filepath.Join(dst, name)
|
||||
|
||||
if srcPath == "" {
|
||||
fmt.Printf(" SKIP %s (not found in source directory)\n", name)
|
||||
continue
|
||||
}
|
||||
|
||||
err := copyFile(srcPath, dstPath)
|
||||
if err != nil {
|
||||
if runtime.GOOS == "windows" && isFileInUse(err) {
|
||||
if isInteractive() {
|
||||
fmt.Printf(" BUSY %s — please close the widget and press Enter to retry: ", name)
|
||||
bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
err = copyFile(srcPath, dstPath)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errors = append(errors, fmt.Sprintf("%s: %v", name, err))
|
||||
fmt.Fprintf(os.Stderr, " FAIL %s: %v\n", name, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
fmt.Printf(" OK %s\n", name)
|
||||
installed++
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if installed == 0 {
|
||||
msg := "No binaries found next to setup. Make sure setup is in the same directory as the binaries."
|
||||
fmt.Fprintln(os.Stderr, msg)
|
||||
showMessage("Claude Statusline — Setup", "Something went wrong.\n\n"+msg, true)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Step 3 — Autostart (auto-yes on Windows, interactive on Linux)
|
||||
enableAutostart := true
|
||||
if isInteractive() {
|
||||
enableAutostart = askYesNo("Enable autostart for claude-widget?")
|
||||
}
|
||||
|
||||
if enableAutostart {
|
||||
if err := createAutostart(dst); err != nil {
|
||||
errors = append(errors, "autostart: "+err.Error())
|
||||
fmt.Fprintf(os.Stderr, " Warning: could not set up autostart: %v\n", err)
|
||||
} else {
|
||||
fmt.Println(" Autostart enabled.")
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Step 4 — Config directory
|
||||
cfgDir := config.ConfigDir()
|
||||
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: could not create config directory: %v\n", err)
|
||||
} else {
|
||||
skPath := config.SessionKeyPath()
|
||||
if _, err := os.Stat(skPath); os.IsNotExist(err) {
|
||||
if err := os.WriteFile(skPath, []byte(""), 0o600); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: could not create session-key file: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5 — Configure Claude Code statusline
|
||||
statuslineName := "claude-statusline"
|
||||
if runtime.GOOS == "windows" {
|
||||
statuslineName = "claude-statusline.exe"
|
||||
}
|
||||
statuslinePath := filepath.Join(dst, statuslineName)
|
||||
if err := configureClaudeCode(statuslinePath); err != nil {
|
||||
errors = append(errors, "Claude Code settings: "+err.Error())
|
||||
fmt.Fprintf(os.Stderr, " Warning: could not configure Claude Code statusline: %v\n", err)
|
||||
} else {
|
||||
fmt.Println(" Claude Code statusline configured.")
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Step 6 — Launch widget on Windows
|
||||
widgetName := "claude-widget"
|
||||
if runtime.GOOS == "windows" {
|
||||
widgetName = "claude-widget.exe"
|
||||
}
|
||||
widgetPath := filepath.Join(dst, widgetName)
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
exec.Command(widgetPath).Start()
|
||||
}
|
||||
|
||||
// Step 7 — Summary
|
||||
fmt.Println(strings.Repeat("-", 40))
|
||||
fmt.Println("Installation complete!")
|
||||
fmt.Println()
|
||||
fmt.Printf(" Binaries: %s\n", dst)
|
||||
fmt.Printf(" Config: %s\n", cfgDir)
|
||||
fmt.Println()
|
||||
fmt.Println("To log in, launch the widget and use the")
|
||||
fmt.Println("\"Login in Browser\" menu item from the tray icon.")
|
||||
fmt.Println()
|
||||
fmt.Printf("Launch the widget: %s\n", widgetPath)
|
||||
|
||||
// Show result dialog on Windows
|
||||
if len(errors) > 0 {
|
||||
showMessage("Claude Statusline — Setup",
|
||||
"Setup completed with warnings:\n\n"+strings.Join(errors, "\n")+
|
||||
"\n\nSuccessfully installed "+fmt.Sprint(installed)+" binaries to:\n"+dst, true)
|
||||
} else {
|
||||
showMessage("Claude Statusline — Setup",
|
||||
"Setup complete!\n\nWidget is running and set to autostart.\n\nTo log in, use the \"Login in Browser\"\nmenu item from the tray icon.", false)
|
||||
}
|
||||
}
|
||||
|
||||
func runUninstall() {
|
||||
fmt.Println("claude-statusline uninstall")
|
||||
fmt.Println(strings.Repeat("=", 40))
|
||||
fmt.Println()
|
||||
|
||||
dst := installDir()
|
||||
var errors []string
|
||||
|
||||
// Step 1 — Kill running widget
|
||||
fmt.Println("Stopping widget...")
|
||||
if runtime.GOOS == "windows" {
|
||||
exec.Command("taskkill", "/F", "/IM", "claude-widget.exe").Run()
|
||||
} else {
|
||||
exec.Command("pkill", "-f", "claude-widget").Run()
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Step 2 — Remove binaries
|
||||
fmt.Println("Removing binaries...")
|
||||
for _, name := range binaryNames() {
|
||||
p := filepath.Join(dst, name)
|
||||
if _, err := os.Stat(p); os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(p); err != nil {
|
||||
if runtime.GOOS == "windows" && isFileInUse(err) {
|
||||
if isInteractive() {
|
||||
fmt.Printf(" BUSY %s — please close the widget and press Enter to retry: ", name)
|
||||
bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
err = os.Remove(p)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errors = append(errors, fmt.Sprintf("%s: %v", name, err))
|
||||
fmt.Fprintf(os.Stderr, " FAIL %s: %v\n", name, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
fmt.Printf(" Removed %s\n", name)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Step 3 — Remove autostart
|
||||
fmt.Println("Removing autostart entry...")
|
||||
if err := removeAutostart(); err != nil {
|
||||
errors = append(errors, "autostart: "+err.Error())
|
||||
fmt.Fprintf(os.Stderr, " Warning: %v\n", err)
|
||||
} else {
|
||||
fmt.Println(" Autostart removed.")
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Step 4 — Remove Claude Code statusline setting
|
||||
fmt.Println("Removing Claude Code statusline setting...")
|
||||
if err := removeClaudeCodeSetting(); err != nil {
|
||||
errors = append(errors, "Claude Code settings: "+err.Error())
|
||||
fmt.Fprintf(os.Stderr, " Warning: %v\n", err)
|
||||
} else {
|
||||
fmt.Println(" Claude Code setting removed.")
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Step 5 — Optionally remove config (only ask in interactive mode)
|
||||
cfgDir := config.ConfigDir()
|
||||
if _, err := os.Stat(cfgDir); err == nil {
|
||||
deleteConfig := false
|
||||
if isInteractive() {
|
||||
deleteConfig = askNo("Also delete config directory (" + cfgDir + ")?")
|
||||
}
|
||||
if deleteConfig {
|
||||
if err := os.RemoveAll(cfgDir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, " Error removing config: %v\n", err)
|
||||
} else {
|
||||
fmt.Println(" Config directory removed.")
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" Config directory kept.")
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
fmt.Println("Uninstall complete.")
|
||||
|
||||
if len(errors) > 0 {
|
||||
showMessage("Claude Statusline — Uninstall",
|
||||
"Uninstall completed with warnings:\n\n"+strings.Join(errors, "\n"), true)
|
||||
} else {
|
||||
showMessage("Claude Statusline — Uninstall",
|
||||
"Uninstall complete.\n\nAll binaries and autostart entry have been removed.", false)
|
||||
}
|
||||
}
|
||||
|
||||
// isInteractive returns true if stdin is likely a terminal (not double-clicked on Windows).
|
||||
// On Linux this always returns true. On Windows it checks if stdin is a valid console.
|
||||
func isInteractive() bool {
|
||||
if runtime.GOOS != "windows" {
|
||||
return true
|
||||
}
|
||||
// When double-clicked from Explorer, stdin is not a pipe but reads will
|
||||
// return immediately with EOF or block forever. Check if we have a real
|
||||
// console by testing if stdin is a character device (terminal).
|
||||
fi, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return fi.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
|
||||
// copyFile reads src and writes to dst, preserving executable permission.
|
||||
func copyFile(src, dst string) error {
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(dst, data, 0o755)
|
||||
}
|
||||
|
||||
// isFileInUse checks if an error indicates the file is locked (Windows).
|
||||
func isFileInUse(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "Access is denied") ||
|
||||
strings.Contains(msg, "being used by another process")
|
||||
}
|
||||
|
||||
// createAutostart sets up autostart for the widget.
|
||||
func createAutostart(installDir string) error {
|
||||
if runtime.GOOS == "windows" {
|
||||
return createAutostartWindows(installDir)
|
||||
}
|
||||
return createAutostartLinux(installDir)
|
||||
}
|
||||
|
||||
func createAutostartWindows(dir string) error {
|
||||
startupDir := filepath.Join(os.Getenv("APPDATA"), "Microsoft", "Windows", "Start Menu", "Programs", "Startup")
|
||||
lnkPath := filepath.Join(startupDir, "Claude Widget.lnk")
|
||||
target := filepath.Join(dir, "claude-widget.exe")
|
||||
|
||||
script := fmt.Sprintf(
|
||||
`$s = (New-Object -COM WScript.Shell).CreateShortcut('%s'); $s.TargetPath = '%s'; $s.WorkingDirectory = '%s'; $s.Save()`,
|
||||
lnkPath, target, dir,
|
||||
)
|
||||
cmd := exec.Command("powershell", "-NoProfile", "-Command", script)
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func createAutostartLinux(dir string) error {
|
||||
autostartDir := filepath.Join(os.Getenv("HOME"), ".config", "autostart")
|
||||
if err := os.MkdirAll(autostartDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
desktopEntry := fmt.Sprintf(`[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Claude Widget
|
||||
Exec=%s
|
||||
Terminal=false
|
||||
X-GNOME-Autostart-enabled=true
|
||||
`, filepath.Join(dir, "claude-widget"))
|
||||
|
||||
return os.WriteFile(filepath.Join(autostartDir, "claude-widget.desktop"), []byte(desktopEntry), 0o644)
|
||||
}
|
||||
|
||||
// removeAutostart removes the autostart entry.
|
||||
func removeAutostart() error {
|
||||
if runtime.GOOS == "windows" {
|
||||
return removeAutostartWindows()
|
||||
}
|
||||
return removeAutostartLinux()
|
||||
}
|
||||
|
||||
func removeAutostartWindows() error {
|
||||
lnkPath := filepath.Join(os.Getenv("APPDATA"), "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "Claude Widget.lnk")
|
||||
if _, err := os.Stat(lnkPath); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return os.Remove(lnkPath)
|
||||
}
|
||||
|
||||
func removeAutostartLinux() error {
|
||||
desktopPath := filepath.Join(os.Getenv("HOME"), ".config", "autostart", "claude-widget.desktop")
|
||||
if _, err := os.Stat(desktopPath); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return os.Remove(desktopPath)
|
||||
}
|
||||
|
||||
// claudeSettingsPath returns the path to Claude Code's settings.json.
|
||||
func claudeSettingsPath() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".claude", "settings.json")
|
||||
}
|
||||
|
||||
// configureClaudeCode updates ~/.claude/settings.json to use the installed
|
||||
// statusline binary. Preserves all existing settings.
|
||||
func configureClaudeCode(statuslinePath string) error {
|
||||
settingsPath := claudeSettingsPath()
|
||||
|
||||
// Read existing settings (or start fresh)
|
||||
settings := make(map[string]any)
|
||||
if data, err := os.ReadFile(settingsPath); err == nil {
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return fmt.Errorf("parse %s: %w", settingsPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Set statusLine command
|
||||
settings["statusLine"] = map[string]any{
|
||||
"type": "command",
|
||||
"command": statuslinePath,
|
||||
}
|
||||
|
||||
// Write back
|
||||
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(settingsPath, append(data, '\n'), 0o644)
|
||||
}
|
||||
|
||||
// removeClaudeCodeSetting removes the statusLine key from ~/.claude/settings.json.
|
||||
func removeClaudeCodeSetting() error {
|
||||
settingsPath := claudeSettingsPath()
|
||||
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil // nothing to do
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
settings := make(map[string]any)
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return fmt.Errorf("parse %s: %w", settingsPath, err)
|
||||
}
|
||||
|
||||
if _, ok := settings["statusLine"]; !ok {
|
||||
return nil // key not present
|
||||
}
|
||||
|
||||
delete(settings, "statusLine")
|
||||
|
||||
out, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(settingsPath, append(out, '\n'), 0o644)
|
||||
}
|
||||
5
cmd/setup/ui_other.go
Normal file
5
cmd/setup/ui_other.go
Normal file
@@ -0,0 +1,5 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
func showMessage(_, _ string, _ bool) {}
|
||||
23
cmd/setup/ui_windows.go
Normal file
23
cmd/setup/ui_windows.go
Normal file
@@ -0,0 +1,23 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
var (
|
||||
user32 = syscall.NewLazyDLL("user32.dll")
|
||||
procMsgBox = user32.NewProc("MessageBoxW")
|
||||
)
|
||||
|
||||
func showMessage(title, text string, isError bool) {
|
||||
var flags uintptr = 0x00000040 // MB_OK | MB_ICONINFORMATION
|
||||
if isError {
|
||||
flags = 0x00000010 // MB_OK | MB_ICONERROR
|
||||
}
|
||||
tPtr, _ := syscall.UTF16PtrFromString(title)
|
||||
mPtr, _ := syscall.UTF16PtrFromString(text)
|
||||
procMsgBox.Call(0, uintptr(unsafe.Pointer(mPtr)), uintptr(unsafe.Pointer(tPtr)), flags)
|
||||
}
|
||||
120
cmd/statusline/main.go
Normal file
120
cmd/statusline/main.go
Normal 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, " | "))
|
||||
}
|
||||
}
|
||||
23
cmd/widget/main.go
Normal file
23
cmd/widget/main.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.davoryn.de/calic/claude-statusline/internal/tray"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Log to file next to the executable
|
||||
exe, _ := os.Executable()
|
||||
logPath := filepath.Join(filepath.Dir(exe), "widget.log")
|
||||
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err == nil {
|
||||
log.SetOutput(f)
|
||||
defer f.Close()
|
||||
}
|
||||
log.Println("widget starting")
|
||||
tray.Run()
|
||||
log.Println("widget exited")
|
||||
}
|
||||
115
fetch-usage.js
115
fetch-usage.js
@@ -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();
|
||||
22
go.mod
Normal file
22
go.mod
Normal file
@@ -0,0 +1,22 @@
|
||||
module git.davoryn.de/calic/claude-statusline
|
||||
|
||||
go 1.24
|
||||
|
||||
require (
|
||||
fyne.io/systray v1.11.0
|
||||
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327
|
||||
github.com/chromedp/chromedp v0.14.2
|
||||
github.com/fogleman/gg v1.3.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
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.34.0 // indirect
|
||||
)
|
||||
31
go.sum
Normal file
31
go.sum
Normal file
@@ -0,0 +1,31 @@
|
||||
fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg=
|
||||
fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
|
||||
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
|
||||
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
|
||||
github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
|
||||
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||
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/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
|
||||
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||
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=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
26
install.ps1
26
install.ps1
@@ -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")
|
||||
28
install.sh
28
install.sh
@@ -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"
|
||||
@@ -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()
|
||||
133
internal/browser/fetch.go
Normal file
133
internal/browser/fetch.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/chromedp/chromedp"
|
||||
|
||||
"git.davoryn.de/calic/claude-statusline/internal/config"
|
||||
)
|
||||
|
||||
// FetchViaChrome navigates to a URL using Chrome with the persistent browser
|
||||
// profile (which has Cloudflare clearance cookies) and returns the response
|
||||
// body. Uses non-headless mode with a minimized/hidden window to avoid
|
||||
// Cloudflare's headless detection, which causes infinite challenge loops.
|
||||
func FetchViaChrome(url string) ([]byte, error) {
|
||||
profileDir := filepath.Join(config.ConfigDir(), "browser-profile")
|
||||
if err := os.MkdirAll(profileDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create browser profile dir: %w", err)
|
||||
}
|
||||
|
||||
// Remove stale lock file from unclean shutdown
|
||||
_ = os.Remove(filepath.Join(profileDir, "SingletonLock"))
|
||||
|
||||
execPath := findBrowserExec()
|
||||
|
||||
// Use non-headless mode: Cloudflare detects headless Chrome and loops
|
||||
// the JS challenge forever. A real (but hidden) browser window passes.
|
||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.Flag("headless", false),
|
||||
chromedp.Flag("window-position", "-32000,-32000"), // off-screen
|
||||
chromedp.Flag("window-size", "1,1"),
|
||||
chromedp.Flag("disable-gpu", true),
|
||||
chromedp.Flag("no-first-run", true),
|
||||
chromedp.Flag("disable-extensions", true),
|
||||
chromedp.UserDataDir(profileDir),
|
||||
)
|
||||
if execPath != "" {
|
||||
opts = append(opts, chromedp.ExecPath(execPath))
|
||||
}
|
||||
|
||||
allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||
defer allocCancel()
|
||||
|
||||
ctx, cancel := chromedp.NewContext(allocCtx)
|
||||
defer cancel()
|
||||
|
||||
// Total timeout for the operation
|
||||
ctx, timeoutCancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer timeoutCancel()
|
||||
|
||||
// Navigate and wait for Cloudflare challenge to resolve.
|
||||
log.Printf("chrome-fetch: navigating to %s (profile: %s)", url, profileDir)
|
||||
if err := chromedp.Run(ctx, chromedp.Navigate(url)); err != nil {
|
||||
log.Printf("chrome-fetch: navigate failed: %v", err)
|
||||
return nil, fmt.Errorf("chromedp navigate: %w", err)
|
||||
}
|
||||
log.Printf("chrome-fetch: navigation complete, polling for JSON...")
|
||||
|
||||
// Poll for JSON response — Cloudflare challenge takes a few seconds to clear
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("chromedp fetch timed out waiting for JSON response")
|
||||
case <-ticker.C:
|
||||
var body string
|
||||
// Try <pre> first (Chrome wraps JSON in <pre> tags)
|
||||
err := chromedp.Run(ctx,
|
||||
chromedp.Text("pre", &body, chromedp.ByQuery),
|
||||
)
|
||||
if err != nil || body == "" {
|
||||
// Fallback: try body directly
|
||||
_ = chromedp.Run(ctx,
|
||||
chromedp.Text("body", &body, chromedp.ByQuery),
|
||||
)
|
||||
}
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
log.Printf("chrome-fetch: page body empty, waiting...")
|
||||
continue
|
||||
}
|
||||
// Check if we got actual JSON (starts with [ or {), not a challenge page
|
||||
if body[0] == '[' || body[0] == '{' {
|
||||
log.Printf("chrome-fetch: got JSON response (%d bytes)", len(body))
|
||||
// Also extract any fresh cookies for future plain HTTP attempts
|
||||
_ = extractAndSaveCookies(ctx)
|
||||
cancel() // graceful close, flushes cookies to profile
|
||||
return []byte(body), nil
|
||||
}
|
||||
// Log a snippet of what we got (challenge page, login redirect, etc.)
|
||||
snippet := body
|
||||
if len(snippet) > 200 {
|
||||
snippet = snippet[:200]
|
||||
}
|
||||
log.Printf("chrome-fetch: non-JSON body (%d bytes): %s", len(body), snippet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractAndSaveCookies saves cf_clearance and other Cloudflare cookies
|
||||
// alongside the session key, so plain HTTP requests can try them next time.
|
||||
func extractAndSaveCookies(ctx context.Context) error {
|
||||
cookies, err := network.GetCookies().Do(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var parts []string
|
||||
for _, c := range cookies {
|
||||
if c.Domain == ".claude.ai" || c.Domain == "claude.ai" {
|
||||
if c.Name == "cf_clearance" || c.Name == "__cf_bm" || c.Name == "_cfuvid" {
|
||||
parts = append(parts, c.Name+"="+c.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write Cloudflare cookies to a file the fetcher can read
|
||||
cfPath := filepath.Join(config.ConfigDir(), "cf-cookies")
|
||||
return os.WriteFile(cfPath, []byte(strings.Join(parts, "\n")+"\n"), 0o600)
|
||||
}
|
||||
102
internal/browser/login.go
Normal file
102
internal/browser/login.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/chromedp/chromedp"
|
||||
|
||||
"git.davoryn.de/calic/claude-statusline/internal/config"
|
||||
)
|
||||
|
||||
// LoginAndGetSessionKey opens a browser window for the user to log in to
|
||||
// claude.ai and extracts the httpOnly sessionKey cookie via DevTools protocol.
|
||||
// The browser uses a persistent profile so the user only needs to log in once.
|
||||
// Returns the session key or an error (e.g. timeout after 2 minutes).
|
||||
func LoginAndGetSessionKey() (string, error) {
|
||||
execPath := findBrowserExec()
|
||||
|
||||
profileDir := filepath.Join(config.ConfigDir(), "browser-profile")
|
||||
if err := os.MkdirAll(profileDir, 0o755); err != nil {
|
||||
return "", fmt.Errorf("create browser profile dir: %w", err)
|
||||
}
|
||||
|
||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.Flag("headless", false),
|
||||
chromedp.UserDataDir(profileDir),
|
||||
)
|
||||
if execPath != "" {
|
||||
opts = append(opts, chromedp.ExecPath(execPath))
|
||||
}
|
||||
|
||||
allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||
defer allocCancel()
|
||||
|
||||
ctx, cancel := chromedp.NewContext(allocCtx)
|
||||
defer cancel()
|
||||
|
||||
// Navigate to login page
|
||||
if err := chromedp.Run(ctx, chromedp.Navigate("https://claude.ai/login")); err != nil {
|
||||
return "", fmt.Errorf("navigate to login: %w", err)
|
||||
}
|
||||
|
||||
// Poll for the sessionKey cookie (httpOnly, so only accessible via DevTools)
|
||||
deadline := time.Now().Add(2 * time.Minute)
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if time.Now().After(deadline) {
|
||||
return "", fmt.Errorf("login timed out after 2 minutes")
|
||||
}
|
||||
|
||||
var cookies []*network.Cookie
|
||||
if err := chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
var err error
|
||||
cookies, err = network.GetCookies().Do(ctx)
|
||||
return err
|
||||
})); err != nil {
|
||||
// Browser may have been closed by user
|
||||
return "", fmt.Errorf("get cookies: %w", err)
|
||||
}
|
||||
|
||||
for _, c := range cookies {
|
||||
if c.Name == "sessionKey" && (c.Domain == ".claude.ai" || c.Domain == "claude.ai") {
|
||||
key := c.Value
|
||||
if err := config.SetSessionKey(key); err != nil {
|
||||
return "", fmt.Errorf("save session key: %w", err)
|
||||
}
|
||||
// Use chromedp.Cancel to close gracefully (flushes cookies to profile)
|
||||
cancel()
|
||||
return key, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// findBrowserExec returns the path to a Chromium-based browser, or "" to let
|
||||
// chromedp use its default detection (Chrome/Chromium on PATH).
|
||||
func findBrowserExec() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
// Prefer Edge (pre-installed on Windows 10+)
|
||||
candidates := []string{
|
||||
filepath.Join(os.Getenv("ProgramFiles(x86)"), "Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
filepath.Join(os.Getenv("ProgramFiles"), "Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
}
|
||||
for _, p := range candidates {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
}
|
||||
// On Linux/macOS, chromedp auto-detects Chrome/Chromium
|
||||
return ""
|
||||
}
|
||||
93
internal/config/config.go
Normal file
93
internal/config/config.go
Normal 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
85
internal/fetcher/cache.go
Normal 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
|
||||
}
|
||||
341
internal/fetcher/fetcher.go
Normal file
341
internal/fetcher/fetcher.go
Normal file
@@ -0,0 +1,341 @@
|
||||
package fetcher
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.davoryn.de/calic/claude-statusline/internal/browser"
|
||||
"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.
|
||||
// Includes any saved Cloudflare cookies from previous Chrome fallbacks.
|
||||
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
|
||||
}
|
||||
|
||||
cookie := "sessionKey=" + sessionKey
|
||||
// Append Cloudflare cookies if available (saved by Chrome fallback)
|
||||
if cfCookies := loadCFCookies(); cfCookies != "" {
|
||||
cookie += "; " + cfCookies
|
||||
}
|
||||
|
||||
req.Header.Set("Cookie", cookie)
|
||||
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
|
||||
}
|
||||
|
||||
// loadCFCookies reads saved Cloudflare cookies from the cf-cookies file.
|
||||
func loadCFCookies() string {
|
||||
data, err := os.ReadFile(filepath.Join(config.ConfigDir(), "cf-cookies"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
// File has one cookie per line (name=value), join with "; "
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
var valid []string
|
||||
for _, l := range lines {
|
||||
l = strings.TrimSpace(l)
|
||||
if l != "" {
|
||||
valid = append(valid, l)
|
||||
}
|
||||
}
|
||||
return strings.Join(valid, "; ")
|
||||
}
|
||||
|
||||
// fetchWithFallback tries a plain HTTP request first, then falls back to
|
||||
// headless Chrome (which can solve Cloudflare JS challenges) on 403.
|
||||
func fetchWithFallback(url, sessionKey string) ([]byte, error) {
|
||||
body, status, err := doRequest(url, sessionKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
if status == 200 {
|
||||
return body, nil
|
||||
}
|
||||
if status == 401 {
|
||||
return nil, fmt.Errorf("auth_expired")
|
||||
}
|
||||
if status == 403 {
|
||||
// Likely a Cloudflare JS challenge — fall back to headless Chrome
|
||||
log.Printf("HTTP 403 for %s, falling back to Chrome", url)
|
||||
chromeBody, chromeErr := browser.FetchViaChrome(url)
|
||||
if chromeErr != nil {
|
||||
log.Printf("Chrome fallback failed: %v", chromeErr)
|
||||
return nil, fmt.Errorf("auth_expired")
|
||||
}
|
||||
log.Printf("Chrome fallback succeeded (%d bytes)", len(chromeBody))
|
||||
return chromeBody, nil
|
||||
}
|
||||
return nil, fmt.Errorf("HTTP %d", status)
|
||||
}
|
||||
|
||||
// DiscoverOrgID fetches the first organization UUID from the API.
|
||||
func DiscoverOrgID(sessionKey string) (string, error) {
|
||||
body, err := fetchWithFallback(apiBase+"/api/organizations", sessionKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
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, err := fetchWithFallback(url, sessionKey)
|
||||
if err != nil {
|
||||
if err.Error() == "auth_expired" {
|
||||
return &CacheData{Error: "auth_expired", Status: 403}, orgID, err
|
||||
}
|
||||
return &CacheData{Error: "fetch_failed", Message: err.Error()}, orgID, err
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
178
internal/renderer/renderer.go
Normal file
178
internal/renderer/renderer.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"math"
|
||||
"runtime"
|
||||
|
||||
"github.com/fogleman/gg"
|
||||
)
|
||||
|
||||
const iconSize = 64
|
||||
|
||||
// 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 - 4 // inset from edge
|
||||
arcWidth := 7.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.
|
||||
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
|
||||
}
|
||||
|
||||
// RenderIconForTray returns icon bytes suitable for systray.SetIcon:
|
||||
// ICO (PNG-compressed) on Windows, raw PNG on other platforms.
|
||||
func RenderIconForTray(pct int) ([]byte, error) {
|
||||
pngData, err := RenderIconPNG(pct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
return pngData, nil
|
||||
}
|
||||
return wrapPNGInICO(pngData, iconSize, iconSize), nil
|
||||
}
|
||||
|
||||
// wrapPNGInICO wraps raw PNG bytes in a minimal ICO container.
|
||||
// Windows Vista+ supports PNG-compressed ICO entries.
|
||||
func wrapPNGInICO(pngData []byte, width, height int) []byte {
|
||||
const headerSize = 6
|
||||
const entrySize = 16
|
||||
imageOffset := headerSize + entrySize
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
// ICONDIR header
|
||||
binary.Write(buf, binary.LittleEndian, uint16(0)) // Reserved
|
||||
binary.Write(buf, binary.LittleEndian, uint16(1)) // Type: 1 = icon
|
||||
binary.Write(buf, binary.LittleEndian, uint16(1)) // Count: 1 image
|
||||
|
||||
// ICONDIRENTRY
|
||||
w := byte(width)
|
||||
if width >= 256 {
|
||||
w = 0 // 0 means 256
|
||||
}
|
||||
h := byte(height)
|
||||
if height >= 256 {
|
||||
h = 0
|
||||
}
|
||||
buf.WriteByte(w) // Width
|
||||
buf.WriteByte(h) // Height
|
||||
buf.WriteByte(0) // ColorCount (0 = no palette)
|
||||
buf.WriteByte(0) // Reserved
|
||||
binary.Write(buf, binary.LittleEndian, uint16(1)) // Planes
|
||||
binary.Write(buf, binary.LittleEndian, uint16(32)) // BitCount
|
||||
binary.Write(buf, binary.LittleEndian, uint32(len(pngData))) // BytesInRes
|
||||
binary.Write(buf, binary.LittleEndian, uint32(imageOffset)) // ImageOffset
|
||||
|
||||
// PNG image data
|
||||
buf.Write(pngData)
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
230
internal/tray/tray.go
Normal file
230
internal/tray/tray.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package tray
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"fyne.io/systray"
|
||||
"git.davoryn.de/calic/claude-statusline/internal/browser"
|
||||
"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%)
|
||||
iconData, err := renderer.RenderIconForTray(0)
|
||||
log.Printf("initial icon: %d bytes, render err=%v", len(iconData), err)
|
||||
if err == nil {
|
||||
systray.SetIcon(iconData)
|
||||
log.Println("SetIcon called (initial)")
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Login / logout
|
||||
mLogin := systray.AddMenuItem("Login in Browser", "Open browser to log in to claude.ai")
|
||||
mLogout := systray.AddMenuItem("Logout", "Clear session key and browser profile")
|
||||
|
||||
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 <-mLogin.ClickedCh:
|
||||
go a.doLogin()
|
||||
case <-mLogout.ClickedCh:
|
||||
a.doLogout()
|
||||
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
|
||||
}
|
||||
log.Printf("onUsageUpdate: pct=%d, error=%q", pct, data.Error)
|
||||
if iconData, err := renderer.RenderIconForTray(pct); err == nil {
|
||||
systray.SetIcon(iconData)
|
||||
log.Printf("SetIcon called (pct=%d, %d bytes)", pct, len(iconData))
|
||||
} else {
|
||||
log.Printf("RenderIconPNG error: %v", err)
|
||||
}
|
||||
|
||||
// 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) doLogin() {
|
||||
systray.SetTooltip("Claude Usage: logging in...")
|
||||
_, err := browser.LoginAndGetSessionKey()
|
||||
if err != nil {
|
||||
systray.SetTooltip(fmt.Sprintf("Claude Usage: login failed — %s", err))
|
||||
return
|
||||
}
|
||||
a.bf.Refresh()
|
||||
}
|
||||
|
||||
func (a *App) doLogout() {
|
||||
_ = os.Remove(config.SessionKeyPath())
|
||||
profileDir := filepath.Join(config.ConfigDir(), "browser-profile")
|
||||
_ = os.RemoveAll(profileDir)
|
||||
a.bf.Refresh()
|
||||
}
|
||||
13
package.json
13
package.json
@@ -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"
|
||||
}
|
||||
9
packaging/linux/claude-widget.desktop
Normal file
9
packaging/linux/claude-widget.desktop
Normal 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
21
packaging/linux/postinstall.sh
Executable 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
3
packaging/linux/preremove.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
# Kill running widget if any
|
||||
pkill -f claude-widget 2>/dev/null || true
|
||||
33
packaging/nfpm.yaml
Normal file
33
packaging/nfpm.yaml
Normal file
@@ -0,0 +1,33 @@
|
||||
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 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-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
|
||||
|
||||
scripts:
|
||||
postinstall: ./packaging/linux/postinstall.sh
|
||||
preremove: ./packaging/linux/preremove.sh
|
||||
@@ -1,2 +0,0 @@
|
||||
pystray>=0.19
|
||||
Pillow>=9.0
|
||||
101
statusline.js
101
statusline.js
@@ -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();
|
||||
Reference in New Issue
Block a user