on main help sign in
feat(skills): add support for user invocable skills
commit6716ef09fc0612e3f707469395546362922e0dc4 authorKieran Klukas <kieran@dunkirk.sh> dateThu 7 May 2026, 20:53 (5 months ago) parentd025403
9 files changed+219−13
README.md+31 internal/commands/commands.go+67 internal/skills/builtin/crush-config/SKILL.md+31 internal/skills/skills.go+30−9 internal/skills/skills_test.go+14 internal/ui/chat/user.go+35−4 internal/ui/dialog/actions.go+2 internal/ui/dialog/commands.go+1 internal/ui/model/ui.go+8
README.md+31
@@ -491,6 +491,37 @@ git clone https://github.com/anthropics/skills.git _temp
491491mv _temp/skills/* . ; rm -r -force _temp
492492```
493493
494+#### User-Invocable Skills
495+
496+Skills can be made invocable as commands from the commands palette (Ctrl+P). Add `user-invocable: true` to the skill's YAML frontmatter:
497+
498+```yaml
499+---
500+name: my-skill
501+description: A skill that can be invoked as a command.
502+user-invocable: true
503+---
504+```
505+
506+User-invocable skills appear in the commands palette with a `user:` or `project:` prefix:
507+- Skills from global directories show as `user:skill-name`
508+- Skills from project directories show as `project:skill-name`
509+
510+When invoked, the skill's instructions are loaded into the conversation context.
511+
512+To prevent the model from auto-triggering a skill (while still allowing user invocation), add `disable-model-invocation: true`:
513+
514+```yaml
515+---
516+name: my-skill
517+description: Only invocable by users, not the model.
518+user-invocable: true
519+disable-model-invocation: true
520+---
521+```
522+
523+Skills with `disable-model-invocation` won't appear in the model's available skills list but can still be invoked manually by users.
524+
494525### Desktop notifications
495526
496527Crush sends desktop notifications when a tool call requires permission and when
internal/commands/commands.go+67
@@ -12,6 +12,7 @@ import (
1212 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
1313 "github.com/charmbracelet/crush/internal/config"
1414 "github.com/charmbracelet/crush/internal/home"
15+ "github.com/charmbracelet/crush/internal/skills"
1516)
1617
1718var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
@@ -45,6 +46,8 @@ type CustomCommand struct {
4546 Name string
4647 Content string
4748 Arguments []Argument
49+ // Skill is set when this command represents a user-invocable skill
50+ Skill *skills.Skill
4851}
4952
5053type commandSource struct {
@@ -58,6 +61,70 @@ func LoadCustomCommands(cfg *config.Config) ([]CustomCommand, error) {
5861 return loadAll(buildCommandSources(cfg))
5962}
6063
64+// LoadSkillCommands loads user-invocable skills as custom commands.
65+func LoadSkillCommands() []CustomCommand {
66+ var commands []CustomCommand
67+
68+ // Load from global skills directories with "user:" prefix
69+ for _, dir := range config.GlobalSkillsDirs() {
70+ commands = append(commands, loadInvocableSkillsFromDir(dir, userCommandPrefix)...)
71+ }
72+
73+ return commands
74+}
75+
76+// LoadProjectSkillCommands loads user-invocable skills from project directories as custom commands.
77+func LoadProjectSkillCommands(workingDir string) []CustomCommand {
78+ var commands []CustomCommand
79+
80+ // Load from project skills directories with "project:" prefix
81+ for _, dir := range config.ProjectSkillsDir(workingDir) {
82+ commands = append(commands, loadInvocableSkillsFromDir(dir, projectCommandPrefix)...)
83+ }
84+
85+ return commands
86+}
87+
88+func loadInvocableSkillsFromDir(dir, prefix string) []CustomCommand {
89+ if _, err := os.Stat(dir); os.IsNotExist(err) {
90+ return nil
91+ }
92+
93+ var commands []CustomCommand
94+
95+ entries, err := os.ReadDir(dir)
96+ if err != nil {
97+ return nil
98+ }
99+
100+ for _, entry := range entries {
101+ if !entry.IsDir() {
102+ continue
103+ }
104+
105+ skillPath := filepath.Join(dir, entry.Name(), skills.SkillFileName)
106+ skill, err := skills.Parse(skillPath)
107+ if err != nil {
108+ continue
109+ }
110+
111+ if !skill.UserInvocable {
112+ continue
113+ }
114+
115+ name := prefix + skill.Name
116+ commands = append(commands, CustomCommand{
117+ ID: name,
118+ Name: name,
119+ Content: skill.Instructions,
120+ Arguments: nil,
121+ Skill: skill,
122+ })
123+ }
124+
125+ return commands
126+}
127+
61128// LoadMCPPrompts loads custom commands from available MCP servers.
62129func LoadMCPPrompts() ([]MCPPrompt, error) {
63130 var commands []MCPPrompt
internal/skills/builtin/crush-config/SKILL.md+31
@@ -211,6 +211,37 @@ reviewed.
211211
212212Other options: `context_paths`, `progress`, `disable_notifications`, `disable_auto_summarize`, `disable_metrics`, `disable_provider_auto_update`, `disable_default_providers`, `data_directory`, `initialize_as`.
213213
214+## User-Invocable Skills
215+
216+Skills can be made invocable as commands from the commands palette. Add `user-invocable: true` to the skill's YAML frontmatter:
217+
218+```yaml
219+---
220+name: my-skill
221+description: A skill that can be invoked as a command.
222+user-invocable: true
223+---
224+```
225+
226+User-invocable skills appear in the commands palette with a prefix:
227+- Skills from global directories: `user:skill-name`
228+- Skills from project directories: `project:skill-name`
229+
230+When invoked, the skill's instructions are loaded into the conversation context.
231+
232+To prevent the model from auto-triggering a skill (while still allowing user invocation), add `disable-model-invocation: true`:
233+
234+```yaml
235+---
236+name: my-skill
237+description: Only invocable by users, not the model.
238+user-invocable: true
239+disable-model-invocation: true
240+---
241+```
242+
243+Skills with `disable-model-invocation` won't appear in the model's available skills list but can still be invoked manually by users.
244+
214245## Hooks
215246
216247Hooks are user-defined shell commands that fire on agent events. Currently only `PreToolUse` is supported, which runs before a tool is executed.
internal/skills/skills.go+30−9
@@ -36,15 +36,17 @@ var (
3636
3737// Skill represents a parsed SKILL.md file.
3838type Skill struct {
39− Name string `yaml:"name" json:"name"`
40− Description string `yaml:"description" json:"description"`
41− License string `yaml:"license,omitempty" json:"license,omitempty"`
42− Compatibility string `yaml:"compatibility,omitempty" json:"compatibility,omitempty"`
43− Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"`
44− Instructions string `yaml:"-" json:"instructions"`
45− Path string `yaml:"-" json:"path"`
46− SkillFilePath string `yaml:"-" json:"skill_file_path"`
47− Builtin bool `yaml:"-" json:"builtin"`
39+ Name string `yaml:"name" json:"name"`
40+ Description string `yaml:"description" json:"description"`
41+ UserInvocable bool `yaml:"user-invocable" json:"user_invocable"`
42+ DisableModelInvocation bool `yaml:"disable-model-invocation" json:"disable_model_invocation"`
43+ License string `yaml:"license,omitempty" json:"license,omitempty"`
44+ Compatibility string `yaml:"compatibility,omitempty" json:"compatibility,omitempty"`
45+ Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"`
46+ Instructions string `yaml:"-" json:"instructions"`
47+ Path string `yaml:"-" json:"path"`
48+ SkillFilePath string `yaml:"-" json:"skill_file_path"`
49+ Builtin bool `yaml:"-" json:"builtin"`
4850}
4951
5052// DiscoveryState represents the outcome of discovering a single skill file.
@@ -293,6 +295,7 @@ func DiscoverWithStates(paths []string) ([]*Skill, []*SkillState) {
293295}
294296
295297// ToPromptXML generates XML for injection into the system prompt.
298+// Skills with DisableModelInvocation set to true are excluded.
296299func ToPromptXML(skills []*Skill) string {
297300 if len(skills) == 0 {
298301 return ""
@@ -301,6 +304,10 @@ func ToPromptXML(skills []*Skill) string {
301304 var sb strings.Builder
302305 sb.WriteString("<available_skills>\n")
303306 for _, s := range skills {
307+ // Skip skills that have disable-model-invocation set
308+ if s.DisableModelInvocation {
309+ continue
310+ }
304311 sb.WriteString(" <skill>\n")
305312 fmt.Fprintf(&sb, " <name>%s</name>\n", escape(s.Name))
306313 fmt.Fprintf(&sb, " <description>%s</description>\n", escape(s.Description))
@@ -314,6 +321,20 @@ func ToPromptXML(skills []*Skill) string {
314321 return sb.String()
315322}
316323
324+// FormatInvocation generates XML for a skill when invoked as a user command.
325+func (s *Skill) FormatInvocation() string {
326+ var sb strings.Builder
327+ sb.WriteString("<loaded_skill>\n")
328+ fmt.Fprintf(&sb, " <name>%s</name>\n", escape(s.Name))
329+ fmt.Fprintf(&sb, " <description>%s</description>\n", escape(s.Description))
330+ fmt.Fprintf(&sb, " <location>%s</location>\n", escape(s.SkillFilePath))
331+ sb.WriteString(" <instructions>\n")
332+ sb.WriteString(escape(s.Instructions))
333+ sb.WriteString("\n </instructions>\n")
334+ sb.WriteString("</loaded_skill>")
335+ return sb.String()
336+}
337+
317338func escape(s string) string {
318339 return promptReplacer.Replace(s)
319340}
internal/skills/skills_test.go+14
@@ -312,6 +312,20 @@ func TestToPromptXML(t *testing.T) {
312312 require.Contains(t, xml, "&amp;") // XML escaping
313313}
314314
315+func TestToPromptXMLDisableModelInvocation(t *testing.T) {
316+ t.Parallel()
317+
318+ skills := []*Skill{
319+ {Name: "visible-skill", Description: "This one appears.", SkillFilePath: "/skills/visible/SKILL.md"},
320+ {Name: "hidden-skill", Description: "This one is hidden.", SkillFilePath: "/skills/hidden/SKILL.md", DisableModelInvocation: true},
321+ }
322+
323+ xml := ToPromptXML(skills)
324+
325+ require.Contains(t, xml, "<name>visible-skill</name>")
326+ require.NotContains(t, xml, "<name>hidden-skill</name>")
327+}
328+
315329func TestToPromptXMLEmpty(t *testing.T) {
316330 t.Parallel()
317331 require.Empty(t, ToPromptXML(nil))
internal/ui/chat/user.go+35−4
@@ -1,6 +1,7 @@
11package chat
22
33import (
4+ "encoding/xml"
45 "strings"
56
67 tea "charm.land/bubbletea/v2"
@@ -12,6 +13,14 @@ import (
1213 "github.com/charmbracelet/crush/internal/ui/styles"
1314)
1415
16+// skillInvocation represents the XML structure for a loaded skill.
17+type skillInvocation struct {
18+ Name string `xml:"name"`
19+ Description string `xml:"description"`
20+ Location string `xml:"location"`
21+ Instructions string `xml:"instructions"`
22+}
23+
1524// UserMessageItem represents a user message in the chat UI.
1625type UserMessageItem struct {
1726 *list.Versioned
@@ -54,13 +63,19 @@ func (m *UserMessageItem) RawRender(width int) string {
5463 return m.renderHighlighted(content, cappedWidth, height)
5564 }
5665
66+ msgContent := strings.TrimSpace(m.message.Content().Text)
67+
68+ // Check if this is a skill invocation (loaded_skill XML)
69+ if strings.HasPrefix(msgContent, "<loaded_skill>") {
70+ content = m.renderSkillInvocation(msgContent, cappedWidth)
71+ height = lipgloss.Height(content)
72+ m.setCachedRender(content, cappedWidth, height)
73+ return m.renderHighlighted(content, cappedWidth, height)
74+ }
75+
5776 renderer := common.MarkdownRenderer(m.sty, cappedWidth)
5877
59− msgContent := strings.TrimSpace(m.message.Content().Text)
60− mu := common.LockMarkdownRenderer(renderer)
61− mu.Lock()
6278 result, err := renderer.Render(msgContent)
63− mu.Unlock()
6479 if err != nil {
6580 content = msgContent
6681 } else {
@@ -81,6 +96,22 @@ func (m *UserMessageItem) RawRender(width int) string {
8196 return m.renderHighlighted(content, cappedWidth, height)
8297}
8398
99+// renderSkillInvocation renders a loaded_skill XML as a special UI element.
100+func (m *UserMessageItem) renderSkillInvocation(content string, width int) string {
101+ var skill skillInvocation
102+ if err := xml.Unmarshal([]byte(content), &skill); err != nil {
103+ // If parsing fails, just render as markdown
104+ renderer := common.MarkdownRenderer(m.sty, width)
105+ result, err := renderer.Render(content)
106+ if err != nil {
107+ return content
108+ }
109+ return strings.TrimSuffix(result, "\n")
110+ }
111+
112+ return toolOutputSkillContent(m.sty, skill.Name, skill.Description)
113+}
114+
84115// Render implements MessageItem.
85116func (m *UserMessageItem) Render(width int) string {
86117 // Bypass the prefix cache while a highlight range is active so
internal/ui/dialog/actions.go+2
@@ -14,6 +14,7 @@ import (
1414 "github.com/charmbracelet/crush/internal/oauth"
1515 "github.com/charmbracelet/crush/internal/permission"
1616 "github.com/charmbracelet/crush/internal/session"
17+ "github.com/charmbracelet/crush/internal/skills"
1718 "github.com/charmbracelet/crush/internal/ui/common"
1819 "github.com/charmbracelet/crush/internal/ui/util"
1920)
@@ -71,6 +72,7 @@ type (
7172 Content string
7273 Arguments []commands.Argument
7374 Args map[string]string // Actual argument values
75+ Skill *skills.Skill // Set when this is a skill command
7476 }
7577 // ActionRunMCPPrompt is a message to run a custom command.
7678 ActionRunMCPPrompt struct {
internal/ui/dialog/commands.go+1
@@ -393,6 +393,7 @@ func (c *Commands) setCommandItems(commandType CommandType) {
393393 action := ActionRunCustomCommand{
394394 Content: cmd.Content,
395395 Arguments: cmd.Arguments,
396+ Skill: cmd.Skill,
396397 }
397398 commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action))
398399 }
internal/ui/model/ui.go+8
@@ -468,6 +468,10 @@ func (m *UI) loadCustomCommands() tea.Cmd {
468468 if err != nil {
469469 slog.Error("Failed to load custom commands", "error", err)
470470 }
471+ // Append user-invocable skills as commands
472+ skillCommands := commands.LoadSkillCommands()
473+ skillCommands = append(skillCommands, commands.LoadProjectSkillCommands(m.com.Workspace.WorkingDir())...)
474+ customCommands = append(customCommands, skillCommands...)
471475 return userCommandsLoadedMsg{Commands: customCommands}
472476 }
473477}
@@ -1541,6 +1545,10 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
15411545 if msg.Args != nil {
15421546 content = substituteArgs(content, msg.Args)
15431547 }
1548+ // If this is a skill command, format it using the skill's FormatInvocation method
1549+ if msg.Skill != nil {
1550+ content = msg.Skill.FormatInvocation()
1551+ }
15441552 cmds = append(cmds, m.sendMessage(content))
15451553 m.dialog.CloseFrontDialog()
15461554 case dialog.ActionRunMCPPrompt: