Files
multica/server/internal/skill/frontmatter.go
Bohan Jiang 0f9d9d1494 fix(skills): align Go/TS frontmatter coercion for non-scalar values (#3614)
The Go SKILL.md frontmatter parser unmarshalled into a {Name,Description}
string struct, so a non-scalar value (a list/map written where a scalar
belongs) made the whole decode fail and dropped even a valid sibling
`name`. The TS parser instead kept the name and JSON-encoded the value,
so the file-viewer (TS) and the import path (Go) could disagree about
the same SKILL.md.

Decode into a generic map and coerce per key on the Go side, mirroring
the TS coercion (scalars -> literal form, sequences/mappings -> JSON), so
both sides produce identical results and a structured value never
discards a sibling key. Rename ParseFrontmatter -> ParseSkillFrontmatter
to remove the cross-language name clash with the TS parseFrontmatter
(which returns {frontmatter, body}), and drop the unused TS
parseSkillFrontmatter export.

Add parity tests for sequence/mapping values plus name-only,
description-only, leading-blank-line and triple-dash-in-body edge cases
on both sides.

Follow-up to #3543 / MUL-2842.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-01 19:42:20 +08:00

70 lines
2.2 KiB
Go

// Package skill provides shared utilities for working with SKILL.md files.
package skill
import (
"encoding/json"
"regexp"
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
// Keeping the trailing newline inside group 1 matters: yaml.v3's `|` clip
// chomping only preserves a final newline when the input itself contains one.
var frontmatterPattern = regexp.MustCompile(`(?s)\A---\r?\n(.*?\r?\n)---`)
// ParseSkillFrontmatter extracts name and description from the YAML frontmatter
// block of a SKILL.md file. Returns empty strings when the frontmatter is
// absent or malformed so callers can keep treating missing metadata as a
// non-fatal condition, matching the behaviour of the legacy line-based parser.
//
// Values are decoded into a generic map and coerced per key (scalars via their
// literal form, sequences/mappings via JSON) rather than unmarshalled into a
// string struct. This means a structured value in one field never discards a
// valid sibling key, and the coercion mirrors the TS parseFrontmatter in
// packages/core/skills/frontmatter.ts so both sides agree on the same input.
func ParseSkillFrontmatter(content string) (name, description string) {
if !strings.HasPrefix(content, "---") {
return "", ""
}
match := frontmatterPattern.FindStringSubmatch(content)
if match == nil {
return "", ""
}
var fm map[string]any
if err := yaml.Unmarshal([]byte(match[1]), &fm); err != nil {
return "", ""
}
return coerceFrontmatterValue(fm["name"]), coerceFrontmatterValue(fm["description"])
}
// coerceFrontmatterValue renders a decoded YAML value as a string, mirroring the
// TS side: nil becomes empty, strings pass through, other scalars use their
// literal form, and structured values (sequences/mappings) are JSON-encoded.
func coerceFrontmatterValue(v any) string {
switch val := v.(type) {
case nil:
return ""
case string:
return val
case bool:
return strconv.FormatBool(val)
case int:
return strconv.Itoa(val)
case int64:
return strconv.FormatInt(val, 10)
case uint64:
return strconv.FormatUint(val, 10)
case float64:
return strconv.FormatFloat(val, 'g', -1, 64)
default:
encoded, err := json.Marshal(val)
if err != nil {
return ""
}
return string(encoded)
}
}