custom_commands.go 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  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/app"
  10. "github.com/sst/opencode/internal/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(app *app.App) ([]Command, error) {
  21. var commands []Command
  22. homeCommandsDir := filepath.Join(app.Info.Path.Config, "commands")
  23. homeCommands, err := loadCommandsFromDir(homeCommandsDir, UserCommandPrefix)
  24. if err != nil {
  25. // Log error but continue - we'll still try to load other commands
  26. fmt.Printf("Warning: failed to load home commands: %v\n", err)
  27. } else {
  28. commands = append(commands, homeCommands...)
  29. }
  30. projectCommandsDir := filepath.Join(app.Info.Path.Root, ".opencode", "commands")
  31. projectCommands, err := loadCommandsFromDir(projectCommandsDir, ProjectCommandPrefix)
  32. if err != nil {
  33. // Log error but return what we have so far
  34. fmt.Printf("Warning: failed to load project commands: %v\n", err)
  35. } else {
  36. commands = append(commands, projectCommands...)
  37. }
  38. return commands, nil
  39. }
  40. // loadCommandsFromDir loads commands from a specific directory with the given prefix
  41. func loadCommandsFromDir(commandsDir string, prefix string) ([]Command, error) {
  42. // Check if the commands directory exists
  43. if _, err := os.Stat(commandsDir); os.IsNotExist(err) {
  44. // Create the commands directory if it doesn't exist
  45. if err := os.MkdirAll(commandsDir, 0755); err != nil {
  46. return nil, fmt.Errorf("failed to create commands directory %s: %w", commandsDir, err)
  47. }
  48. // Return empty list since we just created the directory
  49. return []Command{}, nil
  50. }
  51. var commands []Command
  52. // Walk through the commands directory and load all .md files
  53. err := filepath.Walk(commandsDir, func(path string, info os.FileInfo, err error) error {
  54. if err != nil {
  55. return err
  56. }
  57. // Skip directories
  58. if info.IsDir() {
  59. return nil
  60. }
  61. // Only process markdown files
  62. if !strings.HasSuffix(strings.ToLower(info.Name()), ".md") {
  63. return nil
  64. }
  65. // Read the file content
  66. content, err := os.ReadFile(path)
  67. if err != nil {
  68. return fmt.Errorf("failed to read command file %s: %w", path, err)
  69. }
  70. // Get the command ID from the file name without the .md extension
  71. commandID := strings.TrimSuffix(info.Name(), filepath.Ext(info.Name()))
  72. // Get relative path from commands directory
  73. relPath, err := filepath.Rel(commandsDir, path)
  74. if err != nil {
  75. return fmt.Errorf("failed to get relative path for %s: %w", path, err)
  76. }
  77. // Create the command ID from the relative path
  78. // Replace directory separators with colons
  79. commandIDPath := strings.ReplaceAll(filepath.Dir(relPath), string(filepath.Separator), ":")
  80. if commandIDPath != "." {
  81. commandID = commandIDPath + ":" + commandID
  82. }
  83. // Create a command
  84. command := Command{
  85. ID: prefix + commandID,
  86. Title: prefix + commandID,
  87. Description: fmt.Sprintf("Custom command from %s", relPath),
  88. Handler: func(cmd Command) tea.Cmd {
  89. commandContent := string(content)
  90. // Check for named arguments
  91. matches := namedArgPattern.FindAllStringSubmatch(commandContent, -1)
  92. if len(matches) > 0 {
  93. // Extract unique argument names
  94. argNames := make([]string, 0)
  95. argMap := make(map[string]bool)
  96. for _, match := range matches {
  97. argName := match[1] // Group 1 is the name without $
  98. if !argMap[argName] {
  99. argMap[argName] = true
  100. argNames = append(argNames, argName)
  101. }
  102. }
  103. // Show multi-arguments dialog for all named arguments
  104. return util.CmdHandler(ShowMultiArgumentsDialogMsg{
  105. CommandID: cmd.ID,
  106. Content: commandContent,
  107. ArgNames: argNames,
  108. })
  109. }
  110. // No arguments needed, run command directly
  111. return util.CmdHandler(CommandRunCustomMsg{
  112. Content: commandContent,
  113. Args: nil, // No arguments
  114. })
  115. },
  116. }
  117. commands = append(commands, command)
  118. return nil
  119. })
  120. if err != nil {
  121. return nil, fmt.Errorf("failed to load custom commands from %s: %w", commandsDir, err)
  122. }
  123. return commands, nil
  124. }
  125. // CommandRunCustomMsg is sent when a custom command is executed
  126. type CommandRunCustomMsg struct {
  127. Content string
  128. Args map[string]string // Map of argument names to values
  129. }