custom_commands.go

  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}