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