package skills import ( "bufio" "fmt" "os" "path/filepath" "strings" "github.com/enmanuel/agents/pkg/skills" "gopkg.in/yaml.v3" ) // Loader descubre y carga skills desde un directorio base. type Loader struct { basePath string } // NewLoader crea un nuevo Loader apuntando al directorio de skills. func NewLoader(basePath string) *Loader { return &Loader{basePath: basePath} } // LoadMeta carga solo la metadata (nivel 1) de todas las skills. // Recorre el directorio base buscando SKILL.md y extrae el frontmatter YAML. func (l *Loader) LoadMeta() ([]skills.SkillMeta, error) { var metas []skills.SkillMeta // Recorre categorias (devops/, analysis/, etc.) categories, err := os.ReadDir(l.basePath) if err != nil { return nil, fmt.Errorf("read skills dir: %w", err) } for _, catEntry := range categories { if !catEntry.IsDir() { continue } category := catEntry.Name() catPath := filepath.Join(l.basePath, category) // Recorre skills dentro de la categoria skillDirs, err := os.ReadDir(catPath) if err != nil { continue } for _, skillEntry := range skillDirs { if !skillEntry.IsDir() { continue } skillName := skillEntry.Name() skillPath := filepath.Join(catPath, skillName) skillMdPath := filepath.Join(skillPath, "SKILL.md") // Verificar que existe SKILL.md if _, err := os.Stat(skillMdPath); os.IsNotExist(err) { continue } // Parsear metadata meta, _, err := parseSkillMD(skillMdPath) if err != nil { continue // skip invalid skills } meta.Category = category metas = append(metas, meta) } } return metas, nil } // LoadSkill carga una skill completa (nivel 2) por nombre. // Retorna el struct Skill con metadata, instrucciones y listado de recursos. func (l *Loader) LoadSkill(name string) (*skills.Skill, error) { // Buscar en todas las categorias categories, err := os.ReadDir(l.basePath) if err != nil { return nil, fmt.Errorf("read skills dir: %w", err) } for _, catEntry := range categories { if !catEntry.IsDir() { continue } category := catEntry.Name() skillPath := filepath.Join(l.basePath, category, name) skillMdPath := filepath.Join(skillPath, "SKILL.md") if _, err := os.Stat(skillMdPath); os.IsNotExist(err) { continue } // Parsear skill completa meta, instructions, err := parseSkillMD(skillMdPath) if err != nil { return nil, fmt.Errorf("parse %s: %w", skillMdPath, err) } meta.Category = category skill := &skills.Skill{ Meta: meta, Instructions: instructions, BasePath: skillPath, Scripts: listFiles(filepath.Join(skillPath, "scripts")), References: listFiles(filepath.Join(skillPath, "references")), Templates: listFiles(filepath.Join(skillPath, "templates")), Assets: listFiles(filepath.Join(skillPath, "assets")), } return skill, nil } return nil, fmt.Errorf("skill not found: %s", name) } // ReadResource lee un recurso especifico (nivel 3) de una skill. // path es relativo a la skill (ej: "scripts/deploy.sh", "references/api.md"). func (l *Loader) ReadResource(skillName, resourcePath string) (string, error) { skill, err := l.LoadSkill(skillName) if err != nil { return "", err } fullPath := filepath.Join(skill.BasePath, resourcePath) // Validar que el path esta dentro de la skill (evitar path traversal) absBasePath, err := filepath.Abs(skill.BasePath) if err != nil { return "", fmt.Errorf("abs base path: %w", err) } absFullPath, err := filepath.Abs(fullPath) if err != nil { return "", fmt.Errorf("abs resource path: %w", err) } if !strings.HasPrefix(absFullPath, absBasePath) { return "", fmt.Errorf("path traversal detected: %s", resourcePath) } content, err := os.ReadFile(absFullPath) if err != nil { return "", fmt.Errorf("read resource: %w", err) } return string(content), nil } // parseSkillMD extrae el frontmatter YAML y el cuerpo markdown de un SKILL.md. func parseSkillMD(path string) (skills.SkillMeta, string, error) { f, err := os.Open(path) if err != nil { return skills.SkillMeta{}, "", err } defer f.Close() scanner := bufio.NewScanner(f) var yamlLines []string var bodyLines []string inYAML := false yamlClosed := false for scanner.Scan() { line := scanner.Text() if strings.TrimSpace(line) == "---" { if !inYAML { inYAML = true continue } else { inYAML = false yamlClosed = true continue } } if inYAML { yamlLines = append(yamlLines, line) } else if yamlClosed { bodyLines = append(bodyLines, line) } } if err := scanner.Err(); err != nil { return skills.SkillMeta{}, "", err } // Parse YAML frontmatter var meta skills.SkillMeta yamlStr := strings.Join(yamlLines, "\n") if err := yaml.Unmarshal([]byte(yamlStr), &meta); err != nil { return skills.SkillMeta{}, "", fmt.Errorf("parse yaml: %w", err) } // Cuerpo markdown body := strings.Join(bodyLines, "\n") return meta, body, nil } // listFiles retorna una lista de archivos (rutas relativas) dentro de un directorio. // Si el directorio no existe, retorna una lista vacia. func listFiles(dir string) []string { entries, err := os.ReadDir(dir) if err != nil { return nil } var files []string for _, entry := range entries { if !entry.IsDir() { files = append(files, entry.Name()) } } return files }