Files
agents_and_robots/shell/skills/loader.go
T
egutierrez 3638529468 feat: loader y executor de skills en shell
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/.
2026-03-08 22:13:12 +00:00

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
}