返回 DeepSeek-Reasonix
frontmatter.go
根目录 / internal / frontmatter / frontmatter.go
1 // Package frontmatter parses the ---fenced YAML frontmatter blocks that prefix
2 // skill, command, and memory files.
3 package frontmatter
4
5 import (
6 "bytes"
7 "fmt"
8 "strings"
9
10 "gopkg.in/yaml.v3"
11 )
12
13 type DecodeOptions struct {
14 KnownFields bool
15 }
16
17 // Split separates an optional leading ---fenced block from the body. It returns
18 // the parsed keys (lowercased) and the remaining body. With no opening/closing
19 // fence the whole input is the body. An opened but never closed fence treats the
20 // entire input as body (no partial parse).
21 //
22 // Scalar values are converted to strings. Mapping values are flattened one level
23 // so "metadata:\n type: user" becomes fm["type"] = "user", matching the legacy
24 // parser. Sequence values are joined comma-separated (allowed-tools →
25 // "read_file, grep"), so list-valued keys from skills authored for other agent
26 // tools survive. The last write wins for duplicate keys.
27 func Split(s string) (map[string]string, string) {
28 fm := map[string]string{}
29 raw, body, ok := splitRaw(s)
30 if !ok {
31 return fm, body
32 }
33 parseYAMLFrontmatter(raw, fm)
34 return fm, body
35 }
36
37 // Decode separates frontmatter and decodes the YAML block into out. It is for
38 // callers that need typed schema validation; Split remains the permissive
39 // compatibility parser for legacy metadata consumers.
40 func Decode(s string, out any, opts DecodeOptions) (string, error) {
41 raw, body, ok := splitRaw(s)
42 if !ok || strings.TrimSpace(raw) == "" {
43 return body, nil
44 }
45 dec := yaml.NewDecoder(bytes.NewBufferString(raw))
46 dec.KnownFields(opts.KnownFields)
47 if err := dec.Decode(out); err != nil {
48 return "", err
49 }
50 return body, nil
51 }
52
53 func splitRaw(s string) (raw, body string, ok bool) {
54 s = strings.ReplaceAll(s, "\r\n", "\n")
55 lines := strings.Split(s, "\n")
56 if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
57 return "", s, false
58 }
59 for i := 1; i < len(lines); i++ {
60 if strings.TrimSpace(lines[i]) == "---" {
61 return strings.Join(lines[1:i], "\n"), strings.Join(lines[i+1:], "\n"), true
62 }
63 }
64 return "", s, false // opened but never closed: treat all as body
65 }
66
67 func parseYAMLFrontmatter(content string, out map[string]string) {
68 if strings.TrimSpace(content) == "" {
69 return
70 }
71 var doc yaml.Node
72 if err := yaml.Unmarshal([]byte(content), &doc); err != nil {
73 return
74 }
75 root := mappingRoot(&doc)
76 if root == nil {
77 return
78 }
79 for i := 0; i+1 < len(root.Content); i += 2 {
80 key := normalizeKey(root.Content[i].Value)
81 if key == "" {
82 continue
83 }
84 addYAMLValue(out, key, root.Content[i+1])
85 }
86 }
87
88 func mappingRoot(doc *yaml.Node) *yaml.Node {
89 if doc == nil {
90 return nil
91 }
92 if doc.Kind == yaml.DocumentNode && len(doc.Content) > 0 {
93 doc = doc.Content[0]
94 }
95 if doc.Kind != yaml.MappingNode {
96 return nil
97 }
98 return doc
99 }
100
101 func addYAMLValue(out map[string]string, key string, value *yaml.Node) {
102 switch {
103 case value == nil:
104 return
105 case value.Kind == yaml.MappingNode:
106 for i := 0; i+1 < len(value.Content); i += 2 {
107 nestedKey := normalizeKey(value.Content[i].Value)
108 if nestedKey == "" {
109 continue
110 }
111 addYAMLValue(out, nestedKey, value.Content[i+1])
112 }
113 case value.Kind == yaml.SequenceNode:
114 items := make([]string, 0, len(value.Content))
115 for _, item := range value.Content {
116 if s := yamlScalarString(item); s != "" {
117 items = append(items, s)
118 }
119 }
120 if len(items) > 0 {
121 joined := strings.Join(items, ", ")
122 if key == "argument-hint" {
123 joined = "[" + joined + "]"
124 }
125 out[key] = joined
126 }
127 default:
128 if s := yamlScalarString(value); s != "" {
129 out[key] = s
130 }
131 }
132 }
133
134 func normalizeKey(key string) string {
135 return strings.ToLower(strings.TrimSpace(key))
136 }
137
138 func yamlScalarString(node *yaml.Node) string {
139 if node == nil {
140 return ""
141 }
142 if node.Kind != yaml.ScalarNode {
143 return strings.TrimSpace(fmt.Sprint(node.Value))
144 }
145 return strings.TrimSpace(node.Value)
146 }
147
147 lines GO