repl.go 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. package cmd
  2. import (
  3. "bufio"
  4. "context"
  5. "fmt"
  6. "net/url"
  7. "os"
  8. "strings"
  9. "gogs.dmsc.dev/arp/arp_cli/config"
  10. "github.com/AlecAivazis/survey/v2"
  11. "github.com/urfave/cli/v3"
  12. )
  13. // REPL represents a Read-Eval-Print Loop for interactive CLI usage
  14. type REPL struct {
  15. app *cli.Command
  16. prompt string
  17. history []string
  18. serverURL string // Session-specific server URL
  19. token string // Session-specific token
  20. }
  21. // NewREPL creates a new REPL instance
  22. func NewREPL(app *cli.Command) *REPL {
  23. return &REPL{
  24. app: app,
  25. prompt: "arp> ",
  26. }
  27. }
  28. // Run starts the REPL loop
  29. func (r *REPL) Run(ctx context.Context) error {
  30. // Load saved config for defaults
  31. cfg, err := config.Load()
  32. if err != nil {
  33. return fmt.Errorf("failed to load config: %w", err)
  34. }
  35. // Prompt for server URL
  36. serverURL, err := r.promptServerURL(cfg)
  37. if err != nil {
  38. return err
  39. }
  40. r.serverURL = serverURL
  41. r.token = cfg.Token // Use saved token as starting point
  42. // Update prompt to show connection info
  43. r.updatePrompt()
  44. fmt.Println("ARP CLI - Interactive Mode")
  45. fmt.Printf("Connected to: %s\n", r.serverURL)
  46. fmt.Println("Type 'help' for available commands, 'exit' or 'quit' to leave.")
  47. fmt.Println()
  48. scanner := bufio.NewScanner(os.Stdin)
  49. for {
  50. // Show prompt
  51. fmt.Print(r.prompt)
  52. // Read line
  53. if !scanner.Scan() {
  54. // EOF (Ctrl+D)
  55. fmt.Println()
  56. break
  57. }
  58. line := strings.TrimSpace(scanner.Text())
  59. if line == "" {
  60. continue
  61. }
  62. // Check for exit commands
  63. if line == "exit" || line == "quit" || line == "q" {
  64. fmt.Println("Goodbye!")
  65. break
  66. }
  67. // Parse the line into args
  68. args, err := parseArgs(line)
  69. if err != nil {
  70. fmt.Fprintf(os.Stderr, "Error parsing command: %v\n", err)
  71. continue
  72. }
  73. // Prepend the app name to match CLI expectations
  74. args = append([]string{"arp_cli"}, args...)
  75. // Inject session URL if not already specified
  76. args = r.injectSessionURL(args)
  77. // Execute the command
  78. if err := r.app.Run(ctx, args); err != nil {
  79. fmt.Fprintf(os.Stderr, "Error: %v\n", err)
  80. }
  81. // Store in history
  82. r.history = append(r.history, line)
  83. // Reload config to pick up any token changes from login
  84. r.reloadToken()
  85. }
  86. if err := scanner.Err(); err != nil {
  87. return fmt.Errorf("reading input: %w", err)
  88. }
  89. return nil
  90. }
  91. // promptServerURL prompts the user for a server URL
  92. func (r *REPL) promptServerURL(cfg *config.Config) (string, error) {
  93. // Determine default URL
  94. defaultURL := cfg.ServerURL
  95. if defaultURL == "" {
  96. defaultURL = "http://localhost:8080/query"
  97. }
  98. var serverURL string
  99. prompt := &survey.Input{
  100. Message: "Server URL",
  101. Default: defaultURL,
  102. Help: "The ARP server URL to connect to for this session",
  103. }
  104. if err := survey.AskOne(prompt, &serverURL); err != nil {
  105. return "", err
  106. }
  107. // Ensure URL has the /query endpoint
  108. serverURL = ensureQueryEndpoint(serverURL)
  109. return serverURL, nil
  110. }
  111. // updatePrompt updates the prompt to show connection info
  112. func (r *REPL) updatePrompt() {
  113. // Extract a short identifier from the URL for the prompt
  114. shortName := r.getShortServerName()
  115. r.prompt = fmt.Sprintf("arp[%s]> ", shortName)
  116. }
  117. // getShortServerName extracts a short name from the server URL for the prompt
  118. func (r *REPL) getShortServerName() string {
  119. if r.serverURL == "" {
  120. return "local"
  121. }
  122. parsed, err := url.Parse(r.serverURL)
  123. if err != nil {
  124. return "server"
  125. }
  126. host := parsed.Host
  127. // Remove port if present
  128. if idx := strings.Index(host, ":"); idx != -1 {
  129. host = host[:idx]
  130. }
  131. // Shorten common patterns
  132. if host == "localhost" || host == "127.0.0.1" {
  133. return "local"
  134. }
  135. // Truncate if too long
  136. if len(host) > 15 {
  137. host = host[:12] + "..."
  138. }
  139. return host
  140. }
  141. // injectSessionURL adds the --url flag to commands if not already present
  142. func (r *REPL) injectSessionURL(args []string) []string {
  143. if r.serverURL == "" {
  144. return args
  145. }
  146. // Don't inject URL for help commands (help treats args as topics)
  147. if len(args) > 1 && args[1] == "help" {
  148. return args
  149. }
  150. // Check if --url or -u is already in args
  151. for i, arg := range args {
  152. if arg == "--url" || arg == "-u" {
  153. // URL already specified, don't inject
  154. return args
  155. }
  156. // Check for --url=value format
  157. if strings.HasPrefix(arg, "--url=") || strings.HasPrefix(arg, "-u=") {
  158. return args
  159. }
  160. // Also check if previous arg was --url/-u and this is the value
  161. if i > 0 && (args[i-1] == "--url" || args[i-1] == "-u") {
  162. return args
  163. }
  164. }
  165. // Insert --url flag right after the app name, before any subcommands
  166. // This ensures global flags are processed correctly by urfave/cli/v3
  167. insertPos := 1
  168. // Insert --url flag with value
  169. newArgs := make([]string, 0, len(args)+2)
  170. newArgs = append(newArgs, args[:insertPos]...)
  171. newArgs = append(newArgs, "--url", r.serverURL)
  172. newArgs = append(newArgs, args[insertPos:]...)
  173. return newArgs
  174. }
  175. // reloadToken reloads the token from config (called after commands that might change auth)
  176. func (r *REPL) reloadToken() {
  177. cfg, err := config.Load()
  178. if err == nil {
  179. r.token = cfg.Token
  180. }
  181. }
  182. // parseArgs parses a command line string into arguments
  183. // Handles quoted strings and basic escaping
  184. func parseArgs(line string) ([]string, error) {
  185. var args []string
  186. var current strings.Builder
  187. inQuote := false
  188. quoteChar := rune(0)
  189. for i, ch := range line {
  190. switch {
  191. case ch == '\\' && i+1 < len(line):
  192. // Handle escape sequences
  193. next := rune(line[i+1])
  194. if next == '"' || next == '\'' || next == '\\' || next == ' ' {
  195. current.WriteRune(next)
  196. // Skip the next character
  197. line = line[:i] + line[i+1:]
  198. } else {
  199. current.WriteRune(ch)
  200. }
  201. case (ch == '"' || ch == '\'') && !inQuote:
  202. inQuote = true
  203. quoteChar = ch
  204. case ch == quoteChar && inQuote:
  205. inQuote = false
  206. quoteChar = 0
  207. case ch == ' ' && !inQuote:
  208. if current.Len() > 0 {
  209. args = append(args, current.String())
  210. current.Reset()
  211. }
  212. default:
  213. current.WriteRune(ch)
  214. }
  215. }
  216. // Add the last argument
  217. if current.Len() > 0 {
  218. args = append(args, current.String())
  219. }
  220. return args, nil
  221. }