Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/tui/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 58 additions & 23 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
187 changes: 187 additions & 0 deletions internal/tui/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}