// Package skills parses skill metadata from SKILL.md front-matter and // provides an in-memory registry for skill discovery and session-level loading. package skills import ( "fmt" "os" "path/filepath" "sort" "strings" "sync" ) // SkillsPath returns the directory to search for skill modules. // Resolved from OLLIE_SKILLS_PATH, then ~/.config/ollie/skills. func SkillsPath() string { if p := os.Getenv("OLLIE_SKILLS_PATH"); p != "" { return p } home, _ := os.UserHomeDir() return home + "/.config/ollie/skills" } // SkillInfo describes an available skill module. type SkillInfo struct { Name string Description string Dir string // directory containing SKILL.md } // Registry discovers and loads skills from the filesystem. // Tracks which skills are loaded per session. type Registry struct { mu sync.RWMutex sessions map[string]map[string]bool // sessionID → skill name → loaded } func NewRegistry() *Registry { return &Registry{ sessions: make(map[string]map[string]bool), } } // Discover validates that the skills directory is readable. // Retained for backward compatibility; discovery is now dynamic. func (r *Registry) Discover() error { dir := SkillsPath() if _, err := os.ReadDir(dir); err != nil { return fmt.Errorf("read skills dir %s: %w", dir, err) } return nil } // discover scans the skills directory and returns all found skills. func (r *Registry) discover() map[string]SkillInfo { dir := SkillsPath() entries, err := os.ReadDir(dir) if err != nil { return nil } skills := make(map[string]SkillInfo) for _, e := range entries { if !e.IsDir() { continue } name := e.Name() skillDir := filepath.Join(dir, name) skillFile := filepath.Join(skillDir, "SKILL.md") f, err := os.Open(skillFile) if err != nil { continue } meta, err := ParseFrontMatter(f, name, skillDir) f.Close() if err != nil { continue } skills[name] = SkillInfo{ Name: meta.Name, Description: meta.Description, Dir: skillDir, } } return skills } // Summaries returns all discovered skills. func (r *Registry) Summaries() []SkillInfo { skills := r.discover() out := make([]SkillInfo, 0, len(skills)) for _, info := range skills { out = append(out, info) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out } // Load marks a skill as loaded for the given session. func (r *Registry) Load(sessionID, name string) error { skills := r.discover() if _, exists := skills[name]; !exists { return fmt.Errorf("skill not found: %s", name) } r.mu.Lock() defer r.mu.Unlock() if r.sessions[sessionID] == nil { r.sessions[sessionID] = make(map[string]bool) } r.sessions[sessionID][name] = true return nil } // Unload removes a skill from the session's loaded set. func (r *Registry) Unload(sessionID, name string) error { r.mu.Lock() defer r.mu.Unlock() if _, ok := r.sessions[sessionID]; ok { delete(r.sessions[sessionID], name) if len(r.sessions[sessionID]) == 0 { delete(r.sessions, sessionID) } } return nil } // Loaded returns skills currently loaded for a session. func (r *Registry) Loaded(sessionID string) []SkillInfo { r.mu.RLock() names, ok := r.sessions[sessionID] r.mu.RUnlock() if !ok { return nil } skills := r.discover() out := make([]SkillInfo, 0, len(names)) for name := range names { if info, exists := skills[name]; exists { out = append(out, info) } } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out } // SkillContent reads and returns the full content of a skill's SKILL.md file, // with front-matter stripped. Returns (content, error). func (r *Registry) SkillContent(name string) (string, error) { skills := r.discover() info, exists := skills[name] if !exists { return "", fmt.Errorf("skill not found: %s", name) } data, err := os.ReadFile(filepath.Join(info.Dir, "SKILL.md")) if err != nil { return "", fmt.Errorf("read skill %s: %w", name, err) } return StripFrontMatter(string(data)), nil } // IsLoaded checks if a skill is loaded for the given session. func (r *Registry) IsLoaded(sessionID, name string) bool { r.mu.RLock() defer r.mu.RUnlock() names, ok := r.sessions[sessionID] if !ok { return false } return names[name] } // StripFrontMatter removes YAML front-matter from a markdown string, // returning everything after the closing ---. func StripFrontMatter(content string) string { lines := strings.Split(content, "\n") if len(lines) < 2 || strings.TrimSpace(lines[0]) != "---" { return content } inFM := false bodyStart := 0 for i, line := range lines { if strings.TrimSpace(line) == "---" { if !inFM { inFM = true } else { bodyStart = i + 1 break } } } if bodyStart == 0 { return content } return strings.Join(lines[bodyStart:], "\n") }