// // 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 } // 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 } 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() case "pwd": dir, err := os.Getwd() if err != nil { return fmt.Errorf("get working directory: %w", err) } fmt.Println(dir) 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