package skills import ( "fmt" "io" "log/slog" "os" "path/filepath" "strings" "gopkg.in/yaml.v3" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // Loader loads Skill definitions from YAML — a single stream (Load), a file // (LoadFile), or a directory of configs/skills/*.yaml files (LoadDir). It does // not persist; persist via Store or publish via Register. type Loader struct { log *slog.Logger } // NewLoader builds a Loader. log may be nil. func NewLoader(log *slog.Logger) *Loader { return &Loader{log: sourcedLog(log)} } // skillYAML is the on-disk YAML shape of a skill. type skillYAML struct { Name string `yaml:"name"` Description string `yaml:"description"` Prompt string `yaml:"prompt"` Steps []yamlStep `yaml:"steps"` Refs []yamlRef `yaml:"refs"` Guardrails []yamlGuardrail `yaml:"guardrails"` } type yamlStep struct { Name string `yaml:"name"` Prompt string `yaml:"prompt"` Depends []string `yaml:"depends"` } type yamlRef struct { Name string `yaml:"name"` Type string `yaml:"type"` Value string `yaml:"value"` Optional bool `yaml:"optional"` } type yamlGuardrail struct { ID string `yaml:"id"` Message string `yaml:"message"` Enforced bool `yaml:"enforced"` } // Load parses one skill from r. It returns ErrInvalidSkill when the document has // a blank name. func (l *Loader) Load(r io.Reader) (*Skill, error) { if r == nil { return nil, fmt.Errorf("skills: loader: load: %w", ErrInvalidSkill) } var doc skillYAML if err := yaml.NewDecoder(r).Decode(&doc); err != nil { return nil, fmt.Errorf("skills: loader: decode: %w", err) } skill, err := doc.toSkill() if err != nil { return nil, err } l.log.Info("skill loaded", "skill", skill.Name()) return skill, nil } // LoadFile loads the single skill YAML file at path. func (l *Loader) LoadFile(path string) (*Skill, error) { f, err := os.Open(path) if err != nil { return nil, fmt.Errorf("skills: loader: open %q: %w", path, err) } defer f.Close() skill, err := l.Load(f) if err != nil { return nil, fmt.Errorf("skills: loader: file %q: %w", path, err) } return skill, nil } // LoadDir loads every *.yaml/*.yml skill file in dir, in readdir order. A file // that fails to parse stops the load. Non-YAML files are skipped. func (l *Loader) LoadDir(dir string) ([]*Skill, error) { entries, err := os.ReadDir(dir) if err != nil { return nil, fmt.Errorf("skills: loader: read dir %q: %w", dir, err) } out := make([]*Skill, 0, len(entries)) for _, e := range entries { if e.IsDir() || !isYAML(e.Name()) { continue } path := filepath.Join(dir, e.Name()) skill, err := l.LoadFile(path) if err != nil { return nil, fmt.Errorf("skills: loader: dir %q: %w", path, err) } out = append(out, skill) } return out, nil } // toSkill converts the YAML document into a Skill, validating the required name. func (d skillYAML) toSkill() (*Skill, error) { steps := make([]sdk.Step, 0, len(d.Steps)) for _, st := range d.Steps { steps = append(steps, sdk.Step{Name: st.Name, Prompt: st.Prompt, Depends: st.Depends}) } refs := make([]sdk.Ref, 0, len(d.Refs)) for _, rf := range d.Refs { refs = append(refs, sdk.Ref{Name: rf.Name, Type: rf.Type, Value: rf.Value, Optional: rf.Optional}) } guardrails := make([]sdk.Guardrail, 0, len(d.Guardrails)) for _, g := range d.Guardrails { guardrails = append(guardrails, sdk.Guardrail{ID: g.ID, Message: g.Message, Enforced: g.Enforced}) } skill, err := New(d.Name, d.Description, d.Prompt, steps, refs, guardrails) if err != nil { return nil, fmt.Errorf("skills: loader: invalid skill %q: %w", d.Name, err) } return skill, nil } func isYAML(name string) bool { lower := strings.ToLower(name) return strings.HasSuffix(lower, ".yaml") || strings.HasSuffix(lower, ".yml") }