Skip to content

Commit 61a7865

Browse files
Merge pull request cli#13213 from cli/sammorrowdrums/sm-allow-hidden-dirs-flag
Add --allow-hidden-dirs flag to gh skill install
2 parents 082f15a + eaa0185 commit 61a7865

4 files changed

Lines changed: 659 additions & 14 deletions

File tree

internal/skills/discovery/discovery.go

Lines changed: 133 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ func (s Skill) DisplayName() string {
6666
return "[plugins] " + name
6767
case "root":
6868
return "[root] " + name
69+
case "hidden-dir", "hidden-dir-namespaced":
70+
return "[hidden-dir] " + name
6971
default:
7072
return name
7173
}
@@ -82,6 +84,23 @@ func (s Skill) InstallName() string {
8284
return s.Name
8385
}
8486

87+
// IsHiddenDirConvention returns true if the skill was discovered in a hidden
88+
// (dot-prefixed) directory such as .claude/skills/ or .agents/skills/.
89+
func (s Skill) IsHiddenDirConvention() bool {
90+
return s.Convention == "hidden-dir" || s.Convention == "hidden-dir-namespaced"
91+
}
92+
93+
// HasHiddenDirSkills returns true if any of the given skills were discovered
94+
// in hidden directories.
95+
func HasHiddenDirSkills(skills []Skill) bool {
96+
for _, s := range skills {
97+
if s.IsHiddenDirConvention() {
98+
return true
99+
}
100+
}
101+
return false
102+
}
103+
85104
// ResolvedRef contains the resolved git reference and its SHA.
86105
type ResolvedRef struct {
87106
Ref string // fully qualified ref (refs/heads/*, refs/tags/*) or commit SHA
@@ -393,8 +412,87 @@ func matchSkillConventions(entry treeEntry) *skillMatch {
393412
return nil
394413
}
395414

396-
// DiscoverSkills finds all skills in a repository at the given commit SHA.
415+
// matchHiddenDirConventions checks if a blob path matches a skill convention
416+
// under a hidden (dot-prefixed) root directory. These patterns mirror the
417+
// standard skills/ conventions but rooted under .{host}/skills/:
418+
//
419+
// - .{host}/skills/*/SKILL.md -> "hidden-dir"
420+
// - .{host}/skills/{scope}/*/SKILL.md -> "hidden-dir-namespaced"
421+
func matchHiddenDirConventions(entry treeEntry) *skillMatch {
422+
if path.Base(entry.Path) != "SKILL.md" {
423+
return nil
424+
}
425+
426+
// .{host}/skills/*
427+
// .{host}/skills/{scope}/*
428+
dir := path.Dir(entry.Path)
429+
skillName := path.Base(dir)
430+
431+
if !validateName(skillName) {
432+
return nil
433+
}
434+
435+
// .{host}/skills
436+
// .{host}/skills/{scope}
437+
parentDir := path.Dir(dir)
438+
439+
// .{host}/skills/*/SKILL.md
440+
if path.Base(parentDir) == "skills" {
441+
hiddenRoot := path.Dir(parentDir)
442+
if path.Dir(hiddenRoot) == "." && strings.HasPrefix(hiddenRoot, ".") {
443+
return &skillMatch{entry: entry, name: skillName, skillDir: dir, convention: "hidden-dir"}
444+
}
445+
}
446+
447+
// .{host}/skills/{scope}/*/SKILL.md
448+
grandparentDir := path.Dir(parentDir)
449+
if path.Base(grandparentDir) == "skills" {
450+
hiddenRoot := path.Dir(grandparentDir)
451+
if path.Dir(hiddenRoot) == "." && strings.HasPrefix(hiddenRoot, ".") {
452+
namespace := path.Base(parentDir)
453+
if !validateName(namespace) {
454+
return nil
455+
}
456+
return &skillMatch{entry: entry, name: skillName, namespace: namespace, skillDir: dir, convention: "hidden-dir-namespaced"}
457+
}
458+
}
459+
460+
return nil
461+
}
462+
463+
// DiscoverOptions controls optional discovery behaviors.
464+
type DiscoverOptions struct {
465+
}
466+
467+
// DiscoverSkills finds all non-hidden-dir skills in a repository at the given
468+
// commit SHA. Hidden-dir skills are excluded; use DiscoverSkillsWithOptions to
469+
// retrieve all skills including those in hidden directories.
397470
func DiscoverSkills(client *api.Client, host, owner, repo, commitSHA string) ([]Skill, error) {
471+
all, err := DiscoverSkillsWithOptions(client, host, owner, repo, commitSHA, DiscoverOptions{})
472+
if err != nil {
473+
return nil, err
474+
}
475+
var skills []Skill
476+
for _, s := range all {
477+
if !s.IsHiddenDirConvention() {
478+
skills = append(skills, s)
479+
}
480+
}
481+
if len(skills) == 0 {
482+
return nil, fmt.Errorf(
483+
"no skills found in %s/%s\n"+
484+
" Expected skills in skills/*/SKILL.md, skills/{scope}/*/SKILL.md,\n"+
485+
" */SKILL.md, or plugins/*/skills/*/SKILL.md\n"+
486+
" This repository may be a curated list rather than a skills publisher",
487+
owner, repo,
488+
)
489+
}
490+
return skills, nil
491+
}
492+
493+
// DiscoverSkillsWithOptions finds all skills in a repository at the given
494+
// commit SHA, with configurable discovery behavior.
495+
func DiscoverSkillsWithOptions(client *api.Client, host, owner, repo, commitSHA string, opts DiscoverOptions) ([]Skill, error) {
398496
apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(commitSHA))
399497
var tree treeResponse
400498
if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil {
@@ -419,6 +517,9 @@ func DiscoverSkills(client *api.Client, host, owner, repo, commitSHA string) ([]
419517
continue
420518
}
421519
m := matchSkillConventions(entry)
520+
if m == nil {
521+
m = matchHiddenDirConventions(entry)
522+
}
422523
if m == nil {
423524
continue
424525
}
@@ -703,9 +804,35 @@ func FetchBlob(client *api.Client, host, owner, repo, sha string) (string, error
703804
return string(decoded), nil
704805
}
705806

706-
// DiscoverLocalSkills finds skills in a local directory using the same
707-
// conventions as remote discovery.
807+
// DiscoverLocalSkills finds non-hidden-dir skills in a local directory using
808+
// the same conventions as remote discovery. Hidden-dir skills are excluded; use
809+
// DiscoverLocalSkillsWithOptions to retrieve all skills including those in
810+
// hidden directories.
708811
func DiscoverLocalSkills(dir string) ([]Skill, error) {
812+
all, err := DiscoverLocalSkillsWithOptions(dir, DiscoverOptions{})
813+
if err != nil {
814+
return nil, err
815+
}
816+
var skills []Skill
817+
for _, s := range all {
818+
if !s.IsHiddenDirConvention() {
819+
skills = append(skills, s)
820+
}
821+
}
822+
if len(skills) == 0 {
823+
return nil, fmt.Errorf(
824+
"no skills found in %s\n"+
825+
" Expected SKILL.md in the directory, or skills in skills/*/SKILL.md,\n"+
826+
" skills/{scope}/*/SKILL.md, */SKILL.md, or plugins/*/skills/*/SKILL.md",
827+
dir,
828+
)
829+
}
830+
return skills, nil
831+
}
832+
833+
// DiscoverLocalSkillsWithOptions finds skills in a local directory using the
834+
// same conventions as remote discovery, with configurable discovery behavior.
835+
func DiscoverLocalSkillsWithOptions(dir string, opts DiscoverOptions) ([]Skill, error) {
709836
absDir, err := filepath.Abs(dir)
710837
if err != nil {
711838
return nil, fmt.Errorf("could not resolve path: %w", err)
@@ -751,6 +878,9 @@ func DiscoverLocalSkills(dir string) ([]Skill, error) {
751878

752879
entry := treeEntry{Path: relPath, Type: "blob"}
753880
m := matchSkillConventions(entry)
881+
if m == nil {
882+
m = matchHiddenDirConventions(entry)
883+
}
754884
if m == nil {
755885
return nil
756886
}

0 commit comments

Comments
 (0)