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

@ -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