// Tests for the skills package. What matters here: the catalogue is a TOOL // DESCRIPTION, so what the model knows about the available skills is exactly // what these functions produce. One header read wrong, and the model can no // longer name the skill it wants. package skills import ( "os" "path/filepath" "strings" "testing" ) // write creates a skill file in a temporary directory. func write(t *testing.T, dir, name, content string) { t.Helper() if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { t.Fatal(err) } } func TestParseHeader(t *testing.T) { cases := []struct { name string content string wantName string wantDesc string }{{ name: "complete header", content: "---\nname: go-rename\ndescription: rename a symbol\n---\n# Title\n", wantName: "go-rename", wantDesc: "rename a symbol", }, { name: "no header: the name comes from the file", content: "# Just some markdown\n", wantName: "file", wantDesc: "", }, { name: "empty name: the file name is kept", content: "---\nname:\ndescription: something\n---\n", wantName: "file", wantDesc: "something", }, { // The case parseHeader's comment announces: a `description:` in the // BODY is not metadata. name: "description in the body: ignored", content: "---\nname: go-test\n---\ndescription: this is just text\n", wantName: "go-test", wantDesc: "", }, { name: "spaces around the values", content: "---\n name : go-fmt \n description : format code \n---\n", wantName: "go-fmt", wantDesc: "format code", }, { name: "a colon inside the description", content: "---\nname: go-run\ndescription: run: execute a program\n---\n", wantName: "go-run", wantDesc: "run: execute a program", }} for _, c := range cases { t.Run(c.name, func(t *testing.T) { s := parseHeader(c.content, "/tmp/file.md") if s.Name != c.wantName { t.Errorf("Name = %q, want %q", s.Name, c.wantName) } if s.Description != c.wantDesc { t.Errorf("Description = %q, want %q", s.Description, c.wantDesc) } }) } } func TestListSortsAndReadsHeaders(t *testing.T) { dir := t.TempDir() write(t, dir, "zeta.md", "---\nname: zeta\ndescription: the last one\n---\n") write(t, dir, "alpha.md", "---\nname: alpha\ndescription: the first one\n---\n") write(t, dir, "notes.txt", "not a skill") // ignored: not *.md list := List(dir) if len(list) != 2 { t.Fatalf("%d skills, want 2: %+v", len(list), list) } if list[0].Name != "alpha" || list[1].Name != "zeta" { t.Errorf("expected a sort by name, got %q then %q", list[0].Name, list[1].Name) } } // A missing directory is not an error: this agent simply has no skills, and // main.go will not declare the tool. func TestListMissingDirIsEmpty(t *testing.T) { if list := List(filepath.Join(t.TempDir(), "doesnotexist")); len(list) != 0 { t.Errorf("%d skills for a missing directory, want 0", len(list)) } } func TestRead(t *testing.T) { dir := t.TempDir() write(t, dir, "go-fmt.md", "---\nname: go-fmt\n---\n# Format\n") content, err := Read(dir, "go-fmt") if err != nil { t.Fatalf("Read: %v", err) } if !strings.Contains(content, "# Format") { t.Errorf("unexpected content: %q", content) } if _, err := Read(dir, "unknown"); err == nil { t.Error("an unknown skill must return an error") } } // filepath.Base in Read is a barrier: the name comes from the MODEL, so it must // not be able to escape the skills directory. func TestReadCannotEscapeDir(t *testing.T) { dir := t.TempDir() parent := filepath.Dir(dir) if err := os.WriteFile(filepath.Join(parent, "secret.md"), []byte("forbidden"), 0o644); err != nil { t.Fatal(err) } for _, name := range []string{"../secret", "../../secret", "/etc/passwd"} { if content, err := Read(dir, name); err == nil { t.Errorf("Read(%q) succeeded and returned %q — the barrier is gone", name, content) } } } // The catalogue IS the tool description: every skill must appear in it with its // name, otherwise the model cannot ask for it. func TestCatalogue(t *testing.T) { list := []Skill{ {Name: "go-fmt", Description: "format code"}, {Name: "go-test"}, // no description } cat := Catalogue(list) for _, want := range []string{"go-fmt", "format code", "go-test"} { if !strings.Contains(cat, want) { t.Errorf("the catalogue does not contain %q:\n%s", want, cat) } } if !strings.Contains(cat, "go-fmt — format code") { t.Errorf("name and description must be joined by a dash:\n%s", cat) } // A skill with no description must not leave an orphan dash. if strings.Contains(cat, "go-test —") { t.Errorf("orphan dash for a skill with no description:\n%s", cat) } } func TestNames(t *testing.T) { got := Names([]Skill{{Name: "a"}, {Name: "b"}}) if strings.Join(got, ",") != "a,b" { t.Errorf("Names = %v", got) } } // The skills actually shipped in the repository's skills/ must all have a // name AND a description: without a description, the model picks blindly. // // The path is the real layout — skills/ at the repository root, one directory // per skill. A t.Skip() here would mean "I am testing nothing" without saying // so, so a missing directory is an ERROR, not a skip. func TestShippedSkillsHaveDescriptions(t *testing.T) { dir := filepath.Join("..", "..", "skills") list := List(dir) if len(list) == 0 { t.Fatalf("no skill found in %s — has the layout changed?", dir) } for _, s := range list { if s.Description == "" { t.Errorf("skill %q has no description", s.Name) } if strings.Contains(s.Name, " ") { t.Errorf("name %q contains a space: the model will not be able to quote it", s.Name) } } t.Logf("%d shipped skills, all described", len(list)) } // writeNested stores a skill the Agent Skills way: