@@ -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
1614const skillFile = "SKILL.md"
1715
1816// Skill represents a loaded skill with its metadata and content location.
1917type 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.
327301func 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+
350398func isValidSkill (skill Skill ) bool {
351399 return skill .Description != "" && skill .Name != ""
352400}
0 commit comments