diff --git a/skill/skill.go b/skill/skill.go index 7d48cfc..d55663e 100644 --- a/skill/skill.go +++ b/skill/skill.go @@ -16,12 +16,12 @@ var _ yaml.Unmarshaler = (*AllowedTools)(nil) // Frontmatter represents the parsed YAML frontmatter of a SKILL.md file. type Frontmatter struct { - Name string `yaml:"name"` - Description string `yaml:"description"` - License string `yaml:"license"` - Compatibility string `yaml:"compatibility"` - Metadata map[string]string `yaml:"metadata"` - AllowedTools AllowedTools `yaml:"allowed-tools"` + Name string `yaml:"name"` + Description string `yaml:"description"` + License string `yaml:"license"` + Compatibility string `yaml:"compatibility"` + Metadata map[string]any `yaml:"metadata"` + AllowedTools AllowedTools `yaml:"allowed-tools"` } // AllowedTools handles the type ambiguity in the allowed-tools field. diff --git a/skill/skill_test.go b/skill/skill_test.go index ad7ef16..972985e 100644 --- a/skill/skill_test.go +++ b/skill/skill_test.go @@ -237,6 +237,28 @@ func TestLoad(t *testing.T) { } }) + t.Run("metadata with sequence value parses without error", func(t *testing.T) { + dir := t.TempDir() + content := "---\nname: test\ndescription: desc\nmetadata:\n author: alice\n tags: [foo, bar, baz]\n---\nBody\n" + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + s, err := Load(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if s.Frontmatter.Metadata["author"] != "alice" { + t.Errorf("metadata[author] = %v, want %q", s.Frontmatter.Metadata["author"], "alice") + } + tags, ok := s.Frontmatter.Metadata["tags"].([]any) + if !ok { + t.Fatalf("metadata[tags] = %T, want []any", s.Frontmatter.Metadata["tags"]) + } + if len(tags) != 3 { + t.Errorf("metadata[tags] len = %d, want 3", len(tags)) + } + }) + t.Run("metadata parsing", func(t *testing.T) { dir := t.TempDir() content := "---\nname: test\ndescription: desc\nmetadata:\n author: alice\n version: \"1.0\"\n---\nBody\n" @@ -248,10 +270,10 @@ func TestLoad(t *testing.T) { t.Fatalf("unexpected error: %v", err) } if s.Frontmatter.Metadata["author"] != "alice" { - t.Errorf("metadata[author] = %q, want %q", s.Frontmatter.Metadata["author"], "alice") + t.Errorf("metadata[author] = %v, want %q", s.Frontmatter.Metadata["author"], "alice") } if s.Frontmatter.Metadata["version"] != "1.0" { - t.Errorf("metadata[version] = %q, want %q", s.Frontmatter.Metadata["version"], "1.0") + t.Errorf("metadata[version] = %v, want %q", s.Frontmatter.Metadata["version"], "1.0") } }) } diff --git a/structure/frontmatter.go b/structure/frontmatter.go index 83ffa05..08a4e06 100644 --- a/structure/frontmatter.go +++ b/structure/frontmatter.go @@ -68,20 +68,23 @@ func CheckFrontmatter(s *skill.Skill, opts Options) []types.Result { // Check optional metadata if s.RawFrontmatter["metadata"] != nil { - // Verify it's a map[string]string + // Verify it's a map whose values are non-nil, non-empty if m, ok := s.RawFrontmatter["metadata"].(map[string]any); ok { - allStrings := true + valid := true for k, v := range m { - if _, ok := v.(string); !ok { - results = append(results, ctx.Errorf("metadata[%q] value must be a string", k)) - allStrings = false + if v == nil { + results = append(results, ctx.Errorf("metadata[%q] value must not be null", k)) + valid = false + } else if s, ok := v.(string); ok && s == "" { + results = append(results, ctx.Errorf("metadata[%q] value must not be empty", k)) + valid = false } } - if allStrings { + if valid { results = append(results, ctx.Passf("metadata: (%d entries)", len(m))) } } else { - results = append(results, ctx.Error("metadata must be a map of string keys to string values")) + results = append(results, ctx.Error("metadata must be a map")) } } diff --git a/structure/frontmatter_test.go b/structure/frontmatter_test.go index 6b96866..d9e6903 100644 --- a/structure/frontmatter_test.go +++ b/structure/frontmatter_test.go @@ -373,20 +373,56 @@ func TestCheckFrontmatter_Metadata(t *testing.T) { requireResultContaining(t, results, types.Pass, "metadata: (2 entries)") }) - t.Run("metadata with non-string value", func(t *testing.T) { + t.Run("metadata with integer value", func(t *testing.T) { s := makeSkill("/tmp/my-skill", "my-skill", "desc") s.RawFrontmatter["metadata"] = map[string]any{ "count": 42, } results := CheckFrontmatter(s, Options{}) - requireResultContaining(t, results, types.Error, "metadata[\"count\"] value must be a string") + requireResultContaining(t, results, types.Pass, "metadata: (1 entries)") + }) + + t.Run("metadata with float value", func(t *testing.T) { + s := makeSkill("/tmp/my-skill", "my-skill", "desc") + s.RawFrontmatter["metadata"] = map[string]any{ + "score": 3.14, + } + results := CheckFrontmatter(s, Options{}) + requireResultContaining(t, results, types.Pass, "metadata: (1 entries)") + }) + + t.Run("metadata with sequence value", func(t *testing.T) { + s := makeSkill("/tmp/my-skill", "my-skill", "desc") + s.RawFrontmatter["metadata"] = map[string]any{ + "tags": []any{"foo", "bar"}, + } + results := CheckFrontmatter(s, Options{}) + requireResultContaining(t, results, types.Pass, "metadata: (1 entries)") + }) + + t.Run("metadata with null value", func(t *testing.T) { + s := makeSkill("/tmp/my-skill", "my-skill", "desc") + s.RawFrontmatter["metadata"] = map[string]any{ + "key": nil, + } + results := CheckFrontmatter(s, Options{}) + requireResultContaining(t, results, types.Error, "metadata[\"key\"] value must not be null") + }) + + t.Run("metadata with empty string value", func(t *testing.T) { + s := makeSkill("/tmp/my-skill", "my-skill", "desc") + s.RawFrontmatter["metadata"] = map[string]any{ + "key": "", + } + results := CheckFrontmatter(s, Options{}) + requireResultContaining(t, results, types.Error, "metadata[\"key\"] value must not be empty") }) t.Run("metadata not a map", func(t *testing.T) { s := makeSkill("/tmp/my-skill", "my-skill", "desc") s.RawFrontmatter["metadata"] = "not a map" results := CheckFrontmatter(s, Options{}) - requireResult(t, results, types.Error, "metadata must be a map of string keys to string values") + requireResult(t, results, types.Error, "metadata must be a map") }) }