@@ -1,11 +1,12 @@
1// Package shell provides cross-platform shell execution capabilities.
1// Package shell provides cross-platform shell execution capabilities.
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
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
@@ -19,7 +20,6 @@ import (
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
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{})
43−var (
42+// noopLogger is a logger that does nothing
44− once sync.Once
43+type noopLogger struct{}
45− shellInstance *PersistentShell
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
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
73− return shellInstance
59+ Logger Logger
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 {
68+ cwd := opts.WorkingDir
78− cwd: cwd,
69+ if cwd == "" {
79− env: os.Environ(),
75+ env = os.Environ()
78+ logger := opts.Logger
80+ logger = noopLogger{}
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()
@@ -97,8 +105,76 @@ func (s *PersistentShell) Exec(ctx context.Context, command string) (string, str
108+// GetWorkingDir returns the current working directory
109+func (s *Shell) GetWorkingDir() string {
115+// SetWorkingDir sets the working directory
116+func (s *Shell) SetWorkingDir(dir string) error {
120+ // Verify the directory exists
121+ if _, err := os.Stat(dir); err != nil {
122+ return fmt.Errorf("directory does not exist: %w", err)
129+// GetEnv returns a copy of the environment variables
130+func (s *Shell) GetEnv() []string {
134+ env := make([]string, len(s.env))
139+// SetEnv sets an environment variable
140+func (s *Shell) SetEnv(key, value string) {
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
152+ s.env = append(s.env, keyPrefix+value)
155+// Windows-specific commands that should use native shell
156+var windowsNativeCommands = map[string]bool{
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
@@ -128,7 +204,7 @@ func (s *PersistentShell) determineShellType(command string) ShellType {
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
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
161 err := cmd.Run()
237 err := cmd.Run()
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
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
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))
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
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)
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 {
@@ -245,3 +323,4 @@ func ExitCode(err error) int {