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(cfg *config.Config) ([]Command, error) {
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 := home.Dir(); home != "" {
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 := home.Dir(); home != "" {
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}