on main help sign in
feat: full windows support
cc96c20Raphael Amorim
README.md−1
@@ -10,7 +10,6 @@
10
10
11Crush is a tool for building software with AI.
11Crush is a tool for building software with AI.
12
12
13−
14## License
13## License
15
14
16[MIT](https://github.com/charmbracelet/crush/raw/main/LICENSE)
15[MIT](https://github.com/charmbracelet/crush/raw/main/LICENSE)
internal/llm/tools/bash.go+49−15
@@ -4,6 +4,7 @@ import (
4 "context"
4 "context"
5 "encoding/json"
5 "encoding/json"
6 "fmt"
6 "fmt"
7+ "runtime"
7 "strings"
8 "strings"
8 "time"
9 "time"
9
10
@@ -45,27 +46,58 @@ var bannedCommands = []string{
45 "http-prompt", "chrome", "firefox", "safari",
46 "http-prompt", "chrome", "firefox", "safari",
46}
47}
47
48
48−var safeReadOnlyCommands = []string{
49+// getSafeReadOnlyCommands returns platform-appropriate safe commands
49− "ls", "echo", "pwd", "date", "cal", "uptime", "whoami", "id", "groups", "env", "printenv", "set", "unset", "which", "type", "whereis",
50+func getSafeReadOnlyCommands() []string {
50− "whatis", "uname", "hostname", "df", "du", "free", "top", "ps", "kill", "killall", "nice", "nohup", "time", "timeout",
51+ // Base commands that work on all platforms
51−
52+ baseCommands := []string{
53+ // Cross-platform commands
54+ "echo", "hostname", "whoami",
55+
56+ // Git commands (cross-platform)
52− "git status", "git log", "git diff", "git show", "git branch", "git tag", "git remote", "git ls-files", "git ls-remote",
57+ "git status", "git log", "git diff", "git show", "git branch", "git tag", "git remote", "git ls-files", "git ls-remote",
53− "git rev-parse", "git config --get", "git config --list", "git describe", "git blame", "git grep", "git shortlog",
58+ "git rev-parse", "git config --get", "git config --list", "git describe", "git blame", "git grep", "git shortlog",
59+
60+ // Go commands (cross-platform)
61+ "go version", "go help", "go list", "go env", "go doc", "go vet", "go fmt", "go mod", "go test", "go build", "go run", "go install", "go clean",
62+ }
54
63
55− "go version", "go help", "go list", "go env", "go doc", "go vet", "go fmt", "go mod", "go test", "go build", "go run", "go install", "go clean",
64+ if runtime.GOOS == "windows" {
65+ // Windows-specific commands
66+ windowsCommands := []string{
67+ "dir", "type", "where", "ver", "systeminfo", "tasklist", "ipconfig", "ping", "nslookup",
68+ "Get-Process", "Get-Location", "Get-ChildItem", "Get-Content", "Get-Date", "Get-Host", "Get-ComputerInfo",
69+ }
70+ return append(baseCommands, windowsCommands...)
71+ } else {
72+ // Unix/Linux commands (including WSL, since WSL reports as Linux)
73+ unixCommands := []string{
74+ "ls", "pwd", "date", "cal", "uptime", "id", "groups", "env", "printenv", "set", "unset", "which", "type", "whereis",
75+ "whatis", "uname", "df", "du", "free", "top", "ps", "kill", "killall", "nice", "nohup", "time", "timeout",
76+ }
77+ return append(baseCommands, unixCommands...)
78+ }
56}
79}
57
80
58func bashDescription() string {
81func bashDescription() string {
59 bannedCommandsStr := strings.Join(bannedCommands, ", ")
82 bannedCommandsStr := strings.Join(bannedCommands, ", ")
60 return fmt.Sprintf(`Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
83 return fmt.Sprintf(`Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
61
84
62−IMPORTANT FOR WINDOWS USERS:
85+CROSS-PLATFORM SHELL SUPPORT:
63−- This tool uses a POSIX shell emulator (mvdan.cc/sh/v3) that works cross-platform, including Windows
86+- Unix/Linux/macOS: Uses native bash/sh shell
64−- On Windows, this provides bash-like functionality without requiring WSL or Git Bash
87+- Windows: Intelligent shell selection:
65−- Use forward slashes (/) in paths - they work on all platforms and are converted automatically
88+ * Windows commands (dir, type, copy, etc.) use cmd.exe
66−- Windows-specific commands (like 'dir', 'type', 'copy') are not available - use Unix equivalents ('ls', 'cat', 'cp')
89+ * PowerShell commands (Get-, Set-, etc.) use PowerShell
90+ * Unix-style commands (ls, cat, etc.) use POSIX emulation
91+- WSL: Automatically treated as Linux (which is correct)
92+- Automatic detection: Chooses the best shell based on command and platform
93+- Persistent state: Working directory and environment variables persist between commands
94+
95+WINDOWS-SPECIFIC FEATURES:
96+- Native Windows commands: dir, type, copy, move, del, md, rd, cls, where, tasklist, etc.
97+- PowerShell support: Get-Process, Set-Location, and other PowerShell cmdlets
98+- Windows path handling: Supports both forward slashes (/) and backslashes (\)
99+- Drive letters: Properly handles C:\, D:\, etc.
67−- Environment variables use Unix syntax: $VAR instead of %%VAR%%
100+- Environment variables: Supports both Unix ($VAR) and Windows (%%VAR%%) syntax
68−- File paths are automatically converted between Windows and Unix formats as needed
69
101
70Before executing the command, please follow these steps:
102Before executing the command, please follow these steps:
71
103
@@ -262,6 +294,8 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
262 isSafeReadOnly := false
294 isSafeReadOnly := false
263 cmdLower := strings.ToLower(params.Command)
295 cmdLower := strings.ToLower(params.Command)
264
296
297+ // Get platform-appropriate safe commands
298+ safeReadOnlyCommands := getSafeReadOnlyCommands()
265 for _, safe := range safeReadOnlyCommands {
299 for _, safe := range safeReadOnlyCommands {
266 if strings.HasPrefix(cmdLower, strings.ToLower(safe)) {
300 if strings.HasPrefix(cmdLower, strings.ToLower(safe)) {
267 if len(cmdLower) == len(safe) || cmdLower[len(safe)] == ' ' || cmdLower[len(safe)] == '-' {
301 if len(cmdLower) == len(safe) || cmdLower[len(safe)] == ' ' || cmdLower[len(safe)] == '-' {
@@ -361,4 +395,4 @@ func countLines(s string) int {
361 return 0
395 return 0
362 }
396 }
363 return len(strings.Split(s, "\n"))
397 return len(strings.Split(s, "\n"))
364−}
398+}
internal/llm/tools/bash_test.gonew file+125
@@ -0,0 +1,125 @@
1+package tools
2+
3+import (
4+ "runtime"
5+ "testing"
6+)
7+
8+func TestGetSafeReadOnlyCommands(t *testing.T) {
9+ commands := getSafeReadOnlyCommands()
10+
11+ // Check that we have some commands
12+ if len(commands) == 0 {
13+ t.Fatal("Expected some safe commands, got none")
14+ }
15+
16+ // Check for cross-platform commands that should always be present
17+ crossPlatformCommands := []string{"echo", "hostname", "whoami", "git status", "go version"}
18+ for _, cmd := range crossPlatformCommands {
19+ found := false
20+ for _, safeCmd := range commands {
21+ if safeCmd == cmd {
22+ found = true
23+ break
24+ }
25+ }
26+ if !found {
27+ t.Errorf("Expected cross-platform command %q to be in safe commands", cmd)
28+ }
29+ }
30+
31+ if runtime.GOOS == "windows" {
32+ // Check for Windows-specific commands
33+ windowsCommands := []string{"dir", "type", "Get-Process"}
34+ for _, cmd := range windowsCommands {
35+ found := false
36+ for _, safeCmd := range commands {
37+ if safeCmd == cmd {
38+ found = true
39+ break
40+ }
41+ }
42+ if !found {
43+ t.Errorf("Expected Windows command %q to be in safe commands on Windows", cmd)
44+ }
45+ }
46+
47+ // Check that Unix commands are NOT present on Windows
48+ unixCommands := []string{"ls", "pwd", "ps"}
49+ for _, cmd := range unixCommands {
50+ found := false
51+ for _, safeCmd := range commands {
52+ if safeCmd == cmd {
53+ found = true
54+ break
55+ }
56+ }
57+ if found {
58+ t.Errorf("Unix command %q should not be in safe commands on Windows", cmd)
59+ }
60+ }
61+ } else {
62+ // Check for Unix-specific commands
63+ unixCommands := []string{"ls", "pwd", "ps"}
64+ for _, cmd := range unixCommands {
65+ found := false
66+ for _, safeCmd := range commands {
67+ if safeCmd == cmd {
68+ found = true
69+ break
70+ }
71+ }
72+ if !found {
73+ t.Errorf("Expected Unix command %q to be in safe commands on Unix", cmd)
74+ }
75+ }
76+
77+ // Check that Windows-specific commands are NOT present on Unix
78+ windowsOnlyCommands := []string{"dir", "Get-Process", "systeminfo"}
79+ for _, cmd := range windowsOnlyCommands {
80+ found := false
81+ for _, safeCmd := range commands {
82+ if safeCmd == cmd {
83+ found = true
84+ break
85+ }
86+ }
87+ if found {
88+ t.Errorf("Windows-only command %q should not be in safe commands on Unix", cmd)
89+ }
90+ }
91+ }
92+}
93+
94+func TestPlatformSpecificSafeCommands(t *testing.T) {
95+ // Test that the function returns different results on different platforms
96+ commands := getSafeReadOnlyCommands()
97+
98+ hasWindowsCommands := false
99+ hasUnixCommands := false
100+
101+ for _, cmd := range commands {
102+ if cmd == "dir" || cmd == "Get-Process" || cmd == "systeminfo" {
103+ hasWindowsCommands = true
104+ }
105+ if cmd == "ls" || cmd == "ps" || cmd == "df" {
106+ hasUnixCommands = true
107+ }
108+ }
109+
110+ if runtime.GOOS == "windows" {
111+ if !hasWindowsCommands {
112+ t.Error("Expected Windows commands on Windows platform")
113+ }
114+ if hasUnixCommands {
115+ t.Error("Did not expect Unix commands on Windows platform")
116+ }
117+ } else {
118+ if hasWindowsCommands {
119+ t.Error("Did not expect Windows-only commands on Unix platform")
120+ }
121+ if !hasUnixCommands {
122+ t.Error("Expected Unix commands on Unix platform")
123+ }
124+ }
125+}
internal/llm/tools/glob.go+2−3
@@ -48,12 +48,11 @@ LIMITATIONS:
48- Hidden files (starting with '.') are skipped
48- Hidden files (starting with '.') are skipped
49
49
50WINDOWS NOTES:
50WINDOWS NOTES:
51−- Uses ripgrep (rg) command if available, otherwise falls back to built-in Go implementation
52−- On Windows, install ripgrep via: winget install BurntSushi.ripgrep.MSVC
53- Path separators are handled automatically (both / and \ work)
51- Path separators are handled automatically (both / and \ work)
54−- Patterns should use forward slashes (/) for cross-platform compatibility
52+- Uses ripgrep (rg) command if available, otherwise falls back to built-in Go implementation
55
53
56TIPS:
54TIPS:
55+- Patterns should use forward slashes (/) for cross-platform compatibility
57- For the most useful results, combine with the Grep tool: first find files with Glob, then search their contents with Grep
56- For the most useful results, combine with the Grep tool: first find files with Glob, then search their contents with Grep
58- When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead
57- When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead
59- Always check if results are truncated and refine your search pattern if needed`
58- Always check if results are truncated and refine your search pattern if needed`
internal/llm/tools/grep.go+2−3
@@ -124,11 +124,10 @@ LIMITATIONS:
124- Very large binary files may be skipped
124- Very large binary files may be skipped
125- Hidden files (starting with '.') are skipped
125- Hidden files (starting with '.') are skipped
126
126
127−WINDOWS NOTES:
127+CROSS-PLATFORM NOTES:
128- Uses ripgrep (rg) command if available for better performance
128- Uses ripgrep (rg) command if available for better performance
129−- On Windows, install ripgrep via: winget install BurntSushi.ripgrep.MSVC
130- Falls back to built-in Go implementation if ripgrep is not available
129- Falls back to built-in Go implementation if ripgrep is not available
131−- File paths are normalized automatically for Windows compatibility
130+- File paths are normalized automatically for cross-platform compatibility
132
131
133TIPS:
132TIPS:
134- For faster, more targeted searches, first use Glob to find relevant files, then use Grep
133- For faster, more targeted searches, first use Glob to find relevant files, then use Grep
internal/llm/tools/shell/shell.go+159−10
@@ -1,14 +1,11 @@
1// Package shell provides cross-platform shell execution capabilities.
1// Package shell provides cross-platform shell execution capabilities.
2//
2//
3−// WINDOWS COMPATIBILITY NOTE:
3+// WINDOWS COMPATIBILITY:
4−// This implementation uses mvdan.cc/sh/v3 which provides POSIX shell emulation
4+// This implementation provides both POSIX shell emulation (mvdan.cc/sh/v3) and
5−// on Windows. While this works for basic commands, it has limitations:
5+// native Windows shell support (cmd.exe/PowerShell) for optimal compatibility:
6−// - Windows-specific commands (dir, type, copy) are not available
7−// - PowerShell and cmd.exe specific features are not supported
8−// - Some Windows path handling may be inconsistent
9−//
10−// For full Windows compatibility, consider adding native Windows shell support
11−// using os/exec with cmd.exe or PowerShell for Windows-specific commands.
6+// - On Windows: Uses native cmd.exe or PowerShell for Windows-specific commands
7+// - Cross-platform: Falls back to POSIX emulation for Unix-style commands
8+// - Automatic detection: Chooses the best shell based on command and platform
12package shell
9package shell
13
10
14import (
11import (
@@ -17,6 +14,8 @@ import (
17 "errors"
14 "errors"
18 "fmt"
15 "fmt"
19 "os"
16 "os"
17+ "os/exec"
18+ "runtime"
20 "strings"
19 "strings"
21 "sync"
20 "sync"
22
21
@@ -26,6 +25,15 @@ import (
26 "mvdan.cc/sh/v3/syntax"
25 "mvdan.cc/sh/v3/syntax"
27)
26)
28
27
28+// ShellType represents the type of shell to use
29+type ShellType int
30+
31+const (
32+ ShellTypePOSIX ShellType = iota
33+ ShellTypeCmd
34+ ShellTypePowerShell
35+)
36+
29type PersistentShell struct {
37type PersistentShell struct {
30 env []string
38 env []string
31 cwd string
39 cwd string
@@ -37,6 +45,27 @@ var (
37 shellInstance *PersistentShell
45 shellInstance *PersistentShell
38)
46)
39
47
48+// Windows-specific commands that should use native shell
49+var windowsNativeCommands = map[string]bool{
50+ "dir": true,
51+ "type": true,
52+ "copy": true,
53+ "move": true,
54+ "del": true,
55+ "md": true,
56+ "mkdir": true,
57+ "rd": true,
58+ "rmdir": true,
59+ "cls": true,
60+ "where": true,
61+ "tasklist": true,
62+ "taskkill": true,
63+ "net": true,
64+ "sc": true,
65+ "reg": true,
66+ "wmic": true,
67+}
68+
40func GetPersistentShell(cwd string) *PersistentShell {
69func GetPersistentShell(cwd string) *PersistentShell {
41 once.Do(func() {
70 once.Do(func() {
42 shellInstance = newPersistentShell(cwd)
71 shellInstance = newPersistentShell(cwd)
@@ -55,6 +84,126 @@ func (s *PersistentShell) Exec(ctx context.Context, command string) (string, str
55 s.mu.Lock()
84 s.mu.Lock()
56 defer s.mu.Unlock()
85 defer s.mu.Unlock()
57
86
87+ // Determine which shell to use based on platform and command
88+ shellType := s.determineShellType(command)
89+
90+ switch shellType {
91+ case ShellTypeCmd:
92+ return s.execWindows(ctx, command, "cmd")
93+ case ShellTypePowerShell:
94+ return s.execWindows(ctx, command, "powershell")
95+ default:
96+ return s.execPOSIX(ctx, command)
97+ }
98+}
99+
100+// determineShellType decides which shell to use based on platform and command
101+func (s *PersistentShell) determineShellType(command string) ShellType {
102+ if runtime.GOOS != "windows" {
103+ return ShellTypePOSIX
104+ }
105+
106+ // Extract the first command from the command line
107+ parts := strings.Fields(command)
108+ if len(parts) == 0 {
109+ return ShellTypePOSIX
110+ }
111+
112+ firstCmd := strings.ToLower(parts[0])
113+
114+ // Check if it's a Windows-specific command
115+ if windowsNativeCommands[firstCmd] {
116+ return ShellTypeCmd
117+ }
118+
119+ // Check for PowerShell-specific syntax
120+ if strings.Contains(command, "Get-") || strings.Contains(command, "Set-") ||
121+ strings.Contains(command, "New-") || strings.Contains(command, "$_") ||
122+ strings.Contains(command, "| Where-Object") || strings.Contains(command, "| ForEach-Object") {
123+ return ShellTypePowerShell
124+ }
125+
126+ // Default to POSIX emulation for cross-platform compatibility
127+ return ShellTypePOSIX
128+}
129+
130+// execWindows executes commands using native Windows shells (cmd.exe or PowerShell)
131+func (s *PersistentShell) execWindows(ctx context.Context, command string, shell string) (string, string, error) {
132+ var cmd *exec.Cmd
133+
134+ // Handle directory changes specially to maintain persistent shell behavior
135+ if strings.HasPrefix(strings.TrimSpace(command), "cd ") {
136+ return s.handleWindowsCD(command)
137+ }
138+
139+ switch shell {
140+ case "cmd":
141+ // Use cmd.exe for Windows commands
142+ // Add current directory context to maintain state
143+ fullCommand := fmt.Sprintf("cd /d \"%s\" && %s", s.cwd, command)
144+ cmd = exec.CommandContext(ctx, "cmd", "/C", fullCommand)
145+ case "powershell":
146+ // Use PowerShell for PowerShell commands
147+ // Add current directory context to maintain state
148+ fullCommand := fmt.Sprintf("Set-Location '%s'; %s", s.cwd, command)
149+ cmd = exec.CommandContext(ctx, "powershell", "-Command", fullCommand)
150+ default:
151+ return "", "", fmt.Errorf("unsupported Windows shell: %s", shell)
152+ }
153+
154+ // Set environment variables
155+ cmd.Env = s.env
156+
157+ var stdout, stderr bytes.Buffer
158+ cmd.Stdout = &stdout
159+ cmd.Stderr = &stderr
160+
161+ err := cmd.Run()
162+
163+ logging.InfoPersist("Windows command finished", "shell", shell, "command", command, "err", err)
164+ return stdout.String(), stderr.String(), err
165+}
166+
167+// handleWindowsCD handles directory changes for Windows shells
168+func (s *PersistentShell) handleWindowsCD(command string) (string, string, error) {
169+ // Extract the target directory from the cd command
170+ parts := strings.Fields(command)
171+ if len(parts) < 2 {
172+ return "", "cd: missing directory argument", fmt.Errorf("missing directory argument")
173+ }
174+
175+ targetDir := parts[1]
176+
177+ // Handle relative paths
178+ if !strings.Contains(targetDir, ":") && !strings.HasPrefix(targetDir, "\\") {
179+ // Relative path - resolve against current directory
180+ if targetDir == ".." {
181+ // Go up one directory
182+ if len(s.cwd) > 3 { // Don't go above drive root (C:\)
183+ lastSlash := strings.LastIndex(s.cwd, "\\")
184+ if lastSlash > 2 { // Keep drive letter
185+ s.cwd = s.cwd[:lastSlash]
186+ }
187+ }
188+ } else if targetDir != "." {
189+ // Go to subdirectory
190+ s.cwd = s.cwd + "\\" + targetDir
191+ }
192+ } else {
193+ // Absolute path
194+ s.cwd = targetDir
195+ }
196+
197+ // Verify the directory exists
198+ if _, err := os.Stat(s.cwd); err != nil {
199+ return "", fmt.Sprintf("cd: %s: No such file or directory", targetDir), err
200+ }
201+
202+ return "", "", nil
203+}
204+
205+// execPOSIX executes commands using POSIX shell emulation (cross-platform)
206+func (s *PersistentShell) execPOSIX(ctx context.Context, command string) (string, string, error) {
58 line, err := syntax.NewParser().Parse(strings.NewReader(command), "")
207 line, err := syntax.NewParser().Parse(strings.NewReader(command), "")
59 if err != nil {
208 if err != nil {
60 return "", "", fmt.Errorf("could not parse command: %w", err)
209 return "", "", fmt.Errorf("could not parse command: %w", err)
@@ -77,7 +226,7 @@ func (s *PersistentShell) Exec(ctx context.Context, command string) (string, str
77 for name, vr := range runner.Vars {
226 for name, vr := range runner.Vars {
78 s.env = append(s.env, fmt.Sprintf("%s=%s", name, vr.Str))
227 s.env = append(s.env, fmt.Sprintf("%s=%s", name, vr.Str))
79 }
228 }
80− logging.InfoPersist("Command finished", "command", command, "err", err)
229+ logging.InfoPersist("POSIX command finished", "command", command, "err", err)
81 return stdout.String(), stderr.String(), err
230 return stdout.String(), stderr.String(), err
82}
231}
83
232
internal/llm/tools/shell/shell_test.go+143
@@ -2,6 +2,8 @@ package shell
2
2
3import (
3import (
4 "context"
4 "context"
5+ "runtime"
6+ "strings"
5 "testing"
7 "testing"
6 "time"
8 "time"
7)
9)
@@ -80,3 +82,144 @@ func TestRunContinuity(t *testing.T) {
80 t.Fatalf("Expected output %q, got %q", expect, out)
82 t.Fatalf("Expected output %q, got %q", expect, out)
81 }
83 }
82}
84}
85+
86+// New tests for Windows shell support
87+
88+func TestShellTypeDetection(t *testing.T) {
89+ shell := &PersistentShell{}
90+
91+ tests := []struct {
92+ command string
93+ expected ShellType
94+ windowsOnly bool
95+ }{
96+ // Windows-specific commands
97+ {"dir", ShellTypeCmd, true},
98+ {"type file.txt", ShellTypeCmd, true},
99+ {"copy file1.txt file2.txt", ShellTypeCmd, true},
100+ {"del file.txt", ShellTypeCmd, true},
101+ {"md newdir", ShellTypeCmd, true},
102+ {"tasklist", ShellTypeCmd, true},
103+
104+ // PowerShell commands
105+ {"Get-Process", ShellTypePowerShell, true},
106+ {"Get-ChildItem", ShellTypePowerShell, true},
107+ {"Set-Location C:\\", ShellTypePowerShell, true},
108+ {"Get-Content file.txt | Where-Object {$_ -match 'pattern'}", ShellTypePowerShell, true},
109+ {"$files = Get-ChildItem", ShellTypePowerShell, true},
110+
111+ // Unix/cross-platform commands
112+ {"ls -la", ShellTypePOSIX, false},
113+ {"cat file.txt", ShellTypePOSIX, false},
114+ {"grep pattern file.txt", ShellTypePOSIX, false},
115+ {"echo hello", ShellTypePOSIX, false},
116+ {"git status", ShellTypePOSIX, false},
117+ {"go build", ShellTypePOSIX, false},
118+ }
119+
120+ for _, test := range tests {
121+ t.Run(test.command, func(t *testing.T) {
122+ result := shell.determineShellType(test.command)
123+
124+ if test.windowsOnly && runtime.GOOS != "windows" {
125+ // On non-Windows systems, everything should use POSIX
126+ if result != ShellTypePOSIX {
127+ t.Errorf("On non-Windows, command %q should use POSIX shell, got %v", test.command, result)
128+ }
129+ } else if runtime.GOOS == "windows" {
130+ // On Windows, check the expected shell type
131+ if result != test.expected {
132+ t.Errorf("Command %q should use %v shell, got %v", test.command, test.expected, result)
133+ }
134+ }
135+ })
136+ }
137+}
138+
139+func TestWindowsCDHandling(t *testing.T) {
140+ if runtime.GOOS != "windows" {
141+ t.Skip("Windows CD handling test only runs on Windows")
142+ }
143+
144+ shell := &PersistentShell{
145+ cwd: "C:\\Users",
146+ env: []string{},
147+ }
148+
149+ tests := []struct {
150+ command string
151+ expectedCwd string
152+ shouldError bool
153+ }{
154+ {"cd ..", "C:\\", false},
155+ {"cd Documents", "C:\\Users\\Documents", false},
156+ {"cd C:\\Windows", "C:\\Windows", false},
157+ {"cd", "", true}, // Missing argument
158+ }
159+
160+ for _, test := range tests {
161+ t.Run(test.command, func(t *testing.T) {
162+ originalCwd := shell.cwd
163+ stdout, stderr, err := shell.handleWindowsCD(test.command)
164+
165+ if test.shouldError {
166+ if err == nil {
167+ t.Errorf("Command %q should have failed", test.command)
168+ }
169+ } else {
170+ if err != nil {
171+ t.Errorf("Command %q failed: %v", test.command, err)
172+ }
173+ if shell.cwd != test.expectedCwd {
174+ t.Errorf("Command %q: expected cwd %q, got %q", test.command, test.expectedCwd, shell.cwd)
175+ }
176+ }
177+
178+ // Reset for next test
179+ shell.cwd = originalCwd
180+ _ = stdout
181+ _ = stderr
182+ })
183+ }
184+}
185+
186+func TestCrossPlatformExecution(t *testing.T) {
187+ shell := newPersistentShell(".")
188+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
189+ defer cancel()
190+
191+ // Test a simple command that should work on all platforms
192+ stdout, stderr, err := shell.Exec(ctx, "echo hello")
193+ if err != nil {
194+ t.Fatalf("Echo command failed: %v, stderr: %s", err, stderr)
195+ }
196+
197+ if stdout == "" {
198+ t.Error("Echo command produced no output")
199+ }
200+
201+ // The output should contain "hello" regardless of platform
202+ if !strings.Contains(strings.ToLower(stdout), "hello") {
203+ t.Errorf("Echo output should contain 'hello', got: %q", stdout)
204+ }
205+}
206+
207+func TestWindowsNativeCommands(t *testing.T) {
208+ if runtime.GOOS != "windows" {
209+ t.Skip("Windows native command test only runs on Windows")
210+ }
211+
212+ shell := newPersistentShell(".")
213+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
214+ defer cancel()
215+
216+ // Test Windows dir command
217+ stdout, stderr, err := shell.Exec(ctx, "dir")
218+ if err != nil {
219+ t.Fatalf("Dir command failed: %v, stderr: %s", err, stderr)
220+ }
221+
222+ if stdout == "" {
223+ t.Error("Dir command produced no output")
224+ }
225+}