From 394ded783668cd65afb9ac70a63fff353094d22b Mon Sep 17 00:00:00 2001 From: Luan Vu Date: Sat, 21 Mar 2026 08:58:21 +0700 Subject: [PATCH] fix(skills): parse YAML multiline blocks and lists in skill frontmatter (#325) * fix(skills): parse YAML multiline blocks and lists in skill frontmatter parseSimpleYAML and ParseSkillFrontmatter only handled single-line key: value pairs. YAML multiline block scalars (|, >) and list values (- item) were parsed incorrectly: description: | Multi-line text here... was stored as description="|" (literal pipe character), and indented continuation lines were misinterpreted as new key-value pairs. Fix: parseSimpleYAML now tracks indented continuation lines after block scalar indicators (|, >, |-, >-) and empty-value keys (for list blocks). Continuation lines are joined with spaces. List items (- value) have the prefix stripped. ParseSkillFrontmatter now delegates to parseSimpleYAML instead of duplicating the parsing logic. * fix(skills): preserve empty-value keys in parseSimpleYAML When a key has no value and no indented continuation (e.g. "slug:"), flushBlock now stores "" instead of silently dropping the key. Maintains backward compatibility with the previous parser behavior. --------- Co-authored-by: Luvu182 <208665161+Luvu182@users.noreply.github.com> Co-authored-by: viettranx --- internal/skills/helpers.go | 28 +++------------ internal/skills/loader.go | 73 +++++++++++++++++++++++++++++++++----- 2 files changed, 68 insertions(+), 33 deletions(-) diff --git a/internal/skills/helpers.go b/internal/skills/helpers.go index b57e73e2..1277a1db 100644 --- a/internal/skills/helpers.go +++ b/internal/skills/helpers.go @@ -21,30 +21,10 @@ func ParseSkillFrontmatter(content string) (name, description, slug string, allF return "", "", "", allFields } fm := content[3 : 3+end] - - for _, line := range strings.Split(fm, "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - parts := strings.SplitN(line, ":", 2) - if len(parts) != 2 { - continue - } - key := strings.TrimSpace(parts[0]) - val := strings.TrimSpace(parts[1]) - val = strings.Trim(val, `"'`) - allFields[key] = val - - switch key { - case "name": - name = val - case "description": - description = val - case "slug": - slug = val - } - } + allFields = parseSimpleYAML(fm) + name = allFields["name"] + description = allFields["description"] + slug = allFields["slug"] return } diff --git a/internal/skills/loader.go b/internal/skills/loader.go index a5f0f821..9b43c18a 100644 --- a/internal/skills/loader.go +++ b/internal/skills/loader.go @@ -504,21 +504,76 @@ func stripFrontmatter(content string) string { return frontmatterRe.ReplaceAllString(normalizeLineEndings(content), "") } +// parseSimpleYAML parses a subset of YAML: simple key: value pairs, +// multiline block scalars (| and >), and list values (- item). func parseSimpleYAML(content string) map[string]string { result := make(map[string]string) - for line := range strings.SplitSeq(content, "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { + lines := strings.Split(content, "\n") + + var currentKey string + var blockLines []string + var inBlock bool + + flushBlock := func() { + if currentKey != "" { + if len(blockLines) > 0 { + result[currentKey] = strings.Join(blockLines, " ") + } else { + // Empty value (e.g. "slug:" with no indented continuation). + result[currentKey] = "" + } + } + currentKey = "" + blockLines = nil + inBlock = false + } + + for _, line := range lines { + // Indented continuation line (block scalar or list item) + if inBlock && len(line) > 0 && (line[0] == ' ' || line[0] == '\t') { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + // List item: " - value" + if strings.HasPrefix(trimmed, "- ") { + blockLines = append(blockLines, strings.TrimSpace(trimmed[2:])) + } else if trimmed != "-" { + blockLines = append(blockLines, trimmed) + } continue } - parts := strings.SplitN(line, ":", 2) - if len(parts) == 2 { - key := strings.TrimSpace(parts[0]) - val := strings.TrimSpace(parts[1]) - val = strings.Trim(val, "\"'") - result[key] = val + + // Not indented — flush any pending block and parse as top-level key + flushBlock() + + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue } + parts := strings.SplitN(trimmed, ":", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + val := strings.TrimSpace(parts[1]) + val = strings.Trim(val, "\"'") + + if val == "|" || val == ">" || val == "|-" || val == ">-" { + // Start of a multiline block — collect subsequent indented lines + currentKey = key + inBlock = true + continue + } + if val == "" { + // Could be start of a list block (e.g. "allowed-tools:\n - Bash") + currentKey = key + inBlock = true + continue + } + result[key] = val } + flushBlock() return result }