From 706b47901b3726365eb4742f8fe7e4d0681c04d7 Mon Sep 17 00:00:00 2001 From: Bojan Rajkovic Date: Fri, 27 Feb 2026 13:24:40 -0500 Subject: [PATCH] fix(tui): show all plugins with correct per-project install state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI returns all installed plugins globally but hides plugins from the available array if they're installed anywhere. This caused two bugs: 1. Plugins installed in other projects were hidden entirely (the old isRelevant filter excluded them) 2. Available plugins that happened to be installed elsewhere incorrectly showed as "installed, disabled" (buildInstalledByID merged data from all projects indiscriminately) Replace the filter-based approach with per-plugin relevance checks: - Installed in current project → show with correct scope and state - Installed at user scope → show with user scope and state - Installed elsewhere or only available → show without install state Co-Authored-By: Claude Opus 4.6 --- internal/tui/CLAUDE.md | 2 +- internal/tui/model.go | 81 +++++++++++----- internal/tui/model_test.go | 187 +++++++++++++++++++++++++++++++++++++ 3 files changed, 246 insertions(+), 24 deletions(-) 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) + } +}