diff --git a/bot.go b/bot.go index 78e6585..6c6cd31 100644 --- a/bot.go +++ b/bot.go @@ -6,7 +6,6 @@ import ( "compress/gzip" "context" "encoding/json" - "flag" "fmt" "gf-lt/config" "gf-lt/models" @@ -1771,20 +1770,4 @@ func init() { // atomic default values cachedModelColor.Store("orange") go chatWatcher(ctx) - // parse flags - flag.BoolVar(&cfg.CLIMode, "cli", false, "Run in CLI mode without TUI") - flag.BoolVar(&cfg.ToolUse, "tools", true, "run with tools") - flag.StringVar(&cfg.CurrentModel, "model", "modelname", "name of the model to use") - flag.StringVar(&cliCardPath, "card", "", "Path to syscard JSON file") - flag.BoolVar(&cliContinue, "continue", false, "Continue from last chat (by agent or card)") - flag.StringVar(&cliMsg, "msg", "", "Send message and exit (one-shot mode)") - flag.Parse() - if !cfg.CLIMode { - initTUI() - } - chatBody.Model = cfg.CurrentModel - go updateModelLists() - tools.InitTools(cfg, logger, store) - // tooler = tools.InitTools(cfg, logger, store) - // tooler.RegisterWindowTools(modelHasVision) } diff --git a/config/config.go b/config/config.go index 9d2dcaa..6b2dfae 100644 --- a/config/config.go +++ b/config/config.go @@ -30,6 +30,7 @@ type Config struct { DBPATH string `toml:"DBPATH"` FilePickerDir string `toml:"FilePickerDir"` FilePickerExts string `toml:"FilePickerExts"` + FSAllowOutOfRoot bool `toml:"FSAllowOutOfRoot"` ImagePreview bool `toml:"ImagePreview"` EnableMouse bool `toml:"EnableMouse"` // embeddings diff --git a/main.go b/main.go index 61f9116..d252b4a 100644 --- a/main.go +++ b/main.go @@ -2,9 +2,11 @@ package main import ( "bufio" + "flag" "fmt" "gf-lt/models" "gf-lt/pngmeta" + "gf-lt/tools" "os" "slices" "strconv" @@ -36,6 +38,22 @@ var ( ) func main() { + // parse flags + flag.BoolVar(&cfg.CLIMode, "cli", false, "Run in CLI mode without TUI") + flag.BoolVar(&cfg.ToolUse, "tools", true, "run with tools") + flag.StringVar(&cfg.CurrentModel, "model", "modelname", "name of the model to use") + flag.StringVar(&cliCardPath, "card", "", "Path to syscard JSON file") + flag.BoolVar(&cliContinue, "continue", false, "Continue from last chat (by agent or card)") + flag.StringVar(&cliMsg, "msg", "", "Send message and exit (one-shot mode)") + flag.Parse() + if !cfg.CLIMode { + initTUI() + } + chatBody.Model = cfg.CurrentModel + go updateModelLists() + tools.InitTools(cfg, logger, store) + // tooler = tools.InitTools(cfg, logger, store) + // tooler.RegisterWindowTools(modelHasVision) if cfg.CLIMode { runCLIMode() return diff --git a/props_table.go b/props_table.go index 3cf796c..81dfb64 100644 --- a/props_table.go +++ b/props_table.go @@ -126,6 +126,9 @@ func makePropsTable(props map[string]float32) *tview.Table { addCheckboxRow("Image Preview (file picker)", cfg.ImagePreview, func(checked bool) { cfg.ImagePreview = checked }) + addCheckboxRow("Allow FS out of root", cfg.FSAllowOutOfRoot, func(checked bool) { + cfg.FSAllowOutOfRoot = checked + }) addCheckboxRow("Auto turn (for cards with many chars)", cfg.AutoTurn, func(checked bool) { cfg.AutoTurn = checked }) diff --git a/rag/rag_integration_test.go b/rag/rag_integration_test.go index 39c5011..e364b4d 100644 --- a/rag/rag_integration_test.go +++ b/rag/rag_integration_test.go @@ -54,6 +54,12 @@ func (d dummyStore) Recall(agent, topic string) (string, error) { return func (d dummyStore) RecallTopics(agent string) ([]string, error) { return nil, nil } func (d dummyStore) Forget(agent, topic string) error { return nil } +// TableLister method +func (d dummyStore) ListTables() ([]string, error) { return nil, nil } +func (d dummyStore) GetTableColumns(table string) ([]storage.TableColumn, error) { + return nil, nil +} + // VectorRepo methods (not used but required by interface) func (d dummyStore) WriteVector(row *models.VectorRow) error { return nil } func (d dummyStore) SearchClosest(q []float32, limit int) ([]models.VectorRow, error) { diff --git a/tools/chain.go b/tools/chain.go index ebb5209..e2db232 100644 --- a/tools/chain.go +++ b/tools/chain.go @@ -135,8 +135,7 @@ func ExecChain(command string) string { return "[error] empty command" } - // Handle redirects: find the segment with OpRedirect or OpAppend - // The NEXT segment (if any) is the target file + // Check if we have a redirect var redirectTo string var isAppend bool redirectIdx := -1 @@ -149,25 +148,30 @@ func ExecChain(command string) string { } if redirectIdx >= 0 && redirectIdx+1 < len(segments) { - // The segment after redirect is the target path targetPath, err := resolveRedirectPath(segments[redirectIdx+1].Raw) if err != nil { return fmt.Sprintf("[error] redirect: %v", err) } redirectTo = targetPath - // Get the redirect command BEFORE removing segments redirectCmd := segments[redirectIdx].Raw + + // Check if there's chaining after redirect by looking at the operator of the segment AFTER the target + // The segment at redirectIdx+1 is the target file, and its Op tells us what operator follows + hasChainingAfterRedirect := false + if redirectIdx+1 < len(segments) && segments[redirectIdx+1].Op != OpNone && segments[redirectIdx+1].Op != OpPipe { + hasChainingAfterRedirect = true + } + // Remove both the redirect segment and its target segments = append(segments[:redirectIdx], segments[redirectIdx+2:]...) - // Execute the redirect command explicitly + // Execute the redirect command var lastOutput string var lastErr error lastOutput, lastErr = execSingle(redirectCmd, "") if lastErr != nil { return fmt.Sprintf("[error] redirect: %v", lastErr) } - // Write output to file if err := writeFile(redirectTo, lastOutput, isAppend); err != nil { return fmt.Sprintf("[error] redirect: %v", err) } @@ -176,9 +180,25 @@ func ExecChain(command string) string { mode = "Appended" } size := humanSizeChain(int64(len(lastOutput))) - return fmt.Sprintf("%s %s → %s", mode, size, filepath.Base(redirectTo)) + redirectResult := fmt.Sprintf("%s %s → %s", mode, size, filepath.Base(redirectTo)) + + // If no remaining segments or no chaining, just return the write confirmation + if len(segments) == 0 || !hasChainingAfterRedirect { + return redirectResult + } + + // There are remaining commands after the redirect + collected := []string{redirectResult} + + // Execute remaining commands + for _, seg := range segments { + lastOutput, lastErr = execSingle(seg.Raw, "") + if lastOutput != "" { + collected = append(collected, lastOutput) + } + } + return strings.Join(collected, "\n") } else if redirectIdx >= 0 && redirectIdx+1 >= len(segments) { - // Redirect but no target file return "[error] redirect: target file required" } diff --git a/tools/fs.go b/tools/fs.go index 8db0c26..e52010b 100644 --- a/tools/fs.go +++ b/tools/fs.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "sort" "strconv" "strings" @@ -61,16 +62,17 @@ func resolvePath(rel string) (string, error) { if cfg.FilePickerDir == "" { return "", errors.New("fs root not set") } - if filepath.IsAbs(rel) { + isAbs := filepath.IsAbs(rel) + if isAbs { abs := filepath.Clean(rel) - if !strings.HasPrefix(abs, cfg.FilePickerDir+string(os.PathSeparator)) && abs != cfg.FilePickerDir { + if !cfg.FSAllowOutOfRoot && !strings.HasPrefix(abs, cfg.FilePickerDir+string(os.PathSeparator)) && abs != cfg.FilePickerDir { return "", fmt.Errorf("path escapes fs root: %s", rel) } return abs, nil } abs := filepath.Join(cfg.FilePickerDir, rel) abs = filepath.Clean(abs) - if !strings.HasPrefix(abs, cfg.FilePickerDir+string(os.PathSeparator)) && abs != cfg.FilePickerDir { + if !cfg.FSAllowOutOfRoot && !strings.HasPrefix(abs, cfg.FilePickerDir+string(os.PathSeparator)) && abs != cfg.FilePickerDir { return "", fmt.Errorf("path escapes fs root: %s", rel) } return abs, nil @@ -111,6 +113,59 @@ func FsLs(args []string, stdin string) string { dir = a } } + + hasGlob := strings.ContainsAny(dir, "*?[") + + if hasGlob { + absDir := cfg.FilePickerDir + if filepath.IsAbs(dir) { + absDir = filepath.Dir(dir) + } else if strings.Contains(dir, "/") { + absDir = filepath.Join(cfg.FilePickerDir, filepath.Dir(dir)) + } + globPattern := filepath.Base(dir) + fullPattern := filepath.Join(absDir, globPattern) + + matches, err := filepath.Glob(fullPattern) + if err != nil { + return fmt.Sprintf("[error] ls: %v", err) + } + if len(matches) == 0 { + return "[error] ls: no such file or directory" + } + var out strings.Builder + filter := func(name string) bool { + return showAll || !strings.HasPrefix(name, ".") + } + for _, match := range matches { + info, err := os.Stat(match) + if err != nil { + continue + } + name := filepath.Base(match) + if !filter(name) { + continue + } + if longFormat { + if info.IsDir() { + fmt.Fprintf(&out, "d %-8s %s/\n", "-", name) + } else { + fmt.Fprintf(&out, "f %-8s %s\n", humanSize(info.Size()), name) + } + } else { + if info.IsDir() { + fmt.Fprintf(&out, "%s/\n", name) + } else { + fmt.Fprintf(&out, "%s\n", name) + } + } + } + if out.Len() == 0 { + return "(empty directory)" + } + return strings.TrimRight(out.String(), "\n") + } + abs, err := resolvePath(dir) if err != nil { return fmt.Sprintf("[error] %v", err) @@ -153,36 +208,59 @@ func FsLs(args []string, stdin string) string { func FsCat(args []string, stdin string) string { b64 := false - var path string + var paths []string for _, a := range args { if a == "-b" || a == "--base64" { b64 = true - } else if path == "" { - path = a + } else if a != "" { + paths = append(paths, a) } } - if path == "" { + if len(paths) == 0 { if stdin != "" { return stdin } return "[error] usage: cat or cat (with stdin)" } - abs, err := resolvePath(path) - if err != nil { - return fmt.Sprintf("[error] %v", err) - } - data, err := os.ReadFile(abs) - if err != nil { - return fmt.Sprintf("[error] cat: %v", err) - } - if b64 { - result := base64.StdEncoding.EncodeToString(data) - if IsImageFile(path) { - result += fmt.Sprintf("\n![image](file://%s)", abs) + + var allFiles []string + for _, path := range paths { + if strings.ContainsAny(path, "*?[") { + matches, err := filepath.Glob(path) + if err != nil { + return fmt.Sprintf("[error] cat: %v", err) + } + allFiles = append(allFiles, matches...) + } else { + allFiles = append(allFiles, path) } - return result } - return string(data) + + if len(allFiles) == 0 { + return "[error] cat: no files found" + } + + var results []string + for _, path := range allFiles { + abs, err := resolvePath(path) + if err != nil { + return fmt.Sprintf("[error] %v", err) + } + data, err := os.ReadFile(abs) + if err != nil { + return fmt.Sprintf("[error] cat: %v", err) + } + if b64 { + result := base64.StdEncoding.EncodeToString(data) + if IsImageFile(path) { + result += fmt.Sprintf("\n![image](file://%s)", abs) + } + results = append(results, result) + } else { + results = append(results, string(data)) + } + } + return strings.Join(results, "") } func FsViewImg(args []string, stdin string) string { @@ -323,60 +401,211 @@ func FsRm(args []string, stdin string) string { if len(args) == 0 { return "[error] usage: rm " } - abs, err := resolvePath(args[0]) - if err != nil { - return fmt.Sprintf("[error] %v", err) + force := false + var paths []string + for _, a := range args { + if a == "-f" || a == "--force" { + force = true + } else if !strings.HasPrefix(a, "-") { + paths = append(paths, a) + } } - if err := os.RemoveAll(abs); err != nil { - return fmt.Sprintf("[error] rm: %v", err) + if len(paths) == 0 { + return "[error] usage: rm " } - return "Removed " + args[0] + + var removed []string + var errs []string + for _, path := range paths { + if strings.ContainsAny(path, "*?[") { + matches, err := filepath.Glob(path) + if err != nil { + if !force { + return fmt.Sprintf("[error] rm: %v", err) + } + continue + } + for _, m := range matches { + if err := os.RemoveAll(m); err != nil { + if !force { + errs = append(errs, fmt.Sprintf("%v", err)) + } + continue + } + removed = append(removed, m) + } + } else { + abs, err := resolvePath(path) + if err != nil { + if !force { + return fmt.Sprintf("[error] %v", err) + } + continue + } + if err := os.RemoveAll(abs); err != nil { + if !force { + return fmt.Sprintf("[error] rm: %v", err) + } + continue + } + removed = append(removed, path) + } + } + if len(removed) == 0 && len(errs) > 0 { + return "[error] rm: " + strings.Join(errs, "; ") + } + return "Removed " + strings.Join(removed, ", ") } func FsCp(args []string, stdin string) string { if len(args) < 2 { return "[error] usage: cp " } - srcAbs, err := resolvePath(args[0]) - if err != nil { - return fmt.Sprintf("[error] %v", err) + srcPattern := args[0] + dstPath := args[1] + + // Check if dst is an existing directory (ends with / or is a directory) + dstIsDir := strings.HasSuffix(dstPath, "/") + if !dstIsDir { + if info, err := os.Stat(dstPath); err == nil && info.IsDir() { + dstIsDir = true + } } - dstAbs, err := resolvePath(args[1]) - if err != nil { - return fmt.Sprintf("[error] %v", err) + + // Check for single file copy (no glob and dst doesn't end with / and is not an existing dir) + hasGlob := strings.ContainsAny(srcPattern, "*?[") + + // Single source file to a specific file path (not a glob, not a directory) + if !hasGlob && !dstIsDir { + // Check if destination is an existing file - if not, treat as single file copy + if info, err := os.Stat(dstPath); err != nil || !info.IsDir() { + srcAbs, err := resolvePath(srcPattern) + if err != nil { + return fmt.Sprintf("[error] %v", err) + } + data, err := os.ReadFile(srcAbs) + if err != nil { + return fmt.Sprintf("[error] cp read: %v", err) + } + if err := os.WriteFile(dstPath, data, 0o644); err != nil { + return fmt.Sprintf("[error] cp write: %v", err) + } + return fmt.Sprintf("Copied %s → %s (%s)", srcPattern, dstPath, humanSize(int64(len(data)))) + } } - data, err := os.ReadFile(srcAbs) - if err != nil { - return fmt.Sprintf("[error] cp read: %v", err) + + // Copy to directory (either glob, or explicit directory) + var srcFiles []string + if hasGlob { + matches, err := filepath.Glob(srcPattern) + if err != nil { + return fmt.Sprintf("[error] cp: %v", err) + } + if len(matches) == 0 { + return "[error] cp: no files match pattern" + } + srcFiles = matches + } else { + srcFiles = []string{srcPattern} } - if err := os.MkdirAll(filepath.Dir(dstAbs), 0o755); err != nil { - return fmt.Sprintf("[error] cp mkdir: %v", err) + + var results []string + for _, srcPath := range srcFiles { + srcAbs, err := resolvePath(srcPath) + if err != nil { + return fmt.Sprintf("[error] %v", err) + } + data, err := os.ReadFile(srcAbs) + if err != nil { + return fmt.Sprintf("[error] cp read: %v", err) + } + + dstAbs, err := resolvePath(filepath.Join(dstPath, filepath.Base(srcPath))) + if err != nil { + return fmt.Sprintf("[error] %v", err) + } + if err := os.MkdirAll(filepath.Dir(dstAbs), 0o755); err != nil { + return fmt.Sprintf("[error] cp mkdir: %v", err) + } + if err := os.WriteFile(dstAbs, data, 0o644); err != nil { + return fmt.Sprintf("[error] cp write: %v", err) + } + results = append(results, fmt.Sprintf("%s → %s (%s)", srcPath, filepath.Join(dstPath, filepath.Base(srcPath)), humanSize(int64(len(data))))) } - if err := os.WriteFile(dstAbs, data, 0o644); err != nil { - return fmt.Sprintf("[error] cp write: %v", err) - } - return fmt.Sprintf("Copied %s → %s (%s)", args[0], args[1], humanSize(int64(len(data)))) + return strings.Join(results, ", ") } func FsMv(args []string, stdin string) string { if len(args) < 2 { return "[error] usage: mv " } - srcAbs, err := resolvePath(args[0]) - if err != nil { - return fmt.Sprintf("[error] %v", err) + srcPattern := args[0] + dstPath := args[1] + + // Check if dst is an existing directory (ends with / or is a directory) + dstIsDir := strings.HasSuffix(dstPath, "/") + if !dstIsDir { + if info, err := os.Stat(dstPath); err == nil && info.IsDir() { + dstIsDir = true + } } - dstAbs, err := resolvePath(args[1]) - if err != nil { - return fmt.Sprintf("[error] %v", err) + + // Check for single file move (no glob and dst doesn't end with / and is not an existing dir) + hasGlob := strings.ContainsAny(srcPattern, "*?[") + + // Single source file to a specific file path (not a glob, not a directory) + if !hasGlob && !dstIsDir { + // Check if destination is an existing file - if not, treat as single file move + if info, err := os.Stat(dstPath); err != nil || !info.IsDir() { + srcAbs, err := resolvePath(srcPattern) + if err != nil { + return fmt.Sprintf("[error] %v", err) + } + if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil { + return fmt.Sprintf("[error] mv mkdir: %v", err) + } + if err := os.Rename(srcAbs, dstPath); err != nil { + return fmt.Sprintf("[error] mv: %v", err) + } + return fmt.Sprintf("Moved %s → %s", srcPattern, dstPath) + } } - if err := os.MkdirAll(filepath.Dir(dstAbs), 0o755); err != nil { - return fmt.Sprintf("[error] mv mkdir: %v", err) + + // Move to directory (either glob, or explicit directory) + var srcFiles []string + if hasGlob { + matches, err := filepath.Glob(srcPattern) + if err != nil { + return fmt.Sprintf("[error] mv: %v", err) + } + if len(matches) == 0 { + return "[error] mv: no files match pattern" + } + srcFiles = matches + } else { + srcFiles = []string{srcPattern} } - if err := os.Rename(srcAbs, dstAbs); err != nil { - return fmt.Sprintf("[error] mv: %v", err) + + var results []string + for _, srcPath := range srcFiles { + srcAbs, err := resolvePath(srcPath) + if err != nil { + return fmt.Sprintf("[error] %v", err) + } + + dstAbs, err := resolvePath(filepath.Join(dstPath, filepath.Base(srcPath))) + if err != nil { + return fmt.Sprintf("[error] %v", err) + } + if err := os.MkdirAll(filepath.Dir(dstAbs), 0o755); err != nil { + return fmt.Sprintf("[error] mv mkdir: %v", err) + } + if err := os.Rename(srcAbs, dstAbs); err != nil { + return fmt.Sprintf("[error] mv: %v", err) + } + results = append(results, fmt.Sprintf("%s → %s", srcPath, filepath.Join(dstPath, filepath.Base(srcPath)))) } - return fmt.Sprintf("Moved %s → %s", args[0], args[1]) + return strings.Join(results, ", ") } func FsMkdir(args []string, stdin string) string { @@ -433,14 +662,31 @@ func FsTime(args []string, stdin string) string { func FsGrep(args []string, stdin string) string { if len(args) == 0 { - return "[error] usage: grep [-i] [-v] [-c] [file]" + return "[error] usage: grep [-i] [-v] [-c] [-E] [file]" } ignoreCase := false invert := false countOnly := false + useRegex := false var pattern string var filePath string for _, a := range args { + if strings.HasPrefix(a, "-") && !strings.HasPrefix(a, "--") && len(a) > 1 { + flags := strings.TrimLeft(a, "-") + for _, c := range flags { + switch c { + case 'i': + ignoreCase = true + case 'v': + invert = true + case 'c': + countOnly = true + case 'E': + useRegex = true + } + } + continue + } switch a { case "-i": ignoreCase = true @@ -448,6 +694,8 @@ func FsGrep(args []string, stdin string) string { invert = true case "-c": countOnly = true + case "-E": + useRegex = true default: if pattern == "" { pattern = a @@ -475,16 +723,32 @@ func FsGrep(args []string, stdin string) string { } else { return "[error] grep: no input (use file path or pipe from stdin)" } - if ignoreCase { - pattern = strings.ToLower(pattern) - } + var matched []string for _, line := range lines { - haystack := line - if ignoreCase { - haystack = strings.ToLower(line) + var match bool + if useRegex { + re, err := regexp.Compile(pattern) + if err != nil { + return fmt.Sprintf("[error] grep: invalid regex: %v", err) + } + match = re.MatchString(line) + if ignoreCase && !match { + reIC, err := regexp.Compile("(?i)" + pattern) + if err == nil { + match = reIC.MatchString(line) + } + } + } else { + haystack := line + if ignoreCase { + haystack = strings.ToLower(line) + patternLower := strings.ToLower(pattern) + match = strings.Contains(haystack, patternLower) + } else { + match = strings.Contains(haystack, pattern) + } } - match := strings.Contains(haystack, pattern) if invert { match = !match } diff --git a/tools/unix_test.go b/tools/unix_test.go new file mode 100644 index 0000000..1590a0d --- /dev/null +++ b/tools/unix_test.go @@ -0,0 +1,316 @@ +package tools + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "gf-lt/config" +) + +func init() { + cfg = &config.Config{} + cwd, _ := os.Getwd() + if strings.HasSuffix(cwd, "/tools") || strings.HasSuffix(cwd, "\\tools") { + cwd = filepath.Dir(cwd) + } + cfg.FilePickerDir = cwd +} + +func TestUnixGlobExpansion(t *testing.T) { + tmpDir := filepath.Join(cfg.FilePickerDir, "test_glob_tmp") + os.MkdirAll(tmpDir, 0755) + defer os.RemoveAll(tmpDir) + + os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("content1"), 0644) + os.WriteFile(filepath.Join(tmpDir, "file2.txt"), []byte("content2"), 0644) + os.WriteFile(filepath.Join(tmpDir, "file3.log"), []byte("content3"), 0644) + + tests := []struct { + name string + cmd string + wantErr bool + check func(string) bool + }{ + { + name: "ls glob txt files", + cmd: "ls " + tmpDir + "/*.txt", + wantErr: false, + check: func(r string) bool { return strings.Contains(r, "file1.txt") && strings.Contains(r, "file2.txt") }, + }, + { + name: "cat glob txt files", + cmd: "cat " + tmpDir + "/*.txt", + wantErr: false, + check: func(r string) bool { return strings.Contains(r, "content1") && strings.Contains(r, "content2") }, + }, + { + name: "ls glob no matches", + cmd: "ls " + tmpDir + "/*.nonexistent", + wantErr: false, + check: func(r string) bool { return strings.Contains(r, "no such file") || strings.Contains(r, "(empty") }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ExecChain(tt.cmd) + if tt.wantErr && result == "" { + t.Errorf("expected error for %q, got empty", tt.cmd) + } + if !tt.check(result) { + t.Errorf("check failed for %q, got %q", tt.cmd, result) + } + }) + } +} + +func TestUnixCatMultipleFiles(t *testing.T) { + tmpDir := filepath.Join(cfg.FilePickerDir, "test_cat_multi") + os.MkdirAll(tmpDir, 0755) + defer os.RemoveAll(tmpDir) + + os.WriteFile(filepath.Join(tmpDir, "a.txt"), []byte("file a content\n"), 0644) + os.WriteFile(filepath.Join(tmpDir, "b.txt"), []byte("file b content\n"), 0644) + os.WriteFile(filepath.Join(tmpDir, "c.txt"), []byte("file c content\n"), 0644) + + tests := []struct { + name string + cmd string + check func(string) bool + }{ + { + name: "cat multiple files with paths", + cmd: "cat " + tmpDir + "/a.txt " + tmpDir + "/b.txt", + check: func(r string) bool { + return strings.Contains(r, "file a content") && strings.Contains(r, "file b content") + }, + }, + { + name: "cat three files", + cmd: "cat " + tmpDir + "/a.txt " + tmpDir + "/b.txt " + tmpDir + "/c.txt", + check: func(r string) bool { + return strings.Contains(r, "file a content") && strings.Contains(r, "file b content") && strings.Contains(r, "file c content") + }, + }, + { + name: "cat via shell with glob", + cmd: "cat " + tmpDir + "/*.txt", + check: func(r string) bool { + return strings.Contains(r, "file a content") && strings.Contains(r, "file b content") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ExecChain(tt.cmd) + if !tt.check(result) { + t.Errorf("check failed for %q, got %q", tt.cmd, result) + } + }) + } +} + +func TestUnixGrepPatternQuoting(t *testing.T) { + tmpDir := filepath.Join(cfg.FilePickerDir, "test_grep_quote") + os.MkdirAll(tmpDir, 0755) + defer os.RemoveAll(tmpDir) + + os.WriteFile(filepath.Join(tmpDir, "animals.txt"), []byte("dog\ncat\nbird\nfish\n"), 0644) + os.WriteFile(filepath.Join(tmpDir, "colors.txt"), []byte("red\nblue\ngreen\n"), 0644) + + tests := []struct { + name string + cmd string + check func(string) bool + }{ + { + name: "grep with double quotes OR pattern", + cmd: "grep -E \"dog|cat\" " + tmpDir + "/animals.txt", + check: func(r string) bool { return strings.Contains(r, "dog") && strings.Contains(r, "cat") }, + }, + { + name: "grep with single quotes OR pattern", + cmd: "grep -E 'dog|cat' " + tmpDir + "/animals.txt", + check: func(r string) bool { return strings.Contains(r, "dog") && strings.Contains(r, "cat") }, + }, + { + name: "grep case insensitive with quotes", + cmd: "grep -iE \"DOG|CAT\" " + tmpDir + "/animals.txt", + check: func(r string) bool { return strings.Contains(r, "dog") && strings.Contains(r, "cat") }, + }, + { + name: "grep piped from cat", + cmd: "cat " + tmpDir + "/animals.txt | grep -E \"dog|cat\"", + check: func(r string) bool { return strings.Contains(r, "dog") && strings.Contains(r, "cat") }, + }, + { + name: "grep with complex pattern", + cmd: "grep -E \"red|blue|green\" " + tmpDir + "/colors.txt", + check: func(r string) bool { + return strings.Contains(r, "red") && strings.Contains(r, "blue") && strings.Contains(r, "green") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ExecChain(tt.cmd) + if !tt.check(result) { + t.Errorf("check failed for %q, got %q", tt.cmd, result) + } + }) + } +} + +func TestUnixForLoop(t *testing.T) { + tmpDir := filepath.Join(cfg.FilePickerDir, "test_forloop") + os.MkdirAll(tmpDir, 0755) + defer os.RemoveAll(tmpDir) + + os.WriteFile(filepath.Join(tmpDir, "dog.txt"), []byte("I have a dog\n"), 0644) + os.WriteFile(filepath.Join(tmpDir, "cat.txt"), []byte("I have a cat\n"), 0644) + os.WriteFile(filepath.Join(tmpDir, "red.txt"), []byte("red color\n"), 0644) + + result := ExecChain("cd " + tmpDir + " && for f in *.txt; do echo \"file: $f\"; done") + if result == "" { + t.Error("empty result from for loop execution") + } + if strings.Contains(result, "file:") { + t.Logf("for loop is supported: %s", result) + } else { + t.Logf("for loops not supported (expected): %s", result) + } +} + +func TestUnixGlobWithFileOps(t *testing.T) { + tests := []struct { + name string + cmd string + setup func() string + check func(string) bool + }{ + { + name: "rm glob txt files", + cmd: "rm {dir}/*.txt", + setup: func() string { + tmpDir := filepath.Join(cfg.FilePickerDir, "test_rm_glob") + os.MkdirAll(tmpDir, 0755) + os.WriteFile(filepath.Join(tmpDir, "a.txt"), []byte("content"), 0644) + os.WriteFile(filepath.Join(tmpDir, "b.txt"), []byte("content"), 0644) + return tmpDir + }, + check: func(r string) bool { return !strings.Contains(r, "[error]") }, + }, + { + name: "cp glob to dest", + cmd: "cp {dir}/*.txt {dir}/dest/", + setup: func() string { + tmpDir := filepath.Join(cfg.FilePickerDir, "test_cp_glob") + os.MkdirAll(tmpDir, 0755) + os.MkdirAll(filepath.Join(tmpDir, "dest"), 0755) + os.WriteFile(filepath.Join(tmpDir, "a.txt"), []byte("content a"), 0644) + os.WriteFile(filepath.Join(tmpDir, "b.txt"), []byte("content b"), 0644) + return tmpDir + }, + check: func(r string) bool { return !strings.Contains(r, "[error]") }, + }, + { + name: "mv glob to dest", + cmd: "mv {dir}/*.log {dir}/dest/", + setup: func() string { + tmpDir := filepath.Join(cfg.FilePickerDir, "test_mv_glob") + os.MkdirAll(tmpDir, 0755) + os.MkdirAll(filepath.Join(tmpDir, "dest"), 0755) + os.WriteFile(filepath.Join(tmpDir, "c.log"), []byte("content c"), 0644) + return tmpDir + }, + check: func(r string) bool { return !strings.Contains(r, "[error]") }, + }, + { + name: "ls with flags and glob", + cmd: "ls -la {dir}/*.txt", + setup: func() string { + tmpDir := filepath.Join(cfg.FilePickerDir, "test_ls_glob") + os.MkdirAll(tmpDir, 0755) + os.WriteFile(filepath.Join(tmpDir, "a.txt"), []byte("content"), 0644) + os.WriteFile(filepath.Join(tmpDir, "b.txt"), []byte("content"), 0644) + return tmpDir + }, + check: func(r string) bool { return strings.Contains(r, "a.txt") || strings.Contains(r, "b.txt") }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := tt.setup() + defer os.RemoveAll(tmpDir) + cmd := strings.ReplaceAll(tt.cmd, "{dir}", tmpDir) + result := ExecChain(cmd) + if !tt.check(result) { + t.Errorf("check failed for %q, got %q", cmd, result) + } + }) + } +} + +func TestUnixComplexPiping(t *testing.T) { + tmpDir := filepath.Join(cfg.FilePickerDir, "test_pipe_complex") + os.MkdirAll(tmpDir, 0755) + defer os.RemoveAll(tmpDir) + + os.WriteFile(filepath.Join(tmpDir, "data.txt"), []byte("apple\nbanana\nAPPLE\ncherry\nbanana\n"), 0644) + + tests := []struct { + name string + cmd string + check func(string) bool + }{ + { + name: "cat | grep -i | sort", + cmd: "cat " + tmpDir + "/data.txt | grep -i apple | sort", + check: func(r string) bool { return strings.Contains(r, "apple") && !strings.Contains(r, "banana") }, + }, + { + name: "ls | wc -l", + cmd: "ls " + tmpDir + " | wc -l", + check: func(r string) bool { return strings.TrimSpace(r) == "1" }, + }, + { + name: "echo > file && cat file", + cmd: "echo 'hello world' > " + tmpDir + "/out.txt && cat " + tmpDir + "/out.txt", + check: func(r string) bool { return strings.Contains(r, "hello world") }, + }, + { + name: "grep file | head -2", + cmd: "grep a " + tmpDir + "/data.txt | head -2", + check: func(r string) bool { return strings.Contains(r, "apple") || strings.Contains(r, "banana") }, + }, + { + name: "cat | grep | wc -l", + cmd: "cat " + tmpDir + "/data.txt | grep -i apple | wc -l", + check: func(r string) bool { return strings.TrimSpace(r) == "2" }, + }, + { + name: "ls | grep txt | head -1", + cmd: "ls " + tmpDir + " | grep txt | head -1", + check: func(r string) bool { return strings.Contains(r, "data.txt") }, + }, + { + name: "echo | sed replacement", + cmd: "echo 'hello world' | sed 's/world/universe/'", + check: func(r string) bool { return strings.Contains(r, "hello universe") }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ExecChain(tt.cmd) + if !tt.check(result) { + t.Errorf("check failed for %q, got %q", tt.cmd, result) + } + }) + } +}