package parser import ( "fmt" "os" "strings" "huggingface.co/turenlabs/Vigil/source/pkg/types" ) // Parse reads a Claude Code skill markdown file and extracts frontmatter + body. func Parse(filePath string) (*types.SkillFile, error) { raw, err := os.ReadFile(filePath) if err != nil { return nil, fmt.Errorf("reading skill file: %w", err) } return ParseBytes(raw, filePath) } // ParseBytes parses skill file content from bytes. func ParseBytes(raw []byte, filePath string) (*types.SkillFile, error) { content := string(raw) frontmatter, body, bodyStartLine, err := splitFrontmatter(content) if err != nil { return nil, fmt.Errorf("parsing frontmatter: %w", err) } sf := &types.SkillFile{ RawContent: content, FilePath: filePath, Body: body, BodyStartLine: bodyStartLine, } if frontmatter != "" { parseFrontmatterFields(frontmatter, sf) } return sf, nil } // splitFrontmatter splits a markdown file into YAML frontmatter and body. // Frontmatter is delimited by --- on the first line and a closing ---. // bodyStartLine is the 1-based line number where the body begins in the original content. func splitFrontmatter(content string) (frontmatter, body string, bodyStartLine int, err error) { trimmed := strings.TrimSpace(content) if !strings.HasPrefix(trimmed, "---") { return "", content, 1, nil } // Count leading blank lines that TrimSpace removed leadingLines := 0 for i, ch := range content { if ch == '\n' { leadingLines++ } else if ch != ' ' && ch != '\t' && ch != '\r' { _ = i break } } rest := trimmed[3:] rest = strings.TrimLeft(rest, " \t") if len(rest) > 0 && rest[0] == '\n' { rest = rest[1:] } else if len(rest) > 1 && rest[0] == '\r' && rest[1] == '\n' { rest = rest[2:] } // Handle empty frontmatter: starts with closing --- if strings.HasPrefix(rest, "---") { body = strings.TrimLeft(rest[3:], " \t\r\n") // Opening --- (1) + closing --- (1) + leadingLines bodyStartLine = leadingLines + 3 return "", body, bodyStartLine, nil } idx := strings.Index(rest, "\n---") if idx == -1 { return "", content, 1, nil } frontmatter = rest[:idx] body = strings.TrimLeft(rest[idx+4:], " \t\r\n") // Count lines: leading blank + opening --- line + frontmatter lines + closing --- line fmLines := strings.Count(frontmatter, "\n") + 1 bodyStartLine = leadingLines + 1 + fmLines + 1 // opening--- + fm lines + closing--- // Account for blank lines trimmed between closing --- and body start afterClosing := rest[idx+4:] for _, ch := range afterClosing { if ch == '\n' { bodyStartLine++ } else if ch != ' ' && ch != '\t' && ch != '\r' { break } } return frontmatter, body, bodyStartLine, nil } // parseFrontmatterFields extracts known fields from simple YAML frontmatter. // Handles flat key: value pairs and simple list items (- value). func parseFrontmatterFields(fm string, sf *types.SkillFile) { lines := strings.Split(fm, "\n") var currentKey string for _, line := range lines { trimmed := strings.TrimRight(line, " \t\r") // List item under current key if strings.HasPrefix(trimmed, " - ") || strings.HasPrefix(trimmed, " - ") { val := strings.TrimSpace(trimmed[strings.Index(trimmed, "- ")+2:]) if currentKey == "triggers" { sf.Triggers = append(sf.Triggers, val) } continue } // Key: value pair colonIdx := strings.Index(trimmed, ":") if colonIdx > 0 { key := strings.TrimSpace(trimmed[:colonIdx]) val := strings.TrimSpace(trimmed[colonIdx+1:]) currentKey = key switch key { case "name": sf.Name = val case "description": sf.Description = val case "type": sf.Type = val case "triggers": // Value comes on subsequent lines as list items } } } }