1package commands
  2
  3import (
  4	"fmt"
  5	"io/fs"
  6	"os"
  7	"path/filepath"
  8	"regexp"
  9	"strings"
 10
 11	tea "github.com/charmbracelet/bubbletea/v2"
 12	"github.com/charmbracelet/crush/internal/config"
 13	"github.com/charmbracelet/crush/internal/home"
 14	"github.com/charmbracelet/crush/internal/tui/util"
 15)
 16
 17const (
 18	userCommandPrefix    = "user:"
 19	projectCommandPrefix = "project:"
 20)
 21
 22var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
 23
 24type commandLoader struct {
 25	sources []commandSource
 26}
 27
 28type commandSource struct {
 29	path   string
 30	prefix string
 31}
 32
 33func LoadCustomCommands() ([]Command, error) {
 34	cfg := config.Get()
 35	if cfg == nil {
 36		return nil, fmt.Errorf("config not loaded")
 37	}
 38
 39	loader := &commandLoader{
 40		sources: buildCommandSources(cfg),
 41	}
 42
 43	return loader.loadAll()
 44}
 45
 46func buildCommandSources(cfg *config.Config) []commandSource {
 47	var sources []commandSource
 48
 49	// XDG config directory
 50	if dir := getXDGCommandsDir(); dir != "" {
 51		sources = append(sources, commandSource{
 52			path:   dir,
 53			prefix: userCommandPrefix,
 54		})
 55	}
 56
 57	// Home directory
 58	if home := home.Dir(); home != "" {
 59		sources = append(sources, commandSource{
 60			path:   filepath.Join(home, ".crush", "commands"),
 61			prefix: userCommandPrefix,
 62		})
 63	}
 64
 65	// Project directory
 66	sources = append(sources, commandSource{
 67		path:   filepath.Join(cfg.Options.DataDirectory, "commands"),
 68		prefix: projectCommandPrefix,
 69	})
 70
 71	return sources
 72}
 73
 74func getXDGCommandsDir() string {
 75	xdgHome := os.Getenv("XDG_CONFIG_HOME")
 76	if xdgHome == "" {
 77		if home := home.Dir(); home != "" {
 78			xdgHome = filepath.Join(home, ".config")
 79		}
 80	}
 81	if xdgHome != "" {
 82		return filepath.Join(xdgHome, "crush", "commands")
 83	}
 84	return ""
 85}
 86
 87func (l *commandLoader) loadAll() ([]Command, error) {
 88	var commands []Command
 89
 90	for _, source := range l.sources {
 91		if cmds, err := l.loadFromSource(source); err == nil {
 92			commands = append(commands, cmds...)
 93		}
 94	}
 95
 96	return commands, nil
 97}
 98
 99func (l *commandLoader) loadFromSource(source commandSource) ([]Command, error) {
100	if err := ensureDir(source.path); err != nil {
101		return nil, err
102	}
103
104	var commands []Command
105
106	err := filepath.WalkDir(source.path, func(path string, d fs.DirEntry, err error) error {
107		if err != nil || d.IsDir() || !isMarkdownFile(d.Name()) {
108			return err
109		}
110
111		cmd, err := l.loadCommand(path, source.path, source.prefix)
112		if err != nil {
113			return nil // Skip invalid files
114		}
115
116		commands = append(commands, cmd)
117		return nil
118	})
119
120	return commands, err
121}
122
123func (l *commandLoader) loadCommand(path, baseDir, prefix string) (Command, error) {
124	content, err := os.ReadFile(path)
125	if err != nil {
126		return Command{}, err
127	}
128
129	id := buildCommandID(path, baseDir, prefix)
130	desc := fmt.Sprintf("Custom command from %s", filepath.Base(path))
131
132	return Command{
133		ID:          id,
134		Title:       id,
135		Description: desc,
136		Handler:     createCommandHandler(id, desc, string(content)),
137	}, nil
138}
139
140func buildCommandID(path, baseDir, prefix string) string {
141	relPath, _ := filepath.Rel(baseDir, path)
142	parts := strings.Split(relPath, string(filepath.Separator))
143
144	// Remove .md extension from last part
145	if len(parts) > 0 {
146		lastIdx := len(parts) - 1
147		parts[lastIdx] = strings.TrimSuffix(parts[lastIdx], filepath.Ext(parts[lastIdx]))
148	}
149
150	return prefix + strings.Join(parts, ":")
151}
152
153func createCommandHandler(id, desc, content string) func(Command) tea.Cmd {
154	return func(cmd Command) tea.Cmd {
155		args := extractArgNames(content)
156
157		if len(args) == 0 {
158			return util.CmdHandler(CommandRunCustomMsg{
159				Content: content,
160			})
161		}
162		return util.CmdHandler(ShowArgumentsDialogMsg{
163			CommandID:   id,
164			Description: desc,
165			ArgNames:    args,
166			OnSubmit: func(args map[string]string) tea.Cmd {
167				return execUserPrompt(content, args)
168			},
169		})
170	}
171}
172
173func execUserPrompt(content string, args map[string]string) tea.Cmd {
174	return func() tea.Msg {
175		for name, value := range args {
176			placeholder := "$" + name
177			content = strings.ReplaceAll(content, placeholder, value)
178		}
179		return CommandRunCustomMsg{
180			Content: content,
181		}
182	}
183}
184
185func extractArgNames(content string) []string {
186	matches := namedArgPattern.FindAllStringSubmatch(content, -1)
187	if len(matches) == 0 {
188		return nil
189	}
190
191	seen := make(map[string]bool)
192	var args []string
193
194	for _, match := range matches {
195		arg := match[1]
196		if !seen[arg] {
197			seen[arg] = true
198			args = append(args, arg)
199		}
200	}
201
202	return args
203}
204
205func ensureDir(path string) error {
206	if _, err := os.Stat(path); os.IsNotExist(err) {
207		return os.MkdirAll(path, 0o755)
208	}
209	return nil
210}
211
212func isMarkdownFile(name string) bool {
213	return strings.HasSuffix(strings.ToLower(name), ".md")
214}
215
216type CommandRunCustomMsg struct {
217	Content string
218}