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) }