2026-08-22 21:52:30 +02:00
|
|
|
package discovery
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"strings"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Frontmatter holds the leniently parsed leading YAML block of an item.
|
|
|
|
|
type Frontmatter struct {
|
|
|
|
|
Name string
|
|
|
|
|
Description string
|
|
|
|
|
Tags []string
|
|
|
|
|
Requires []string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ParseFrontmatter extracts name, description, tags and requires from a
|
|
|
|
|
// leading `---` fenced block. Parsing is deliberately lenient: a block that
|
|
|
|
|
// opens but never closes, or contains lines without a colon, yields warnings
|
|
|
|
|
// instead of failures; the parseable prefix still wins. Files without a
|
|
|
|
|
// leading fence have no frontmatter and no warning.
|
|
|
|
|
func ParseFrontmatter(data []byte) (Frontmatter, []string) {
|
|
|
|
|
var fm Frontmatter
|
|
|
|
|
var warnings []string
|
2026-08-23 15:17:46 +02:00
|
|
|
lines, fenced, closed := parseFence(data)
|
|
|
|
|
if !fenced {
|
2026-08-22 21:52:30 +02:00
|
|
|
return fm, nil
|
|
|
|
|
}
|
2026-08-23 15:17:46 +02:00
|
|
|
for _, line := range lines {
|
2026-08-22 21:52:30 +02:00
|
|
|
trimmed := strings.TrimSpace(line)
|
|
|
|
|
if trimmed == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
key, value, ok := strings.Cut(trimmed, ":")
|
|
|
|
|
if !ok {
|
|
|
|
|
warnings = append(warnings, "malformed frontmatter line (missing colon): "+trimmed)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
key = strings.TrimSpace(key)
|
|
|
|
|
value = strings.TrimSpace(value)
|
|
|
|
|
switch strings.ToLower(key) {
|
|
|
|
|
case "name":
|
|
|
|
|
fm.Name = value
|
|
|
|
|
case "description":
|
|
|
|
|
fm.Description = value
|
|
|
|
|
case "tags":
|
|
|
|
|
fm.Tags = splitList(value)
|
|
|
|
|
case "requires":
|
|
|
|
|
fm.Requires = splitList(value)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !closed {
|
|
|
|
|
warnings = append(warnings, "frontmatter block not closed")
|
|
|
|
|
}
|
|
|
|
|
return fm, warnings
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 15:17:46 +02:00
|
|
|
// definitionKeys mark a flat .md under agents/ or commands/ as an intentional
|
|
|
|
|
// agent/command definition rather than dropped documentation.
|
|
|
|
|
var definitionKeys = map[string]bool{
|
|
|
|
|
"name": true,
|
|
|
|
|
"description": true,
|
|
|
|
|
"mode": true,
|
|
|
|
|
"agent": true,
|
|
|
|
|
"model": true,
|
|
|
|
|
"argument-hint": true,
|
|
|
|
|
"temperature": true,
|
|
|
|
|
"permission": true,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// HasDefinitionFrontmatter reports whether data's leading frontmatter block
|
|
|
|
|
// contains any key that marks a flat .md as an agent/command definition.
|
|
|
|
|
func HasDefinitionFrontmatter(data []byte) bool {
|
|
|
|
|
lines, fenced, _ := parseFence(data)
|
|
|
|
|
if !fenced {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
for _, line := range lines {
|
|
|
|
|
key, _, ok := strings.Cut(strings.TrimSpace(line), ":")
|
|
|
|
|
if ok && definitionKeys[strings.ToLower(strings.TrimSpace(key))] {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// parseFence returns the lines between the leading `---` fences: the block
|
|
|
|
|
// lines, whether the file is fenced at all, and whether the fence closes.
|
|
|
|
|
func parseFence(data []byte) (lines []string, fenced, closed bool) {
|
|
|
|
|
all := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n")
|
|
|
|
|
if len(all) == 0 || strings.TrimSpace(all[0]) != "---" {
|
|
|
|
|
return nil, false, false
|
|
|
|
|
}
|
|
|
|
|
fenced = true
|
|
|
|
|
for _, line := range all[1:] {
|
|
|
|
|
if strings.TrimSpace(line) == "---" {
|
|
|
|
|
return lines, fenced, true
|
|
|
|
|
}
|
|
|
|
|
lines = append(lines, line)
|
|
|
|
|
}
|
|
|
|
|
return lines, fenced, false
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-22 21:52:30 +02:00
|
|
|
func splitList(v string) []string {
|
|
|
|
|
if v == "" {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
parts := strings.Split(v, ",")
|
|
|
|
|
var out []string
|
|
|
|
|
for _, p := range parts {
|
|
|
|
|
if t := strings.TrimSpace(p); t != "" {
|
|
|
|
|
out = append(out, t)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|