Detailed changes
@@ -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
@@ -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
@@ -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.
@@ -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)
}
@@ -312,6 +312,20 @@ func TestToPromptXML(t *testing.T) {
require.Contains(t, xml, "&") // 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))
@@ -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
@@ -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 {
@@ -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))
}
@@ -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: