@@ -9,6 +9,8 @@ import (
10 "path/filepath"
10 "path/filepath"
@@ -23,7 +25,10 @@ const (
23 MaxCompatibilityLength = 500
25 MaxCompatibilityLength = 500
26−var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
29+ namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
30+ promptReplacer = strings.NewReplacer("&", "&", "<", "<", ">", ">", "\"", """, "'", "'")
28// Skill represents a parsed SKILL.md file.
33// Skill represents a parsed SKILL.md file.
29type Skill struct {
34type Skill struct {
@@ -106,19 +111,31 @@ func ParseContent(content []byte) (*Skill, error) {
107// splitFrontmatter extracts YAML frontmatter and body from markdown content.
112// splitFrontmatter extracts YAML frontmatter and body from markdown content.
108func splitFrontmatter(content string) (frontmatter, body string, err error) {
113func 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")
109 // Normalize line endings to \n for consistent parsing.
116 // Normalize line endings to \n for consistent parsing.
110 content = strings.ReplaceAll(content, "\r\n", "\n")
117 content = strings.ReplaceAll(content, "\r\n", "\n")
111− if !strings.HasPrefix(content, "---\n") {
118+ content = strings.ReplaceAll(content, "\r", "\n")
120+ lines := strings.Split(content, "\n")
121+ start := slices.IndexFunc(lines, func(line string) bool {
122+ return strings.TrimSpace(line) != ""
124+ if start == -1 || strings.TrimSpace(lines[start]) != "---" {
112 return "", "", errors.New("no YAML frontmatter found")
125 return "", "", errors.New("no YAML frontmatter found")
115− rest := strings.TrimPrefix(content, "---\n")
128+ endOffset := slices.IndexFunc(lines[start+1:], func(line string) bool {
116− before, after, ok := strings.Cut(rest, "\n---")
129+ return strings.TrimSpace(line) == "---"
131+ if endOffset == -1 {
118 return "", "", errors.New("unclosed frontmatter")
132 return "", "", errors.New("unclosed frontmatter")
134+ end := start + 1 + endOffset
136+ frontmatter = strings.Join(lines[start+1:end], "\n")
137+ body = strings.Join(lines[end+1:], "\n")
121− return before, after, nil
138+ return frontmatter, body, nil
124// Discover finds all valid skills in the given paths.
141// Discover finds all valid skills in the given paths.
@@ -136,8 +153,9 @@ func Discover(paths []string) []*Skill {
136 Follow: true,
153 Follow: true,
137 ToSlash: fastwalk.DefaultToSlash(),
154 ToSlash: fastwalk.DefaultToSlash(),
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 {
140 if err != nil {
157 if err != nil {
158+ slog.Warn("Failed to walk skills path entry", "base", base, "path", path, "error", err)
141 return nil
159 return nil
143 if d.IsDir() || d.Name() != SkillFileName {
161 if d.IsDir() || d.Name() != SkillFileName {
@@ -165,8 +183,21 @@ func Discover(paths []string) []*Skill {
165 mu.Unlock()
183 mu.Unlock()
166 return nil
184 return nil
187+ slog.Warn("Failed to walk skills path", "path", base, "error", err)
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)
196+ return skills[i].SkillFilePath < skills[j].SkillFilePath
170 return skills
201 return skills
@@ -193,8 +224,7 @@ func ToPromptXML(skills []*Skill) string {
195func escape(s string) string {
226func escape(s string) string {
196− r := strings.NewReplacer("&", "&", "<", "<", ">", ">", "\"", """, "'", "'")
197− return r.Replace(s)
227+ return promptReplacer.Replace(s)
200// Deduplicate removes duplicate skills by name. When duplicates exist, the
230// Deduplicate removes duplicate skills by name. When duplicates exist, the