diff --git a/internal/tui/CLAUDE.md b/internal/tui/CLAUDE.md index 014298d..631118e 100644 --- a/internal/tui/CLAUDE.md +++ b/internal/tui/CLAUDE.md @@ -9,7 +9,7 @@ Implements the two-pane plugin manager interface using Bubble Tea's Elm Architec ## Contracts - **Exposes**: `NewModel(client, workingDir) -> *Model`, implements `tea.Model` interface -- **Guarantees**: Pending operations (install/uninstall/enable/disable/scope-change) tracked until explicit Apply. Filter preserves selection when possible. Only project/local plugins for current workingDir are shown (plus user-scoped plugins). +- **Guarantees**: Pending operations (install/uninstall/enable/disable/scope-change) tracked until explicit Apply. Filter preserves selection when possible. All plugins (available + installed) are shown; installed state (scopes, enabled/disabled) reflects only the current workingDir. Plugins installed elsewhere appear without install state. - **Expects**: Valid `claude.Client` implementation. Terminal with reasonable size (handles resize). ## Dependencies diff --git a/internal/tui/model.go b/internal/tui/model.go index 699beb7..15b63c0 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -398,7 +398,7 @@ func (m *Model) loadPlugins() tea.Msg { } // mergePlugins combines installed and available plugins, grouped by marketplace. -// Only installed plugins relevant to workingDir are included. +// All plugins are shown; installed state reflects the current workingDir only. func mergePlugins(list *claude.PluginList, workingDir string) []PluginState { allScopes := claude.GetAllEnabledPlugins(workingDir) installedByID := buildInstalledByID(list.Installed) @@ -407,13 +407,14 @@ func mergePlugins(list *claude.PluginList, workingDir string) []PluginState { // Process available plugins for _, p := range list.Available { - state := processAvailablePlugin(p, installedByID, allScopes, seenInstalled) + state := processAvailablePlugin(p, installedByID, allScopes, seenInstalled, workingDir) byMarketplace[state.Marketplace] = append(byMarketplace[state.Marketplace], state) } - // Process installed plugins not in available list + // Process installed plugins not in available list (includes plugins the CLI + // hides from the available array because they're installed somewhere). for _, p := range list.Installed { - state, ok := processInstalledPlugin(p, allScopes, seenInstalled) + state, ok := processInstalledPlugin(p, allScopes, seenInstalled, workingDir) if ok { byMarketplace[state.Marketplace] = append(byMarketplace[state.Marketplace], state) } @@ -422,6 +423,20 @@ func mergePlugins(list *claude.PluginList, workingDir string) []PluginState { return sortAndGroupByMarketplace(byMarketplace) } +// isInstalledInProject reports whether an installed plugin belongs to the +// current project context: user-scoped (global), matching projectPath, or +// present in the project's settings files. +func isInstalledInProject(p claude.InstalledPlugin, workingDir string, allScopes claude.ScopeState) bool { + if p.Scope == claude.ScopeUser { + return true + } + if p.ProjectPath == workingDir { + return true + } + _, inSettings := allScopes[p.ID] + return inSettings +} + // buildInstalledByID creates a map of installed plugins by ID. func buildInstalledByID(installed []claude.InstalledPlugin) map[string]claude.InstalledPlugin { result := make(map[string]claude.InstalledPlugin) @@ -431,14 +446,17 @@ func buildInstalledByID(installed []claude.InstalledPlugin) map[string]claude.In return result } -// processAvailablePlugin processes an available plugin and merges with installed data. -func processAvailablePlugin(p claude.AvailablePlugin, installedByID map[string]claude.InstalledPlugin, allScopes claude.ScopeState, seenInstalled map[string]bool) PluginState { +// processAvailablePlugin processes an available plugin and merges with installed data +// only if the plugin is installed in the current project. +func processAvailablePlugin(p claude.AvailablePlugin, installedByID map[string]claude.InstalledPlugin, allScopes claude.ScopeState, seenInstalled map[string]bool, workingDir string) PluginState { state := PluginStateFromAvailable(p) state.AvailableVersion = p.Version if installed, ok := installedByID[p.PluginID]; ok { - mergeInstalledInfo(&state, installed, p.Version) seenInstalled[p.PluginID] = true + if isInstalledInProject(installed, workingDir, allScopes) { + mergeInstalledInfo(&state, installed, p.Version) + } } // Apply scope data from settings files @@ -470,28 +488,45 @@ func mergeInstalledInfo(state *PluginState, installed claude.InstalledPlugin, av } } -// processInstalledPlugin processes an installed plugin not in the available list. -// Returns the state and true if it should be included, false otherwise. -func processInstalledPlugin(p claude.InstalledPlugin, allScopes claude.ScopeState, seenInstalled map[string]bool) (PluginState, bool) { - // Include if plugin appears in any settings file OR is user-scoped in CLI output - _, inSettings := allScopes[p.ID] - isRelevant := p.Scope == claude.ScopeUser || inSettings - - if !isRelevant || seenInstalled[p.ID] { +// processInstalledPlugin processes an installed plugin not already handled by +// processAvailablePlugin. Plugins installed in the current project are shown +// with full state; plugins only installed elsewhere appear without install state +// (the CLI hides these from the available array, so this is the only way they +// show up). +func processInstalledPlugin(p claude.InstalledPlugin, allScopes claude.ScopeState, seenInstalled map[string]bool, workingDir string) (PluginState, bool) { + // Skip duplicates (CLI can return the same plugin multiple times) + if seenInstalled[p.ID] { return PluginState{}, false } - seenInstalled[p.ID] = true - state := PluginStateFromInstalled(p) - // Apply scope data from settings files - if scopes, ok := allScopes[p.ID]; ok { - state.InstalledScopes = scopes - } else if p.Scope == claude.ScopeUser { - // User-scoped plugin not in settings — CLI is authority - state.InstalledScopes = map[claude.Scope]bool{claude.ScopeUser: true} + if isInstalledInProject(p, workingDir, allScopes) { + state := PluginStateFromInstalled(p) + if scopes, ok := allScopes[p.ID]; ok { + state.InstalledScopes = scopes + } else if p.Scope == claude.ScopeUser { + state.InstalledScopes = map[claude.Scope]bool{claude.ScopeUser: true} + } + return state, true } + // Installed elsewhere — show without install state so the user can install + // it in the current project. Read cached manifest for description/metadata. + name, marketplace := parsePluginID(p.ID) + state := PluginState{ + ID: p.ID, + Name: name, + Marketplace: marketplace, + InstalledScopes: map[claude.Scope]bool{}, + } + if p.InstallPath != "" { + if manifest, err := claude.ReadPluginManifest(p.InstallPath); err == nil { + state.Description = manifest.Description + state.AuthorName = manifest.AuthorName + state.AuthorEmail = manifest.AuthorEmail + } + state.Components = claude.ScanPluginComponents(p.InstallPath) + } return state, true } diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index d778444..e77633b 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -2548,3 +2548,190 @@ func TestRenderPendingIndicator_MultiScopeInstall(t *testing.T) { t.Errorf("renderPendingIndicator(multi-scope install) should contain 'LOCAL': %q", result) } } + +func TestIsInstalledInProject(t *testing.T) { + allScopes := claude.ScopeState{ + "in-settings@mp": {claude.ScopeProject: true}, + } + workingDir := "/my/project" + + tests := []struct { + name string + p claude.InstalledPlugin + want bool + }{ + { + name: "user scope is always relevant", + p: claude.InstalledPlugin{ID: "foo@mp", Scope: claude.ScopeUser}, + want: true, + }, + { + name: "project scope matching workingDir", + p: claude.InstalledPlugin{ID: "bar@mp", Scope: claude.ScopeProject, ProjectPath: "/my/project"}, + want: true, + }, + { + name: "project scope different workingDir", + p: claude.InstalledPlugin{ID: "baz@mp", Scope: claude.ScopeProject, ProjectPath: "/other/project"}, + want: false, + }, + { + name: "plugin in settings files", + p: claude.InstalledPlugin{ID: "in-settings@mp", Scope: claude.ScopeProject, ProjectPath: "/other/project"}, + want: true, + }, + { + name: "local scope different workingDir", + p: claude.InstalledPlugin{ID: "qux@mp", Scope: claude.ScopeLocal, ProjectPath: "/other/project"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isInstalledInProject(tt.p, workingDir, allScopes) + if got != tt.want { + t.Errorf("isInstalledInProject() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestMergePluginsCurrentProjectInstalled(t *testing.T) { + // Plugin installed at project scope in the current project should show as installed + list := &claude.PluginList{ + Installed: []claude.InstalledPlugin{ + {ID: "local-plugin@mp", Version: "1.0.0", Scope: claude.ScopeProject, ProjectPath: "/my/project", Enabled: true}, + }, + } + + plugins := mergePlugins(list, "/my/project") + + // Find the plugin (skip group headers) + var found *PluginState + for i := range plugins { + if plugins[i].ID == "local-plugin@mp" { + found = &plugins[i] + break + } + } + if found == nil { + t.Fatal("plugin installed in current project should appear in list") + } + if !found.IsInstalled() { + t.Error("plugin should show as installed in current project") + } + if !found.Enabled { + t.Error("plugin should show as enabled") + } +} + +func TestMergePluginsOtherProjectNotInstalled(t *testing.T) { + // Plugin installed at project scope in ANOTHER project should show without install state + list := &claude.PluginList{ + Installed: []claude.InstalledPlugin{ + {ID: "remote-plugin@mp", Version: "2.0.0", Scope: claude.ScopeProject, ProjectPath: "/other/project", Enabled: false}, + }, + } + + plugins := mergePlugins(list, "/my/project") + + var found *PluginState + for i := range plugins { + if plugins[i].ID == "remote-plugin@mp" { + found = &plugins[i] + break + } + } + if found == nil { + t.Fatal("plugin installed elsewhere should still appear in list") + } + if found.IsInstalled() { + t.Error("plugin from another project should NOT show as installed") + } + if found.Version != "" { + t.Errorf("plugin from another project should have no version, got %q", found.Version) + } +} + +func TestMergePluginsUserScopeAlwaysInstalled(t *testing.T) { + // User-scoped plugins should always show as installed + list := &claude.PluginList{ + Installed: []claude.InstalledPlugin{ + {ID: "global-plugin@mp", Version: "3.0.0", Scope: claude.ScopeUser, Enabled: true}, + }, + } + + plugins := mergePlugins(list, "/any/project") + + var found *PluginState + for i := range plugins { + if plugins[i].ID == "global-plugin@mp" { + found = &plugins[i] + break + } + } + if found == nil { + t.Fatal("user-scoped plugin should appear in list") + } + if !found.IsInstalled() { + t.Error("user-scoped plugin should show as installed") + } + if !found.Enabled { + t.Error("user-scoped plugin should show as enabled") + } +} + +func TestMergePluginsAvailableNotMergedFromOtherProject(t *testing.T) { + // An available plugin that's installed in another project should NOT show + // as installed in the current project. + list := &claude.PluginList{ + Available: []claude.AvailablePlugin{ + {PluginID: "shared@mp", Name: "shared", Description: "a shared plugin", MarketplaceName: "mp"}, + }, + Installed: []claude.InstalledPlugin{ + {ID: "shared@mp", Version: "1.0.0", Scope: claude.ScopeProject, ProjectPath: "/other/project", Enabled: false}, + }, + } + + plugins := mergePlugins(list, "/my/project") + + var found *PluginState + for i := range plugins { + if plugins[i].ID == "shared@mp" { + found = &plugins[i] + break + } + } + if found == nil { + t.Fatal("available plugin should appear in list") + } + if found.IsInstalled() { + t.Error("available plugin installed elsewhere should NOT show as installed here") + } + if found.Description != "a shared plugin" { + t.Errorf("available plugin should keep its description, got %q", found.Description) + } +} + +func TestMergePluginsDeduplicatesInstalledPlugins(t *testing.T) { + // CLI can return the same plugin ID multiple times; should only appear once + list := &claude.PluginList{ + Installed: []claude.InstalledPlugin{ + {ID: "dup@mp", Version: "1.0.0", Scope: claude.ScopeProject, ProjectPath: "/other/a"}, + {ID: "dup@mp", Version: "1.0.0", Scope: claude.ScopeProject, ProjectPath: "/other/b"}, + }, + } + + plugins := mergePlugins(list, "/my/project") + + count := 0 + for _, p := range plugins { + if p.ID == "dup@mp" { + count++ + } + } + if count != 1 { + t.Errorf("duplicate plugin should appear once, got %d", count) + } +}