uicmd.go

  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}