fix: parse skill frontmatter as YAML (#25610)

This commit is contained in:
Michael Suchacz
2026-05-22 15:09:30 +02:00
committed by GitHub
parent 15ada66e14
commit bdf2698fcd
5 changed files with 223 additions and 87 deletions
+23 -35
View File
@@ -5,6 +5,7 @@ import (
"strings"
"golang.org/x/xerrors"
"gopkg.in/yaml.v3"
)
// SkillNameRegex is the regular expression used to validate kebab-case skill names.
@@ -24,32 +25,21 @@ var markdownCommentRe = regexp.MustCompile(`<!--[\s\S]*?-->`)
// the frontmatter is missing a required name field.
var ErrFrontmatterNameRequired = xerrors.New("frontmatter missing required 'name' field")
func unquoteFrontmatterScalar(value string) string {
if len(value) < 2 {
return value
func frontmatterStringField(frontmatter map[string]any, key string) (string, bool, error) {
value, ok := frontmatter[key]
if !ok {
return "", false, nil
}
quote := value[0]
if quote != value[len(value)-1] {
return value
}
inner := value[1 : len(value)-1]
switch quote {
case '"':
// This parser supports a small SKILL.md scalar subset, not full
// YAML. Double quotes only unescape quoted text and Windows paths.
return strings.NewReplacer(`\"`, `"`, `\\`, `\`).Replace(inner)
case '\'':
return inner
default:
return value
stringValue, ok := value.(string)
if !ok {
return "", true, xerrors.Errorf("frontmatter field %q must be a string", key)
}
return strings.TrimRight(stringValue, "\r\n"), true, nil
}
// ParseSkillFrontmatter extracts name, description, and the
// remaining body from a skill meta file. The expected format is
// YAML-ish frontmatter delimited by "---" lines:
// YAML frontmatter delimited by "---" lines:
//
// ---
// name: my-skill
@@ -78,25 +68,23 @@ func ParseSkillFrontmatter(content string) (name, description, body string, err
)
}
for _, line := range lines[1:closingIdx] {
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
value = unquoteFrontmatterScalar(value)
switch strings.ToLower(key) {
case "name":
name = value
case "description":
description = value
}
frontmatterContent := strings.Join(lines[1:closingIdx], "\n")
var frontmatter map[string]any
if err := yaml.Unmarshal([]byte(frontmatterContent), &frontmatter); err != nil {
return "", "", "", xerrors.Errorf("parse frontmatter YAML: %w", err)
}
if name == "" {
name, ok, err := frontmatterStringField(frontmatter, "name")
if err != nil {
return "", "", "", xerrors.Errorf("%w: %v", ErrFrontmatterNameRequired, err)
}
if !ok || name == "" {
return "", "", "", xerrors.Errorf("%w", ErrFrontmatterNameRequired)
}
description, _, err = frontmatterStringField(frontmatter, "description")
if err != nil {
return "", "", "", err
}
// Everything after the closing delimiter is the body.
body = strings.Join(lines[closingIdx+1:], "\n")
+50 -7
View File
@@ -1,6 +1,7 @@
package workspacesdk_test
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
@@ -41,13 +42,48 @@ func TestParseSkillFrontmatter(t *testing.T) {
require.Equal(t, "Review \"critical\" C:\\paths.", desc)
})
t.Run("PlainHashValue", func(t *testing.T) {
t.Run("FoldedDescription", func(t *testing.T) {
t.Parallel()
name, desc, body, err := workspacesdk.ParseSkillFrontmatter(
strings.Join([]string{
"---",
"name: brainstorming",
"description: >",
" Use before any creative work: features, components, functionality changes,",
" or behavior modifications. Turns ideas into approved designs through",
" collaborative dialog. Hard gate: no implementation action until the",
" design is presented and approved.",
"",
"---",
"Use this skill.",
}, "\n"),
)
require.NoError(t, err)
require.Equal(t, "brainstorming", name)
require.Equal(t, strings.Join([]string{
"Use before any creative work: features, components, functionality changes,",
"or behavior modifications. Turns ideas into approved designs through",
"collaborative dialog. Hard gate: no implementation action until the",
"design is presented and approved.",
}, " "), desc)
require.Equal(t, "Use this skill.", body)
})
t.Run("YAMLComments", func(t *testing.T) {
t.Parallel()
_, desc, _, err := workspacesdk.ParseSkillFrontmatter(
"---\nname: plain-hash\ndescription: Build # test\n---\nBody\n",
)
require.NoError(t, err)
require.Equal(t, "Build # test", desc)
require.Equal(t, "Build", desc)
})
t.Run("ErrorNullDescription", func(t *testing.T) {
t.Parallel()
_, _, _, err := workspacesdk.ParseSkillFrontmatter(
"---\nname: null-description\ndescription: null\n---\nBody\n",
)
require.ErrorContains(t, err, `frontmatter field "description" must be a string`)
})
t.Run("NoDescription", func(t *testing.T) {
@@ -99,14 +135,12 @@ func TestParseSkillFrontmatter(t *testing.T) {
require.Empty(t, body)
})
t.Run("CaseInsensitiveKeys", func(t *testing.T) {
t.Run("YAMLKeysAreCaseSensitive", func(t *testing.T) {
t.Parallel()
name, desc, _, err := workspacesdk.ParseSkillFrontmatter(
_, _, _, err := workspacesdk.ParseSkillFrontmatter(
"---\nName: upper\nDescription: Also upper\n---\n",
)
require.NoError(t, err)
require.Equal(t, "upper", name)
require.Equal(t, "Also upper", desc)
require.ErrorIs(t, err, workspacesdk.ErrFrontmatterNameRequired)
})
t.Run("UnknownKeysIgnored", func(t *testing.T) {
@@ -139,6 +173,15 @@ func TestParseSkillFrontmatter(t *testing.T) {
require.ErrorContains(t, err, "frontmatter missing required 'name' field")
})
t.Run("ErrorNullName", func(t *testing.T) {
t.Parallel()
_, _, _, err := workspacesdk.ParseSkillFrontmatter(
"---\nname: null\n---\nBody\n",
)
require.ErrorIs(t, err, workspacesdk.ErrFrontmatterNameRequired)
require.ErrorContains(t, err, `frontmatter field "name" must be a string`)
})
t.Run("WhitespaceAroundDelimiters", func(t *testing.T) {
t.Parallel()
name, _, _, err := workspacesdk.ParseSkillFrontmatter(