From 417f805bd93e383b63a84e9ec82926c2a8d2c9a1 Mon Sep 17 00:00:00 2001 From: Jin Hai Date: Mon, 15 Jun 2026 16:37:47 +0800 Subject: [PATCH] Go: add API mode check in file system command (#16022) ### What problem does this PR solve? As title. ### Type of change - [x] Bug Fix (non-breaking change which fixes an issue) Signed-off-by: Jin Hai --- cmd/ragflow_cli.go | 2 +- internal/cli/cli.go | 667 +--------------------------- internal/cli/cli_http.go | 2 +- internal/cli/filesystem_command.go | 688 +++++++++++++++++++++++++++++ 4 files changed, 694 insertions(+), 665 deletions(-) create mode 100644 internal/cli/filesystem_command.go diff --git a/cmd/ragflow_cli.go b/cmd/ragflow_cli.go index da6941e764..9f5f7fb961 100644 --- a/cmd/ragflow_cli.go +++ b/cmd/ragflow_cli.go @@ -69,7 +69,7 @@ func main() { os.Exit(1) } } else { - if err = client.NewRun(); err != nil { + if err = client.Run(); err != nil { fmt.Printf("CLI error: %v\n", err) os.Exit(1) } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 2cd3d6a71b..6841187202 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -17,18 +17,14 @@ 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" @@ -553,7 +549,7 @@ func NewCLIWithConfig(commandLineConfig *CommandLineConfig) (*CLI, error) { } // Run starts the interactive CLI -func (c *CLI) NewRun() error { +func (c *CLI) Run() error { // If username is provided without password, prompt for password cliConfig := c.Config switch cliConfig.CLIMode { @@ -674,7 +670,7 @@ func (c *CLI) NewRun() error { c.line.AppendHistory(input) } - if err = c.executeNew(input); err != nil { + if err = c.execute(input); err != nil { fmt.Printf("CLI error: %v\n", err) } } @@ -682,7 +678,7 @@ func (c *CLI) NewRun() error { return nil } -func (c *CLI) executeNew(input string) error { +func (c *CLI) execute(input string) error { p := NewParser(input) cmd, err := p.Parse(c.Config.CLIMode) if err != nil { @@ -707,498 +703,6 @@ func (c *CLI) executeNew(input string) error { 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) @@ -1303,7 +807,7 @@ func (c *CLI) RunSingleCommand(command *string) error { defer c.Cleanup() // Execute the command - if err := c.executeNew(*command); err != nil { + if err := c.execute(*command); err != nil { return err } return nil @@ -1347,98 +851,6 @@ func (c *CLI) VerifyAuth(username, password string) error { 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] @@ -1507,74 +919,3 @@ Datasets syntax (full filter set): ` fmt.Println(help) } - -// printListHelp prints help for the list/ls command -func printListHelp() { - help := `List command usage: ls [path] [options] - -List contents of a path in the context filesystem. - -Arguments: - [path] Path to list (default: root - shows all providers and folders) - Examples: datasets, datasets/kb1, myfolder - -Options: - -n, --limit Maximum number of items to display (default: 10) - Example: -n 20 - -h, --help Show this help message - -Examples: - ls # List root (all providers and file_manager folders) - ls datasets # List all datasets - ls datasets/kb1 # List files in kb1 dataset (default 10 items) - ls myfolder # List files in file_manager folder 'myfolder' - ls -n 5 # List 5 items at root -` - fmt.Println(help) -} - -// parseListCommandArgs parses list/ls command arguments -// Format: ls [path] [-n limit] [-h|--help] -func parseListCommandArgs(args []string) (*ListCommandOptions, error) { - opts := &ListCommandOptions{ - Path: "", // Empty path means list root (all providers and file_manager folders) - Limit: 10, - } - - // Check for help flag - for _, arg := range args { - if arg == "-h" || arg == "--help" { - printListHelp() - return nil, nil - } - } - - // Parse arguments - i := 0 - for i < len(args) { - arg := args[i] - - switch arg { - case "-n", "--limit": - if i+1 >= len(args) { - return nil, fmt.Errorf("missing value for %s flag", arg) - } - limit, err := strconv.Atoi(args[i+1]) - if err != nil { - return nil, fmt.Errorf("invalid limit value: %s", args[i+1]) - } - opts.Limit = limit - i += 2 - default: - // If it doesn't start with -, treat as path - if !strings.HasPrefix(arg, "-") { - opts.Path = arg - } else { - return nil, fmt.Errorf("unknown flag: %s", arg) - } - i++ - } - } - - return opts, nil -} diff --git a/internal/cli/cli_http.go b/internal/cli/cli_http.go index 6c94a0816f..f23a59ade4 100644 --- a/internal/cli/cli_http.go +++ b/internal/cli/cli_http.go @@ -328,7 +328,7 @@ func (c *CLI) ExecuteUserCommand(cmd *Command) (ResponseIf, error) { case "save_config_command": return c.SaveServerConfig(cmd) case "file_system_command": - return c.executeFilesystem(cmd) + return c.ExecuteFilesystemCommand(cmd) default: return nil, fmt.Errorf("command '%s' would be executed with API", cmd.Type) } diff --git a/internal/cli/filesystem_command.go b/internal/cli/filesystem_command.go new file mode 100644 index 0000000000..353277047f --- /dev/null +++ b/internal/cli/filesystem_command.go @@ -0,0 +1,688 @@ +// +// 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" + "fmt" + "io" + "os" + "ragflow/internal/cli/filesystem" + "strconv" + "strings" + "unicode/utf8" +) + +// ExecuteFilesystemCommand ExecuteFilesystem executes a Filesystem command and returns a ResponseIf. +func (c *CLI) ExecuteFilesystemCommand(cmd *Command) (ResponseIf, error) { + if c.Config.CLIMode != APIMode { + return nil, fmt.Errorf("this command is only allowed in USER mode") + } + + 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() + } + } +} + +// 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 +} + +// printListHelp prints help for the list/ls command +func printListHelp() { + help := `List command usage: ls [path] [options] + +List contents of a path in the context filesystem. + +Arguments: + [path] Path to list (default: root - shows all providers and folders) + Examples: datasets, datasets/kb1, myfolder + +Options: + -n, --limit Maximum number of items to display (default: 10) + Example: -n 20 + -h, --help Show this help message + +Examples: + ls # List root (all providers and file_manager folders) + ls datasets # List all datasets + ls datasets/kb1 # List files in kb1 dataset (default 10 items) + ls myfolder # List files in file_manager folder 'myfolder' + ls -n 5 # List 5 items at root +` + fmt.Println(help) +} + +// parseListCommandArgs parses list/ls command arguments +// Format: ls [path] [-n limit] [-h|--help] +func parseListCommandArgs(args []string) (*ListCommandOptions, error) { + opts := &ListCommandOptions{ + Path: "", // Empty path means list root (all providers and file_manager folders) + Limit: 10, + } + + // Check for help flag + for _, arg := range args { + if arg == "-h" || arg == "--help" { + printListHelp() + return nil, nil + } + } + + // Parse arguments + i := 0 + for i < len(args) { + arg := args[i] + + switch arg { + case "-n", "--limit": + if i+1 >= len(args) { + return nil, fmt.Errorf("missing value for %s flag", arg) + } + limit, err := strconv.Atoi(args[i+1]) + if err != nil { + return nil, fmt.Errorf("invalid limit value: %s", args[i+1]) + } + opts.Limit = limit + i += 2 + default: + // If it doesn't start with -, treat as path + if !strings.HasPrefix(arg, "-") { + opts.Path = arg + } else { + return nil, fmt.Errorf("unknown flag: %s", arg) + } + i++ + } + } + + return opts, nil +}