feat: agent skills (#1690)

Kujtim Hoxha and Christian Rocha created

Co-authored-by: Christian Rocha <christian@rocha.is>

Change summary

README.md                             |  43 +++++
go.mod                                |   2 
internal/agent/common_test.go         |   4 
internal/agent/coordinator.go         |   2 
internal/agent/prompt/prompt.go       |  47 +++-
internal/agent/templates/coder.md.tpl |  10 +
internal/agent/tools/view.go          |  63 ++++++
internal/config/config.go             |   1 
internal/config/load.go               |  31 +++
internal/skills/skills.go             | 164 +++++++++++++++++++
internal/skills/skills_test.go        | 249 +++++++++++++++++++++++++++++
11 files changed, 591 insertions(+), 25 deletions(-)

Detailed changes

README.md 🔗

@@ -362,6 +362,49 @@ completely hidden from the agent.
 
 To disable tools from MCP servers, see the [MCP config section](#mcps).
 
+### Agent Skills
+
+Crush supports the [Agent Skills](https://agentskills.io) open standard for
+extending agent capabilities with reusable skill packages. Skills are folders
+containing a `SKILL.md` file with instructions that Crush can discover and
+activate on demand.
+
+Skills are discovered from:
+
+- `~/.config/crush/skills/` on Unix (default, can be overridden with `CRUSH_SKILLS_DIR`)
+- `%LOCALAPPDATA%\crush\skills\` on Windows (default, can be overridden with `CRUSH_SKILLS_DIR`)
+- Additional paths configured via `options.skills_paths`
+
+```jsonc
+{
+  "$schema": "https://charm.land/crush.json",
+  "options": {
+    "skills_paths": [
+      "~/.config/crush/skills", // Windows: "%LOCALAPPDATA%\\crush\\skills",
+      "./project-skills"
+    ]
+  }
+}
+```
+
+You can get started with example skills from [anthropics/skills](https://github.com/anthropics/skills):
+
+```bash
+# Unix
+mkdir -p ~/.config/crush/skills
+cd ~/.config/crush/skills
+git clone https://github.com/anthropics/skills.git _temp
+mv _temp/skills/* . && rm -rf _temp
+```
+
+```powershell
+# Windows (PowerShell)
+mkdir -Force "$env:LOCALAPPDATA\crush\skills"
+cd "$env:LOCALAPPDATA\crush\skills"
+git clone https://github.com/anthropics/skills.git _temp
+mv _temp/skills/* . ; rm -r -force _temp
+```
+
 ### Initialization
 
 When you initialize a project, Crush analyzes your codebase and creates

go.mod 🔗

@@ -61,6 +61,7 @@ require (
 	golang.org/x/sync v0.19.0
 	golang.org/x/text v0.32.0
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1
+	gopkg.in/yaml.v3 v3.0.1
 	mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5
 	mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5
 )
@@ -177,5 +178,4 @@ require (
 	google.golang.org/protobuf v1.36.10 // indirect
 	gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 // indirect
 	gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
-	gopkg.in/yaml.v3 v3.0.1 // indirect
 )

internal/agent/common_test.go 🔗

@@ -178,6 +178,10 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel
 		GeneratedWith: true,
 	}
 
+	// Clear skills paths to ensure test reproducibility - user's skills
+	// would be included in prompt and break VCR cassette matching.
+	cfg.Options.SkillsPaths = []string{}
+
 	systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), *cfg)
 	if err != nil {
 		return nil, err

internal/agent/coordinator.go 🔗

@@ -390,7 +390,7 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
 		tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
 		tools.NewSourcegraphTool(nil),
 		tools.NewTodosTool(c.sessions),
-		tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir()),
+		tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
 		tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
 	)
 

internal/agent/prompt/prompt.go 🔗

@@ -14,6 +14,7 @@ import (
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/home"
 	"github.com/charmbracelet/crush/internal/shell"
+	"github.com/charmbracelet/crush/internal/skills"
 )
 
 // Prompt represents a template-based prompt generator.
@@ -26,15 +27,16 @@ type Prompt struct {
 }
 
 type PromptDat struct {
-	Provider     string
-	Model        string
-	Config       config.Config
-	WorkingDir   string
-	IsGitRepo    bool
-	Platform     string
-	Date         string
-	GitStatus    string
-	ContextFiles []ContextFile
+	Provider      string
+	Model         string
+	Config        config.Config
+	WorkingDir    string
+	IsGitRepo     bool
+	Platform      string
+	Date          string
+	GitStatus     string
+	ContextFiles  []ContextFile
+	AvailSkillXML string
 }
 
 type ContextFile struct {
@@ -162,15 +164,28 @@ func (p *Prompt) promptData(ctx context.Context, provider, model string, cfg con
 		files[pathKey] = content
 	}
 
+	// Discover and load skills metadata.
+	var availSkillXML string
+	if len(cfg.Options.SkillsPaths) > 0 {
+		expandedPaths := make([]string, 0, len(cfg.Options.SkillsPaths))
+		for _, pth := range cfg.Options.SkillsPaths {
+			expandedPaths = append(expandedPaths, expandPath(pth, cfg))
+		}
+		if discoveredSkills := skills.Discover(expandedPaths); len(discoveredSkills) > 0 {
+			availSkillXML = skills.ToPromptXML(discoveredSkills)
+		}
+	}
+
 	isGit := isGitRepo(cfg.WorkingDir())
 	data := PromptDat{
-		Provider:   provider,
-		Model:      model,
-		Config:     cfg,
-		WorkingDir: filepath.ToSlash(workingDir),
-		IsGitRepo:  isGit,
-		Platform:   platform,
-		Date:       p.now().Format("1/2/2006"),
+		Provider:      provider,
+		Model:         model,
+		Config:        cfg,
+		WorkingDir:    filepath.ToSlash(workingDir),
+		IsGitRepo:     isGit,
+		Platform:      platform,
+		Date:          p.now().Format("1/2/2006"),
+		AvailSkillXML: availSkillXML,
 	}
 	if isGit {
 		var err error

internal/agent/templates/coder.md.tpl 🔗

@@ -360,6 +360,16 @@ Diagnostics (lint/typecheck) included in tool output.
 - Ignore issues in files you didn't touch (unless user asks)
 </lsp>
 {{end}}
+{{- if .AvailSkillXML}}
+
+{{.AvailSkillXML}}
+
+<skills_usage>
+When a user task matches a skill's description, read the skill's SKILL.md file to get full instructions.
+Skills are activated by reading their location path. Follow the skill's instructions to complete the task.
+If a skill mentions scripts, references, or assets, they are placed in the same folder as the skill itself (e.g., scripts/, references/, assets/ subdirectories within the skill's folder).
+</skills_usage>
+{{end}}
 
 {{if .ContextFiles}}
 <memory>

internal/agent/tools/view.go 🔗

@@ -38,6 +38,7 @@ type viewTool struct {
 	lspClients  *csync.Map[string, *lsp.Client]
 	workingDir  string
 	permissions permission.Service
+	skillsPaths []string
 }
 
 type ViewResponseMetadata struct {
@@ -52,7 +53,7 @@ const (
 	MaxLineLength    = 2000
 )
 
-func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string) fantasy.AgentTool {
+func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string, skillsPaths ...string) fantasy.AgentTool {
 	return fantasy.NewAgentTool(
 		ViewToolName,
 		string(viewDescription),
@@ -76,8 +77,11 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
 			}
 
 			relPath, err := filepath.Rel(absWorkingDir, absFilePath)
-			if err != nil || strings.HasPrefix(relPath, "..") {
-				// File is outside working directory, request permission
+			isOutsideWorkDir := err != nil || strings.HasPrefix(relPath, "..")
+			isSkillFile := isInSkillsPath(absFilePath, skillsPaths)
+
+			// Request permission for files outside working directory, unless it's a skill file.
+			if isOutsideWorkDir && !isSkillFile {
 				sessionID := GetSessionFromContext(ctx)
 				if sessionID == "" {
 					return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory")
@@ -137,15 +141,19 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
 				return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
 			}
 
-			// Check file size
-			if fileInfo.Size() > MaxReadSize {
+			// Based on the specifications we should not limit the skills read.
+			if !isSkillFile && fileInfo.Size() > MaxReadSize {
 				return fantasy.NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes",
 					fileInfo.Size(), MaxReadSize)), nil
 			}
 
-			// Set default limit if not provided
+			// Set default limit if not provided (no limit for SKILL.md files)
 			if params.Limit <= 0 {
-				params.Limit = DefaultReadLimit
+				if isSkillFile {
+					params.Limit = 1000000 // Effectively no limit for skill files
+				} else {
+					params.Limit = DefaultReadLimit
+				}
 			}
 
 			isSupportedImage, mimeType := getImageMimeType(filePath)
@@ -315,3 +323,44 @@ func (s *LineScanner) Text() string {
 func (s *LineScanner) Err() error {
 	return s.scanner.Err()
 }
+
+// isInSkillsPath checks if filePath is within any of the configured skills
+// directories. Returns true for files that can be read without permission
+// prompts and without size limits.
+//
+// Note that symlinks are resolved to prevent path traversal attacks via
+// symbolic links.
+func isInSkillsPath(filePath string, skillsPaths []string) bool {
+	if len(skillsPaths) == 0 {
+		return false
+	}
+
+	absFilePath, err := filepath.Abs(filePath)
+	if err != nil {
+		return false
+	}
+
+	evalFilePath, err := filepath.EvalSymlinks(absFilePath)
+	if err != nil {
+		return false
+	}
+
+	for _, skillsPath := range skillsPaths {
+		absSkillsPath, err := filepath.Abs(skillsPath)
+		if err != nil {
+			continue
+		}
+
+		evalSkillsPath, err := filepath.EvalSymlinks(absSkillsPath)
+		if err != nil {
+			continue
+		}
+
+		relPath, err := filepath.Rel(evalSkillsPath, evalFilePath)
+		if err == nil && !strings.HasPrefix(relPath, "..") {
+			return true
+		}
+	}
+
+	return false
+}

internal/config/config.go 🔗

@@ -256,6 +256,7 @@ func (Attribution) JSONSchemaExtend(schema *jsonschema.Schema) {
 
 type Options struct {
 	ContextPaths              []string     `json:"context_paths,omitempty" jsonschema:"description=Paths to files containing context information for the AI,example=.cursorrules,example=CRUSH.md"`
+	SkillsPaths               []string     `json:"skills_paths,omitempty" jsonschema:"description=Paths to directories containing Agent Skills (folders with SKILL.md files),example=~/.config/crush/skills,example=./skills"`
 	TUI                       *TUIOptions  `json:"tui,omitempty" jsonschema:"description=Terminal user interface options"`
 	Debug                     bool         `json:"debug,omitempty" jsonschema:"description=Enable debug logging,default=false"`
 	DebugLSP                  bool         `json:"debug_lsp,omitempty" jsonschema:"description=Enable debug logging for LSP servers,default=false"`

internal/config/load.go 🔗

@@ -329,6 +329,9 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
 	if c.Options.ContextPaths == nil {
 		c.Options.ContextPaths = []string{}
 	}
+	if c.Options.SkillsPaths == nil {
+		c.Options.SkillsPaths = []string{}
+	}
 	if dataDir != "" {
 		c.Options.DataDirectory = dataDir
 	} else if c.Options.DataDirectory == "" {
@@ -362,6 +365,12 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
 	slices.Sort(c.Options.ContextPaths)
 	c.Options.ContextPaths = slices.Compact(c.Options.ContextPaths)
 
+	// Add the default skills directory if not already present.
+	defaultSkillsDir := GlobalSkillsDir()
+	if !slices.Contains(c.Options.SkillsPaths, defaultSkillsDir) {
+		c.Options.SkillsPaths = append([]string{defaultSkillsDir}, c.Options.SkillsPaths...)
+	}
+
 	if str, ok := os.LookupEnv("CRUSH_DISABLE_PROVIDER_AUTO_UPDATE"); ok {
 		c.Options.DisableProviderAutoUpdate, _ = strconv.ParseBool(str)
 	}
@@ -736,3 +745,25 @@ func isInsideWorktree() bool {
 	).CombinedOutput()
 	return err == nil && strings.TrimSpace(string(bts)) == "true"
 }
+
+// GlobalSkillsDir returns the default directory for Agent Skills.
+// Skills in this directory are auto-discovered and their files can be read
+// without permission prompts.
+func GlobalSkillsDir() string {
+	if crushSkills := os.Getenv("CRUSH_SKILLS_DIR"); crushSkills != "" {
+		return crushSkills
+	}
+	if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
+		return filepath.Join(xdgConfigHome, appName, "skills")
+	}
+
+	if runtime.GOOS == "windows" {
+		localAppData := cmp.Or(
+			os.Getenv("LOCALAPPDATA"),
+			filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
+		)
+		return filepath.Join(localAppData, appName, "skills")
+	}
+
+	return filepath.Join(home.Dir(), ".config", appName, "skills")
+}

internal/skills/skills.go 🔗

@@ -0,0 +1,164 @@
+// Package skills implements the Agent Skills open standard.
+// See https://agentskills.io for the specification.
+package skills
+
+import (
+	"errors"
+	"fmt"
+	"log/slog"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+
+	"gopkg.in/yaml.v3"
+)
+
+const (
+	SkillFileName          = "SKILL.md"
+	MaxNameLength          = 64
+	MaxDescriptionLength   = 1024
+	MaxCompatibilityLength = 500
+)
+
+var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
+
+// Skill represents a parsed SKILL.md file.
+type Skill struct {
+	Name          string            `yaml:"name" json:"name"`
+	Description   string            `yaml:"description" json:"description"`
+	License       string            `yaml:"license,omitempty" json:"license,omitempty"`
+	Compatibility string            `yaml:"compatibility,omitempty" json:"compatibility,omitempty"`
+	Metadata      map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"`
+	Instructions  string            `yaml:"-" json:"instructions"`
+	Path          string            `yaml:"-" json:"path"`
+	SkillFilePath string            `yaml:"-" json:"skill_file_path"`
+}
+
+// Validate checks if the skill meets spec requirements.
+func (s *Skill) Validate() error {
+	var errs []error
+
+	if s.Name == "" {
+		errs = append(errs, errors.New("name is required"))
+	} else {
+		if len(s.Name) > MaxNameLength {
+			errs = append(errs, fmt.Errorf("name exceeds %d characters", MaxNameLength))
+		}
+		if !namePattern.MatchString(s.Name) {
+			errs = append(errs, errors.New("name must be alphanumeric with hyphens, no leading/trailing/consecutive hyphens"))
+		}
+		if s.Path != "" && !strings.EqualFold(filepath.Base(s.Path), s.Name) {
+			errs = append(errs, fmt.Errorf("name %q must match directory %q", s.Name, filepath.Base(s.Path)))
+		}
+	}
+
+	if s.Description == "" {
+		errs = append(errs, errors.New("description is required"))
+	} else if len(s.Description) > MaxDescriptionLength {
+		errs = append(errs, fmt.Errorf("description exceeds %d characters", MaxDescriptionLength))
+	}
+
+	if len(s.Compatibility) > MaxCompatibilityLength {
+		errs = append(errs, fmt.Errorf("compatibility exceeds %d characters", MaxCompatibilityLength))
+	}
+
+	return errors.Join(errs...)
+}
+
+// Parse parses a SKILL.md file.
+func Parse(path string) (*Skill, error) {
+	content, err := os.ReadFile(path)
+	if err != nil {
+		return nil, err
+	}
+
+	frontmatter, body, err := splitFrontmatter(string(content))
+	if err != nil {
+		return nil, err
+	}
+
+	var skill Skill
+	if err := yaml.Unmarshal([]byte(frontmatter), &skill); err != nil {
+		return nil, fmt.Errorf("parsing frontmatter: %w", err)
+	}
+
+	skill.Instructions = strings.TrimSpace(body)
+	skill.Path = filepath.Dir(path)
+	skill.SkillFilePath = path
+
+	return &skill, nil
+}
+
+// splitFrontmatter extracts YAML frontmatter and body from markdown content.
+func splitFrontmatter(content string) (frontmatter, body string, err error) {
+	// Normalize line endings to \n for consistent parsing.
+	content = strings.ReplaceAll(content, "\r\n", "\n")
+	if !strings.HasPrefix(content, "---\n") {
+		return "", "", errors.New("no YAML frontmatter found")
+	}
+
+	rest := strings.TrimPrefix(content, "---\n")
+	before, after, ok := strings.Cut(rest, "\n---")
+	if !ok {
+		return "", "", errors.New("unclosed frontmatter")
+	}
+
+	return before, after, nil
+}
+
+// Discover finds all valid skills in the given paths.
+func Discover(paths []string) []*Skill {
+	var skills []*Skill
+	seen := make(map[string]bool)
+
+	for _, base := range paths {
+		filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error {
+			if err != nil {
+				return nil
+			}
+			if d.IsDir() || d.Name() != SkillFileName || seen[path] {
+				return nil
+			}
+			seen[path] = true
+			skill, err := Parse(path)
+			if err != nil {
+				slog.Warn("Failed to parse skill file", "path", path, "error", err)
+				return nil
+			}
+			if err := skill.Validate(); err != nil {
+				slog.Warn("Skill validation failed", "path", path, "error", err)
+				return nil
+			}
+			slog.Info("Successfully loaded skill", "name", skill.Name, "path", path)
+			skills = append(skills, skill)
+			return nil
+		})
+	}
+
+	return skills
+}
+
+// ToPromptXML generates XML for injection into the system prompt.
+func ToPromptXML(skills []*Skill) string {
+	if len(skills) == 0 {
+		return ""
+	}
+
+	var sb strings.Builder
+	sb.WriteString("<available_skills>\n")
+	for _, s := range skills {
+		sb.WriteString("  <skill>\n")
+		fmt.Fprintf(&sb, "    <name>%s</name>\n", escape(s.Name))
+		fmt.Fprintf(&sb, "    <description>%s</description>\n", escape(s.Description))
+		fmt.Fprintf(&sb, "    <location>%s</location>\n", escape(s.SkillFilePath))
+		sb.WriteString("  </skill>\n")
+	}
+	sb.WriteString("</available_skills>")
+	return sb.String()
+}
+
+func escape(s string) string {
+	r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;", "'", "&apos;")
+	return r.Replace(s)
+}

internal/skills/skills_test.go 🔗

@@ -0,0 +1,249 @@
+package skills
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestParse(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name        string
+		content     string
+		wantName    string
+		wantDesc    string
+		wantLicense string
+		wantCompat  string
+		wantMeta    map[string]string
+		wantTools   string
+		wantInstr   string
+		wantErr     bool
+	}{
+		{
+			name: "full skill",
+			content: `---
+name: pdf-processing
+description: Extracts text and tables from PDF files, fills PDF forms, and merges multiple PDFs.
+license: Apache-2.0
+compatibility: Requires python 3.8+, pdfplumber, pdfrw libraries
+metadata:
+  author: example-org
+  version: "1.0"
+---
+
+# PDF Processing
+
+## When to use this skill
+Use this skill when the user needs to work with PDF files.
+`,
+			wantName:    "pdf-processing",
+			wantDesc:    "Extracts text and tables from PDF files, fills PDF forms, and merges multiple PDFs.",
+			wantLicense: "Apache-2.0",
+			wantCompat:  "Requires python 3.8+, pdfplumber, pdfrw libraries",
+			wantMeta:    map[string]string{"author": "example-org", "version": "1.0"},
+			wantInstr:   "# PDF Processing\n\n## When to use this skill\nUse this skill when the user needs to work with PDF files.",
+		},
+		{
+			name: "minimal skill",
+			content: `---
+name: my-skill
+description: A simple skill for testing.
+---
+
+# My Skill
+
+Instructions here.
+`,
+			wantName:  "my-skill",
+			wantDesc:  "A simple skill for testing.",
+			wantInstr: "# My Skill\n\nInstructions here.",
+		},
+		{
+			name:    "no frontmatter",
+			content: "# Just Markdown\n\nNo frontmatter here.",
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			// Write content to temp file.
+			dir := t.TempDir()
+			path := filepath.Join(dir, "SKILL.md")
+			require.NoError(t, os.WriteFile(path, []byte(tt.content), 0o644))
+
+			skill, err := Parse(path)
+			if tt.wantErr {
+				require.Error(t, err)
+				return
+			}
+			require.NoError(t, err)
+
+			require.Equal(t, tt.wantName, skill.Name)
+			require.Equal(t, tt.wantDesc, skill.Description)
+			require.Equal(t, tt.wantLicense, skill.License)
+			require.Equal(t, tt.wantCompat, skill.Compatibility)
+
+			if tt.wantMeta != nil {
+				require.Equal(t, tt.wantMeta, skill.Metadata)
+			}
+
+			require.Equal(t, tt.wantInstr, skill.Instructions)
+		})
+	}
+}
+
+func TestSkillValidate(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name    string
+		skill   Skill
+		wantErr bool
+		errMsg  string
+	}{
+		{
+			name: "valid skill",
+			skill: Skill{
+				Name:        "pdf-processing",
+				Description: "Processes PDF files.",
+				Path:        "/skills/pdf-processing",
+			},
+		},
+		{
+			name:    "missing name",
+			skill:   Skill{Description: "Some description."},
+			wantErr: true,
+			errMsg:  "name is required",
+		},
+		{
+			name:    "missing description",
+			skill:   Skill{Name: "my-skill", Path: "/skills/my-skill"},
+			wantErr: true,
+			errMsg:  "description is required",
+		},
+		{
+			name:    "name too long",
+			skill:   Skill{Name: strings.Repeat("a", 65), Description: "Some description."},
+			wantErr: true,
+			errMsg:  "exceeds",
+		},
+		{
+			name:    "valid name - mixed case",
+			skill:   Skill{Name: "MySkill", Description: "Some description.", Path: "/skills/MySkill"},
+			wantErr: false,
+		},
+		{
+			name:    "invalid name - starts with hyphen",
+			skill:   Skill{Name: "-my-skill", Description: "Some description."},
+			wantErr: true,
+			errMsg:  "alphanumeric with hyphens",
+		},
+		{
+			name:    "name doesn't match directory",
+			skill:   Skill{Name: "my-skill", Description: "Some description.", Path: "/skills/other-skill"},
+			wantErr: true,
+			errMsg:  "must match directory",
+		},
+		{
+			name:    "description too long",
+			skill:   Skill{Name: "my-skill", Description: strings.Repeat("a", 1025), Path: "/skills/my-skill"},
+			wantErr: true,
+			errMsg:  "description exceeds",
+		},
+		{
+			name:    "compatibility too long",
+			skill:   Skill{Name: "my-skill", Description: "desc", Compatibility: strings.Repeat("a", 501), Path: "/skills/my-skill"},
+			wantErr: true,
+			errMsg:  "compatibility exceeds",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			err := tt.skill.Validate()
+			if tt.wantErr {
+				require.Error(t, err)
+				require.Contains(t, err.Error(), tt.errMsg)
+			} else {
+				require.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestDiscover(t *testing.T) {
+	t.Parallel()
+
+	tmpDir := t.TempDir()
+
+	// Create valid skill 1.
+	skill1Dir := filepath.Join(tmpDir, "skill-one")
+	require.NoError(t, os.MkdirAll(skill1Dir, 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(skill1Dir, "SKILL.md"), []byte(`---
+name: skill-one
+description: First test skill.
+---
+# Skill One
+`), 0o644))
+
+	// Create valid skill 2 in nested directory.
+	skill2Dir := filepath.Join(tmpDir, "nested", "skill-two")
+	require.NoError(t, os.MkdirAll(skill2Dir, 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(skill2Dir, "SKILL.md"), []byte(`---
+name: skill-two
+description: Second test skill.
+---
+# Skill Two
+`), 0o644))
+
+	// Create invalid skill (won't be included).
+	invalidDir := filepath.Join(tmpDir, "invalid-dir")
+	require.NoError(t, os.MkdirAll(invalidDir, 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(invalidDir, "SKILL.md"), []byte(`---
+name: wrong-name
+description: Name doesn't match directory.
+---
+`), 0o644))
+
+	skills := Discover([]string{tmpDir})
+	require.Len(t, skills, 2)
+
+	names := make(map[string]bool)
+	for _, s := range skills {
+		names[s.Name] = true
+	}
+	require.True(t, names["skill-one"])
+	require.True(t, names["skill-two"])
+}
+
+func TestToPromptXML(t *testing.T) {
+	t.Parallel()
+
+	skills := []*Skill{
+		{Name: "pdf-processing", Description: "Extracts text from PDFs.", SkillFilePath: "/skills/pdf-processing/SKILL.md"},
+		{Name: "data-analysis", Description: "Analyzes datasets & charts.", SkillFilePath: "/skills/data-analysis/SKILL.md"},
+	}
+
+	xml := ToPromptXML(skills)
+
+	require.Contains(t, xml, "<available_skills>")
+	require.Contains(t, xml, "<name>pdf-processing</name>")
+	require.Contains(t, xml, "<description>Extracts text from PDFs.</description>")
+	require.Contains(t, xml, "&amp;") // XML escaping
+}
+
+func TestToPromptXMLEmpty(t *testing.T) {
+	t.Parallel()
+	require.Empty(t, ToPromptXML(nil))
+	require.Empty(t, ToPromptXML([]*Skill{}))
+}