feat(update): auto-update in background on startup

Amolith created

On startup, Crush now checks for updates in the background and
automatically downloads/applies them if CanSelfUpdate() is true. Users
are notified to restart. Package manager installs show platform-specific
upgrade instructions instead.

- Add DisableAutoUpdate config option and CRUSH_DISABLE_AUTO_UPDATE env
  var
- Add platform detection for Homebrew, npm, AUR, Nix, apt, yum, winget,
  Scoop
- Fix Windows winget detection (require ProgramFiles prefix)
- Add --force flag to crush update apply
- Add tests for version comparison edge cases and platform detection

Assisted-by: Claude Opus 4.5 via Crush <crush@charm.land>

Change summary

go.mod                            |   1 
go.sum                            |   2 
internal/cmd/root.go              |  87 +++++++++++
internal/cmd/update.go            |  58 ++++++-
internal/config/config.go         |   1 
internal/config/load.go           |   4 
internal/update/update.go         | 148 +++++++++++++++++--
internal/update/update_darwin.go  |  38 +++++
internal/update/update_test.go    | 241 ++++++++++++++++++++++++++++++++
internal/update/update_unix.go    |  73 +++++++++
internal/update/update_windows.go |  62 ++++++++
11 files changed, 680 insertions(+), 35 deletions(-)

Detailed changes

go.mod 🔗

@@ -10,6 +10,7 @@ require (
 	charm.land/x/vcr v0.1.1
 	github.com/JohannesKaufmann/html-to-markdown v1.6.0
 	github.com/MakeNowJust/heredoc v1.0.0
+	github.com/Masterminds/semver/v3 v3.4.0
 	github.com/PuerkitoBio/goquery v1.11.0
 	github.com/alecthomas/chroma/v2 v2.20.0
 	github.com/atotto/clipboard v0.1.4

go.sum 🔗

@@ -28,6 +28,8 @@ github.com/JohannesKaufmann/html-to-markdown v1.6.0 h1:04VXMiE50YYfCfLboJCLcgqF5
 github.com/JohannesKaufmann/html-to-markdown v1.6.0/go.mod h1:NUI78lGg/a7vpEJTz/0uOcYMaibytE4BUOQS8k78yPQ=
 github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
 github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
+github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
+github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
 github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
 github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
 github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=

internal/cmd/root.go 🔗

@@ -23,6 +23,7 @@ import (
 	"github.com/charmbracelet/crush/internal/stringext"
 	termutil "github.com/charmbracelet/crush/internal/term"
 	"github.com/charmbracelet/crush/internal/tui"
+	tuiutil "github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/crush/internal/update"
 	"github.com/charmbracelet/crush/internal/version"
 	"github.com/charmbracelet/fang"
@@ -100,6 +101,9 @@ crush -y
 			tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state
 		go app.Subscribe(program)
 
+		// Start async update check unless disabled.
+		go checkForUpdateAsync(cmd.Context(), program)
+
 		if _, err := program.Run(); err != nil {
 			event.Error(err)
 			slog.Error("TUI run error", "error", err)
@@ -176,9 +180,23 @@ func hasVersionFlag() bool {
 	return false
 }
 
+// isAutoUpdateDisabled checks if update checks are disabled via env var.
+// Config is not loaded at this point (called before Execute), so only env var is checked.
+func isAutoUpdateDisabled() bool {
+	if str, ok := os.LookupEnv("CRUSH_DISABLE_AUTO_UPDATE"); ok {
+		v, _ := strconv.ParseBool(str)
+		return v
+	}
+	return false
+}
+
 // checkForUpdateSync performs a synchronous update check with a short timeout.
 // Returns a formatted update message if an update is available, empty string otherwise.
 func checkForUpdateSync() string {
+	if isAutoUpdateDisabled() {
+		return ""
+	}
+
 	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
 	defer cancel()
 
@@ -194,6 +212,75 @@ func checkForUpdateSync() string {
 	return fmt.Sprintf("\nUpdate available: v%s → v%s\nRun 'crush update apply' to install.\n", info.Current, info.Latest)
 }
 
+// checkForUpdateAsync checks for updates in the background and applies them if possible.
+func checkForUpdateAsync(ctx context.Context, program *tea.Program) {
+	// Check config (if loaded) or env var.
+	if isAutoUpdateDisabled() {
+		return
+	}
+	if cfg := config.Get(); cfg != nil && cfg.Options.DisableAutoUpdate {
+		return
+	}
+
+	checkCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
+	defer cancel()
+
+	info, err := update.Check(checkCtx, version.Version, update.Default)
+	if err != nil || !info.Available() || info.IsDevelopment() {
+		return
+	}
+
+	// Check install method.
+	method := update.DetectInstallMethod()
+	if !method.CanSelfUpdate() {
+		// Package manager install - show instructions.
+		program.Send(tuiutil.InfoMsg{
+			Type: tuiutil.InfoTypeUpdate,
+			Msg:  fmt.Sprintf("Update available: v%s → v%s. Run: %s", info.Current, info.Latest, method.UpdateInstructions()),
+			TTL:  30 * time.Second,
+		})
+		return
+	}
+
+	// Attempt self-update.
+	asset, err := update.FindAsset(info.Release.Assets)
+	if err != nil {
+		program.Send(tuiutil.InfoMsg{
+			Type: tuiutil.InfoTypeWarn,
+			Msg:  "Update available but failed to find asset. Run 'crush update' for details.",
+			TTL:  15 * time.Second,
+		})
+		return
+	}
+
+	binaryPath, err := update.Download(checkCtx, asset, info.Release)
+	if err != nil {
+		program.Send(tuiutil.InfoMsg{
+			Type: tuiutil.InfoTypeWarn,
+			Msg:  "Update download failed. Run 'crush update' for details.",
+			TTL:  15 * time.Second,
+		})
+		return
+	}
+	defer os.Remove(binaryPath)
+
+	if err := update.Apply(binaryPath); err != nil {
+		program.Send(tuiutil.InfoMsg{
+			Type: tuiutil.InfoTypeWarn,
+			Msg:  "Update failed to install. Run 'crush update' for details.",
+			TTL:  15 * time.Second,
+		})
+		return
+	}
+
+	// Success!
+	program.Send(tuiutil.InfoMsg{
+		Type: tuiutil.InfoTypeUpdate,
+		Msg:  fmt.Sprintf("Updated to v%s! Restart Crush to use the new version.", info.Latest),
+		TTL:  30 * time.Second,
+	})
+}
+
 func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
 	if termutil.SupportsProgressBar() {
 		_, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)

internal/cmd/update.go 🔗

@@ -27,6 +27,9 @@ crush update
 
 # Apply the update if available
 crush update apply
+
+# Force re-download even if already on latest version
+crush update apply --force
   `,
 	RunE: func(cmd *cobra.Command, args []string) error {
 		ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
@@ -45,7 +48,9 @@ crush update apply
 		if info.IsDevelopment() {
 			fmt.Fprintf(os.Stderr, "You are running a development version of Crush (%s).\n", info.Current)
 			fmt.Fprintf(os.Stderr, "The latest stable release is v%s.\n", info.Latest)
-			fmt.Fprintf(os.Stderr, "Visit %s to learn more.\n", info.URL)
+			fmt.Fprintf(os.Stderr, "To install the latest stable version, run:\n")
+			fmt.Fprintf(os.Stderr, "  go install github.com/charmbracelet/crush@latest\n")
+			fmt.Fprintf(os.Stderr, "Or visit %s to download manually.\n", info.URL)
 			return nil
 		}
 
@@ -54,6 +59,15 @@ crush update apply
 			return nil
 		}
 
+		// Check install method and provide appropriate instructions.
+		method := update.DetectInstallMethod()
+		if !method.CanSelfUpdate() {
+			fmt.Fprintf(os.Stderr, "Update available: v%s → v%s\n", info.Current, info.Latest)
+			fmt.Fprintf(os.Stderr, "Crush was installed via %s. To update, run:\n", method)
+			fmt.Fprintf(os.Stderr, "  %s\n", method.UpdateInstructions())
+			return nil
+		}
+
 		fmt.Fprintf(os.Stderr, "Update available: v%s → v%s\n", info.Current, info.Latest)
 		fmt.Fprintf(os.Stderr, "Run 'crush update apply' to install the latest version.\n")
 		fmt.Fprintf(os.Stderr, "Or visit %s to download manually.\n", info.URL)
@@ -69,11 +83,26 @@ var updateApplyCmd = &cobra.Command{
 	Example: `
 # Apply the latest update
 crush update apply
+
+# Force re-download even if already on latest version
+crush update apply --force
   `,
 	RunE: func(cmd *cobra.Command, args []string) error {
+		force, _ := cmd.Flags().GetBool("force")
+
 		ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Minute)
 		defer cancel()
 
+		// Check install method first.
+		method := update.DetectInstallMethod()
+		if !method.CanSelfUpdate() {
+			fmt.Fprintf(os.Stderr, "Crush was installed via %s.\n", method)
+			fmt.Fprintf(os.Stderr, "Self-update is not supported for this installation method.\n")
+			fmt.Fprintf(os.Stderr, "To update, run:\n")
+			fmt.Fprintf(os.Stderr, "  %s\n", method.UpdateInstructions())
+			return nil
+		}
+
 		spinner := newUpdateSpinner(ctx, cancel, "Checking for updates")
 		spinner.Start()
 
@@ -85,24 +114,22 @@ crush update apply
 
 		if info.IsDevelopment() {
 			spinner.Stop()
-			return fmt.Errorf("cannot update development versions automatically")
+			fmt.Fprintf(os.Stderr, "You are running a development version of Crush (%s).\n", info.Current)
+			fmt.Fprintf(os.Stderr, "Self-update is not supported for development versions.\n")
+			fmt.Fprintf(os.Stderr, "To install the latest stable version, run:\n")
+			fmt.Fprintf(os.Stderr, "  go install github.com/charmbracelet/crush@latest\n")
+			return nil
 		}
 
-		if !info.Available() {
+		if !info.Available() && !force {
 			spinner.Stop()
 			fmt.Fprintf(os.Stderr, "You are already running the latest version (v%s).\n", info.Current)
+			fmt.Fprintf(os.Stderr, "Use --force to re-download and reinstall.\n")
 			return nil
 		}
 
-		// Get the latest release with assets.
-		release, err := update.Default.Latest(ctx)
-		if err != nil {
-			spinner.Stop()
-			return fmt.Errorf("failed to fetch release information: %w", err)
-		}
-
 		// Find the appropriate asset for this platform.
-		asset, err := update.FindAsset(release.Assets)
+		asset, err := update.FindAsset(info.Release.Assets)
 		if err != nil {
 			spinner.Stop()
 			return fmt.Errorf("failed to find update for your platform: %w", err)
@@ -113,7 +140,7 @@ crush update apply
 		spinner.Start()
 
 		// Download the asset.
-		binaryPath, err := update.Download(ctx, asset, release)
+		binaryPath, err := update.Download(ctx, asset, info.Release)
 		if err != nil {
 			spinner.Stop()
 			return fmt.Errorf("failed to download update: %w", err)
@@ -132,7 +159,11 @@ crush update apply
 
 		spinner.Stop()
 
-		fmt.Fprintf(os.Stderr, "Successfully updated to v%s!\n", info.Latest)
+		if force && !info.Available() {
+			fmt.Fprintf(os.Stderr, "Successfully reinstalled v%s!\n", info.Latest)
+		} else {
+			fmt.Fprintf(os.Stderr, "Successfully updated to v%s!\n", info.Latest)
+		}
 		fmt.Fprintf(os.Stderr, "Run 'crush -v' to verify the new version.\n")
 
 		return nil
@@ -161,5 +192,6 @@ func newUpdateSpinner(ctx context.Context, cancel context.CancelFunc, label stri
 }
 
 func init() {
+	updateApplyCmd.Flags().BoolP("force", "f", false, "Force re-download even if already on latest version")
 	updateCmd.AddCommand(updateApplyCmd)
 }

internal/config/config.go 🔗

@@ -221,6 +221,7 @@ type Options struct {
 	DataDirectory             string       `json:"data_directory,omitempty" jsonschema:"description=Directory for storing application data (relative to working directory),default=.crush,example=.crush"` // Relative to the cwd
 	DisabledTools             []string     `json:"disabled_tools" jsonschema:"description=Tools to disable"`
 	DisableProviderAutoUpdate bool         `json:"disable_provider_auto_update,omitempty" jsonschema:"description=Disable providers auto-update,default=false"`
+	DisableAutoUpdate         bool         `json:"disable_auto_update,omitempty" jsonschema:"description=Disable automatic update checks,default=false"`
 	Attribution               *Attribution `json:"attribution,omitempty" jsonschema:"description=Attribution settings for generated content"`
 	DisableMetrics            bool         `json:"disable_metrics,omitempty" jsonschema:"description=Disable sending metrics,default=false"`
 	InitializeAs              string       `json:"initialize_as,omitempty" jsonschema:"description=Name of the context file to create/update during project initialization,default=AGENTS.md,example=AGENTS.md,example=CRUSH.md,example=CLAUDE.md,example=docs/LLMs.md"`

internal/config/load.go 🔗

@@ -379,6 +379,10 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
 		c.Options.DisableProviderAutoUpdate, _ = strconv.ParseBool(str)
 	}
 
+	if str, ok := os.LookupEnv("CRUSH_DISABLE_AUTO_UPDATE"); ok {
+		c.Options.DisableAutoUpdate, _ = strconv.ParseBool(str)
+	}
+
 	if c.Options.Attribution == nil {
 		c.Options.Attribution = &Attribution{
 			TrailerStyle:  TrailerStyleAssistedBy,

internal/update/update.go 🔗

@@ -19,11 +19,12 @@ import (
 	"strings"
 	"time"
 
+	"github.com/Masterminds/semver/v3"
 	"github.com/charmbracelet/crush/internal/version"
 )
 
 const (
-	githubApiUrl     = "https://api.github.com/repos/charmbracelet/crush/releases/latest"
+	githubAPIURL     = "https://api.github.com/repos/charmbracelet/crush/releases/latest"
 	maxBinarySize    = 500 * 1024 * 1024 // 500MB max for extracted binary
 	maxArchiveSize   = 500 * 1024 * 1024 // 500MB max for downloaded archive
 	maxChecksumsSize = 1 * 1024 * 1024   // 1MB max for checksums.txt
@@ -42,34 +43,139 @@ type Info struct {
 	Current string
 	Latest  string
 	URL     string
+	Release *Release
 }
 
-// Matches a version string like:
-// v0.0.0-0.20251231235959-06c807842604
-var goInstallRegexp = regexp.MustCompile(`^v?\d+\.\d+\.\d+-\d+\.\d{14}-[0-9a-f]{12}$`)
+// goInstallRegexp matches pseudo-versions from go install:
+// v0.0.0-20251231235959-06c807842604
+var goInstallRegexp = regexp.MustCompile(`^v?\d+\.\d+\.\d+-\d{14}-[0-9a-f]{12}$`)
 
+// gitDescribeRegexp matches git describe versions:
+// v0.19.0-15-g1a2b3c4d (tag-commits-ghash)
+var gitDescribeRegexp = regexp.MustCompile(`^v?\d+\.\d+\.\d+-\d+-g[0-9a-f]+$`)
+
+// IsDevelopment returns true if the current version appears to be a
+// development build rather than an official release.
 func (i Info) IsDevelopment() bool {
-	return i.Current == "devel" || i.Current == "unknown" || strings.Contains(i.Current, "dirty") || goInstallRegexp.MatchString(i.Current)
+	return i.Current == "devel" ||
+		i.Current == "unknown" ||
+		strings.Contains(i.Current, "dirty") ||
+		goInstallRegexp.MatchString(i.Current) ||
+		gitDescribeRegexp.MatchString(i.Current)
 }
 
 // Available returns true if there's an update available.
-//
-// If both current and latest are stable versions, returns true if versions are
-// different.
-// If current is a pre-release and latest isn't, returns true.
-// If latest is a pre-release and current isn't, returns false.
+// Uses proper semver comparison to handle version ordering correctly.
+// Returns false if either version cannot be parsed.
+// Special case: if current is stable and latest is a prerelease, returns false
+// (we don't offer prerelease updates to stable users).
 func (i Info) Available() bool {
-	cpr := strings.Contains(i.Current, "-")
-	lpr := strings.Contains(i.Latest, "-")
-	// current is pre release && latest isn't a prerelease
-	if cpr && !lpr {
-		return true
-	}
-	// latest is pre release && current isn't a prerelease
-	if lpr && !cpr {
+	current, err := semver.NewVersion(i.Current)
+	if err != nil {
+		return false
+	}
+	latest, err := semver.NewVersion(i.Latest)
+	if err != nil {
+		return false
+	}
+
+	// Don't offer prerelease updates to stable users.
+	if current.Prerelease() == "" && latest.Prerelease() != "" {
 		return false
 	}
-	return i.Current != i.Latest
+
+	return latest.GreaterThan(current)
+}
+
+// InstallMethod represents how Crush was installed.
+type InstallMethod int
+
+const (
+	InstallMethodUnknown InstallMethod = iota
+	InstallMethodBinary                // Direct binary download
+	InstallMethodHomebrew
+	InstallMethodNPM
+	InstallMethodAUR
+	InstallMethodNix
+	InstallMethodWinget
+	InstallMethodScoop
+	InstallMethodApt
+	InstallMethodYum
+	InstallMethodGoInstall
+)
+
+// String returns a human-readable name for the install method.
+func (m InstallMethod) String() string {
+	switch m {
+	case InstallMethodBinary:
+		return "binary"
+	case InstallMethodHomebrew:
+		return "Homebrew"
+	case InstallMethodNPM:
+		return "npm"
+	case InstallMethodAUR:
+		return "AUR"
+	case InstallMethodNix:
+		return "Nix"
+	case InstallMethodWinget:
+		return "winget"
+	case InstallMethodScoop:
+		return "Scoop"
+	case InstallMethodApt:
+		return "apt"
+	case InstallMethodYum:
+		return "yum"
+	case InstallMethodGoInstall:
+		return "go install"
+	default:
+		return "unknown"
+	}
+}
+
+// CanSelfUpdate returns true if this install method supports self-updating.
+func (m InstallMethod) CanSelfUpdate() bool {
+	return m == InstallMethodBinary || m == InstallMethodUnknown
+}
+
+// UpdateInstructions returns the command to update Crush for this install method.
+func (m InstallMethod) UpdateInstructions() string {
+	switch m {
+	case InstallMethodHomebrew:
+		return "brew upgrade charmbracelet/tap/crush"
+	case InstallMethodNPM:
+		return "npm update -g @charmland/crush"
+	case InstallMethodAUR:
+		return "yay -Syu crush-bin"
+	case InstallMethodNix:
+		return "nix flake update  # or update your NUR channel"
+	case InstallMethodWinget:
+		return "winget upgrade charmbracelet.crush"
+	case InstallMethodScoop:
+		return "scoop update crush"
+	case InstallMethodApt:
+		return "sudo apt update && sudo apt upgrade crush"
+	case InstallMethodYum:
+		return "sudo yum update crush"
+	case InstallMethodGoInstall:
+		return "go install github.com/charmbracelet/crush@latest"
+	default:
+		return ""
+	}
+}
+
+// DetectInstallMethod attempts to determine how Crush was installed.
+// This is implemented per-platform in update_darwin.go, update_unix.go,
+// and update_windows.go.
+func DetectInstallMethod() InstallMethod {
+	exe, err := os.Executable()
+	if err != nil {
+		return InstallMethodUnknown
+	}
+	exe, err = filepath.EvalSymlinks(exe)
+	if err != nil {
+		return InstallMethodUnknown
+	}
+	return detectInstallMethod(exe)
 }
 
 // Check checks if a new version is available.
@@ -87,6 +193,7 @@ func Check(ctx context.Context, current string, client Client) (Info, error) {
 	info.Latest = strings.TrimPrefix(release.TagName, "v")
 	info.Current = strings.TrimPrefix(info.Current, "v")
 	info.URL = release.HTMLURL
+	info.Release = release
 	return info, nil
 }
 
@@ -94,6 +201,7 @@ func Check(ctx context.Context, current string, client Client) (Info, error) {
 type Asset struct {
 	Name               string `json:"name"`
 	BrowserDownloadURL string `json:"browser_download_url"`
+	Size               int64  `json:"size"`
 }
 
 // Release represents a GitHub release.
@@ -116,7 +224,7 @@ func (c *github) Latest(ctx context.Context) (*Release, error) {
 		Timeout: 30 * time.Second,
 	}
 
-	req, err := http.NewRequestWithContext(ctx, "GET", githubApiUrl, nil)
+	req, err := http.NewRequestWithContext(ctx, "GET", githubAPIURL, nil)
 	if err != nil {
 		return nil, err
 	}

internal/update/update_darwin.go 🔗

@@ -5,6 +5,9 @@ package update
 import (
 	"debug/macho"
 	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
 )
 
 const binaryName = "crush"
@@ -21,3 +24,38 @@ func validateBinary(path string) error {
 	}
 	return nil
 }
+
+// detectInstallMethod determines how Crush was installed on macOS.
+func detectInstallMethod(exePath string) InstallMethod {
+	// Check for Homebrew installation.
+	// Apple Silicon: /opt/homebrew/Cellar/crush/...
+	// Intel: /usr/local/Cellar/crush/...
+	if strings.Contains(exePath, "/Cellar/") ||
+		strings.HasPrefix(exePath, "/opt/homebrew/") ||
+		strings.HasPrefix(exePath, "/usr/local/Homebrew/") {
+		return InstallMethodHomebrew
+	}
+
+	// Check for Nix installation.
+	if strings.HasPrefix(exePath, "/nix/store/") {
+		return InstallMethodNix
+	}
+
+	// Check for npm global installation.
+	// Typically in /usr/local/lib/node_modules or ~/.npm-global
+	if strings.Contains(exePath, "node_modules") {
+		return InstallMethodNPM
+	}
+
+	// Check for go install (typically in ~/go/bin or $GOPATH/bin).
+	gopath := os.Getenv("GOPATH")
+	if gopath == "" {
+		home, _ := os.UserHomeDir()
+		gopath = filepath.Join(home, "go")
+	}
+	if strings.HasPrefix(exePath, filepath.Join(gopath, "bin")) {
+		return InstallMethodGoInstall
+	}
+
+	return InstallMethodUnknown
+}

internal/update/update_test.go 🔗

@@ -5,6 +5,7 @@ import (
 	"archive/zip"
 	"compress/gzip"
 	"context"
+	"errors"
 	"os"
 	"path/filepath"
 	"runtime"
@@ -44,6 +45,13 @@ func TestCheckForUpdate_Beta(t *testing.T) {
 	})
 }
 
+func TestCheckForUpdate_NetworkError(t *testing.T) {
+	t.Parallel()
+	_, err := Check(t.Context(), "0.19.0", errorClient{errors.New("network unreachable")})
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "failed to fetch")
+}
+
 type testClient struct{ tag string }
 
 // Latest implements Client.
@@ -54,6 +62,13 @@ func (t testClient) Latest(ctx context.Context) (*Release, error) {
 	}, nil
 }
 
+type errorClient struct{ err error }
+
+// Latest implements Client.
+func (e errorClient) Latest(ctx context.Context) (*Release, error) {
+	return nil, e.err
+}
+
 func TestFindAsset(t *testing.T) {
 	t.Parallel()
 
@@ -111,11 +126,16 @@ func TestIsDevelopment(t *testing.T) {
 		{"unknown version", "unknown", true},
 		{"dirty version", "0.19.0-dirty", true},
 		{"dirty with suffix", "0.19.0-10-g1234567-dirty", true},
-		{"go install version", "v0.0.0-0.20251231235959-06c807842604", true},
+		{"go install pseudo-version", "v0.0.0-20251231235959-06c807842604", true},
+		{"git describe version", "v0.19.0-15-g1a2b3c4d", true},
+		{"git describe short hash", "0.19.0-3-gabcdef0", true},
+		{"git describe long hash", "v1.0.0-100-g0123456789ab", true},
 		{"stable version", "0.19.0", false},
 		{"pre-release beta", "0.19.0-beta.1", false},
 		{"pre-release rc", "0.19.0-rc.1", false},
 		{"pre-release alpha", "0.19.0-alpha.1", false},
+		{"major version", "1.0.0", false},
+		{"with v prefix", "v2.0.0", false},
 	}
 
 	for _, tt := range tests {
@@ -136,14 +156,46 @@ func TestAvailable(t *testing.T) {
 		latest  string
 		want    bool
 	}{
+		// Basic cases.
 		{"same version", "0.19.0", "0.19.0", false},
 		{"newer available", "0.19.0", "0.19.1", true},
-		{"older latest (downgrade)", "0.19.1", "0.19.0", true},
+		{"older latest (no downgrade)", "0.19.1", "0.19.0", false},
+
+		// Pre-release handling.
 		{"rc to stable", "0.19.0-rc.1", "0.19.0", true},
 		{"stable to rc", "0.19.0", "0.20.0-rc.1", false},
 		{"alpha to beta", "0.19.0-alpha.1", "0.19.0-beta.1", true},
 		{"beta to rc", "0.19.0-beta.1", "0.19.0-rc.1", true},
 		{"same pre-release", "0.19.0-beta.1", "0.19.0-beta.1", false},
+
+		// Semver edge cases - multi-digit versions.
+		{"0.9.9 to 0.10.0", "0.9.9", "0.10.0", true},
+		{"0.19.0 to 0.19.10", "0.19.0", "0.19.10", true},
+		{"1.9.0 to 1.10.0", "1.9.0", "1.10.0", true},
+
+		// Major version bumps.
+		{"0.x to 1.0", "0.99.99", "1.0.0", true},
+		{"1.x to 2.0", "1.0.0", "2.0.0", true},
+
+		// With v prefix.
+		{"v prefix current", "v0.19.0", "0.19.1", true},
+		{"v prefix latest", "0.19.0", "v0.19.1", true},
+		{"v prefix both", "v0.19.0", "v0.19.1", true},
+
+		// Malformed versions should return false.
+		{"malformed current", "not-a-version", "0.19.0", false},
+		{"malformed latest", "0.19.0", "not-a-version", false},
+		{"both malformed", "bad", "worse", false},
+
+		// Build metadata is ignored in semver comparison.
+		{"build metadata only diff", "1.0.0+build.1", "1.0.0+build.2", false},
+		{"build metadata vs plain", "1.0.0", "1.0.0+build.1", false},
+		{"build metadata with newer version", "1.0.0+build.1", "1.0.1", true},
+
+		// Pre-release ordering edge cases.
+		{"prerelease alpha ordering", "1.0.0-alpha", "1.0.0-alpha.1", true},
+		{"prerelease numeric ordering", "1.0.0-1", "1.0.0-2", true},
+		{"prerelease alpha vs beta", "1.0.0-alpha.2", "1.0.0-beta.1", true},
 	}
 
 	for _, tt := range tests {
@@ -380,3 +432,188 @@ func TestApply(t *testing.T) {
 		require.Equal(t, content, dstContent)
 	})
 }
+
+func TestInstallMethod_String(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		method InstallMethod
+		want   string
+	}{
+		{InstallMethodUnknown, "unknown"},
+		{InstallMethodBinary, "binary"},
+		{InstallMethodHomebrew, "Homebrew"},
+		{InstallMethodNPM, "npm"},
+		{InstallMethodAUR, "AUR"},
+		{InstallMethodNix, "Nix"},
+		{InstallMethodWinget, "winget"},
+		{InstallMethodScoop, "Scoop"},
+		{InstallMethodApt, "apt"},
+		{InstallMethodYum, "yum"},
+		{InstallMethodGoInstall, "go install"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.want, func(t *testing.T) {
+			t.Parallel()
+			require.Equal(t, tt.want, tt.method.String())
+		})
+	}
+}
+
+func TestInstallMethod_CanSelfUpdate(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		method InstallMethod
+		want   bool
+	}{
+		{InstallMethodUnknown, true},
+		{InstallMethodBinary, true},
+		{InstallMethodHomebrew, false},
+		{InstallMethodNPM, false},
+		{InstallMethodAUR, false},
+		{InstallMethodNix, false},
+		{InstallMethodWinget, false},
+		{InstallMethodScoop, false},
+		{InstallMethodApt, false},
+		{InstallMethodYum, false},
+		{InstallMethodGoInstall, false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.method.String(), func(t *testing.T) {
+			t.Parallel()
+			require.Equal(t, tt.want, tt.method.CanSelfUpdate())
+		})
+	}
+}
+
+func TestInstallMethod_UpdateInstructions(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		method   InstallMethod
+		contains string
+	}{
+		{InstallMethodHomebrew, "brew upgrade"},
+		{InstallMethodNPM, "npm update"},
+		{InstallMethodAUR, "yay"},
+		{InstallMethodNix, "nix"},
+		{InstallMethodWinget, "winget upgrade"},
+		{InstallMethodScoop, "scoop update"},
+		{InstallMethodApt, "apt"},
+		{InstallMethodYum, "yum update"},
+		{InstallMethodGoInstall, "go install"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.method.String(), func(t *testing.T) {
+			t.Parallel()
+			instructions := tt.method.UpdateInstructions()
+			require.Contains(t, instructions, tt.contains)
+		})
+	}
+
+	t.Run("unknown returns empty", func(t *testing.T) {
+		t.Parallel()
+		require.Empty(t, InstallMethodUnknown.UpdateInstructions())
+	})
+
+	t.Run("binary returns empty", func(t *testing.T) {
+		t.Parallel()
+		require.Empty(t, InstallMethodBinary.UpdateInstructions())
+	})
+}
+
+func TestDetectInstallMethod_GoInstall(t *testing.T) {
+	// Cannot use t.Parallel() with t.Setenv.
+
+	// Test that a path in GOPATH/bin is detected as go install.
+	tmpDir := t.TempDir()
+	gopathBin := filepath.Join(tmpDir, "go", "bin")
+	require.NoError(t, os.MkdirAll(gopathBin, 0o755))
+
+	exePath := filepath.Join(gopathBin, "crush")
+	t.Setenv("GOPATH", filepath.Join(tmpDir, "go"))
+
+	method := detectInstallMethod(exePath)
+	require.Equal(t, InstallMethodGoInstall, method)
+}
+
+func TestDetectInstallMethod_Nix(t *testing.T) {
+	t.Parallel()
+
+	// Test that a path in /nix/store is detected as Nix.
+	exePath := "/nix/store/abc123-crush-0.21.0/bin/crush"
+	method := detectInstallMethod(exePath)
+	require.Equal(t, InstallMethodNix, method)
+}
+
+func TestDetectInstallMethod_Unknown(t *testing.T) {
+	t.Parallel()
+
+	// Test that an unknown path returns Unknown.
+	exePath := "/some/random/path/crush"
+	method := detectInstallMethod(exePath)
+	require.Equal(t, InstallMethodUnknown, method)
+}
+
+func TestDetectInstallMethod_NPM(t *testing.T) {
+	t.Parallel()
+
+	tests := []string{
+		"/usr/local/lib/node_modules/@charmland/crush/bin/crush",
+		"/home/user/.npm-global/lib/node_modules/@charmland/crush/bin/crush",
+		"/Users/user/node_modules/.bin/crush",
+	}
+
+	for _, exePath := range tests {
+		t.Run(exePath, func(t *testing.T) {
+			t.Parallel()
+			method := detectInstallMethod(exePath)
+			require.Equal(t, InstallMethodNPM, method)
+		})
+	}
+}
+
+func TestDetectInstallMethod_Homebrew(t *testing.T) {
+	t.Parallel()
+
+	// Test paths that work across platforms (contain /Cellar/).
+	tests := []struct {
+		name    string
+		exePath string
+	}{
+		{"Intel Mac Cellar", "/usr/local/Cellar/crush/0.21.0/bin/crush"},
+		{"Apple Silicon Cellar", "/opt/homebrew/Cellar/crush/0.21.0/bin/crush"},
+		{"Linux Homebrew Cellar", "/home/linuxbrew/.linuxbrew/Cellar/crush/0.21.0/bin/crush"},
+		{"Linux Homebrew user Cellar", "/home/user/.linuxbrew/Cellar/crush/0.21.0/bin/crush"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			method := detectInstallMethod(tt.exePath)
+			require.Equal(t, InstallMethodHomebrew, method)
+		})
+	}
+}
+
+func TestDetectInstallMethod_DefaultGoPath(t *testing.T) {
+	// Cannot use t.Parallel() with t.Setenv.
+
+	// Test detection with default GOPATH (~/go/bin) when GOPATH is not set.
+	tmpDir := t.TempDir()
+	home := filepath.Join(tmpDir, "home", "user")
+	gopathBin := filepath.Join(home, "go", "bin")
+	require.NoError(t, os.MkdirAll(gopathBin, 0o755))
+
+	// Unset GOPATH to test default detection.
+	t.Setenv("GOPATH", "")
+	t.Setenv("HOME", home)
+
+	exePath := filepath.Join(gopathBin, "crush")
+	method := detectInstallMethod(exePath)
+	require.Equal(t, InstallMethodGoInstall, method)
+}

internal/update/update_unix.go 🔗

@@ -5,6 +5,9 @@ package update
 import (
 	"debug/elf"
 	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
 )
 
 const binaryName = "crush"
@@ -21,3 +24,73 @@ func validateBinary(path string) error {
 	}
 	return nil
 }
+
+// detectInstallMethod determines how Crush was installed on Linux/BSD.
+func detectInstallMethod(exePath string) InstallMethod {
+	// Check for Nix installation first (works on any platform with Nix).
+	if strings.HasPrefix(exePath, "/nix/store/") {
+		return InstallMethodNix
+	}
+
+	// Check for apt (Debian/Ubuntu) installation.
+	// If binary is in /usr/bin and dpkg knows about it.
+	if strings.HasPrefix(exePath, "/usr/bin/") || strings.HasPrefix(exePath, "/usr/local/bin/") {
+		if _, err := os.Stat("/var/lib/dpkg/info/crush.list"); err == nil {
+			return InstallMethodApt
+		}
+	}
+
+	// Check for yum/dnf (Fedora/RHEL) installation.
+	if strings.HasPrefix(exePath, "/usr/bin/") || strings.HasPrefix(exePath, "/usr/local/bin/") {
+		// Check rpm database for crush package.
+		if _, err := os.Stat("/var/lib/rpm"); err == nil {
+			// We have an rpm database; if we're in /usr/bin, likely installed via yum.
+			// More precise detection would require running `rpm -qf` but we avoid
+			// spawning processes. Presence in /usr/bin with rpm db is a good signal.
+			if strings.HasPrefix(exePath, "/usr/bin/crush") {
+				// Check if there's no dpkg (to distinguish from apt).
+				if _, err := os.Stat("/var/lib/dpkg"); os.IsNotExist(err) {
+					return InstallMethodYum
+				}
+			}
+		}
+	}
+
+	// Check for AUR installation (Arch Linux).
+	// AUR packages are tracked by pacman in /var/lib/pacman.
+	if _, err := os.Stat("/var/lib/pacman/local"); err == nil {
+		// Arch-based system. Check if crush is in pacman's database.
+		entries, err := os.ReadDir("/var/lib/pacman/local")
+		if err == nil {
+			for _, entry := range entries {
+				if strings.HasPrefix(entry.Name(), "crush-") {
+					return InstallMethodAUR
+				}
+			}
+		}
+	}
+
+	// Check for Homebrew on Linux.
+	if strings.Contains(exePath, "/Cellar/") ||
+		strings.HasPrefix(exePath, "/home/linuxbrew/") ||
+		strings.Contains(exePath, "/.linuxbrew/") {
+		return InstallMethodHomebrew
+	}
+
+	// Check for npm global installation.
+	if strings.Contains(exePath, "node_modules") {
+		return InstallMethodNPM
+	}
+
+	// Check for go install.
+	gopath := os.Getenv("GOPATH")
+	if gopath == "" {
+		home, _ := os.UserHomeDir()
+		gopath = filepath.Join(home, "go")
+	}
+	if strings.HasPrefix(exePath, filepath.Join(gopath, "bin")) {
+		return InstallMethodGoInstall
+	}
+
+	return InstallMethodUnknown
+}

internal/update/update_windows.go 🔗

@@ -5,6 +5,9 @@ package update
 import (
 	"debug/pe"
 	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
 )
 
 const binaryName = "crush.exe"
@@ -19,3 +22,62 @@ func validateBinary(path string) error {
 	// PE files opened successfully are valid executables.
 	return nil
 }
+
+// detectInstallMethod determines how Crush was installed on Windows.
+func detectInstallMethod(exePath string) InstallMethod {
+	// Normalize path separators for comparison.
+	exePath = filepath.ToSlash(exePath)
+	exePathLower := strings.ToLower(exePath)
+
+	// Check for Scoop installation.
+	// Typically in ~/scoop/apps/crush/current/ or ~/scoop/shims/
+	home, _ := os.UserHomeDir()
+	if home != "" {
+		scoopPath := strings.ToLower(filepath.ToSlash(filepath.Join(home, "scoop")))
+		if strings.HasPrefix(exePathLower, scoopPath) {
+			return InstallMethodScoop
+		}
+	}
+
+	// Check for winget installation.
+	// Winget installs to %LOCALAPPDATA%\Microsoft\WinGet\Packages\
+	localAppData := os.Getenv("LOCALAPPDATA")
+	if localAppData != "" {
+		wingetPath := strings.ToLower(filepath.ToSlash(filepath.Join(localAppData, "Microsoft", "WinGet")))
+		if strings.HasPrefix(exePathLower, wingetPath) {
+			return InstallMethodWinget
+		}
+	}
+
+	// Also check Program Files for winget system-wide installs.
+	programFiles := os.Getenv("ProgramFiles")
+	if programFiles != "" {
+		pfPath := strings.ToLower(filepath.ToSlash(programFiles))
+		if strings.HasPrefix(exePathLower, pfPath) && strings.Contains(exePathLower, "charmbracelet") {
+			return InstallMethodWinget
+		}
+	}
+
+	// Check for npm global installation.
+	appData := os.Getenv("APPDATA")
+	if appData != "" {
+		npmPath := strings.ToLower(filepath.ToSlash(filepath.Join(appData, "npm")))
+		if strings.HasPrefix(exePathLower, npmPath) {
+			return InstallMethodNPM
+		}
+	}
+
+	// Check for go install.
+	gopath := os.Getenv("GOPATH")
+	if gopath == "" && home != "" {
+		gopath = filepath.Join(home, "go")
+	}
+	if gopath != "" {
+		gopathBin := strings.ToLower(filepath.ToSlash(filepath.Join(gopath, "bin")))
+		if strings.HasPrefix(exePathLower, gopathBin) {
+			return InstallMethodGoInstall
+		}
+	}
+
+	return InstallMethodUnknown
+}