Skip to content

Commit 2b19292

Browse files
feat(skills): add support for user invocable skills
1 parent 95e93e9 commit 2b19292

6 files changed

Lines changed: 128 additions & 1 deletion

File tree

internal/commands/commands.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

1718
var 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

5053
type 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.
62129
func LoadMCPPrompts() ([]MCPPrompt, error) {
63130
var commands []MCPPrompt

internal/skills/skills.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ var (
3636
type Skill struct {
3737
Name string `yaml:"name" json:"name"`
3838
Description string `yaml:"description" json:"description"`
39+
UserInvocable bool `yaml:"user-invocable" json:"user_invocable"`
3940
License string `yaml:"license,omitempty" json:"license,omitempty"`
4041
Compatibility string `yaml:"compatibility,omitempty" json:"compatibility,omitempty"`
4142
Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"`
@@ -279,6 +280,20 @@ func ToPromptXML(skills []*Skill) string {
279280
return sb.String()
280281
}
281282

283+
// FormatInvocation generates XML for a skill when invoked as a user command.
284+
func (s *Skill) FormatInvocation() string {
285+
var sb strings.Builder
286+
sb.WriteString("<loaded_skill>\n")
287+
fmt.Fprintf(&sb, " <name>%s</name>\n", escape(s.Name))
288+
fmt.Fprintf(&sb, " <description>%s</description>\n", escape(s.Description))
289+
fmt.Fprintf(&sb, " <location>%s</location>\n", escape(s.SkillFilePath))
290+
sb.WriteString(" <instructions>\n")
291+
sb.WriteString(escape(s.Instructions))
292+
sb.WriteString("\n </instructions>\n")
293+
sb.WriteString("</loaded_skill>")
294+
return sb.String()
295+
}
296+
282297
func escape(s string) string {
283298
return promptReplacer.Replace(s)
284299
}

internal/ui/chat/user.go

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package chat
22

33
import (
4+
"encoding/xml"
45
"strings"
56

67
tea "charm.land/bubbletea/v2"
@@ -11,6 +12,14 @@ import (
1112
"github.com/charmbracelet/crush/internal/ui/styles"
1213
)
1314

15+
// skillInvocation represents the XML structure for a loaded skill.
16+
type skillInvocation struct {
17+
Name string `xml:"name"`
18+
Description string `xml:"description"`
19+
Location string `xml:"location"`
20+
Instructions string `xml:"instructions"`
21+
}
22+
1423
// UserMessageItem represents a user message in the chat UI.
1524
type UserMessageItem struct {
1625
*highlightableMessageItem
@@ -44,9 +53,18 @@ func (m *UserMessageItem) RawRender(width int) string {
4453
return m.renderHighlighted(content, cappedWidth, height)
4554
}
4655

56+
msgContent := strings.TrimSpace(m.message.Content().Text)
57+
58+
// Check if this is a skill invocation (loaded_skill XML)
59+
if strings.HasPrefix(msgContent, "<loaded_skill>") {
60+
content = m.renderSkillInvocation(msgContent, cappedWidth)
61+
height = lipgloss.Height(content)
62+
m.setCachedRender(content, cappedWidth, height)
63+
return m.renderHighlighted(content, cappedWidth, height)
64+
}
65+
4766
renderer := common.MarkdownRenderer(m.sty, cappedWidth)
4867

49-
msgContent := strings.TrimSpace(m.message.Content().Text)
5068
result, err := renderer.Render(msgContent)
5169
if err != nil {
5270
content = msgContent
@@ -68,6 +86,22 @@ func (m *UserMessageItem) RawRender(width int) string {
6886
return m.renderHighlighted(content, cappedWidth, height)
6987
}
7088

89+
// renderSkillInvocation renders a loaded_skill XML as a special UI element.
90+
func (m *UserMessageItem) renderSkillInvocation(content string, width int) string {
91+
var skill skillInvocation
92+
if err := xml.Unmarshal([]byte(content), &skill); err != nil {
93+
// If parsing fails, just render as markdown
94+
renderer := common.MarkdownRenderer(m.sty, width)
95+
result, err := renderer.Render(content)
96+
if err != nil {
97+
return content
98+
}
99+
return strings.TrimSuffix(result, "\n")
100+
}
101+
102+
return toolOutputSkillContent(m.sty, skill.Name, skill.Description)
103+
}
104+
71105
// Render implements MessageItem.
72106
func (m *UserMessageItem) Render(width int) string {
73107
var prefix string

internal/ui/dialog/actions.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,10 @@ func (m *UI) loadCustomCommands() tea.Cmd {
467467
if err != nil {
468468
slog.Error("Failed to load custom commands", "error", err)
469469
}
470+
// Append user-invocable skills as commands
471+
skillCommands := commands.LoadSkillCommands()
472+
skillCommands = append(skillCommands, commands.LoadProjectSkillCommands(m.com.Workspace.WorkingDir())...)
473+
customCommands = append(customCommands, skillCommands...)
470474
return userCommandsLoadedMsg{Commands: customCommands}
471475
}
472476
}
@@ -1540,6 +1544,10 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
15401544
if msg.Args != nil {
15411545
content = substituteArgs(content, msg.Args)
15421546
}
1547+
// If this is a skill command, format it using the skill's FormatInvocation method
1548+
if msg.Skill != nil {
1549+
content = msg.Skill.FormatInvocation()
1550+
}
15431551
cmds = append(cmds, m.sendMessage(content))
15441552
m.dialog.CloseFrontDialog()
15451553
case dialog.ActionRunMCPPrompt:

0 commit comments

Comments
 (0)