// // Copyright 2026 The InfiniFlow Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package cli import ( "context" "encoding/json" "errors" "fmt" "os" "os/signal" "path/filepath" "strconv" "strings" "syscall" "unicode/utf8" "github.com/peterh/liner" "gopkg.in/yaml.v3" "ragflow/internal/cli/filesystem" ) // ConfigFile represents the rf.yml configuration file structure type ConfigFile struct { Host string `yaml:"host"` APIToken string `yaml:"api_token"` UserName string `yaml:"user_name"` Password string `yaml:"password"` } // OutputFormat represents the output format type type OutputFormat string const ( OutputFormatTable OutputFormat = "table" // Table format with borders OutputFormatPlain OutputFormat = "plain" // Plain text, space-separated (no borders) OutputFormatJSON OutputFormat = "json" // JSON format (reserved for future use) ) // ConnectionArgs holds the parsed command line arguments type ConnectionArgs struct { Host string Port int Password string APIToken string UserName string ConfigFilePath string // Path to the config file (e.g., rf.yml) Command *string // Original command string (for SQL mode) CommandArgs []string // Split command arguments (for ContextEngine mode) IsSQLMode bool // true=SQL mode (quoted), false= ContextEngine mode (unquoted) ShowHelp bool AdminMode bool OutputFormat OutputFormat // Output format: table, plain, json Verbose bool // Enable verbose logging } // LoadDefaultConfigFile reads the rf.yml file from current directory if it exists func LoadDefaultConfigFile() (*ConfigFile, error) { // Try to read rf.yml from current directory data, err := os.ReadFile("rf.yml") if err != nil { // File doesn't exist, return nil without error if os.IsNotExist(err) { return nil, nil } return nil, err } var config ConfigFile if err = yaml.Unmarshal(data, &config); err != nil { return nil, fmt.Errorf("failed to parse rf.yml: %v", err) } return &config, nil } // LoadConfigFileFromPath reads a config file from the specified path func LoadConfigFileFromPath(path string) (*ConfigFile, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("failed to read config file %s: %v", path, err) } var config ConfigFile if err = yaml.Unmarshal(data, &config); err != nil { return nil, fmt.Errorf("failed to parse config file %s: %v", path, err) } return &config, nil } // parseHostPort parses a host:port string and returns host and port func parseHostPort(hostPort string) (string, int, error) { if hostPort == "" { return "", -1, nil } // Split host and port parts := strings.Split(hostPort, ":") if len(parts) != 2 { return "", -1, fmt.Errorf("invalid host format, expected host:port, got: %s", hostPort) } host := parts[0] port, err := strconv.Atoi(parts[1]) if err != nil { return "", -1, fmt.Errorf("invalid port number: %s", parts[1]) } return host, port, nil } // ParseConnectionArgs parses command line arguments similar to Python's parse_connection_args func ParseConnectionArgs(args []string) (*ConnectionArgs, error) { // First, scan args to check for help, config file, admin mode, and verbose flag var configFilePath string var adminMode bool = false var verboseMode bool = false foundCommand := false for i := 0; i < len(args); i++ { arg := args[i] // If we found a command (non-flag arg), stop processing global flags // This allows subcommands like "search --help" to handle their own help if !strings.HasPrefix(arg, "-") { foundCommand = true continue } // Only process --help as global help if it's before any command if !foundCommand && (arg == "--help" || arg == "-help") { return &ConnectionArgs{ShowHelp: true, Verbose: verboseMode}, nil } else if (arg == "-f" || arg == "--config") && i+1 < len(args) { configFilePath = args[i+1] // Convert to absolute path immediately if !filepath.IsAbs(configFilePath) { absPath, err := filepath.Abs(configFilePath) if err == nil { configFilePath = absPath } } i++ } else if (arg == "-o" || arg == "--output") && i+1 < len(args) { // -o/--output is allowed with config file, skip it and its value i++ continue } else if arg == "--admin" { adminMode = true } else if arg == "-v" || arg == "--verbose" { verboseMode = true } } // Load config file with priority: -f > rf.yml > none var config *ConfigFile var err error // Parse arguments manually to support both short and long forms // and to handle priority: command line > config file > defaults result := &ConnectionArgs{ Verbose: verboseMode, ConfigFilePath: configFilePath, } if !adminMode { // Only user mode read config file if configFilePath != "" { // User specified config file via -f config, err = LoadConfigFileFromPath(configFilePath) if err != nil { return nil, err } } else { // Try default rf.yml config, err = LoadDefaultConfigFile() if err != nil { return nil, err } } // Apply config file values first (lower priority) if config != nil { // Parse host:port from config file if config.Host != "" { h, port, err := parseHostPort(config.Host) if err != nil { return nil, fmt.Errorf("invalid host in config file: %v", err) } result.Host = h result.Port = port } result.UserName = config.UserName result.Password = config.Password result.APIToken = config.APIToken } } // Get non-flag arguments (command to execute) var nonFlagArgs []string // Override with command line flags (higher priority) // Handle both short and long forms manually // Once we encounter a non-flag argument (command), stop parsing global flags // Remaining args belong to the subcommand foundCommand = false for i := 0; i < len(args); i++ { arg := args[i] // If we've found the command, collect remaining args as subcommand args if foundCommand { nonFlagArgs = append(nonFlagArgs, arg) continue } switch arg { case "-h", "--host": if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { hostVal := args[i+1] h, port, err := parseHostPort(hostVal) if err != nil { return nil, fmt.Errorf("invalid host format: %v", err) } result.Host = h result.Port = port i++ } case "-t", "--token": if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { result.APIToken = args[i+1] i++ } case "-u", "--user": if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { result.UserName = args[i+1] i++ } case "-p", "--password": if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { result.Password = args[i+1] i++ } case "-f", "--config": // Skip config file path (already parsed) if i+1 < len(args) { i++ } case "-o", "--output": // Parse output format if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { format := args[i+1] switch format { case "plain": result.OutputFormat = OutputFormatPlain case "json": result.OutputFormat = OutputFormatJSON default: result.OutputFormat = OutputFormatTable } i++ } case "-v", "--verbose": result.Verbose = true case "--admin", "-admin": result.AdminMode = true case "--help", "-help": // Already handled above continue default: // Non-flag argument (command) if !strings.HasPrefix(arg, "-") { nonFlagArgs = append(nonFlagArgs, arg) foundCommand = true } } } // Set defaults if not provided if result.Host == "" { result.Host = "127.0.0.1" } if result.Port == -1 || result.Port == 0 { if result.AdminMode { result.Port = 9383 } else { result.Port = 9384 } } if result.UserName == "" && result.Password != "" { return nil, fmt.Errorf("username (-u/--user) is required when using password (-p/--password)") } if result.AdminMode { result.APIToken = "" if result.UserName == "" { result.UserName = "admin@ragflow.io" result.Password = "" } } else { // For user mode // Validate mutual exclusivity: -t and (-u, -p) are mutually exclusive hasToken := result.APIToken != "" hasUserPass := result.UserName != "" || result.Password != "" if hasToken && hasUserPass { return nil, fmt.Errorf("cannot use both API token (-t/--token) and username/password (-u/--user, -p/--password). Please use one authentication method") } } // Get command from remaining args (non-flag arguments) if len(nonFlagArgs) > 0 { // Check if this is SQL mode or ContextEngine mode // SQL mode: single argument that looks like SQL (e.g., "LIST DATASETS") // ContextEngine mode: multiple arguments (e.g., "ls", "datasets") if len(nonFlagArgs) == 1 && looksLikeSQL(nonFlagArgs[0]) { // SQL mode: single argument that looks like SQL result.IsSQLMode = true command := nonFlagArgs[0] result.Command = &command } else { // ContextEngine mode: multiple arguments result.IsSQLMode = false result.CommandArgs = nonFlagArgs // Also store joined version for backward compatibility command := strings.Join(nonFlagArgs, " ") result.Command = &command } } return result, nil } // looksLikeSQL checks if a string looks like a SQL command func looksLikeSQL(s string) bool { s = strings.ToUpper(strings.TrimSpace(s)) sqlPrefixes := []string{ "LIST ", "SHOW ", "CREATE ", "DROP ", "ALTER ", "LOGIN ", "REGISTER ", "PING", "GRANT ", "REVOKE ", "SET ", "UNSET ", "UPDATE ", "DELETE ", "INSERT ", "SELECT ", "DESCRIBE ", "EXPLAIN ", "ADD ", "ENABLE ", "DISABLE ", "CHAT ", "USE", "THINK", "REMOVE ", } for _, prefix := range sqlPrefixes { if strings.HasPrefix(s, prefix) { return true } } return false } // PrintUsage prints the CLI usage information func PrintUsage() { fmt.Println(`RAGFlow CLI Client Usage: ragflow_cli [options] [command] Options: -h, --host string RAGFlow service address (host:port, default "127.0.0.1:9380") -t, --token string API token for authentication -u, --user string Username for authentication -p, --password string Password for authentication -f, --config string Path to config file (YAML format) -o, --output string Output format: table, plain, json (search defaults to json) -v, --verbose Enable verbose logging (shows debug info) --admin, -admin Run in admin mode --help Show this help message Mode: --admin, -admin Run in admin mode (prompt: RAGFlow(admin)>) Default is user mode (prompt: RAGFlow(user)>). Authentication: You can authenticate using either: 1. API token: -t or --token 2. Username and password: -u/--user and -p/--password Note: These two methods are mutually exclusive. Configuration File: The CLI will automatically read rf.yml from the current directory if it exists. Use -f or --config to specify a custom config file path. Command line options override config file values. Config file format: host: 127.0.0.1:9380 api_token: your-api-token user_name: your-username password: your-password Note: api_token and user_name/password are mutually exclusive in config file. Commands: SQL commands (use quotes): "LIST USERS", "CREATE USER 'email' 'password'", etc. Filesystem commands (no quotes): ls datasets, search "keyword", cat path, etc. Skill commands: install-skill [options] Install a skill from local path or remote URL uninstall-skill Remove an installed skill search skills -q [--space space1] Search skills in a space If no command is provided, CLI runs in interactive mode.`) } // HistoryFile returns the path to the history file func HistoryFile() string { return os.Getenv("HOME") + "/" + historyFileName } const historyFileName = ".ragflow_cli_history" // CLI represents the command line interface type CLI struct { client *RAGFlowClient contextEngine *filesystem.Engine prompt string running bool line *liner.State args *ConnectionArgs outputFormat OutputFormat // Output format } // NewCLI creates a new CLI instance func NewCLI() (*CLI, error) { return NewCLIWithArgs(nil) } // NewCLIWithArgs creates a new CLI instance with connection arguments func NewCLIWithArgs(args *ConnectionArgs) (*CLI, error) { // Create liner first line := liner.NewLiner() // Determine server type based on --admin or --user flag // Default to "user" mode if not specified serverType := "user" if args != nil && args.AdminMode { serverType = "admin" } // Create client with password prompt using liner client := NewRAGFlowClient(serverType) client.PasswordPrompt = line.PasswordPrompt // Apply connection arguments if provided if args != nil { client.HTTPClient.Host = args.Host if args.Port > 0 { client.HTTPClient.Port = args.Port } if args.APIToken != "" { client.HTTPClient.APIToken = args.APIToken } } // Apply API token if provided (from config file) if args.APIToken != "" { client.HTTPClient.APIToken = args.APIToken client.HTTPClient.useAPIToken = true } // Set output format client.OutputFormat = args.OutputFormat // Auto-login if user and password are provided (from config file) if args.UserName != "" && args.Password != "" && args.APIToken == "" { if err := client.LoginUserInteractive(args.UserName, args.Password); err != nil { line.Close() return nil, fmt.Errorf("auto-login failed: %w", err) } } // Set prompt based on server type prompt := "RAGFlow(user)> " if serverType == "admin" { prompt = "RAGFlow(admin)> " } // Create filesystem engine and register providers engine := filesystem.NewEngine() engine.RegisterProvider(filesystem.NewDatasetProvider(&httpClientAdapter{client: client.HTTPClient})) engine.RegisterProvider(filesystem.NewFileProvider(&httpClientAdapter{client: client.HTTPClient})) engine.RegisterProvider(filesystem.NewSkillProvider(&httpClientAdapter{client: client.HTTPClient})) return &CLI{ prompt: prompt, client: client, contextEngine: engine, line: line, args: args, outputFormat: args.OutputFormat, }, nil } // Run starts the interactive CLI func (c *CLI) Run() error { // If username is provided without password, prompt for password if c.args != nil && c.args.UserName != "" && c.args.Password == "" && c.args.APIToken == "" { maxAttempts := 3 for attempt := 1; attempt <= maxAttempts; attempt++ { fmt.Print("Please input your password: ") password, err := ReadPassword() if password == "" { if attempt < maxAttempts { fmt.Println("Password cannot be empty, please try again") continue } return errors.New("no password provided after 3 attempts") } c.args.Password = password if err = c.VerifyAuth(); err != nil { if attempt < maxAttempts { fmt.Printf("Authentication failed: %v (%d/%d attempts)\n", err, attempt, maxAttempts) continue } return fmt.Errorf("authentication failed after %d attempts: %v", maxAttempts, err) } break } } c.running = true // Load history from file histFile := HistoryFile() if f, err := os.Open(histFile); err == nil { c.line.ReadHistory(f) f.Close() } // Save history on exit defer func() { if f, err := os.Create(histFile); err == nil { c.line.WriteHistory(f) f.Close() } c.line.Close() }() fmt.Println("Welcome to RAGFlow CLI") fmt.Println("Type \\? for help, \\q to quit") fmt.Println() for c.running { input, err := c.line.Prompt(c.prompt) if err != nil { fmt.Printf("Error reading input: %v\n", err) continue } input = strings.TrimSpace(input) if input == "" { continue } // Add to history (skip meta commands) if !strings.HasPrefix(input, "\\") { c.line.AppendHistory(input) } if err = c.executeNew(input); err != nil { fmt.Printf("CLI error: %v\n", err) } } return nil } func (c *CLI) executeNew(input string) error { p := NewParser(input) cmd, err := p.Parse(c.args.AdminMode) if err != nil { return err } if cmd == nil { return nil } // Handle meta commands if cmd.Type == "meta" { return c.handleMetaCommand(cmd) } // Execute the command using the client var result ResponseIf result, err = c.client.ExecuteCommand(cmd) if result != nil { result.PrintOut() } return err } func (c *CLI) execute(input string) error { // Determine execution mode based on input and args input = strings.TrimSpace(input) // Handle meta commands (start with \) if strings.HasPrefix(input, "\\") { p := NewParser(input) cmd, err := p.Parse(c.args.AdminMode) if err != nil { return err } if cmd != nil && cmd.Type == "meta" { return c.handleMetaCommand(cmd) } } // Check if we should use SQL mode or Filesystem mode isSQLMode := false if c.args != nil && len(c.args.CommandArgs) > 0 { // Non-interactive mode: use pre-determined mode from args isSQLMode = c.args.IsSQLMode } else { // Interactive mode: determine based on input isSQLMode = looksLikeSQL(input) } if isSQLMode { // SQL mode: use parser p := NewParser(input) cmd, err := p.Parse(c.args.AdminMode) if err != nil { return err } if cmd == nil { return nil } // Execute SQL command using the client var result ResponseIf result, err = c.client.ExecuteCommand(cmd) if result != nil { result.SetOutputFormat(c.outputFormat) result.PrintOut() } return err } // Filesystem mode: execute filesystem command return c.executeFilesystem(input) } // executeFilesystem executes a Filesystem command func (c *CLI) executeFilesystem(input string) error { // Parse input into arguments var args []string if c.args != nil && len(c.args.CommandArgs) > 0 { // Non-interactive mode: use pre-parsed args args = c.args.CommandArgs } else { // Interactive mode: parse input args = parseFilesystemArgs(input) } if len(args) == 0 { return fmt.Errorf("no command provided") } // Check if we have a filesystem engine if c.contextEngine == nil { return fmt.Errorf("filesystem engine not available") } cmdType := args[0] cmdArgs := args[1:] // Build filesystem command var ceCmd *filesystem.Command switch cmdType { case "ls", "list": // Parse list command arguments listOpts, err := parseListCommandArgs(cmdArgs) if err != nil { return err } if listOpts == nil { // Help was printed return nil } ceCmd = &filesystem.Command{ Type: filesystem.CommandList, Path: listOpts.Path, Params: map[string]interface{}{ "limit": listOpts.Limit, }, } case "search": // Parse search command arguments searchOpts, err := parseSearchCommandArgs(cmdArgs) if err != nil { return err } if searchOpts == nil { // Help was printed return nil } // Determine the path for provider resolution // Use first dir if specified, otherwise default to "datasets" searchPath := "datasets" if len(searchOpts.Dirs) > 0 { searchPath = searchOpts.Dirs[0] } // Check if searching skills (supports: "skills" or "skills/space1") if searchPath == "skills" || strings.HasPrefix(searchPath, "skills/") { // Parse space ID from path (e.g., "skills/space1" -> "space1") spaceID := "default" if strings.HasPrefix(searchPath, "skills/") { spaceID = strings.TrimPrefix(searchPath, "skills/") if spaceID == "" { spaceID = "default" } } // Get skill provider and perform search provider := c.contextEngine.GetProvider("skills") if provider == nil { return fmt.Errorf("skill provider not available") } skillProvider, ok := provider.(*filesystem.SkillProvider) if !ok { return fmt.Errorf("invalid skill provider type") } pageSize := searchOpts.TopK if pageSize <= 0 { pageSize = 10 } searchOptions := &filesystem.SearchOptions{ Query: searchOpts.Query, Limit: pageSize, Offset: 0, TopK: pageSize, } result, err := skillProvider.Search(context.Background(), spaceID, searchOptions) if err != nil { return err } // Print skill search results with full details c.printSkillSearchResults(result, c.outputFormat) return nil } ceCmd = &filesystem.Command{ Type: filesystem.CommandSearch, Path: searchPath, Params: map[string]interface{}{ "query": searchOpts.Query, "top_k": searchOpts.TopK, "threshold": searchOpts.Threshold, "dirs": searchOpts.Dirs, }, } case "cat": if len(cmdArgs) == 0 { return fmt.Errorf("cat requires a path argument") } // Handle cat command directly since it returns []byte, not *Result content, err := c.contextEngine.Cat(context.Background(), cmdArgs[0]) if err != nil { return err } if content == nil || len(content) == 0 { fmt.Println("(empty file)") } else if isBinaryContent(content) { return fmt.Errorf("cannot display binary file content") } fmt.Println(string(content)) return nil case "install-skill": // Get the file provider and skill provider from the engine fileProvider, ok := c.contextEngine.GetProvider("files").(*filesystem.FileProvider) if !ok { return fmt.Errorf("file provider not available") } skillProvider := c.contextEngine.GetProvider("skills") if skillProvider == nil { return fmt.Errorf("skill provider not available") } // Create adapter for HTTPClient httpAdapter := &httpClientAdapter{client: c.client.HTTPClient} cmd := filesystem.NewInstallSkillCommand(httpAdapter, fileProvider, skillProvider) return cmd.Execute(cmdArgs) case "uninstall-skill": skillProvider := c.contextEngine.GetProvider("skills") if skillProvider == nil { return fmt.Errorf("skill provider not available") } fileProvider := c.contextEngine.GetProvider("files") if fileProvider == nil { return fmt.Errorf("file provider not available") } // Create adapter for HTTPClient httpAdapter := &httpClientAdapter{client: c.client.HTTPClient} fileProv, _ := fileProvider.(*filesystem.FileProvider) cmd := filesystem.NewUninstallSkillCommand(httpAdapter, skillProvider, fileProv) return cmd.Execute(cmdArgs) case "add-skill": fmt.Println("⚠ Warning: 'add-skill' is deprecated. Use 'install-skill' instead.") // Forward to install-skill fileProvider, ok := c.contextEngine.GetProvider("files").(*filesystem.FileProvider) if !ok { return fmt.Errorf("file provider not available") } skillProvider := c.contextEngine.GetProvider("skills") if skillProvider == nil { return fmt.Errorf("skill provider not available") } httpAdapter := &httpClientAdapter{client: c.client.HTTPClient} cmd := filesystem.NewInstallSkillCommand(httpAdapter, fileProvider, skillProvider) return cmd.Execute(cmdArgs) case "delete-skill": fmt.Println("⚠ Warning: 'delete-skill' is deprecated. Use 'uninstall-skill' instead.") // Forward to uninstall-skill skillProvider := c.contextEngine.GetProvider("skills") if skillProvider == nil { return fmt.Errorf("skill provider not available") } fileProvider := c.contextEngine.GetProvider("files") if fileProvider == nil { return fmt.Errorf("file provider not available") } httpAdapter := &httpClientAdapter{client: c.client.HTTPClient} fileProv, _ := fileProvider.(*filesystem.FileProvider) cmd := filesystem.NewUninstallSkillCommand(httpAdapter, skillProvider, fileProv) return cmd.Execute(cmdArgs) default: return fmt.Errorf("unknown filesystem command: %s", cmdType) } // Execute the command result, err := c.contextEngine.Execute(context.Background(), ceCmd) if err != nil { return err } // Print result // For search command, default to JSON format if not explicitly set to plain/table format := c.outputFormat if ceCmd.Type == filesystem.CommandSearch && format != OutputFormatPlain && format != OutputFormatTable { format = OutputFormatJSON } // Get limit for list command limit := 0 if ceCmd.Type == filesystem.CommandList { if l, ok := ceCmd.Params["limit"].(int); ok { limit = l } } c.printFilesystemResult(result, ceCmd.Type, format, limit) return nil } // parseFilesystemArgs parses Filesystem command arguments // Supports simple space-separated args and quoted strings func parseFilesystemArgs(input string) []string { var args []string var current strings.Builder inQuote := false var quoteChar rune for _, ch := range input { switch ch { case '"', '\'': if !inQuote { inQuote = true quoteChar = ch if current.Len() > 0 { args = append(args, current.String()) current.Reset() } } else if ch == quoteChar { inQuote = false args = append(args, current.String()) current.Reset() } else { current.WriteRune(ch) } case ' ', '\t': if inQuote { current.WriteRune(ch) } else if current.Len() > 0 { args = append(args, current.String()) current.Reset() } default: current.WriteRune(ch) } } if current.Len() > 0 { args = append(args, current.String()) } return args } // printFilesystemResult prints the result of a filesystem command func (c *CLI) printFilesystemResult(result *filesystem.Result, cmdType filesystem.CommandType, format OutputFormat, limit int) { if result == nil { return } switch cmdType { case filesystem.CommandList: if len(result.Nodes) == 0 { fmt.Println("(empty)") return } displayCount := len(result.Nodes) if limit > 0 && displayCount > limit { displayCount = limit } if format == OutputFormatPlain { // Plain format: simple space-separated, no headers for i := 0; i < displayCount; i++ { node := result.Nodes[i] fmt.Printf("%s %s %s %s\n", node.Name, node.Type, node.Path, node.CreatedAt.Format("2006-01-02 15:04")) } } else { // Table format: with headers and aligned columns fmt.Printf("%-30s %-12s %-50s %-20s\n", "NAME", "TYPE", "PATH", "CREATED") fmt.Println(strings.Repeat("-", 112)) for i := 0; i < displayCount; i++ { node := result.Nodes[i] created := node.CreatedAt.Format("2006-01-02 15:04") if node.CreatedAt.IsZero() { created = "-" } // Remove leading "/" from path for display displayPath := node.Path if strings.HasPrefix(displayPath, "/") { displayPath = displayPath[1:] } fmt.Printf("%-30s %-12s %-50s %-20s\n", node.Name, node.Type, displayPath, created) } } if limit > 0 && result.Total > limit { fmt.Printf("\n... and %d more (use -n to show more)\n", result.Total-limit) } fmt.Printf("Total: %d\n", result.Total) case filesystem.CommandSearch: if len(result.Nodes) == 0 { if format == OutputFormatJSON { fmt.Println("[]") } else { fmt.Println("No results found") } return } // Build data for output (same fields for all formats: content, path, score) type searchResult struct { Content string `json:"content"` Path string `json:"path"` Score float64 `json:"score,omitempty"` } results := make([]searchResult, 0, len(result.Nodes)) for _, node := range result.Nodes { content := node.Name if content == "" { content = "(empty)" } displayPath := node.Path if strings.HasPrefix(displayPath, "/") { displayPath = displayPath[1:] } var score float64 if s, ok := node.Metadata["similarity"].(float64); ok { score = s } else if s, ok := node.Metadata["_score"].(float64); ok { score = s } results = append(results, searchResult{ Content: content, Path: displayPath, Score: score, }) } // Output based on format if format == OutputFormatJSON { jsonData, err := json.MarshalIndent(results, "", " ") if err != nil { fmt.Printf("Error marshaling JSON: %v\n", err) return } fmt.Println(string(jsonData)) } else if format == OutputFormatPlain { // Plain format: simple space-separated, no borders fmt.Printf("%-70s %-50s %-10s\n", "CONTENT", "PATH", "SCORE") for i, sr := range results { content := strings.Join(strings.Fields(sr.Content), " ") if len(content) > 70 { content = content[:67] + "..." } displayPath := sr.Path if len(displayPath) > 50 { displayPath = displayPath[:47] + "..." } scoreStr := "-" if sr.Score > 0 { scoreStr = fmt.Sprintf("%.4f", sr.Score) } fmt.Printf("%-70s %-50s %-10s\n", content, displayPath, scoreStr) if i >= 99 { fmt.Printf("\n... and %d more results\n", result.Total-i-1) break } } fmt.Printf("\nTotal: %d\n", result.Total) } else { // Table format: with borders col1Width, col2Width, col3Width := 70, 50, 10 sep := "+" + strings.Repeat("-", col1Width+2) + "+" + strings.Repeat("-", col2Width+2) + "+" + strings.Repeat("-", col3Width+2) + "+" fmt.Println(sep) fmt.Printf("| %-70s | %-50s | %-10s |\n", "CONTENT", "PATH", "SCORE") fmt.Println(sep) for i, sr := range results { content := strings.Join(strings.Fields(sr.Content), " ") if len(content) > 70 { content = content[:67] + "..." } displayPath := sr.Path if len(displayPath) > 50 { displayPath = displayPath[:47] + "..." } scoreStr := "-" if sr.Score > 0 { scoreStr = fmt.Sprintf("%.4f", sr.Score) } fmt.Printf("| %-70s | %-50s | %-10s |\n", content, displayPath, scoreStr) if i >= 99 { fmt.Printf("\n... and %d more results\n", result.Total-i-1) break } } fmt.Println(sep) fmt.Printf("Total: %d\n", result.Total) } case filesystem.CommandCat: // Cat output is handled differently - it returns []byte, not *Result // This case should not be reached in normal flow since Cat returns []byte directly fmt.Println("Content retrieved") } } // printSkillSearchResults prints skill search results with full details func (c *CLI) printSkillSearchResults(result *filesystem.Result, format OutputFormat) { if result == nil || len(result.Nodes) == 0 { if format == OutputFormatJSON { fmt.Println("[]") } else { fmt.Println("No skills found") } return } // Skill search result structure type skillSearchResult struct { SkillID string `json:"skill_id"` Name string `json:"name"` Description string `json:"description"` Tags string `json:"tags"` Score float64 `json:"score"` BM25Score float64 `json:"bm25_score"` VectorScore float64 `json:"vector_score"` } results := make([]skillSearchResult, 0, len(result.Nodes)) for _, node := range result.Nodes { // Extract metadata skillID := "" if id, ok := node.Metadata["skill_id"].(string); ok { skillID = id } description := "" if desc, ok := node.Metadata["description"].(string); ok { description = desc } tags := "" if t, ok := node.Metadata["tags"].([]string); ok { tags = strings.Join(t, ", ") } var score, bm25Score, vectorScore float64 if s, ok := node.Metadata["score"].(float64); ok { score = s } if b, ok := node.Metadata["bm25_score"].(float64); ok { bm25Score = b } if v, ok := node.Metadata["vector_score"].(float64); ok { vectorScore = v } results = append(results, skillSearchResult{ SkillID: skillID, Name: node.Name, Description: description, Tags: tags, Score: score, BM25Score: bm25Score, VectorScore: vectorScore, }) } if format == OutputFormatJSON { jsonData, err := json.MarshalIndent(results, "", " ") if err != nil { fmt.Printf("Error marshaling JSON: %v\n", err) return } fmt.Println(string(jsonData)) } else if format == OutputFormatPlain { fmt.Printf("Found %d skill(s):\n", len(results)) for _, sr := range results { fmt.Printf("\nName: %s\n", sr.Name) fmt.Printf("Skill ID: %s\n", sr.SkillID) fmt.Printf("Description: %s\n", sr.Description) fmt.Printf("Tags: %s\n", sr.Tags) fmt.Printf("Score: %.6f (BM25: %.6f, Vector: %.6f)\n", sr.Score, sr.BM25Score, sr.VectorScore) } } else { // Table format fmt.Printf("Found %d skill(s):\n", len(results)) fmt.Println() for _, sr := range results { fmt.Printf("Name: %s\n", sr.Name) fmt.Printf("Skill ID: %s\n", sr.SkillID) fmt.Printf("Description: %s\n", sr.Description) fmt.Printf("Tags: %s\n", sr.Tags) fmt.Printf("Score: %.6f (BM25: %.6f, Vector: %.6f)\n", sr.Score, sr.BM25Score, sr.VectorScore) fmt.Println() } } } func (c *CLI) handleMetaCommand(cmd *Command) error { command := cmd.Params["command"].(string) args, _ := cmd.Params["args"].([]string) switch command { case "q", "quit", "exit": fmt.Println("Goodbye!") c.running = false case "?", "h", "help": c.printHelp() case "c", "clear": // Clear screen (simple approach) fmt.Print("\033[H\033[2J") case "admin": c.client.ServerType = "admin" c.prompt = "RAGFlow(admin)> " fmt.Println("Switched to ADMIN mode") case "user": c.client.ServerType = "user" c.prompt = "RAGFlow(user)> " fmt.Println("Switched to USER mode") case "host": if len(args) == 0 { fmt.Printf("Current host: %s\n", c.client.HTTPClient.Host) } else { c.client.HTTPClient.Host = args[0] fmt.Printf("Host set to: %s\n", args[0]) } case "port": if len(args) == 0 { fmt.Printf("Current port: %d\n", c.client.HTTPClient.Port) } else { port, err := strconv.Atoi(args[0]) if err != nil { return fmt.Errorf("invalid port number: %s", args[0]) } if port < 1 || port > 65535 { return fmt.Errorf("port must be between 1 and 65535") } c.client.HTTPClient.Port = port fmt.Printf("Port set to: %d\n", port) } case "status": fmt.Printf("Server: %s:%d (mode: %s)\n", c.client.HTTPClient.Host, c.client.HTTPClient.Port, c.client.ServerType) default: return fmt.Errorf("unknown meta command: \\%s", command) } return nil } func (c *CLI) printHelp() { help := ` RAGFlow CLI Help ================ Meta Commands: \admin - Switch to ADMIN mode (port 9381) \user - Switch to USER mode (port 9380) \host [ip] - Show or set server host (default: 127.0.0.1) \port [num] - Show or set server port (default: 9380 for user, 9381 for admin) \status - Show current connection status \? or \h - Show this help \q or \quit - Exit CLI \c or \clear - Clear screen Commands (User Mode): LOGIN USER 'email'; - Login as user LOGIN USER 'email' PASSWORD 'pwd'; - Login as user with password REGISTER USER 'name' AS 'nickname' PASSWORD 'pwd'; - Register new user SHOW VERSION; - Show version info PING; - Ping server LIST DATASETS; - List user datasets LIST AGENTS; - List user agents LIST CHATS; - List user chats LIST MODEL PROVIDERS; - List model providers LIST DEFAULT MODELS; - List default models LIST TOKENS; - List API tokens LIST PROVIDERS; - List available LLM providers CREATE TOKEN; - Create new API token ADD PROVIDER 'name'; - Create a provider without API key ADD PROVIDER 'name' 'api_key'; - Create a provider with API key DROP TOKEN 'token_value'; - Delete an API token DELETE PROVIDER 'name'; - Delete a provider SET TOKEN 'token_value'; - Set and validate API token SHOW TOKEN; - Show current API token SHOW PROVIDER 'name'; - Show provider details SHOW CURRENT MODEL; - Show current model settings UNSET TOKEN; - Remove current API token ALTER PROVIDER 'name' NAME 'new_name'; - Rename a provider USE MODEL 'provider/instance/model'; - Set current model for chat CHAT 'message'; - Chat using current model CHAT 'provider/instance/model' 'message'; - Chat with specified model Filesystem Commands (no quotes): ls [path] - List resources e.g., ls - List root (providers and folders) e.g., ls datasets - List all datasets e.g., ls datasets/kb1 - Show dataset info e.g., ls myfolder - List files in 'myfolder' (file_manager) list [path] - Same as ls search [options] - Search resources in datasets Use 'search -h' for detailed options cat - Show file content e.g., cat files/docs/file.txt - Show file content Note: cat datasets or cat datasets/kb1 will error Examples: ragflow_cli -f rf.yml "LIST USERS" # SQL mode (with quotes) ragflow_cli -f rf.yml ls datasets # Filesystem mode (no quotes) ragflow_cli -f rf.yml ls files # List files in root ragflow_cli -f rf.yml cat datasets # Error: datasets is a directory ragflow_cli -f rf.yml ls files/myfolder # List folder contents For more information, see documentation. ` fmt.Println(help) } // Cleanup performs cleanup before exit func (c *CLI) Cleanup() { // Close liner to restore terminal settings if c.line != nil { c.line.Close() } } // RunInteractive runs the CLI in interactive mode func RunInteractive() error { cli, err := NewCLI() if err != nil { return fmt.Errorf("failed to create CLI: %v", err) } // Handle interrupt signal sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) go func() { <-sigChan cli.Cleanup() os.Exit(0) }() return cli.Run() } // RunSingleCommand executes a single command and exits func (c *CLI) RunSingleCommand(command *string) error { // Ensure cleanup is called on exit to restore terminal settings defer c.Cleanup() // Execute the command if err := c.execute(*command); err != nil { return err } return nil } // VerifyAuth verifies authentication if needed func (c *CLI) VerifyAuth() error { if c.args == nil { return nil } // If API token is provided, use it for authentication if c.args.APIToken != "" { // TODO: Implement API token authentication return nil } // Otherwise, use username/password authentication if c.args.UserName == "" { return fmt.Errorf("username is required") } if c.args.Password == "" { return fmt.Errorf("password is required") } // Create login command with username and password cmd := NewCommand("login_user") cmd.Params["email"] = c.args.UserName cmd.Params["password"] = c.args.Password _, err := c.client.ExecuteCommand(cmd) return err } // isBinaryContent checks if content is binary (contains null bytes or invalid UTF-8) func isBinaryContent(content []byte) bool { // Check for null bytes (binary file indicator) for _, b := range content { if b == 0 { return true } } // Check valid UTF-8 return !utf8.Valid(content) } // SearchCommandOptions holds parsed search command options type SearchCommandOptions struct { Query string TopK int Threshold float64 Dirs []string } // ListCommandOptions holds parsed list command options type ListCommandOptions struct { Path string Limit int } // parseSearchCommandArgs parses search command arguments // Format: search [path] [-n number] // // search -h|--help (shows help) func parseSearchCommandArgs(args []string) (*SearchCommandOptions, error) { opts := &SearchCommandOptions{ TopK: 10, Threshold: 0.2, Dirs: []string{}, } // Check for help flag for _, arg := range args { if arg == "-h" || arg == "--help" { printSearchHelp() return nil, nil } } // Parse arguments // Format: search [path] [-n number] i := 0 for i < len(args) { arg := args[i] // Handle -n flag for number of results if arg == "-n" || arg == "--number" { if i+1 >= len(args) { return nil, fmt.Errorf("missing value for %s flag", arg) } topK, err := strconv.Atoi(args[i+1]) if err != nil { return nil, fmt.Errorf("invalid number value: %s", args[i+1]) } opts.TopK = topK i += 2 continue } // If it starts with -, it's an unknown flag if strings.HasPrefix(arg, "-") { return nil, fmt.Errorf("unknown flag: %s", arg) } // Non-flag arguments: first is query, second is path if opts.Query == "" { opts.Query = arg } else if len(opts.Dirs) == 0 { opts.Dirs = append(opts.Dirs, arg) } i++ } // Validate required parameters if opts.Query == "" { return nil, fmt.Errorf("query is required") } // If no path specified, default to "datasets" if len(opts.Dirs) == 0 { opts.Dirs = []string{"datasets"} } return opts, nil } // printSearchHelp prints help for the search command func printSearchHelp() { help := `Search command usage: search [path] [-n number] Search for content in datasets or skills. Arguments: Search query (required) Example: "machine learning" [path] Path to search in (default: datasets) Supports: - 'datasets' (all datasets) - 'datasets/' (specific dataset) - 'skills' (default skill space) - 'skills/' (specific skill space) Example: skills/space1 Options: -n, --number Number of results to return (default: 10) Example: -n 20 -h, --help Show this help message Output: Default output format is JSON. Use --output plain or --output table for other formats. Examples: search "neural networks" # Search all datasets search "AI" datasets/kb1 # Search in kb1 search "RAG" skills/space1 -n 20 # Search skills in hub1, return 20 results search "data processing" skills # Search skills (default space) ` fmt.Println(help) } // printListHelp prints help for the list/ls command func printListHelp() { help := `List command usage: ls [path] [options] List contents of a path in the context filesystem. Arguments: [path] Path to list (default: root - shows all providers and folders) Examples: datasets, datasets/kb1, myfolder Options: -n, --limit Maximum number of items to display (default: 10) Example: -n 20 -h, --help Show this help message Examples: ls # List root (all providers and file_manager folders) ls datasets # List all datasets ls datasets/kb1 # List files in kb1 dataset (default 10 items) ls myfolder # List files in file_manager folder 'myfolder' ls -n 5 # List 5 items at root ` fmt.Println(help) } // parseListCommandArgs parses list/ls command arguments // Format: ls [path] [-n limit] [-h|--help] func parseListCommandArgs(args []string) (*ListCommandOptions, error) { opts := &ListCommandOptions{ Path: "", // Empty path means list root (all providers and file_manager folders) Limit: 10, } // Check for help flag for _, arg := range args { if arg == "-h" || arg == "--help" { printListHelp() return nil, nil } } // Parse arguments i := 0 for i < len(args) { arg := args[i] switch arg { case "-n", "--limit": if i+1 >= len(args) { return nil, fmt.Errorf("missing value for %s flag", arg) } limit, err := strconv.Atoi(args[i+1]) if err != nil { return nil, fmt.Errorf("invalid limit value: %s", args[i+1]) } opts.Limit = limit i += 2 default: // If it doesn't start with -, treat as path if !strings.HasPrefix(arg, "-") { opts.Path = arg } else { return nil, fmt.Errorf("unknown flag: %s", arg) } i++ } } return opts, nil }