@@ -9,6 +9,8 @@ import (
99 "os"
1010 "path/filepath"
1111 "regexp"
12+ "slices"
13+ "sort"
1214 "strings"
1315 "sync"
1416
@@ -23,7 +25,10 @@ const (
2325 MaxCompatibilityLength = 500
2426)
2527
26−var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
28+var (
29+ namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
30+ promptReplacer = strings.NewReplacer("&", "&", "<", "<", ">", ">", "\"", """, "'", "'")
31+)
2732
2833// Skill represents a parsed SKILL.md file.
2934type Skill struct {
@@ -106,19 +111,31 @@ func ParseContent(content []byte) (*Skill, error) {
106111
107112// splitFrontmatter extracts YAML frontmatter and body from markdown content.
108113func splitFrontmatter(content string) (frontmatter, body string, err error) {
114+ // Strip UTF-8 BOM for compatibility with editors that include it.
115+ content = strings.TrimPrefix(content, "\uFEFF")
109116 // Normalize line endings to \n for consistent parsing.
110117 content = strings.ReplaceAll(content, "\r\n", "\n")
111− if !strings.HasPrefix(content, "---\n") {
118+ content = strings.ReplaceAll(content, "\r", "\n")
119+
120+ lines := strings.Split(content, "\n")
121+ start := slices.IndexFunc(lines, func(line string) bool {
122+ return strings.TrimSpace(line) != ""
123+ })
124+ if start == -1 || strings.TrimSpace(lines[start]) != "---" {
112125 return "", "", errors.New("no YAML frontmatter found")
113126 }
114127
115− rest := strings.TrimPrefix(content, "---\n")
116− before, after, ok := strings.Cut(rest, "\n---")
117− if !ok {
128+ endOffset := slices.IndexFunc(lines[start+1:], func(line string) bool {
129+ return strings.TrimSpace(line) == "---"
130+ })
131+ if endOffset == -1 {
118132 return "", "", errors.New("unclosed frontmatter")
119133 }
134+ end := start + 1 + endOffset
120135
121− return before, after, nil
136+ frontmatter = strings.Join(lines[start+1:end], "\n")
137+ body = strings.Join(lines[end+1:], "\n")
138+ return frontmatter, body, nil
122139}
123140
124141// Discover finds all valid skills in the given paths.
@@ -136,8 +153,9 @@ func Discover(paths []string) []*Skill {
136153 Follow: true,
137154 ToSlash: fastwalk.DefaultToSlash(),
138155 }
139− fastwalk.Walk(&conf, base, func(path string, d os.DirEntry, err error) error {
156+ err := fastwalk.Walk(&conf, base, func(path string, d os.DirEntry, err error) error {
140157 if err != nil {
158+ slog.Warn("Failed to walk skills path entry", "base", base, "path", path, "error", err)
141159 return nil
142160 }
143161 if d.IsDir() || d.Name() != SkillFileName {
@@ -165,8 +183,21 @@ func Discover(paths []string) []*Skill {
165183 mu.Unlock()
166184 return nil
167185 })
186+ if err != nil {
187+ slog.Warn("Failed to walk skills path", "path", base, "error", err)
188+ }
168189 }
169190
191+ // fastwalk traversal order is non-deterministic, so sort for stable output.
192+ sort.SliceStable(skills, func(i, j int) bool {
193+ left := strings.ToLower(skills[i].SkillFilePath)
194+ right := strings.ToLower(skills[j].SkillFilePath)
195+ if left == right {
196+ return skills[i].SkillFilePath < skills[j].SkillFilePath
197+ }
198+ return left < right
199+ })
200+
170201 return skills
171202}
172203
@@ -193,8 +224,7 @@ func ToPromptXML(skills []*Skill) string {
193224}
194225
195226func escape(s string) string {
196− r := strings.NewReplacer("&", "&", "<", "<", ">", ">", "\"", """, "'", "'")
197− return r.Replace(s)
227+ return promptReplacer.Replace(s)
198228}
199229
200230// Deduplicate removes duplicate skills by name. When duplicates exist, the