shelley: Implement Agent Skills support

Philip Zeyliger and Shelley created

Fixes https://github.com/boldsoftware/shelley/issues/10

Prompt: Implement skills for Shelley. See https://agentskills.io/home

Add support for the Agent Skills specification (https://agentskills.io),
an open format for giving agents new capabilities and expertise.

Skills are directories containing a SKILL.md file with YAML frontmatter
(name, description) and markdown instructions. Shelley discovers skills
from:
- All subdirectories of ~/.config/shelley/ that contain skills
- All subdirectories of ~/.shelley/ that contain skills
- .skills/ directories walking up from working dir to git root

Discovered skills are included in the system prompt as XML, allowing
the agent to activate skills by reading the SKILL.md file when a task
matches the skill's description.

New package: skills/
- Parse SKILL.md frontmatter (simple YAML parser, no dependencies)
- Validate skill names per spec (lowercase, hyphens, no consecutive --)
- Discover skills in configured directories
- Walk up directory tree to find project .skills directories
- Generate <available_skills> XML for system prompt

Changes to server/:
- Add SkillsXML field to SystemPromptData
- Call collectSkills() during prompt generation
- Update system_prompt.txt template with skills section

Co-authored-by: Shelley <shelley@exe.dev>

Change summary

server/system_prompt.go  |  37 +++
server/system_prompt.txt |   7 
skills/skills.go         | 474 ++++++++++++++++++++++++++++++++++++++++++
skills/skills_test.go    | 447 +++++++++++++++++++++++++++++++++++++++
4 files changed, 965 insertions(+)

Detailed changes

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()

server/system_prompt.txt 🔗

@@ -63,6 +63,13 @@ Direct user instructions from the current conversation always take highest prece
 {{end}}</directory_specific_guidance_files>
 {{end}}
 {{end}}
+{{if .SkillsXML}}
+<skills>
+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}}
+</skills>
+{{end}}
 {{if .ShelleyDBPath}}
 <previous_conversations>
 Your conversation history is stored in a SQLite database at: {{.ShelleyDBPath}}

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 <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
+}

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{
+		"<available_skills>",
+		"</available_skills>",
+		"<skill>",
+		"</skill>",
+		"<name>pdf-processing</name>",
+		"<description>Extract text &amp; 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)
+	}
+}