diff --git a/internal/cli/root.go b/internal/cli/root.go index 459e7690..e372e2a6 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -17,6 +17,7 @@ import ( "github.com/basecamp/basecamp-cli/internal/commands" "github.com/basecamp/basecamp-cli/internal/completion" "github.com/basecamp/basecamp-cli/internal/config" + "github.com/basecamp/basecamp-cli/internal/harness" "github.com/basecamp/basecamp-cli/internal/hostutil" "github.com/basecamp/basecamp-cli/internal/output" "github.com/basecamp/basecamp-cli/internal/tui" @@ -26,6 +27,7 @@ import ( // NewRootCmd creates the root cobra command. func NewRootCmd() *cobra.Command { var flags appctx.GlobalFlags + var updateCheck *commands.UpdateCheck cmd := &cobra.Command{ Use: "basecamp", @@ -42,6 +44,9 @@ func NewRootCmd() *cobra.Command { return nil } + // Start background update check early so it runs during command execution + updateCheck = commands.StartUpdateCheck() + // Bare root (no subcommand, no args) will show help or // quickstart depending on mode. Tolerate config/profile // errors so bad config doesn't block help, while still @@ -162,8 +167,24 @@ func NewRootCmd() *cobra.Command { if commands.RefreshSkillsIfVersionChanged() { if app == nil || !app.IsMachineOutput() { fmt.Fprintf(os.Stderr, "Agent skill updated to match CLI %s\n", version.Version) + + // One-time hint: if plugin/CLI version mismatch after upgrade, nudge the user + if pv := harness.InstalledPluginVersion(); pv != "" && pv != version.Version && !version.IsDev() { + fmt.Fprintf(os.Stderr, "Basecamp plugin version mismatch (plugin %s, CLI %s) — %s\n", pv, version.Version, harness.AutoUpdateHint) + } + } + } + + // Ambient update notice — only for interactive TTY sessions. + // Skip after upgrade (just acted on it) and doctor (has its own version check). + if updateCheck != nil && cmd.Name() != "upgrade" && cmd.Name() != "doctor" { + if notice := updateCheck.Notice(); notice != "" { + if app != nil && app.IsInteractive() && !app.IsMachineOutput() { + fmt.Fprintln(os.Stderr, notice) + } } } + return nil } diff --git a/internal/commands/doctor.go b/internal/commands/doctor.go index 915f7060..293a79ee 100644 --- a/internal/commands/doctor.go +++ b/internal/commands/doctor.go @@ -218,6 +218,18 @@ func runDoctorChecks(ctx context.Context, app *appctx.App, verbose bool) []Check } } + // 13. Claude plugin version (doctor-only, not part of generic agent checks + // which gate setup wizard behavior) + if harness.DetectClaude() { + pvc := harness.CheckClaudePluginVersion() + checks = append(checks, Check{ + Name: pvc.Name, + Status: pvc.Status, + Message: pvc.Message, + Hint: pvc.Hint, + }) + } + return checks } diff --git a/internal/commands/update_notice.go b/internal/commands/update_notice.go new file mode 100644 index 00000000..faf49c5e --- /dev/null +++ b/internal/commands/update_notice.go @@ -0,0 +1,135 @@ +package commands + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/basecamp/basecamp-cli/internal/config" + "github.com/basecamp/basecamp-cli/internal/version" +) + +// checkInterval is how often we query GitHub for the latest version. +var checkInterval = 24 * time.Hour + +// stdoutIsTerminal reports whether stdout is a terminal. Extracted for testability. +var stdoutIsTerminal = func() bool { + fi, err := os.Stdout.Stat() + if err != nil { + return false + } + return (fi.Mode() & os.ModeCharDevice) != 0 +} + +// UpdateCheck holds state for a non-blocking version check. +type UpdateCheck struct { + latest string + done chan struct{} +} + +// StartUpdateCheck begins a background version check if the cache is stale. +// Returns nil if the check should be skipped (dev build, opted out, etc.). +func StartUpdateCheck() *UpdateCheck { + if version.IsDev() { + return nil + } + if os.Getenv("BASECAMP_NO_UPDATE_CHECK") == "1" { + return nil + } + + // Skip for non-interactive sessions — no point fetching if we won't display + if !stdoutIsTerminal() { + return nil + } + + uc := &UpdateCheck{done: make(chan struct{})} + cached := readUpdateCache() + + if cached != nil { + age := time.Since(cached.CheckedAt) + if age >= 0 && age < checkInterval { + // Cache is fresh — use it directly, no goroutine needed + uc.latest = cached.LatestVersion + close(uc.done) + return uc + } + } + + // Cache is stale or missing — fetch in the background + go func() { + defer close(uc.done) + latest, err := versionChecker() + if err != nil || latest == "" { + return + } + uc.latest = latest + writeUpdateCache(latest) + }() + + return uc +} + +// Notice returns a formatted update notice, or "" if no update is available +// or the check hasn't completed. Never blocks. +func (uc *UpdateCheck) Notice() string { + if uc == nil { + return "" + } + + // Non-blocking check: if the goroutine hasn't finished, skip + select { + case <-uc.done: + default: + return "" + } + + if uc.latest == "" || uc.latest == version.Version { + return "" + } + + return fmt.Sprintf( + "Update available: %s → %s — Run \"basecamp upgrade\" to update", + version.Version, uc.latest, + ) +} + +// updateCache is the on-disk format for the version check result. +type updateCache struct { + LatestVersion string `json:"latest_version"` + CheckedAt time.Time `json:"checked_at"` +} + +func updateCachePath() string { + return filepath.Join(config.GlobalConfigDir(), ".update-check") +} + +func readUpdateCache() *updateCache { + data, err := os.ReadFile(updateCachePath()) + if err != nil { + return nil + } + var c updateCache + if err := json.Unmarshal(data, &c); err != nil { + return nil + } + if c.LatestVersion == "" || c.CheckedAt.IsZero() { + return nil + } + return &c +} + +func writeUpdateCache(latestVersion string) { + c := updateCache{ + LatestVersion: latestVersion, + CheckedAt: time.Now().UTC(), + } + data, err := json.Marshal(c) + if err != nil { + return + } + dir := filepath.Dir(updateCachePath()) + _ = os.MkdirAll(dir, 0o755) //nolint:gosec // G301: config dir + _ = os.WriteFile(updateCachePath(), data, 0o644) //nolint:gosec // G306: not a secret +} diff --git a/internal/commands/update_notice_test.go b/internal/commands/update_notice_test.go new file mode 100644 index 00000000..b5421745 --- /dev/null +++ b/internal/commands/update_notice_test.go @@ -0,0 +1,216 @@ +package commands + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/version" +) + +func TestStartUpdateCheck_SkipsDevBuild(t *testing.T) { + origVersion := version.Version + version.Version = "dev" + defer func() { version.Version = origVersion }() + + uc := StartUpdateCheck() + assert.Nil(t, uc) +} + +func TestStartUpdateCheck_SkipsWhenEnvSet(t *testing.T) { + origVersion := version.Version + version.Version = "1.0.0" + defer func() { version.Version = origVersion }() + + t.Setenv("BASECAMP_NO_UPDATE_CHECK", "1") + + uc := StartUpdateCheck() + assert.Nil(t, uc) +} + +func TestStartUpdateCheck_SkipsNonInteractive(t *testing.T) { + origVersion := version.Version + version.Version = "1.0.0" + defer func() { version.Version = origVersion }() + + origTTY := stdoutIsTerminal + stdoutIsTerminal = func() bool { return false } + defer func() { stdoutIsTerminal = origTTY }() + + uc := StartUpdateCheck() + assert.Nil(t, uc) +} + +func TestStartUpdateCheck_UsesFreshCache(t *testing.T) { + configDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configDir) + + origVersion := version.Version + version.Version = "1.0.0" + defer func() { version.Version = origVersion }() + + origTTY := stdoutIsTerminal + stdoutIsTerminal = func() bool { return true } + defer func() { stdoutIsTerminal = origTTY }() + + // Write a fresh cache entry + cache := updateCache{ + LatestVersion: "2.0.0", + CheckedAt: time.Now().UTC(), + } + cacheDir := filepath.Join(configDir, "basecamp") + require.NoError(t, os.MkdirAll(cacheDir, 0o755)) + data, _ := json.Marshal(cache) + require.NoError(t, os.WriteFile(filepath.Join(cacheDir, ".update-check"), data, 0o644)) + + uc := StartUpdateCheck() + require.NotNil(t, uc) + + notice := uc.Notice() + assert.Contains(t, notice, "2.0.0") + assert.Contains(t, notice, "1.0.0") + assert.Contains(t, notice, "basecamp upgrade") +} + +func TestStartUpdateCheck_CacheHitSameVersion(t *testing.T) { + configDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configDir) + + origVersion := version.Version + version.Version = "1.0.0" + defer func() { version.Version = origVersion }() + + origTTY := stdoutIsTerminal + stdoutIsTerminal = func() bool { return true } + defer func() { stdoutIsTerminal = origTTY }() + + cache := updateCache{ + LatestVersion: "1.0.0", + CheckedAt: time.Now().UTC(), + } + cacheDir := filepath.Join(configDir, "basecamp") + require.NoError(t, os.MkdirAll(cacheDir, 0o755)) + data, _ := json.Marshal(cache) + require.NoError(t, os.WriteFile(filepath.Join(cacheDir, ".update-check"), data, 0o644)) + + uc := StartUpdateCheck() + require.NotNil(t, uc) + + assert.Equal(t, "", uc.Notice()) +} + +func TestStartUpdateCheck_StaleCacheFetchesInBackground(t *testing.T) { + configDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configDir) + + origVersion := version.Version + version.Version = "1.0.0" + defer func() { version.Version = origVersion }() + + origTTY := stdoutIsTerminal + stdoutIsTerminal = func() bool { return true } + defer func() { stdoutIsTerminal = origTTY }() + + origChecker := versionChecker + versionChecker = func() (string, error) { return "3.0.0", nil } + defer func() { versionChecker = origChecker }() + + // Write a stale cache entry (25 hours old) + cache := updateCache{ + LatestVersion: "2.0.0", + CheckedAt: time.Now().UTC().Add(-25 * time.Hour), + } + cacheDir := filepath.Join(configDir, "basecamp") + require.NoError(t, os.MkdirAll(cacheDir, 0o755)) + data, _ := json.Marshal(cache) + require.NoError(t, os.WriteFile(filepath.Join(cacheDir, ".update-check"), data, 0o644)) + + uc := StartUpdateCheck() + require.NotNil(t, uc) + + // Wait for goroutine to finish + <-uc.done + + notice := uc.Notice() + assert.Contains(t, notice, "3.0.0") + + // Cache should be updated + updated := readUpdateCache() + require.NotNil(t, updated) + assert.Equal(t, "3.0.0", updated.LatestVersion) +} + +func TestStartUpdateCheck_NoCacheFetchesInBackground(t *testing.T) { + configDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configDir) + + origVersion := version.Version + version.Version = "1.0.0" + defer func() { version.Version = origVersion }() + + origTTY := stdoutIsTerminal + stdoutIsTerminal = func() bool { return true } + defer func() { stdoutIsTerminal = origTTY }() + + origChecker := versionChecker + versionChecker = func() (string, error) { return "1.5.0", nil } + defer func() { versionChecker = origChecker }() + + uc := StartUpdateCheck() + require.NotNil(t, uc) + + // Wait for goroutine to finish + <-uc.done + + assert.Contains(t, uc.Notice(), "1.5.0") +} + +func TestNotice_NilReceiver(t *testing.T) { + var uc *UpdateCheck + assert.Equal(t, "", uc.Notice()) +} + +func TestNotice_NonBlocking(t *testing.T) { + // Create an UpdateCheck with a channel that never closes + uc := &UpdateCheck{ + done: make(chan struct{}), + } + + // Should return empty immediately without blocking + assert.Equal(t, "", uc.Notice()) +} + +func TestUpdateCacheRoundTrip(t *testing.T) { + configDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configDir) + + writeUpdateCache("4.0.0") + + cached := readUpdateCache() + require.NotNil(t, cached) + assert.Equal(t, "4.0.0", cached.LatestVersion) + assert.WithinDuration(t, time.Now().UTC(), cached.CheckedAt, 5*time.Second) +} + +func TestReadUpdateCache_Invalid(t *testing.T) { + configDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configDir) + + cacheDir := filepath.Join(configDir, "basecamp") + require.NoError(t, os.MkdirAll(cacheDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(cacheDir, ".update-check"), []byte("not json"), 0o644)) + + assert.Nil(t, readUpdateCache()) +} + +func TestReadUpdateCache_Missing(t *testing.T) { + configDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configDir) + + assert.Nil(t, readUpdateCache()) +} diff --git a/internal/commands/wizard_agents.go b/internal/commands/wizard_agents.go index bed7985b..afa91514 100644 --- a/internal/commands/wizard_agents.go +++ b/internal/commands/wizard_agents.go @@ -131,6 +131,13 @@ func runClaudeSetup(cmd *cobra.Command, styles *tui.Styles) error { fmt.Fprintln(w, styles.Warning.Render(fmt.Sprintf(" Claude skill symlink failed: %s", err))) } + // Nudge: recommend enabling auto-update (only when plugin is actually installed) + if harness.CheckClaudePlugin().Status == "pass" { + fmt.Fprintln(w) + fmt.Fprintln(w, styles.Muted.Render(" Tip: Enable auto-update to stay current with new CLI releases:")) + fmt.Fprintln(w, styles.Muted.Render(" "+harness.AutoUpdateHint)) + } + return nil } diff --git a/internal/harness/claude.go b/internal/harness/claude.go index b7105d1f..c14b734d 100644 --- a/internal/harness/claude.go +++ b/internal/harness/claude.go @@ -2,9 +2,12 @@ package harness import ( "encoding/json" + "fmt" "os" "os/exec" "path/filepath" + + "github.com/basecamp/basecamp-cli/internal/version" ) func init() { @@ -163,6 +166,114 @@ func CheckClaudeSkillLink() *StatusCheck { } } +// AutoUpdateHint is the user-facing instruction for enabling plugin auto-update. +const AutoUpdateHint = "In Claude Code: /plugins → Marketplaces → 37signals → Enable auto-update" + +// CheckClaudePluginVersion compares the installed plugin version against the +// running CLI version. Returns a warn check when they differ. +func CheckClaudePluginVersion() *StatusCheck { + installed := InstalledPluginVersion() + if installed == "" { + // Can't determine version — don't nag. + return &StatusCheck{ + Name: "Claude Code Plugin Version", + Status: "pass", + Message: "Version not tracked", + } + } + + cliVersion := version.Version + if cliVersion == "dev" { + return &StatusCheck{ + Name: "Claude Code Plugin Version", + Status: "pass", + Message: fmt.Sprintf("Installed (%s, dev build)", installed), + } + } + + if installed == cliVersion { + return &StatusCheck{ + Name: "Claude Code Plugin Version", + Status: "pass", + Message: fmt.Sprintf("Up to date (%s)", installed), + } + } + + return &StatusCheck{ + Name: "Claude Code Plugin Version", + Status: "warn", + Message: fmt.Sprintf("Mismatched (plugin %s, CLI %s)", installed, cliVersion), + Hint: AutoUpdateHint, + } +} + +// InstalledPluginVersion reads the installed plugin version from +// ~/.claude/plugins/installed_plugins.json. Returns "" if unreadable. +func InstalledPluginVersion() string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "" + } + data, err := os.ReadFile(filepath.Join(filepath.Clean(home), ".claude", "plugins", "installed_plugins.json")) //nolint:gosec // G304: trusted path + if err != nil { + return "" + } + return installedPluginVersion(data) +} + +// installedPluginVersion extracts the version of the basecamp plugin from +// the installed_plugins.json data. Handles v2, v1 flat map, and array formats. +func installedPluginVersion(data []byte) string { + // v2 format: {"version": 2, "plugins": {"basecamp@37signals": [{"version": "1.0.0", ...}]}} + var pluginMap map[string]any + if err := json.Unmarshal(data, &pluginMap); err == nil { + if inner, ok := pluginMap["plugins"]; ok { + if innerMap, ok := inner.(map[string]any); ok { + for key, val := range innerMap { + if !matchesPluginKey(key) { + continue + } + if arr, ok := val.([]any); ok { + for _, entry := range arr { + if obj, ok := entry.(map[string]any); ok { + if v, ok := obj["version"].(string); ok { + return v + } + } + } + } + } + } + } + + // v1 flat map: {"basecamp@37signals": {"version": "1.0.0"}} + for key, val := range pluginMap { + if !matchesPluginKey(key) { + continue + } + if obj, ok := val.(map[string]any); ok { + if v, ok := obj["version"].(string); ok { + return v + } + } + } + } + + // Array format: [{"name": "basecamp@37signals", "version": "1.0.0", ...}] + var plugins []map[string]any + if err := json.Unmarshal(data, &plugins); err == nil { + for _, p := range plugins { + if matchesBasecamp(p) { + if v, ok := p["version"].(string); ok { + return v + } + } + } + } + + return "" +} + // pluginInstalled checks if "basecamp" appears as an installed plugin. // Handles multiple possible JSON schemas without panicking. func pluginInstalled(data []byte) bool { diff --git a/internal/harness/claude_test.go b/internal/harness/claude_test.go index 1d7371cf..156c9613 100644 --- a/internal/harness/claude_test.go +++ b/internal/harness/claude_test.go @@ -7,6 +7,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/version" ) func TestPluginInstalled_ArrayFormat(t *testing.T) { @@ -223,3 +225,113 @@ func TestIsStalePluginKey(t *testing.T) { assert.False(t, isStalePluginKey("basecamp")) assert.False(t, isStalePluginKey("other@basecamp")) } + +func TestInstalledPluginVersion_V2(t *testing.T) { + data := []byte(`{"version":2,"plugins":{"basecamp@37signals":[{"scope":"user","version":"1.2.3"}]}}`) + assert.Equal(t, "1.2.3", installedPluginVersion(data)) +} + +func TestInstalledPluginVersion_V2_BareKey(t *testing.T) { + data := []byte(`{"version":2,"plugins":{"basecamp":[{"scope":"user","version":"0.5.0"}]}}`) + assert.Equal(t, "0.5.0", installedPluginVersion(data)) +} + +func TestInstalledPluginVersion_V2_NotFound(t *testing.T) { + data := []byte(`{"version":2,"plugins":{"other@marketplace":[{"scope":"user","version":"1.0.0"}]}}`) + assert.Equal(t, "", installedPluginVersion(data)) +} + +func TestInstalledPluginVersion_Array(t *testing.T) { + data := []byte(`[{"name":"basecamp@37signals","version":"2.0.0"}]`) + assert.Equal(t, "2.0.0", installedPluginVersion(data)) +} + +func TestInstalledPluginVersion_Array_NotFound(t *testing.T) { + data := []byte(`[{"name":"other-plugin","version":"1.0.0"}]`) + assert.Equal(t, "", installedPluginVersion(data)) +} + +func TestInstalledPluginVersion_V1FlatMap(t *testing.T) { + data := []byte(`{"basecamp@37signals":{"version":"1.0.0"}}`) + assert.Equal(t, "1.0.0", installedPluginVersion(data)) +} + +func TestInstalledPluginVersion_Empty(t *testing.T) { + assert.Equal(t, "", installedPluginVersion([]byte(`{}`))) + assert.Equal(t, "", installedPluginVersion([]byte(`[]`))) + assert.Equal(t, "", installedPluginVersion([]byte(`invalid`))) +} + +func TestCheckClaudePluginVersion_UpToDate(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + origVersion := version.Version + version.Version = "1.5.0" + defer func() { version.Version = origVersion }() + + pluginsDir := filepath.Join(home, ".claude", "plugins") + require.NoError(t, os.MkdirAll(pluginsDir, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(pluginsDir, "installed_plugins.json"), + []byte(`{"version":2,"plugins":{"basecamp@37signals":[{"scope":"user","version":"1.5.0"}]}}`), + 0o644, + )) + + check := CheckClaudePluginVersion() + assert.Equal(t, "pass", check.Status) + assert.Contains(t, check.Message, "1.5.0") +} + +func TestCheckClaudePluginVersion_Outdated(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + origVersion := version.Version + version.Version = "2.0.0" + defer func() { version.Version = origVersion }() + + pluginsDir := filepath.Join(home, ".claude", "plugins") + require.NoError(t, os.MkdirAll(pluginsDir, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(pluginsDir, "installed_plugins.json"), + []byte(`{"version":2,"plugins":{"basecamp@37signals":[{"scope":"user","version":"0.1.0"}]}}`), + 0o644, + )) + + check := CheckClaudePluginVersion() + assert.Equal(t, "warn", check.Status) + assert.Contains(t, check.Message, "0.1.0") + assert.Contains(t, check.Message, "2.0.0") + assert.Contains(t, check.Hint, "auto-update") +} + +func TestCheckClaudePluginVersion_DevBuild(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + origVersion := version.Version + version.Version = "dev" + defer func() { version.Version = origVersion }() + + pluginsDir := filepath.Join(home, ".claude", "plugins") + require.NoError(t, os.MkdirAll(pluginsDir, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(pluginsDir, "installed_plugins.json"), + []byte(`{"version":2,"plugins":{"basecamp@37signals":[{"scope":"user","version":"0.1.0"}]}}`), + 0o644, + )) + + check := CheckClaudePluginVersion() + assert.Equal(t, "pass", check.Status) + assert.Contains(t, check.Message, "dev build") +} + +func TestCheckClaudePluginVersion_NoFile(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + check := CheckClaudePluginVersion() + assert.Equal(t, "pass", check.Status) + assert.Contains(t, check.Message, "not tracked") +}