@@ -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+