custom_commands.go

  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}