13 tea "charm.land/bubbletea/v2"
14 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
15 "github.com/charmbracelet/crush/internal/config"
16 "github.com/charmbracelet/crush/internal/home"
17 "github.com/charmbracelet/crush/internal/tui/components/chat"
18 "github.com/charmbracelet/crush/internal/tui/util"
22 userCommandPrefix = "user:"
23 projectCommandPrefix = "project:"
26var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
28type commandLoader struct {
29 sources []commandSource
32type commandSource struct {
37func LoadCustomCommands() ([]Command, error) {
40 return nil, fmt.Errorf("config not loaded")
43 loader := &commandLoader{
44 sources: buildCommandSources(cfg),
47 return loader.loadAll()
50func buildCommandSources(cfg *config.Config) []commandSource {
51 var sources []commandSource
53 // XDG config directory
54 if dir := getXDGCommandsDir(); dir != "" {
55 sources = append(sources, commandSource{
57 prefix: userCommandPrefix,
62 if home := home.Dir(); home != "" {
63 sources = append(sources, commandSource{
64 path: filepath.Join(home, ".crush", "commands"),
65 prefix: userCommandPrefix,
70 sources = append(sources, commandSource{
71 path: filepath.Join(cfg.Options.DataDirectory, "commands"),
72 prefix: projectCommandPrefix,
78func getXDGCommandsDir() string {
79 xdgHome := os.Getenv("XDG_CONFIG_HOME")
81 if home := home.Dir(); home != "" {
82 xdgHome = filepath.Join(home, ".config")
86 return filepath.Join(xdgHome, "crush", "commands")
91func (l *commandLoader) loadAll() ([]Command, error) {
92 var commands []Command
94 for _, source := range l.sources {
95 if cmds, err := l.loadFromSource(source); err == nil {
96 commands = append(commands, cmds...)
103func (l *commandLoader) loadFromSource(source commandSource) ([]Command, error) {
104 if err := ensureDir(source.path); err != nil {
108 var commands []Command
110 err := filepath.WalkDir(source.path, func(path string, d fs.DirEntry, err error) error {
111 if err != nil || d.IsDir() || !isMarkdownFile(d.Name()) {
115 cmd, err := l.loadCommand(path, source.path, source.prefix)
117 return nil // Skip invalid files
120 commands = append(commands, cmd)
127func (l *commandLoader) loadCommand(path, baseDir, prefix string) (Command, error) {
128 content, err := os.ReadFile(path)
130 return Command{}, err
133 id := buildCommandID(path, baseDir, prefix)
134 desc := fmt.Sprintf("Custom command from %s", filepath.Base(path))
140 Handler: createCommandHandler(id, desc, string(content)),
144func buildCommandID(path, baseDir, prefix string) string {
145 relPath, _ := filepath.Rel(baseDir, path)
146 parts := strings.Split(relPath, string(filepath.Separator))
148 // Remove .md extension from last part
150 lastIdx := len(parts) - 1
151 parts[lastIdx] = strings.TrimSuffix(parts[lastIdx], filepath.Ext(parts[lastIdx]))
154 return prefix + strings.Join(parts, ":")
157func createCommandHandler(id, desc, content string) func(Command) tea.Cmd {
158 return func(cmd Command) tea.Cmd {
159 args := extractArgNames(content)
162 return util.CmdHandler(CommandRunCustomMsg{
166 return util.CmdHandler(ShowArgumentsDialogMsg{
170 OnSubmit: func(args map[string]string) tea.Cmd {
171 return execUserPrompt(content, args)
177func execUserPrompt(content string, args map[string]string) tea.Cmd {
178 return func() tea.Msg {
179 for name, value := range args {
180 placeholder := "$" + name
181 content = strings.ReplaceAll(content, placeholder, value)
183 return CommandRunCustomMsg{
189func extractArgNames(content string) []string {
190 matches := namedArgPattern.FindAllStringSubmatch(content, -1)
191 if len(matches) == 0 {
195 seen := make(map[string]bool)
198 for _, match := range matches {
202 args = append(args, arg)
209func ensureDir(path string) error {
210 if _, err := os.Stat(path); os.IsNotExist(err) {
211 return os.MkdirAll(path, 0o755)
216func isMarkdownFile(name string) bool {
217 return strings.HasSuffix(strings.ToLower(name), ".md")
220type CommandRunCustomMsg struct {
224func loadMCPPrompts() []Command {
225 var commands []Command
226 for mcpName, prompts := range mcp.Prompts() {
227 for _, prompt := range prompts {
228 key := mcpName + ":" + prompt.Name
229 commands = append(commands, Command{
231 Title: cmp.Or(prompt.Title, prompt.Name),
232 Description: prompt.Description,
233 Handler: createMCPPromptHandler(mcpName, prompt.Name, prompt),
241func createMCPPromptHandler(mcpName, promptName string, prompt *mcp.Prompt) func(Command) tea.Cmd {
242 return func(cmd Command) tea.Cmd {
243 if len(prompt.Arguments) == 0 {
244 return execMCPPrompt(mcpName, promptName, nil)
246 return util.CmdHandler(ShowMCPPromptArgumentsDialogMsg{
248 OnSubmit: func(args map[string]string) tea.Cmd {
249 return execMCPPrompt(mcpName, promptName, args)
255func execMCPPrompt(clientName, promptName string, args map[string]string) tea.Cmd {
256 return func() tea.Msg {
257 ctx := context.Background()
258 result, err := mcp.GetPromptMessages(ctx, clientName, promptName, args)
260 return util.ReportError(err)
264 Text: strings.Join(result, " "),
269type ShowMCPPromptArgumentsDialogMsg struct {
271 OnSubmit func(arg map[string]string) tea.Cmd
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list