custom_commands.go 5.4 KB

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