diff --git a/internal/skill/catalog.go b/internal/skill/catalog.go index 501503a..d84474a 100644 --- a/internal/skill/catalog.go +++ b/internal/skill/catalog.go @@ -131,7 +131,7 @@ func parseMarketplaceManifest(dir string) (*Catalog, error) { } if len(plugin.Skills) == 0 { pluginCatalog, err := parsePluginManifest(filepath.Join(dir, sourceDir)) - if err == nil { + if err == nil && len(pluginCatalog.Skills) > 0 { for _, entry := range pluginCatalog.Skills { entry.Path = filepath.Join(sourceDir, entry.Path) if !seen[entry.Path] { @@ -139,6 +139,16 @@ func parseMarketplaceManifest(dir string) (*Catalog, error) { catalog.Skills = append(catalog.Skills, entry) } } + continue + } + + // A plugin rooted at a skill directory: Claude Code loads the + // SKILL.md at the plugin root as the plugin's skill. + skillPath := filepath.Clean(sourceDir) + info, err := os.Stat(filepath.Join(dir, skillPath, "SKILL.md")) + if skillPath != "." && err == nil && info.Mode().IsRegular() && !seen[skillPath] { + seen[skillPath] = true + catalog.Skills = append(catalog.Skills, entryFromSkillDir(dir, skillPath)) } continue } diff --git a/internal/skill/catalog_test.go b/internal/skill/catalog_test.go index a0d3f17..533f53f 100644 --- a/internal/skill/catalog_test.go +++ b/internal/skill/catalog_test.go @@ -115,6 +115,33 @@ func TestReadCatalogFromMarketplaceSkipsRemoteSources(t *testing.T) { }, catalog.Skills) } +func TestReadCatalogFromMarketplaceSkillRootedPlugins(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + writeFile(t, filepath.Join(dir, ".claude-plugin", "marketplace.json"), `{ + "plugins": [ + {"name": "alpha", "source": "./skills/alpha"}, + {"name": "beta", "source": "./skills/cli/beta/"}, + {"name": "no-skill", "source": "./skills/no-skill"}, + {"name": "duplicate", "source": "./skills/alpha"} + ] +}`) + writeFile(t, filepath.Join(dir, "skills", "alpha", ".claude-plugin", "plugin.json"), + `{"name": "alpha", "description": "Alpha plugin", "version": "1.0.0"}`) + writeFile(t, filepath.Join(dir, "skills", "alpha", "SKILL.md"), "---\nname: alpha\ndescription: Alpha\n---\n# Alpha") + writeFile(t, filepath.Join(dir, "skills", "cli", "beta", "SKILL.md"), "---\nname: beta\n---\n# Beta") + writeFile(t, filepath.Join(dir, "skills", "no-skill", "README.md"), "not a skill") + writeFile(t, filepath.Join(dir, "skills", "unlisted", "SKILL.md"), "# Unlisted") + + catalog, err := ReadCatalog(dir) + require.NoError(t, err) + require.Equal(t, []Entry{ + {Name: "alpha", Description: "Alpha", Path: filepath.Join("skills", "alpha")}, + {Name: "beta", Path: filepath.Join("skills", "cli", "beta")}, + }, catalog.Skills) +} + func TestReadCatalogPluginJSONFallbackFromEmptyMarketplace(t *testing.T) { t.Parallel()