1package dialog
2
3import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "regexp"
8 "strings"
9
10 tea "github.com/charmbracelet/bubbletea"
11 "github.com/opencode-ai/opencode/internal/config"
12 "github.com/opencode-ai/opencode/internal/tui/util"
13)
14
15// Command prefix constants
16const (
17 UserCommandPrefix = "user:"
18 ProjectCommandPrefix = "project:"
19)
20
21// namedArgPattern is a regex pattern to find named arguments in the format $NAME
22var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
23
24// LoadCustomCommands loads custom commands from both XDG_CONFIG_HOME and project data directory
25func LoadCustomCommands() ([]Command, error) {
26 cfg := config.Get()
27 if cfg == nil {
28 return nil, fmt.Errorf("config not loaded")
29 }
30
31 var commands []Command
32
33 // Load user commands from XDG_CONFIG_HOME/opencode/commands
34 xdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
35 if xdgConfigHome == "" {
36 // Default to ~/.config if XDG_CONFIG_HOME is not set
37 home, err := os.UserHomeDir()
38 if err == nil {
39 xdgConfigHome = filepath.Join(home, ".config")
40 }
41 }
42
43 if xdgConfigHome != "" {
44 userCommandsDir := filepath.Join(xdgConfigHome, "opencode", "commands")
45 userCommands, err := loadCommandsFromDir(userCommandsDir, UserCommandPrefix)
46 if err != nil {
47 // Log error but continue - we'll still try to load other commands
48 fmt.Printf("Warning: failed to load user commands from XDG_CONFIG_HOME: %v\n", err)
49 } else {
50 commands = append(commands, userCommands...)
51 }
52 }
53
54 // Load commands from $HOME/.opencode/commands
55 home, err := os.UserHomeDir()
56 if err == nil {
57 homeCommandsDir := filepath.Join(home, ".opencode", "commands")
58 homeCommands, err := loadCommandsFromDir(homeCommandsDir, UserCommandPrefix)
59 if err != nil {
60 // Log error but continue - we'll still try to load other commands
61 fmt.Printf("Warning: failed to load home commands: %v\n", err)
62 } else {
63 commands = append(commands, homeCommands...)
64 }
65 }
66
67 // Load project commands from data directory
68 projectCommandsDir := filepath.Join(cfg.Data.Directory, "commands")
69 projectCommands, err := loadCommandsFromDir(projectCommandsDir, ProjectCommandPrefix)
70 if err != nil {
71 // Log error but return what we have so far
72 fmt.Printf("Warning: failed to load project commands: %v\n", err)
73 } else {
74 commands = append(commands, projectCommands...)
75 }
76
77 return commands, nil
78}
79
80// loadCommandsFromDir loads commands from a specific directory with the given prefix
81func loadCommandsFromDir(commandsDir string, prefix string) ([]Command, error) {
82 // Check if the commands directory exists
83 if _, err := os.Stat(commandsDir); os.IsNotExist(err) {
84 // Create the commands directory if it doesn't exist
85 if err := os.MkdirAll(commandsDir, 0755); err != nil {
86 return nil, fmt.Errorf("failed to create commands directory %s: %w", commandsDir, err)
87 }
88 // Return empty list since we just created the directory
89 return []Command{}, nil
90 }
91
92 var commands []Command
93
94 // Walk through the commands directory and load all .md files
95 err := filepath.Walk(commandsDir, func(path string, info os.FileInfo, err error) error {
96 if err != nil {
97 return err
98 }
99
100 // Skip directories
101 if info.IsDir() {
102 return nil
103 }
104
105 // Only process markdown files
106 if !strings.HasSuffix(strings.ToLower(info.Name()), ".md") {
107 return nil
108 }
109
110 // Read the file content
111 content, err := os.ReadFile(path)
112 if err != nil {
113 return fmt.Errorf("failed to read command file %s: %w", path, err)
114 }
115
116 // Get the command ID from the file name without the .md extension
117 commandID := strings.TrimSuffix(info.Name(), filepath.Ext(info.Name()))
118
119 // Get relative path from commands directory
120 relPath, err := filepath.Rel(commandsDir, path)
121 if err != nil {
122 return fmt.Errorf("failed to get relative path for %s: %w", path, err)
123 }
124
125 // Create the command ID from the relative path
126 // Replace directory separators with colons
127 commandIDPath := strings.ReplaceAll(filepath.Dir(relPath), string(filepath.Separator), ":")
128 if commandIDPath != "." {
129 commandID = commandIDPath + ":" + commandID
130 }
131
132 // Create a command
133 command := Command{
134 ID: prefix + commandID,
135 Title: prefix + commandID,
136 Description: fmt.Sprintf("Custom command from %s", relPath),
137 Handler: func(cmd Command) tea.Cmd {
138 commandContent := string(content)
139
140 // Check for named arguments
141 matches := namedArgPattern.FindAllStringSubmatch(commandContent, -1)
142 if len(matches) > 0 {
143 // Extract unique argument names
144 argNames := make([]string, 0)
145 argMap := make(map[string]bool)
146
147 for _, match := range matches {
148 argName := match[1] // Group 1 is the name without $
149 if !argMap[argName] {
150 argMap[argName] = true
151 argNames = append(argNames, argName)
152 }
153 }
154
155 // Show multi-arguments dialog for all named arguments
156 return util.CmdHandler(ShowMultiArgumentsDialogMsg{
157 CommandID: cmd.ID,
158 Content: commandContent,
159 ArgNames: argNames,
160 })
161 }
162
163 // No arguments needed, run command directly
164 return util.CmdHandler(CommandRunCustomMsg{
165 Content: commandContent,
166 Args: nil, // No arguments
167 })
168 },
169 }
170
171 commands = append(commands, command)
172 return nil
173 })
174
175 if err != nil {
176 return nil, fmt.Errorf("failed to load custom commands from %s: %w", commandsDir, err)
177 }
178
179 return commands, nil
180}
181
182// CommandRunCustomMsg is sent when a custom command is executed
183type CommandRunCustomMsg struct {
184 Content string
185 Args map[string]string // Map of argument names to values
186}