Merge pull request #13235 from cli/sammorrowdrums/fix-skill-install-discovery

Make skill discovery less strict: support nested `skills/` directories
This commit is contained in:
Sam Morrow 2026-04-21 10:01:53 +02:00 committed by GitHub
commit a67f4f7303
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 313 additions and 17 deletions

View file

@ -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"}
}
@ -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,
@ -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)
@ -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,
)
}
@ -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 {

View file

@ -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) {
@ -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) {
@ -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",
@ -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) {
@ -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)
}
})
}
}
@ -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) {
@ -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) {
@ -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) {

View file

@ -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),
@ -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
}

View file

@ -5,6 +5,7 @@ import (
"encoding/base64"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
@ -231,7 +232,7 @@ func stubSkillByPath(reg *httpmock.Registry, owner, repo, sha, skillPath, skillN
parentPath = skillPath[:idx]
}
reg.Register(
httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/contents/%s", owner, repo, parentPath)),
httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/contents/%s", owner, repo, url.PathEscape(parentPath))),
httpmock.StringResponse(fmt.Sprintf(`[{"name": %q, "path": %q, "sha": %q, "type": "dir"}]`, skillName, skillPath, treeSHA)),
)
}
@ -759,6 +760,34 @@ func TestInstallRun(t *testing.T) {
},
wantStdout: "Installed git-commit",
},
{
name: "remote install by nested skill path skips full discovery",
isTTY: true,
stubs: func(reg *httpmock.Registry) {
stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123")
stubSkillByPath(reg, "monalisa", "skills-repo", "abc123",
"terraform/code-generation/skills/terraform-style-guide", "terraform-style-guide", "treeSHA")
// DiscoverSkillByPath: tree + blob (for fetchDescription)
stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent)
// installer.Install: tree + blob (again, for writing files)
stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent)
},
opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions {
t.Helper()
return &InstallOptions{
IO: ios,
HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil },
GitClient: &git.Client{RepoDir: t.TempDir()},
SkillSource: "monalisa/skills-repo",
SkillName: "terraform/code-generation/skills/terraform-style-guide",
Agent: "github-copilot",
Scope: "project",
ScopeChanged: true,
Dir: t.TempDir(),
}
},
wantStdout: "Installed terraform-style-guide",
},
{
name: "remote install with URL repo argument",
isTTY: true,
@ -2075,6 +2104,31 @@ func TestRunLocalInstall(t *testing.T) {
}
}
func Test_isSkillPath(t *testing.T) {
tests := []struct {
name string
path string
want bool
}{
{name: "empty string", path: "", want: false},
{name: "plain skill name", path: "git-commit", want: false},
{name: "SKILL.md at root", path: "SKILL.md", want: true},
{name: "SKILL.md suffix", path: "skills/code-review/SKILL.md", want: true},
{name: "starts with skills/", path: "skills/code-review", want: true},
{name: "starts with plugins/", path: "plugins/hubot/skills/pr-summary", want: true},
{name: "nested skills/ path", path: "terraform/code-generation/skills/terraform-style-guide", want: true},
{name: "deeply nested skills/ path", path: "a/b/c/skills/my-skill", want: true},
{name: "nested plugins/ path", path: "vendor/plugins/hubot/skills/pr-summary", want: true},
{name: "name containing skills substring", path: "myskills", want: false},
{name: "namespaced path", path: "skills/monalisa/issue-triage", want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, isSkillPath(tt.path))
})
}
}
func Test_printReviewHint(t *testing.T) {
tests := []struct {
name string