package cmd import ( "bufio" "context" "fmt" "net/url" "os" "strings" "gogs.dmsc.dev/arp/arp_cli/config" "github.com/AlecAivazis/survey/v2" "github.com/urfave/cli/v3" ) // REPL represents a Read-Eval-Print Loop for interactive CLI usage type REPL struct { app *cli.Command prompt string history []string serverURL string // Session-specific server URL token string // Session-specific token } // NewREPL creates a new REPL instance func NewREPL(app *cli.Command) *REPL { return &REPL{ app: app, prompt: "arp> ", } } // Run starts the REPL loop func (r *REPL) Run(ctx context.Context) error { // Load saved config for defaults cfg, err := config.Load() if err != nil { return fmt.Errorf("failed to load config: %w", err) } // Prompt for server URL serverURL, err := r.promptServerURL(cfg) if err != nil { return err } r.serverURL = serverURL r.token = cfg.Token // Use saved token as starting point // Update prompt to show connection info r.updatePrompt() fmt.Println("ARP CLI - Interactive Mode") fmt.Printf("Connected to: %s\n", r.serverURL) fmt.Println("Type 'help' for available commands, 'exit' or 'quit' to leave.") fmt.Println() scanner := bufio.NewScanner(os.Stdin) for { // Show prompt fmt.Print(r.prompt) // Read line if !scanner.Scan() { // EOF (Ctrl+D) fmt.Println() break } line := strings.TrimSpace(scanner.Text()) if line == "" { continue } // Check for exit commands if line == "exit" || line == "quit" || line == "q" { fmt.Println("Goodbye!") break } // Parse the line into args args, err := parseArgs(line) if err != nil { fmt.Fprintf(os.Stderr, "Error parsing command: %v\n", err) continue } // Prepend the app name to match CLI expectations args = append([]string{"arp_cli"}, args...) // Inject session URL if not already specified args = r.injectSessionURL(args) // Execute the command if err := r.app.Run(ctx, args); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) } // Store in history r.history = append(r.history, line) // Reload config to pick up any token changes from login r.reloadToken() } if err := scanner.Err(); err != nil { return fmt.Errorf("reading input: %w", err) } return nil } // promptServerURL prompts the user for a server URL func (r *REPL) promptServerURL(cfg *config.Config) (string, error) { // Determine default URL defaultURL := cfg.ServerURL if defaultURL == "" { defaultURL = "http://localhost:8080/query" } var serverURL string prompt := &survey.Input{ Message: "Server URL", Default: defaultURL, Help: "The ARP server URL to connect to for this session", } if err := survey.AskOne(prompt, &serverURL); err != nil { return "", err } // Ensure URL has the /query endpoint serverURL = ensureQueryEndpoint(serverURL) return serverURL, nil } // updatePrompt updates the prompt to show connection info func (r *REPL) updatePrompt() { // Extract a short identifier from the URL for the prompt shortName := r.getShortServerName() r.prompt = fmt.Sprintf("arp[%s]> ", shortName) } // getShortServerName extracts a short name from the server URL for the prompt func (r *REPL) getShortServerName() string { if r.serverURL == "" { return "local" } parsed, err := url.Parse(r.serverURL) if err != nil { return "server" } host := parsed.Host // Remove port if present if idx := strings.Index(host, ":"); idx != -1 { host = host[:idx] } // Shorten common patterns if host == "localhost" || host == "127.0.0.1" { return "local" } // Truncate if too long if len(host) > 15 { host = host[:12] + "..." } return host } // injectSessionURL adds the --url flag to commands if not already present func (r *REPL) injectSessionURL(args []string) []string { if r.serverURL == "" { return args } // Don't inject URL for help commands (help treats args as topics) if len(args) > 1 && args[1] == "help" { return args } // Check if --url or -u is already in args for i, arg := range args { if arg == "--url" || arg == "-u" { // URL already specified, don't inject return args } // Check for --url=value format if strings.HasPrefix(arg, "--url=") || strings.HasPrefix(arg, "-u=") { return args } // Also check if previous arg was --url/-u and this is the value if i > 0 && (args[i-1] == "--url" || args[i-1] == "-u") { return args } } // Insert --url flag right after the app name, before any subcommands // This ensures global flags are processed correctly by urfave/cli/v3 insertPos := 1 // Insert --url flag with value newArgs := make([]string, 0, len(args)+2) newArgs = append(newArgs, args[:insertPos]...) newArgs = append(newArgs, "--url", r.serverURL) newArgs = append(newArgs, args[insertPos:]...) return newArgs } // reloadToken reloads the token from config (called after commands that might change auth) func (r *REPL) reloadToken() { cfg, err := config.Load() if err == nil { r.token = cfg.Token } } // parseArgs parses a command line string into arguments // Handles quoted strings and basic escaping func parseArgs(line string) ([]string, error) { var args []string var current strings.Builder inQuote := false quoteChar := rune(0) for i, ch := range line { switch { case ch == '\\' && i+1 < len(line): // Handle escape sequences next := rune(line[i+1]) if next == '"' || next == '\'' || next == '\\' || next == ' ' { current.WriteRune(next) // Skip the next character line = line[:i] + line[i+1:] } else { current.WriteRune(ch) } case (ch == '"' || ch == '\'') && !inQuote: inQuote = true quoteChar = ch case ch == quoteChar && inQuote: inQuote = false quoteChar = 0 case ch == ' ' && !inQuote: if current.Len() > 0 { args = append(args, current.String()) current.Reset() } default: current.WriteRune(ch) } } // Add the last argument if current.Len() > 0 { args = append(args, current.String()) } return args, nil }