diff --git a/claude/code/skills/bitcointalk/collect.sh b/claude/code/skills/bitcointalk/collect.sh old mode 100644 new mode 100755 diff --git a/claude/code/skills/block-explorer/generate-jobs.sh b/claude/code/skills/block-explorer/generate-jobs.sh old mode 100644 new mode 100755 diff --git a/claude/code/skills/coinmarketcap/generate-jobs.sh b/claude/code/skills/coinmarketcap/generate-jobs.sh old mode 100644 new mode 100755 diff --git a/claude/code/skills/coinmarketcap/process.sh b/claude/code/skills/coinmarketcap/process.sh old mode 100644 new mode 100755 diff --git a/claude/code/skills/cryptonote-discovery/discover.sh b/claude/code/skills/cryptonote-discovery/discover.sh old mode 100644 new mode 100755 diff --git a/claude/code/skills/job-collector/generate-jobs.sh b/claude/code/skills/job-collector/generate-jobs.sh old mode 100644 new mode 100755 diff --git a/claude/code/skills/job-collector/process.sh b/claude/code/skills/job-collector/process.sh old mode 100644 new mode 100755 diff --git a/claude/code/skills/mining-pools/generate-jobs.sh b/claude/code/skills/mining-pools/generate-jobs.sh old mode 100644 new mode 100755 diff --git a/pkg/plugin/contract_test.go b/pkg/plugin/contract_test.go new file mode 100644 index 0000000..b2975b9 --- /dev/null +++ b/pkg/plugin/contract_test.go @@ -0,0 +1,488 @@ +// Package plugin verifies the Claude Code plugin contract. Every plugin in the +// marketplace must satisfy the structural rules Claude Code expects: valid JSON +// manifests, commands with YAML frontmatter, executable scripts, and well-formed +// hooks. These tests catch breakage before a tag ships. +package plugin + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ── types ────────────────────────────────────────────────────────── + +type marketplace struct { + Name string `json:"name"` + Description string `json:"description"` + Owner owner `json:"owner"` + Plugins []plugin `json:"plugins"` +} + +type owner struct { + Name string `json:"name"` + Email string `json:"email"` +} + +type plugin struct { + Name string `json:"name"` + Source string `json:"source"` + Description string `json:"description"` + Version string `json:"version"` +} + +type pluginManifest struct { + Name string `json:"name"` + Description string `json:"description"` + Version string `json:"version"` +} + +type hooksFile struct { + Schema string `json:"$schema"` + Hooks map[string]json.RawMessage `json:"hooks"` +} + +type hookEntry struct { + Matcher string `json:"matcher"` + Hooks []hookDef `json:"hooks"` + Description string `json:"description"` +} + +type hookDef struct { + Type string `json:"type"` + Command string `json:"command"` +} + +// ── helpers ──────────────────────────────────────────────────────── + +func repoRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + require.NoError(t, err) + for { + if _, err := os.Stat(filepath.Join(dir, ".claude-plugin", "marketplace.json")); err == nil { + return dir + } + parent := filepath.Dir(dir) + require.NotEqual(t, parent, dir, "marketplace.json not found") + dir = parent + } +} + +func loadMarketplace(t *testing.T) (marketplace, string) { + t.Helper() + root := repoRoot(t) + data, err := os.ReadFile(filepath.Join(root, ".claude-plugin", "marketplace.json")) + require.NoError(t, err) + var mp marketplace + require.NoError(t, json.Unmarshal(data, &mp)) + return mp, root +} + +// validHookEvents are the hook events Claude Code supports. +var validHookEvents = map[string]bool{ + "PreToolUse": true, + "PostToolUse": true, + "Stop": true, + "SubagentStop": true, + "SessionStart": true, + "SessionEnd": true, + "UserPromptSubmit": true, + "PreCompact": true, + "Notification": true, +} + +// ── Marketplace contract ─────────────────────────────────────────── + +func TestMarketplace_Valid(t *testing.T) { + mp, _ := loadMarketplace(t) + assert.NotEmpty(t, mp.Name, "marketplace must have a name") + assert.NotEmpty(t, mp.Description, "marketplace must have a description") + assert.NotEmpty(t, mp.Owner.Name, "marketplace must have an owner name") + assert.NotEmpty(t, mp.Plugins, "marketplace must list at least one plugin") +} + +func TestMarketplace_PluginsHaveRequiredFields(t *testing.T) { + mp, _ := loadMarketplace(t) + for _, p := range mp.Plugins { + assert.NotEmpty(t, p.Name, "plugin must have a name") + assert.NotEmpty(t, p.Source, "plugin %s must have a source path", p.Name) + assert.NotEmpty(t, p.Description, "plugin %s must have a description", p.Name) + assert.NotEmpty(t, p.Version, "plugin %s must have a version", p.Name) + } +} + +func TestMarketplace_UniquePluginNames(t *testing.T) { + mp, _ := loadMarketplace(t) + seen := map[string]bool{} + for _, p := range mp.Plugins { + assert.False(t, seen[p.Name], "duplicate plugin name: %s", p.Name) + seen[p.Name] = true + } +} + +// ── Plugin directory structure ───────────────────────────────────── + +func TestPlugin_DirectoryExists(t *testing.T) { + mp, root := loadMarketplace(t) + for _, p := range mp.Plugins { + pluginDir := filepath.Join(root, p.Source) + info, err := os.Stat(pluginDir) + require.NoError(t, err, "plugin %s: source dir %s must exist", p.Name, p.Source) + assert.True(t, info.IsDir(), "plugin %s: source must be a directory", p.Name) + } +} + +func TestPlugin_HasManifest(t *testing.T) { + mp, root := loadMarketplace(t) + for _, p := range mp.Plugins { + manifestPath := filepath.Join(root, p.Source, ".claude-plugin", "plugin.json") + data, err := os.ReadFile(manifestPath) + require.NoError(t, err, "plugin %s: must have .claude-plugin/plugin.json", p.Name) + + var manifest pluginManifest + require.NoError(t, json.Unmarshal(data, &manifest), "plugin %s: invalid plugin.json", p.Name) + assert.NotEmpty(t, manifest.Name, "plugin %s: manifest must have a name", p.Name) + assert.NotEmpty(t, manifest.Description, "plugin %s: manifest must have a description", p.Name) + assert.NotEmpty(t, manifest.Version, "plugin %s: manifest must have a version", p.Name) + } +} + +// ── Commands contract ────────────────────────────────────────────── + +func TestPlugin_CommandsAreMarkdown(t *testing.T) { + mp, root := loadMarketplace(t) + for _, p := range mp.Plugins { + cmdDir := filepath.Join(root, p.Source, "commands") + entries, err := os.ReadDir(cmdDir) + if os.IsNotExist(err) { + continue // commands dir is optional + } + require.NoError(t, err, "plugin %s: failed to read commands dir", p.Name) + + for _, entry := range entries { + if entry.IsDir() { + continue + } + assert.True(t, strings.HasSuffix(entry.Name(), ".md"), + "plugin %s: command %s must be a .md file", p.Name, entry.Name()) + } + } +} + +func TestPlugin_CommandsHaveFrontmatter(t *testing.T) { + mp, root := loadMarketplace(t) + for _, p := range mp.Plugins { + cmdDir := filepath.Join(root, p.Source, "commands") + entries, err := os.ReadDir(cmdDir) + if os.IsNotExist(err) { + continue + } + require.NoError(t, err) + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") { + continue + } + data, err := os.ReadFile(filepath.Join(cmdDir, entry.Name())) + require.NoError(t, err) + + content := string(data) + assert.True(t, strings.HasPrefix(content, "---"), + "plugin %s: command %s must start with YAML frontmatter (---)", p.Name, entry.Name()) + + // Must have closing frontmatter + parts := strings.SplitN(content[3:], "---", 2) + assert.True(t, len(parts) >= 2, + "plugin %s: command %s must have closing frontmatter (---)", p.Name, entry.Name()) + + // Frontmatter must contain name: + assert.Contains(t, parts[0], "name:", + "plugin %s: command %s frontmatter must contain 'name:'", p.Name, entry.Name()) + } + } +} + +// ── Hooks contract ───────────────────────────────────────────────── + +func TestPlugin_HooksFileValid(t *testing.T) { + mp, root := loadMarketplace(t) + for _, p := range mp.Plugins { + hooksPath := filepath.Join(root, p.Source, "hooks.json") + data, err := os.ReadFile(hooksPath) + if os.IsNotExist(err) { + continue // hooks.json is optional + } + require.NoError(t, err, "plugin %s: failed to read hooks.json", p.Name) + + var hf hooksFile + require.NoError(t, json.Unmarshal(data, &hf), "plugin %s: invalid hooks.json", p.Name) + assert.NotEmpty(t, hf.Hooks, "plugin %s: hooks.json must define at least one event", p.Name) + } +} + +func TestPlugin_HooksUseValidEvents(t *testing.T) { + mp, root := loadMarketplace(t) + for _, p := range mp.Plugins { + hooksPath := filepath.Join(root, p.Source, "hooks.json") + data, err := os.ReadFile(hooksPath) + if os.IsNotExist(err) { + continue + } + require.NoError(t, err) + + var hf hooksFile + require.NoError(t, json.Unmarshal(data, &hf)) + + for event := range hf.Hooks { + assert.True(t, validHookEvents[event], + "plugin %s: unknown hook event %q (valid: %v)", p.Name, event, validHookEvents) + } + } +} + +func TestPlugin_HookScriptsExist(t *testing.T) { + mp, root := loadMarketplace(t) + for _, p := range mp.Plugins { + hooksPath := filepath.Join(root, p.Source, "hooks.json") + data, err := os.ReadFile(hooksPath) + if os.IsNotExist(err) { + continue + } + require.NoError(t, err) + + var hf hooksFile + require.NoError(t, json.Unmarshal(data, &hf)) + + pluginRoot := filepath.Join(root, p.Source) + + for event, raw := range hf.Hooks { + var entries []hookEntry + require.NoError(t, json.Unmarshal(raw, &entries), + "plugin %s: failed to parse %s entries", p.Name, event) + + for _, entry := range entries { + for _, h := range entry.Hooks { + if h.Type != "command" { + continue + } + // Resolve ${CLAUDE_PLUGIN_ROOT} to the plugin source directory + cmd := strings.ReplaceAll(h.Command, "${CLAUDE_PLUGIN_ROOT}", pluginRoot) + // Extract the script path (first arg, before any flags) + scriptPath := strings.Fields(cmd)[0] + _, err := os.Stat(scriptPath) + assert.NoError(t, err, + "plugin %s: hook script %s does not exist (event: %s)", p.Name, h.Command, event) + } + } + } + } +} + +func TestPlugin_HookScriptsExecutable(t *testing.T) { + mp, root := loadMarketplace(t) + for _, p := range mp.Plugins { + hooksPath := filepath.Join(root, p.Source, "hooks.json") + data, err := os.ReadFile(hooksPath) + if os.IsNotExist(err) { + continue + } + require.NoError(t, err) + + var hf hooksFile + require.NoError(t, json.Unmarshal(data, &hf)) + + pluginRoot := filepath.Join(root, p.Source) + + for event, raw := range hf.Hooks { + var entries []hookEntry + require.NoError(t, json.Unmarshal(raw, &entries)) + + for _, entry := range entries { + for _, h := range entry.Hooks { + if h.Type != "command" { + continue + } + cmd := strings.ReplaceAll(h.Command, "${CLAUDE_PLUGIN_ROOT}", pluginRoot) + scriptPath := strings.Fields(cmd)[0] + info, err := os.Stat(scriptPath) + if err != nil { + continue // Already caught by ScriptsExist test + } + assert.NotZero(t, info.Mode()&0111, + "plugin %s: hook script %s must be executable (event: %s)", p.Name, h.Command, event) + } + } + } + } +} + +// ── Scripts contract ─────────────────────────────────────────────── + +func TestPlugin_AllScriptsExecutable(t *testing.T) { + mp, root := loadMarketplace(t) + for _, p := range mp.Plugins { + scriptsDir := filepath.Join(root, p.Source, "scripts") + entries, err := os.ReadDir(scriptsDir) + if os.IsNotExist(err) { + continue + } + require.NoError(t, err) + + for _, entry := range entries { + if entry.IsDir() { + continue + } + if !strings.HasSuffix(entry.Name(), ".sh") { + continue + } + info, err := entry.Info() + require.NoError(t, err) + assert.NotZero(t, info.Mode()&0111, + "plugin %s: script %s must be executable", p.Name, entry.Name()) + } + } +} + +func TestPlugin_ScriptsHaveShebang(t *testing.T) { + mp, root := loadMarketplace(t) + for _, p := range mp.Plugins { + scriptsDir := filepath.Join(root, p.Source, "scripts") + entries, err := os.ReadDir(scriptsDir) + if os.IsNotExist(err) { + continue + } + require.NoError(t, err) + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sh") { + continue + } + data, err := os.ReadFile(filepath.Join(scriptsDir, entry.Name())) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(string(data), "#!"), + "plugin %s: script %s must start with a shebang (#!)", p.Name, entry.Name()) + } + } +} + +// ── Skills contract ──────────────────────────────────────────────── + +func TestPlugin_SkillsHaveSkillMd(t *testing.T) { + mp, root := loadMarketplace(t) + for _, p := range mp.Plugins { + skillsDir := filepath.Join(root, p.Source, "skills") + entries, err := os.ReadDir(skillsDir) + if os.IsNotExist(err) { + continue + } + require.NoError(t, err) + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + skillMd := filepath.Join(skillsDir, entry.Name(), "SKILL.md") + _, err := os.Stat(skillMd) + assert.NoError(t, err, + "plugin %s: skill %s must have a SKILL.md", p.Name, entry.Name()) + } + } +} + +func TestPlugin_SkillScriptsExecutable(t *testing.T) { + mp, root := loadMarketplace(t) + for _, p := range mp.Plugins { + skillsDir := filepath.Join(root, p.Source, "skills") + entries, err := os.ReadDir(skillsDir) + if os.IsNotExist(err) { + continue + } + require.NoError(t, err) + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + skillDir := filepath.Join(skillsDir, entry.Name()) + scripts, _ := os.ReadDir(skillDir) + for _, s := range scripts { + if s.IsDir() || !strings.HasSuffix(s.Name(), ".sh") { + continue + } + info, err := s.Info() + require.NoError(t, err) + assert.NotZero(t, info.Mode()&0111, + "plugin %s: skill script %s/%s must be executable", p.Name, entry.Name(), s.Name()) + } + } + } +} + +// ── Cross-references ─────────────────────────────────────────────── + +func TestPlugin_CollectionScriptsExecutable(t *testing.T) { + mp, root := loadMarketplace(t) + for _, p := range mp.Plugins { + collDir := filepath.Join(root, p.Source, "collection") + entries, err := os.ReadDir(collDir) + if os.IsNotExist(err) { + continue + } + require.NoError(t, err) + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sh") { + continue + } + info, err := entry.Info() + require.NoError(t, err) + assert.NotZero(t, info.Mode()&0111, + "plugin %s: collection script %s must be executable", p.Name, entry.Name()) + } + } +} + +func TestMarketplace_SourcesMatchDirectories(t *testing.T) { + mp, root := loadMarketplace(t) + + // Every directory in claude/ should be listed in marketplace + claudeDir := filepath.Join(root, "claude") + entries, err := os.ReadDir(claudeDir) + require.NoError(t, err) + + pluginNames := map[string]bool{} + for _, p := range mp.Plugins { + pluginNames[p.Name] = true + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + assert.True(t, pluginNames[entry.Name()], + "directory claude/%s exists but is not listed in marketplace.json", entry.Name()) + } +} + +func TestMarketplace_VersionConsistency(t *testing.T) { + mp, root := loadMarketplace(t) + for _, p := range mp.Plugins { + manifestPath := filepath.Join(root, p.Source, ".claude-plugin", "plugin.json") + data, err := os.ReadFile(manifestPath) + if err != nil { + continue // Already caught by HasManifest test + } + var manifest pluginManifest + if err := json.Unmarshal(data, &manifest); err != nil { + continue + } + assert.Equal(t, p.Version, manifest.Version, + "plugin %s: marketplace version %q != manifest version %q", p.Name, p.Version, manifest.Version) + } +}