From 995b26ffe765c89339b525d108df47cbbae19098 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Tue, 7 Apr 2026 02:17:17 +0200 Subject: [PATCH] feat(skill): registry with multi-directory loading and precedence --- internal/skill/registry.go | 109 ++++++++++++++++++++++++++++ internal/skill/registry_test.go | 125 ++++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 internal/skill/registry.go create mode 100644 internal/skill/registry_test.go diff --git a/internal/skill/registry.go b/internal/skill/registry.go new file mode 100644 index 0000000..5a50cd2 --- /dev/null +++ b/internal/skill/registry.go @@ -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 +} diff --git a/internal/skill/registry_test.go b/internal/skill/registry_test.go new file mode 100644 index 0000000..749dfd4 --- /dev/null +++ b/internal/skill/registry_test.go @@ -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)) + } +}