diff --git a/server/system_prompt.go b/server/system_prompt.go index 3cf358d4c668a641083854f9a25ddd8a4b339993..1b0d1b0863bb681b5f0b70368ab39acf6dbb89c9 100644 --- a/server/system_prompt.go +++ b/server/system_prompt.go @@ -8,6 +8,8 @@ import ( "path/filepath" "strings" "text/template" + + "shelley.exe.dev/skills" ) //go:embed system_prompt.txt @@ -25,6 +27,7 @@ type SystemPromptData struct { IsSudoAvailable bool Hostname string // For exe.dev, the public hostname (e.g., "vmname.exe.xyz") ShelleyDBPath string // Path to the shelley database + SkillsXML string // XML block for available skills } // DBPath is the path to the shelley database, set at startup @@ -119,6 +122,13 @@ func collectSystemData(workingDir string) (*SystemPromptData, error) { } } + // Discover and load skills + var gitRoot string + if gitInfo != nil { + gitRoot = gitInfo.Root + } + data.SkillsXML = collectSkills(wd, gitRoot) + return data, nil } @@ -285,6 +295,33 @@ func isExeDev() bool { return err == nil } +// collectSkills discovers skills from default directories and project tree. +func collectSkills(workingDir, gitRoot string) string { + // Start with default directories (user-level skills) + dirs := skills.DefaultDirs() + + // Discover user-level skills from configured directories + foundSkills := skills.Discover(dirs) + + // Also discover skills anywhere in the project tree + treeSkills := skills.DiscoverInTree(workingDir, gitRoot) + + // Merge, avoiding duplicates by path + seen := make(map[string]bool) + for _, s := range foundSkills { + seen[s.Path] = true + } + for _, s := range treeSkills { + if !seen[s.Path] { + foundSkills = append(foundSkills, s) + seen[s.Path] = true + } + } + + // Generate XML + return skills.ToPromptXML(foundSkills) +} + func isSudoAvailable() bool { cmd := exec.Command("sudo", "-n", "id") _, err := cmd.CombinedOutput() diff --git a/server/system_prompt.txt b/server/system_prompt.txt index 5f6692717e3fbf1ff66cf72893c4b5a676bc82c3..36c44f783e6b8b708074968497760b32f8784e6f 100644 --- a/server/system_prompt.txt +++ b/server/system_prompt.txt @@ -63,6 +63,13 @@ Direct user instructions from the current conversation always take highest prece {{end}} {{end}} {{end}} +{{if .SkillsXML}} + +You have access to skills that extend your capabilities. Skills are activated by reading the SKILL.md file at the location shown below. When a user's task matches a skill's description, activate it by reading the full SKILL.md file. + +{{.SkillsXML}} + +{{end}} {{if .ShelleyDBPath}} Your conversation history is stored in a SQLite database at: {{.ShelleyDBPath}} diff --git a/skills/skills.go b/skills/skills.go new file mode 100644 index 0000000000000000000000000000000000000000..53e59391a676c33271e7958ef0872b28178179c6 --- /dev/null +++ b/skills/skills.go @@ -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 XML block for system prompts. +func ToPromptXML(skills []Skill) string { + if len(skills) == 0 { + return "" + } + + var sb strings.Builder + sb.WriteString("\n") + + for _, skill := range skills { + sb.WriteString("\n") + sb.WriteString("") + sb.WriteString(html.EscapeString(skill.Name)) + sb.WriteString("\n") + sb.WriteString("") + sb.WriteString(html.EscapeString(skill.Description)) + sb.WriteString("\n") + sb.WriteString("") + sb.WriteString(html.EscapeString(skill.Path)) + sb.WriteString("\n") + sb.WriteString("\n") + } + + sb.WriteString("") + 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 +} diff --git a/skills/skills_test.go b/skills/skills_test.go new file mode 100644 index 0000000000000000000000000000000000000000..306d817b64263d2ff59295721a607a3eb07abdfc --- /dev/null +++ b/skills/skills_test.go @@ -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{ + "", + "", + "", + "", + "pdf-processing", + "Extract text & tables from PDF files.", + "/home/user/.shelley/skills/pdf-processing/SKILL.md", + "data-analysis", + } + + 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) + } +}