Files
ragflow/internal/cli/cli.go
Jin Hai 61cc5ffef2 Add api tokens commands of go admin cli (#13765)
### What problem does this PR solve?

- GENERATE TOKENS OF USER 'xxx@xxx.com'
- DROP KEY 'ragflow-yyyyy' OF 'xxx@xxx.com'
- LIST KEYS OF 'xxx@xxx.com'

### Type of change

- [x] New Feature (non-breaking change which adds functionality)

---------

Signed-off-by: Jin Hai <haijin.chn@gmail.com>
2026-03-25 21:39:14 +08:00

653 lines
17 KiB
Go

//
// 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 (
"errors"
"fmt"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"github.com/peterh/liner"
"golang.org/x/term"
"gopkg.in/yaml.v3"
)
// 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"`
}
// ConnectionArgs holds the parsed command line arguments
type ConnectionArgs struct {
Host string
Port int
Password string
APIToken string
UserName string
Command string
ShowHelp bool
AdminMode bool
}
// 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, and admin mode
var configFilePath string
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "--help" || arg == "-help" {
return &ConnectionArgs{ShowHelp: true}, nil
} else if (arg == "-f" || arg == "--config") && i+1 < len(args) {
configFilePath = args[i+1]
i++
}
}
// Load config file with priority: -f > rf.yml > none
var config *ConfigFile
var err error
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
}
}
// Parse arguments manually to support both short and long forms
// and to handle priority: command line > config file > defaults
// Build result from config file first (if exists), then override with command line flags
result := &ConnectionArgs{}
// Get non-flag arguments (command to execute)
var nonFlagArgs []string
// 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
}
// Override with command line flags (higher priority)
// Handle both short and long forms manually
for i := 0; i < len(args); i++ {
arg := args[i]
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 "--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)
}
}
}
// 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 {
result.Command = strings.Join(nonFlagArgs, " ")
}
return result, 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)
--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 like: LOGIN USER 'email'; LIST USERS; etc.
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
prompt string
running bool
line *liner.State
args *ConnectionArgs
}
// 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
}
}
// Set prompt based on server type
prompt := "RAGFlow(user)> "
if serverType == "admin" {
prompt = "RAGFlow(admin)> "
}
return &CLI{
prompt: prompt,
client: client,
line: line,
args: args,
}, 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 == "" {
// Allow 3 attempts for password verification
maxAttempts := 3
for attempt := 1; attempt <= maxAttempts; attempt++ {
var input string
var err error
// Check if terminal supports password masking
if term.IsTerminal(int(os.Stdin.Fd())) {
input, err = c.line.PasswordPrompt("Please input your password: ")
} else {
// Terminal doesn't support password masking, use regular prompt
fmt.Println("Warning: This terminal does not support secure password input")
input, err = c.line.Prompt("Please input your password (will be visible): ")
}
if err != nil {
fmt.Printf("Error reading input: %v\n", err)
return err
}
input = strings.TrimSpace(input)
if input == "" {
if attempt < maxAttempts {
fmt.Println("Password cannot be empty, please try again")
continue
}
return errors.New("no password provided after 3 attempts")
}
// Set the password for verification
c.args.Password = input
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)
}
// Authentication successful
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.execute(input); err != nil {
fmt.Printf("CLI error: %v\n", err)
}
}
return nil
}
func (c *CLI) execute(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) 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
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
CREATE TOKEN; - Create new API token
DROP TOKEN 'token_value'; - Delete an API token
SET TOKEN 'token_value'; - Set and validate API token
SHOW TOKEN; - Show current API token
UNSET TOKEN; - Remove current API token
Commands (Admin Mode):
LIST USERS; - List all users
SHOW USER 'email'; - Show user details
CREATE USER 'email' 'password'; - Create new user
DROP USER 'email'; - Delete user
ALTER USER PASSWORD 'email' 'new_password'; - Change user password
ALTER USER ACTIVE 'email' on/off; - Activate/deactivate user
GRANT ADMIN 'email'; - Grant admin role
REVOKE ADMIN 'email'; - Revoke admin role
LIST SERVICES; - List services
SHOW SERVICE <id>; - Show service details
PING; - Ping server
... and many more
Meta Commands:
\? or \h - Show this help
\q or \quit - Exit CLI
\c or \clear - Clear screen
For more information, see documentation.
`
fmt.Println(help)
}
// Cleanup performs cleanup before exit
func (c *CLI) Cleanup() {
fmt.Println("\nCleaning up...")
}
// 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 {
// 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
}