diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 3877f35..c88c107 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -28,14 +28,14 @@ jobs: - 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-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 setup ./cmd/setup - 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" -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" -o setup.exe ./cmd/setup - name: Build .deb package run: | @@ -45,17 +45,16 @@ jobs: - name: Create Linux tarball run: | 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 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 }} - 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-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 }}/ cd dist-win && zip -r ../claude-statusline_${{ env.VERSION }}_windows_amd64.zip claude-statusline-${{ env.VERSION }} diff --git a/.gitignore b/.gitignore index 3d37613..f49d5c4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # Go binaries claude-statusline -claude-fetcher claude-widget +/setup *.exe # Build output diff --git a/README.md b/README.md index cdfa9b7..92338e9 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ ## 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 @@ -27,47 +27,49 @@ Headless status bar for Claude Code. Shows context window utilization and token 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 -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 ``` claude.ai API │ - ├──► claude-fetcher (cron) ──► /tmp/claude_usage.json ──► claude-statusline (Claude Code) - │ │ - └──► claude-widget (built-in fetcher) ──┘──► System tray icon + └──► claude-widget (background fetcher) ──► /tmp/claude_usage.json ──► claude-statusline (Claude Code) + │ + └──► 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 +### 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) ```bash sudo dpkg -i claude-statusline_0.3.0_amd64.deb ``` -Installs all three binaries to `/usr/bin/`, sets up autostart for the widget, and adds a cron job for the fetcher. +Installs binaries to `/usr/bin/` and sets up autostart for the widget. -### Linux (tar.gz) +### Linux (manual) ```bash tar xzf claude-statusline_0.3.0_linux_amd64.tar.gz -sudo cp claude-statusline-0.3.0/claude-{statusline,fetcher,widget} /usr/local/bin/ +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 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 ### Environment Variables @@ -136,12 +158,12 @@ Right-click the tray icon to access: ```bash # 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 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-fetcher.exe ./cmd/fetcher +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 @@ -152,17 +174,17 @@ VERSION=0.3.0 nfpm package --config packaging/nfpm.yaml --packager deb ``` cmd/ statusline/main.go # CLI statusline (reads stdin + cache) - fetcher/main.go # Standalone cron fetcher (writes cache) - widget/main.go # Desktop tray widget entry point + 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 (shared between widget + standalone) + 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, cron, install scripts + linux/ # .desktop file, install scripts ``` ## License diff --git a/cmd/fetcher/main.go b/cmd/fetcher/main.go deleted file mode 100644 index cbcaa11..0000000 --- a/cmd/fetcher/main.go +++ /dev/null @@ -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() - } -} diff --git a/cmd/setup/main.go b/cmd/setup/main.go new file mode 100644 index 0000000..ad78311 --- /dev/null +++ b/cmd/setup/main.go @@ -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) +} diff --git a/cmd/setup/ui_other.go b/cmd/setup/ui_other.go new file mode 100644 index 0000000..372ae9d --- /dev/null +++ b/cmd/setup/ui_other.go @@ -0,0 +1,5 @@ +//go:build !windows + +package main + +func showMessage(_, _ string, _ bool) {} diff --git a/cmd/setup/ui_windows.go b/cmd/setup/ui_windows.go new file mode 100644 index 0000000..21681b9 --- /dev/null +++ b/cmd/setup/ui_windows.go @@ -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) +} diff --git a/cmd/widget/main.go b/cmd/widget/main.go index 078da55..810f56e 100644 --- a/cmd/widget/main.go +++ b/cmd/widget/main.go @@ -1,9 +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") } diff --git a/go.mod b/go.mod index a27d3b5..810326f 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,22 @@ module git.davoryn.de/calic/claude-statusline -go 1.21 +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.15.0 // indirect + golang.org/x/sys v0.34.0 // indirect ) diff --git a/go.sum b/go.sum index 5182ab9..a515321 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +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.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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= diff --git a/internal/browser/login.go b/internal/browser/login.go new file mode 100644 index 0000000..0aa1850 --- /dev/null +++ b/internal/browser/login.go @@ -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 "" +} diff --git a/internal/renderer/renderer.go b/internal/renderer/renderer.go index c6f165c..66b20b1 100644 --- a/internal/renderer/renderer.go +++ b/internal/renderer/renderer.go @@ -2,15 +2,17 @@ package renderer import ( "bytes" + "encoding/binary" "image" "image/color" "image/png" "math" + "runtime" "github.com/fogleman/gg" ) -const iconSize = 256 +const iconSize = 64 // Claude orange for the starburst logo. var claudeOrange = color.RGBA{224, 123, 83, 255} @@ -93,8 +95,8 @@ func drawArc(dc *gg.Context, pct int) { cx := float64(iconSize) / 2 cy := float64(iconSize) / 2 - radius := float64(iconSize)/2 - 14 // inset from edge - arcWidth := 28.0 + 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 @@ -114,7 +116,7 @@ func RenderIcon(pct int) image.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) { img := RenderIcon(pct) var buf bytes.Buffer @@ -123,3 +125,54 @@ func RenderIconPNG(pct int) ([]byte, error) { } 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() +} diff --git a/internal/tray/tray.go b/internal/tray/tray.go index b2cb064..960a634 100644 --- a/internal/tray/tray.go +++ b/internal/tray/tray.go @@ -2,11 +2,13 @@ package tray import ( "fmt" - "os/exec" - "runtime" + "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" @@ -49,8 +51,11 @@ func (a *App) onReady() { systray.SetTooltip("Claude Usage: loading...") // 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) + log.Println("SetIcon called (initial)") } // Usage display items (non-clickable info) @@ -82,8 +87,9 @@ func (a *App) onReady() { a.menuItems.intervalRadio = append(a.menuItems.intervalRadio, item) } - // Session key - mSessionKey := systray.AddMenuItem("Session Key...", "Open session key config file") + // 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") @@ -98,8 +104,10 @@ func (a *App) onReady() { select { case <-mRefresh.ClickedCh: a.bf.Refresh() - case <-mSessionKey.ClickedCh: - a.openSessionKeyFile() + case <-mLogin.ClickedCh: + go a.doLogin() + case <-mLogout.ClickedCh: + a.doLogout() case <-mQuit.ClickedCh: systray.Quit() return @@ -133,8 +141,12 @@ func (a *App) onUsageUpdate(data fetcher.ParsedUsage) { if data.Error == "" { 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) + log.Printf("SetIcon called (pct=%d, %d bytes)", pct, len(iconData)) + } else { + log.Printf("RenderIconPNG error: %v", err) } // Update tooltip @@ -200,16 +212,19 @@ func (a *App) setInterval(idx int) { a.bf.SetInterval(intervals[idx].seconds) } -func (a *App) openSessionKeyFile() { - path := config.SessionKeyPath() - var cmd *exec.Cmd - switch runtime.GOOS { - case "windows": - cmd = exec.Command("notepad", path) - case "darwin": - cmd = exec.Command("open", "-t", path) - default: - cmd = exec.Command("xdg-open", path) +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 } - _ = 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() } diff --git a/packaging/linux/claude-statusline-fetch b/packaging/linux/claude-statusline-fetch deleted file mode 100644 index e348fdf..0000000 --- a/packaging/linux/claude-statusline-fetch +++ /dev/null @@ -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 diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml index 6c182a9..9717cac 100644 --- a/packaging/nfpm.yaml +++ b/packaging/nfpm.yaml @@ -6,7 +6,7 @@ section: utils priority: optional maintainer: Axel Meyer 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. vendor: davoryn.de homepage: https://git.davoryn.de/calic/claude-statusline @@ -18,11 +18,6 @@ contents: file_info: mode: 0755 - - src: ./claude-fetcher - dst: /usr/bin/claude-fetcher - file_info: - mode: 0755 - - src: ./claude-widget dst: /usr/bin/claude-widget file_info: @@ -33,11 +28,6 @@ contents: file_info: mode: 0644 - - src: ./packaging/linux/claude-statusline-fetch - dst: /etc/cron.d/claude-statusline-fetch - file_info: - mode: 0644 - scripts: postinstall: ./packaging/linux/postinstall.sh preremove: ./packaging/linux/preremove.sh