package skills
import (
"os"
"path/filepath"
"strings"
"testing"
)
// write creates <root>/<slug>/SKILL.md with the given contents.
func write(t *testing.T, root, slug, body string) string {
t.Helper()
dir := filepath.Join(root, slug)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
return dir
}
const wellFormed = `---
name: commit-helper
description: Use when writing commit messages
---
Read the last twenty commits for tone.
`
func TestDiscoverWellFormed(t *testing.T) {
home, work := t.TempDir(), t.TempDir()
dir := write(t, filepath.Join(home, ".agents", "skills"), "commit-helper", wellFormed)
found, problems := Discover(home, work)
if len(problems) != 0 {
t.Fatalf("problems: %v", problems)
}
if len(found) != 1 {
t.Fatalf("got %d skills, want 1", len(found))
}
s := found[0]
if s.Name != "commit-helper" {
t.Errorf("Name = %q", s.Name)
}
if s.Description != "Use when writing commit messages" {
t.Errorf("Description = %q", s.Description)
}
if s.Source != "personal" {
t.Errorf("Source = %q, want personal", s.Source)
}
if s.Dir != dir {
t.Errorf("Dir = %q, want %q", s.Dir, dir)
}
if !strings.Contains(s.Body, "last twenty commits") {
t.Errorf("Body = %q", s.Body)
}
if strings.Contains(s.Body, "description:") {
t.Error("Body still contains the frontmatter")
}
}
func TestProjectShadowsPersonal(t *testing.T) {
home, work := t.TempDir(), t.TempDir()
write(t, filepath.Join(home, ".agents", "skills"), "dup", `---
name: dup
description: personal one
---
personal body
`)
write(t, filepath.Join(work, ".agents", "skills"), "dup", `---
name: dup
description: project one
---
project body
`)
found, problems := Discover(home, work)
if len(problems) != 0 {
t.Fatalf("problems: %v", problems)
}
if len(found) != 1 {
t.Fatalf("got %d skills, want 1", len(found))
}
if found[0].Description != "project one" || found[0].Source != "project" {
t.Errorf("personal skill was not shadowed: %+v", found[0])
}
}
func TestMalformedSkillsAreProblemsAndDoNotBlockOthers(t *testing.T) {
home, work := t.TempDir(), t.TempDir()
root := filepath.Join(home, ".agents", "skills")
write(t, root, "good", wellFormed2())
write(t, root, "no-frontmatter", "just a body, no frontmatter\n")
write(t, root, "no-description", "---\nname: no-description\n---\nbody\n")
write(t, root, "wrong-name", "---\nname: something-else\ndescription: d\n---\nbody\n")
write(t, root, "Bad_Slug", "---\nname: Bad_Slug\ndescription: d\n---\nbody\n")
found, problems := Discover(home, work)
if len(found) != 1 || found[0].Name != "good" {
t.Fatalf("got %+v, want only the good skill", found)
}
if len(problems) != 4 {
t.Fatalf("got %d problems, want 4: %v", len(problems), problems)
}
// Every problem names the offending path, or it is not actionable.
for _, p := range problems {
if !strings.Contains(p.Error(), root) {
t.Errorf("problem does not name a path: %v", p)
}
}
}
func wellFormed2() string {
return "---\nname: good\ndescription: a good skill\n---\nbody\n"
}
func TestDescriptionScalarStyles(t *testing.T) {
// A folded and a quoted description must parse to the same text as a plain
// one. This is the whole reason for the yaml dependency.
cases := map[string]string{
"plain": "description: Use when writing commit messages",
"quoted": `description: "Use when writing commit messages"`,
"folded": "description: >-\n Use when writing\n commit messages",
}
for slug, line := range cases {
t.Run(slug, func(t *testing.T) {
home, work := t.TempDir(), t.TempDir()
write(t, filepath.Join(home, ".agents", "skills"), slug,
"---\nname: "+slug+"\n"+line+"\n---\nbody\n")
found, problems := Discover(home, work)
if len(problems) != 0 {
t.Fatalf("problems: %v", problems)
}
if len(found) != 1 || found[0].Description != "Use when writing commit messages" {
t.Fatalf("Description = %+v", found)
}
})
}
}
func TestNoRootsIsSilent(t *testing.T) {
found, problems := Discover(t.TempDir(), t.TempDir())
if len(found) != 0 || len(problems) != 0 {
t.Fatalf("found %v, problems %v; want both empty", found, problems)
}
}
func TestNestedSkillIsNotDiscovered(t *testing.T) {
home, work := t.TempDir(), t.TempDir()
write(t, filepath.Join(home, ".agents", "skills", "outer"), "inner", wellFormed2())
found, problems := Discover(home, work)
if len(found) != 0 {
t.Fatalf("found %+v, want none", found)
}
// "outer" has no SKILL.md of its own; that is not a problem, just not a skill.
if len(problems) != 0 {
t.Fatalf("problems: %v", problems)
}
}
func TestSymlinkedSkillIsDiscovered(t *testing.T) {
home, work := t.TempDir(), t.TempDir()
elsewhere := t.TempDir()
realDir := write(t, elsewhere, "linked-in", `---
name: linked-in
description: reached through a symlink
---
symlinked body
`)
root := filepath.Join(home, ".agents", "skills")
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
link := filepath.Join(root, "linked-in")
if err := os.Symlink(realDir, link); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
found, problems := Discover(home, work)
if len(problems) != 0 {
t.Fatalf("problems: %v", problems)
}
if len(found) != 1 {
t.Fatalf("got %d skills, want 1", len(found))
}
if found[0].Name != "linked-in" {
t.Errorf("Name = %q", found[0].Name)
}
if found[0].Description != "reached through a symlink" {
t.Errorf("Description = %q", found[0].Description)
}
}
func TestEmptyHomeDoesNotScanWorkingDirectory(t *testing.T) {
cwd := t.TempDir()
write(t, filepath.Join(cwd, ".agents", "skills"), "leaky", wellFormed2())
t.Chdir(cwd)
found, problems := Discover("", t.TempDir())
if len(found) != 0 {
t.Fatalf("found %+v, want none", found)
}
if len(problems) != 0 {
t.Fatalf("problems: %v", problems)
}
}
func TestRelativeWorkrootDoesNotScanWorkingDirectory(t *testing.T) {
cwd := t.TempDir()
write(t, filepath.Join(cwd, ".agents-probe-relative", ".agents", "skills"), "leaky", wellFormed2())
t.Chdir(cwd)
found, problems := Discover(t.TempDir(), ".agents-probe-relative")
if len(found) != 0 {
t.Fatalf("found %+v, want none", found)
}
if len(problems) != 0 {
t.Fatalf("problems: %v", problems)
}
}
func TestSortedByName(t *testing.T) {
home, work := t.TempDir(), t.TempDir()
root := filepath.Join(home, ".agents", "skills")
for _, slug := range []string{"zebra", "alpha", "middle"} {
write(t, root, slug, "---\nname: "+slug+"\ndescription: d\n---\nbody\n")
}
found, _ := Discover(home, work)
var got []string
for _, s := range found {
got = append(got, s.Name)
}
if strings.Join(got, ",") != "alpha,middle,zebra" {
t.Errorf("order = %v", got)
}
}