feat(skills): add support for user invocable skills

Kieran Klukas created

Change summary

README.md                                     | 31 +++++++++
internal/commands/commands.go                 | 67 +++++++++++++++++++++
internal/skills/builtin/crush-config/SKILL.md | 31 +++++++++
internal/skills/skills.go                     | 39 +++++++++--
internal/skills/skills_test.go                | 14 ++++
internal/ui/chat/user.go                      | 39 ++++++++++-
internal/ui/dialog/actions.go                 |  2 
internal/ui/dialog/commands.go                |  1 
internal/ui/model/ui.go                       |  8 ++
9 files changed, 219 insertions(+), 13 deletions(-)

Detailed changes

README.md 🔗

@@ -491,6 +491,37 @@ git clone https://github.com/anthropics/skills.git _temp
 mv _temp/skills/* . ; rm -r -force _temp
 ```
 
+#### User-Invocable Skills
+
+Skills can be made invocable as commands from the commands palette (Ctrl+P). Add `user-invocable: true` to the skill's YAML frontmatter:
+
+```yaml
+---
+name: my-skill
+description: A skill that can be invoked as a command.
+user-invocable: true
+---
+```
+
+User-invocable skills appear in the commands palette with a `user:` or `project:` prefix:
+- Skills from global directories show as `user:skill-name`
+- Skills from project directories show as `project:skill-name`
+
+When invoked, the skill's instructions are loaded into the conversation context.
+
+To prevent the model from auto-triggering a skill (while still allowing user invocation), add `disable-model-invocation: true`:
+
+```yaml
+---
+name: my-skill
+description: Only invocable by users, not the model.
+user-invocable: true
+disable-model-invocation: true
+---
+```
+
+Skills with `disable-model-invocation` won't appear in the model's available skills list but can still be invoked manually by users.
+
 ### Desktop notifications
 
 Crush sends desktop notifications when a tool call requires permission and when

internal/commands/commands.go 🔗

@@ -12,6 +12,7 @@ import (
 	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/home"
+	"github.com/charmbracelet/crush/internal/skills"
 )
 
 var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
@@ -45,6 +46,8 @@ type CustomCommand struct {
 	Name      string
 	Content   string
 	Arguments []Argument
+	// Skill is set when this command represents a user-invocable skill
+	Skill *skills.Skill
 }
 
 type commandSource struct {
@@ -58,6 +61,70 @@ func LoadCustomCommands(cfg *config.Config) ([]CustomCommand, error) {
 	return loadAll(buildCommandSources(cfg))
 }
 
+// LoadSkillCommands loads user-invocable skills as custom commands.
+func LoadSkillCommands() []CustomCommand {
+	var commands []CustomCommand
+
+	// Load from global skills directories with "user:" prefix
+	for _, dir := range config.GlobalSkillsDirs() {
+		commands = append(commands, loadInvocableSkillsFromDir(dir, userCommandPrefix)...)
+	}
+
+	return commands
+}
+
+// LoadProjectSkillCommands loads user-invocable skills from project directories as custom commands.
+func LoadProjectSkillCommands(workingDir string) []CustomCommand {
+	var commands []CustomCommand
+
+	// Load from project skills directories with "project:" prefix
+	for _, dir := range config.ProjectSkillsDir(workingDir) {
+		commands = append(commands, loadInvocableSkillsFromDir(dir, projectCommandPrefix)...)
+	}
+
+	return commands
+}
+
+func loadInvocableSkillsFromDir(dir, prefix string) []CustomCommand {
+	if _, err := os.Stat(dir); os.IsNotExist(err) {
+		return nil
+	}
+
+	var commands []CustomCommand
+
+	entries, err := os.ReadDir(dir)
+	if err != nil {
+		return nil
+	}
+
+	for _, entry := range entries {
+		if !entry.IsDir() {
+			continue
+		}
+
+		skillPath := filepath.Join(dir, entry.Name(), skills.SkillFileName)
+		skill, err := skills.Parse(skillPath)
+		if err != nil {
+			continue
+		}
+
+		if !skill.UserInvocable {
+			continue
+		}
+
+		name := prefix + skill.Name
+		commands = append(commands, CustomCommand{
+			ID:        name,
+			Name:      name,
+			Content:   skill.Instructions,
+			Arguments: nil,
+			Skill:     skill,
+		})
+	}
+
+	return commands
+}
+
 // LoadMCPPrompts loads custom commands from available MCP servers.
 func LoadMCPPrompts() ([]MCPPrompt, error) {
 	var commands []MCPPrompt

internal/skills/builtin/crush-config/SKILL.md 🔗

@@ -211,6 +211,37 @@ reviewed.
 
 Other options: `context_paths`, `progress`, `disable_notifications`, `disable_auto_summarize`, `disable_metrics`, `disable_provider_auto_update`, `disable_default_providers`, `data_directory`, `initialize_as`.
 
+## User-Invocable Skills
+
+Skills can be made invocable as commands from the commands palette. Add `user-invocable: true` to the skill's YAML frontmatter:
+
+```yaml
+---
+name: my-skill
+description: A skill that can be invoked as a command.
+user-invocable: true
+---
+```
+
+User-invocable skills appear in the commands palette with a prefix:
+- Skills from global directories: `user:skill-name`
+- Skills from project directories: `project:skill-name`
+
+When invoked, the skill's instructions are loaded into the conversation context.
+
+To prevent the model from auto-triggering a skill (while still allowing user invocation), add `disable-model-invocation: true`:
+
+```yaml
+---
+name: my-skill
+description: Only invocable by users, not the model.
+user-invocable: true
+disable-model-invocation: true
+---
+```
+
+Skills with `disable-model-invocation` won't appear in the model's available skills list but can still be invoked manually by users.
+
 ## Hooks
 
 Hooks 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 🔗

@@ -36,15 +36,17 @@ var (
 
 // 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"`
-	Builtin       bool              `yaml:"-" json:"builtin"`
+	Name                   string            `yaml:"name" json:"name"`
+	Description            string            `yaml:"description" json:"description"`
+	UserInvocable          bool              `yaml:"user-invocable" json:"user_invocable"`
+	DisableModelInvocation bool              `yaml:"disable-model-invocation" json:"disable_model_invocation"`
+	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"`
+	Builtin                bool              `yaml:"-" json:"builtin"`
 }
 
 // DiscoveryState represents the outcome of discovering a single skill file.
@@ -293,6 +295,7 @@ func DiscoverWithStates(paths []string) ([]*Skill, []*SkillState) {
 }
 
 // ToPromptXML generates XML for injection into the system prompt.
+// Skills with DisableModelInvocation set to true are excluded.
 func ToPromptXML(skills []*Skill) string {
 	if len(skills) == 0 {
 		return ""
@@ -301,6 +304,10 @@ func ToPromptXML(skills []*Skill) string {
 	var sb strings.Builder
 	sb.WriteString("<available_skills>\n")
 	for _, s := range skills {
+		// Skip skills that have disable-model-invocation set
+		if s.DisableModelInvocation {
+			continue
+		}
 		sb.WriteString("  <skill>\n")
 		fmt.Fprintf(&sb, "    <name>%s</name>\n", escape(s.Name))
 		fmt.Fprintf(&sb, "    <description>%s</description>\n", escape(s.Description))
@@ -314,6 +321,20 @@ func ToPromptXML(skills []*Skill) string {
 	return sb.String()
 }
 
+// FormatInvocation generates XML for a skill when invoked as a user command.
+func (s *Skill) FormatInvocation() string {
+	var sb strings.Builder
+	sb.WriteString("<loaded_skill>\n")
+	fmt.Fprintf(&sb, "  <name>%s</name>\n", escape(s.Name))
+	fmt.Fprintf(&sb, "  <description>%s</description>\n", escape(s.Description))
+	fmt.Fprintf(&sb, "  <location>%s</location>\n", escape(s.SkillFilePath))
+	sb.WriteString("  <instructions>\n")
+	sb.WriteString(escape(s.Instructions))
+	sb.WriteString("\n  </instructions>\n")
+	sb.WriteString("</loaded_skill>")
+	return sb.String()
+}
+
 func escape(s string) string {
 	return promptReplacer.Replace(s)
 }

internal/skills/skills_test.go 🔗

@@ -312,6 +312,20 @@ func TestToPromptXML(t *testing.T) {
 	require.Contains(t, xml, "&amp;") // XML escaping
 }
 
+func TestToPromptXMLDisableModelInvocation(t *testing.T) {
+	t.Parallel()
+
+	skills := []*Skill{
+		{Name: "visible-skill", Description: "This one appears.", SkillFilePath: "/skills/visible/SKILL.md"},
+		{Name: "hidden-skill", Description: "This one is hidden.", SkillFilePath: "/skills/hidden/SKILL.md", DisableModelInvocation: true},
+	}
+
+	xml := ToPromptXML(skills)
+
+	require.Contains(t, xml, "<name>visible-skill</name>")
+	require.NotContains(t, xml, "<name>hidden-skill</name>")
+}
+
 func TestToPromptXMLEmpty(t *testing.T) {
 	t.Parallel()
 	require.Empty(t, ToPromptXML(nil))

internal/ui/chat/user.go 🔗

@@ -1,6 +1,7 @@
 package chat
 
 import (
+	"encoding/xml"
 	"strings"
 
 	tea "charm.land/bubbletea/v2"
@@ -12,6 +13,14 @@ import (
 	"github.com/charmbracelet/crush/internal/ui/styles"
 )
 
+// skillInvocation represents the XML structure for a loaded skill.
+type skillInvocation struct {
+	Name         string `xml:"name"`
+	Description  string `xml:"description"`
+	Location     string `xml:"location"`
+	Instructions string `xml:"instructions"`
+}
+
 // UserMessageItem represents a user message in the chat UI.
 type UserMessageItem struct {
 	*list.Versioned
@@ -54,13 +63,19 @@ func (m *UserMessageItem) RawRender(width int) string {
 		return m.renderHighlighted(content, cappedWidth, height)
 	}
 
+	msgContent := strings.TrimSpace(m.message.Content().Text)
+
+	// Check if this is a skill invocation (loaded_skill XML)
+	if strings.HasPrefix(msgContent, "<loaded_skill>") {
+		content = m.renderSkillInvocation(msgContent, cappedWidth)
+		height = lipgloss.Height(content)
+		m.setCachedRender(content, cappedWidth, height)
+		return m.renderHighlighted(content, cappedWidth, height)
+	}
+
 	renderer := common.MarkdownRenderer(m.sty, cappedWidth)
 
-	msgContent := strings.TrimSpace(m.message.Content().Text)
-	mu := common.LockMarkdownRenderer(renderer)
-	mu.Lock()
 	result, err := renderer.Render(msgContent)
-	mu.Unlock()
 	if err != nil {
 		content = msgContent
 	} else {
@@ -81,6 +96,22 @@ func (m *UserMessageItem) RawRender(width int) string {
 	return m.renderHighlighted(content, cappedWidth, height)
 }
 
+// renderSkillInvocation renders a loaded_skill XML as a special UI element.
+func (m *UserMessageItem) renderSkillInvocation(content string, width int) string {
+	var skill skillInvocation
+	if err := xml.Unmarshal([]byte(content), &skill); err != nil {
+		// If parsing fails, just render as markdown
+		renderer := common.MarkdownRenderer(m.sty, width)
+		result, err := renderer.Render(content)
+		if err != nil {
+			return content
+		}
+		return strings.TrimSuffix(result, "\n")
+	}
+
+	return toolOutputSkillContent(m.sty, skill.Name, skill.Description)
+}
+
 // Render implements MessageItem.
 func (m *UserMessageItem) Render(width int) string {
 	// Bypass the prefix cache while a highlight range is active so

internal/ui/dialog/actions.go 🔗

@@ -14,6 +14,7 @@ import (
 	"github.com/charmbracelet/crush/internal/oauth"
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/skills"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/util"
 )
@@ -71,6 +72,7 @@ type (
 		Content   string
 		Arguments []commands.Argument
 		Args      map[string]string // Actual argument values
+		Skill     *skills.Skill     // Set when this is a skill command
 	}
 	// ActionRunMCPPrompt is a message to run a custom command.
 	ActionRunMCPPrompt struct {

internal/ui/dialog/commands.go 🔗

@@ -393,6 +393,7 @@ func (c *Commands) setCommandItems(commandType CommandType) {
 			action := ActionRunCustomCommand{
 				Content:   cmd.Content,
 				Arguments: cmd.Arguments,
+				Skill:     cmd.Skill,
 			}
 			commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action))
 		}

internal/ui/model/ui.go 🔗

@@ -468,6 +468,10 @@ func (m *UI) loadCustomCommands() tea.Cmd {
 		if err != nil {
 			slog.Error("Failed to load custom commands", "error", err)
 		}
+		// Append user-invocable skills as commands
+		skillCommands := commands.LoadSkillCommands()
+		skillCommands = append(skillCommands, commands.LoadProjectSkillCommands(m.com.Workspace.WorkingDir())...)
+		customCommands = append(customCommands, skillCommands...)
 		return userCommandsLoadedMsg{Commands: customCommands}
 	}
 }
@@ -1541,6 +1545,10 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 		if msg.Args != nil {
 			content = substituteArgs(content, msg.Args)
 		}
+		// If this is a skill command, format it using the skill's FormatInvocation method
+		if msg.Skill != nil {
+			content = msg.Skill.FormatInvocation()
+		}
 		cmds = append(cmds, m.sendMessage(content))
 		m.dialog.CloseFrontDialog()
 	case dialog.ActionRunMCPPrompt: