on main help sign in
chore: move shell to its own package
baad28aKujtim Hoxha
internal/llm/tools/bash.go+1−1
@@ -9,8 +9,8 @@ import (
9 "time"
9 "time"
10
10
11 "github.com/charmbracelet/crush/internal/config"
11 "github.com/charmbracelet/crush/internal/config"
12− "github.com/charmbracelet/crush/internal/llm/tools/shell"
13 "github.com/charmbracelet/crush/internal/permission"
12 "github.com/charmbracelet/crush/internal/permission"
13+ "github.com/charmbracelet/crush/internal/shell"
14)
14)
15
15
16type BashParams struct {
16type BashParams struct {
internal/llm/tools/shell/comparison_test.go → internal/shell/comparison_test.gorenamed+2−2
@@ -9,7 +9,7 @@ import (
9)
9)
10
10
11func TestShellPerformanceComparison(t *testing.T) {
11func TestShellPerformanceComparison(t *testing.T) {
12− shell := newPersistentShell(t.TempDir())
12+ shell := NewShell(&Options{WorkingDir: t.TempDir()})
13
13
14 // Test quick command
14 // Test quick command
15 start := time.Now()
15 start := time.Now()
@@ -27,7 +27,7 @@ func TestShellPerformanceComparison(t *testing.T) {
27
27
28// Benchmark CPU usage during polling
28// Benchmark CPU usage during polling
29func BenchmarkShellPolling(b *testing.B) {
29func BenchmarkShellPolling(b *testing.B) {
30− shell := newPersistentShell(b.TempDir())
30+ shell := NewShell(&Options{WorkingDir: b.TempDir()})
31
31
32 b.ReportAllocs()
32 b.ReportAllocs()
33
33
internal/shell/doc.gonew file+30
@@ -0,0 +1,30 @@
1+package shell
2+
3+// Example usage of the shell package:
4+//
5+// 1. For one-off commands:
6+//
7+// shell := shell.NewShell(nil)
8+// stdout, stderr, err := shell.Exec(context.Background(), "echo hello")
9+//
10+// 2. For maintaining state across commands:
11+//
12+// shell := shell.NewShell(&shell.Options{
13+// WorkingDir: "/tmp",
14+// Logger: myLogger,
15+// })
16+// shell.Exec(ctx, "export FOO=bar")
17+// shell.Exec(ctx, "echo $FOO") // Will print "bar"
18+//
19+// 3. For the singleton persistent shell (used by tools):
20+//
21+// shell := shell.GetPersistentShell("/path/to/cwd")
22+// stdout, stderr, err := shell.Exec(ctx, "ls -la")
23+//
24+// 4. Managing environment and working directory:
25+//
26+// shell := shell.NewShell(nil)
27+// shell.SetEnv("MY_VAR", "value")
28+// shell.SetWorkingDir("/tmp")
29+// cwd := shell.GetWorkingDir()
30+// env := shell.GetEnv()
internal/shell/persistent.gonew file+38
@@ -0,0 +1,38 @@
1+package shell
2+
3+import (
4+ "sync"
5+
6+ "github.com/charmbracelet/crush/internal/logging"
7+)
8+
9+// PersistentShell is a singleton shell instance that maintains state across the application
10+type PersistentShell struct {
11+ *Shell
12+}
13+
14+var (
15+ once sync.Once
16+ shellInstance *PersistentShell
17+)
18+
19+// GetPersistentShell returns the singleton persistent shell instance
20+// This maintains backward compatibility with the existing API
21+func GetPersistentShell(cwd string) *PersistentShell {
22+ once.Do(func() {
23+ shellInstance = &PersistentShell{
24+ Shell: NewShell(&Options{
25+ WorkingDir: cwd,
26+ Logger: &loggingAdapter{},
27+ }),
28+ }
29+ })
30+ return shellInstance
31+}
32+
33+// loggingAdapter adapts the internal logging package to the Logger interface
34+type loggingAdapter struct{}
35+
36+func (l *loggingAdapter) InfoPersist(msg string, keysAndValues ...interface{}) {
37+ logging.InfoPersist(msg, keysAndValues...)
38+}
internal/llm/tools/shell/shell.go → internal/shell/shell.gorenamed+127−48
@@ -1,11 +1,12 @@
1// Package shell provides cross-platform shell execution capabilities.
1// Package shell provides cross-platform shell execution capabilities.
2//
2//
3+// This package offers two main types:
4+// - Shell: A general-purpose shell executor for one-off or managed commands
5+// - PersistentShell: A singleton shell that maintains state across the application
6+//
3// WINDOWS COMPATIBILITY:
7// WINDOWS COMPATIBILITY:
4// This implementation provides both POSIX shell emulation (mvdan.cc/sh/v3) and
8// This implementation provides both POSIX shell emulation (mvdan.cc/sh/v3) and
5−// native Windows shell support (cmd.exe/PowerShell) for optimal compatibility:
9+// native Windows shell support (cmd.exe/PowerShell) for optimal compatibility.
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
9package shell
10package shell
10
11
11import (
12import (
@@ -19,7 +20,6 @@ import (
19 "strings"
20 "strings"
20 "sync"
21 "sync"
21
22
22− "github.com/charmbracelet/crush/internal/logging"
23 "mvdan.cc/sh/v3/expand"
23 "mvdan.cc/sh/v3/expand"
24 "mvdan.cc/sh/v3/interp"
24 "mvdan.cc/sh/v3/interp"
25 "mvdan.cc/sh/v3/syntax"
25 "mvdan.cc/sh/v3/syntax"
@@ -34,53 +34,61 @@ const (
34 ShellTypePowerShell
34 ShellTypePowerShell
35)
35)
36
36
37−type PersistentShell struct {
37+// Logger interface for optional logging
38− env []string
38+type Logger interface {
39− cwd string
39+ InfoPersist(msg string, keysAndValues ...interface{})
40− mu sync.Mutex
41}
40}
42
41
43−var (
42+// noopLogger is a logger that does nothing
44− once sync.Once
43+type noopLogger struct{}
45− shellInstance *PersistentShell
46−)
47
44
48−// Windows-specific commands that should use native shell
45+func (noopLogger) InfoPersist(msg string, keysAndValues ...interface{}) {}
49−var windowsNativeCommands = map[string]bool{
46+
50− "dir": true,
47+// Shell provides cross-platform shell execution with optional state persistence
51− "type": true,
48+type Shell struct {
52− "copy": true,
49+ env []string
53− "move": true,
50+ cwd string
54− "del": true,
51+ mu sync.Mutex
55− "md": true,
52+ logger Logger
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}
53}
68
54
69−func GetPersistentShell(cwd string) *PersistentShell {
55+// Options for creating a new shell
70− once.Do(func() {
56+type Options struct {
71− shellInstance = newPersistentShell(cwd)
57+ WorkingDir string
72− })
58+ Env []string
73− return shellInstance
59+ Logger Logger
74}
60}
75
61
76−func newPersistentShell(cwd string) *PersistentShell {
62+// NewShell creates a new shell instance with the given options
77− return &PersistentShell{
63+func NewShell(opts *Options) *Shell {
64+ if opts == nil {
65+ opts = &Options{}
66+ }
67+
68+ cwd := opts.WorkingDir
78− cwd: cwd,
69+ if cwd == "" {
70+ cwd, _ = os.Getwd()
71+ }
72+
73+ env := opts.Env
74+ if env == nil {
79− env: os.Environ(),
75+ env = os.Environ()
76+ }
77+
78+ logger := opts.Logger
79+ if logger == nil {
80+ logger = noopLogger{}
81+ }
82+
83+ return &Shell{
84+ cwd: cwd,
85+ env: env,
86+ logger: logger,
80 }
87 }
81}
88}
82
89
90+// Exec executes a command in the shell
83−func (s *PersistentShell) Exec(ctx context.Context, command string) (string, string, error) {
91+func (s *Shell) Exec(ctx context.Context, command string) (string, string, error) {
84 s.mu.Lock()
92 s.mu.Lock()
85 defer s.mu.Unlock()
93 defer s.mu.Unlock()
86
94
@@ -97,8 +105,76 @@ func (s *PersistentShell) Exec(ctx context.Context, command string) (string, str
97 }
105 }
98}
106}
99
107
108+// GetWorkingDir returns the current working directory
109+func (s *Shell) GetWorkingDir() string {
110+ s.mu.Lock()
111+ defer s.mu.Unlock()
112+ return s.cwd
113+}
114+
115+// SetWorkingDir sets the working directory
116+func (s *Shell) SetWorkingDir(dir string) error {
117+ s.mu.Lock()
118+ defer s.mu.Unlock()
119+
120+ // Verify the directory exists
121+ if _, err := os.Stat(dir); err != nil {
122+ return fmt.Errorf("directory does not exist: %w", err)
123+ }
124+
125+ s.cwd = dir
126+ return nil
127+}
128+
129+// GetEnv returns a copy of the environment variables
130+func (s *Shell) GetEnv() []string {
131+ s.mu.Lock()
132+ defer s.mu.Unlock()
133+
134+ env := make([]string, len(s.env))
135+ copy(env, s.env)
136+ return env
137+}
138+
139+// SetEnv sets an environment variable
140+func (s *Shell) SetEnv(key, value string) {
141+ s.mu.Lock()
142+ defer s.mu.Unlock()
143+
144+ // Update or add the environment variable
145+ keyPrefix := key + "="
146+ for i, env := range s.env {
147+ if strings.HasPrefix(env, keyPrefix) {
148+ s.env[i] = keyPrefix + value
149+ return
150+ }
151+ }
152+ s.env = append(s.env, keyPrefix+value)
153+}
154+
155+// Windows-specific commands that should use native shell
156+var windowsNativeCommands = map[string]bool{
157+ "dir": true,
158+ "type": true,
159+ "copy": true,
160+ "move": true,
161+ "del": true,
162+ "md": true,
163+ "mkdir": true,
164+ "rd": true,
165+ "rmdir": true,
166+ "cls": true,
167+ "where": true,
168+ "tasklist": true,
169+ "taskkill": true,
170+ "net": true,
171+ "sc": true,
172+ "reg": true,
173+ "wmic": true,
174+}
175+
100// determineShellType decides which shell to use based on platform and command
176// determineShellType decides which shell to use based on platform and command
101−func (s *PersistentShell) determineShellType(command string) ShellType {
177+func (s *Shell) determineShellType(command string) ShellType {
102 if runtime.GOOS != "windows" {
178 if runtime.GOOS != "windows" {
103 return ShellTypePOSIX
179 return ShellTypePOSIX
104 }
180 }
@@ -128,7 +204,7 @@ func (s *PersistentShell) determineShellType(command string) ShellType {
128}
204}
129
205
130// execWindows executes commands using native Windows shells (cmd.exe or PowerShell)
206// 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) {
207+func (s *Shell) execWindows(ctx context.Context, command string, shell string) (string, string, error) {
132 var cmd *exec.Cmd
208 var cmd *exec.Cmd
133
209
134 // Handle directory changes specially to maintain persistent shell behavior
210 // Handle directory changes specially to maintain persistent shell behavior
@@ -160,12 +236,12 @@ func (s *PersistentShell) execWindows(ctx context.Context, command string, shell
160
236
161 err := cmd.Run()
237 err := cmd.Run()
162
238
163− logging.InfoPersist("Windows command finished", "shell", shell, "command", command, "err", err)
239+ s.logger.InfoPersist("Windows command finished", "shell", shell, "command", command, "err", err)
164 return stdout.String(), stderr.String(), err
240 return stdout.String(), stderr.String(), err
165}
241}
166
242
167// handleWindowsCD handles directory changes for Windows shells
243// handleWindowsCD handles directory changes for Windows shells
168−func (s *PersistentShell) handleWindowsCD(command string) (string, string, error) {
244+func (s *Shell) handleWindowsCD(command string) (string, string, error) {
169 // Extract the target directory from the cd command
245 // Extract the target directory from the cd command
170 parts := strings.Fields(command)
246 parts := strings.Fields(command)
171 if len(parts) < 2 {
247 if len(parts) < 2 {
@@ -203,7 +279,7 @@ func (s *PersistentShell) handleWindowsCD(command string) (string, string, error
203}
279}
204
280
205// execPOSIX executes commands using POSIX shell emulation (cross-platform)
281// execPOSIX executes commands using POSIX shell emulation (cross-platform)
206−func (s *PersistentShell) execPOSIX(ctx context.Context, command string) (string, string, error) {
282+func (s *Shell) execPOSIX(ctx context.Context, command string) (string, string, error) {
207 line, err := syntax.NewParser().Parse(strings.NewReader(command), "")
283 line, err := syntax.NewParser().Parse(strings.NewReader(command), "")
208 if err != nil {
284 if err != nil {
209 return "", "", fmt.Errorf("could not parse command: %w", err)
285 return "", "", fmt.Errorf("could not parse command: %w", err)
@@ -226,15 +302,17 @@ func (s *PersistentShell) execPOSIX(ctx context.Context, command string) (string
226 for name, vr := range runner.Vars {
302 for name, vr := range runner.Vars {
227 s.env = append(s.env, fmt.Sprintf("%s=%s", name, vr.Str))
303 s.env = append(s.env, fmt.Sprintf("%s=%s", name, vr.Str))
228 }
304 }
229− logging.InfoPersist("POSIX command finished", "command", command, "err", err)
305+ s.logger.InfoPersist("POSIX command finished", "command", command, "err", err)
230 return stdout.String(), stderr.String(), err
306 return stdout.String(), stderr.String(), err
231}
307}
232
308
309+// IsInterrupt checks if an error is due to interruption
233func IsInterrupt(err error) bool {
310func IsInterrupt(err error) bool {
234 return errors.Is(err, context.Canceled) ||
311 return errors.Is(err, context.Canceled) ||
235 errors.Is(err, context.DeadlineExceeded)
312 errors.Is(err, context.DeadlineExceeded)
236}
313}
237
314
315+// ExitCode extracts the exit code from an error
238func ExitCode(err error) int {
316func ExitCode(err error) int {
239 if err == nil {
317 if err == nil {
240 return 0
318 return 0
@@ -245,3 +323,4 @@ func ExitCode(err error) int {
245 }
323 }
246 return 1
324 return 1
247}
325}
326+
internal/llm/tools/shell/shell_test.go → internal/shell/shell_test.gorenamed+14−15
@@ -10,7 +10,7 @@ import (
10
10
11// Benchmark to measure CPU efficiency
11// Benchmark to measure CPU efficiency
12func BenchmarkShellQuickCommands(b *testing.B) {
12func BenchmarkShellQuickCommands(b *testing.B) {
13− shell := newPersistentShell(b.TempDir())
13+ shell := NewShell(&Options{WorkingDir: b.TempDir()})
14
14
15 b.ReportAllocs()
15 b.ReportAllocs()
16
16
@@ -27,7 +27,7 @@ func TestTestTimeout(t *testing.T) {
27 ctx, cancel := context.WithTimeout(t.Context(), time.Millisecond)
27 ctx, cancel := context.WithTimeout(t.Context(), time.Millisecond)
28 t.Cleanup(cancel)
28 t.Cleanup(cancel)
29
29
30− shell := newPersistentShell(t.TempDir())
30+ shell := NewShell(&Options{WorkingDir: t.TempDir()})
31 _, _, err := shell.Exec(ctx, "sleep 10")
31 _, _, err := shell.Exec(ctx, "sleep 10")
32 if status := ExitCode(err); status == 0 {
32 if status := ExitCode(err); status == 0 {
33 t.Fatalf("Expected non-zero exit status, got %d", status)
33 t.Fatalf("Expected non-zero exit status, got %d", status)
@@ -44,7 +44,7 @@ func TestTestCancel(t *testing.T) {
44 ctx, cancel := context.WithCancel(t.Context())
44 ctx, cancel := context.WithCancel(t.Context())
45 cancel() // immediately cancel the context
45 cancel() // immediately cancel the context
46
46
47− shell := newPersistentShell(t.TempDir())
47+ shell := NewShell(&Options{WorkingDir: t.TempDir()})
48 _, _, err := shell.Exec(ctx, "sleep 10")
48 _, _, err := shell.Exec(ctx, "sleep 10")
49 if status := ExitCode(err); status == 0 {
49 if status := ExitCode(err); status == 0 {
50 t.Fatalf("Expected non-zero exit status, got %d", status)
50 t.Fatalf("Expected non-zero exit status, got %d", status)
@@ -58,7 +58,7 @@ func TestTestCancel(t *testing.T) {
58}
58}
59
59
60func TestRunCommandError(t *testing.T) {
60func TestRunCommandError(t *testing.T) {
61− shell := newPersistentShell(t.TempDir())
61+ shell := NewShell(&Options{WorkingDir: t.TempDir()})
62 _, _, err := shell.Exec(t.Context(), "nopenopenope")
62 _, _, err := shell.Exec(t.Context(), "nopenopenope")
63 if status := ExitCode(err); status == 0 {
63 if status := ExitCode(err); status == 0 {
64 t.Fatalf("Expected non-zero exit status, got %d", status)
64 t.Fatalf("Expected non-zero exit status, got %d", status)
@@ -72,7 +72,7 @@ func TestRunCommandError(t *testing.T) {
72}
72}
73
73
74func TestRunContinuity(t *testing.T) {
74func TestRunContinuity(t *testing.T) {
75− shell := newPersistentShell(t.TempDir())
75+ shell := NewShell(&Options{WorkingDir: t.TempDir()})
76 shell.Exec(t.Context(), "export FOO=bar")
76 shell.Exec(t.Context(), "export FOO=bar")
77 dst := t.TempDir()
77 dst := t.TempDir()
78 shell.Exec(t.Context(), "cd "+dst)
78 shell.Exec(t.Context(), "cd "+dst)
@@ -141,10 +141,9 @@ func TestWindowsCDHandling(t *testing.T) {
141 t.Skip("Windows CD handling test only runs on Windows")
141 t.Skip("Windows CD handling test only runs on Windows")
142 }
142 }
143
143
144− shell := &PersistentShell{
144+ shell := NewShell(&Options{
145− cwd: "C:\\Users",
145+ WorkingDir: "C:\\Users",
146− env: []string{},
147− }
146+ })
148
147
149 tests := []struct {
148 tests := []struct {
150 command string
149 command string
@@ -159,7 +158,7 @@ func TestWindowsCDHandling(t *testing.T) {
159
158
160 for _, test := range tests {
159 for _, test := range tests {
161 t.Run(test.command, func(t *testing.T) {
160 t.Run(test.command, func(t *testing.T) {
162− originalCwd := shell.cwd
161+ originalCwd := shell.GetWorkingDir()
163 stdout, stderr, err := shell.handleWindowsCD(test.command)
162 stdout, stderr, err := shell.handleWindowsCD(test.command)
164
163
165 if test.shouldError {
164 if test.shouldError {
@@ -170,13 +169,13 @@ func TestWindowsCDHandling(t *testing.T) {
170 if err != nil {
169 if err != nil {
171 t.Errorf("Command %q failed: %v", test.command, err)
170 t.Errorf("Command %q failed: %v", test.command, err)
172 }
171 }
173− if shell.cwd != test.expectedCwd {
172+ if shell.GetWorkingDir() != test.expectedCwd {
174− t.Errorf("Command %q: expected cwd %q, got %q", test.command, test.expectedCwd, shell.cwd)
173+ t.Errorf("Command %q: expected cwd %q, got %q", test.command, test.expectedCwd, shell.GetWorkingDir())
175 }
174 }
176 }
175 }
177
176
178 // Reset for next test
177 // Reset for next test
179− shell.cwd = originalCwd
178+ shell.SetWorkingDir(originalCwd)
180 _ = stdout
179 _ = stdout
181 _ = stderr
180 _ = stderr
182 })
181 })
@@ -184,7 +183,7 @@ func TestWindowsCDHandling(t *testing.T) {
184}
183}
185
184
186func TestCrossPlatformExecution(t *testing.T) {
185func TestCrossPlatformExecution(t *testing.T) {
187− shell := newPersistentShell(".")
186+ shell := NewShell(&Options{WorkingDir: "."})
188 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
187 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
189 defer cancel()
188 defer cancel()
190
189
@@ -209,7 +208,7 @@ func TestWindowsNativeCommands(t *testing.T) {
209 t.Skip("Windows native command test only runs on Windows")
208 t.Skip("Windows native command test only runs on Windows")
210 }
209 }
211
210
212− shell := newPersistentShell(".")
211+ shell := NewShell(&Options{WorkingDir: "."})
213 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
212 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
214 defer cancel()
213 defer cancel()
215
214