first commit
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
package skill
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
skills []*Skill
|
||||
dirs []string
|
||||
}
|
||||
|
||||
func NewManager(dir string) *Manager {
|
||||
dirs := []string{}
|
||||
if dir != "" {
|
||||
dirs = append(dirs, dir)
|
||||
} else {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
dirs = append(dirs, filepath.Join(home, ".config", "ai-agent", "skills"))
|
||||
}
|
||||
}
|
||||
return &Manager{dirs: dirs}
|
||||
}
|
||||
|
||||
func (m *Manager) AddSearchPath(dir string) {
|
||||
for _, d := range m.dirs {
|
||||
if d == dir {
|
||||
return
|
||||
}
|
||||
}
|
||||
m.dirs = append(m.dirs, dir)
|
||||
}
|
||||
|
||||
func (m *Manager) Names() []string {
|
||||
var names []string
|
||||
for _, s := range m.skills {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (m *Manager) LoadAll() error {
|
||||
for _, dir := range m.dirs {
|
||||
if err := m.loadFromDir(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) loadFromDir(dir string) error {
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("read skills dir: %w", err)
|
||||
}
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
skill, err := parseFrontmatter(string(data))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
skill.Path = path
|
||||
if skill.Name == "" {
|
||||
skill.Name = strings.TrimSuffix(entry.Name(), ".md")
|
||||
}
|
||||
m.skills = append(m.skills, skill)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) All() []*Skill {
|
||||
return m.skills
|
||||
}
|
||||
|
||||
func (m *Manager) Activate(name string) error {
|
||||
for _, s := range m.skills {
|
||||
if s.Name == name {
|
||||
s.Active = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("skill not found: %s", name)
|
||||
}
|
||||
|
||||
func (m *Manager) Deactivate(name string) error {
|
||||
for _, s := range m.skills {
|
||||
if s.Name == name {
|
||||
s.Active = false
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("skill not found: %s", name)
|
||||
}
|
||||
|
||||
func (m *Manager) ActiveContent() string {
|
||||
var parts []string
|
||||
for _, s := range m.skills {
|
||||
if s.Active && s.Content != "" {
|
||||
parts = append(parts, fmt.Sprintf("### %s\n%s", s.Name, s.Content))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package skill
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestManager_LoadAll(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create valid skill files.
|
||||
os.WriteFile(filepath.Join(dir, "greeting.md"), []byte("---\nname: greeting\ndescription: Say hello\n---\nHello!"), 0o644)
|
||||
os.WriteFile(filepath.Join(dir, "farewell.md"), []byte("---\nname: farewell\ndescription: Say bye\n---\nGoodbye!"), 0o644)
|
||||
|
||||
// Create a non-.md file (should be skipped).
|
||||
os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("not a skill"), 0o644)
|
||||
|
||||
// Create a subdirectory (should be skipped).
|
||||
os.MkdirAll(filepath.Join(dir, "subdir"), 0o755)
|
||||
|
||||
m := NewManager(dir)
|
||||
if err := m.LoadAll(); err != nil {
|
||||
t.Fatalf("LoadAll: %v", err)
|
||||
}
|
||||
|
||||
skills := m.All()
|
||||
if len(skills) != 2 {
|
||||
t.Fatalf("loaded %d skills, want 2", len(skills))
|
||||
}
|
||||
|
||||
names := map[string]bool{}
|
||||
for _, s := range skills {
|
||||
names[s.Name] = true
|
||||
}
|
||||
if !names["greeting"] {
|
||||
t.Error("missing 'greeting' skill")
|
||||
}
|
||||
if !names["farewell"] {
|
||||
t.Error("missing 'farewell' skill")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_LoadAll_NoFrontmatter(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// File without frontmatter uses filename as name.
|
||||
os.WriteFile(filepath.Join(dir, "plain.md"), []byte("Just content, no frontmatter"), 0o644)
|
||||
|
||||
m := NewManager(dir)
|
||||
if err := m.LoadAll(); err != nil {
|
||||
t.Fatalf("LoadAll: %v", err)
|
||||
}
|
||||
|
||||
skills := m.All()
|
||||
if len(skills) != 1 {
|
||||
t.Fatalf("loaded %d skills, want 1", len(skills))
|
||||
}
|
||||
if skills[0].Name != "plain" {
|
||||
t.Errorf("Name = %q, want 'plain'", skills[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_LoadAll_NonexistentDir(t *testing.T) {
|
||||
m := NewManager("/nonexistent/path/that/does/not/exist")
|
||||
if err := m.LoadAll(); err != nil {
|
||||
t.Fatalf("LoadAll on nonexistent dir should not error, got: %v", err)
|
||||
}
|
||||
if len(m.All()) != 0 {
|
||||
t.Errorf("expected 0 skills from nonexistent dir, got %d", len(m.All()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_Activate_Deactivate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "test.md"), []byte("---\nname: test\n---\nTest content"), 0o644)
|
||||
|
||||
m := NewManager(dir)
|
||||
m.LoadAll()
|
||||
|
||||
t.Run("activate found", func(t *testing.T) {
|
||||
err := m.Activate("test")
|
||||
if err != nil {
|
||||
t.Fatalf("Activate: %v", err)
|
||||
}
|
||||
skill := m.All()[0]
|
||||
if !skill.Active {
|
||||
t.Error("skill should be active after Activate")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("activate not found", func(t *testing.T) {
|
||||
err := m.Activate("nonexistent")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent skill")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deactivate found", func(t *testing.T) {
|
||||
err := m.Deactivate("test")
|
||||
if err != nil {
|
||||
t.Fatalf("Deactivate: %v", err)
|
||||
}
|
||||
skill := m.All()[0]
|
||||
if skill.Active {
|
||||
t.Error("skill should be inactive after Deactivate")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deactivate not found", func(t *testing.T) {
|
||||
err := m.Deactivate("nonexistent")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent skill")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestManager_ActiveContent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "alpha.md"), []byte("---\nname: alpha\n---\nAlpha content"), 0o644)
|
||||
os.WriteFile(filepath.Join(dir, "beta.md"), []byte("---\nname: beta\n---\nBeta content"), 0o644)
|
||||
|
||||
m := NewManager(dir)
|
||||
m.LoadAll()
|
||||
|
||||
t.Run("none active returns empty", func(t *testing.T) {
|
||||
content := m.ActiveContent()
|
||||
if content != "" {
|
||||
t.Errorf("expected empty content, got %q", content)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("one active returns its content", func(t *testing.T) {
|
||||
m.Activate("alpha")
|
||||
content := m.ActiveContent()
|
||||
if content == "" {
|
||||
t.Fatal("expected non-empty content")
|
||||
}
|
||||
if !contains(content, "Alpha content") {
|
||||
t.Errorf("content missing 'Alpha content': %q", content)
|
||||
}
|
||||
if contains(content, "Beta content") {
|
||||
t.Errorf("content should not contain inactive 'Beta content': %q", content)
|
||||
}
|
||||
m.Deactivate("alpha")
|
||||
})
|
||||
|
||||
t.Run("multiple active returns combined", func(t *testing.T) {
|
||||
m.Activate("alpha")
|
||||
m.Activate("beta")
|
||||
content := m.ActiveContent()
|
||||
if !contains(content, "Alpha content") || !contains(content, "Beta content") {
|
||||
t.Errorf("combined content missing expected parts: %q", content)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && searchString(s, substr)
|
||||
}
|
||||
|
||||
func searchString(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package skill
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Skill represents a loadable skill definition.
|
||||
type Skill struct {
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
Active bool `yaml:"-"`
|
||||
Content string `yaml:"-"` // markdown body after frontmatter
|
||||
Path string `yaml:"-"` // file path
|
||||
}
|
||||
|
||||
// parseFrontmatter extracts YAML frontmatter and markdown body from a skill file.
|
||||
// Frontmatter is delimited by "---" on the first and closing lines.
|
||||
func parseFrontmatter(data string) (*Skill, error) {
|
||||
scanner := bufio.NewScanner(strings.NewReader(data))
|
||||
|
||||
// Check for opening "---".
|
||||
if !scanner.Scan() || strings.TrimSpace(scanner.Text()) != "---" {
|
||||
// No frontmatter — treat entire content as body.
|
||||
return &Skill{Content: data}, nil
|
||||
}
|
||||
|
||||
// Read YAML lines until closing "---".
|
||||
var yamlBuf strings.Builder
|
||||
foundEnd := false
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.TrimSpace(line) == "---" {
|
||||
foundEnd = true
|
||||
break
|
||||
}
|
||||
yamlBuf.WriteString(line)
|
||||
yamlBuf.WriteString("\n")
|
||||
}
|
||||
|
||||
if !foundEnd {
|
||||
// No closing delimiter — treat as body only.
|
||||
return &Skill{Content: data}, nil
|
||||
}
|
||||
|
||||
// Parse YAML frontmatter.
|
||||
s := &Skill{}
|
||||
if err := yaml.Unmarshal([]byte(yamlBuf.String()), s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Remaining content is the markdown body.
|
||||
var bodyBuf strings.Builder
|
||||
for scanner.Scan() {
|
||||
if bodyBuf.Len() > 0 {
|
||||
bodyBuf.WriteString("\n")
|
||||
}
|
||||
bodyBuf.WriteString(scanner.Text())
|
||||
}
|
||||
s.Content = strings.TrimSpace(bodyBuf.String())
|
||||
|
||||
return s, nil
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package skill
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseFrontmatter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantName string
|
||||
wantDesc string
|
||||
wantContent string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid frontmatter",
|
||||
input: "---\nname: test\ndescription: desc\n---\nBody content",
|
||||
wantName: "test",
|
||||
wantDesc: "desc",
|
||||
wantContent: "Body content",
|
||||
},
|
||||
{
|
||||
name: "no frontmatter",
|
||||
input: "Just body",
|
||||
wantContent: "Just body",
|
||||
},
|
||||
{
|
||||
name: "missing closing delimiter",
|
||||
input: "---\nname: test\nBody",
|
||||
wantContent: "---\nname: test\nBody",
|
||||
},
|
||||
{
|
||||
name: "invalid YAML",
|
||||
input: "---\n: :\n---\nbody",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty body",
|
||||
input: "---\nname: test\n---\n",
|
||||
wantName: "test",
|
||||
wantContent: "",
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
wantContent: "",
|
||||
},
|
||||
{
|
||||
name: "multiline body",
|
||||
input: "---\nname: multi\n---\nline 1\nline 2\nline 3",
|
||||
wantName: "multi",
|
||||
wantContent: "line 1\nline 2\nline 3",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
skill, err := parseFrontmatter(tt.input)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if skill.Name != tt.wantName {
|
||||
t.Errorf("Name = %q, want %q", skill.Name, tt.wantName)
|
||||
}
|
||||
if skill.Description != tt.wantDesc {
|
||||
t.Errorf("Description = %q, want %q", skill.Description, tt.wantDesc)
|
||||
}
|
||||
if skill.Content != tt.wantContent {
|
||||
t.Errorf("Content = %q, want %q", skill.Content, tt.wantContent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user