Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 67 additions & 9 deletions internal/skills/discovery/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,24 @@ func matchSkillConventions(entry treeEntry) *skillMatch {
return &skillMatch{entry: entry, name: skillName, namespace: namespace, skillDir: dir, convention: "plugins"}
}

// Deeply nested skills/ directory: <prefix>/skills/<name>/SKILL.md
// Matches skills/ at any depth, not just at the repository root.
// Exclude paths with dot-prefixed segments (handled by
// matchHiddenDirConventions) and paths under a plugins/ directory
// (handled by the plugins convention above).
if path.Base(parentDir) == "skills" && !hasHiddenSegment(entry.Path) && !hasPluginsAncestor(entry.Path) {
return &skillMatch{entry: entry, name: skillName, skillDir: dir, convention: "skills"}
}

// Deeply nested namespaced: <prefix>/skills/<namespace>/<name>/SKILL.md
if path.Base(grandparentDir) == "skills" && !hasHiddenSegment(entry.Path) && !hasPluginsAncestor(entry.Path) {
namespace := path.Base(parentDir)
if !validateName(namespace) {
return nil
}
return &skillMatch{entry: entry, name: skillName, namespace: namespace, skillDir: dir, convention: "skills-namespaced"}
}

if parentDir == "." && skillName != "skills" && skillName != "plugins" && !strings.HasPrefix(skillName, ".") {
return &skillMatch{entry: entry, name: skillName, skillDir: dir, convention: "root"}
}
Expand Down Expand Up @@ -534,6 +552,7 @@ func DiscoverSkillsWithOptions(client *api.Client, host, owner, repo, commitSHA
return nil, fmt.Errorf(
"no skills found in %s/%s\n"+
" Expected skills in skills/*/SKILL.md, skills/{scope}/*/SKILL.md,\n"+
" {prefix}/skills/*/SKILL.md, {prefix}/skills/{scope}/*/SKILL.md,\n"+
" */SKILL.md, or plugins/*/skills/*/SKILL.md\n"+
" This repository may be a curated list rather than a skills publisher",
owner, repo,
Expand Down Expand Up @@ -667,18 +686,35 @@ func DiscoverSkillByPath(client *api.Client, host, owner, repo, commitSHA, skill
return nil, fmt.Errorf("no SKILL.md found in %s", skillPath)
}

var namespace string
var namespace, convention string
parts := strings.Split(skillPath, "/")
if len(parts) >= 3 && parts[0] == "skills" {
namespace = parts[1]
for i, p := range parts {
if p != "skills" {
continue
}

// Plugin convention: .../plugins/<ns>/skills/<name>
if i >= 2 && parts[i-2] == "plugins" {
namespace = parts[i-1]
convention = "plugins"
break
}

// Namespaced skill convention: .../skills/<ns>/<name>
afterSkills := parts[i+1:]
if len(afterSkills) >= 2 {
namespace = afterSkills[0]
}
break
}

skill := &Skill{
Name: skillName,
Namespace: namespace,
Path: skillPath,
BlobSHA: blobSHA,
TreeSHA: treeSHA,
Name: skillName,
Namespace: namespace,
Convention: convention,
Path: skillPath,
BlobSHA: blobSHA,
TreeSHA: treeSHA,
}

skill.Description = fetchDescription(client, host, owner, repo, skill)
Expand Down Expand Up @@ -907,7 +943,9 @@ func DiscoverLocalSkillsWithOptions(dir string, opts DiscoverOptions) ([]Skill,
return nil, fmt.Errorf(
"no skills found in %s\n"+
" Expected SKILL.md in the directory, or skills in skills/*/SKILL.md,\n"+
" skills/{scope}/*/SKILL.md, */SKILL.md, or plugins/*/skills/*/SKILL.md",
" skills/{scope}/*/SKILL.md, {prefix}/skills/*/SKILL.md,\n"+
" {prefix}/skills/{scope}/*/SKILL.md, */SKILL.md, or\n"+
" plugins/*/skills/*/SKILL.md",
dir,
)
}
Expand Down Expand Up @@ -955,6 +993,26 @@ func validateName(name string) bool {
return safeNamePattern.MatchString(name)
}

// hasHiddenSegment reports whether any path component starts with a dot.
func hasHiddenSegment(p string) bool {
for _, seg := range strings.Split(p, "/") {
if strings.HasPrefix(seg, ".") {
return true
}
}
return false
}

// hasPluginsAncestor reports whether any path component is "plugins".
func hasPluginsAncestor(p string) bool {
for _, seg := range strings.Split(p, "/") {
if seg == "plugins" {
return true
}
}
return false
}

// IsSpecCompliant checks if a skill name matches the strict agentskills.io spec.
func IsSpecCompliant(name string) bool {
if len(name) == 0 || len(name) > 64 {
Expand Down
191 changes: 185 additions & 6 deletions internal/skills/discovery/discovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,52 @@ func TestMatchSkillConventions(t *testing.T) {
path: ".hidden/SKILL.md",
wantNil: true,
},
{
name: "nested skills directory",
path: "terraform/code-generation/skills/terraform-style-guide/SKILL.md",
wantName: "terraform-style-guide",
wantConvention: "skills",
},
{
name: "deeply nested skills directory",
path: "a/b/c/skills/my-skill/SKILL.md",
wantName: "my-skill",
wantConvention: "skills",
},
{
name: "nested namespaced skills directory",
path: "terraform/code-generation/skills/hashicorp/terraform-style-guide/SKILL.md",
wantName: "terraform-style-guide",
wantNamespace: "hashicorp",
wantConvention: "skills-namespaced",
},
{
name: "single prefix before skills directory",
path: "packer/skills/packer-builder/SKILL.md",
wantName: "packer-builder",
wantConvention: "skills",
},
{
name: "root-level skills still has priority",
path: "skills/code-review/SKILL.md",
wantName: "code-review",
wantConvention: "skills",
},
{
name: "nested skills dir itself is not a skill",
path: "terraform/skills/SKILL.md",
wantNil: true,
},
{
name: "nested skills under hidden dir excluded",
path: ".claude/skills/code-review/SKILL.md",
wantNil: true,
},
{
name: "nested plugins skills not matched as plain skills",
path: "vendor/plugins/hubot/skills/pr-summary/SKILL.md",
wantNil: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down Expand Up @@ -865,6 +911,41 @@ func TestDiscoverSkills(t *testing.T) {
},
wantSkills: []string{"code-review"},
},
{
name: "discovers skills in nested skills directory",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"),
httpmock.JSONResponse(map[string]interface{}{
"sha": "abc123", "truncated": false,
"tree": []map[string]interface{}{
{"path": "terraform/code-generation/skills/terraform-style-guide", "type": "tree", "sha": "tree-sha-1"},
{"path": "terraform/code-generation/skills/terraform-style-guide/SKILL.md", "type": "blob", "sha": "blob-1"},
{"path": "terraform/code-generation/skills/terraform-test", "type": "tree", "sha": "tree-sha-2"},
{"path": "terraform/code-generation/skills/terraform-test/SKILL.md", "type": "blob", "sha": "blob-2"},
{"path": "README.md", "type": "blob", "sha": "readme"},
},
}))
},
wantSkills: []string{"terraform-style-guide", "terraform-test"},
},
{
name: "discovers mixed root-level and nested skills",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"),
httpmock.JSONResponse(map[string]interface{}{
"sha": "abc123", "truncated": false,
"tree": []map[string]interface{}{
{"path": "skills/code-review", "type": "tree", "sha": "tree-sha-1"},
{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "blob-1"},
{"path": "terraform/skills/tf-lint", "type": "tree", "sha": "tree-sha-2"},
{"path": "terraform/skills/tf-lint/SKILL.md", "type": "blob", "sha": "blob-2"},
},
}))
},
wantSkills: []string{"code-review", "tf-lint"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down Expand Up @@ -967,12 +1048,13 @@ func TestDiscoverSkillsWithOptions(t *testing.T) {

func TestDiscoverSkillByPath(t *testing.T) {
tests := []struct {
name string
skillPath string
stubs func(*httpmock.Registry)
wantName string
wantNS string
wantErr string
name string
skillPath string
stubs func(*httpmock.Registry)
wantName string
wantNS string
wantConvention string
wantErr string
}{
{
name: "discovers skill by path",
Expand Down Expand Up @@ -1112,6 +1194,84 @@ func TestDiscoverSkillByPath(t *testing.T) {
},
wantErr: "no SKILL.md found",
},
{
name: "deeply nested path discovers skill",
skillPath: "terraform/code-generation/skills/terraform-style-guide",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/terraform%2Fcode-generation%2Fskills"),
httpmock.JSONResponse([]map[string]interface{}{
{"name": "terraform-style-guide", "path": "terraform/code-generation/skills/terraform-style-guide", "sha": "tree-sha", "type": "dir"},
}))
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"),
httpmock.JSONResponse(map[string]interface{}{
"sha": "tree-sha", "truncated": false,
"tree": []map[string]interface{}{
{"path": "SKILL.md", "type": "blob", "sha": "blob-sha"},
},
}))
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"),
httpmock.JSONResponse(map[string]interface{}{
"sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==",
}))
},
wantName: "terraform-style-guide",
},
{
name: "deeply nested namespaced path sets namespace",
skillPath: "terraform/code-generation/skills/hashicorp/terraform-style-guide",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/terraform%2Fcode-generation%2Fskills%2Fhashicorp"),
httpmock.JSONResponse([]map[string]interface{}{
{"name": "terraform-style-guide", "path": "terraform/code-generation/skills/hashicorp/terraform-style-guide", "sha": "tree-sha", "type": "dir"},
}))
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"),
httpmock.JSONResponse(map[string]interface{}{
"sha": "tree-sha", "truncated": false,
"tree": []map[string]interface{}{
{"path": "SKILL.md", "type": "blob", "sha": "blob-sha"},
},
}))
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"),
httpmock.JSONResponse(map[string]interface{}{
"sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==",
}))
},
wantName: "terraform-style-guide",
wantNS: "hashicorp",
},
{
name: "plugins path sets namespace and convention",
skillPath: "plugins/hubot/skills/pr-summary",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/plugins%2Fhubot%2Fskills"),
httpmock.JSONResponse([]map[string]interface{}{
{"name": "pr-summary", "path": "plugins/hubot/skills/pr-summary", "sha": "tree-sha", "type": "dir"},
}))
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"),
httpmock.JSONResponse(map[string]interface{}{
"sha": "tree-sha", "truncated": false,
"tree": []map[string]interface{}{
{"path": "SKILL.md", "type": "blob", "sha": "blob-sha"},
},
}))
reg.Register(
httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"),
httpmock.JSONResponse(map[string]interface{}{
"sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==",
}))
},
wantName: "pr-summary",
wantNS: "hubot",
wantConvention: "plugins",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand All @@ -1131,6 +1291,9 @@ func TestDiscoverSkillByPath(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, tt.wantName, skill.Name)
assert.Equal(t, tt.wantNS, skill.Namespace)
if tt.wantConvention != "" {
assert.Equal(t, tt.wantConvention, skill.Convention)
}
})
}
}
Expand Down Expand Up @@ -1184,6 +1347,19 @@ func TestDiscoverLocalSkills(t *testing.T) {
setup: func(t *testing.T, dir string) {},
wantErr: "could not access",
},
{
name: "discovers skills in nested skills/ directory",
createDir: true,
setup: func(t *testing.T, dir string) {
t.Helper()
for _, name := range []string{"terraform-style-guide", "terraform-test"} {
skillDir := filepath.Join(dir, "terraform", "code-generation", "skills", name)
require.NoError(t, os.MkdirAll(skillDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# "+name), 0o644))
}
},
wantSkills: []string{"terraform-style-guide", "terraform-test"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down Expand Up @@ -1278,6 +1454,7 @@ func TestMatchesSkillPath(t *testing.T) {
{name: "plugins convention", path: "plugins/hubot/skills/pr-summary/SKILL.md", wantName: "pr-summary"},
{name: "non-skill file", path: "README.md", wantName: ""},
{name: "non-SKILL.md in skill dir", path: "skills/code-review/prompt.txt", wantName: ""},
{name: "nested skills convention", path: "terraform/code-generation/skills/terraform-style-guide/SKILL.md", wantName: "terraform-style-guide"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand All @@ -1300,6 +1477,8 @@ func TestMatchSkillPath(t *testing.T) {
{name: "same name different namespace 1", path: "skills/kynan/commit/SKILL.md", wantName: "commit", wantNamespace: "kynan"},
{name: "same name different namespace 2", path: "skills/will/commit/SKILL.md", wantName: "commit", wantNamespace: "will"},
{name: "root convention", path: "my-skill/SKILL.md", wantName: "my-skill", wantNamespace: ""},
{name: "nested skills convention", path: "terraform/code-generation/skills/terraform-style-guide/SKILL.md", wantName: "terraform-style-guide", wantNamespace: ""},
{name: "nested namespaced convention", path: "terraform/code-generation/skills/hashicorp/terraform-style-guide/SKILL.md", wantName: "terraform-style-guide", wantNamespace: "hashicorp"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down
7 changes: 6 additions & 1 deletion pkg/cmd/skills/install/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,9 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru
tracking metadata injected into frontmatter.

Skills are discovered automatically using the %[1]sskills/*/SKILL.md%[1]s convention
defined by the Agent Skills specification. For more information on the specification,
defined by the Agent Skills specification, including when the %[1]sskills/%[1]s
directory is nested under a prefix (e.g. %[1]sterraform/code-generation/skills/...%[1]s).
For more information on the specification,
see: https://agentskills.io/specification

The skill argument can be a name, a namespaced name (%[1]sauthor/skill%[1]s),
Expand Down Expand Up @@ -504,6 +506,9 @@ func isSkillPath(name string) bool {
if strings.HasPrefix(name, "skills/") || strings.HasPrefix(name, "plugins/") {
return true
}
if strings.Contains(name, "/skills/") || strings.Contains(name, "/plugins/") {
return true
}
return false
}

Expand Down
Loading
Loading