feat(skill): registry with multi-directory loading and precedence

This commit is contained in:
2026-04-07 02:17:17 +02:00
parent 42fc2adcd8
commit 995b26ffe7
2 changed files with 234 additions and 0 deletions

109
internal/skill/registry.go Normal file
View File

@@ -0,0 +1,109 @@
package skill
import (
"errors"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"sync"
)
// Registry holds loaded skills keyed by name.
// Thread-safe; safe for concurrent Get while Load methods run serially.
type Registry struct {
mu sync.RWMutex
skills map[string]*Skill
}
// NewRegistry returns an empty Registry.
func NewRegistry() *Registry {
return &Registry{skills: make(map[string]*Skill)}
}
// LoadBundled loads all skills embedded in the binary.
// Later calls to LoadDir or LoadBundled can override bundled skills by name.
func (r *Registry) LoadBundled() error {
skills, err := BundledSkills()
if err != nil {
return err
}
r.mu.Lock()
defer r.mu.Unlock()
for _, s := range skills {
r.skills[s.Frontmatter.Name] = s
}
return nil
}
// LoadDir scans dir for *.md files and loads each as a skill with the given source tag.
// Non-existent directories are silently skipped.
// Skills loaded later override earlier ones with the same name.
func (r *Registry) LoadDir(dir, source string) error {
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil
}
return err
}
r.mu.Lock()
defer r.mu.Unlock()
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
continue
}
path := filepath.Join(dir, entry.Name())
data, err := os.ReadFile(path)
if err != nil {
return err
}
s, err := Parse(data, source)
if err != nil {
// Skip unparseable files rather than aborting the whole load.
continue
}
s.FilePath = path
r.skills[s.Frontmatter.Name] = s
}
return nil
}
// Get returns the skill with the given name, or nil if not found.
func (r *Registry) Get(name string) *Skill {
if r == nil {
return nil
}
r.mu.RLock()
defer r.mu.RUnlock()
return r.skills[name]
}
// Names returns all skill names in sorted order.
func (r *Registry) Names() []string {
r.mu.RLock()
defer r.mu.RUnlock()
names := make([]string, 0, len(r.skills))
for name := range r.skills {
names = append(names, name)
}
sort.Strings(names)
return names
}
// All returns all skills sorted by name.
func (r *Registry) All() []*Skill {
r.mu.RLock()
defer r.mu.RUnlock()
skills := make([]*Skill, 0, len(r.skills))
for _, s := range r.skills {
skills = append(skills, s)
}
sort.Slice(skills, func(i, j int) bool {
return skills[i].Frontmatter.Name < skills[j].Frontmatter.Name
})
return skills
}

View File

@@ -0,0 +1,125 @@
package skill
import (
"os"
"path/filepath"
"testing"
)
func writeSkillFile(t *testing.T, dir, filename, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0o644); err != nil {
t.Fatalf("writing skill file: %v", err)
}
}
func TestRegistry_LoadDir_SingleFile(t *testing.T) {
dir := t.TempDir()
writeSkillFile(t, dir, "myskill.md", "---\nname: myskill\ndescription: test skill\n---\ndo the thing\n")
reg := NewRegistry()
if err := reg.LoadDir(dir, "user"); err != nil {
t.Fatalf("LoadDir error: %v", err)
}
sk := reg.Get("myskill")
if sk == nil {
t.Fatal("skill not found after LoadDir")
}
if sk.Source != "user" {
t.Errorf("source = %q, want %q", sk.Source, "user")
}
}
func TestRegistry_LoadDir_MissingDir_NoError(t *testing.T) {
reg := NewRegistry()
err := reg.LoadDir("/nonexistent/path/that/does/not/exist", "user")
if err != nil {
t.Errorf("LoadDir on missing dir should not error, got: %v", err)
}
}
func TestRegistry_LoadDir_SkipsNonMarkdown(t *testing.T) {
dir := t.TempDir()
writeSkillFile(t, dir, "skill.md", "---\nname: good\n---\nbody\n")
writeSkillFile(t, dir, "README.txt", "not a skill")
writeSkillFile(t, dir, "config.toml", "[section]\nkey = 1")
reg := NewRegistry()
if err := reg.LoadDir(dir, "user"); err != nil {
t.Fatalf("LoadDir error: %v", err)
}
if len(reg.Names()) != 1 {
t.Errorf("expected 1 skill, got %d: %v", len(reg.Names()), reg.Names())
}
}
func TestRegistry_OverridePrecedence(t *testing.T) {
dir1 := t.TempDir()
dir2 := t.TempDir()
writeSkillFile(t, dir1, "shared.md", "---\nname: shared\ndescription: from dir1\n---\nbody1\n")
writeSkillFile(t, dir2, "shared.md", "---\nname: shared\ndescription: from dir2\n---\nbody2\n")
reg := NewRegistry()
reg.LoadDir(dir1, "user")
reg.LoadDir(dir2, "project")
sk := reg.Get("shared")
if sk == nil {
t.Fatal("skill not found")
}
if sk.Frontmatter.Description != "from dir2" {
t.Errorf("later load should override: got description %q", sk.Frontmatter.Description)
}
if sk.Source != "project" {
t.Errorf("source = %q, want project", sk.Source)
}
}
func TestRegistry_GetUnknown_ReturnsNil(t *testing.T) {
reg := NewRegistry()
if reg.Get("nonexistent") != nil {
t.Error("expected nil for unknown skill")
}
}
func TestRegistry_Names_Sorted(t *testing.T) {
dir := t.TempDir()
writeSkillFile(t, dir, "zebra.md", "---\nname: zebra\n---\nbody\n")
writeSkillFile(t, dir, "alpha.md", "---\nname: alpha\n---\nbody\n")
writeSkillFile(t, dir, "middle.md", "---\nname: middle\n---\nbody\n")
reg := NewRegistry()
reg.LoadDir(dir, "test")
names := reg.Names()
if len(names) != 3 {
t.Fatalf("expected 3 names, got %d", len(names))
}
if names[0] != "alpha" || names[1] != "middle" || names[2] != "zebra" {
t.Errorf("names not sorted: %v", names)
}
}
func TestRegistry_LoadBundled(t *testing.T) {
reg := NewRegistry()
if err := reg.LoadBundled(); err != nil {
t.Fatalf("LoadBundled error: %v", err)
}
if reg.Get("batch") == nil {
t.Error("batch skill not found after LoadBundled")
}
}
func TestRegistry_All_ReturnsCopy(t *testing.T) {
dir := t.TempDir()
writeSkillFile(t, dir, "a.md", "---\nname: aaa\n---\nbody\n")
reg := NewRegistry()
reg.LoadDir(dir, "test")
all := reg.All()
if len(all) != 1 {
t.Fatalf("expected 1, got %d", len(all))
}
}