skills support

This commit is contained in:
Luxferre
2026-09-10 10:38:36 +03:00
parent 5b5853a2dd
commit b3f2864f6a
3 changed files with 220 additions and 2 deletions
+108
View File
@@ -2525,3 +2525,111 @@ func TestWriteFileOmittedOverwritesWholeFile(t *testing.T) {
}
// #18: skillsDir mirrors toolsDir, and skillPrompt composes SKILL.md + prompt.
func TestSkills(t *testing.T) {
base := t.TempDir()
skillDir := filepath.Join(base, "skills")
if err := os.MkdirAll(filepath.Join(skillDir, "greet"), 0755); err != nil {
t.Fatal(err)
}
skillMd := "You are a friendly greeter.\n"
if err := os.WriteFile(filepath.Join(skillDir, "greet", "SKILL.md"), []byte(skillMd), 0644); err != nil {
t.Fatal(err)
}
// skillsDir prefers the env var, then the config key.
t.Setenv("BANTAM_SKILLS_DIR", skillDir)
if got := skillsDir(&Cfg{}); got != skillDir {
t.Errorf("skillsDir() with env = %q, want %q", got, skillDir)
}
t.Setenv("BANTAM_SKILLS_DIR", "")
if got := skillsDir(&Cfg{Raw: map[string]string{"bantam_skills_dir": skillDir}}); got != skillDir {
t.Errorf("skillsDir() with config = %q, want %q", got, skillDir)
}
// With a skills dir set, the name is resolved relative to it.
got, err := skillPrompt("/skill greet hello there", &Cfg{Raw: map[string]string{"bantam_skills_dir": skillDir}})
if err != nil {
t.Fatalf("skillPrompt(relative): %v", err)
}
want := skillMd + "\nhello there"
if got != want {
t.Errorf("skillPrompt(relative) = %q, want %q", got, want)
}
// With no skills dir, the name is an absolute path to the skill directory.
got, err = skillPrompt("/skill "+filepath.Join(skillDir, "greet")+" just hi", &Cfg{})
if err != nil {
t.Fatalf("skillPrompt(absolute): %v", err)
}
if got != skillMd+"\njust hi" {
t.Errorf("skillPrompt(absolute) = %q, want %q", got, skillMd+"\njust hi")
}
// Absolute path directly to a SKILL.md file also works.
got, err = skillPrompt("/skill "+filepath.Join(skillDir, "greet", "SKILL.md"), &Cfg{})
if err != nil {
t.Fatalf("skillPrompt(file): %v", err)
}
if got != strings.TrimRight(skillMd, "\n") {
t.Errorf("skillPrompt(file) = %q, want %q", got, strings.TrimRight(skillMd, "\n"))
}
// Missing skill reports an error; bare /skill reports usage.
if _, err := skillPrompt("/skill nope", &Cfg{Raw: map[string]string{"bantam_skills_dir": skillDir}}); err == nil {
t.Error("skillPrompt(missing) expected error, got nil")
}
if _, err := skillPrompt("/skill", &Cfg{}); err == nil {
t.Error("skillPrompt(bare) expected error, got nil")
}
}
// #19: bare /skill lists skills under the configured dir, or reports none.
func TestListSkills(t *testing.T) {
base := t.TempDir()
skillDir := filepath.Join(base, "skills")
if err := os.MkdirAll(filepath.Join(skillDir, "alpha"), 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(skillDir, "beta"), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, "alpha", "SKILL.md"), []byte("a"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, "beta", "SKILL.md"), []byte("b"), 0644); err != nil {
t.Fatal(err)
}
// a directory without SKILL.md must NOT be reported as a skill
if err := os.MkdirAll(filepath.Join(skillDir, "notaskill"), 0755); err != nil {
t.Fatal(err)
}
capture := func() string {
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
old := os.Stdout
os.Stdout = w
listSkills(&Cfg{Raw: map[string]string{"bantam_skills_dir": skillDir}})
w.Close()
os.Stdout = old
data, _ := io.ReadAll(r)
return string(data)
}
out := capture()
if !strings.Contains(out, "alpha") || !strings.Contains(out, "beta") {
t.Errorf("listSkills missing skills, got:\n%s", out)
}
if strings.Contains(out, "notaskill") {
t.Errorf("listSkills reported a non-skill directory, got:\n%s", out)
}
// No skills dir configured -> clear notice, no panic.
os.Stdout, _ = os.Open(os.DevNull)
listSkills(&Cfg{})
os.Stdout.Close()
}