1// Package uicmd provides functionality to load and handle custom commands
2// from markdown files and MCP prompts.
3// TODO: Move this into internal/ui after refactoring.
4// TODO: DELETE when we delete the old tui
5package uicmd
6
7import (
8 "cmp"
9 "context"
10 "fmt"
11 "io/fs"
12 "os"
13 "path/filepath"
14 "regexp"
15 "strings"
16
17 tea "charm.land/bubbletea/v2"
18 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
19 "github.com/charmbracelet/crush/internal/config"
20 "github.com/charmbracelet/crush/internal/home"
21 "github.com/charmbracelet/crush/internal/tui/components/chat"
22 "github.com/charmbracelet/crush/internal/tui/util"
23)
24
25type CommandType uint
26
27func (c CommandType) String() string { return []string{"System", "User", "MCP"}[c] }
28
29const (
30 SystemCommands CommandType = iota
31 UserCommands
32 MCPPrompts
33)
34
35// Command represents a command that can be executed
36type Command struct {
37 ID string
38 Title string
39 Description string
40 Shortcut string // Optional shortcut for the command
41 Handler func(cmd Command) tea.Cmd
42}
43
44// ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog.
45type ShowArgumentsDialogMsg struct {
46 CommandID string
47 Description string
48 ArgNames []string
49 OnSubmit func(args map[string]string) tea.Cmd
50}
51
52// CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed.
53type CloseArgumentsDialogMsg struct {
54 Submit bool
55 CommandID string
56 Content string
57 Args map[string]string
58}
59
60const (
61 userCommandPrefix = "user:"
62 projectCommandPrefix = "project:"
63)
64
65var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
66
67type commandLoader struct {
68 sources []commandSource
69}
70
71type commandSource struct {
72 path string
73 prefix string
74}
75
76func LoadCustomCommands() ([]Command, error) {
77 return LoadCustomCommandsFromConfig(config.Get())
78}
79
80func LoadCustomCommandsFromConfig(cfg *config.Config) ([]Command, error) {
81 if cfg == nil {
82 return nil, fmt.Errorf("config not loaded")
83 }
84
85 loader := &commandLoader{
86 sources: buildCommandSources(cfg),
87 }
88
89 return loader.loadAll()
90}
91
92func buildCommandSources(cfg *config.Config) []commandSource {
93 var sources []commandSource
94
95 // XDG config directory
96 if dir := getXDGCommandsDir(); dir != "" {
97 sources = append(sources, commandSource{
98 path: dir,
99 prefix: userCommandPrefix,
100 })
101 }
102
103 // Home directory
104 if home := home.Dir(); home != "" {
105 sources = append(sources, commandSource{
106 path: filepath.Join(home, ".crush", "commands"),
107 prefix: userCommandPrefix,
108 })
109 }
110
111 // Project directory
112 sources = append(sources, commandSource{
113 path: filepath.Join(cfg.Options.DataDirectory, "commands"),
114 prefix: projectCommandPrefix,
115 })
116
117 return sources
118}
119
120func getXDGCommandsDir() string {
121 xdgHome := os.Getenv("XDG_CONFIG_HOME")
122 if xdgHome == "" {
123 if home := home.Dir(); home != "" {
124 xdgHome = filepath.Join(home, ".config")
125 }
126 }
127 if xdgHome != "" {
128 return filepath.Join(xdgHome, "crush", "commands")
129 }
130 return ""
131}
132
133func (l *commandLoader) loadAll() ([]Command, error) {
134 var commands []Command
135
136 for _, source := range l.sources {
137 if cmds, err := l.loadFromSource(source); err == nil {
138 commands = append(commands, cmds...)
139 }
140 }
141
142 return commands, nil
143}
144
145func (l *commandLoader) loadFromSource(source commandSource) ([]Command, error) {
146 if err := ensureDir(source.path); err != nil {
147 return nil, err
148 }
149
150 var commands []Command
151
152 err := filepath.WalkDir(source.path, func(path string, d fs.DirEntry, err error) error {
153 if err != nil || d.IsDir() || !isMarkdownFile(d.Name()) {
154 return err
155 }
156
157 cmd, err := l.loadCommand(path, source.path, source.prefix)
158 if err != nil {
159 return nil // Skip invalid files
160 }
161
162 commands = append(commands, cmd)
163 return nil
164 })
165
166 return commands, err
167}
168
169func (l *commandLoader) loadCommand(path, baseDir, prefix string) (Command, error) {
170 content, err := os.ReadFile(path)
171 if err != nil {
172 return Command{}, err
173 }
174
175 id := buildCommandID(path, baseDir, prefix)
176 desc := fmt.Sprintf("Custom command from %s", filepath.Base(path))
177
178 return Command{
179 ID: id,
180 Title: id,
181 Description: desc,
182 Handler: createCommandHandler(id, desc, string(content)),
183 }, nil
184}
185
186func buildCommandID(path, baseDir, prefix string) string {
187 relPath, _ := filepath.Rel(baseDir, path)
188 parts := strings.Split(relPath, string(filepath.Separator))
189
190 // Remove .md extension from last part
191 if len(parts) > 0 {
192 lastIdx := len(parts) - 1
193 parts[lastIdx] = strings.TrimSuffix(parts[lastIdx], filepath.Ext(parts[lastIdx]))
194 }
195
196 return prefix + strings.Join(parts, ":")
197}
198
199func createCommandHandler(id, desc, content string) func(Command) tea.Cmd {
200 return func(cmd Command) tea.Cmd {
201 args := extractArgNames(content)
202
203 if len(args) == 0 {
204 return util.CmdHandler(CommandRunCustomMsg{
205 Content: content,
206 })
207 }
208 return util.CmdHandler(ShowArgumentsDialogMsg{
209 CommandID: id,
210 Description: desc,
211 ArgNames: args,
212 OnSubmit: func(args map[string]string) tea.Cmd {
213 return execUserPrompt(content, args)
214 },
215 })
216 }
217}
218
219func execUserPrompt(content string, args map[string]string) tea.Cmd {
220 return func() tea.Msg {
221 for name, value := range args {
222 placeholder := "$" + name
223 content = strings.ReplaceAll(content, placeholder, value)
224 }
225 return CommandRunCustomMsg{
226 Content: content,
227 }
228 }
229}
230
231func extractArgNames(content string) []string {
232 matches := namedArgPattern.FindAllStringSubmatch(content, -1)
233 if len(matches) == 0 {
234 return nil
235 }
236
237 seen := make(map[string]bool)
238 var args []string
239
240 for _, match := range matches {
241 arg := match[1]
242 if !seen[arg] {
243 seen[arg] = true
244 args = append(args, arg)
245 }
246 }
247
248 return args
249}
250
251func ensureDir(path string) error {
252 if _, err := os.Stat(path); os.IsNotExist(err) {
253 return os.MkdirAll(path, 0o755)
254 }
255 return nil
256}
257
258func isMarkdownFile(name string) bool {
259 return strings.HasSuffix(strings.ToLower(name), ".md")
260}
261
262type CommandRunCustomMsg struct {
263 Content string
264}
265
266func LoadMCPPrompts() []Command {
267 var commands []Command
268 for mcpName, prompts := range mcp.Prompts() {
269 for _, prompt := range prompts {
270 key := mcpName + ":" + prompt.Name
271 commands = append(commands, Command{
272 ID: key,
273 Title: cmp.Or(prompt.Title, prompt.Name),
274 Description: prompt.Description,
275 Handler: createMCPPromptHandler(mcpName, prompt.Name, prompt),
276 })
277 }
278 }
279
280 return commands
281}
282
283func createMCPPromptHandler(mcpName, promptName string, prompt *mcp.Prompt) func(Command) tea.Cmd {
284 return func(cmd Command) tea.Cmd {
285 if len(prompt.Arguments) == 0 {
286 return execMCPPrompt(mcpName, promptName, nil)
287 }
288 return util.CmdHandler(ShowMCPPromptArgumentsDialogMsg{
289 Prompt: prompt,
290 OnSubmit: func(args map[string]string) tea.Cmd {
291 return execMCPPrompt(mcpName, promptName, args)
292 },
293 })
294 }
295}
296
297func execMCPPrompt(clientName, promptName string, args map[string]string) tea.Cmd {
298 return func() tea.Msg {
299 ctx := context.Background()
300 result, err := mcp.GetPromptMessages(ctx, clientName, promptName, args)
301 if err != nil {
302 return util.ReportError(err)
303 }
304
305 return chat.SendMsg{
306 Text: strings.Join(result, " "),
307 }
308 }
309}
310
311type ShowMCPPromptArgumentsDialogMsg struct {
312 Prompt *mcp.Prompt
313 OnSubmit func(arg map[string]string) tea.Cmd
314}