3638529468
Agregar componentes impuros para manejo de skills en shell/skills/: Loader (filesystem I/O): - LoadMeta(): carga metadata de todas las skills - LoadSkill(): carga skill completa con instrucciones - ReadResource(): lee recursos con path traversal protection - Parsing de SKILL.md con frontmatter YAML Executor (script execution): - Ejecucion segura de scripts con allowlist de interpreters - Timeout obligatorio por script - Inferencia de interpreter desde extension - Proteccion contra scripts maliciosos Incluye tests completos con tmpdir para loader y executor. Arquitectura: impure shell, todo I/O aislado en shell/.
224 lines
5.2 KiB
Go
224 lines
5.2 KiB
Go
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
|
|
}
|