package skill import ( "fmt" "os" "path/filepath" "sort" "strings" "sync" ) const ( // DefaultSkillsDir is the default directory for user-installed skills. DefaultSkillsDir = "~/.agents/skills" // SkillManifestFile is the name of the skill manifest file. SkillManifestFile = "SKILL.md" ) // Manager is a thread-safe registry for loading, storing, and querying Skills. // // Skills are loaded from a directory tree where each subdirectory containing // a SKILL.md file is treated as a skill. The Manager automatically discovers // skills on initialization and provides methods for finding skills by trigger // keywords or by name. type Manager struct { mu sync.RWMutex skillsDir string skills map[string]*Skill } // NewManager creates a new Skill manager that scans the given directory for skills. // If skillsDir is empty, DefaultSkillsDir is used. func NewManager(skillsDir string) *Manager { if skillsDir == "" { skillsDir = DefaultSkillsDir } // Expand ~ to home directory skillsDir = expandHome(skillsDir) return &Manager{ skillsDir: skillsDir, skills: make(map[string]*Skill), } } // LoadAll scans the skills directory and loads all skills found. // It returns the number of skills loaded and any errors encountered. func (m *Manager) LoadAll() (int, error) { m.mu.Lock() defer m.mu.Unlock() // Clear existing skills m.skills = make(map[string]*Skill) // Check if skills directory exists info, err := os.Stat(m.skillsDir) if err != nil { if os.IsNotExist(err) { return 0, nil // No skills directory yet — not an error } return 0, fmt.Errorf("skill: cannot access skills directory %q: %w", m.skillsDir, err) } if !info.IsDir() { return 0, fmt.Errorf("skill: %q is not a directory", m.skillsDir) } // Read all entries in the skills directory entries, err := os.ReadDir(m.skillsDir) if err != nil { return 0, fmt.Errorf("skill: failed to read skills directory %q: %w", m.skillsDir, err) } var loadErrors []string loaded := 0 for _, entry := range entries { if !entry.IsDir() { continue } skillDir := filepath.Join(m.skillsDir, entry.Name()) skillPath := filepath.Join(skillDir, SkillManifestFile) if _, err := os.Stat(skillPath); os.IsNotExist(err) { continue // No SKILL.md in this directory — skip } skill, err := ParseSkillFile(skillPath) if err != nil { loadErrors = append(loadErrors, fmt.Sprintf("%s: %v", entry.Name(), err)) continue } m.skills[skill.Name] = skill loaded++ } if len(loadErrors) > 0 { return loaded, fmt.Errorf("skill: loaded %d skills with %d errors: %s", loaded, len(loadErrors), joinStrings(loadErrors, "; ")) } return loaded, nil } // GetSkill retrieves a skill by its name. Returns false if not found. func (m *Manager) GetSkill(name string) (*Skill, bool) { m.mu.RLock() defer m.mu.RUnlock() skill, ok := m.skills[name] return skill, ok } // ListSkills returns all loaded skills sorted by name. func (m *Manager) ListSkills() []*Skill { m.mu.RLock() defer m.mu.RUnlock() result := make([]*Skill, 0, len(m.skills)) for _, skill := range m.skills { result = append(result, skill) } sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name }) return result } // FindSkill finds skills whose triggers match the given query string. // Returns all matching skills sorted by relevance (more trigger matches first). func (m *Manager) FindSkill(query string) []*Skill { m.mu.RLock() defer m.mu.RUnlock() var matches []*Skill for _, skill := range m.skills { if skill.MatchTrigger(query) { matches = append(matches, skill) } } // Sort by number of matching triggers (descending) sort.Slice(matches, func(i, j int) bool { return countMatches(matches[i], query) > countMatches(matches[j], query) }) return matches } // SkillsDir returns the directory being scanned for skills. func (m *Manager) SkillsDir() string { return m.skillsDir } // Reload refreshes all skills from disk. func (m *Manager) Reload() (int, error) { return m.LoadAll() } // countMatches counts how many of the skill's triggers match the query. func countMatches(skill *Skill, query string) int { count := 0 queryLower := strings.ToLower(query) for _, trigger := range skill.Triggers { if strings.Contains(queryLower, strings.ToLower(trigger)) { count++ } } return count } // expandHome replaces "~" with the user's home directory. func expandHome(path string) string { if len(path) > 0 && path[0] == '~' { home, err := os.UserHomeDir() if err != nil { return path } return filepath.Join(home, path[1:]) } return path } // joinStrings joins a slice of strings with a separator. func joinStrings(parts []string, sep string) string { if len(parts) == 0 { return "" } result := parts[0] for _, p := range parts[1:] { result += sep + p } return result }