on main help sign in
refactor: simplify skills parsing and improve discovery visibility (#2350)
commit7a6a179ccc0fc1d5744bad3a7ccabd085a6474ca authorhuaiyuWangh <34158348+huaiyuWangh@users.noreply.github.com> dateTue 14 Apr 2026, 04:22 (6 months ago) parentca69095
2 files changed+101−9
internal/skills/skills.go+39−9 internal/skills/skills_test.go+62
internal/skills/skills.go+39−9
@@ -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("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;", "'", "&apos;")
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("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;", "'", "&apos;")
197− return r.Replace(s)
227+ return promptReplacer.Replace(s)
198228}
199229
200230// Deduplicate removes duplicate skills by name. When duplicates exist, the
internal/skills/skills_test.go+62
@@ -63,6 +63,39 @@ Instructions here.
6363 wantDesc: "A simple skill for testing.",
6464 wantInstr: "# My Skill\n\nInstructions here.",
6565 },
66+ {
67+ name: "frontmatter with utf8 bom",
68+ content: "\uFEFF---\n" +
69+ "name: bom-skill\n" +
70+ "description: Skill with bom.\n" +
71+ "---\n\n" +
72+ "# BOM Skill\n",
73+ wantName: "bom-skill",
74+ wantDesc: "Skill with bom.",
75+ wantInstr: "# BOM Skill",
76+ },
77+ {
78+ name: "frontmatter with leading blank lines",
79+ content: "\n\n---\n" +
80+ "name: blank-prefix\n" +
81+ "description: Skill with leading blank lines.\n" +
82+ "---\n\n" +
83+ "# Blank Prefix\n",
84+ wantName: "blank-prefix",
85+ wantDesc: "Skill with leading blank lines.",
86+ wantInstr: "# Blank Prefix",
87+ },
88+ {
89+ name: "frontmatter delimiter with trailing spaces",
90+ content: "--- \n" +
91+ "name: spaced-delimiter\n" +
92+ "description: Delimiter has spaces.\n" +
93+ "--- \n\n" +
94+ "# Spaced Delimiter\n",
95+ wantName: "spaced-delimiter",
96+ wantDesc: "Delimiter has spaces.",
97+ wantInstr: "# Spaced Delimiter",
98+ },
6699 {
67100 name: "no frontmatter",
68101 content: "# Just Markdown\n\nNo frontmatter here.",
@@ -217,6 +250,7 @@ description: Name doesn't match directory.
217250
218251 skills := Discover([]string{tmpDir})
219252 require.Len(t, skills, 2)
253+ require.Equal(t, []string{"skill-two", "skill-one"}, []string{skills[0].Name, skills[1].Name})
220254
221255 names := make(map[string]bool)
222256 for _, s := range skills {
@@ -248,6 +282,34 @@ func TestToPromptXMLEmpty(t *testing.T) {
248282 require.Empty(t, ToPromptXML([]*Skill{}))
249283}
250284
285+func TestEscape(t *testing.T) {
286+ t.Parallel()
287+
288+ tests := []struct {
289+ name string
290+ in string
291+ want string
292+ }{
293+ {
294+ name: "escape xml special chars",
295+ in: `<tag attr="x&y">'z'</tag>`,
296+ want: `&lt;tag attr=&quot;x&amp;y&quot;&gt;&apos;z&apos;&lt;/tag&gt;`,
297+ },
298+ {
299+ name: "plain text unchanged",
300+ in: "hello world",
301+ want: "hello world",
302+ },
303+ }
304+
305+ for _, tt := range tests {
306+ t.Run(tt.name, func(t *testing.T) {
307+ t.Parallel()
308+ require.Equal(t, tt.want, escape(tt.in))
309+ })
310+ }
311+}
312+
251313func TestToPromptXMLBuiltinType(t *testing.T) {
252314 t.Parallel()
253315