1package dialog
2
3import (
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
15const (
16 UserCommandPrefix = "user:"
17 ProjectCommandPrefix = "project:"
18)
19
20// LoadCustomCommands loads custom commands from both XDG_CONFIG_HOME and project data directory
21func 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
77func 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
164type CommandRunCustomMsg struct {
165 Content string
166}