on main help sign in
chore: move shell to its own package
commitbaad28ac9012967020558d4c1d388cb08abbd97d authorKujtim Hoxha <kujtimii.h@gmail.com> dateSat 28 Jun 2025, 13:35 (1 year ago) parent431a6c4
6 files changed+212−66
internal/llm/tools/bash.go+1−1 internal/llm/tools/shell/comparison_test.go → internal/shell/comparison_test.go+2−2 internal/shell/doc.go+30 internal/shell/persistent.go+38 internal/llm/tools/shell/shell.go → internal/shell/shell.go+127−48 internal/llm/tools/shell/shell_test.go → internal/shell/shell_test.go+14−15
internal/llm/tools/bash.go+1−1
@@ -9,8 +9,8 @@ import (
99 "time"
1010
1111 "github.com/charmbracelet/crush/internal/config"
12− "github.com/charmbracelet/crush/internal/llm/tools/shell"
1312 "github.com/charmbracelet/crush/internal/permission"
13+ "github.com/charmbracelet/crush/internal/shell"
1414)
1515
1616type BashParams struct {
internal/llm/tools/shell/comparison_test.go → internal/shell/comparison_test.gorenamed+2−2
@@ -9,7 +9,7 @@ import (
99)
1010
1111func TestShellPerformanceComparison(t *testing.T) {
12− shell := newPersistentShell(t.TempDir())
12+ shell := NewShell(&Options{WorkingDir: t.TempDir()})
1313
1414 // Test quick command
1515 start := time.Now()
@@ -27,7 +27,7 @@ func TestShellPerformanceComparison(t *testing.T) {
2727
2828// Benchmark CPU usage during polling
2929func BenchmarkShellPolling(b *testing.B) {
30− shell := newPersistentShell(b.TempDir())
30+ shell := NewShell(&Options{WorkingDir: b.TempDir()})
3131
3232 b.ReportAllocs()
3333
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 @@
11// Package shell provides cross-platform shell execution capabilities.
22//
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+//
37// WINDOWS COMPATIBILITY:
48// This implementation provides both POSIX shell emulation (mvdan.cc/sh/v3) and
5−// 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
9+// native Windows shell support (cmd.exe/PowerShell) for optimal compatibility.
910package shell
1011
1112import (
@@ -19,7 +20,6 @@ import (
1920 "strings"
2021 "sync"
2122
22− "github.com/charmbracelet/crush/internal/logging"
2323 "mvdan.cc/sh/v3/expand"
2424 "mvdan.cc/sh/v3/interp"
2525 "mvdan.cc/sh/v3/syntax"
@@ -34,53 +34,61 @@ const (
3434 ShellTypePowerShell
3535)
3636
37−type PersistentShell struct {
38− env []string
39− cwd string
40− mu sync.Mutex
37+// Logger interface for optional logging
38+type Logger interface {
39+ InfoPersist(msg string, keysAndValues ...interface{})
4140}
4241
43−var (
44− once sync.Once
45− shellInstance *PersistentShell
46−)
42+// noopLogger is a logger that does nothing
43+type noopLogger struct{}
4744
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,
45+func (noopLogger) InfoPersist(msg string, keysAndValues ...interface{}) {}
46+
47+// Shell provides cross-platform shell execution with optional state persistence
48+type Shell struct {
49+ env []string
50+ cwd string
51+ mu sync.Mutex
52+ logger Logger
6753}
6854
69−func GetPersistentShell(cwd string) *PersistentShell {
70− once.Do(func() {
71− shellInstance = newPersistentShell(cwd)
72− })
73− return shellInstance
55+// Options for creating a new shell
56+type Options struct {
57+ WorkingDir string
58+ Env []string
59+ Logger Logger
7460}
7561
76−func newPersistentShell(cwd string) *PersistentShell {
77− return &PersistentShell{
78− cwd: cwd,
79− env: os.Environ(),
62+// NewShell creates a new shell instance with the given options
63+func NewShell(opts *Options) *Shell {
64+ if opts == nil {
65+ opts = &Options{}
66+ }
67+
68+ cwd := opts.WorkingDir
69+ if cwd == "" {
70+ cwd, _ = os.Getwd()
71+ }
72+
73+ env := opts.Env
74+ if env == nil {
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,
8087 }
8188}
8289
83−func (s *PersistentShell) Exec(ctx context.Context, command string) (string, string, error) {
90+// Exec executes a command in the shell
91+func (s *Shell) Exec(ctx context.Context, command string) (string, string, error) {
8492 s.mu.Lock()
8593 defer s.mu.Unlock()
8694
@@ -97,8 +105,76 @@ func (s *PersistentShell) Exec(ctx context.Context, command string) (string, str
97105 }
98106}
99107
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+
100176// 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 {
102178 if runtime.GOOS != "windows" {
103179 return ShellTypePOSIX
104180 }
@@ -128,7 +204,7 @@ func (s *PersistentShell) determineShellType(command string) ShellType {
128204}
129205
130206// 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) {
132208 var cmd *exec.Cmd
133209
134210 // Handle directory changes specially to maintain persistent shell behavior
@@ -160,12 +236,12 @@ func (s *PersistentShell) execWindows(ctx context.Context, command string, shell
160236
161237 err := cmd.Run()
162238
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)
164240 return stdout.String(), stderr.String(), err
165241}
166242
167243// 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) {
169245 // Extract the target directory from the cd command
170246 parts := strings.Fields(command)
171247 if len(parts) < 2 {
@@ -203,7 +279,7 @@ func (s *PersistentShell) handleWindowsCD(command string) (string, string, error
203279}
204280
205281// 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) {
207283 line, err := syntax.NewParser().Parse(strings.NewReader(command), "")
208284 if err != nil {
209285 return "", "", fmt.Errorf("could not parse command: %w", err)
@@ -226,15 +302,17 @@ func (s *PersistentShell) execPOSIX(ctx context.Context, command string) (string
226302 for name, vr := range runner.Vars {
227303 s.env = append(s.env, fmt.Sprintf("%s=%s", name, vr.Str))
228304 }
229− logging.InfoPersist("POSIX command finished", "command", command, "err", err)
305+ s.logger.InfoPersist("POSIX command finished", "command", command, "err", err)
230306 return stdout.String(), stderr.String(), err
231307}
232308
309+// IsInterrupt checks if an error is due to interruption
233310func IsInterrupt(err error) bool {
234311 return errors.Is(err, context.Canceled) ||
235312 errors.Is(err, context.DeadlineExceeded)
236313}
237314
315+// ExitCode extracts the exit code from an error
238316func ExitCode(err error) int {
239317 if err == nil {
240318 return 0
@@ -245,3 +323,4 @@ func ExitCode(err error) int {
245323 }
246324 return 1
247325}
326+
internal/llm/tools/shell/shell_test.go → internal/shell/shell_test.gorenamed+14−15
@@ -10,7 +10,7 @@ import (
1010
1111// Benchmark to measure CPU efficiency
1212func BenchmarkShellQuickCommands(b *testing.B) {
13− shell := newPersistentShell(b.TempDir())
13+ shell := NewShell(&Options{WorkingDir: b.TempDir()})
1414
1515 b.ReportAllocs()
1616
@@ -27,7 +27,7 @@ func TestTestTimeout(t *testing.T) {
2727 ctx, cancel := context.WithTimeout(t.Context(), time.Millisecond)
2828 t.Cleanup(cancel)
2929
30− shell := newPersistentShell(t.TempDir())
30+ shell := NewShell(&Options{WorkingDir: t.TempDir()})
3131 _, _, err := shell.Exec(ctx, "sleep 10")
3232 if status := ExitCode(err); status == 0 {
3333 t.Fatalf("Expected non-zero exit status, got %d", status)
@@ -44,7 +44,7 @@ func TestTestCancel(t *testing.T) {
4444 ctx, cancel := context.WithCancel(t.Context())
4545 cancel() // immediately cancel the context
4646
47− shell := newPersistentShell(t.TempDir())
47+ shell := NewShell(&Options{WorkingDir: t.TempDir()})
4848 _, _, err := shell.Exec(ctx, "sleep 10")
4949 if status := ExitCode(err); status == 0 {
5050 t.Fatalf("Expected non-zero exit status, got %d", status)
@@ -58,7 +58,7 @@ func TestTestCancel(t *testing.T) {
5858}
5959
6060func TestRunCommandError(t *testing.T) {
61− shell := newPersistentShell(t.TempDir())
61+ shell := NewShell(&Options{WorkingDir: t.TempDir()})
6262 _, _, err := shell.Exec(t.Context(), "nopenopenope")
6363 if status := ExitCode(err); status == 0 {
6464 t.Fatalf("Expected non-zero exit status, got %d", status)
@@ -72,7 +72,7 @@ func TestRunCommandError(t *testing.T) {
7272}
7373
7474func TestRunContinuity(t *testing.T) {
75− shell := newPersistentShell(t.TempDir())
75+ shell := NewShell(&Options{WorkingDir: t.TempDir()})
7676 shell.Exec(t.Context(), "export FOO=bar")
7777 dst := t.TempDir()
7878 shell.Exec(t.Context(), "cd "+dst)
@@ -141,10 +141,9 @@ func TestWindowsCDHandling(t *testing.T) {
141141 t.Skip("Windows CD handling test only runs on Windows")
142142 }
143143
144− shell := &PersistentShell{
145− cwd: "C:\\Users",
146− env: []string{},
147− }
144+ shell := NewShell(&Options{
145+ WorkingDir: "C:\\Users",
146+ })
148147
149148 tests := []struct {
150149 command string
@@ -159,7 +158,7 @@ func TestWindowsCDHandling(t *testing.T) {
159158
160159 for _, test := range tests {
161160 t.Run(test.command, func(t *testing.T) {
162− originalCwd := shell.cwd
161+ originalCwd := shell.GetWorkingDir()
163162 stdout, stderr, err := shell.handleWindowsCD(test.command)
164163
165164 if test.shouldError {
@@ -170,13 +169,13 @@ func TestWindowsCDHandling(t *testing.T) {
170169 if err != nil {
171170 t.Errorf("Command %q failed: %v", test.command, err)
172171 }
173− if shell.cwd != test.expectedCwd {
174− t.Errorf("Command %q: expected cwd %q, got %q", test.command, test.expectedCwd, shell.cwd)
172+ if shell.GetWorkingDir() != test.expectedCwd {
173+ t.Errorf("Command %q: expected cwd %q, got %q", test.command, test.expectedCwd, shell.GetWorkingDir())
175174 }
176175 }
177176
178177 // Reset for next test
179− shell.cwd = originalCwd
178+ shell.SetWorkingDir(originalCwd)
180179 _ = stdout
181180 _ = stderr
182181 })
@@ -184,7 +183,7 @@ func TestWindowsCDHandling(t *testing.T) {
184183}
185184
186185func TestCrossPlatformExecution(t *testing.T) {
187− shell := newPersistentShell(".")
186+ shell := NewShell(&Options{WorkingDir: "."})
188187 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
189188 defer cancel()
190189
@@ -209,7 +208,7 @@ func TestWindowsNativeCommands(t *testing.T) {
209208 t.Skip("Windows native command test only runs on Windows")
210209 }
211210
212− shell := newPersistentShell(".")
211+ shell := NewShell(&Options{WorkingDir: "."})
213212 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
214213 defer cancel()
215214