on main help sign in
feat: agent skills (#1690)

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

commit1fbe7d48900d8734d4a8c4aaffe734e95f2b786d authorKujtim Hoxha <kujtimii.h@gmail.com> dateSat 27 Dec 2025, 21:10 (1 year ago) parent63a3d83
11 files changed+591−25
README.md+43 go.mod+1−1 internal/agent/common_test.go+4 internal/agent/coordinator.go+1−1 internal/agent/prompt/prompt.go+31−16 internal/agent/templates/coder.md.tpl+10 internal/agent/tools/view.go+56−7 internal/config/config.go+1 internal/config/load.go+31 internal/skills/skills.go+164 internal/skills/skills_test.go+249
README.md+43
@@ -362,6 +362,49 @@ completely hidden from the agent.
362362
363363To disable tools from MCP servers, see the [MCP config section](#mcps).
364364
365+### Agent Skills
366+
367+Crush supports the [Agent Skills](https://agentskills.io) open standard for
368+extending agent capabilities with reusable skill packages. Skills are folders
369+containing a `SKILL.md` file with instructions that Crush can discover and
370+activate on demand.
371+
372+Skills are discovered from:
373+
374+- `~/.config/crush/skills/` on Unix (default, can be overridden with `CRUSH_SKILLS_DIR`)
375+- `%LOCALAPPDATA%\crush\skills\` on Windows (default, can be overridden with `CRUSH_SKILLS_DIR`)
376+- Additional paths configured via `options.skills_paths`
377+
378+```jsonc
379+{
380+ "$schema": "https://charm.land/crush.json",
381+ "options": {
382+ "skills_paths": [
383+ "~/.config/crush/skills", // Windows: "%LOCALAPPDATA%\\crush\\skills",
384+ "./project-skills"
385+ ]
386+ }
387+}
388+```
389+
390+You can get started with example skills from [anthropics/skills](https://github.com/anthropics/skills):
391+
392+```bash
393+# Unix
394+mkdir -p ~/.config/crush/skills
395+cd ~/.config/crush/skills
396+git clone https://github.com/anthropics/skills.git _temp
397+mv _temp/skills/* . && rm -rf _temp
398+```
399+
400+```powershell
401+# Windows (PowerShell)
402+mkdir -Force "$env:LOCALAPPDATA\crush\skills"
403+cd "$env:LOCALAPPDATA\crush\skills"
404+git clone https://github.com/anthropics/skills.git _temp
405+mv _temp/skills/* . ; rm -r -force _temp
406+```
407+
365408### Initialization
366409
367410When you initialize a project, Crush analyzes your codebase and creates
go.mod+1−1
@@ -61,6 +61,7 @@ require (
6161 golang.org/x/sync v0.19.0
6262 golang.org/x/text v0.32.0
6363 gopkg.in/natefinch/lumberjack.v2 v2.2.1
64+ gopkg.in/yaml.v3 v3.0.1
6465 mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5
6566 mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5
6667)
@@ -177,5 +178,4 @@ require (
177178 google.golang.org/protobuf v1.36.10 // indirect
178179 gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 // indirect
179180 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
180− gopkg.in/yaml.v3 v3.0.1 // indirect
181181)
internal/agent/common_test.go+4
@@ -178,6 +178,10 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel
178178 GeneratedWith: true,
179179 }
180180
181+ // Clear skills paths to ensure test reproducibility - user's skills
182+ // would be included in prompt and break VCR cassette matching.
183+ cfg.Options.SkillsPaths = []string{}
184+
181185 systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), *cfg)
182186 if err != nil {
183187 return nil, err
internal/agent/coordinator.go+1−1
@@ -390,7 +390,7 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
390390 tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
391391 tools.NewSourcegraphTool(nil),
392392 tools.NewTodosTool(c.sessions),
393− tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir()),
393+ tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
394394 tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
395395 )
396396
internal/agent/prompt/prompt.go+31−16
@@ -14,6 +14,7 @@ import (
1414 "github.com/charmbracelet/crush/internal/config"
1515 "github.com/charmbracelet/crush/internal/home"
1616 "github.com/charmbracelet/crush/internal/shell"
17+ "github.com/charmbracelet/crush/internal/skills"
1718)
1819
1920// Prompt represents a template-based prompt generator.
@@ -26,15 +27,16 @@ type Prompt struct {
2627}
2728
2829type PromptDat struct {
29− Provider string
30− Model string
31− Config config.Config
32− WorkingDir string
33− IsGitRepo bool
34− Platform string
35− Date string
36− GitStatus string
37− ContextFiles []ContextFile
30+ Provider string
31+ Model string
32+ Config config.Config
33+ WorkingDir string
34+ IsGitRepo bool
35+ Platform string
36+ Date string
37+ GitStatus string
38+ ContextFiles []ContextFile
39+ AvailSkillXML string
3840}
3941
4042type ContextFile struct {
@@ -162,15 +164,28 @@ func (p *Prompt) promptData(ctx context.Context, provider, model string, cfg con
162164 files[pathKey] = content
163165 }
164166
167+ // Discover and load skills metadata.
168+ var availSkillXML string
169+ if len(cfg.Options.SkillsPaths) > 0 {
170+ expandedPaths := make([]string, 0, len(cfg.Options.SkillsPaths))
171+ for _, pth := range cfg.Options.SkillsPaths {
172+ expandedPaths = append(expandedPaths, expandPath(pth, cfg))
173+ }
174+ if discoveredSkills := skills.Discover(expandedPaths); len(discoveredSkills) > 0 {
175+ availSkillXML = skills.ToPromptXML(discoveredSkills)
176+ }
177+ }
178+
165179 isGit := isGitRepo(cfg.WorkingDir())
166180 data := PromptDat{
167− Provider: provider,
168− Model: model,
169− Config: cfg,
170− WorkingDir: filepath.ToSlash(workingDir),
171− IsGitRepo: isGit,
172− Platform: platform,
173− Date: p.now().Format("1/2/2006"),
181+ Provider: provider,
182+ Model: model,
183+ Config: cfg,
184+ WorkingDir: filepath.ToSlash(workingDir),
185+ IsGitRepo: isGit,
186+ Platform: platform,
187+ Date: p.now().Format("1/2/2006"),
188+ AvailSkillXML: availSkillXML,
174189 }
175190 if isGit {
176191 var err error
internal/agent/templates/coder.md.tpl+10
@@ -360,6 +360,16 @@ Diagnostics (lint/typecheck) included in tool output.
360360- Ignore issues in files you didn't touch (unless user asks)
361361</lsp>
362362{{end}}
363+{{- if .AvailSkillXML}}
364+
365+{{.AvailSkillXML}}
366+
367+<skills_usage>
368+When a user task matches a skill's description, read the skill's SKILL.md file to get full instructions.
369+Skills are activated by reading their location path. Follow the skill's instructions to complete the task.
370+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).
371+</skills_usage>
372+{{end}}
363373
364374{{if .ContextFiles}}
365375<memory>
internal/agent/tools/view.go+56−7
@@ -38,6 +38,7 @@ type viewTool struct {
3838 lspClients *csync.Map[string, *lsp.Client]
3939 workingDir string
4040 permissions permission.Service
41+ skillsPaths []string
4142}
4243
4344type ViewResponseMetadata struct {
@@ -52,7 +53,7 @@ const (
5253 MaxLineLength = 2000
5354)
5455
55−func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string) fantasy.AgentTool {
56+func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string, skillsPaths ...string) fantasy.AgentTool {
5657 return fantasy.NewAgentTool(
5758 ViewToolName,
5859 string(viewDescription),
@@ -76,8 +77,11 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
7677 }
7778
7879 relPath, err := filepath.Rel(absWorkingDir, absFilePath)
79− if err != nil || strings.HasPrefix(relPath, "..") {
80− // File is outside working directory, request permission
80+ isOutsideWorkDir := err != nil || strings.HasPrefix(relPath, "..")
81+ isSkillFile := isInSkillsPath(absFilePath, skillsPaths)
82+
83+ // Request permission for files outside working directory, unless it's a skill file.
84+ if isOutsideWorkDir && !isSkillFile {
8185 sessionID := GetSessionFromContext(ctx)
8286 if sessionID == "" {
8387 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
137141 return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
138142 }
139143
140− // Check file size
141− if fileInfo.Size() > MaxReadSize {
144+ // Based on the specifications we should not limit the skills read.
145+ if !isSkillFile && fileInfo.Size() > MaxReadSize {
142146 return fantasy.NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes",
143147 fileInfo.Size(), MaxReadSize)), nil
144148 }
145149
146− // Set default limit if not provided
150+ // Set default limit if not provided (no limit for SKILL.md files)
147151 if params.Limit <= 0 {
148− params.Limit = DefaultReadLimit
152+ if isSkillFile {
153+ params.Limit = 1000000 // Effectively no limit for skill files
154+ } else {
155+ params.Limit = DefaultReadLimit
156+ }
149157 }
150158
151159 isSupportedImage, mimeType := getImageMimeType(filePath)
@@ -315,3 +323,44 @@ func (s *LineScanner) Text() string {
315323func (s *LineScanner) Err() error {
316324 return s.scanner.Err()
317325}
326+
327+// isInSkillsPath checks if filePath is within any of the configured skills
328+// directories. Returns true for files that can be read without permission
329+// prompts and without size limits.
330+//
331+// Note that symlinks are resolved to prevent path traversal attacks via
332+// symbolic links.
333+func isInSkillsPath(filePath string, skillsPaths []string) bool {
334+ if len(skillsPaths) == 0 {
335+ return false
336+ }
337+
338+ absFilePath, err := filepath.Abs(filePath)
339+ if err != nil {
340+ return false
341+ }
342+
343+ evalFilePath, err := filepath.EvalSymlinks(absFilePath)
344+ if err != nil {
345+ return false
346+ }
347+
348+ for _, skillsPath := range skillsPaths {
349+ absSkillsPath, err := filepath.Abs(skillsPath)
350+ if err != nil {
351+ continue
352+ }
353+
354+ evalSkillsPath, err := filepath.EvalSymlinks(absSkillsPath)
355+ if err != nil {
356+ continue
357+ }
358+
359+ relPath, err := filepath.Rel(evalSkillsPath, evalFilePath)
360+ if err == nil && !strings.HasPrefix(relPath, "..") {
361+ return true
362+ }
363+ }
364+
365+ return false
366+}
internal/config/config.go+1
@@ -256,6 +256,7 @@ func (Attribution) JSONSchemaExtend(schema *jsonschema.Schema) {
256256
257257type Options struct {
258258 ContextPaths []string `json:"context_paths,omitempty" jsonschema:"description=Paths to files containing context information for the AI,example=.cursorrules,example=CRUSH.md"`
259+ 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"`
259260 TUI *TUIOptions `json:"tui,omitempty" jsonschema:"description=Terminal user interface options"`
260261 Debug bool `json:"debug,omitempty" jsonschema:"description=Enable debug logging,default=false"`
261262 DebugLSP bool `json:"debug_lsp,omitempty" jsonschema:"description=Enable debug logging for LSP servers,default=false"`
internal/config/load.go+31
@@ -329,6 +329,9 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
329329 if c.Options.ContextPaths == nil {
330330 c.Options.ContextPaths = []string{}
331331 }
332+ if c.Options.SkillsPaths == nil {
333+ c.Options.SkillsPaths = []string{}
334+ }
332335 if dataDir != "" {
333336 c.Options.DataDirectory = dataDir
334337 } else if c.Options.DataDirectory == "" {
@@ -362,6 +365,12 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
362365 slices.Sort(c.Options.ContextPaths)
363366 c.Options.ContextPaths = slices.Compact(c.Options.ContextPaths)
364367
368+ // Add the default skills directory if not already present.
369+ defaultSkillsDir := GlobalSkillsDir()
370+ if !slices.Contains(c.Options.SkillsPaths, defaultSkillsDir) {
371+ c.Options.SkillsPaths = append([]string{defaultSkillsDir}, c.Options.SkillsPaths...)
372+ }
373+
365374 if str, ok := os.LookupEnv("CRUSH_DISABLE_PROVIDER_AUTO_UPDATE"); ok {
366375 c.Options.DisableProviderAutoUpdate, _ = strconv.ParseBool(str)
367376 }
@@ -736,3 +745,25 @@ func isInsideWorktree() bool {
736745 ).CombinedOutput()
737746 return err == nil && strings.TrimSpace(string(bts)) == "true"
738747}
748+
749+// GlobalSkillsDir returns the default directory for Agent Skills.
750+// Skills in this directory are auto-discovered and their files can be read
751+// without permission prompts.
752+func GlobalSkillsDir() string {
753+ if crushSkills := os.Getenv("CRUSH_SKILLS_DIR"); crushSkills != "" {
754+ return crushSkills
755+ }
756+ if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
757+ return filepath.Join(xdgConfigHome, appName, "skills")
758+ }
759+
760+ if runtime.GOOS == "windows" {
761+ localAppData := cmp.Or(
762+ os.Getenv("LOCALAPPDATA"),
763+ filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
764+ )
765+ return filepath.Join(localAppData, appName, "skills")
766+ }
767+
768+ return filepath.Join(home.Dir(), ".config", appName, "skills")
769+}
internal/skills/skills.gonew file+164
@@ -0,0 +1,164 @@
1+// Package skills implements the Agent Skills open standard.
2+// See https://agentskills.io for the specification.
3+package skills
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "log/slog"
9+ "os"
10+ "path/filepath"
11+ "regexp"
12+ "strings"
13+
14+ "gopkg.in/yaml.v3"
15+)
16+
17+const (
18+ SkillFileName = "SKILL.md"
19+ MaxNameLength = 64
20+ MaxDescriptionLength = 1024
21+ MaxCompatibilityLength = 500
22+)
23+
24+var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
25+
26+// Skill represents a parsed SKILL.md file.
27+type Skill struct {
28+ Name string `yaml:"name" json:"name"`
29+ Description string `yaml:"description" json:"description"`
30+ License string `yaml:"license,omitempty" json:"license,omitempty"`
31+ Compatibility string `yaml:"compatibility,omitempty" json:"compatibility,omitempty"`
32+ Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"`
33+ Instructions string `yaml:"-" json:"instructions"`
34+ Path string `yaml:"-" json:"path"`
35+ SkillFilePath string `yaml:"-" json:"skill_file_path"`
36+}
37+
38+// Validate checks if the skill meets spec requirements.
39+func (s *Skill) Validate() error {
40+ var errs []error
41+
42+ if s.Name == "" {
43+ errs = append(errs, errors.New("name is required"))
44+ } else {
45+ if len(s.Name) > MaxNameLength {
46+ errs = append(errs, fmt.Errorf("name exceeds %d characters", MaxNameLength))
47+ }
48+ if !namePattern.MatchString(s.Name) {
49+ errs = append(errs, errors.New("name must be alphanumeric with hyphens, no leading/trailing/consecutive hyphens"))
50+ }
51+ if s.Path != "" && !strings.EqualFold(filepath.Base(s.Path), s.Name) {
52+ errs = append(errs, fmt.Errorf("name %q must match directory %q", s.Name, filepath.Base(s.Path)))
53+ }
54+ }
55+
56+ if s.Description == "" {
57+ errs = append(errs, errors.New("description is required"))
58+ } else if len(s.Description) > MaxDescriptionLength {
59+ errs = append(errs, fmt.Errorf("description exceeds %d characters", MaxDescriptionLength))
60+ }
61+
62+ if len(s.Compatibility) > MaxCompatibilityLength {
63+ errs = append(errs, fmt.Errorf("compatibility exceeds %d characters", MaxCompatibilityLength))
64+ }
65+
66+ return errors.Join(errs...)
67+}
68+
69+// Parse parses a SKILL.md file.
70+func Parse(path string) (*Skill, error) {
71+ content, err := os.ReadFile(path)
72+ if err != nil {
73+ return nil, err
74+ }
75+
76+ frontmatter, body, err := splitFrontmatter(string(content))
77+ if err != nil {
78+ return nil, err
79+ }
80+
81+ var skill Skill
82+ if err := yaml.Unmarshal([]byte(frontmatter), &skill); err != nil {
83+ return nil, fmt.Errorf("parsing frontmatter: %w", err)
84+ }
85+
86+ skill.Instructions = strings.TrimSpace(body)
87+ skill.Path = filepath.Dir(path)
88+ skill.SkillFilePath = path
89+
90+ return &skill, nil
91+}
92+
93+// splitFrontmatter extracts YAML frontmatter and body from markdown content.
94+func splitFrontmatter(content string) (frontmatter, body string, err error) {
95+ // Normalize line endings to \n for consistent parsing.
96+ content = strings.ReplaceAll(content, "\r\n", "\n")
97+ if !strings.HasPrefix(content, "---\n") {
98+ return "", "", errors.New("no YAML frontmatter found")
99+ }
100+
101+ rest := strings.TrimPrefix(content, "---\n")
102+ before, after, ok := strings.Cut(rest, "\n---")
103+ if !ok {
104+ return "", "", errors.New("unclosed frontmatter")
105+ }
106+
107+ return before, after, nil
108+}
109+
110+// Discover finds all valid skills in the given paths.
111+func Discover(paths []string) []*Skill {
112+ var skills []*Skill
113+ seen := make(map[string]bool)
114+
115+ for _, base := range paths {
116+ filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error {
117+ if err != nil {
118+ return nil
119+ }
120+ if d.IsDir() || d.Name() != SkillFileName || seen[path] {
121+ return nil
122+ }
123+ seen[path] = true
124+ skill, err := Parse(path)
125+ if err != nil {
126+ slog.Warn("Failed to parse skill file", "path", path, "error", err)
127+ return nil
128+ }
129+ if err := skill.Validate(); err != nil {
130+ slog.Warn("Skill validation failed", "path", path, "error", err)
131+ return nil
132+ }
133+ slog.Info("Successfully loaded skill", "name", skill.Name, "path", path)
134+ skills = append(skills, skill)
135+ return nil
136+ })
137+ }
138+
139+ return skills
140+}
141+
142+// ToPromptXML generates XML for injection into the system prompt.
143+func ToPromptXML(skills []*Skill) string {
144+ if len(skills) == 0 {
145+ return ""
146+ }
147+
148+ var sb strings.Builder
149+ sb.WriteString("<available_skills>\n")
150+ for _, s := range skills {
151+ sb.WriteString(" <skill>\n")
152+ fmt.Fprintf(&sb, " <name>%s</name>\n", escape(s.Name))
153+ fmt.Fprintf(&sb, " <description>%s</description>\n", escape(s.Description))
154+ fmt.Fprintf(&sb, " <location>%s</location>\n", escape(s.SkillFilePath))
155+ sb.WriteString(" </skill>\n")
156+ }
157+ sb.WriteString("</available_skills>")
158+ return sb.String()
159+}
160+
161+func escape(s string) string {
162+ r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;", "'", "&apos;")
163+ return r.Replace(s)
164+}
internal/skills/skills_test.gonew file+249
@@ -0,0 +1,249 @@
1+package skills
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestParse(t *testing.T) {
13+ t.Parallel()
14+
15+ tests := []struct {
16+ name string
17+ content string
18+ wantName string
19+ wantDesc string
20+ wantLicense string
21+ wantCompat string
22+ wantMeta map[string]string
23+ wantTools string
24+ wantInstr string
25+ wantErr bool
26+ }{
27+ {
28+ name: "full skill",
29+ content: `---
30+name: pdf-processing
31+description: Extracts text and tables from PDF files, fills PDF forms, and merges multiple PDFs.
32+license: Apache-2.0
33+compatibility: Requires python 3.8+, pdfplumber, pdfrw libraries
34+metadata:
35+ author: example-org
36+ version: "1.0"
37+---
38+
39+# PDF Processing
40+
41+## When to use this skill
42+Use this skill when the user needs to work with PDF files.
43+`,
44+ wantName: "pdf-processing",
45+ wantDesc: "Extracts text and tables from PDF files, fills PDF forms, and merges multiple PDFs.",
46+ wantLicense: "Apache-2.0",
47+ wantCompat: "Requires python 3.8+, pdfplumber, pdfrw libraries",
48+ wantMeta: map[string]string{"author": "example-org", "version": "1.0"},
49+ wantInstr: "# PDF Processing\n\n## When to use this skill\nUse this skill when the user needs to work with PDF files.",
50+ },
51+ {
52+ name: "minimal skill",
53+ content: `---
54+name: my-skill
55+description: A simple skill for testing.
56+---
57+
58+# My Skill
59+
60+Instructions here.
61+`,
62+ wantName: "my-skill",
63+ wantDesc: "A simple skill for testing.",
64+ wantInstr: "# My Skill\n\nInstructions here.",
65+ },
66+ {
67+ name: "no frontmatter",
68+ content: "# Just Markdown\n\nNo frontmatter here.",
69+ wantErr: true,
70+ },
71+ }
72+
73+ for _, tt := range tests {
74+ t.Run(tt.name, func(t *testing.T) {
75+ t.Parallel()
76+
77+ // Write content to temp file.
78+ dir := t.TempDir()
79+ path := filepath.Join(dir, "SKILL.md")
80+ require.NoError(t, os.WriteFile(path, []byte(tt.content), 0o644))
81+
82+ skill, err := Parse(path)
83+ if tt.wantErr {
84+ require.Error(t, err)
85+ return
86+ }
87+ require.NoError(t, err)
88+
89+ require.Equal(t, tt.wantName, skill.Name)
90+ require.Equal(t, tt.wantDesc, skill.Description)
91+ require.Equal(t, tt.wantLicense, skill.License)
92+ require.Equal(t, tt.wantCompat, skill.Compatibility)
93+
94+ if tt.wantMeta != nil {
95+ require.Equal(t, tt.wantMeta, skill.Metadata)
96+ }
97+
98+ require.Equal(t, tt.wantInstr, skill.Instructions)
99+ })
100+ }
101+}
102+
103+func TestSkillValidate(t *testing.T) {
104+ t.Parallel()
105+
106+ tests := []struct {
107+ name string
108+ skill Skill
109+ wantErr bool
110+ errMsg string
111+ }{
112+ {
113+ name: "valid skill",
114+ skill: Skill{
115+ Name: "pdf-processing",
116+ Description: "Processes PDF files.",
117+ Path: "/skills/pdf-processing",
118+ },
119+ },
120+ {
121+ name: "missing name",
122+ skill: Skill{Description: "Some description."},
123+ wantErr: true,
124+ errMsg: "name is required",
125+ },
126+ {
127+ name: "missing description",
128+ skill: Skill{Name: "my-skill", Path: "/skills/my-skill"},
129+ wantErr: true,
130+ errMsg: "description is required",
131+ },
132+ {
133+ name: "name too long",
134+ skill: Skill{Name: strings.Repeat("a", 65), Description: "Some description."},
135+ wantErr: true,
136+ errMsg: "exceeds",
137+ },
138+ {
139+ name: "valid name - mixed case",
140+ skill: Skill{Name: "MySkill", Description: "Some description.", Path: "/skills/MySkill"},
141+ wantErr: false,
142+ },
143+ {
144+ name: "invalid name - starts with hyphen",
145+ skill: Skill{Name: "-my-skill", Description: "Some description."},
146+ wantErr: true,
147+ errMsg: "alphanumeric with hyphens",
148+ },
149+ {
150+ name: "name doesn't match directory",
151+ skill: Skill{Name: "my-skill", Description: "Some description.", Path: "/skills/other-skill"},
152+ wantErr: true,
153+ errMsg: "must match directory",
154+ },
155+ {
156+ name: "description too long",
157+ skill: Skill{Name: "my-skill", Description: strings.Repeat("a", 1025), Path: "/skills/my-skill"},
158+ wantErr: true,
159+ errMsg: "description exceeds",
160+ },
161+ {
162+ name: "compatibility too long",
163+ skill: Skill{Name: "my-skill", Description: "desc", Compatibility: strings.Repeat("a", 501), Path: "/skills/my-skill"},
164+ wantErr: true,
165+ errMsg: "compatibility exceeds",
166+ },
167+ }
168+
169+ for _, tt := range tests {
170+ t.Run(tt.name, func(t *testing.T) {
171+ t.Parallel()
172+
173+ err := tt.skill.Validate()
174+ if tt.wantErr {
175+ require.Error(t, err)
176+ require.Contains(t, err.Error(), tt.errMsg)
177+ } else {
178+ require.NoError(t, err)
179+ }
180+ })
181+ }
182+}
183+
184+func TestDiscover(t *testing.T) {
185+ t.Parallel()
186+
187+ tmpDir := t.TempDir()
188+
189+ // Create valid skill 1.
190+ skill1Dir := filepath.Join(tmpDir, "skill-one")
191+ require.NoError(t, os.MkdirAll(skill1Dir, 0o755))
192+ require.NoError(t, os.WriteFile(filepath.Join(skill1Dir, "SKILL.md"), []byte(`---
193+name: skill-one
194+description: First test skill.
195+---
196+# Skill One
197+`), 0o644))
198+
199+ // Create valid skill 2 in nested directory.
200+ skill2Dir := filepath.Join(tmpDir, "nested", "skill-two")
201+ require.NoError(t, os.MkdirAll(skill2Dir, 0o755))
202+ require.NoError(t, os.WriteFile(filepath.Join(skill2Dir, "SKILL.md"), []byte(`---
203+name: skill-two
204+description: Second test skill.
205+---
206+# Skill Two
207+`), 0o644))
208+
209+ // Create invalid skill (won't be included).
210+ invalidDir := filepath.Join(tmpDir, "invalid-dir")
211+ require.NoError(t, os.MkdirAll(invalidDir, 0o755))
212+ require.NoError(t, os.WriteFile(filepath.Join(invalidDir, "SKILL.md"), []byte(`---
213+name: wrong-name
214+description: Name doesn't match directory.
215+---
216+`), 0o644))
217+
218+ skills := Discover([]string{tmpDir})
219+ require.Len(t, skills, 2)
220+
221+ names := make(map[string]bool)
222+ for _, s := range skills {
223+ names[s.Name] = true
224+ }
225+ require.True(t, names["skill-one"])
226+ require.True(t, names["skill-two"])
227+}
228+
229+func TestToPromptXML(t *testing.T) {
230+ t.Parallel()
231+
232+ skills := []*Skill{
233+ {Name: "pdf-processing", Description: "Extracts text from PDFs.", SkillFilePath: "/skills/pdf-processing/SKILL.md"},
234+ {Name: "data-analysis", Description: "Analyzes datasets & charts.", SkillFilePath: "/skills/data-analysis/SKILL.md"},
235+ }
236+
237+ xml := ToPromptXML(skills)
238+
239+ require.Contains(t, xml, "<available_skills>")
240+ require.Contains(t, xml, "<name>pdf-processing</name>")
241+ require.Contains(t, xml, "<description>Extracts text from PDFs.</description>")
242+ require.Contains(t, xml, "&amp;") // XML escaping
243+}
244+
245+func TestToPromptXMLEmpty(t *testing.T) {
246+ t.Parallel()
247+ require.Empty(t, ToPromptXML(nil))
248+ require.Empty(t, ToPromptXML([]*Skill{}))
249+}