on main help sign in
feat(skills): builtin skills + skill disabling + crush-config builtin (#2466)
2832d52Christian Rocha
README.md+37−18
@@ -224,6 +224,10 @@ Crush’s default model listing is managed in [Catwalk](https://github.com/charm
224
224
225## Configuration
225## Configuration
226
226
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+
227Crush runs great with no configuration. That said, if you do need or want to
231Crush runs great with no configuration. That said, if you do need or want to
228customize Crush, configuration can be added either local to the project itself,
232customize Crush, configuration can be added either local to the project itself,
229or globally, with the following priority:
233or globally, with the following priority:
@@ -241,7 +245,8 @@ Configuration itself is stored as a JSON object:
241}
245}
242```
246```
243
247
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:
245
250
246```bash
251```bash
247# Unix
252# Unix
@@ -253,8 +258,9 @@ $HOME/.local/share/crush/crush.json
253
258
254> [!TIP]
259> [!TIP]
255> You can override the user and data config locations by setting:
260> You can override the user and data config locations by setting:
261+>
256−> * `CRUSH_GLOBAL_CONFIG`
262+> - `CRUSH_GLOBAL_CONFIG`
257−> * `CRUSH_GLOBAL_DATA`
263+> - `CRUSH_GLOBAL_DATA`
258
264
259### LSPs
265### LSPs
260
266
@@ -371,16 +377,28 @@ completely hidden from the agent.
371{
377{
372 "$schema": "https://charm.land/crush.json",
378 "$schema": "https://charm.land/crush.json",
373 "options": {
379 "options": {
374− "disabled_tools": [
380+ "disabled_tools": ["bash", "sourcegraph"]
375− "bash",
376− "sourcegraph"
377− ]
378 }
381 }
379}
382}
380```
383```
381
384
382To disable tools from MCP servers, see the [MCP config section](#mcps).
385To disable tools from MCP servers, see the [MCP config section](#mcps).
383
386
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+
384### Agent Skills
402### Agent Skills
385
403
386Crush supports the [Agent Skills](https://agentskills.io) open standard for
404Crush supports the [Agent Skills](https://agentskills.io) open standard for
@@ -412,9 +430,9 @@ relative paths:
412 "options": {
430 "options": {
413 "skills_paths": [
431 "skills_paths": [
414 "~/.config/crush/skills", // Windows: "%LOCALAPPDATA%\\crush\\skills",
432 "~/.config/crush/skills", // Windows: "%LOCALAPPDATA%\\crush\\skills",
415− "./project-skills"
433+ "./project-skills",
416− ]
434+ ],
417− }
435+ },
418}
436}
419```
437```
420
438
@@ -446,8 +464,8 @@ focused _and_ your terminal supports reporting the focus state.
446{
464{
447 "$schema": "https://charm.land/crush.json",
465 "$schema": "https://charm.land/crush.json",
448 "options": {
466 "options": {
449− "disable_notifications": false // default
467+ "disable_notifications": false, // default
450− }
468+ },
451}
469}
452```
470```
453
471
@@ -496,10 +514,10 @@ it creates. You can customize this behavior with the `attribution` option:
496
514
497- `trailer_style`: Controls the attribution trailer added to commit messages
515- `trailer_style`: Controls the attribution trailer added to commit messages
498 (default: `assisted-by`)
516 (default: `assisted-by`)
499− - `assisted-by`: Adds `Assisted-by: [Model Name] via Crush <crush@charm.land>`
517+ - `assisted-by`: Adds `Assisted-by: [Model Name] via Crush <crush@charm.land>`
500− (includes the model name)
518+ (includes the model name)
501− - `co-authored-by`: Adds `Co-Authored-By: Crush <crush@charm.land>`
519+ - `co-authored-by`: Adds `Co-Authored-By: Crush <crush@charm.land>`
502− - `none`: No attribution trailer
520+ - `none`: No attribution trailer
503- `generated_with`: When true (default), adds `💘 Generated with Crush` line to
521- `generated_with`: When true (default), adds `💘 Generated with Crush` line to
504 commit messages and PR descriptions
522 commit messages and PR descriptions
505
523
@@ -511,8 +529,9 @@ Anthropic-compatible APIs.
511> [!NOTE]
529> [!NOTE]
512> Note that we support two "types" for OpenAI. Make sure to choose the right one
530> Note that we support two "types" for OpenAI. Make sure to choose the right one
513> to ensure the best experience!
531> to ensure the best experience!
532+>
514−> * `openai` should be used when proxying or routing requests through OpenAI.
533+> - `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.
534+> - `openai-compat` should be used when using non-OpenAI providers that have OpenAI-compatible APIs.
516
535
517#### OpenAI-Compatible APIs
536#### OpenAI-Compatible APIs
518
537
internal/agent/common_test.go+1
@@ -192,6 +192,7 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel
192
192
193 // Clear some fields to avoid issues with VCR cassette matching.
193 // Clear some fields to avoid issues with VCR cassette matching.
194 cfg.Config().Options.SkillsPaths = nil
194 cfg.Config().Options.SkillsPaths = nil
195+ cfg.Config().Options.DisabledSkills = []string{"crush-config"}
195 cfg.Config().Options.ContextPaths = nil
196 cfg.Config().Options.ContextPaths = nil
196 cfg.Config().LSP = nil
197 cfg.Config().LSP = nil
197
198
internal/agent/prompt/prompt.go+25−2
@@ -4,6 +4,7 @@ import (
4 "cmp"
4 "cmp"
5 "context"
5 "context"
6 "fmt"
6 "fmt"
7+ "log/slog"
7 "os"
8 "os"
8 "path/filepath"
9 "path/filepath"
9 "runtime"
10 "runtime"
@@ -167,16 +168,38 @@ func (p *Prompt) promptData(ctx context.Context, provider, model string, store *
167
168
168 // Discover and load skills metadata.
169 // Discover and load skills metadata.
169 var availSkillXML string
170 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.
170 if len(cfg.Options.SkillsPaths) > 0 {
180 if len(cfg.Options.SkillsPaths) > 0 {
171 expandedPaths := make([]string, 0, len(cfg.Options.SkillsPaths))
181 expandedPaths := make([]string, 0, len(cfg.Options.SkillsPaths))
172 for _, pth := range cfg.Options.SkillsPaths {
182 for _, pth := range cfg.Options.SkillsPaths {
173 expandedPaths = append(expandedPaths, expandPath(pth, store))
183 expandedPaths = append(expandedPaths, expandPath(pth, store))
174 }
184 }
175− if discoveredSkills := skills.Discover(expandedPaths); len(discoveredSkills) > 0 {
185+ for _, userSkill := range skills.Discover(expandedPaths) {
176− availSkillXML = skills.ToPromptXML(discoveredSkills)
186+ if builtinNames[userSkill.Name] {
187+ slog.Warn("User skill overrides builtin skill", "name", userSkill.Name)
188+ }
189+ allSkills = append(allSkills, userSkill)
177 }
190 }
178 }
191 }
179
192
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+
180 isGit := isGitRepo(store.WorkingDir())
203 isGit := isGitRepo(store.WorkingDir())
181 data := PromptDat{
204 data := PromptDat{
182 Provider: provider,
205 Provider: provider,
internal/agent/templates/coder.md.tpl+4−1
@@ -377,7 +377,10 @@ Diagnostics (lint/typecheck) included in tool output.
377
377
378<skills_usage>
378<skills_usage>
379When a user task matches a skill's description, read the skill's SKILL.md file to get full instructions.
379When 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 **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.
380−Skills are activated by reading their location path. Follow the skill's instructions to complete the task.
383+Follow the skill's instructions to complete the task.
381If 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).
384If 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).
382</skills_usage>
385</skills_usage>
383{{end}}
386{{end}}
internal/agent/tools/view.go+59
@@ -7,6 +7,7 @@ import (
7 "encoding/base64"
7 "encoding/base64"
8 "fmt"
8 "fmt"
9 "io"
9 "io"
10+ "io/fs"
10 "os"
11 "os"
11 "path/filepath"
12 "path/filepath"
12 "strings"
13 "strings"
@@ -73,6 +74,11 @@ func NewViewTool(
73 return fantasy.NewTextErrorResponse("file_path is required"), nil
74 return fantasy.NewTextErrorResponse("file_path is required"), nil
74 }
75 }
75
76
77+ // Handle builtin skill files (crush: prefix).
78+ if strings.HasPrefix(params.FilePath, skills.BuiltinPrefix) {
79+ return readBuiltinFile(params)
80+ }
81+
76 // Handle relative paths
82 // Handle relative paths
77 filePath := filepathext.SmartJoin(workingDir, params.FilePath)
83 filePath := filepathext.SmartJoin(workingDir, params.FilePath)
78
84
@@ -373,3 +379,56 @@ func isInSkillsPath(filePath string, skillsPaths []string) bool {
373
379
374 return false
380 return false
375}
381}
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 @@
1package tools
1package tools
2
2
3import (
3import (
4+ "encoding/json"
4 "fmt"
5 "fmt"
5 "os"
6 "os"
6 "path/filepath"
7 "path/filepath"
@@ -85,3 +86,54 @@ func TestReadTextFileTruncatesLongLines(t *testing.T) {
85 require.False(t, hasMore)
86 require.False(t, hasMore)
86 require.Equal(t, strings.Repeat("a", MaxLineLength)+"...", content)
87 require.Equal(t, strings.Repeat("a", MaxLineLength)+"...", content)
87}
88}
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 {
256 AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers,default=true"`
256 AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers,default=true"`
257 Progress *bool `json:"progress,omitempty" jsonschema:"description=Show indeterminate progress updates during long operations,default=true"`
257 Progress *bool `json:"progress,omitempty" jsonschema:"description=Show indeterminate progress updates during long operations,default=true"`
258 DisableNotifications bool `json:"disable_notifications,omitempty" jsonschema:"description=Disable desktop notifications,default=false"`
258 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"`
259}
260}
260
261
261type MCPs map[string]MCPConfig
262type 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 {
35 Instructions string `yaml:"-" json:"instructions"`
35 Instructions string `yaml:"-" json:"instructions"`
36 Path string `yaml:"-" json:"path"`
36 Path string `yaml:"-" json:"path"`
37 SkillFilePath string `yaml:"-" json:"skill_file_path"`
37 SkillFilePath string `yaml:"-" json:"skill_file_path"`
38+ Builtin bool `yaml:"-" json:"builtin"`
38}
39}
39
40
40// Validate checks if the skill meets spec requirements.
41// Validate checks if the skill meets spec requirements.
@@ -68,13 +69,26 @@ func (s *Skill) Validate() error {
68 return errors.Join(errs...)
69 return errors.Join(errs...)
69}
70}
70
71
71−// Parse parses a SKILL.md file.
72+// Parse parses a SKILL.md file from disk.
72func Parse(path string) (*Skill, error) {
73func Parse(path string) (*Skill, error) {
73 content, err := os.ReadFile(path)
74 content, err := os.ReadFile(path)
74 if err != nil {
75 if err != nil {
75 return nil, err
76 return nil, err
76 }
77 }
77
78
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) {
78 frontmatter, body, err := splitFrontmatter(string(content))
92 frontmatter, body, err := splitFrontmatter(string(content))
79 if err != nil {
93 if err != nil {
80 return nil, err
94 return nil, err
@@ -86,8 +100,6 @@ func Parse(path string) (*Skill, error) {
86 }
100 }
87
101
88 skill.Instructions = strings.TrimSpace(body)
102 skill.Instructions = strings.TrimSpace(body)
89− skill.Path = filepath.Dir(path)
90− skill.SkillFilePath = path
91
103
92 return &skill, nil
104 return &skill, nil
93}
105}
@@ -171,6 +183,9 @@ func ToPromptXML(skills []*Skill) string {
171 fmt.Fprintf(&sb, " <name>%s</name>\n", escape(s.Name))
183 fmt.Fprintf(&sb, " <name>%s</name>\n", escape(s.Name))
172 fmt.Fprintf(&sb, " <description>%s</description>\n", escape(s.Description))
184 fmt.Fprintf(&sb, " <description>%s</description>\n", escape(s.Description))
173 fmt.Fprintf(&sb, " <location>%s</location>\n", escape(s.SkillFilePath))
185 fmt.Fprintf(&sb, " <location>%s</location>\n", escape(s.SkillFilePath))
186+ if s.Builtin {
187+ sb.WriteString(" <type>builtin</type>\n")
188+ }
174 sb.WriteString(" </skill>\n")
189 sb.WriteString(" </skill>\n")
175 }
190 }
176 sb.WriteString("</available_skills>")
191 sb.WriteString("</available_skills>")
@@ -181,3 +196,41 @@ func escape(s string) string {
181 r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;", "'", "&apos;")
196 r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;", "'", "&apos;")
182 return r.Replace(s)
197 return r.Replace(s)
183}
198}
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) {
247 require.Empty(t, ToPromptXML(nil))
247 require.Empty(t, ToPromptXML(nil))
248 require.Empty(t, ToPromptXML([]*Skill{}))
248 require.Empty(t, ToPromptXML([]*Skill{}))
249}
249}
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+}