Skip to content

Commit ed4f2f9

Browse files
committed
Fix skill frontmatter parsing when description contains a colon
Replace the YAML parser in parseFrontmatter with simple line-by-line parsing that splits on the first ": ". The YAML parser rejected unquoted colons in values (e.g. "Usage: run this"), which caused skills with such descriptions to silently fail to load. Also simplify the Skill struct by removing unused yaml struct tags and the dead stringOrList type, since YAML unmarshaling is no longer used for local skill frontmatter. Fixes #2393 Assisted-By: docker-agent
1 parent e10701a commit ed4f2f9

2 files changed

Lines changed: 117 additions & 50 deletions

File tree

pkg/skills/skills.go

Lines changed: 93 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,24 @@ import (
88
"slices"
99
"strings"
1010

11-
"github.com/goccy/go-yaml"
12-
1311
"github.com/docker/docker-agent/pkg/paths"
1412
)
1513

1614
const skillFile = "SKILL.md"
1715

1816
// Skill represents a loaded skill with its metadata and content location.
1917
type Skill struct {
20-
Name string `yaml:"name"`
21-
Description string `yaml:"description"`
22-
FilePath string `yaml:"-"`
23-
BaseDir string `yaml:"-"`
24-
Files []string `yaml:"-"`
25-
Local bool `yaml:"-"` // true for filesystem-loaded skills, false for remote
26-
License string `yaml:"license"`
27-
Compatibility string `yaml:"compatibility"`
28-
Metadata map[string]string `yaml:"metadata"`
29-
AllowedTools stringOrList `yaml:"allowed-tools"`
30-
Context string `yaml:"context"` // "fork" to run the skill as an isolated sub-agent
18+
Name string
19+
Description string
20+
FilePath string
21+
BaseDir string
22+
Files []string
23+
Local bool // true for filesystem-loaded skills, false for remote
24+
License string
25+
Compatibility string
26+
Metadata map[string]string
27+
AllowedTools []string
28+
Context string // "fork" to run the skill as an isolated sub-agent
3129
}
3230

3331
// IsFork returns true when the skill should be executed in an isolated
@@ -37,33 +35,6 @@ func (s *Skill) IsFork() bool {
3735
return s.Context == "fork"
3836
}
3937

40-
// stringOrList is a []string that can be unmarshalled from either a YAML list
41-
// or a single comma-separated string (e.g. "Read, Grep").
42-
type stringOrList []string
43-
44-
func (s *stringOrList) UnmarshalYAML(unmarshal func(any) error) error {
45-
var list []string
46-
if err := unmarshal(&list); err == nil {
47-
*s = list
48-
return nil
49-
}
50-
51-
var single string
52-
if err := unmarshal(&single); err != nil {
53-
return err
54-
}
55-
56-
parts := strings.Split(single, ",")
57-
result := make([]string, 0, len(parts))
58-
for _, p := range parts {
59-
if t := strings.TrimSpace(p); t != "" {
60-
result = append(result, t)
61-
}
62-
}
63-
*s = result
64-
return nil
65-
}
66-
6738
// Load discovers and loads skills from the given sources.
6839
// Each source is either "local" (for filesystem-based skills) or an HTTP/HTTPS URL
6940
// (for remote skills per the well-known skills discovery spec).
@@ -322,8 +293,11 @@ func loadSkillFile(path, dirName string) (Skill, bool) {
322293
return skill, true
323294
}
324295

325-
// parseFrontmatter extracts YAML frontmatter from a markdown file.
326-
// Returns the parsed Skill and whether parsing was successful.
296+
// parseFrontmatter extracts and parses the YAML-like frontmatter from a
297+
// markdown file. Instead of using a full YAML parser (which rejects unquoted
298+
// colons in values), we do simple line-by-line key: value splitting on the
299+
// first ": ". This is more robust for the simple frontmatter format used by
300+
// skill files.
327301
func parseFrontmatter(content string) (Skill, bool) {
328302
content = strings.ReplaceAll(content, "\r\n", "\n")
329303
content = strings.ReplaceAll(content, "\r", "\n")
@@ -337,16 +311,90 @@ func parseFrontmatter(content string) (Skill, bool) {
337311
return Skill{}, false
338312
}
339313

340-
frontmatterBlock := content[4 : endIndex+3]
314+
block := content[4 : endIndex+3]
315+
lines := strings.Split(block, "\n")
341316

342317
var skill Skill
343-
if err := yaml.Unmarshal([]byte(frontmatterBlock), &skill); err != nil {
344-
return Skill{}, false
318+
var currentKey string // tracks multi-line keys like "metadata" or "allowed-tools"
319+
320+
for _, line := range lines {
321+
// Indented lines belong to the current multi-line key.
322+
if line != "" && (line[0] == ' ' || line[0] == '\t') {
323+
trimmed := strings.TrimSpace(line)
324+
switch currentKey {
325+
case "metadata":
326+
if k, v, ok := splitKeyValue(trimmed); ok {
327+
if skill.Metadata == nil {
328+
skill.Metadata = make(map[string]string)
329+
}
330+
skill.Metadata[k] = unquote(v)
331+
}
332+
case "allowed-tools":
333+
if strings.HasPrefix(trimmed, "- ") {
334+
skill.AllowedTools = append(skill.AllowedTools, unquote(strings.TrimSpace(trimmed[2:])))
335+
}
336+
}
337+
continue
338+
}
339+
340+
currentKey = ""
341+
key, value, ok := splitKeyValue(line)
342+
if !ok {
343+
continue
344+
}
345+
346+
switch key {
347+
case "name":
348+
skill.Name = unquote(value)
349+
case "description":
350+
skill.Description = unquote(value)
351+
case "license":
352+
skill.License = unquote(value)
353+
case "compatibility":
354+
skill.Compatibility = unquote(value)
355+
case "context":
356+
skill.Context = unquote(value)
357+
case "metadata":
358+
currentKey = "metadata"
359+
case "allowed-tools":
360+
if value != "" {
361+
// Inline comma-separated list.
362+
for item := range strings.SplitSeq(value, ",") {
363+
if t := unquote(strings.TrimSpace(item)); t != "" {
364+
skill.AllowedTools = append(skill.AllowedTools, t)
365+
}
366+
}
367+
} else {
368+
currentKey = "allowed-tools"
369+
}
370+
}
345371
}
346372

347373
return skill, true
348374
}
349375

376+
// splitKeyValue splits a line on the first ": " into key and value.
377+
func splitKeyValue(line string) (string, string, bool) {
378+
if key, value, ok := strings.Cut(line, ": "); ok {
379+
return key, value, true
380+
}
381+
// Handle "key:" with no value (e.g. "metadata:").
382+
if strings.HasSuffix(line, ":") {
383+
return line[:len(line)-1], "", true
384+
}
385+
return "", "", false
386+
}
387+
388+
// unquote strips matching surrounding quotes from a string value.
389+
func unquote(s string) string {
390+
if len(s) >= 2 {
391+
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
392+
return s[1 : len(s)-1]
393+
}
394+
}
395+
return s
396+
}
397+
350398
func isValidSkill(skill Skill) bool {
351399
return skill.Description != "" && skill.Name != ""
352400
}

pkg/skills/skills_test.go

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ Body`,
8989
License: "Apache-2.0",
9090
Compatibility: "Requires docker and git",
9191
Metadata: map[string]string{"author": "test-org", "version": "1.0"},
92-
AllowedTools: stringOrList{"Bash(git:*)", "Read", "Write"},
92+
AllowedTools: []string{"Bash(git:*)", "Read", "Write"},
9393
},
9494
wantOK: true,
9595
},
@@ -105,7 +105,7 @@ Body`,
105105
want: Skill{
106106
Name: "csv-skill",
107107
Description: "Skill with comma-separated allowed tools",
108-
AllowedTools: stringOrList{"Read", "Grep", "Write"},
108+
AllowedTools: []string{"Read", "Grep", "Write"},
109109
},
110110
wantOK: true,
111111
},
@@ -121,7 +121,7 @@ Body`,
121121
want: Skill{
122122
Name: "single-tool-skill",
123123
Description: "Skill with a single allowed tool",
124-
AllowedTools: stringOrList{"Read"},
124+
AllowedTools: []string{"Read"},
125125
},
126126
wantOK: true,
127127
},
@@ -155,7 +155,26 @@ Body`,
155155
Name: "scoped-fork",
156156
Description: "Fork skill with tool restrictions",
157157
Context: "fork",
158-
AllowedTools: stringOrList{"Read", "Grep"},
158+
AllowedTools: []string{"Read", "Grep"},
159+
},
160+
wantOK: true,
161+
},
162+
{
163+
name: "allowed-tools list with quoted items",
164+
content: "---\nname: quoted-tools\ndescription: Skill with quoted tool items\nallowed-tools:\n - \"Bash(git:*)\"\n - 'Read'\n---\n\nBody",
165+
want: Skill{
166+
Name: "quoted-tools",
167+
Description: "Skill with quoted tool items",
168+
AllowedTools: []string{"Bash(git:*)", "Read"},
169+
},
170+
wantOK: true,
171+
},
172+
{
173+
name: "colon in description value",
174+
content: "---\nname: node-webapp-scaffold\ndescription: scaffold a minimal Node.js project. Usage: run this\n---\n\nBody",
175+
want: Skill{
176+
Name: "node-webapp-scaffold",
177+
Description: "scaffold a minimal Node.js project. Usage: run this",
159178
},
160179
wantOK: true,
161180
},
@@ -292,7 +311,7 @@ allowed-tools:
292311
assert.Equal(t, "Apache-2.0", skills[0].License)
293312
assert.Equal(t, "Requires docker", skills[0].Compatibility)
294313
assert.Equal(t, map[string]string{"author": "test-org", "version": "2.0"}, skills[0].Metadata)
295-
assert.Equal(t, stringOrList{"Bash(git:*)", "Read"}, skills[0].AllowedTools)
314+
assert.Equal(t, []string{"Bash(git:*)", "Read"}, skills[0].AllowedTools)
296315
}
297316

298317
func TestLoadSkillsFromDir_NonExistentDir(t *testing.T) {

0 commit comments

Comments
 (0)