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
7// WINDOWS COMPATIBILITY:
8// This implementation provides both POSIX shell emulation (mvdan.cc/sh/v3) and
9// native Windows shell support (cmd.exe/PowerShell) for optimal compatibility.
23 "mvdan.cc/sh/v3/expand"
24 "mvdan.cc/sh/v3/interp"
25 "mvdan.cc/sh/v3/syntax"
28// ShellType represents the type of shell to use
32 ShellTypePOSIX ShellType = iota
37// Logger interface for optional logging
38type Logger interface {
39 InfoPersist(msg string, keysAndValues ...interface{})
42// noopLogger is a logger that does nothing
43type noopLogger struct{}
45func (noopLogger) InfoPersist(msg string, keysAndValues ...interface{}) {}
47// CommandBlockFunc is a function that determines if a command should be blocked
48type CommandBlockFunc func(args []string) bool
50// Shell provides cross-platform shell execution with optional state persistence
56 blockFuncs []CommandBlockFunc
59// Options for creating a new shell
64 BlockFuncs []CommandBlockFunc
67// NewShell creates a new shell instance with the given options
68func NewShell(opts *Options) *Shell {
73 cwd := opts.WorkingDir
92 blockFuncs: opts.BlockFuncs,
96// Exec executes a command in the shell
97func (s *Shell) Exec(ctx context.Context, command string) (string, string, error) {
101 // Determine which shell to use based on platform and command
102 shellType := s.determineShellType(command)
106 return s.execWindows(ctx, command, "cmd")
107 case ShellTypePowerShell:
108 return s.execWindows(ctx, command, "powershell")
110 return s.execPOSIX(ctx, command)
114// GetWorkingDir returns the current working directory
115func (s *Shell) GetWorkingDir() string {
121// SetWorkingDir sets the working directory
122func (s *Shell) SetWorkingDir(dir string) error {
126 // Verify the directory exists
127 if _, err := os.Stat(dir); err != nil {
128 return fmt.Errorf("directory does not exist: %w", err)
135// GetEnv returns a copy of the environment variables
136func (s *Shell) GetEnv() []string {
140 env := make([]string, len(s.env))
145// SetEnv sets an environment variable
146func (s *Shell) SetEnv(key, value string) {
150 // Update or add the environment variable
151 keyPrefix := key + "="
152 for i, env := range s.env {
153 if strings.HasPrefix(env, keyPrefix) {
154 s.env[i] = keyPrefix + value
158 s.env = append(s.env, keyPrefix+value)
161// SetBlockFuncs sets the command block functions for the shell
162func (s *Shell) SetBlockFuncs(blockFuncs []CommandBlockFunc) {
165 s.blockFuncs = blockFuncs
168// Windows-specific commands that should use native shell
169var windowsNativeCommands = map[string]bool{
189// determineShellType decides which shell to use based on platform and command
190func (s *Shell) determineShellType(command string) ShellType {
191 if runtime.GOOS != "windows" {
192 return ShellTypePOSIX
195 // Extract the first command from the command line
196 parts := strings.Fields(command)
198 return ShellTypePOSIX
201 firstCmd := strings.ToLower(parts[0])
203 // Check if it's a Windows-specific command
204 if windowsNativeCommands[firstCmd] {
208 // Check for PowerShell-specific syntax
209 if strings.Contains(command, "Get-") || strings.Contains(command, "Set-") ||
210 strings.Contains(command, "New-") || strings.Contains(command, "$_") ||
211 strings.Contains(command, "| Where-Object") || strings.Contains(command, "| ForEach-Object") {
212 return ShellTypePowerShell
215 // Default to POSIX emulation for cross-platform compatibility
216 return ShellTypePOSIX
219// CreateSimpleCommandBlocker creates a CommandBlockFunc that blocks exact command matches
220func CreateSimpleCommandBlocker(bannedCommands []string) CommandBlockFunc {
221 bannedSet := make(map[string]bool)
222 for _, cmd := range bannedCommands {
223 bannedSet[cmd] = true
226 return func(args []string) bool {
230 return bannedSet[args[0]]
234// CreateSubCommandBlocker creates a CommandBlockFunc that blocks specific subcommands
235func CreateSubCommandBlocker(blockedSubCommands [][]string) CommandBlockFunc {
236 return func(args []string) bool {
237 for _, blocked := range blockedSubCommands {
238 if len(args) >= len(blocked) {
240 for i, part := range blocked {
254func (s *Shell) createCommandBlockHandler() func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
255 return func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
256 return func(ctx context.Context, args []string) error {
258 return next(ctx, args)
261 for _, blockFunc := range s.blockFuncs {
263 return fmt.Errorf("command '%s' is not allowed for security reasons", strings.Join(args, " "))
267 return next(ctx, args)
272// execWindows executes commands using native Windows shells (cmd.exe or PowerShell)
273func (s *Shell) execWindows(ctx context.Context, command string, shell string) (string, string, error) {
276 // Handle directory changes specially to maintain persistent shell behavior
277 if strings.HasPrefix(strings.TrimSpace(command), "cd ") {
278 return s.handleWindowsCD(command)
283 // Use cmd.exe for Windows commands
284 // Add current directory context to maintain state
285 fullCommand := fmt.Sprintf("cd /d \"%s\" && %s", s.cwd, command)
286 cmd = exec.CommandContext(ctx, "cmd", "/C", fullCommand)
288 // Use PowerShell for PowerShell commands
289 // Add current directory context to maintain state
290 fullCommand := fmt.Sprintf("Set-Location '%s'; %s", s.cwd, command)
291 cmd = exec.CommandContext(ctx, "powershell", "-Command", fullCommand)
293 return "", "", fmt.Errorf("unsupported Windows shell: %s", shell)
296 // Set environment variables
299 var stdout, stderr bytes.Buffer
305 s.logger.InfoPersist("Windows command finished", "shell", shell, "command", command, "err", err)
306 return stdout.String(), stderr.String(), err
309// handleWindowsCD handles directory changes for Windows shells
310func (s *Shell) handleWindowsCD(command string) (string, string, error) {
311 // Extract the target directory from the cd command
312 parts := strings.Fields(command)
314 return "", "cd: missing directory argument", fmt.Errorf("missing directory argument")
317 targetDir := parts[1]
319 // Handle relative paths
320 if !strings.Contains(targetDir, ":") && !strings.HasPrefix(targetDir, "\\") {
321 // Relative path - resolve against current directory
322 if targetDir == ".." {
323 // Go up one directory
324 if len(s.cwd) > 3 { // Don't go above drive root (C:\)
325 lastSlash := strings.LastIndex(s.cwd, "\\")
326 if lastSlash > 2 { // Keep drive letter
327 s.cwd = s.cwd[:lastSlash]
330 } else if targetDir != "." {
331 // Go to subdirectory
332 s.cwd = s.cwd + "\\" + targetDir
339 // Verify the directory exists
340 if _, err := os.Stat(s.cwd); err != nil {
341 return "", fmt.Sprintf("cd: %s: No such file or directory", targetDir), err
347// execPOSIX executes commands using POSIX shell emulation (cross-platform)
348func (s *Shell) execPOSIX(ctx context.Context, command string) (string, string, error) {
349 line, err := syntax.NewParser().Parse(strings.NewReader(command), "")
351 return "", "", fmt.Errorf("could not parse command: %w", err)
354 var stdout, stderr bytes.Buffer
355 runner, err := interp.New(
356 interp.StdIO(nil, &stdout, &stderr),
357 interp.Interactive(false),
358 interp.Env(expand.ListEnviron(s.env...)),
360 interp.ExecHandlers(s.createCommandBlockHandler()),
363 return "", "", fmt.Errorf("could not run command: %w", err)
366 err = runner.Run(ctx, line)
369 for name, vr := range runner.Vars {
370 s.env = append(s.env, fmt.Sprintf("%s=%s", name, vr.Str))
372 s.logger.InfoPersist("POSIX command finished", "command", command, "err", err)
373 return stdout.String(), stderr.String(), err
376// IsInterrupt checks if an error is due to interruption
377func IsInterrupt(err error) bool {
378 return errors.Is(err, context.Canceled) ||
379 errors.Is(err, context.DeadlineExceeded)
382// ExitCode extracts the exit code from an error
383func ExitCode(err error) int {
387 status, ok := interp.IsExitStatus(err)
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list