Skip to content

Commit 455da1a

Browse files
committed
feat: detect incomplete plugins and improve install error handling
- Add IsIncomplete flag to detect plugins missing .claude-plugin/plugin.json - Check local marketplace directory for plugin.json during plugin loading - Show [incomplete] tag in search results with legend - Provide actionable error messages suggesting marketplace refresh or direct use - Skip download when valid cache exists (use cached plugin files) - Check if plugin already installed in target scope before installing
1 parent 89a8557 commit 455da1a

6 files changed

Lines changed: 110 additions & 12 deletions

File tree

cmd/plum/install.go

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -143,11 +143,30 @@ func installPlugin(pluginArg string, scope settings.Scope, projectPath string) e
143143
// Check if plugin is installable via plum
144144
if !pluginInfo.Installable {
145145
fmt.Printf("Cannot install %s: %s\n\n", fullName, pluginInfo.InstallabilityReason)
146-
fmt.Println("This plugin requires a different installation method.")
147-
fmt.Println("Check the plugin's homepage for installation instructions.")
146+
if pluginInfo.IsIncomplete {
147+
fmt.Println("This plugin doesn't have a standard plugin manifest. You can try:")
148+
fmt.Println()
149+
fmt.Println(" 1. Refresh your marketplace in case it was recently updated:")
150+
fmt.Println(" plum marketplace refresh")
151+
fmt.Println()
152+
fmt.Println(" 2. Use the plugin directly from the marketplace directory")
153+
fmt.Println(" (Claude Code can access skills/commands without installation)")
154+
} else {
155+
fmt.Println("This plugin requires a different installation method.")
156+
fmt.Println("Check the plugin's homepage for installation instructions.")
157+
}
148158
return fmt.Errorf("plugin not installable via plum")
149159
}
150160

161+
// Check if already installed in the requested scope
162+
scopeSettings, err := settings.LoadSettings(scope, projectPath)
163+
if err == nil {
164+
if _, exists := scopeSettings.EnabledPlugins[fullName]; exists {
165+
fmt.Printf("%s is already installed in %s scope\n", fullName, scope)
166+
return nil
167+
}
168+
}
169+
151170
fmt.Printf("Installing %s...\n", fullName)
152171

153172
// Get cache directory
@@ -156,9 +175,17 @@ func installPlugin(pluginArg string, scope settings.Scope, projectPath string) e
156175
return fmt.Errorf("failed to get cache directory: %w", err)
157176
}
158177

159-
// Download plugin files to cache
160-
if err := downloadPluginToCache(pluginInfo, cacheDir); err != nil {
161-
return fmt.Errorf("failed to download plugin: %w", err)
178+
// Check if cache already exists with valid plugin.json
179+
// This allows installation to succeed even if remote download fails
180+
cacheValid := isValidPluginCache(cacheDir)
181+
182+
// Try to download plugin files to cache (skip if cache is valid)
183+
if !cacheValid {
184+
if err := downloadPluginToCache(pluginInfo, cacheDir); err != nil {
185+
return fmt.Errorf("failed to download plugin: %w", err)
186+
}
187+
} else {
188+
fmt.Println("Using cached plugin files")
162189
}
163190

164191
// Register in installed_plugins_v2.json
@@ -184,6 +211,7 @@ type pluginSearchResult struct {
184211
Source string // Path within marketplace
185212
Installable bool // Whether plum can install this plugin
186213
InstallabilityReason string // Human-readable reason if not installable
214+
IsIncomplete bool // True if plugin is missing required files
187215
}
188216

189217
// findPluginInMarketplaces searches for a plugin across all known marketplaces
@@ -209,6 +237,7 @@ func findPluginInMarketplaces(pluginName, marketplaceFilter string) (*pluginSear
209237
Source: p.Source,
210238
Installable: p.Installable(),
211239
InstallabilityReason: p.InstallabilityReason(),
240+
IsIncomplete: p.IsIncomplete,
212241
})
213242
}
214243
}
@@ -229,6 +258,17 @@ func findPluginInMarketplaces(pluginName, marketplaceFilter string) (*pluginSear
229258
return matches[0], nil
230259
}
231260

261+
// isValidPluginCache checks if a cache directory contains a valid plugin.json
262+
func isValidPluginCache(cacheDir string) bool {
263+
pluginJSONPath := filepath.Join(cacheDir, ".claude-plugin", "plugin.json")
264+
info, err := os.Stat(pluginJSONPath)
265+
if err != nil {
266+
return false
267+
}
268+
// Ensure it's a file with non-zero size
269+
return !info.IsDir() && info.Size() > 0
270+
}
271+
232272
// pluginCacheDir returns the path to cache a plugin
233273
// Path: ~/.claude/plugins/cache/<marketplace>/<plugin>/
234274
func pluginCacheDir(marketplaceName, pluginName string) (string, error) {

cmd/plum/search.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ func outputSearchTable(results []SearchResult, query string) error {
124124
hasInstalled := false
125125
hasBuiltIn := false
126126
hasExternal := false
127+
hasIncomplete := false
127128

128129
// Rows
129130
for _, r := range results {
@@ -146,6 +147,9 @@ func outputSearchTable(results []SearchResult, query string) error {
146147
case "[external]":
147148
name += " " + r.InstallabilityTag
148149
hasExternal = true
150+
case "[incomplete]":
151+
name += " " + r.InstallabilityTag
152+
hasIncomplete = true
149153
}
150154

151155
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\n", name, r.Marketplace, desc)
@@ -162,6 +166,9 @@ func outputSearchTable(results []SearchResult, query string) error {
162166
if hasExternal {
163167
_, _ = fmt.Fprintln(w, "[external] = external repo (install manually)")
164168
}
169+
if hasIncomplete {
170+
_, _ = fmt.Fprintln(w, "[incomplete] = missing plugin.json (not installable)")
171+
}
165172

166173
return w.Flush()
167174
}

internal/config/config.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ func LoadAllPlugins() ([]plugin.Plugin, error) {
175175
}
176176
seenPluginNames[mp.Name] = marketplaceName
177177

178-
p := convertMarketplacePlugin(mp, marketplaceName, marketplaceRepo, marketplaceSource, false, installedSet)
178+
p := convertMarketplacePlugin(mp, marketplaceName, marketplaceRepo, marketplaceSource, false, installedSet, entry.InstallLocation)
179179
plugins = append(plugins, p)
180180
}
181181
}
@@ -204,7 +204,8 @@ func LoadAllPlugins() ([]plugin.Plugin, error) {
204204
}
205205
seenPluginNames[mp.Name] = marketplaceName
206206

207-
p := convertMarketplacePlugin(mp, marketplaceName, disc.Repo, disc.Source, true, installedSet)
207+
// Discovered marketplaces don't have local paths - pass empty string
208+
p := convertMarketplacePlugin(mp, marketplaceName, disc.Repo, disc.Source, true, installedSet, "")
208209
plugins = append(plugins, p)
209210
}
210211
}
@@ -213,17 +214,36 @@ func LoadAllPlugins() ([]plugin.Plugin, error) {
213214
}
214215

215216
// convertMarketplacePlugin converts a MarketplacePlugin to a Plugin.
217+
// marketplacePath is the local path to the marketplace directory (empty for discovered marketplaces).
216218
func convertMarketplacePlugin(
217219
mp marketplace.MarketplacePlugin,
218220
marketplaceName string,
219221
marketplaceRepo string,
220222
marketplaceSource string,
221223
isDiscoverable bool,
222224
installedSet map[string]PluginInstall,
225+
marketplacePath string,
223226
) plugin.Plugin {
224227
fullName := mp.Name + "@" + marketplaceName
225228
install, isInstalled := installedSet[fullName]
226229

230+
// Check if plugin is incomplete (missing .claude-plugin/plugin.json)
231+
// Only check for locally installed marketplaces, not discovered ones
232+
isIncomplete := mp.IsIncomplete
233+
if !isIncomplete && marketplacePath != "" && !mp.HasLSPServers && !mp.IsExternalURL {
234+
// Construct path to plugin.json based on source
235+
sourcePath := mp.Source
236+
if sourcePath == "" {
237+
sourcePath = "plugins/" + mp.Name
238+
} else if len(sourcePath) > 2 && sourcePath[:2] == "./" {
239+
sourcePath = sourcePath[2:]
240+
}
241+
pluginJSONPath := filepath.Join(marketplacePath, sourcePath, ".claude-plugin", "plugin.json")
242+
if _, err := os.Stat(pluginJSONPath); os.IsNotExist(err) {
243+
isIncomplete = true
244+
}
245+
}
246+
227247
p := plugin.Plugin{
228248
Name: mp.Name,
229249
Description: mp.Description,
@@ -248,6 +268,7 @@ func convertMarketplacePlugin(
248268
Tags: mp.Tags,
249269
HasLSPServers: mp.HasLSPServers,
250270
IsExternalURL: mp.IsExternalURL,
271+
IsIncomplete: isIncomplete,
251272
}
252273

253274
if isInstalled {

internal/marketplace/types.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,16 @@ type MarketplacePlugin struct {
3939
Tags []string `json:"tags"`
4040
Strict bool `json:"strict"`
4141

42-
// Installability tracking (set during unmarshaling)
42+
// Installability tracking (set during unmarshaling or validation)
4343
HasLSPServers bool `json:"-"` // True if plugin has lspServers config (built into Claude Code)
4444
IsExternalURL bool `json:"-"` // True if source points to external Git repo
45+
IsIncomplete bool `json:"-"` // True if plugin is missing required files (e.g., .claude-plugin/plugin.json)
4546
}
4647

4748
// Installable returns true if the plugin can be installed via plum.
48-
// Plugins with LSP servers or external URLs require different installation methods.
49+
// Plugins with LSP servers, external URLs, or missing files require different installation methods.
4950
func (mp *MarketplacePlugin) Installable() bool {
50-
return !mp.HasLSPServers && !mp.IsExternalURL
51+
return !mp.HasLSPServers && !mp.IsExternalURL && !mp.IsIncomplete
5152
}
5253

5354
// InstallabilityReason returns a human-readable reason why the plugin is not installable.
@@ -58,6 +59,8 @@ func (mp *MarketplacePlugin) InstallabilityReason() string {
5859
return "LSP plugin (built into Claude Code)"
5960
case mp.IsExternalURL:
6061
return "external repository (requires manual installation)"
62+
case mp.IsIncomplete:
63+
return "incomplete plugin (missing .claude-plugin/plugin.json)"
6164
default:
6265
return ""
6366
}
@@ -71,6 +74,8 @@ func (mp *MarketplacePlugin) InstallabilityTag() string {
7174
return "[built-in]"
7275
case mp.IsExternalURL:
7376
return "[external]"
77+
case mp.IsIncomplete:
78+
return "[incomplete]"
7479
default:
7580
return ""
7681
}

internal/marketplace/types_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,26 @@ func TestMarketplacePlugin_UnmarshalJSON_NullLSPServers(t *testing.T) {
196196
}
197197
}
198198

199+
func TestMarketplacePlugin_IncompletePlugin(t *testing.T) {
200+
// IsIncomplete is set externally during plugin loading, not unmarshaling
201+
plugin := MarketplacePlugin{
202+
Name: "incomplete-plugin",
203+
Source: "./plugins/incomplete",
204+
Description: "An incomplete plugin",
205+
IsIncomplete: true, // Set manually (normally done during loading)
206+
}
207+
208+
if plugin.Installable() {
209+
t.Error("expected plugin to NOT be installable (incomplete)")
210+
}
211+
if plugin.InstallabilityReason() != "incomplete plugin (missing .claude-plugin/plugin.json)" {
212+
t.Errorf("unexpected installability reason: %q", plugin.InstallabilityReason())
213+
}
214+
if plugin.InstallabilityTag() != "[incomplete]" {
215+
t.Errorf("unexpected installability tag: %q", plugin.InstallabilityTag())
216+
}
217+
}
218+
199219
func TestMarketplaceManifest_UnmarshalJSON(t *testing.T) {
200220
jsonData := `{
201221
"name": "test-marketplace",

internal/plugin/plugin.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,13 @@ type Plugin struct {
3030
// Installability tracking
3131
HasLSPServers bool `json:"-"` // True if plugin has lspServers config (built into Claude Code)
3232
IsExternalURL bool `json:"-"` // True if source points to external Git repo
33+
IsIncomplete bool `json:"-"` // True if plugin is missing required files (e.g., .claude-plugin/plugin.json)
3334
}
3435

3536
// Installable returns true if the plugin can be installed via plum.
36-
// Plugins with LSP servers or external URLs require different installation methods.
37+
// Plugins with LSP servers, external URLs, or missing files require different installation methods.
3738
func (p Plugin) Installable() bool {
38-
return !p.HasLSPServers && !p.IsExternalURL
39+
return !p.HasLSPServers && !p.IsExternalURL && !p.IsIncomplete
3940
}
4041

4142
// InstallabilityReason returns a human-readable reason why the plugin is not installable.
@@ -46,6 +47,8 @@ func (p Plugin) InstallabilityReason() string {
4647
return "LSP plugin (built into Claude Code)"
4748
case p.IsExternalURL:
4849
return "external repository (requires manual installation)"
50+
case p.IsIncomplete:
51+
return "incomplete plugin (missing .claude-plugin/plugin.json)"
4952
default:
5053
return ""
5154
}
@@ -59,6 +62,8 @@ func (p Plugin) InstallabilityTag() string {
5962
return "[built-in]"
6063
case p.IsExternalURL:
6164
return "[external]"
65+
case p.IsIncomplete:
66+
return "[incomplete]"
6267
default:
6368
return ""
6469
}

0 commit comments

Comments
 (0)