refactor(skills): use fastwalk to resolve symlinks (#1732)

Amolith created

Change summary

internal/skills/skills.go | 23 +++++++++++++++++++++--
1 file changed, 21 insertions(+), 2 deletions(-)

Detailed changes

internal/skills/skills.go 🔗

@@ -10,7 +10,9 @@ import (
 	"path/filepath"
 	"regexp"
 	"strings"
+	"sync"
 
+	"github.com/charlievieth/fastwalk"
 	"gopkg.in/yaml.v3"
 )
 
@@ -110,17 +112,32 @@ func splitFrontmatter(content string) (frontmatter, body string, err error) {
 // Discover finds all valid skills in the given paths.
 func Discover(paths []string) []*Skill {
 	var skills []*Skill
+	var mu sync.Mutex
 	seen := make(map[string]bool)
 
 	for _, base := range paths {
-		filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error {
+		// We use fastwalk with Follow: true instead of filepath.WalkDir because
+		// WalkDir doesn't follow symlinked directories at any depth—only entry
+		// points. This ensures skills in symlinked subdirectories are discovered.
+		// fastwalk is concurrent, so we protect shared state (seen, skills) with mu.
+		conf := fastwalk.Config{
+			Follow:  true,
+			ToSlash: fastwalk.DefaultToSlash(),
+		}
+		fastwalk.Walk(&conf, base, func(path string, d os.DirEntry, err error) error {
 			if err != nil {
 				return nil
 			}
-			if d.IsDir() || d.Name() != SkillFileName || seen[path] {
+			if d.IsDir() || d.Name() != SkillFileName {
+				return nil
+			}
+			mu.Lock()
+			if seen[path] {
+				mu.Unlock()
 				return nil
 			}
 			seen[path] = true
+			mu.Unlock()
 			skill, err := Parse(path)
 			if err != nil {
 				slog.Warn("Failed to parse skill file", "path", path, "error", err)
@@ -131,7 +148,9 @@ func Discover(paths []string) []*Skill {
 				return nil
 			}
 			slog.Info("Successfully loaded skill", "name", skill.Name, "path", path)
+			mu.Lock()
 			skills = append(skills, skill)
+			mu.Unlock()
 			return nil
 		})
 	}