@@ -0,0 +1,474 @@
+// Package skills implements the Agent Skills specification.
+// See https://agentskills.io for the full specification.
+package skills
+
+import (
+ "html"
+ "os"
+ "path/filepath"
+ "strings"
+ "unicode"
+)
+
+const (
+ MaxNameLength = 64
+ MaxDescriptionLength = 1024
+ MaxCompatibilityLength = 500
+)
+
+// Skill represents a parsed skill from a SKILL.md file.
+type Skill struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ License string `json:"license,omitempty"`
+ Compatibility string `json:"compatibility,omitempty"`
+ AllowedTools string `json:"allowed_tools,omitempty"`
+ Metadata map[string]string `json:"metadata,omitempty"`
+ Path string `json:"path"` // Path to SKILL.md file
+}
+
+// Discover finds all skills in the given directories.
+// It scans each directory for subdirectories containing SKILL.md files.
+func Discover(dirs []string) []Skill {
+ var skills []Skill
+ seen := make(map[string]bool)
+
+ for _, dir := range dirs {
+ dir = expandPath(dir)
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ continue
+ }
+
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+
+ skillDir := filepath.Join(dir, entry.Name())
+ skillMD := findSkillMD(skillDir)
+ if skillMD == "" {
+ continue
+ }
+
+ // Avoid duplicates
+ absPath, err := filepath.Abs(skillMD)
+ if err != nil {
+ continue
+ }
+ if seen[absPath] {
+ continue
+ }
+ seen[absPath] = true
+
+ skill, err := Parse(skillMD)
+ if err != nil {
+ continue // Skip invalid skills
+ }
+
+ // Validate name matches directory
+ if skill.Name != entry.Name() {
+ continue
+ }
+
+ skills = append(skills, skill)
+ }
+ }
+
+ return skills
+}
+
+// findSkillMD looks for SKILL.md or skill.md in a directory.
+func findSkillMD(dir string) string {
+ for _, name := range []string{"SKILL.md", "skill.md"} {
+ path := filepath.Join(dir, name)
+ if _, err := os.Stat(path); err == nil {
+ return path
+ }
+ }
+ return ""
+}
+
+// Parse reads and parses a SKILL.md file.
+func Parse(path string) (Skill, error) {
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return Skill{}, err
+ }
+
+ frontmatter, err := parseFrontmatter(string(content))
+ if err != nil {
+ return Skill{}, err
+ }
+
+ name, _ := frontmatter["name"].(string)
+ description, _ := frontmatter["description"].(string)
+
+ if name == "" || description == "" {
+ return Skill{}, &ValidationError{Message: "name and description are required"}
+ }
+
+ if err := validateName(name); err != nil {
+ return Skill{}, err
+ }
+
+ if len(description) > MaxDescriptionLength {
+ return Skill{}, &ValidationError{Message: "description exceeds maximum length"}
+ }
+
+ skill := Skill{
+ Name: name,
+ Description: description,
+ Path: path,
+ }
+
+ if license, ok := frontmatter["license"].(string); ok {
+ skill.License = license
+ }
+
+ if compat, ok := frontmatter["compatibility"].(string); ok {
+ if len(compat) > MaxCompatibilityLength {
+ return Skill{}, &ValidationError{Message: "compatibility exceeds maximum length"}
+ }
+ skill.Compatibility = compat
+ }
+
+ if tools, ok := frontmatter["allowed-tools"].(string); ok {
+ skill.AllowedTools = tools
+ }
+
+ if metadata, ok := frontmatter["metadata"].(map[string]any); ok {
+ skill.Metadata = make(map[string]string)
+ for k, v := range metadata {
+ if s, ok := v.(string); ok {
+ skill.Metadata[k] = s
+ }
+ }
+ }
+
+ return skill, nil
+}
+
+// ValidationError represents a skill validation error.
+type ValidationError struct {
+ Message string
+}
+
+func (e *ValidationError) Error() string {
+ return e.Message
+}
+
+// validateName checks that a skill name follows the spec.
+func validateName(name string) error {
+ if len(name) == 0 || len(name) > MaxNameLength {
+ return &ValidationError{Message: "name must be 1-64 characters"}
+ }
+
+ if name != strings.ToLower(name) {
+ return &ValidationError{Message: "name must be lowercase"}
+ }
+
+ if strings.HasPrefix(name, "-") || strings.HasSuffix(name, "-") {
+ return &ValidationError{Message: "name cannot start or end with hyphen"}
+ }
+
+ if strings.Contains(name, "--") {
+ return &ValidationError{Message: "name cannot contain consecutive hyphens"}
+ }
+
+ for _, r := range name {
+ if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' {
+ return &ValidationError{Message: "name can only contain letters, digits, and hyphens"}
+ }
+ }
+
+ return nil
+}
+
+// parseFrontmatter extracts YAML frontmatter from markdown content.
+// This is a simple parser that handles basic YAML without external dependencies.
+func parseFrontmatter(content string) (map[string]any, error) {
+ if !strings.HasPrefix(content, "---") {
+ return nil, &ValidationError{Message: "SKILL.md must start with YAML frontmatter (---)"}
+ }
+
+ parts := strings.SplitN(content, "---", 3)
+ if len(parts) < 3 {
+ return nil, &ValidationError{Message: "SKILL.md frontmatter not properly closed with ---"}
+ }
+
+ yamlContent := parts[1]
+ return parseSimpleYAML(yamlContent)
+}
+
+// parseSimpleYAML parses simple YAML frontmatter.
+// Supports: strings, and nested maps (for metadata).
+func parseSimpleYAML(content string) (map[string]any, error) {
+ result := make(map[string]any)
+ lines := strings.Split(content, "\n")
+
+ var currentKey string
+ var inNestedMap bool
+ nestedMap := make(map[string]any)
+
+ for _, line := range lines {
+ // Skip empty lines and comments
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
+ continue
+ }
+
+ // Check for nested map entries (indented with spaces)
+ if inNestedMap && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) {
+ parts := strings.SplitN(trimmed, ":", 2)
+ if len(parts) == 2 {
+ key := strings.TrimSpace(parts[0])
+ value := strings.TrimSpace(parts[1])
+ value = unquoteYAML(value)
+ nestedMap[key] = value
+ }
+ continue
+ }
+
+ // If we were in a nested map, save it
+ if inNestedMap && currentKey != "" {
+ result[currentKey] = nestedMap
+ nestedMap = make(map[string]any)
+ inNestedMap = false
+ }
+
+ // Parse top-level key: value
+ parts := strings.SplitN(trimmed, ":", 2)
+ if len(parts) != 2 {
+ continue
+ }
+
+ key := strings.TrimSpace(parts[0])
+ value := strings.TrimSpace(parts[1])
+
+ if value == "" {
+ // Could be start of a nested map
+ currentKey = key
+ inNestedMap = true
+ continue
+ }
+
+ value = unquoteYAML(value)
+ result[key] = value
+ }
+
+ // Handle final nested map
+ if inNestedMap && currentKey != "" && len(nestedMap) > 0 {
+ result[currentKey] = nestedMap
+ }
+
+ return result, nil
+}
+
+// unquoteYAML removes surrounding quotes from a YAML string value.
+func unquoteYAML(s string) string {
+ if len(s) >= 2 {
+ if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
+ return s[1 : len(s)-1]
+ }
+ }
+ return s
+}
+
+// ToPromptXML generates the <available_skills> XML block for system prompts.
+func ToPromptXML(skills []Skill) string {
+ if len(skills) == 0 {
+ return ""
+ }
+
+ var sb strings.Builder
+ sb.WriteString("<available_skills>\n")
+
+ for _, skill := range skills {
+ sb.WriteString("<skill>\n")
+ sb.WriteString("<name>")
+ sb.WriteString(html.EscapeString(skill.Name))
+ sb.WriteString("</name>\n")
+ sb.WriteString("<description>")
+ sb.WriteString(html.EscapeString(skill.Description))
+ sb.WriteString("</description>\n")
+ sb.WriteString("<location>")
+ sb.WriteString(html.EscapeString(skill.Path))
+ sb.WriteString("</location>\n")
+ sb.WriteString("</skill>\n")
+ }
+
+ sb.WriteString("</available_skills>")
+ return sb.String()
+}
+
+// DefaultDirs returns the default skill directories to search.
+func DefaultDirs() []string {
+ dirs := []string{}
+
+ // User-level skills from ~/.config/shelley/ (XDG convention)
+ // We scan all subdirectories of ~/.config/shelley/ for skills
+ if home, err := os.UserHomeDir(); err == nil {
+ configDir := filepath.Join(home, ".config", "shelley")
+ if entries, err := os.ReadDir(configDir); err == nil {
+ for _, entry := range entries {
+ if entry.IsDir() {
+ subdir := filepath.Join(configDir, entry.Name())
+ // Check if this directory contains skills (has subdirs with SKILL.md)
+ // or is itself a skill directory
+ if findSkillMD(subdir) != "" {
+ // This is a skill directory itself, add parent
+ dirs = append(dirs, configDir)
+ break
+ }
+ // Otherwise check if it's a container of skills
+ if hasSkillSubdirs(subdir) {
+ dirs = append(dirs, subdir)
+ }
+ }
+ }
+ }
+ // Also check legacy ~/.shelley/ location
+ shelleyDir := filepath.Join(home, ".shelley")
+ if entries, err := os.ReadDir(shelleyDir); err == nil {
+ for _, entry := range entries {
+ if entry.IsDir() {
+ subdir := filepath.Join(shelleyDir, entry.Name())
+ if findSkillMD(subdir) != "" {
+ dirs = append(dirs, shelleyDir)
+ break
+ }
+ if hasSkillSubdirs(subdir) {
+ dirs = append(dirs, subdir)
+ }
+ }
+ }
+ }
+ }
+
+ return dirs
+}
+
+// hasSkillSubdirs checks if a directory contains any skill subdirectories.
+func hasSkillSubdirs(dir string) bool {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return false
+ }
+ for _, entry := range entries {
+ if entry.IsDir() {
+ if findSkillMD(filepath.Join(dir, entry.Name())) != "" {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// expandPath expands ~ to the user's home directory.
+func expandPath(path string) string {
+ if strings.HasPrefix(path, "~/") {
+ if home, err := os.UserHomeDir(); err == nil {
+ return filepath.Join(home, path[2:])
+ }
+ }
+ return path
+}
+
+// ProjectSkillsDirs returns all .skills directories found by walking up from
+// the working directory to the git root (or filesystem root if no git root).
+func ProjectSkillsDirs(workingDir, gitRoot string) []string {
+ var dirs []string
+ seen := make(map[string]bool)
+
+ // Determine the stopping point
+ stopAt := gitRoot
+ if stopAt == "" {
+ stopAt = "/"
+ }
+
+ // Walk up from working directory
+ current := workingDir
+ for current != "" {
+ skillsDir := filepath.Join(current, ".skills")
+ if !seen[skillsDir] {
+ if info, err := os.Stat(skillsDir); err == nil && info.IsDir() {
+ dirs = append(dirs, skillsDir)
+ seen[skillsDir] = true
+ }
+ }
+
+ // Stop if we've reached the git root or filesystem root
+ if current == stopAt || current == "/" {
+ break
+ }
+
+ parent := filepath.Dir(current)
+ if parent == current {
+ break
+ }
+ current = parent
+ }
+
+ return dirs
+}
+
+// DiscoverInTree finds all skills by walking the directory tree looking for SKILL.md files.
+// If gitRoot is provided, it searches from gitRoot. Otherwise, it searches from workingDir downward.
+func DiscoverInTree(workingDir, gitRoot string) []Skill {
+ var skills []Skill
+ seen := make(map[string]bool)
+
+ // Determine root to search from
+ searchRoot := gitRoot
+ if searchRoot == "" {
+ searchRoot = workingDir
+ }
+
+ filepath.Walk(searchRoot, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return nil // Continue on errors
+ }
+
+ if info.IsDir() {
+ // Skip hidden directories and common ignore patterns
+ name := info.Name()
+ if name != "." && (strings.HasPrefix(name, ".") || name == "node_modules" || name == "vendor") {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+
+ // Check if this is a SKILL.md file
+ lowerName := strings.ToLower(info.Name())
+ if lowerName != "skill.md" {
+ return nil
+ }
+
+ // Avoid duplicates
+ absPath, err := filepath.Abs(path)
+ if err != nil {
+ return nil
+ }
+ if seen[absPath] {
+ return nil
+ }
+ seen[absPath] = true
+
+ skill, err := Parse(path)
+ if err != nil {
+ return nil // Skip invalid skills
+ }
+
+ // Validate name matches parent directory
+ parentDir := filepath.Base(filepath.Dir(path))
+ if skill.Name != parentDir {
+ return nil
+ }
+
+ skills = append(skills, skill)
+ return nil
+ })
+
+ return skills
+}
@@ -0,0 +1,447 @@
+package skills
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestParse(t *testing.T) {
+ tests := []struct {
+ name string
+ content string
+ wantName string
+ wantDesc string
+ wantError bool
+ }{
+ {
+ name: "basic skill",
+ content: `---
+name: pdf-processing
+description: Extract text and tables from PDF files.
+---
+
+Instructions here.
+`,
+ wantName: "pdf-processing",
+ wantDesc: "Extract text and tables from PDF files.",
+ wantError: false,
+ },
+ {
+ name: "with metadata",
+ content: `---
+name: data-analysis
+description: Analyzes datasets and generates reports.
+license: MIT
+metadata:
+ author: example-org
+ version: "1.0"
+---
+
+Body content.
+`,
+ wantName: "data-analysis",
+ wantDesc: "Analyzes datasets and generates reports.",
+ wantError: false,
+ },
+ {
+ name: "missing frontmatter",
+ content: "# Just markdown\n\nNo frontmatter here.",
+ wantError: true,
+ },
+ {
+ name: "missing name",
+ content: `---
+description: A skill without a name
+---
+`,
+ wantError: true,
+ },
+ {
+ name: "invalid name - uppercase",
+ content: `---
+name: PDF-Processing
+description: A skill with uppercase name
+---
+`,
+ wantError: true,
+ },
+ {
+ name: "invalid name - starts with hyphen",
+ content: `---
+name: -pdf
+description: A skill starting with hyphen
+---
+`,
+ wantError: true,
+ },
+ {
+ name: "invalid name - consecutive hyphens",
+ content: `---
+name: pdf--processing
+description: A skill with consecutive hyphens
+---
+`,
+ wantError: true,
+ },
+ {
+ name: "quoted values",
+ content: `---
+name: "my-skill"
+description: 'A skill with quoted values'
+---
+`,
+ wantName: "my-skill",
+ wantDesc: "A skill with quoted values",
+ wantError: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Create temp file
+ tmpDir := t.TempDir()
+ path := filepath.Join(tmpDir, "SKILL.md")
+ if err := os.WriteFile(path, []byte(tt.content), 0o644); err != nil {
+ t.Fatalf("failed to write test file: %v", err)
+ }
+
+ skill, err := Parse(path)
+ if tt.wantError {
+ if err == nil {
+ t.Error("expected error, got nil")
+ }
+ return
+ }
+
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if skill.Name != tt.wantName {
+ t.Errorf("name = %q, want %q", skill.Name, tt.wantName)
+ }
+
+ if skill.Description != tt.wantDesc {
+ t.Errorf("description = %q, want %q", skill.Description, tt.wantDesc)
+ }
+ })
+ }
+}
+
+func TestDiscover(t *testing.T) {
+ // Create a temp directory structure
+ tmpDir := t.TempDir()
+
+ // Create a valid skill
+ skillDir := filepath.Join(tmpDir, "my-skill")
+ if err := os.MkdirAll(skillDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ skillContent := `---
+name: my-skill
+description: A test skill for discovery.
+---
+
+Test instructions.
+`
+ if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(skillContent), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Create an invalid skill (name doesn't match directory)
+ badSkillDir := filepath.Join(tmpDir, "bad-skill")
+ if err := os.MkdirAll(badSkillDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ badSkillContent := `---
+name: different-name
+description: Name doesn't match directory.
+---
+`
+ if err := os.WriteFile(filepath.Join(badSkillDir, "SKILL.md"), []byte(badSkillContent), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Create a directory without SKILL.md
+ emptyDir := filepath.Join(tmpDir, "empty-skill")
+ if err := os.MkdirAll(emptyDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ skills := Discover([]string{tmpDir})
+
+ if len(skills) != 1 {
+ t.Fatalf("expected 1 skill, got %d", len(skills))
+ }
+
+ if skills[0].Name != "my-skill" {
+ t.Errorf("skill name = %q, want %q", skills[0].Name, "my-skill")
+ }
+}
+
+func TestToPromptXML(t *testing.T) {
+ skills := []Skill{
+ {
+ Name: "pdf-processing",
+ Description: "Extract text & tables from PDF files.",
+ Path: "/home/user/.shelley/skills/pdf-processing/SKILL.md",
+ },
+ {
+ Name: "data-analysis",
+ Description: "Analyze datasets and generate reports.",
+ Path: "/home/user/.shelley/skills/data-analysis/SKILL.md",
+ },
+ }
+
+ xml := ToPromptXML(skills)
+
+ // Check that it contains expected elements
+ expected := []string{
+ "<available_skills>",
+ "</available_skills>",
+ "<skill>",
+ "</skill>",
+ "<name>pdf-processing</name>",
+ "<description>Extract text & tables from PDF files.</description>",
+ "<location>/home/user/.shelley/skills/pdf-processing/SKILL.md</location>",
+ "<name>data-analysis</name>",
+ }
+
+ for _, s := range expected {
+ if !contains(xml, s) {
+ t.Errorf("expected XML to contain %q", s)
+ }
+ }
+}
+
+func TestToPromptXMLEmpty(t *testing.T) {
+ xml := ToPromptXML(nil)
+ if xml != "" {
+ t.Errorf("expected empty string for nil skills, got %q", xml)
+ }
+
+ xml = ToPromptXML([]Skill{})
+ if xml != "" {
+ t.Errorf("expected empty string for empty skills, got %q", xml)
+ }
+}
+
+func TestValidateName(t *testing.T) {
+ validNames := []string{
+ "a",
+ "pdf-processing",
+ "data-analysis",
+ "code-review",
+ "my-skill-123",
+ "skill",
+ }
+
+ for _, name := range validNames {
+ if err := validateName(name); err != nil {
+ t.Errorf("validateName(%q) returned error: %v", name, err)
+ }
+ }
+
+ invalidNames := []string{
+ "",
+ "PDF-Processing",
+ "-pdf",
+ "pdf-",
+ "pdf--processing",
+ "pdf processing",
+ "pdf_processing",
+ "pdf.processing",
+ }
+
+ for _, name := range invalidNames {
+ if err := validateName(name); err == nil {
+ t.Errorf("validateName(%q) should return error", name)
+ }
+ }
+}
+
+func contains(s, substr string) bool {
+ return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr))
+}
+
+func containsHelper(s, substr string) bool {
+ for i := 0; i <= len(s)-len(substr); i++ {
+ if s[i:i+len(substr)] == substr {
+ return true
+ }
+ }
+ return false
+}
+
+func TestDiscoverInTree(t *testing.T) {
+ // Create a directory structure:
+ // tmpDir/
+ // skill-root/
+ // SKILL.md
+ // subdir/
+ // nested/
+ // skill-nested/
+ // SKILL.md
+ // .hidden/
+ // skill-hidden/
+ // SKILL.md (should be skipped)
+ // node_modules/
+ // skill-nm/
+ // SKILL.md (should be skipped)
+
+ tmpDir := t.TempDir()
+
+ // Create root-level skill
+ rootSkillDir := filepath.Join(tmpDir, "skill-root")
+ if err := os.MkdirAll(rootSkillDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(rootSkillDir, "SKILL.md"), []byte("---\nname: skill-root\ndescription: Root skill\n---\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Create nested skill
+ nestedSkillDir := filepath.Join(tmpDir, "subdir", "nested", "skill-nested")
+ if err := os.MkdirAll(nestedSkillDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(nestedSkillDir, "SKILL.md"), []byte("---\nname: skill-nested\ndescription: Nested skill\n---\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Create hidden directory skill (should be skipped)
+ hiddenSkillDir := filepath.Join(tmpDir, ".hidden", "skill-hidden")
+ if err := os.MkdirAll(hiddenSkillDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(hiddenSkillDir, "SKILL.md"), []byte("---\nname: skill-hidden\ndescription: Hidden skill\n---\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Create node_modules skill (should be skipped)
+ nmSkillDir := filepath.Join(tmpDir, "node_modules", "skill-nm")
+ if err := os.MkdirAll(nmSkillDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(nmSkillDir, "SKILL.md"), []byte("---\nname: skill-nm\ndescription: Node modules skill\n---\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Test with git root
+ skills := DiscoverInTree(tmpDir, tmpDir)
+
+ if len(skills) != 2 {
+ t.Fatalf("expected 2 skills, got %d: %v", len(skills), skillNames(skills))
+ }
+
+ // Check we found the right skills
+ names := make(map[string]bool)
+ for _, s := range skills {
+ names[s.Name] = true
+ }
+
+ if !names["skill-root"] {
+ t.Error("expected to find skill-root")
+ }
+ if !names["skill-nested"] {
+ t.Error("expected to find skill-nested")
+ }
+ if names["skill-hidden"] {
+ t.Error("should not find skill-hidden (in hidden directory)")
+ }
+ if names["skill-nm"] {
+ t.Error("should not find skill-nm (in node_modules)")
+ }
+}
+
+func TestDiscoverInTreeNoGitRoot(t *testing.T) {
+ // When no git root, should search from working dir
+ tmpDir := t.TempDir()
+
+ // Create a skill
+ skillDir := filepath.Join(tmpDir, "my-skill")
+ if err := os.MkdirAll(skillDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: my-skill\ndescription: Test skill\n---\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Test with empty git root
+ skills := DiscoverInTree(tmpDir, "")
+
+ if len(skills) != 1 {
+ t.Fatalf("expected 1 skill, got %d", len(skills))
+ }
+ if skills[0].Name != "my-skill" {
+ t.Errorf("expected my-skill, got %s", skills[0].Name)
+ }
+}
+
+func skillNames(skills []Skill) []string {
+ names := make([]string, len(skills))
+ for i, s := range skills {
+ names[i] = s.Name
+ }
+ return names
+}
+
+func TestProjectSkillsDirs(t *testing.T) {
+ // Create a directory structure:
+ // tmpDir/
+ // .skills/
+ // skill-a/
+ // SKILL.md
+ // subdir/
+ // .skills/
+ // skill-b/
+ // SKILL.md
+ // deeper/
+ // (working dir)
+
+ tmpDir := t.TempDir()
+
+ // Create root .skills
+ rootSkillsDir := filepath.Join(tmpDir, ".skills", "skill-a")
+ if err := os.MkdirAll(rootSkillsDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(rootSkillsDir, "SKILL.md"), []byte("---\nname: skill-a\ndescription: Skill A\n---\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Create subdir .skills
+ subSkillsDir := filepath.Join(tmpDir, "subdir", ".skills", "skill-b")
+ if err := os.MkdirAll(subSkillsDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(subSkillsDir, "SKILL.md"), []byte("---\nname: skill-b\ndescription: Skill B\n---\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Create deeper working directory
+ workingDir := filepath.Join(tmpDir, "subdir", "deeper")
+ if err := os.MkdirAll(workingDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ // Test walking up from working dir to git root (tmpDir)
+ dirs := ProjectSkillsDirs(workingDir, tmpDir)
+
+ // Should find both .skills directories
+ if len(dirs) != 2 {
+ t.Fatalf("expected 2 .skills dirs, got %d: %v", len(dirs), dirs)
+ }
+
+ // subdir/.skills should come first (closer to working dir)
+ expectedFirst := filepath.Join(tmpDir, "subdir", ".skills")
+ expectedSecond := filepath.Join(tmpDir, ".skills")
+
+ if dirs[0] != expectedFirst {
+ t.Errorf("first dir = %q, want %q", dirs[0], expectedFirst)
+ }
+ if dirs[1] != expectedSecond {
+ t.Errorf("second dir = %q, want %q", dirs[1], expectedSecond)
+ }
+}