forked from bots-garden/ori
| ✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme | 1 | package skills_test |
| 2 | ||
| 3 | import ( | |
| 4 | "encoding/json" | |
| 5 | "net/http" | |
| 6 | "net/http/httptest" | |
| 7 | "os" | |
| 8 | "path/filepath" | |
| 9 | "testing" | |
| 10 | ||
| 11 | "rickub.com/bots-garden/ori/internal/skills" | |
| 12 | ) | |
| 13 | ||
| 14 | // writeSkill creates <base>/.claude/skills/<dir>/SKILL.md with content. | |
| 15 | func writeSkill(t *testing.T, base, dir, content string) string { | |
| 16 | t.Helper() | |
| 17 | full := filepath.Join(base, ".claude", "skills", dir) | |
| 18 | if err := os.MkdirAll(full, 0o755); err != nil { | |
| 19 | t.Fatal(err) | |
| 20 | } | |
| 21 | path := filepath.Join(full, "SKILL.md") | |
| 22 | if err := os.WriteFile(path, []byte(content), 0o644); err != nil { | |
| 23 | t.Fatal(err) | |
| 24 | } | |
| 25 | return path | |
| 26 | } | |
| 27 | ||
| 28 | func TestDiscoverParsesFrontmatterAndFallsBackToDirectoryName(t *testing.T) { | |
| 29 | cwd := t.TempDir() | |
| 30 | writeSkill(t, cwd, "quality", "---\nname: quality\ndescription: Audit code quality with qlty.\n---\n# Quality\n") | |
| 31 | writeSkill(t, cwd, "bare-dir", "# No frontmatter here\n") | |
| 32 | writeSkill(t, cwd, "quoted", "---\nname: \"fancy-name\"\ndescription: 'Single quoted: with colon'\nother: ignored\n---\n") | |
| 33 | writeSkill(t, cwd, "folded", "---\nname: folded\ndescription: >\n First line of the\n description.\n---\n") | |
| 34 | // A stray file (not a directory) and a directory without SKILL.md are ignored. | |
| 35 | if err := os.WriteFile(filepath.Join(cwd, ".claude", "skills", "README.md"), []byte("x"), 0o644); err != nil { | |
| 36 | t.Fatal(err) | |
| 37 | } | |
| 38 | if err := os.MkdirAll(filepath.Join(cwd, ".claude", "skills", "empty"), 0o755); err != nil { | |
| 39 | t.Fatal(err) | |
| 40 | } | |
| 41 | ||
| 42 | got := skills.New(cwd, "").Discover() | |
| 43 | want := []skills.Skill{ | |
| 44 | {Name: "bare-dir", Description: ""}, | |
| 45 | {Name: "fancy-name", Description: "Single quoted: with colon"}, | |
| 46 | {Name: "folded", Description: "First line of the description."}, | |
| 47 | {Name: "quality", Description: "Audit code quality with qlty."}, | |
| 48 | } | |
| 49 | if len(got) != len(want) { | |
| 50 | t.Fatalf("Discover = %+v, want %d skills", got, len(want)) | |
| 51 | } | |
| 52 | for i := range want { | |
| 53 | if got[i].Name != want[i].Name || got[i].Description != want[i].Description { | |
| 54 | t.Errorf("skill[%d] = %+v, want %+v", i, got[i], want[i]) | |
| 55 | } | |
| 56 | if got[i].Source != skills.SourceProject || filepath.Base(got[i].Path) != "SKILL.md" { | |
| 57 | t.Errorf("skill[%d] source/path = %q %q", i, got[i].Source, got[i].Path) | |
| 58 | } | |
| 59 | } | |
| 60 | } | |
| 61 | ||
| 62 | func TestDiscoverMergesUserSkillsAndProjectShadowsThem(t *testing.T) { | |
| 63 | cwd, home := t.TempDir(), t.TempDir() | |
| 64 | writeSkill(t, home, "shared", "---\nname: shared\ndescription: user version\n---\n") | |
| 65 | writeSkill(t, home, "user-only", "---\nname: user-only\ndescription: only at home\n---\n") | |
| 66 | writeSkill(t, cwd, "shared", "---\nname: shared\ndescription: project version\n---\n") | |
| 67 | ||
| 68 | got := skills.New(cwd, home).Discover() | |
| 69 | if len(got) != 2 { | |
| 70 | t.Fatalf("Discover = %+v, want 2 skills", got) | |
| 71 | } | |
| 72 | if got[0].Name != "shared" || got[0].Description != "project version" || got[0].Source != skills.SourceProject { | |
| 73 | t.Errorf("shared = %+v, want the project version", got[0]) | |
| 74 | } | |
| 75 | if got[1].Name != "user-only" || got[1].Source != skills.SourceUser { | |
| 76 | t.Errorf("user-only = %+v, want the user skill", got[1]) | |
| 77 | } | |
| 78 | } | |
| 79 | ||
| 80 | func TestDiscoverWithoutSkillDirectoriesIsEmpty(t *testing.T) { | |
| 81 | if got := skills.New(t.TempDir(), t.TempDir()).Discover(); len(got) != 0 { | |
| 82 | t.Errorf("Discover = %+v, want none", got) | |
| 83 | } | |
| 84 | } | |
| 85 | ||
| 86 | func TestEndpointReturnsJSONArray(t *testing.T) { | |
| 87 | cwd := t.TempDir() | |
| 88 | writeSkill(t, cwd, "demo", "---\nname: demo\ndescription: A demo skill\n---\n") | |
| 89 | mux := http.NewServeMux() | |
| 90 | for pattern, handler := range skills.New(cwd, "").Routes() { | |
| 91 | mux.Handle(pattern, handler) | |
| 92 | } | |
| 93 | ||
| 94 | rec := httptest.NewRecorder() | |
| 95 | mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/skills", nil)) | |
| 96 | if rec.Code != http.StatusOK { | |
| 97 | t.Fatalf("GET /api/skills = %d", rec.Code) | |
| 98 | } | |
| 99 | var payload struct { | |
| 100 | Skills []skills.Skill `json:"skills"` | |
| 101 | } | |
| 102 | if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { | |
| 103 | t.Fatalf("not JSON: %v", err) | |
| 104 | } | |
| 105 | if len(payload.Skills) != 1 || payload.Skills[0].Name != "demo" || payload.Skills[0].Description != "A demo skill" { | |
| 106 | t.Errorf("skills = %+v", payload.Skills) | |
| 107 | } | |
| 108 | ||
| 109 | // No skills anywhere: still a JSON array, never null. | |
| 110 | empty := http.NewServeMux() | |
| 111 | for pattern, handler := range skills.New(t.TempDir(), "").Routes() { | |
| 112 | empty.Handle(pattern, handler) | |
| 113 | } | |
| 114 | rec = httptest.NewRecorder() | |
| 115 | empty.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/skills", nil)) | |
| 116 | if body := rec.Body.String(); body != "{\"skills\":[]}\n" { | |
| 117 | t.Errorf("empty body = %q", body) | |
| 118 | } | |
| 119 | } |