package skills import ( "os" "path/filepath" "strings" "testing" ) // write creates //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 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) } }