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