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}