package skill import ( "fmt" "os" "path/filepath" "sort" "strings" ) // FrontmatterDelimiters for YAML frontmatter in SKILL.md files. const ( frontmatterDelim = "---" ) // ParseSkillFile parses a SKILL.md file and returns a populated Skill struct. // // The expected format is: // // --- // name: my-skill // description: Does something useful // triggers: ["keyword1", "keyword2"] // --- // // # My Skill // // Detailed description... func ParseSkillFile(path string) (*Skill, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("skill: cannot read %q: %w", path, err) } return ParseSkillData(path, data) } // ParseSkillData parses SKILL.md content from raw bytes. // The path parameter is used to locate the scripts/ directory. func ParseSkillData(path string, data []byte) (*Skill, error) { content := string(data) skill := &Skill{ Path: path, Body: content, Triggers: []string{}, } // Parse YAML frontmatter rest, err := parseFrontmatter(content, skill) if err != nil { return nil, err } skill.Body = strings.TrimSpace(rest) // Validate required fields if skill.Name == "" { return nil, fmt.Errorf("skill: %q is missing 'name' in frontmatter", path) } // Discover scripts directory skillDir := filepath.Dir(path) scriptsDir := filepath.Join(skillDir, "scripts") skill.ScriptsDir = scriptsDir if info, err := os.Stat(scriptsDir); err == nil && info.IsDir() { scripts, err := discoverScripts(scriptsDir) if err != nil { return nil, fmt.Errorf("skill: failed to discover scripts in %q: %w", scriptsDir, err) } skill.Scripts = scripts } return skill, nil } // parseFrontmatter extracts YAML frontmatter delimited by "---" lines // and populates the Skill struct fields. func parseFrontmatter(content string, skill *Skill) (string, error) { content = strings.TrimSpace(content) if !strings.HasPrefix(content, frontmatterDelim) { // No frontmatter — treat entire content as body return content, nil } // Find the closing delimiter rest := content[len(frontmatterDelim):] rest = strings.TrimLeft(rest, "\n\r") endIdx := strings.Index(rest, "\n"+frontmatterDelim) if endIdx < 0 { // Also check for end-of-file style endIdx = strings.Index(rest, frontmatterDelim) if endIdx < 0 { return "", fmt.Errorf("skill: unclosed frontmatter in skill file") } } frontmatter := rest[:endIdx] body := rest[endIdx+len(frontmatterDelim)+1:] // Parse the YAML frontmatter (simple key-value parser) if err := parseSimpleYAML(frontmatter, skill); err != nil { return "", err } return body, nil } // parseSimpleYAML parses a simplified YAML format for skill frontmatter. // Supports: string values, quoted strings, and array values. func parseSimpleYAML(yaml string, skill *Skill) error { lines := strings.Split(yaml, "\n") for _, line := range lines { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } colonIdx := strings.Index(line, ":") if colonIdx < 0 { continue } key := strings.TrimSpace(line[:colonIdx]) value := strings.TrimSpace(line[colonIdx+1:]) switch key { case "name": skill.Name = trimQuotes(value) case "description": skill.Description = trimQuotes(value) case "triggers": triggers, err := parseYAMLArray(value) if err != nil { return fmt.Errorf("skill: invalid triggers format: %w", err) } skill.Triggers = triggers } } return nil } // parseYAMLArray parses a YAML array like '["a", "b", "c"]' or '[a, b, c]'. func parseYAMLArray(value string) ([]string, error) { value = strings.TrimSpace(value) // Handle YAML list format: ["a", "b"] or [a, b] if strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") { inner := value[1 : len(value)-1] if strings.TrimSpace(inner) == "" { return []string{}, nil } parts := splitCommas(inner) result := make([]string, len(parts)) for i, p := range parts { result[i] = trimQuotes(strings.TrimSpace(p)) } return result, nil } // Handle YAML list format with dashes: // triggers: // - browser // - navigate // (This would be handled line-by-line in a different flow) // For now, treat single value as a one-element list if value != "" && value != "[]" { return []string{trimQuotes(value)}, nil } return []string{}, nil } // splitCommas splits a comma-separated string respecting quoted sections. func splitCommas(s string) []string { var parts []string var current strings.Builder inQuote := false quoteChar := byte(0) for i := 0; i < len(s); i++ { c := s[i] if inQuote { current.WriteByte(c) if c == quoteChar { inQuote = false } } else if c == '"' || c == '\'' { current.WriteByte(c) inQuote = true quoteChar = c } else if c == ',' { parts = append(parts, current.String()) current.Reset() } else { current.WriteByte(c) } } if current.Len() > 0 { parts = append(parts, current.String()) } return parts } // trimQuotes removes surrounding quotes from a string value. func trimQuotes(s string) string { s = strings.TrimSpace(s) if len(s) >= 2 { if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { return s[1 : len(s)-1] } } return s } // discoverScripts lists all executable/readable files in a scripts directory. func discoverScripts(scriptsDir string) ([]string, error) { entries, err := os.ReadDir(scriptsDir) if err != nil { return nil, err } var scripts []string for _, entry := range entries { if entry.IsDir() { continue } scripts = append(scripts, entry.Name()) } sort.Strings(scripts) return scripts, nil } // LoadSkillFromDir loads a skill from a directory containing a SKILL.md file. func LoadSkillFromDir(dir string) (*Skill, error) { skillPath := filepath.Join(dir, "SKILL.md") if _, err := os.Stat(skillPath); os.IsNotExist(err) { return nil, fmt.Errorf("skill: no SKILL.md found in %q", dir) } return ParseSkillFile(skillPath) }