Remove standalone fetcher, add setup tool with install/uninstall workflow
All checks were successful
Release / build (push) Successful in 1m45s

Drop claude-fetcher binary and cron job — the widget's built-in
BackgroundFetcher is the sole fetcher now. Add cmd/setup with cross-platform
install and uninstall (--uninstall): kills widget, removes binaries + autostart,
cleans Claude Code statusline setting, optionally removes config dir.

Also includes: browser-based login (chromedp), ICO wrapper for Windows tray
icon, and reduced icon size (64px).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Axel Meyer
2026-02-26 19:11:08 +01:00
parent 5b0366f16b
commit 47165ce02c
15 changed files with 796 additions and 126 deletions

View File

@@ -28,14 +28,14 @@ jobs:
- name: Build Linux binaries - name: Build Linux binaries
run: | 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-statusline ./cmd/statusline
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o claude-fetcher ./cmd/fetcher
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o claude-widget ./cmd/widget 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
- name: Build Windows binaries - name: Build Windows binaries
run: | 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" -o claude-statusline.exe ./cmd/statusline
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o claude-fetcher.exe ./cmd/fetcher
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 -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
- name: Build .deb package - name: Build .deb package
run: | run: |
@@ -45,17 +45,16 @@ jobs:
- name: Create Linux tarball - name: Create Linux tarball
run: | run: |
mkdir -p dist/claude-statusline-${{ env.VERSION }} mkdir -p dist/claude-statusline-${{ env.VERSION }}
cp claude-statusline claude-fetcher claude-widget 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 README.md CHANGELOG.md dist/claude-statusline-${{ env.VERSION }}/
cp packaging/linux/claude-widget.desktop dist/claude-statusline-${{ env.VERSION }}/ cp packaging/linux/claude-widget.desktop dist/claude-statusline-${{ env.VERSION }}/
cp packaging/linux/claude-statusline-fetch dist/claude-statusline-${{ env.VERSION }}/
tar -czf claude-statusline_${{ env.VERSION }}_linux_amd64.tar.gz -C dist claude-statusline-${{ env.VERSION }} tar -czf claude-statusline_${{ env.VERSION }}_linux_amd64.tar.gz -C dist claude-statusline-${{ env.VERSION }}
- name: Create Windows zip - name: Create Windows zip
run: | run: |
apt-get update -qq && apt-get install -y -qq zip >/dev/null 2>&1 apt-get update -qq && apt-get install -y -qq zip >/dev/null 2>&1
mkdir -p dist-win/claude-statusline-${{ env.VERSION }} mkdir -p dist-win/claude-statusline-${{ env.VERSION }}
cp claude-statusline.exe claude-fetcher.exe claude-widget.exe dist-win/claude-statusline-${{ env.VERSION }}/ cp 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 }}/ 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 }} cd dist-win && zip -r ../claude-statusline_${{ env.VERSION }}_windows_amd64.zip claude-statusline-${{ env.VERSION }}

2
.gitignore vendored
View File

@@ -1,7 +1,7 @@
# Go binaries # Go binaries
claude-statusline claude-statusline
claude-fetcher
claude-widget claude-widget
/setup
*.exe *.exe
# Build output # Build output

View File

@@ -17,7 +17,7 @@
## Overview ## Overview
Three static binaries built from one Go codebase. No runtime dependencies — no Node.js, Python, or system packages needed. 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 ### CLI Statusline
@@ -27,47 +27,49 @@ Headless status bar for Claude Code. Shows context window utilization and token
Context ▓▓▓▓░░░░░░ 40% | Token ▓▓░░░░░░░░ 19% 78M Context ▓▓▓▓░░░░░░ 40% | Token ▓▓░░░░░░░░ 19% 78M
``` ```
### Usage Fetcher
Standalone binary for cron. Fetches token usage from the Claude API and writes a shared JSON cache.
### Desktop Widget ### Desktop Widget
System tray icon showing 5-hour usage as a circular progress bar on a Claude starburst logo. Color shifts from green through amber to red as usage increases. Right-click menu shows detailed stats and configuration. 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 ## Topology
``` ```
claude.ai API claude.ai API
──► claude-fetcher (cron) ──► /tmp/claude_usage.json ──► claude-statusline (Claude Code) ──► claude-widget (background fetcher) ──► /tmp/claude_usage.json ──► claude-statusline (Claude Code)
└──► claude-widget (built-in fetcher) ──┘──► System tray icon ──► System tray icon
``` ```
Only one fetcher needs to run. The widget has a built-in fetcher; the standalone `claude-fetcher` is for headless/cron setups. Both write the same cache format.
## Installation ## Installation
### Windows / Linux (setup tool)
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) ### Debian/Ubuntu (.deb)
```bash ```bash
sudo dpkg -i claude-statusline_0.3.0_amd64.deb sudo dpkg -i claude-statusline_0.3.0_amd64.deb
``` ```
Installs all three binaries to `/usr/bin/`, sets up autostart for the widget, and adds a cron job for the fetcher. Installs binaries to `/usr/bin/` and sets up autostart for the widget.
### Linux (tar.gz) ### Linux (manual)
```bash ```bash
tar xzf claude-statusline_0.3.0_linux_amd64.tar.gz tar xzf claude-statusline_0.3.0_linux_amd64.tar.gz
sudo cp claude-statusline-0.3.0/claude-{statusline,fetcher,widget} /usr/local/bin/ cp claude-statusline-0.3.0/claude-{statusline,widget} ~/.local/bin/
``` ```
### Windows
Extract the `.zip` and place the `.exe` files anywhere on your PATH.
### Session Key Setup ### Session Key Setup
After installing, paste your claude.ai session key: After installing, paste your claude.ai session key:
@@ -98,6 +100,26 @@ Add to your Claude Code settings (`~/.claude/settings.json`):
} }
``` ```
## 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 ## Configuration
### Environment Variables ### Environment Variables
@@ -136,12 +158,12 @@ Right-click the tray icon to access:
```bash ```bash
# All binaries # All binaries
go build ./cmd/statusline && go build ./cmd/fetcher && go build ./cmd/widget go build ./cmd/statusline && go build ./cmd/widget && go build ./cmd/setup
# Cross-compile for Windows # 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 -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 claude-statusline.exe ./cmd/statusline
GOOS=windows GOARCH=amd64 go build -o claude-fetcher.exe ./cmd/fetcher GOOS=windows GOARCH=amd64 go build -o setup.exe ./cmd/setup
# Build .deb # Build .deb
VERSION=0.3.0 nfpm package --config packaging/nfpm.yaml --packager deb VERSION=0.3.0 nfpm package --config packaging/nfpm.yaml --packager deb
@@ -152,17 +174,17 @@ VERSION=0.3.0 nfpm package --config packaging/nfpm.yaml --packager deb
``` ```
cmd/ cmd/
statusline/main.go # CLI statusline (reads stdin + cache) statusline/main.go # CLI statusline (reads stdin + cache)
fetcher/main.go # Standalone cron fetcher (writes cache) widget/main.go # Desktop tray widget with built-in fetcher
widget/main.go # Desktop tray widget entry point setup/main.go # Cross-platform install/uninstall tool
internal/ internal/
config/config.go # Shared config (session key, org ID, intervals) config/config.go # Shared config (session key, org ID, intervals)
fetcher/fetcher.go # HTTP fetch logic (shared between widget + standalone) fetcher/fetcher.go # HTTP fetch logic (used by widget)
fetcher/cache.go # JSON cache read/write (/tmp/claude_usage.json) fetcher/cache.go # JSON cache read/write (/tmp/claude_usage.json)
renderer/renderer.go # Icon rendering: starburst + arc (fogleman/gg) renderer/renderer.go # Icon rendering: starburst + arc (fogleman/gg)
tray/tray.go # System tray setup + menu (fyne-io/systray) tray/tray.go # System tray setup + menu (fyne-io/systray)
packaging/ packaging/
nfpm.yaml # .deb packaging config nfpm.yaml # .deb packaging config
linux/ # .desktop file, cron, install scripts linux/ # .desktop file, install scripts
``` ```
## License ## License

View File

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

479
cmd/setup/main.go Normal file
View 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
View File

@@ -0,0 +1,5 @@
//go:build !windows
package main
func showMessage(_, _ string, _ bool) {}

23
cmd/setup/ui_windows.go Normal file
View 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)
}

View File

@@ -1,9 +1,23 @@
package main package main
import ( import (
"log"
"os"
"path/filepath"
"git.davoryn.de/calic/claude-statusline/internal/tray" "git.davoryn.de/calic/claude-statusline/internal/tray"
) )
func main() { func main() {
tray.Run() // 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")
} }

11
go.mod
View File

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

23
go.sum
View File

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

102
internal/browser/login.go Normal file
View 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 ""
}

View File

@@ -2,15 +2,17 @@ package renderer
import ( import (
"bytes" "bytes"
"encoding/binary"
"image" "image"
"image/color" "image/color"
"image/png" "image/png"
"math" "math"
"runtime"
"github.com/fogleman/gg" "github.com/fogleman/gg"
) )
const iconSize = 256 const iconSize = 64
// Claude orange for the starburst logo. // Claude orange for the starburst logo.
var claudeOrange = color.RGBA{224, 123, 83, 255} var claudeOrange = color.RGBA{224, 123, 83, 255}
@@ -93,8 +95,8 @@ func drawArc(dc *gg.Context, pct int) {
cx := float64(iconSize) / 2 cx := float64(iconSize) / 2
cy := float64(iconSize) / 2 cy := float64(iconSize) / 2
radius := float64(iconSize)/2 - 14 // inset from edge radius := float64(iconSize)/2 - 4 // inset from edge
arcWidth := 28.0 arcWidth := 7.0
startAngle := -math.Pi / 2 // 12 o'clock startAngle := -math.Pi / 2 // 12 o'clock
endAngle := startAngle + (float64(pct)/100)*2*math.Pi endAngle := startAngle + (float64(pct)/100)*2*math.Pi
@@ -114,7 +116,7 @@ func RenderIcon(pct int) image.Image {
return dc.Image() return dc.Image()
} }
// RenderIconPNG generates the icon as PNG bytes (for systray). // RenderIconPNG generates the icon as PNG bytes.
func RenderIconPNG(pct int) ([]byte, error) { func RenderIconPNG(pct int) ([]byte, error) {
img := RenderIcon(pct) img := RenderIcon(pct)
var buf bytes.Buffer var buf bytes.Buffer
@@ -123,3 +125,54 @@ func RenderIconPNG(pct int) ([]byte, error) {
} }
return buf.Bytes(), nil 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()
}

View File

@@ -2,11 +2,13 @@ package tray
import ( import (
"fmt" "fmt"
"os/exec" "log"
"runtime" "os"
"path/filepath"
"sync" "sync"
"fyne.io/systray" "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/config"
"git.davoryn.de/calic/claude-statusline/internal/fetcher" "git.davoryn.de/calic/claude-statusline/internal/fetcher"
"git.davoryn.de/calic/claude-statusline/internal/renderer" "git.davoryn.de/calic/claude-statusline/internal/renderer"
@@ -49,8 +51,11 @@ func (a *App) onReady() {
systray.SetTooltip("Claude Usage: loading...") systray.SetTooltip("Claude Usage: loading...")
// Set initial icon (0%) // Set initial icon (0%)
if iconData, err := renderer.RenderIconPNG(0); err == nil { iconData, err := renderer.RenderIconForTray(0)
log.Printf("initial icon: %d bytes, render err=%v", len(iconData), err)
if err == nil {
systray.SetIcon(iconData) systray.SetIcon(iconData)
log.Println("SetIcon called (initial)")
} }
// Usage display items (non-clickable info) // Usage display items (non-clickable info)
@@ -82,8 +87,9 @@ func (a *App) onReady() {
a.menuItems.intervalRadio = append(a.menuItems.intervalRadio, item) a.menuItems.intervalRadio = append(a.menuItems.intervalRadio, item)
} }
// Session key // Login / logout
mSessionKey := systray.AddMenuItem("Session Key...", "Open session key config file") 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() systray.AddSeparator()
mQuit := systray.AddMenuItem("Quit", "Exit Claude Usage Widget") mQuit := systray.AddMenuItem("Quit", "Exit Claude Usage Widget")
@@ -98,8 +104,10 @@ func (a *App) onReady() {
select { select {
case <-mRefresh.ClickedCh: case <-mRefresh.ClickedCh:
a.bf.Refresh() a.bf.Refresh()
case <-mSessionKey.ClickedCh: case <-mLogin.ClickedCh:
a.openSessionKeyFile() go a.doLogin()
case <-mLogout.ClickedCh:
a.doLogout()
case <-mQuit.ClickedCh: case <-mQuit.ClickedCh:
systray.Quit() systray.Quit()
return return
@@ -133,8 +141,12 @@ func (a *App) onUsageUpdate(data fetcher.ParsedUsage) {
if data.Error == "" { if data.Error == "" {
pct = data.FiveHourPct pct = data.FiveHourPct
} }
if iconData, err := renderer.RenderIconPNG(pct); err == nil { log.Printf("onUsageUpdate: pct=%d, error=%q", pct, data.Error)
if iconData, err := renderer.RenderIconForTray(pct); err == nil {
systray.SetIcon(iconData) systray.SetIcon(iconData)
log.Printf("SetIcon called (pct=%d, %d bytes)", pct, len(iconData))
} else {
log.Printf("RenderIconPNG error: %v", err)
} }
// Update tooltip // Update tooltip
@@ -200,16 +212,19 @@ func (a *App) setInterval(idx int) {
a.bf.SetInterval(intervals[idx].seconds) a.bf.SetInterval(intervals[idx].seconds)
} }
func (a *App) openSessionKeyFile() { func (a *App) doLogin() {
path := config.SessionKeyPath() systray.SetTooltip("Claude Usage: logging in...")
var cmd *exec.Cmd _, err := browser.LoginAndGetSessionKey()
switch runtime.GOOS { if err != nil {
case "windows": systray.SetTooltip(fmt.Sprintf("Claude Usage: login failed — %s", err))
cmd = exec.Command("notepad", path) return
case "darwin":
cmd = exec.Command("open", "-t", path)
default:
cmd = exec.Command("xdg-open", path)
} }
_ = cmd.Start() a.bf.Refresh()
}
func (a *App) doLogout() {
_ = os.Remove(config.SessionKeyPath())
profileDir := filepath.Join(config.ConfigDir(), "browser-profile")
_ = os.RemoveAll(profileDir)
a.bf.Refresh()
} }

View File

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

View File

@@ -6,7 +6,7 @@ section: utils
priority: optional priority: optional
maintainer: Axel Meyer <axel.meyer@durania.net> maintainer: Axel Meyer <axel.meyer@durania.net>
description: | description: |
Claude API usage monitor — CLI statusline, cron fetcher, and desktop tray widget. 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. Shows 5-hour and 7-day token usage from claude.ai in your terminal or system tray.
vendor: davoryn.de vendor: davoryn.de
homepage: https://git.davoryn.de/calic/claude-statusline homepage: https://git.davoryn.de/calic/claude-statusline
@@ -18,11 +18,6 @@ contents:
file_info: file_info:
mode: 0755 mode: 0755
- src: ./claude-fetcher
dst: /usr/bin/claude-fetcher
file_info:
mode: 0755
- src: ./claude-widget - src: ./claude-widget
dst: /usr/bin/claude-widget dst: /usr/bin/claude-widget
file_info: file_info:
@@ -33,11 +28,6 @@ contents:
file_info: file_info:
mode: 0644 mode: 0644
- src: ./packaging/linux/claude-statusline-fetch
dst: /etc/cron.d/claude-statusline-fetch
file_info:
mode: 0644
scripts: scripts:
postinstall: ./packaging/linux/postinstall.sh postinstall: ./packaging/linux/postinstall.sh
preremove: ./packaging/linux/preremove.sh preremove: ./packaging/linux/preremove.sh