Skip to content

Commit 5ff4a0f

Browse files
committed
Merge branch 'main' into fix-formatting
2 parents 9e120f9 + f7ec89d commit 5ff4a0f

File tree

2 files changed

+124
-5
lines changed

2 files changed

+124
-5
lines changed

pkg/skills/loader.go

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
"path/filepath"
1010
"regexp"
1111
"strings"
12+
13+
"github.com/sipeed/picoclaw/pkg/logger"
1214
)
1315

1416
var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
@@ -251,6 +253,11 @@ func (sl *SkillsLoader) BuildSkillsSummary() string {
251253
func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata {
252254
content, err := os.ReadFile(skillPath)
253255
if err != nil {
256+
logger.WarnCF("skills", "Failed to read skill metadata",
257+
map[string]interface{}{
258+
"skill_path": skillPath,
259+
"error": err.Error(),
260+
})
254261
return nil
255262
}
256263

@@ -283,10 +290,15 @@ func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata {
283290

284291
// parseSimpleYAML parses simple key: value YAML format
285292
// Example: name: github\n description: "..."
293+
// Normalizes line endings to handle \n (Unix), \r\n (Windows), and \r (classic Mac)
286294
func (sl *SkillsLoader) parseSimpleYAML(content string) map[string]string {
287295
result := make(map[string]string)
288296

289-
for _, line := range strings.Split(content, "\n") {
297+
// Normalize line endings: convert \r\n and \r to \n
298+
normalized := strings.ReplaceAll(content, "\r\n", "\n")
299+
normalized = strings.ReplaceAll(normalized, "\r", "\n")
300+
301+
for _, line := range strings.Split(normalized, "\n") {
290302
line = strings.TrimSpace(line)
291303
if line == "" || strings.HasPrefix(line, "#") {
292304
continue
@@ -306,9 +318,10 @@ func (sl *SkillsLoader) parseSimpleYAML(content string) map[string]string {
306318
}
307319

308320
func (sl *SkillsLoader) extractFrontmatter(content string) string {
309-
// (?s) enables DOTALL mode so . matches newlines
310-
// Match first ---, capture everything until next --- on its own line
311-
re := regexp.MustCompile(`(?s)^---\n(.*)\n---`)
321+
// Support \n (Unix), \r\n (Windows), and \r (classic Mac) line endings for frontmatter blocks
322+
// (?s) enables DOTALL so . matches newlines;
323+
// ^--- at start, then ... --- at start of line, honoring all three line ending types
324+
re := regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---`)
312325
match := re.FindStringSubmatch(content)
313326
if len(match) > 1 {
314327
return match[1]
@@ -317,7 +330,11 @@ func (sl *SkillsLoader) extractFrontmatter(content string) string {
317330
}
318331

319332
func (sl *SkillsLoader) stripFrontmatter(content string) string {
320-
re := regexp.MustCompile(`^---\n.*?\n---\n`)
333+
// Support \n (Unix), \r\n (Windows), and \r (classic Mac) line endings for frontmatter blocks
334+
// (?s) enables DOTALL so . matches newlines;
335+
// ^--- at start, then ... --- at start of line, honoring all three line ending types
336+
// Match zero or more trailing line endings after closing --- (handles both with and without blank lines)
337+
re := regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`)
321338
return re.ReplaceAllString(content, "")
322339
}
323340

pkg/skills/loader_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,105 @@ func TestSkillsInfoValidate(t *testing.T) {
7575
})
7676
}
7777
}
78+
79+
func TestExtractFrontmatter(t *testing.T) {
80+
sl := &SkillsLoader{}
81+
82+
testcases := []struct {
83+
name string
84+
content string
85+
expectedName string
86+
expectedDesc string
87+
lineEndingType string
88+
}{
89+
{
90+
name: "unix-line-endings",
91+
lineEndingType: "Unix (\\n)",
92+
content: "---\nname: test-skill\ndescription: A test skill\n---\n\n# Skill Content",
93+
expectedName: "test-skill",
94+
expectedDesc: "A test skill",
95+
},
96+
{
97+
name: "windows-line-endings",
98+
lineEndingType: "Windows (\\r\\n)",
99+
content: "---\r\nname: test-skill\r\ndescription: A test skill\r\n---\r\n\r\n# Skill Content",
100+
expectedName: "test-skill",
101+
expectedDesc: "A test skill",
102+
},
103+
{
104+
name: "classic-mac-line-endings",
105+
lineEndingType: "Classic Mac (\\r)",
106+
content: "---\rname: test-skill\rdescription: A test skill\r---\r\r# Skill Content",
107+
expectedName: "test-skill",
108+
expectedDesc: "A test skill",
109+
},
110+
}
111+
112+
for _, tc := range testcases {
113+
t.Run(tc.name, func(t *testing.T) {
114+
// Extract frontmatter
115+
frontmatter := sl.extractFrontmatter(tc.content)
116+
assert.NotEmpty(t, frontmatter, "Frontmatter should be extracted for %s line endings", tc.lineEndingType)
117+
118+
// Parse YAML to get name and description (parseSimpleYAML now handles all line ending types)
119+
yamlMeta := sl.parseSimpleYAML(frontmatter)
120+
assert.Equal(t, tc.expectedName, yamlMeta["name"], "Name should be correctly parsed from frontmatter with %s line endings", tc.lineEndingType)
121+
assert.Equal(t, tc.expectedDesc, yamlMeta["description"], "Description should be correctly parsed from frontmatter with %s line endings", tc.lineEndingType)
122+
})
123+
}
124+
}
125+
126+
func TestStripFrontmatter(t *testing.T) {
127+
sl := &SkillsLoader{}
128+
129+
testcases := []struct {
130+
name string
131+
content string
132+
expectedContent string
133+
lineEndingType string
134+
}{
135+
{
136+
name: "unix-line-endings",
137+
lineEndingType: "Unix (\\n)",
138+
content: "---\nname: test-skill\ndescription: A test skill\n---\n\n# Skill Content",
139+
expectedContent: "# Skill Content",
140+
},
141+
{
142+
name: "windows-line-endings",
143+
lineEndingType: "Windows (\\r\\n)",
144+
content: "---\r\nname: test-skill\r\ndescription: A test skill\r\n---\r\n\r\n# Skill Content",
145+
expectedContent: "# Skill Content",
146+
},
147+
{
148+
name: "classic-mac-line-endings",
149+
lineEndingType: "Classic Mac (\\r)",
150+
content: "---\rname: test-skill\rdescription: A test skill\r---\r\r# Skill Content",
151+
expectedContent: "# Skill Content",
152+
},
153+
{
154+
name: "unix-line-endings-without-trailing-newline",
155+
lineEndingType: "Unix (\\n) without trailing newline",
156+
content: "---\nname: test-skill\ndescription: A test skill\n---\n# Skill Content",
157+
expectedContent: "# Skill Content",
158+
},
159+
{
160+
name: "windows-line-endings-without-trailing-newline",
161+
lineEndingType: "Windows (\\r\\n) without trailing newline",
162+
content: "---\r\nname: test-skill\r\ndescription: A test skill\r\n---\r\n# Skill Content",
163+
expectedContent: "# Skill Content",
164+
},
165+
{
166+
name: "no-frontmatter",
167+
lineEndingType: "No frontmatter",
168+
content: "# Skill Content\n\nSome content here.",
169+
expectedContent: "# Skill Content\n\nSome content here.",
170+
},
171+
}
172+
173+
for _, tc := range testcases {
174+
t.Run(tc.name, func(t *testing.T) {
175+
result := sl.stripFrontmatter(tc.content)
176+
assert.Equal(t, tc.expectedContent, result, "Frontmatter should be stripped correctly for %s", tc.lineEndingType)
177+
})
178+
}
179+
}

0 commit comments

Comments
 (0)