diff --git a/README.md b/README.md index f4159372732799f90d865ba27d11f00afd5385ed..3c1e78e60d4c4a614129560251274292202ce850 100644 --- a/README.md +++ b/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 diff --git a/internal/commands/commands.go b/internal/commands/commands.go index fe9d7e71606ebc15af1c5bdbddf85ab0b9a5c1fb..58a5c2e123a8ad655097c189213a4f5e8e847920 100644 --- a/internal/commands/commands.go +++ b/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 diff --git a/internal/skills/builtin/crush-config/SKILL.md b/internal/skills/builtin/crush-config/SKILL.md index df4f6444b4145bcbd1d1de9a2c078aedb5b105e9..dd69a3733e58fa5f41debcb6bd83b00ab9d203ad 100644 --- a/internal/skills/builtin/crush-config/SKILL.md +++ b/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. diff --git a/internal/skills/skills.go b/internal/skills/skills.go index 7071ba61820158a5324a48fbb5faf5cc89bd5f38..9b431214c809843c3356585f9f729a8d597590b5 100644 --- a/internal/skills/skills.go +++ b/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("\n") for _, s := range skills { + // Skip skills that have disable-model-invocation set + if s.DisableModelInvocation { + continue + } sb.WriteString(" \n") fmt.Fprintf(&sb, " %s\n", escape(s.Name)) fmt.Fprintf(&sb, " %s\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("\n") + fmt.Fprintf(&sb, " %s\n", escape(s.Name)) + fmt.Fprintf(&sb, " %s\n", escape(s.Description)) + fmt.Fprintf(&sb, " %s\n", escape(s.SkillFilePath)) + sb.WriteString(" \n") + sb.WriteString(escape(s.Instructions)) + sb.WriteString("\n \n") + sb.WriteString("") + return sb.String() +} + func escape(s string) string { return promptReplacer.Replace(s) } diff --git a/internal/skills/skills_test.go b/internal/skills/skills_test.go index f11a48c37f58fe727da97a90939efdedd0189250..fc78b8dc62a0b855cdc0f37861219128ff1dc9c8 100644 --- a/internal/skills/skills_test.go +++ b/internal/skills/skills_test.go @@ -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, "visible-skill") + require.NotContains(t, xml, "hidden-skill") +} + func TestToPromptXMLEmpty(t *testing.T) { t.Parallel() require.Empty(t, ToPromptXML(nil)) diff --git a/internal/ui/chat/user.go b/internal/ui/chat/user.go index 2aca4495b2dd15e115416a564daa072c4b1421a8..f355ad0fd545bafa10994f8aea421b185f5eceac 100644 --- a/internal/ui/chat/user.go +++ b/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, "") { + 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 diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index a2de6513c13a9d00febd8ca510472542e687ce4a..4ec9a59316a703ab49ba571cbf29446d6af3829d 100644 --- a/internal/ui/dialog/actions.go +++ b/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 { diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index a222124381e9058853c2170afb10016ce83a64ca..185185e5ea3ffc15744d75e605bf8b17b94a8081 100644 --- a/internal/ui/dialog/commands.go +++ b/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)) } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 6b60b503715307e23cb68282b8976a52136e27d7..df5cd26a7a91fdfb3fa674647aa434d59d20cb97 100644 --- a/internal/ui/model/ui.go +++ b/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: