diff --git a/internal/skill/catalog.go b/internal/skill/catalog.go index 7d05986..501503a 100644 --- a/internal/skill/catalog.go +++ b/internal/skill/catalog.go @@ -31,10 +31,13 @@ type marketplaceFile struct { } type marketplacePlugin struct { - Name string `json:"name"` - Description string `json:"description"` - Source string `json:"source"` - Skills []string `json:"skills"` + Name string `json:"name"` + Description string `json:"description"` + // Source is a relative path string, or an object such as + // {"source": "github", ...} or {"source": "archive", ...} for a plugin + // hosted outside the marketplace repository. + Source json.RawMessage `json:"source"` + Skills []string `json:"skills"` } // pluginFile represents the .claude-plugin/plugin.json format. @@ -120,7 +123,12 @@ func parseMarketplaceManifest(dir string) (*Catalog, error) { var catalog Catalog seen := make(map[string]bool) for _, plugin := range marketplace.Plugins { - sourceDir := strings.TrimPrefix(plugin.Source, "./") + sourceDir, ok := plugin.localSourceDir() + if !ok { + // The plugin's files live outside this repository, so its skills + // are not part of the snapshot. + continue + } if len(plugin.Skills) == 0 { pluginCatalog, err := parsePluginManifest(filepath.Join(dir, sourceDir)) if err == nil { @@ -151,6 +159,20 @@ func parseMarketplaceManifest(dir string) (*Catalog, error) { return &catalog, nil } +// localSourceDir returns the plugin's directory relative to the marketplace +// root. It reports false when the source is not a relative path string, +// such as a github, git-subdir, npm, or archive source object. +func (plugin marketplacePlugin) localSourceDir() (string, bool) { + if len(plugin.Source) == 0 { + return "", true + } + var sourcePath string + if err := json.Unmarshal(plugin.Source, &sourcePath); err != nil { + return "", false + } + return strings.TrimPrefix(sourcePath, "./"), true +} + // parsePluginManifest reads .claude-plugin/plugin.json and discovers skills // by scanning the skills directory it references. func parsePluginManifest(dir string) (*Catalog, error) { diff --git a/internal/skill/catalog_test.go b/internal/skill/catalog_test.go index 67cf1ed..a0d3f17 100644 --- a/internal/skill/catalog_test.go +++ b/internal/skill/catalog_test.go @@ -91,6 +91,30 @@ func TestReadCatalogFromMarketplacePluginManifest(t *testing.T) { }}, catalog.Skills) } +func TestReadCatalogFromMarketplaceSkipsRemoteSources(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + writeFile(t, filepath.Join(dir, ".claude-plugin", "marketplace.json"), `{ + "plugins": [ + {"name": "local", "source": "./skills", "skills": ["./alpha", "./cli/beta"]}, + {"name": "archived", "source": {"source": "archive", "url": "https://example.com/remote.zip"}, "skills": ["./remote"]}, + {"name": "hosted", "source": {"source": "github", "repo": "acme/hosted"}}, + {"name": "subdir", "source": {"source": "git-subdir", "url": "acme/monorepo", "path": "skills"}, "skills": ["./alpha"]} + ] +}`) + writeFile(t, filepath.Join(dir, "skills", "alpha", "SKILL.md"), "---\nname: 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", "unlisted", "SKILL.md"), "# Unlisted") + + catalog, err := ReadCatalog(dir) + require.NoError(t, err) + require.Equal(t, []Entry{ + {Name: "alpha", Path: filepath.Join("skills", "alpha")}, + {Name: "beta", Path: filepath.Join("skills", "cli", "beta")}, + }, catalog.Skills) +} + func TestReadCatalogPluginJSONFallbackFromEmptyMarketplace(t *testing.T) { t.Parallel()