// // 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" "io" "os" //"os/signal" "path/filepath" "strconv" "strings" //"syscall" "unicode/utf8" "github.com/peterh/liner" "gopkg.in/yaml.v3" "ragflow/internal/cli/filesystem" ) type APIServerConfig struct { Name string `yaml:"name"` Host string `yaml:"host"` UserName *string `yaml:"user_name"` UserPassword *string `yaml:"password"` ApiToken *string `yaml:"api_token"` IP string Port int } // ConfigFile represents the rf.yml configuration file structure type ConfigFile struct { Host string `yaml:"host"` // default API server host APIToken string `yaml:"api_token"` // default API server api token UserName string `yaml:"user_name"` // default API server user name Password string `yaml:"password"` // default API server password APIServerMap map[string]*APIServerConfig `yaml:"api_servers"` } // 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) ) type CommandLineMode string const ( APIMode CommandLineMode = "api" AdminMode CommandLineMode = "admin" IngestorMode CommandLineMode = "ingestor" // If we want to access ingestor CollectorMode CommandLineMode = "collector" // If we want to access collector DefaultAPIServer = "default" ) type CommandLineConfig struct { CLIMode CommandLineMode AdminClientConfig *AdminModeConfig APIClientConfig APIModeConfig ShowHelp bool Verbose bool Interactive bool OutputFormat OutputFormat Command *string } type AdminModeConfig struct { AdminHost string AdminPort int AdminName *string AdminPassword *string //AdminCommand *string } type APIModeConfig struct { CurrentAPIServer string APIServerMap map[string]*APIServerConfig } func (c *CommandLineConfig) Print() { b, err := json.MarshalIndent(c, "", " ") if err == nil { fmt.Println(string(b)) } } func ParseArgs(args []string) (*CommandLineConfig, error) { commandLineConfig := &CommandLineConfig{ CLIMode: APIMode, AdminClientConfig: nil, ShowHelp: false, Verbose: false, Interactive: true, OutputFormat: OutputFormatTable, Command: nil, } for i := 0; i < len(args); i++ { arg := args[i] switch arg { case "-o", "--output": // Parse output format if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { format := args[i+1] switch format { case "plain": commandLineConfig.OutputFormat = OutputFormatPlain case "json": commandLineConfig.OutputFormat = OutputFormatJSON default: commandLineConfig.OutputFormat = OutputFormatTable } i++ } case "-v", "--verbose": commandLineConfig.Verbose = true case "--admin", "-admin": commandLineConfig.CLIMode = AdminMode case "--help", "-help": commandLineConfig.ShowHelp = true default: if !strings.HasPrefix(arg, "-") { commandLineConfig.Interactive = false } } } var commandArgs []string var foundCommand bool switch commandLineConfig.CLIMode { case APIMode: defaultApiServerConfig := &APIServerConfig{ UserName: nil, UserPassword: nil, ApiToken: nil, } configFile := "rf.yml" for i := 0; i < len(args); i++ { arg := args[i] // Handle known global flags (already parsed in first pass). // Intercept here regardless of position so they are never // mistaken for command args or unknown flags downstream. switch arg { case "-o", "--output": if i+1 < len(args) { i++ } continue case "-v", "--verbose", "--help", "-help": continue case "--admin", "-admin": return nil, fmt.Errorf("unexpected parameter: --admin") } // If we've found the command, collect remaining args if foundCommand { commandArgs = append(commandArgs, 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) } defaultApiServerConfig.IP = h defaultApiServerConfig.Port = port i++ } case "-t", "--token": if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { defaultApiServerConfig.ApiToken = &args[i+1] i++ } case "-u", "--user": if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { defaultApiServerConfig.UserName = &args[i+1] i++ } case "-p", "--password": if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { defaultApiServerConfig.UserPassword = &args[i+1] i++ } case "-f", "--config": if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { configFile = args[i+1] // Convert to absolute path immediately if !filepath.IsAbs(configFile) { absPath, err := filepath.Abs(configFile) if err == nil { configFile = absPath } } i++ } default: // Non-flag argument (command) if !strings.HasPrefix(arg, "-") { commandArgs = append(commandArgs, arg) foundCommand = true } } } var config ConfigFile data, err := os.ReadFile(configFile) if err == nil { if err = yaml.Unmarshal(data, &config); err != nil { return nil, fmt.Errorf("failed to parse rf.yml: %v", err) } if config.Host != "" { var h string var port int h, port, err = parseHostPort(config.Host) if err != nil { return nil, fmt.Errorf("invalid host in config file: %v", err) } if defaultApiServerConfig.IP == "" { defaultApiServerConfig.IP = h } if defaultApiServerConfig.Port == 0 { defaultApiServerConfig.Port = port } } if config.UserName != "" { if defaultApiServerConfig.UserName == nil { defaultApiServerConfig.UserName = &config.UserName } } if config.Password != "" { if defaultApiServerConfig.UserPassword == nil { defaultApiServerConfig.UserPassword = &config.Password } } if config.APIToken != "" { if defaultApiServerConfig.ApiToken == nil { defaultApiServerConfig.ApiToken = &config.APIToken } } } else { if configFile == "rf.yml" && os.IsNotExist(err) { } else { return nil, fmt.Errorf("failed to read %s: %v", configFile, err) } } if defaultApiServerConfig.IP == "" { defaultApiServerConfig.IP = "127.0.0.1" } if defaultApiServerConfig.Port == 0 { defaultApiServerConfig.Port = 9384 } commandLineConfig.APIClientConfig.APIServerMap = config.APIServerMap if commandLineConfig.APIClientConfig.APIServerMap == nil { commandLineConfig.APIClientConfig.APIServerMap = make(map[string]*APIServerConfig) } if commandLineConfig.APIClientConfig.APIServerMap[DefaultAPIServer] != nil { return nil, fmt.Errorf("'Default' API server config should be in api_servers") } commandLineConfig.APIClientConfig.APIServerMap[DefaultAPIServer] = defaultApiServerConfig commandLineConfig.APIClientConfig.CurrentAPIServer = DefaultAPIServer case AdminMode: AdminConfig := &AdminModeConfig{ AdminHost: "127.0.0.1", AdminPort: 9383, //AdminName: "admin@ragflow.io", //AdminPassword: "admin", } for i := 0; i < len(args); i++ { arg := args[i] // Handle known global flags regardless of position switch arg { case "-o", "--output": if i+1 < len(args) { i++ } continue case "-v", "--verbose", "--admin", "-admin", "--help", "-help": continue case "-t", "--token": return nil, fmt.Errorf("token is invalid in admin mode") case "-f", "--config": return nil, fmt.Errorf("config is invalid in admin mode") } // If we've found the command, collect remaining args if foundCommand { commandArgs = append(commandArgs, 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) } AdminConfig.AdminHost = h AdminConfig.AdminPort = port i++ } case "-u", "--user": if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { AdminConfig.AdminName = &args[i+1] i++ } case "-p", "--password": if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { AdminConfig.AdminPassword = &args[i+1] i++ } default: // Non-flag argument (command) if !strings.HasPrefix(arg, "-") { commandArgs = append(commandArgs, arg) foundCommand = true } } } commandLineConfig.AdminClientConfig = AdminConfig } commandArgsLen := len(commandArgs) if commandArgsLen > 0 { if commandArgsLen == 1 { commandLineConfig.Command = &commandArgs[0] } else { ApiCommand := strings.Join(commandArgs, " ") commandLineConfig.Command = &ApiCommand } } return commandLineConfig, nil } // 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 } // 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 { running bool line *liner.State APIServerClientMap map[string]*HTTPClient AdminServerClient *HTTPClient PasswordPrompt PasswordPromptFunc // Function for password input ContextEngine *filesystem.Engine // Context Engine for virtual filesystem CurrentModel *CurrentModel // Current model configuration Config *CommandLineConfig } // NewCLI creates a new CLI instance //func NewCLI() (*CLI, error) { // return NewCLIWithArgs(nil) //} func NewCLIWithConfig(commandLineConfig *CommandLineConfig) (*CLI, error) { // Create liner first line := liner.NewLiner() cli := &CLI{ line: line, Config: commandLineConfig, } if commandLineConfig.CLIMode == APIMode { apiServerConfig := commandLineConfig.APIClientConfig.APIServerMap[commandLineConfig.APIClientConfig.CurrentAPIServer] httpClient := NewHTTPClient() httpClient.Host = apiServerConfig.IP httpClient.Port = apiServerConfig.Port if apiServerConfig.ApiToken != nil { httpClient.APIToken = apiServerConfig.ApiToken httpClient.useAPIToken = true } cli.APIServerClientMap = map[string]*HTTPClient{ cli.Config.APIClientConfig.CurrentAPIServer: httpClient, } // Auto-login if user and password are provided (from config file) if apiServerConfig.UserName != nil && apiServerConfig.UserPassword != nil && apiServerConfig.ApiToken == nil { if err := cli.LoginUserInteractive(*apiServerConfig.UserName, *apiServerConfig.UserPassword); err != nil { line.Close() return nil, fmt.Errorf("auto-login failed: %w", err) } } engine := filesystem.NewEngine() // Register providers // TODO: if http config change, engine http config won't be updated. They should share the same config engine.RegisterProvider(filesystem.NewDatasetProvider(&httpClientAdapter{httpClient})) engine.RegisterProvider(filesystem.NewFileProvider(&httpClientAdapter{httpClient})) engine.RegisterProvider(filesystem.NewSkillProvider(&httpClientAdapter{httpClient})) cli.ContextEngine = engine } else if commandLineConfig.CLIMode == AdminMode { httpClient := NewHTTPClient() httpClient.Host = commandLineConfig.AdminClientConfig.AdminHost httpClient.Port = commandLineConfig.AdminClientConfig.AdminPort cli.AdminServerClient = httpClient adminServerConfig := commandLineConfig.AdminClientConfig // Auto-login if user and password are provided (from config file) if adminServerConfig.AdminName != nil && adminServerConfig.AdminPassword != nil { if err := cli.LoginUserInteractive(*adminServerConfig.AdminName, *adminServerConfig.AdminPassword); err != nil { line.Close() return nil, fmt.Errorf("auto-login failed: %w", err) } } } else { return nil, fmt.Errorf("invalid CLI mode: %s", commandLineConfig.CLIMode) } return cli, nil } // Run starts the interactive CLI func (c *CLI) NewRun() error { // If username is provided without password, prompt for password cliConfig := c.Config switch cliConfig.CLIMode { case APIMode: apiConfig := c.Config.APIClientConfig.APIServerMap[c.Config.APIClientConfig.CurrentAPIServer] if apiConfig.UserName != nil && apiConfig.UserPassword == nil && apiConfig.ApiToken == nil { // provider username but no password or api token maxAttempts := 3 for attempt := 1; attempt <= maxAttempts; attempt++ { fmt.Printf("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") } apiConfig.UserPassword = &password if err = c.VerifyAuth(*apiConfig.UserName, *apiConfig.UserPassword); err != nil { if attempt < maxAttempts { fmt.Printf("Authentication failed (%d/%d attempts)\n", attempt, maxAttempts) continue } return fmt.Errorf("authentication failed after %d attempts", maxAttempts) } break } } case AdminMode: adminConfig := c.Config.AdminClientConfig if adminConfig.AdminName != nil && adminConfig.AdminPassword == nil { // provider username but no password or api token maxAttempts := 3 for attempt := 1; attempt <= maxAttempts; attempt++ { fmt.Printf("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") } adminConfig.AdminPassword = &password if err = c.VerifyAuth(*adminConfig.AdminName, *adminConfig.AdminPassword); err != nil { if attempt < maxAttempts { fmt.Printf("Authentication failed (%d/%d attempts)\n", attempt, maxAttempts) continue } return fmt.Errorf("authentication failed after %d attempts", maxAttempts) } break } } default: return fmt.Errorf("unexpected CLI mode: %s", cliConfig.CLIMode) } 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 { var prompt string switch cliConfig.CLIMode { case APIMode: prompt = fmt.Sprintf("RAGFlow(api/%s)> ", c.Config.APIClientConfig.CurrentAPIServer) case AdminMode: prompt = "RAGFlow(admin)> " default: return fmt.Errorf("unexpected CLI mode: %s", cliConfig.CLIMode) } input, err := c.line.Prompt(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.Config.CLIMode) 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.ExecuteCommand(cmd) if result != nil { result.PrintOut() } return err } // executeFilesystem executes a Filesystem command and returns a ResponseIf. func (c *CLI) executeFilesystem(cmd *Command) (ResponseIf, error) { rawInput, _ := cmd.Params["command"].(string) r, w, err := os.Pipe() if err != nil { return nil, fmt.Errorf("create stdout pipe: %w", err) } old := os.Stdout os.Stdout = w defer func() { os.Stdout = old _ = w.Close() _ = r.Close() }() var buf strings.Builder copyErrCh := make(chan error, 1) go func() { _, copyErr := io.Copy(&buf, r) copyErrCh <- copyErr }() execErr := c.executeFilesystemInner(rawInput) _ = w.Close() // signal EOF to reader goroutine copyErr := <-copyErrCh if copyErr != nil { return nil, fmt.Errorf("capture filesystem output: %w", copyErr) } return &FileSystemResponse{Output: buf.String()}, execErr } // executeFilesystemInner executes a Filesystem command and writes output to stdout. // It is called by executeFilesystem which captures the stdout output. func (c *CLI) executeFilesystemInner(input string) error { // Parse input into arguments var args []string // 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 httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer] 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.Config.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: 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: 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.Config.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() 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() } } // 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.executeNew(*command); err != nil { return err } return nil } // VerifyAuth verifies authentication if needed func (c *CLI) NewVerifyAuth(username, password *string) error { // Otherwise, use username/password authentication if username == nil { return fmt.Errorf("username is required") } if password == nil { return fmt.Errorf("password is required") } // Create login command with username and password cmd := NewCommand("login_user") cmd.Params["email"] = *username cmd.Params["password"] = *password _, err := c.ExecuteCommand(cmd) return err } // VerifyAuth verifies authentication if needed func (c *CLI) VerifyAuth(username, password string) error { // Otherwise, use username/password authentication if username == "" { return fmt.Errorf("username is required") } if password == "" { return fmt.Errorf("password is required") } // Create login command with username and password cmd := NewCommand("login_user") cmd.Params["email"] = username cmd.Params["password"] = password _, err := c.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) Datasets syntax (full filter set): search 'query' on datasets 'kb_names' [with