From 1fbe7d48900d8734d4a8c4aaffe734e95f2b786d Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Sat, 27 Dec 2025 21:10:04 +0100 Subject: [PATCH] feat: agent skills (#1690) Co-authored-by: Christian Rocha --- 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(-) create mode 100644 internal/skills/skills.go create mode 100644 internal/skills/skills_test.go 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{})) +}