Skip to content

Commit 5cdb6dd

Browse files
authored
Merge pull request #2401 from dgageot/board/fix-docker-agent-issue-2393endtml-36255625
Fix skill frontmatter parsing when description contains a colon
2 parents 72717f0 + ed4f2f9 commit 5cdb6dd

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)