on main help sign in
feat(skills): builtin skills + skill disabling + crush-config builtin (#2466)
commit2832d5277fe92dad9e577ba9cabadaf4535c4432 authorChristian Rocha <christian@rocha.is> dateThu 2 Apr 2026, 16:37 (7 months ago) parent28f2087
11 files changed+609−24
README.md+37−18 internal/agent/common_test.go+1 internal/agent/prompt/prompt.go+25−2 internal/agent/templates/coder.md.tpl+4−1 internal/agent/tools/view.go+59 internal/agent/tools/view_test.go+52 internal/config/config.go+1 internal/skills/builtin/crush-config/SKILL.md+175 internal/skills/embed.go+66 internal/skills/skills.go+56−3 internal/skills/skills_test.go+133
README.md+37−18
@@ -224,6 +224,10 @@ Crush’s default model listing is managed in [Catwalk](https://github.com/charm
224224
225225## Configuration
226226
227+> [!TIP]
228+> Crush ships with a builtin `crush-config` skill for configuring itself. In
229+> many cases you can simply ask Crush to configure itself.
230+
227231Crush runs great with no configuration. That said, if you do need or want to
228232customize Crush, configuration can be added either local to the project itself,
229233or globally, with the following priority:
@@ -241,7 +245,8 @@ Configuration itself is stored as a JSON object:
241245}
242246```
243247
244−As an additional note, Crush also stores ephemeral data, such as application state, in one additional location:
248+As an additional note, Crush also stores ephemeral data, such as application
249+state, in one additional location:
245250
246251```bash
247252# Unix
@@ -253,8 +258,9 @@ $HOME/.local/share/crush/crush.json
253258
254259> [!TIP]
255260> You can override the user and data config locations by setting:
256−> * `CRUSH_GLOBAL_CONFIG`
257−> * `CRUSH_GLOBAL_DATA`
261+>
262+> - `CRUSH_GLOBAL_CONFIG`
263+> - `CRUSH_GLOBAL_DATA`
258264
259265### LSPs
260266
@@ -371,16 +377,28 @@ completely hidden from the agent.
371377{
372378 "$schema": "https://charm.land/crush.json",
373379 "options": {
374− "disabled_tools": [
375− "bash",
376− "sourcegraph"
377− ]
380+ "disabled_tools": ["bash", "sourcegraph"]
378381 }
379382}
380383```
381384
382385To disable tools from MCP servers, see the [MCP config section](#mcps).
383386
387+### Disabling Skills
388+
389+If you'd like to prevent Crush from using certain skills entirely, you can
390+disable them via the `options.disabled_skills` list. Disabled skills are hidden
391+from the agent, including builtin skills and skills discovered from disk.
392+
393+```json
394+{
395+ "$schema": "https://charm.land/crush.json",
396+ "options": {
397+ "disabled_skills": ["crush-config"]
398+ }
399+}
400+```
401+
384402### Agent Skills
385403
386404Crush supports the [Agent Skills](https://agentskills.io) open standard for
@@ -412,9 +430,9 @@ relative paths:
412430 "options": {
413431 "skills_paths": [
414432 "~/.config/crush/skills", // Windows: "%LOCALAPPDATA%\\crush\\skills",
415− "./project-skills"
416− ]
417− }
433+ "./project-skills",
434+ ],
435+ },
418436}
419437```
420438
@@ -446,8 +464,8 @@ focused _and_ your terminal supports reporting the focus state.
446464{
447465 "$schema": "https://charm.land/crush.json",
448466 "options": {
449− "disable_notifications": false // default
450− }
467+ "disable_notifications": false, // default
468+ },
451469}
452470```
453471
@@ -496,10 +514,10 @@ it creates. You can customize this behavior with the `attribution` option:
496514
497515- `trailer_style`: Controls the attribution trailer added to commit messages
498516 (default: `assisted-by`)
499− - `assisted-by`: Adds `Assisted-by: [Model Name] via Crush <crush@charm.land>`
500− (includes the model name)
501− - `co-authored-by`: Adds `Co-Authored-By: Crush <crush@charm.land>`
502− - `none`: No attribution trailer
517+ - `assisted-by`: Adds `Assisted-by: [Model Name] via Crush <crush@charm.land>`
518+ (includes the model name)
519+ - `co-authored-by`: Adds `Co-Authored-By: Crush <crush@charm.land>`
520+ - `none`: No attribution trailer
503521- `generated_with`: When true (default), adds `💘 Generated with Crush` line to
504522 commit messages and PR descriptions
505523
@@ -511,8 +529,9 @@ Anthropic-compatible APIs.
511529> [!NOTE]
512530> Note that we support two "types" for OpenAI. Make sure to choose the right one
513531> to ensure the best experience!
514−> * `openai` should be used when proxying or routing requests through OpenAI.
515−> * `openai-compat` should be used when using non-OpenAI providers that have OpenAI-compatible APIs.
532+>
533+> - `openai` should be used when proxying or routing requests through OpenAI.
534+> - `openai-compat` should be used when using non-OpenAI providers that have OpenAI-compatible APIs.
516535
517536#### OpenAI-Compatible APIs
518537
internal/agent/common_test.go+1
@@ -192,6 +192,7 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel
192192
193193 // Clear some fields to avoid issues with VCR cassette matching.
194194 cfg.Config().Options.SkillsPaths = nil
195+ cfg.Config().Options.DisabledSkills = []string{"crush-config"}
195196 cfg.Config().Options.ContextPaths = nil
196197 cfg.Config().LSP = nil
197198
internal/agent/prompt/prompt.go+25−2
@@ -4,6 +4,7 @@ import (
44 "cmp"
55 "context"
66 "fmt"
7+ "log/slog"
78 "os"
89 "path/filepath"
910 "runtime"
@@ -167,16 +168,38 @@ func (p *Prompt) promptData(ctx context.Context, provider, model string, store *
167168
168169 // Discover and load skills metadata.
169170 var availSkillXML string
171+
172+ // Start with builtin skills.
173+ allSkills := skills.DiscoverBuiltin()
174+ builtinNames := make(map[string]bool, len(allSkills))
175+ for _, s := range allSkills {
176+ builtinNames[s.Name] = true
177+ }
178+
179+ // Discover user skills from configured paths.
170180 if len(cfg.Options.SkillsPaths) > 0 {
171181 expandedPaths := make([]string, 0, len(cfg.Options.SkillsPaths))
172182 for _, pth := range cfg.Options.SkillsPaths {
173183 expandedPaths = append(expandedPaths, expandPath(pth, store))
174184 }
175− if discoveredSkills := skills.Discover(expandedPaths); len(discoveredSkills) > 0 {
176− availSkillXML = skills.ToPromptXML(discoveredSkills)
185+ for _, userSkill := range skills.Discover(expandedPaths) {
186+ if builtinNames[userSkill.Name] {
187+ slog.Warn("User skill overrides builtin skill", "name", userSkill.Name)
188+ }
189+ allSkills = append(allSkills, userSkill)
177190 }
178191 }
179192
193+ // Deduplicate: user skills override builtins with the same name.
194+ allSkills = skills.Deduplicate(allSkills)
195+
196+ // Filter out disabled skills.
197+ allSkills = skills.Filter(allSkills, cfg.Options.DisabledSkills)
198+
199+ if len(allSkills) > 0 {
200+ availSkillXML = skills.ToPromptXML(allSkills)
201+ }
202+
180203 isGit := isGitRepo(store.WorkingDir())
181204 data := PromptDat{
182205 Provider: provider,
internal/agent/templates/coder.md.tpl+4−1
@@ -377,7 +377,10 @@ Diagnostics (lint/typecheck) included in tool output.
377377
378378<skills_usage>
379379When a user task matches a skill's description, read the skill's SKILL.md file to get full instructions.
380−Skills are activated by reading their location path. Follow the skill's instructions to complete the task.
380+Skills are activated by reading their **exact** location path as shown above using the View tool. Always pass the location value directly to the View tool's file_path parameter — never guess, modify, or construct skill paths yourself.
381+Builtin skills (type=builtin) have virtual location identifiers starting with "crush://skills/". The "crush://" prefix is NOT a URL or network address — it is a special internal identifier that the View tool understands natively. Pass them verbatim to the View tool. Do not treat them as URLs, MCP resources, or filesystem paths.
382+Do not use MCP tools (including read_mcp_resource) to load skills.
383+Follow the skill's instructions to complete the task.
381384If 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).
382385</skills_usage>
383386{{end}}
internal/agent/tools/view.go+59
@@ -7,6 +7,7 @@ import (
77 "encoding/base64"
88 "fmt"
99 "io"
10+ "io/fs"
1011 "os"
1112 "path/filepath"
1213 "strings"
@@ -73,6 +74,11 @@ func NewViewTool(
7374 return fantasy.NewTextErrorResponse("file_path is required"), nil
7475 }
7576
77+ // Handle builtin skill files (crush: prefix).
78+ if strings.HasPrefix(params.FilePath, skills.BuiltinPrefix) {
79+ return readBuiltinFile(params)
80+ }
81+
7682 // Handle relative paths
7783 filePath := filepathext.SmartJoin(workingDir, params.FilePath)
7884
@@ -373,3 +379,56 @@ func isInSkillsPath(filePath string, skillsPaths []string) bool {
373379
374380 return false
375381}
382+
383+// readBuiltinFile reads a file from the embedded builtin skills filesystem.
384+func readBuiltinFile(params ViewParams) (fantasy.ToolResponse, error) {
385+ embeddedPath := "builtin/" + strings.TrimPrefix(params.FilePath, skills.BuiltinPrefix)
386+ builtinFS := skills.BuiltinFS()
387+
388+ data, err := fs.ReadFile(builtinFS, embeddedPath)
389+ if err != nil {
390+ return fantasy.NewTextErrorResponse(fmt.Sprintf("Builtin file not found: %s", params.FilePath)), nil
391+ }
392+
393+ content := string(data)
394+ if !utf8.ValidString(content) {
395+ return fantasy.NewTextErrorResponse("File content is not valid UTF-8"), nil
396+ }
397+
398+ limit := params.Limit
399+ if limit <= 0 {
400+ limit = 1000000 // Effectively no limit for skill files.
401+ }
402+
403+ lines := strings.Split(content, "\n")
404+ offset := min(params.Offset, len(lines))
405+ lines = lines[offset:]
406+
407+ hasMore := len(lines) > limit
408+ if hasMore {
409+ lines = lines[:limit]
410+ }
411+
412+ output := "<file>\n"
413+ output += addLineNumbers(strings.Join(lines, "\n"), offset+1)
414+ if hasMore {
415+ output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
416+ offset+len(lines))
417+ }
418+ output += "\n</file>\n"
419+
420+ meta := ViewResponseMetadata{
421+ FilePath: params.FilePath,
422+ Content: strings.Join(lines, "\n"),
423+ }
424+ if skill, err := skills.ParseContent(data); err == nil {
425+ meta.ResourceType = ViewResourceSkill
426+ meta.ResourceName = skill.Name
427+ meta.ResourceDescription = skill.Description
428+ }
429+
430+ return fantasy.WithResponseMetadata(
431+ fantasy.NewTextResponse(output),
432+ meta,
433+ ), nil
434+}
internal/agent/tools/view_test.go+52
@@ -1,6 +1,7 @@
11package tools
22
33import (
4+ "encoding/json"
45 "fmt"
56 "os"
67 "path/filepath"
@@ -85,3 +86,54 @@ func TestReadTextFileTruncatesLongLines(t *testing.T) {
8586 require.False(t, hasMore)
8687 require.Equal(t, strings.Repeat("a", MaxLineLength)+"...", content)
8788}
89+
90+func TestReadBuiltinFile(t *testing.T) {
91+ t.Parallel()
92+
93+ t.Run("reads crush-config skill", func(t *testing.T) {
94+ t.Parallel()
95+
96+ resp, err := readBuiltinFile(ViewParams{
97+ FilePath: "crush://skills/crush-config/SKILL.md",
98+ })
99+ require.NoError(t, err)
100+ require.NotEmpty(t, resp.Content)
101+ require.Contains(t, resp.Content, "Crush Configuration")
102+ })
103+
104+ t.Run("not found", func(t *testing.T) {
105+ t.Parallel()
106+
107+ resp, err := readBuiltinFile(ViewParams{
108+ FilePath: "crush://skills/nonexistent/SKILL.md",
109+ })
110+ require.NoError(t, err)
111+ require.True(t, resp.IsError)
112+ })
113+
114+ t.Run("metadata has skill info", func(t *testing.T) {
115+ t.Parallel()
116+
117+ resp, err := readBuiltinFile(ViewParams{
118+ FilePath: "crush://skills/crush-config/SKILL.md",
119+ })
120+ require.NoError(t, err)
121+
122+ var meta ViewResponseMetadata
123+ require.NoError(t, json.Unmarshal([]byte(resp.Metadata), &meta))
124+ require.Equal(t, ViewResourceSkill, meta.ResourceType)
125+ require.Equal(t, "crush-config", meta.ResourceName)
126+ require.NotEmpty(t, meta.ResourceDescription)
127+ })
128+
129+ t.Run("respects offset", func(t *testing.T) {
130+ t.Parallel()
131+
132+ resp, err := readBuiltinFile(ViewParams{
133+ FilePath: "crush://skills/crush-config/SKILL.md",
134+ Offset: 5,
135+ })
136+ require.NoError(t, err)
137+ require.NotContains(t, resp.Content, " 1|")
138+ })
139+}
internal/config/config.go+1
@@ -256,6 +256,7 @@ type Options struct {
256256 AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers,default=true"`
257257 Progress *bool `json:"progress,omitempty" jsonschema:"description=Show indeterminate progress updates during long operations,default=true"`
258258 DisableNotifications bool `json:"disable_notifications,omitempty" jsonschema:"description=Disable desktop notifications,default=false"`
259+ DisabledSkills []string `json:"disabled_skills,omitempty" jsonschema:"description=List of skill names to disable and hide from the agent,example=crush-config"`
259260}
260261
261262type MCPs map[string]MCPConfig
internal/skills/builtin/crush-config/SKILL.mdnew file+175
@@ -0,0 +1,175 @@
1+---
2+name: crush-config
3+description: Configure Crush settings including providers, LSPs, MCPs, skills, permissions, and behavior options. Use when the user needs help with crush.json configuration, setting up providers, configuring LSPs, adding MCP servers, or changing Crush behavior.
4+---
5+
6+# Crush Configuration
7+
8+Crush uses JSON configuration files with the following priority (highest to lowest):
9+
10+1. `.crush.json` (project-local, hidden)
11+2. `crush.json` (project-local)
12+3. `$XDG_CONFIG_HOME/crush/crush.json` or `$HOME/.config/crush/crush.json` (global)
13+
14+## Basic Structure
15+
16+```json
17+{
18+ "$schema": "https://charm.land/crush.json",
19+ "options": {}
20+}
21+```
22+
23+The `$schema` property enables IDE autocomplete but is optional.
24+
25+## Common Configurations
26+
27+### Project-Local Skills
28+
29+Add a relative path to keep project-specific skills alongside your code:
30+
31+```json
32+{
33+ "options": {
34+ "skills_paths": ["./skills"]
35+ }
36+}
37+```
38+
39+> [!IMPORTANT]
40+> Keep in mind that the following paths are loaded by default, so they DO NOT NEED to be added to `skill_paths`:
41+>
42+> * `.agents/skills`
43+> * `.crush/skills`
44+> * `.claude/skills`
45+> * `.cursor/skills`
46+
47+### LSP Configuration
48+
49+```json
50+{
51+ "lsp": {
52+ "go": {
53+ "command": "gopls",
54+ "env": {
55+ "GOTOOLCHAIN": "go1.24.5"
56+ }
57+ },
58+ "typescript": {
59+ "command": "typescript-language-server",
60+ "args": ["--stdio"]
61+ }
62+ }
63+}
64+```
65+
66+### MCP Servers
67+
68+```json
69+{
70+ "mcp": {
71+ "filesystem": {
72+ "type": "stdio",
73+ "command": "node",
74+ "args": ["/path/to/mcp-server.js"]
75+ },
76+ "github": {
77+ "type": "http",
78+ "url": "https://api.githubcopilot.com/mcp/",
79+ "headers": {
80+ "Authorization": "Bearer $GH_PAT"
81+ }
82+ }
83+ }
84+}
85+```
86+
87+### Custom Provider
88+
89+```json
90+{
91+ "providers": {
92+ "deepseek": {
93+ "type": "openai-compat",
94+ "base_url": "https://api.deepseek.com/v1",
95+ "api_key": "$DEEPSEEK_API_KEY",
96+ "models": [
97+ {
98+ "id": "deepseek-chat",
99+ "name": "Deepseek V3",
100+ "context_window": 64000
101+ }
102+ ]
103+ }
104+ }
105+}
106+```
107+
108+### Tool Permissions
109+
110+```json
111+{
112+ "permissions": {
113+ "allowed_tools": ["view", "ls", "grep", "edit"]
114+ }
115+}
116+```
117+
118+### Disable Built-in Tools
119+
120+```json
121+{
122+ "options": {
123+ "disabled_tools": ["bash", "sourcegraph"]
124+ }
125+}
126+```
127+
128+### Disable Skills
129+
130+```json
131+{
132+ "options": {
133+ "disabled_skills": ["crush-config"]
134+ }
135+}
136+```
137+
138+`disabled_skills` disables skills by name, including both builtin skills and
139+skills discovered from disk paths.
140+
141+### Debug Options
142+
143+```json
144+{
145+ "options": {
146+ "debug": true,
147+ "debug_lsp": true
148+ }
149+}
150+```
151+
152+### Attribution Settings
153+
154+```json
155+{
156+ "options": {
157+ "attribution": {
158+ "trailer_style": "assisted-by",
159+ "generated_with": true
160+ }
161+ }
162+}
163+```
164+
165+## Environment Variables
166+
167+- `CRUSH_GLOBAL_CONFIG` - Override global config location
168+- `CRUSH_GLOBAL_DATA` - Override data directory location
169+- `CRUSH_SKILLS_DIR` - Override default skills directory
170+
171+## Provider Types
172+
173+- `openai` - For OpenAI or OpenAI-compatible APIs that route through OpenAI
174+- `openai-compat` - For non-OpenAI providers with OpenAI-compatible APIs
175+- `anthropic` - For Anthropic-compatible APIs
internal/skills/embed.gonew file+66
@@ -0,0 +1,66 @@
1+package skills
2+
3+import (
4+ "embed"
5+ "io/fs"
6+ "log/slog"
7+ "path/filepath"
8+)
9+
10+// BuiltinPrefix is the path prefix for builtin skill files. It is used by
11+// the View tool to distinguish embedded files from disk files.
12+const BuiltinPrefix = "crush://skills/"
13+
14+//go:embed builtin/*
15+var builtinFS embed.FS
16+
17+// BuiltinFS returns the embedded filesystem containing builtin skills.
18+func BuiltinFS() embed.FS {
19+ return builtinFS
20+}
21+
22+// DiscoverBuiltin finds all valid skills embedded in the binary.
23+func DiscoverBuiltin() []*Skill {
24+ var discovered []*Skill
25+
26+ fs.WalkDir(builtinFS, "builtin", func(path string, d fs.DirEntry, err error) error {
27+ if err != nil {
28+ return nil
29+ }
30+ if d.IsDir() || d.Name() != SkillFileName {
31+ return nil
32+ }
33+
34+ content, err := builtinFS.ReadFile(path)
35+ if err != nil {
36+ slog.Warn("Failed to read builtin skill file", "path", path, "error", err)
37+ return nil
38+ }
39+
40+ skill, err := ParseContent(content)
41+ if err != nil {
42+ slog.Warn("Failed to parse builtin skill file", "path", path, "error", err)
43+ return nil
44+ }
45+
46+ // Set paths using the crush prefix. Strip the leading "builtin/"
47+ // so the path is relative to the embedded root
48+ // (e.g., "crush://skills/crush-config/SKILL.md").
49+ relPath, _ := filepath.Rel("builtin", path)
50+ relPath = filepath.ToSlash(relPath)
51+ skill.SkillFilePath = BuiltinPrefix + relPath
52+ skill.Path = BuiltinPrefix + filepath.Dir(relPath)
53+ skill.Builtin = true
54+
55+ if err := skill.Validate(); err != nil {
56+ slog.Warn("Builtin skill validation failed", "path", path, "error", err)
57+ return nil
58+ }
59+
60+ slog.Debug("Successfully loaded builtin skill", "name", skill.Name, "path", skill.SkillFilePath)
61+ discovered = append(discovered, skill)
62+ return nil
63+ })
64+
65+ return discovered
66+}
internal/skills/skills.go+56−3
@@ -35,6 +35,7 @@ type Skill struct {
3535 Instructions string `yaml:"-" json:"instructions"`
3636 Path string `yaml:"-" json:"path"`
3737 SkillFilePath string `yaml:"-" json:"skill_file_path"`
38+ Builtin bool `yaml:"-" json:"builtin"`
3839}
3940
4041// Validate checks if the skill meets spec requirements.
@@ -68,13 +69,26 @@ func (s *Skill) Validate() error {
6869 return errors.Join(errs...)
6970}
7071
71−// Parse parses a SKILL.md file.
72+// Parse parses a SKILL.md file from disk.
7273func Parse(path string) (*Skill, error) {
7374 content, err := os.ReadFile(path)
7475 if err != nil {
7576 return nil, err
7677 }
7778
79+ skill, err := ParseContent(content)
80+ if err != nil {
81+ return nil, err
82+ }
83+
84+ skill.Path = filepath.Dir(path)
85+ skill.SkillFilePath = path
86+
87+ return skill, nil
88+}
89+
90+// ParseContent parses a SKILL.md from raw bytes.
91+func ParseContent(content []byte) (*Skill, error) {
7892 frontmatter, body, err := splitFrontmatter(string(content))
7993 if err != nil {
8094 return nil, err
@@ -86,8 +100,6 @@ func Parse(path string) (*Skill, error) {
86100 }
87101
88102 skill.Instructions = strings.TrimSpace(body)
89− skill.Path = filepath.Dir(path)
90− skill.SkillFilePath = path
91103
92104 return &skill, nil
93105}
@@ -171,6 +183,9 @@ func ToPromptXML(skills []*Skill) string {
171183 fmt.Fprintf(&sb, " <name>%s</name>\n", escape(s.Name))
172184 fmt.Fprintf(&sb, " <description>%s</description>\n", escape(s.Description))
173185 fmt.Fprintf(&sb, " <location>%s</location>\n", escape(s.SkillFilePath))
186+ if s.Builtin {
187+ sb.WriteString(" <type>builtin</type>\n")
188+ }
174189 sb.WriteString(" </skill>\n")
175190 }
176191 sb.WriteString("</available_skills>")
@@ -181,3 +196,41 @@ func escape(s string) string {
181196 r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;", "'", "&apos;")
182197 return r.Replace(s)
183198}
199+
200+// Deduplicate removes duplicate skills by name. When duplicates exist, the
201+// last occurrence wins. This means user skills (appended after builtins)
202+// override builtin skills with the same name.
203+func Deduplicate(all []*Skill) []*Skill {
204+ seen := make(map[string]int, len(all))
205+ for i, s := range all {
206+ seen[s.Name] = i
207+ }
208+
209+ result := make([]*Skill, 0, len(seen))
210+ for i, s := range all {
211+ if seen[s.Name] == i {
212+ result = append(result, s)
213+ }
214+ }
215+ return result
216+}
217+
218+// Filter removes skills whose names appear in the disabled list.
219+func Filter(all []*Skill, disabled []string) []*Skill {
220+ if len(disabled) == 0 {
221+ return all
222+ }
223+
224+ disabledSet := make(map[string]bool, len(disabled))
225+ for _, name := range disabled {
226+ disabledSet[name] = true
227+ }
228+
229+ result := make([]*Skill, 0, len(all))
230+ for _, s := range all {
231+ if !disabledSet[s.Name] {
232+ result = append(result, s)
233+ }
234+ }
235+ return result
236+}
internal/skills/skills_test.go+133
@@ -247,3 +247,136 @@ func TestToPromptXMLEmpty(t *testing.T) {
247247 require.Empty(t, ToPromptXML(nil))
248248 require.Empty(t, ToPromptXML([]*Skill{}))
249249}
250+
251+func TestToPromptXMLBuiltinType(t *testing.T) {
252+ t.Parallel()
253+
254+ skills := []*Skill{
255+ {Name: "builtin-skill", Description: "A builtin.", SkillFilePath: "crush://skills/builtin-skill/SKILL.md", Builtin: true},
256+ {Name: "user-skill", Description: "A user skill.", SkillFilePath: "/home/user/.config/crush/skills/user-skill/SKILL.md"},
257+ }
258+ xml := ToPromptXML(skills)
259+ require.Contains(t, xml, "<type>builtin</type>")
260+ require.Equal(t, 1, strings.Count(xml, "<type>builtin</type>"))
261+}
262+
263+func TestParseContent(t *testing.T) {
264+ t.Parallel()
265+
266+ content := []byte(`---
267+name: my-skill
268+description: A test skill.
269+---
270+
271+# My Skill
272+
273+Instructions here.
274+`)
275+ skill, err := ParseContent(content)
276+ require.NoError(t, err)
277+ require.Equal(t, "my-skill", skill.Name)
278+ require.Equal(t, "A test skill.", skill.Description)
279+ require.Equal(t, "# My Skill\n\nInstructions here.", skill.Instructions)
280+ require.Empty(t, skill.Path)
281+ require.Empty(t, skill.SkillFilePath)
282+}
283+
284+func TestParseContent_NoFrontmatter(t *testing.T) {
285+ t.Parallel()
286+
287+ _, err := ParseContent([]byte("# Just Markdown"))
288+ require.Error(t, err)
289+}
290+
291+func TestDiscoverBuiltin(t *testing.T) {
292+ t.Parallel()
293+
294+ discovered := DiscoverBuiltin()
295+ require.NotEmpty(t, discovered)
296+
297+ var found bool
298+ for _, s := range discovered {
299+ if s.Name == "crush-config" {
300+ found = true
301+ require.True(t, strings.HasPrefix(s.SkillFilePath, BuiltinPrefix))
302+ require.True(t, strings.HasPrefix(s.Path, BuiltinPrefix))
303+ require.Equal(t, "crush://skills/crush-config/SKILL.md", s.SkillFilePath)
304+ require.Equal(t, "crush://skills/crush-config", s.Path)
305+ require.NotEmpty(t, s.Description)
306+ require.NotEmpty(t, s.Instructions)
307+ require.True(t, s.Builtin)
308+ }
309+ }
310+ require.True(t, found, "crush-config builtin skill not found")
311+}
312+
313+func TestDeduplicate(t *testing.T) {
314+ t.Parallel()
315+
316+ tests := []struct {
317+ name string
318+ input []*Skill
319+ wantLen int
320+ wantName string
321+ wantPath string
322+ }{
323+ {
324+ name: "no duplicates",
325+ input: []*Skill{{Name: "a", Path: "/a"}, {Name: "b", Path: "/b"}},
326+ wantLen: 2,
327+ },
328+ {
329+ name: "user overrides builtin",
330+ input: []*Skill{{Name: "crush-config", Path: "crush://skills/crush-config"}, {Name: "crush-config", Path: "/user/crush-config"}},
331+ wantLen: 1,
332+ wantName: "crush-config",
333+ wantPath: "/user/crush-config",
334+ },
335+ {
336+ name: "empty",
337+ input: nil,
338+ wantLen: 0,
339+ },
340+ }
341+
342+ for _, tt := range tests {
343+ t.Run(tt.name, func(t *testing.T) {
344+ t.Parallel()
345+ result := Deduplicate(tt.input)
346+ require.Len(t, result, tt.wantLen)
347+ if tt.wantName != "" {
348+ require.Equal(t, tt.wantName, result[0].Name)
349+ require.Equal(t, tt.wantPath, result[0].Path)
350+ }
351+ })
352+ }
353+}
354+
355+func TestFilter(t *testing.T) {
356+ t.Parallel()
357+
358+ all := []*Skill{
359+ {Name: "a"},
360+ {Name: "b"},
361+ {Name: "c"},
362+ }
363+
364+ tests := []struct {
365+ name string
366+ disabled []string
367+ wantLen int
368+ }{
369+ {"no filter", nil, 3},
370+ {"filter one", []string{"b"}, 2},
371+ {"filter all", []string{"a", "b", "c"}, 0},
372+ {"filter nonexistent", []string{"d"}, 3},
373+ }
374+
375+ for _, tt := range tests {
376+ t.Run(tt.name, func(t *testing.T) {
377+ t.Parallel()
378+ result := Filter(all, tt.disabled)
379+ require.Len(t, result, tt.wantLen)
380+ })
381+ }
382+}