on main help sign in
feat(skills): add support for user invocable skills
6716ef0Kieran Klukas
README.md+31
@@ -491,6 +491,37 @@ git clone https://github.com/anthropics/skills.git _temp
491mv _temp/skills/* . ; rm -r -force _temp
491mv _temp/skills/* . ; rm -r -force _temp
492```
492```
493
493
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+
494### Desktop notifications
525### Desktop notifications
495
526
496Crush sends desktop notifications when a tool call requires permission and when
527Crush sends desktop notifications when a tool call requires permission and when
internal/commands/commands.go+67
@@ -12,6 +12,7 @@ import (
12 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
12 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
13 "github.com/charmbracelet/crush/internal/config"
13 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/home"
14 "github.com/charmbracelet/crush/internal/home"
15+ "github.com/charmbracelet/crush/internal/skills"
15)
16)
16
17
17var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
18var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
@@ -45,6 +46,8 @@ type CustomCommand struct {
45 Name string
46 Name string
46 Content string
47 Content string
47 Arguments []Argument
48 Arguments []Argument
49+ // Skill is set when this command represents a user-invocable skill
50+ Skill *skills.Skill
48}
51}
49
52
50type commandSource struct {
53type commandSource struct {
@@ -58,6 +61,70 @@ func LoadCustomCommands(cfg *config.Config) ([]CustomCommand, error) {
58 return loadAll(buildCommandSources(cfg))
61 return loadAll(buildCommandSources(cfg))
59}
62}
60
63
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+
61// LoadMCPPrompts loads custom commands from available MCP servers.
128// LoadMCPPrompts loads custom commands from available MCP servers.
62func LoadMCPPrompts() ([]MCPPrompt, error) {
129func LoadMCPPrompts() ([]MCPPrompt, error) {
63 var commands []MCPPrompt
130 var commands []MCPPrompt
internal/skills/builtin/crush-config/SKILL.md+31
@@ -211,6 +211,37 @@ reviewed.
211
211
212Other options: `context_paths`, `progress`, `disable_notifications`, `disable_auto_summarize`, `disable_metrics`, `disable_provider_auto_update`, `disable_default_providers`, `data_directory`, `initialize_as`.
212Other options: `context_paths`, `progress`, `disable_notifications`, `disable_auto_summarize`, `disable_metrics`, `disable_provider_auto_update`, `disable_default_providers`, `data_directory`, `initialize_as`.
213
213
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+
214## Hooks
245## Hooks
215
246
216Hooks are user-defined shell commands that fire on agent events. Currently only `PreToolUse` is supported, which runs before a tool is executed.
247Hooks 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 (
36
36
37// Skill represents a parsed SKILL.md file.
37// Skill represents a parsed SKILL.md file.
38type Skill struct {
38type Skill struct {
39− Name string `yaml:"name" json:"name"`
39+ Name string `yaml:"name" json:"name"`
40− Description string `yaml:"description" json:"description"`
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"`
41− License string `yaml:"license,omitempty" json:"license,omitempty"`
43+ License string `yaml:"license,omitempty" json:"license,omitempty"`
42− Compatibility string `yaml:"compatibility,omitempty" json:"compatibility,omitempty"`
44+ Compatibility string `yaml:"compatibility,omitempty" json:"compatibility,omitempty"`
43− Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"`
45+ Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"`
44− Instructions string `yaml:"-" json:"instructions"`
46+ Instructions string `yaml:"-" json:"instructions"`
45− Path string `yaml:"-" json:"path"`
47+ Path string `yaml:"-" json:"path"`
46− SkillFilePath string `yaml:"-" json:"skill_file_path"`
48+ SkillFilePath string `yaml:"-" json:"skill_file_path"`
47− Builtin bool `yaml:"-" json:"builtin"`
49+ Builtin bool `yaml:"-" json:"builtin"`
48}
50}
49
51
50// DiscoveryState represents the outcome of discovering a single skill file.
52// DiscoveryState represents the outcome of discovering a single skill file.
@@ -293,6 +295,7 @@ func DiscoverWithStates(paths []string) ([]*Skill, []*SkillState) {
293}
295}
294
296
295// ToPromptXML generates XML for injection into the system prompt.
297// ToPromptXML generates XML for injection into the system prompt.
298+// Skills with DisableModelInvocation set to true are excluded.
296func ToPromptXML(skills []*Skill) string {
299func ToPromptXML(skills []*Skill) string {
297 if len(skills) == 0 {
300 if len(skills) == 0 {
298 return ""
301 return ""
@@ -301,6 +304,10 @@ func ToPromptXML(skills []*Skill) string {
301 var sb strings.Builder
304 var sb strings.Builder
302 sb.WriteString("<available_skills>\n")
305 sb.WriteString("<available_skills>\n")
303 for _, s := range skills {
306 for _, s := range skills {
307+ // Skip skills that have disable-model-invocation set
308+ if s.DisableModelInvocation {
309+ continue
310+ }
304 sb.WriteString(" <skill>\n")
311 sb.WriteString(" <skill>\n")
305 fmt.Fprintf(&sb, " <name>%s</name>\n", escape(s.Name))
312 fmt.Fprintf(&sb, " <name>%s</name>\n", escape(s.Name))
306 fmt.Fprintf(&sb, " <description>%s</description>\n", escape(s.Description))
313 fmt.Fprintf(&sb, " <description>%s</description>\n", escape(s.Description))
@@ -314,6 +321,20 @@ func ToPromptXML(skills []*Skill) string {
314 return sb.String()
321 return sb.String()
315}
322}
316
323
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+
317func escape(s string) string {
338func escape(s string) string {
318 return promptReplacer.Replace(s)
339 return promptReplacer.Replace(s)
319}
340}
internal/skills/skills_test.go+14
@@ -312,6 +312,20 @@ func TestToPromptXML(t *testing.T) {
312 require.Contains(t, xml, "&amp;") // XML escaping
312 require.Contains(t, xml, "&amp;") // XML escaping
313}
313}
314
314
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+
315func TestToPromptXMLEmpty(t *testing.T) {
329func TestToPromptXMLEmpty(t *testing.T) {
316 t.Parallel()
330 t.Parallel()
317 require.Empty(t, ToPromptXML(nil))
331 require.Empty(t, ToPromptXML(nil))
internal/ui/chat/user.go+35−4
@@ -1,6 +1,7 @@
1package chat
1package chat
2
2
3import (
3import (
4+ "encoding/xml"
4 "strings"
5 "strings"
5
6
6 tea "charm.land/bubbletea/v2"
7 tea "charm.land/bubbletea/v2"
@@ -12,6 +13,14 @@ import (
12 "github.com/charmbracelet/crush/internal/ui/styles"
13 "github.com/charmbracelet/crush/internal/ui/styles"
13)
14)
14
15
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+
15// UserMessageItem represents a user message in the chat UI.
24// UserMessageItem represents a user message in the chat UI.
16type UserMessageItem struct {
25type UserMessageItem struct {
17 *list.Versioned
26 *list.Versioned
@@ -54,13 +63,19 @@ func (m *UserMessageItem) RawRender(width int) string {
54 return m.renderHighlighted(content, cappedWidth, height)
63 return m.renderHighlighted(content, cappedWidth, height)
55 }
64 }
56
65
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+
57 renderer := common.MarkdownRenderer(m.sty, cappedWidth)
76 renderer := common.MarkdownRenderer(m.sty, cappedWidth)
58
77
59− msgContent := strings.TrimSpace(m.message.Content().Text)
60− mu := common.LockMarkdownRenderer(renderer)
61− mu.Lock()
62 result, err := renderer.Render(msgContent)
78 result, err := renderer.Render(msgContent)
63− mu.Unlock()
64 if err != nil {
79 if err != nil {
65 content = msgContent
80 content = msgContent
66 } else {
81 } else {
@@ -81,6 +96,22 @@ func (m *UserMessageItem) RawRender(width int) string {
81 return m.renderHighlighted(content, cappedWidth, height)
96 return m.renderHighlighted(content, cappedWidth, height)
82}
97}
83
98
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+
84// Render implements MessageItem.
115// Render implements MessageItem.
85func (m *UserMessageItem) Render(width int) string {
116func (m *UserMessageItem) Render(width int) string {
86 // Bypass the prefix cache while a highlight range is active so
117 // Bypass the prefix cache while a highlight range is active so
internal/ui/dialog/actions.go+2
@@ -14,6 +14,7 @@ import (
14 "github.com/charmbracelet/crush/internal/oauth"
14 "github.com/charmbracelet/crush/internal/oauth"
15 "github.com/charmbracelet/crush/internal/permission"
15 "github.com/charmbracelet/crush/internal/permission"
16 "github.com/charmbracelet/crush/internal/session"
16 "github.com/charmbracelet/crush/internal/session"
17+ "github.com/charmbracelet/crush/internal/skills"
17 "github.com/charmbracelet/crush/internal/ui/common"
18 "github.com/charmbracelet/crush/internal/ui/common"
18 "github.com/charmbracelet/crush/internal/ui/util"
19 "github.com/charmbracelet/crush/internal/ui/util"
19)
20)
@@ -71,6 +72,7 @@ type (
71 Content string
72 Content string
72 Arguments []commands.Argument
73 Arguments []commands.Argument
73 Args map[string]string // Actual argument values
74 Args map[string]string // Actual argument values
75+ Skill *skills.Skill // Set when this is a skill command
74 }
76 }
75 // ActionRunMCPPrompt is a message to run a custom command.
77 // ActionRunMCPPrompt is a message to run a custom command.
76 ActionRunMCPPrompt struct {
78 ActionRunMCPPrompt struct {
internal/ui/dialog/commands.go+1
@@ -393,6 +393,7 @@ func (c *Commands) setCommandItems(commandType CommandType) {
393 action := ActionRunCustomCommand{
393 action := ActionRunCustomCommand{
394 Content: cmd.Content,
394 Content: cmd.Content,
395 Arguments: cmd.Arguments,
395 Arguments: cmd.Arguments,
396+ Skill: cmd.Skill,
396 }
397 }
397 commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action))
398 commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action))
398 }
399 }
internal/ui/model/ui.go+8
@@ -468,6 +468,10 @@ func (m *UI) loadCustomCommands() tea.Cmd {
468 if err != nil {
468 if err != nil {
469 slog.Error("Failed to load custom commands", "error", err)
469 slog.Error("Failed to load custom commands", "error", err)
470 }
470 }
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...)
471 return userCommandsLoadedMsg{Commands: customCommands}
475 return userCommandsLoadedMsg{Commands: customCommands}
472 }
476 }
473}
477}
@@ -1541,6 +1545,10 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1541 if msg.Args != nil {
1545 if msg.Args != nil {
1542 content = substituteArgs(content, msg.Args)
1546 content = substituteArgs(content, msg.Args)
1543 }
1547 }
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+ }
1544 cmds = append(cmds, m.sendMessage(content))
1552 cmds = append(cmds, m.sendMessage(content))
1545 m.dialog.CloseFrontDialog()
1553 m.dialog.CloseFrontDialog()
1546 case dialog.ActionRunMCPPrompt:
1554 case dialog.ActionRunMCPPrompt: