summaryrefslogtreecommitdiffhomepage
path: root/packages/tui/internal/components/dialog/custom_commands.go
blob: e23a90f66482f29b8c948e559509a170c5fef6f1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
package dialog

import (
	"fmt"
	"os"
	"path/filepath"
	"regexp"
	"strings"

	tea "github.com/charmbracelet/bubbletea"
	"github.com/sst/opencode/internal/app"
	"github.com/sst/opencode/internal/util"
)

// Command prefix constants
const (
	UserCommandPrefix    = "user:"
	ProjectCommandPrefix = "project:"
)

// namedArgPattern is a regex pattern to find named arguments in the format $NAME
var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)

// LoadCustomCommands loads custom commands from both XDG_CONFIG_HOME and project data directory
func LoadCustomCommands(app *app.App) ([]Command, error) {
	var commands []Command

	homeCommandsDir := filepath.Join(app.Info.Path.Config, "commands")
	homeCommands, err := loadCommandsFromDir(homeCommandsDir, UserCommandPrefix)
	if err != nil {
		// Log error but continue - we'll still try to load other commands
		fmt.Printf("Warning: failed to load home commands: %v\n", err)
	} else {
		commands = append(commands, homeCommands...)
	}

	projectCommandsDir := filepath.Join(app.Info.Path.Root, ".opencode", "commands")
	projectCommands, err := loadCommandsFromDir(projectCommandsDir, ProjectCommandPrefix)
	if err != nil {
		// Log error but return what we have so far
		fmt.Printf("Warning: failed to load project commands: %v\n", err)
	} else {
		commands = append(commands, projectCommands...)
	}

	return commands, nil
}

// loadCommandsFromDir loads commands from a specific directory with the given prefix
func loadCommandsFromDir(commandsDir string, prefix string) ([]Command, error) {
	// Check if the commands directory exists
	if _, err := os.Stat(commandsDir); os.IsNotExist(err) {
		// Create the commands directory if it doesn't exist
		if err := os.MkdirAll(commandsDir, 0755); err != nil {
			return nil, fmt.Errorf("failed to create commands directory %s: %w", commandsDir, err)
		}
		// Return empty list since we just created the directory
		return []Command{}, nil
	}

	var commands []Command

	// Walk through the commands directory and load all .md files
	err := filepath.Walk(commandsDir, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}

		// Skip directories
		if info.IsDir() {
			return nil
		}

		// Only process markdown files
		if !strings.HasSuffix(strings.ToLower(info.Name()), ".md") {
			return nil
		}

		// Read the file content
		content, err := os.ReadFile(path)
		if err != nil {
			return fmt.Errorf("failed to read command file %s: %w", path, err)
		}

		// Get the command ID from the file name without the .md extension
		commandID := strings.TrimSuffix(info.Name(), filepath.Ext(info.Name()))

		// Get relative path from commands directory
		relPath, err := filepath.Rel(commandsDir, path)
		if err != nil {
			return fmt.Errorf("failed to get relative path for %s: %w", path, err)
		}

		// Create the command ID from the relative path
		// Replace directory separators with colons
		commandIDPath := strings.ReplaceAll(filepath.Dir(relPath), string(filepath.Separator), ":")
		if commandIDPath != "." {
			commandID = commandIDPath + ":" + commandID
		}

		// Create a command
		command := Command{
			ID:          prefix + commandID,
			Title:       prefix + commandID,
			Description: fmt.Sprintf("Custom command from %s", relPath),
			Handler: func(cmd Command) tea.Cmd {
				commandContent := string(content)

				// Check for named arguments
				matches := namedArgPattern.FindAllStringSubmatch(commandContent, -1)
				if len(matches) > 0 {
					// Extract unique argument names
					argNames := make([]string, 0)
					argMap := make(map[string]bool)

					for _, match := range matches {
						argName := match[1] // Group 1 is the name without $
						if !argMap[argName] {
							argMap[argName] = true
							argNames = append(argNames, argName)
						}
					}

					// Show multi-arguments dialog for all named arguments
					return util.CmdHandler(ShowMultiArgumentsDialogMsg{
						CommandID: cmd.ID,
						Content:   commandContent,
						ArgNames:  argNames,
					})
				}

				// No arguments needed, run command directly
				return util.CmdHandler(CommandRunCustomMsg{
					Content: commandContent,
					Args:    nil, // No arguments
				})
			},
		}

		commands = append(commands, command)
		return nil
	})

	if err != nil {
		return nil, fmt.Errorf("failed to load custom commands from %s: %w", commandsDir, err)
	}

	return commands, nil
}

// CommandRunCustomMsg is sent when a custom command is executed
type CommandRunCustomMsg struct {
	Content string
	Args    map[string]string // Map of argument names to values
}