193 lines
4.6 KiB
Go
193 lines
4.6 KiB
Go
// 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"
|
|
"sort"
|
|
"os"
|
|
"path/filepath"
|
|
"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
|
|
global map[string]SkillInfo
|
|
sessions map[string]map[string]bool // sessionID → skill name → loaded
|
|
}
|
|
|
|
func NewRegistry() *Registry {
|
|
return &Registry{
|
|
global: make(map[string]SkillInfo),
|
|
sessions: make(map[string]map[string]bool),
|
|
}
|
|
}
|
|
|
|
// Discover scans the skills directory and registers all found skills.
|
|
func (r *Registry) Discover() error {
|
|
dir := SkillsPath()
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return fmt.Errorf("read skills dir %s: %w", dir, err)
|
|
}
|
|
global := 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
|
|
}
|
|
global[name] = SkillInfo{
|
|
Name: meta.Name,
|
|
Description: meta.Description,
|
|
Dir: skillDir,
|
|
}
|
|
}
|
|
r.mu.Lock()
|
|
r.global = global
|
|
r.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// Summaries returns all discovered skills.
|
|
func (r *Registry) Summaries() []SkillInfo {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
out := make([]SkillInfo, 0, len(r.global))
|
|
for _, info := range r.global {
|
|
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 {
|
|
r.mu.RLock()
|
|
_, exists := r.global[name]
|
|
r.mu.RUnlock()
|
|
if !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()
|
|
defer r.mu.RUnlock()
|
|
names, ok := r.sessions[sessionID]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
out := make([]SkillInfo, 0, len(names))
|
|
for name := range names {
|
|
if info, exists := r.global[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) {
|
|
r.mu.RLock()
|
|
info, exists := r.global[name]
|
|
r.mu.RUnlock()
|
|
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")
|
|
}
|