diff --git a/internal/cli/root.go b/internal/cli/root.go index 459e7690..04803191 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" @@ -162,6 +163,11 @@ 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) + } } } 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/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") +}