Skip to content

Commit dc5dfd0

Browse files
committed
fix: claude plgin skills
1 parent 72249f0 commit dc5dfd0

6 files changed

Lines changed: 270 additions & 85 deletions

File tree

cmd/endpoint/scan.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,10 @@ type Options struct {
6363
// It is the integration point for cmd/ai/discover; opts.Kinds is
6464
// overwritten.
6565
func RunAITool(ctx context.Context, opts Options) error {
66-
opts.Kinds = []string{scanners.KindAITool, scanners.KindAgentSkill}
66+
opts.Kinds = []string{
67+
scanners.KindAITool,
68+
scanners.KindAgentSkill,
69+
}
6770
return runScan(ctx, opts)
6871
}
6972

pkg/inventory/scanners/skills/adapter.go

Lines changed: 177 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -4,112 +4,216 @@ import (
44
"context"
55
"os"
66
"path/filepath"
7+
"strings"
78

89
"github.com/safedep/vet/pkg/inventory"
910
)
1011

11-
const scannerName = "skills"
12+
const (
13+
scannerName = "skills"
1214

13-
// agentEntry maps an agent identifier to its project-local and global skill
14-
// directory paths. ProjectPath is relative to cfg.ProjectDir; GlobalPath is
15-
// relative to cfg.HomeDir.
16-
type agentEntry struct {
17-
App string
18-
ProjectPath string
19-
GlobalPath string
15+
// appGlobalSkills is the App name used for skill directories that are
16+
// shared by multiple agents (e.g. .agents/skills/). Using a single name
17+
// avoids emitting the same skill once per agent that claims the path.
18+
appGlobalSkills = "Global Skills"
19+
)
20+
21+
// pathEntry is a (relative path, app name) pair for one scope.
22+
// Path is relative to cfg.ProjectDir (project scope) or homeDir (system scope).
23+
type pathEntry struct {
24+
App string
25+
Path string
26+
}
27+
28+
// projectPaths lists the unique project-scoped skill directories to scan.
29+
// Paths shared by multiple agents are collapsed into a single appGlobalSkills entry.
30+
var projectPaths = []pathEntry{
31+
// .agents/skills/ is the shared convention used by amp, kimi-cli, replit,
32+
// universal, antigravity, cline, warp, codex, cursor, deepagents,
33+
// firebender, gemini-cli, github-copilot, and opencode.
34+
{App: appGlobalSkills, Path: ".agents/skills"},
35+
// Agent-specific project paths below.
36+
{App: "augment", Path: ".augment/skills"},
37+
{App: "bob", Path: ".bob/skills"},
38+
{App: "claude-code", Path: ".claude/skills"},
39+
{App: "codebuddy", Path: ".codebuddy/skills"},
40+
{App: "command-code", Path: ".commandcode/skills"},
41+
{App: "continue", Path: ".continue/skills"},
42+
{App: "cortex", Path: ".cortex/skills"},
43+
{App: "crush", Path: ".crush/skills"},
44+
{App: "droid", Path: ".factory/skills"},
45+
{App: "goose", Path: ".goose/skills"},
46+
{App: "iflow-cli", Path: ".iflow/skills"},
47+
{App: "junie", Path: ".junie/skills"},
48+
{App: "kilo", Path: ".kilocode/skills"},
49+
{App: "kiro-cli", Path: ".kiro/skills"},
50+
{App: "kode", Path: ".kode/skills"},
51+
{App: "mcpjam", Path: ".mcpjam/skills"},
52+
{App: "mistral-vibe", Path: ".vibe/skills"},
53+
{App: "mux", Path: ".mux/skills"},
54+
{App: "openclaw", Path: "skills"},
55+
{App: "openhands", Path: ".openhands/skills"},
56+
{App: "pi", Path: ".pi/skills"},
57+
{App: "qoder", Path: ".qoder/skills"},
58+
{App: "qwen-code", Path: ".qwen/skills"},
59+
{App: "roo", Path: ".roo/skills"},
60+
// trae and trae-cn share .trae/skills/ at project scope.
61+
{App: "trae", Path: ".trae/skills"},
62+
{App: "windsurf", Path: ".windsurf/skills"},
63+
{App: "zencoder", Path: ".zencoder/skills"},
64+
{App: "neovate", Path: ".neovate/skills"},
65+
{App: "pochi", Path: ".pochi/skills"},
66+
{App: "adal", Path: ".adal/skills"},
2067
}
2168

22-
// agentRegistry is the canonical mapping of agents to their skill directories.
23-
// Derived from the supported-agents table in the agent skills scanner design doc.
24-
var agentRegistry = []agentEntry{
25-
{App: "amp", ProjectPath: ".agents/skills", GlobalPath: ".config/agents/skills"},
26-
{App: "kimi-cli", ProjectPath: ".agents/skills", GlobalPath: ".config/agents/skills"},
27-
{App: "replit", ProjectPath: ".agents/skills", GlobalPath: ".config/agents/skills"},
28-
{App: "universal", ProjectPath: ".agents/skills", GlobalPath: ".config/agents/skills"},
29-
{App: "antigravity", ProjectPath: ".agents/skills", GlobalPath: ".gemini/antigravity/skills"},
30-
{App: "augment", ProjectPath: ".augment/skills", GlobalPath: ".augment/skills"},
31-
{App: "bob", ProjectPath: ".bob/skills", GlobalPath: ".bob/skills"},
32-
{App: "claude-code", ProjectPath: ".claude/skills", GlobalPath: ".claude/skills"},
33-
{App: "openclaw", ProjectPath: "skills", GlobalPath: ".openclaw/skills"},
34-
{App: "cline", ProjectPath: ".agents/skills", GlobalPath: ".agents/skills"},
35-
{App: "warp", ProjectPath: ".agents/skills", GlobalPath: ".agents/skills"},
36-
{App: "codebuddy", ProjectPath: ".codebuddy/skills", GlobalPath: ".codebuddy/skills"},
37-
{App: "codex", ProjectPath: ".agents/skills", GlobalPath: ".codex/skills"},
38-
{App: "command-code", ProjectPath: ".commandcode/skills", GlobalPath: ".commandcode/skills"},
39-
{App: "continue", ProjectPath: ".continue/skills", GlobalPath: ".continue/skills"},
40-
{App: "cortex", ProjectPath: ".cortex/skills", GlobalPath: ".snowflake/cortex/skills"},
41-
{App: "crush", ProjectPath: ".crush/skills", GlobalPath: ".config/crush/skills"},
42-
{App: "cursor", ProjectPath: ".agents/skills", GlobalPath: ".cursor/skills"},
43-
{App: "deepagents", ProjectPath: ".agents/skills", GlobalPath: ".deepagents/agent/skills"},
44-
{App: "droid", ProjectPath: ".factory/skills", GlobalPath: ".factory/skills"},
45-
{App: "firebender", ProjectPath: ".agents/skills", GlobalPath: ".firebender/skills"},
46-
{App: "gemini-cli", ProjectPath: ".agents/skills", GlobalPath: ".gemini/skills"},
47-
{App: "github-copilot", ProjectPath: ".agents/skills", GlobalPath: ".copilot/skills"},
48-
{App: "goose", ProjectPath: ".goose/skills", GlobalPath: ".config/goose/skills"},
49-
{App: "junie", ProjectPath: ".junie/skills", GlobalPath: ".junie/skills"},
50-
{App: "iflow-cli", ProjectPath: ".iflow/skills", GlobalPath: ".iflow/skills"},
51-
{App: "kilo", ProjectPath: ".kilocode/skills", GlobalPath: ".kilocode/skills"},
52-
{App: "kiro-cli", ProjectPath: ".kiro/skills", GlobalPath: ".kiro/skills"},
53-
{App: "kode", ProjectPath: ".kode/skills", GlobalPath: ".kode/skills"},
54-
{App: "mcpjam", ProjectPath: ".mcpjam/skills", GlobalPath: ".mcpjam/skills"},
55-
{App: "mistral-vibe", ProjectPath: ".vibe/skills", GlobalPath: ".vibe/skills"},
56-
{App: "mux", ProjectPath: ".mux/skills", GlobalPath: ".mux/skills"},
57-
{App: "opencode", ProjectPath: ".agents/skills", GlobalPath: ".config/opencode/skills"},
58-
{App: "openhands", ProjectPath: ".openhands/skills", GlobalPath: ".openhands/skills"},
59-
{App: "pi", ProjectPath: ".pi/skills", GlobalPath: ".pi/agent/skills"},
60-
{App: "qoder", ProjectPath: ".qoder/skills", GlobalPath: ".qoder/skills"},
61-
{App: "qwen-code", ProjectPath: ".qwen/skills", GlobalPath: ".qwen/skills"},
62-
{App: "roo", ProjectPath: ".roo/skills", GlobalPath: ".roo/skills"},
63-
{App: "trae", ProjectPath: ".trae/skills", GlobalPath: ".trae/skills"},
64-
{App: "trae-cn", ProjectPath: ".trae/skills", GlobalPath: ".trae-cn/skills"},
65-
{App: "windsurf", ProjectPath: ".windsurf/skills", GlobalPath: ".codeium/windsurf/skills"},
66-
{App: "zencoder", ProjectPath: ".zencoder/skills", GlobalPath: ".zencoder/skills"},
67-
{App: "neovate", ProjectPath: ".neovate/skills", GlobalPath: ".neovate/skills"},
68-
{App: "pochi", ProjectPath: ".pochi/skills", GlobalPath: ".pochi/skills"},
69-
{App: "adal", ProjectPath: ".adal/skills", GlobalPath: ".adal/skills"},
69+
// globalPaths lists the unique system-scoped (home-relative) skill directories
70+
// to scan. Paths shared by multiple agents are collapsed into appGlobalSkills.
71+
var globalPaths = []pathEntry{
72+
// ~/.config/agents/skills/ is shared by amp, kimi-cli, replit, universal.
73+
{App: appGlobalSkills, Path: ".config/agents/skills"},
74+
// ~/.agents/skills/ is shared by cline and warp.
75+
{App: appGlobalSkills, Path: ".agents/skills"},
76+
// Agent-specific global paths below.
77+
{App: "antigravity", Path: ".gemini/antigravity/skills"},
78+
{App: "augment", Path: ".augment/skills"},
79+
{App: "bob", Path: ".bob/skills"},
80+
{App: "claude-code", Path: ".claude/skills"},
81+
{App: "codebuddy", Path: ".codebuddy/skills"},
82+
{App: "codex", Path: ".codex/skills"},
83+
{App: "command-code", Path: ".commandcode/skills"},
84+
{App: "continue", Path: ".continue/skills"},
85+
{App: "cortex", Path: ".snowflake/cortex/skills"},
86+
{App: "crush", Path: ".config/crush/skills"},
87+
{App: "cursor", Path: ".cursor/skills"},
88+
{App: "deepagents", Path: ".deepagents/agent/skills"},
89+
{App: "droid", Path: ".factory/skills"},
90+
{App: "firebender", Path: ".firebender/skills"},
91+
{App: "gemini-cli", Path: ".gemini/skills"},
92+
{App: "github-copilot", Path: ".copilot/skills"},
93+
{App: "goose", Path: ".config/goose/skills"},
94+
{App: "iflow-cli", Path: ".iflow/skills"},
95+
{App: "junie", Path: ".junie/skills"},
96+
{App: "kilo", Path: ".kilocode/skills"},
97+
{App: "kiro-cli", Path: ".kiro/skills"},
98+
{App: "kode", Path: ".kode/skills"},
99+
{App: "mcpjam", Path: ".mcpjam/skills"},
100+
{App: "mistral-vibe", Path: ".vibe/skills"},
101+
{App: "mux", Path: ".mux/skills"},
102+
{App: "openclaw", Path: ".openclaw/skills"},
103+
{App: "opencode", Path: ".config/opencode/skills"},
104+
{App: "openhands", Path: ".openhands/skills"},
105+
{App: "pi", Path: ".pi/agent/skills"},
106+
{App: "qoder", Path: ".qoder/skills"},
107+
{App: "qwen-code", Path: ".qwen/skills"},
108+
{App: "roo", Path: ".roo/skills"},
109+
{App: "trae", Path: ".trae/skills"},
110+
{App: "trae-cn", Path: ".trae-cn/skills"},
111+
{App: "windsurf", Path: ".codeium/windsurf/skills"},
112+
{App: "zencoder", Path: ".zencoder/skills"},
113+
{App: "neovate", Path: ".neovate/skills"},
114+
{App: "pochi", Path: ".pochi/skills"},
115+
{App: "adal", Path: ".adal/skills"},
70116
}
71117

72118
type adapter struct{}
73119

74120
// New constructs an inventory.Scanner that discovers agent skill directories
75-
// for all agents in the agentRegistry.
121+
// for all supported agents.
76122
func New() inventory.Scanner {
77123
return &adapter{}
78124
}
79125

80126
// Name returns the stable scanner identifier used in logs and ScanError.
81127
func (a *adapter) Name() string { return scannerName }
82128

83-
// Scan walks each agent's project-local and global skill directories,
84-
// emitting one Item per subdirectory found.
129+
// Scan walks each entry in projectPaths (when project scope is enabled) and
130+
// globalPaths (when system scope is enabled), emitting one Item per skill
131+
// subdirectory found.
85132
func (a *adapter) Scan(ctx context.Context, cfg inventory.ScanConfig, emit inventory.EmitFunc) error {
86-
homeDir := cfg.HomeDir
87-
if homeDir == "" {
88-
if h, err := os.UserHomeDir(); err == nil {
89-
homeDir = h
133+
if cfg.ScopeEnabled(inventory.ScopeProject) && cfg.ProjectDir != "" {
134+
for _, e := range projectPaths {
135+
dir := filepath.Join(cfg.ProjectDir, e.Path)
136+
if err := scanDir(ctx, dir, e.App, "", inventory.ScopeProject, emit); err != nil {
137+
return err
138+
}
90139
}
91140
}
92141

93-
for _, entry := range agentRegistry {
94-
if cfg.ScopeEnabled(inventory.ScopeProject) && cfg.ProjectDir != "" {
95-
dir := filepath.Join(cfg.ProjectDir, entry.ProjectPath)
96-
if err := scanDir(ctx, dir, entry.App, inventory.ScopeProject, emit); err != nil {
97-
return err
142+
if cfg.ScopeEnabled(inventory.ScopeSystem) {
143+
homeDir := cfg.HomeDir
144+
if homeDir == "" {
145+
if h, err := os.UserHomeDir(); err == nil {
146+
homeDir = h
98147
}
99148
}
100-
if cfg.ScopeEnabled(inventory.ScopeSystem) && homeDir != "" {
101-
dir := filepath.Join(homeDir, entry.GlobalPath)
102-
if err := scanDir(ctx, dir, entry.App, inventory.ScopeSystem, emit); err != nil {
149+
if homeDir != "" {
150+
for _, e := range globalPaths {
151+
dir := filepath.Join(homeDir, e.Path)
152+
if err := scanDir(ctx, dir, e.App, "", inventory.ScopeSystem, emit); err != nil {
153+
return err
154+
}
155+
}
156+
if err := scanClaudePluginSkills(ctx, homeDir, inventory.ScopeSystem, emit); err != nil {
103157
return err
104158
}
159+
if err := scanClaudeMarketplaceSkills(ctx, homeDir, inventory.ScopeSystem, emit); err != nil {
160+
return err
161+
}
162+
}
163+
}
164+
165+
return nil
166+
}
167+
168+
// scanClaudePluginSkills globs ~/.claude/plugins/cache/<org>/<plugin>/<version>/skills/
169+
// App is "claude-code"; Name is "<plugin>/<skill-name>".
170+
func scanClaudePluginSkills(ctx context.Context, homeDir string, scope inventory.Scope, emit inventory.EmitFunc) error {
171+
pattern := filepath.Join(homeDir, ".claude", "plugins", "cache", "*", "*", "*", "skills")
172+
matches, err := filepath.Glob(pattern)
173+
if err != nil || len(matches) == 0 {
174+
return nil
175+
}
176+
for _, skillsDir := range matches {
177+
// Extract plugin name: cache/<org>/<plugin>/<version>/skills → plugin is [len-3]
178+
parts := strings.Split(filepath.ToSlash(skillsDir), "/")
179+
if len(parts) < 3 {
180+
continue
181+
}
182+
pluginName := parts[len(parts)-3]
183+
if err := scanDir(ctx, skillsDir, "claude-code", pluginName+"/", scope, emit); err != nil {
184+
return err
185+
}
186+
}
187+
return nil
188+
}
189+
190+
// scanClaudeMarketplaceSkills globs
191+
// ~/.claude/plugins/marketplaces/<marketplace>/plugins/<plugin>/skills/
192+
// App is "claude-code"; Name is "<plugin>/<skill-name>".
193+
func scanClaudeMarketplaceSkills(ctx context.Context, homeDir string, scope inventory.Scope, emit inventory.EmitFunc) error {
194+
pattern := filepath.Join(homeDir, ".claude", "plugins", "marketplaces", "*", "plugins", "*", "skills")
195+
matches, err := filepath.Glob(pattern)
196+
if err != nil || len(matches) == 0 {
197+
return nil
198+
}
199+
for _, skillsDir := range matches {
200+
// Extract plugin name: .../plugins/<plugin>/skills → plugin is [len-2]
201+
parts := strings.Split(filepath.ToSlash(skillsDir), "/")
202+
if len(parts) < 2 {
203+
continue
204+
}
205+
pluginName := parts[len(parts)-2]
206+
if err := scanDir(ctx, skillsDir, "claude-code", pluginName+"/", scope, emit); err != nil {
207+
return err
105208
}
106209
}
107210
return nil
108211
}
109212

110213
// scanDir lists subdirectories of dir and emits one Item per directory found.
214+
// namePrefix is prepended to the skill name (e.g. "superpowers/" for plugin skills).
111215
// Missing or unreadable directories are silently skipped.
112-
func scanDir(_ context.Context, dir, app string, scope inventory.Scope, emit inventory.EmitFunc) error {
216+
func scanDir(_ context.Context, dir, app, namePrefix string, scope inventory.Scope, emit inventory.EmitFunc) error {
113217
entries, err := os.ReadDir(dir)
114218
if err != nil {
115219
return nil
@@ -120,7 +224,7 @@ func scanDir(_ context.Context, dir, app string, scope inventory.Scope, emit inv
120224
}
121225
s := &skill{
122226
App: app,
123-
Name: e.Name(),
227+
Name: namePrefix + e.Name(),
124228
Scope: scope,
125229
ConfigPath: filepath.Join(dir, e.Name()),
126230
SkillsDir: dir,

pkg/inventory/scanners/skills/adapter_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,58 @@ func TestAdapterScanRespectsProjectScopeOnly(t *testing.T) {
132132
}
133133
}
134134

135+
func TestAdapterScanEmitsClaudePluginSkills(t *testing.T) {
136+
tmp := t.TempDir()
137+
// Simulate ~/.claude/plugins/cache/<org>/<plugin>/<version>/skills/<skill>/
138+
skillDir := filepath.Join(tmp, ".claude", "plugins", "cache", "acme-org", "my-plugin", "1.0.0", "skills")
139+
require.NoError(t, os.MkdirAll(filepath.Join(skillDir, "skill-alpha"), 0o755))
140+
require.NoError(t, os.MkdirAll(filepath.Join(skillDir, "skill-beta"), 0o755))
141+
142+
var emitted []*inventory.Item
143+
err := New().Scan(context.Background(), inventory.ScanConfig{
144+
HomeDir: tmp,
145+
Scopes: []inventory.Scope{inventory.ScopeSystem},
146+
}, func(item *inventory.Item) error {
147+
emitted = append(emitted, item)
148+
return nil
149+
})
150+
require.NoError(t, err)
151+
require.Len(t, emitted, 2)
152+
153+
names := []string{emitted[0].Name, emitted[1].Name}
154+
assert.ElementsMatch(t, []string{"my-plugin/skill-alpha", "my-plugin/skill-beta"}, names)
155+
assert.Equal(t, "claude-code", emitted[0].App)
156+
assert.Equal(t, inventory.KindAgentSkill, emitted[0].Kind)
157+
assert.Equal(t, inventory.ScopeSystem, emitted[0].Scope)
158+
assert.Equal(t, emitted[0].ConfigPath, emitted[0].Metadata["skill.path"])
159+
}
160+
161+
func TestAdapterScanEmitsClaudeMarketplaceSkills(t *testing.T) {
162+
tmp := t.TempDir()
163+
// Simulate ~/.claude/plugins/marketplaces/<marketplace>/plugins/<plugin>/skills/<skill>/
164+
skillDir := filepath.Join(tmp, ".claude", "plugins", "marketplaces", "my-marketplace", "plugins", "my-plugin", "skills")
165+
require.NoError(t, os.MkdirAll(filepath.Join(skillDir, "skill-one"), 0o755))
166+
require.NoError(t, os.MkdirAll(filepath.Join(skillDir, "skill-two"), 0o755))
167+
168+
var emitted []*inventory.Item
169+
err := New().Scan(context.Background(), inventory.ScanConfig{
170+
HomeDir: tmp,
171+
Scopes: []inventory.Scope{inventory.ScopeSystem},
172+
}, func(item *inventory.Item) error {
173+
emitted = append(emitted, item)
174+
return nil
175+
})
176+
require.NoError(t, err)
177+
require.Len(t, emitted, 2)
178+
179+
names := []string{emitted[0].Name, emitted[1].Name}
180+
assert.ElementsMatch(t, []string{"my-plugin/skill-one", "my-plugin/skill-two"}, names)
181+
assert.Equal(t, "claude-code", emitted[0].App)
182+
assert.Equal(t, inventory.KindAgentSkill, emitted[0].Kind)
183+
assert.Equal(t, inventory.ScopeSystem, emitted[0].Scope)
184+
assert.Equal(t, emitted[0].ConfigPath, emitted[0].Metadata["skill.path"])
185+
}
186+
135187
func TestAdapterScanRespectsSystemScopeOnly(t *testing.T) {
136188
tmp := t.TempDir()
137189
require.NoError(t, os.MkdirAll(filepath.Join(tmp, ".claude", "skills", "sys-skill"), 0o755))

pkg/inventory/scanners/skills/translate.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ func translate(s *skill) *inventory.Item {
2929
App: s.App,
3030
Scope: s.Scope,
3131
ConfigPath: s.ConfigPath,
32+
Metadata: map[string]string{"skill.path": s.ConfigPath},
3233
}
3334
}
3435

@@ -41,6 +42,10 @@ func itemIdentity(app string, kind inventory.Kind, scope inventory.Scope, name,
4142
}
4243

4344
// sourceID groups skills emitted from the same agent skills directory.
45+
// Uses FNV-64a so the result is always ≤16 hex chars, satisfying the
46+
// backend's 100-character limit even for deep plugin paths.
4447
func sourceID(app, skillsDir string) string {
45-
return fmt.Sprintf("%s:%s", app, skillsDir)
48+
h := fnv.New64a()
49+
_, _ = fmt.Fprintf(h, "%s:%s", app, skillsDir)
50+
return fmt.Sprintf("%x", h.Sum64())
4651
}

0 commit comments

Comments
 (0)