diff --git a/README.md b/README.md
index c268cb7cedf4b80632dbd75458ad3db90900edf0..4d876ef648b8237a4e2a172c23acfe5e05ec386b 100644
--- a/README.md
+++ b/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
diff --git a/go.mod b/go.mod
index 110c30ec3f74e0c78d5d34f6b0f3d53f41b6ae58..185494a12d021596c9100b2bebc553776ac0e8d0 100644
--- a/go.mod
+++ b/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
)
diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go
index 0464af1def5661492a2b26af4ba75f0ae44c9e9c..bfe987ffb9a3bf73556b502724a115f41fcc6caf 100644
--- a/internal/agent/common_test.go
+++ b/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
diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go
index ef2bdfc9cd7671b43ba22ec8a02b77b7510e5518..363f5690c8868ebb95726d1f66f628f301abef91 100644
--- a/internal/agent/coordinator.go
+++ b/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()),
)
diff --git a/internal/agent/prompt/prompt.go b/internal/agent/prompt/prompt.go
index d10fbcae3c3a37f295ec9f9de637cb130d9b6abc..d68c7c132116c49cd004bee52169be7487133efa 100644
--- a/internal/agent/prompt/prompt.go
+++ b/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
diff --git a/internal/agent/templates/coder.md.tpl b/internal/agent/templates/coder.md.tpl
index 225e021efe5f08f2cfd68184d684af2cbd57684e..3e9476d4ee08e5025c8c83845afa79295891e164 100644
--- a/internal/agent/templates/coder.md.tpl
+++ b/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)
{{end}}
+{{- if .AvailSkillXML}}
+
+{{.AvailSkillXML}}
+
+
+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).
+
+{{end}}
{{if .ContextFiles}}
diff --git a/internal/agent/tools/view.go b/internal/agent/tools/view.go
index aacd4cab23231c1b27f3d2589578e81e29cf6ed3..577fcad4dc0eaf65c46aec7e8c1e9a1b32c97062 100644
--- a/internal/agent/tools/view.go
+++ b/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
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 887e58b66d92c860c5d7fa9bc7a512b3853be4f4..e68ad8c27ca7e3c2313a3b18b48bcbedc3d677e9 100644
--- a/internal/config/config.go
+++ b/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"`
diff --git a/internal/config/load.go b/internal/config/load.go
index 27866e5891afc8774cb5d5ac2c8fd4f979161e2f..b16df0ee76d66e08e0a2e51862b8c5846100dafb 100644
--- a/internal/config/load.go
+++ b/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")
+}
diff --git a/internal/skills/skills.go b/internal/skills/skills.go
new file mode 100644
index 0000000000000000000000000000000000000000..384f589d423b0855b27c985f0914049e17135393
--- /dev/null
+++ b/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("\n")
+ for _, s := range skills {
+ sb.WriteString(" \n")
+ fmt.Fprintf(&sb, " %s\n", escape(s.Name))
+ fmt.Fprintf(&sb, " %s\n", escape(s.Description))
+ fmt.Fprintf(&sb, " %s\n", escape(s.SkillFilePath))
+ sb.WriteString(" \n")
+ }
+ sb.WriteString("")
+ return sb.String()
+}
+
+func escape(s string) string {
+ r := strings.NewReplacer("&", "&", "<", "<", ">", ">", "\"", """, "'", "'")
+ return r.Replace(s)
+}
diff --git a/internal/skills/skills_test.go b/internal/skills/skills_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..f90d7cb341d85e85945efc06f46dd372a7cf4725
--- /dev/null
+++ b/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, "")
+ require.Contains(t, xml, "pdf-processing")
+ require.Contains(t, xml, "Extracts text from PDFs.")
+ require.Contains(t, xml, "&") // XML escaping
+}
+
+func TestToPromptXMLEmpty(t *testing.T) {
+ t.Parallel()
+ require.Empty(t, ToPromptXML(nil))
+ require.Empty(t, ToPromptXML([]*Skill{}))
+}