Remove standalone fetcher, add setup tool with install/uninstall workflow
All checks were successful
Release / build (push) Successful in 1m45s
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:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user