Skip to content

Commit 9aaef21

Browse files
spboyerCopilot
andcommitted
fix: discover skills under .github/skills/ directory
Closes #52 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7d033df commit 9aaef21

4 files changed

Lines changed: 179 additions & 11 deletions

File tree

internal/discovery/discovery.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ func Discover(root string) ([]DiscoveredSkill, error) {
4646
return nil // skip inaccessible entries
4747
}
4848

49-
// Skip hidden directories
50-
if info.IsDir() && strings.HasPrefix(info.Name(), ".") {
49+
// Skip hidden directories, except .github
50+
if info.IsDir() && strings.HasPrefix(info.Name(), ".") && info.Name() != ".github" {
5151
return filepath.SkipDir
5252
}
5353

internal/discovery/discovery_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,3 +229,55 @@ func TestFilterWithEval(t *testing.T) {
229229
t.Errorf("expected no-eval, got %s", withoutEval[0].Name)
230230
}
231231
}
232+
233+
func TestDiscoverGitHubSkillsDir(t *testing.T) {
234+
// Discover() finds skills under .github/skills/
235+
root := t.TempDir()
236+
237+
setupSkillDir(t, filepath.Join(root, ".github", "skills", "github-skill"))
238+
setupEvalFile(t, filepath.Join(root, ".github", "skills", "github-skill", "eval.yaml"))
239+
240+
skills, err := Discover(root)
241+
if err != nil {
242+
t.Fatal(err)
243+
}
244+
245+
if len(skills) != 1 {
246+
t.Fatalf("expected 1 skill, got %d", len(skills))
247+
}
248+
if skills[0].Name != "github-skill" {
249+
t.Errorf("expected github-skill, got %s", skills[0].Name)
250+
}
251+
if !skills[0].HasEval() {
252+
t.Error("github-skill should have eval")
253+
}
254+
}
255+
256+
func TestDiscoverOtherHiddenDirsStillSkipped(t *testing.T) {
257+
// .github is exempted, but other hidden dirs (.hidden, .secret) are still skipped
258+
root := t.TempDir()
259+
260+
// .github/skills/ should be found
261+
setupSkillDir(t, filepath.Join(root, ".github", "skills", "github-skill"))
262+
setupEvalFile(t, filepath.Join(root, ".github", "skills", "github-skill", "eval.yaml"))
263+
264+
// .hidden/ should be skipped
265+
setupSkillDir(t, filepath.Join(root, ".hidden", "secret-skill"))
266+
setupEvalFile(t, filepath.Join(root, ".hidden", "secret-skill", "eval.yaml"))
267+
268+
// .secret/ should be skipped
269+
setupSkillDir(t, filepath.Join(root, ".secret", "another-skill"))
270+
setupEvalFile(t, filepath.Join(root, ".secret", "another-skill", "eval.yaml"))
271+
272+
skills, err := Discover(root)
273+
if err != nil {
274+
t.Fatal(err)
275+
}
276+
277+
if len(skills) != 1 {
278+
t.Fatalf("expected 1 skill (.github only), got %d", len(skills))
279+
}
280+
if skills[0].Name != "github-skill" {
281+
t.Errorf("expected github-skill, got %s", skills[0].Name)
282+
}
283+
}

internal/workspace/workspace.go

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -118,20 +118,38 @@ func DetectContext(dir string, opts ...DetectOption) (*WorkspaceContext, error)
118118

119119
// 3. Check for configured skills subdirectory with SKILL.md children
120120
skillsDir := filepath.Join(absDir, o.skillsDir)
121+
var skills []SkillInfo
121122
if isDir(skillsDir) {
122-
skills := scanForSkills(skillsDir)
123-
if len(skills) > 0 {
124-
return &WorkspaceContext{
125-
Type: ContextMultiSkill,
126-
Root: absDir,
127-
Skills: skills,
128-
EvalsDir: o.evalsDir,
129-
}, nil
123+
skills = scanForSkills(skillsDir)
124+
}
125+
126+
// 3b. Also check .github/skills/ directory (GitHub Copilot convention)
127+
githubSkillsDir := filepath.Join(absDir, ".github", "skills")
128+
if isDir(githubSkillsDir) {
129+
githubSkills := scanForSkills(githubSkillsDir)
130+
// Merge with configured skills, deduplicating by name (configured wins)
131+
existingNames := make(map[string]bool)
132+
for _, s := range skills {
133+
existingNames[s.Name] = true
130134
}
135+
for _, s := range githubSkills {
136+
if !existingNames[s.Name] {
137+
skills = append(skills, s)
138+
}
139+
}
140+
}
141+
142+
if len(skills) > 0 {
143+
return &WorkspaceContext{
144+
Type: ContextMultiSkill,
145+
Root: absDir,
146+
Skills: skills,
147+
EvalsDir: o.evalsDir,
148+
}, nil
131149
}
132150

133151
// 4. Scan immediate children of dir for SKILL.md
134-
skills := scanForSkills(absDir)
152+
skills = scanForSkills(absDir)
135153
if len(skills) > 0 {
136154
return &WorkspaceContext{
137155
Type: ContextMultiSkill,

internal/workspace/workspace_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,3 +426,101 @@ func TestFindEval_WithCustomEvalsDir(t *testing.T) {
426426
t.Errorf("expected %q, got %q", expected, evalPath)
427427
}
428428
}
429+
430+
func TestDetectContext_GitHubSkillsDir(t *testing.T) {
431+
// Skills in .github/skills/ are auto-discovered
432+
root := t.TempDir()
433+
writeFile(t, filepath.Join(root, ".github", "skills", "github-skill", "SKILL.md"), skillMD("github-skill"))
434+
435+
ctx, err := DetectContext(root)
436+
if err != nil {
437+
t.Fatalf("unexpected error: %v", err)
438+
}
439+
if ctx.Type != ContextMultiSkill {
440+
t.Fatalf("expected ContextMultiSkill, got %d", ctx.Type)
441+
}
442+
if len(ctx.Skills) != 1 {
443+
t.Fatalf("expected 1 skill, got %d", len(ctx.Skills))
444+
}
445+
if ctx.Skills[0].Name != "github-skill" {
446+
t.Errorf("expected 'github-skill', got %q", ctx.Skills[0].Name)
447+
}
448+
}
449+
450+
func TestDetectContext_BothSkillsDirs(t *testing.T) {
451+
// Skills in both skills/ and .github/skills/ are merged
452+
root := t.TempDir()
453+
writeFile(t, filepath.Join(root, "skills", "regular-skill", "SKILL.md"), skillMD("regular-skill"))
454+
writeFile(t, filepath.Join(root, ".github", "skills", "github-skill", "SKILL.md"), skillMD("github-skill"))
455+
456+
ctx, err := DetectContext(root)
457+
if err != nil {
458+
t.Fatalf("unexpected error: %v", err)
459+
}
460+
if ctx.Type != ContextMultiSkill {
461+
t.Fatalf("expected ContextMultiSkill, got %d", ctx.Type)
462+
}
463+
if len(ctx.Skills) != 2 {
464+
t.Fatalf("expected 2 skills, got %d", len(ctx.Skills))
465+
}
466+
467+
names := map[string]bool{}
468+
for _, s := range ctx.Skills {
469+
names[s.Name] = true
470+
}
471+
if !names["regular-skill"] || !names["github-skill"] {
472+
t.Errorf("expected skills regular-skill and github-skill, got %v", names)
473+
}
474+
}
475+
476+
func TestDetectContext_GitHubSkillsDirDedup(t *testing.T) {
477+
// Same skill name in both dirs: configured skills/ wins
478+
root := t.TempDir()
479+
writeFile(t, filepath.Join(root, "skills", "shared-name", "SKILL.md"), skillMD("shared-name"))
480+
writeFile(t, filepath.Join(root, ".github", "skills", "shared-name", "SKILL.md"), skillMD("shared-name"))
481+
482+
ctx, err := DetectContext(root)
483+
if err != nil {
484+
t.Fatalf("unexpected error: %v", err)
485+
}
486+
if ctx.Type != ContextMultiSkill {
487+
t.Fatalf("expected ContextMultiSkill, got %d", ctx.Type)
488+
}
489+
if len(ctx.Skills) != 1 {
490+
t.Fatalf("expected 1 skill (deduped), got %d", len(ctx.Skills))
491+
}
492+
if ctx.Skills[0].Name != "shared-name" {
493+
t.Errorf("expected 'shared-name', got %q", ctx.Skills[0].Name)
494+
}
495+
// Verify that skills/ directory won (not .github/skills/)
496+
expectedDir := filepath.Join(root, "skills", "shared-name")
497+
if ctx.Skills[0].Dir != expectedDir {
498+
t.Errorf("expected dir %q (configured should win), got %q", expectedDir, ctx.Skills[0].Dir)
499+
}
500+
}
501+
502+
func TestDetectContext_GitHubSkillsDirWithCustomOverride(t *testing.T) {
503+
// Custom paths.skills + .github/skills/ both work
504+
root := t.TempDir()
505+
writeFile(t, filepath.Join(root, "my-skills", "custom-skill", "SKILL.md"), skillMD("custom-skill"))
506+
writeFile(t, filepath.Join(root, ".github", "skills", "github-skill", "SKILL.md"), skillMD("github-skill"))
507+
508+
ctx, err := DetectContext(root, WithSkillsDir("my-skills"))
509+
if err != nil {
510+
t.Fatalf("unexpected error: %v", err)
511+
}
512+
if ctx.Type != ContextMultiSkill {
513+
t.Fatalf("expected ContextMultiSkill, got %d", ctx.Type)
514+
}
515+
if len(ctx.Skills) != 2 {
516+
t.Fatalf("expected 2 skills, got %d", len(ctx.Skills))
517+
}
518+
519+
names := map[string]bool{}
520+
for _, s := range ctx.Skills {
521+
names[s.Name] = true
522+
}
523+
if !names["custom-skill"] || !names["github-skill"] {
524+
t.Errorf("expected skills custom-skill and github-skill, got %v", names)
525+
}
526+
}

0 commit comments

Comments
 (0)