1// Package shell provides cross-platform shell execution capabilities.
3// This package provides Shell instances for executing commands with their own
4// working directory and environment. Each shell execution is independent.
6// WINDOWS COMPATIBILITY:
7// This implementation provides POSIX shell emulation (mvdan.cc/sh/v3) even on
8// Windows. Commands should use forward slashes (/) as path separators to work
9// correctly on all platforms.
23 "github.com/charmbracelet/x/exp/slice"
24 "mvdan.cc/sh/moreinterp/coreutils"
25 "mvdan.cc/sh/v3/expand"
26 "mvdan.cc/sh/v3/interp"
27 "mvdan.cc/sh/v3/syntax"
30// ShellType represents the type of shell to use
34 ShellTypePOSIX ShellType = iota
39// Logger interface for optional logging
40type Logger interface {
41 InfoPersist(msg string, keysAndValues ...any)
44// noopLogger is a logger that does nothing
45type noopLogger struct{}
47func (noopLogger) InfoPersist(msg string, keysAndValues ...any) {}
49// BlockFunc is a function that determines if a command should be blocked
50type BlockFunc func(args []string) bool
52// Shell provides cross-platform shell execution with optional state persistence
58 blockFuncs []BlockFunc
61// Options for creating a new shell
66 BlockFuncs []BlockFunc
69// NewShell creates a new shell instance with the given options
70func NewShell(opts *Options) *Shell {
75 cwd := opts.WorkingDir
85 // Allow tools to detect execution by Crush.
102 blockFuncs: opts.BlockFuncs,
106// Exec executes a command in the shell
107func (s *Shell) Exec(ctx context.Context, command string) (string, string, error) {
111 return s.exec(ctx, command)
114// ExecStream executes a command in the shell with streaming output to provided writers
115func (s *Shell) ExecStream(ctx context.Context, command string, stdout, stderr io.Writer) error {
119 return s.execStream(ctx, command, stdout, stderr)
122// GetWorkingDir returns the current working directory
123func (s *Shell) GetWorkingDir() string {
129// SetWorkingDir sets the working directory
130func (s *Shell) SetWorkingDir(dir string) error {
134 // Verify the directory exists
135 if _, err := os.Stat(dir); err != nil {
136 return fmt.Errorf("directory does not exist: %w", err)
143// GetEnv returns a copy of the environment variables
144func (s *Shell) GetEnv() []string {
148 env := make([]string, len(s.env))
153// SetEnv sets an environment variable
154func (s *Shell) SetEnv(key, value string) {
158 // Update or add the environment variable
159 keyPrefix := key + "="
160 for i, env := range s.env {
161 if strings.HasPrefix(env, keyPrefix) {
162 s.env[i] = keyPrefix + value
166 s.env = append(s.env, keyPrefix+value)
169// SetBlockFuncs sets the command block functions for the shell
170func (s *Shell) SetBlockFuncs(blockFuncs []BlockFunc) {
173 s.blockFuncs = blockFuncs
176// CommandsBlocker creates a BlockFunc that blocks exact command matches
177func CommandsBlocker(cmds []string) BlockFunc {
178 bannedSet := make(map[string]struct{})
179 for _, cmd := range cmds {
180 bannedSet[cmd] = struct{}{}
183 return func(args []string) bool {
187 _, ok := bannedSet[args[0]]
192// ArgumentsBlocker creates a BlockFunc that blocks specific subcommand
193func ArgumentsBlocker(cmd string, args []string, flags []string) BlockFunc {
194 return func(parts []string) bool {
195 if len(parts) == 0 || parts[0] != cmd {
199 argParts, flagParts := splitArgsFlags(parts[1:])
200 if len(argParts) < len(args) || len(flagParts) < len(flags) {
204 argsMatch := slices.Equal(argParts[:len(args)], args)
205 flagsMatch := slice.IsSubset(flags, flagParts)
207 return argsMatch && flagsMatch
211func splitArgsFlags(parts []string) (args []string, flags []string) {
212 args = make([]string, 0, len(parts))
213 flags = make([]string, 0, len(parts))
214 for _, part := range parts {
215 if strings.HasPrefix(part, "-") {
216 // Extract flag name before '=' if present
218 if before, _, ok := strings.Cut(part, "="); ok {
221 flags = append(flags, flag)
223 args = append(args, part)
229func (s *Shell) blockHandler() func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
230 return func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
231 return func(ctx context.Context, args []string) error {
233 return next(ctx, args)
236 for _, blockFunc := range s.blockFuncs {
238 return fmt.Errorf("command is not allowed for security reasons: %q", args[0])
242 return next(ctx, args)
247func (s *Shell) builtinHandler() func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
248 return func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
249 return func(ctx context.Context, args []string) error {
251 return next(ctx, args)
257 hc := interp.HandlerCtx(ctx)
258 return handleJQ(args, hc.Stdin, hc.Stdout, hc.Stderr)
260 return next(ctx, args)
266// newInterp creates a new interpreter with the current shell state
267func (s *Shell) newInterp(stdout, stderr io.Writer) (*interp.Runner, error) {
269 interp.StdIO(nil, stdout, stderr),
270 interp.Interactive(false),
271 interp.Env(expand.ListEnviron(s.env...)),
273 interp.ExecHandlers(s.execHandlers()...),
277// updateShellFromRunner updates the shell from the interpreter after execution.
278func (s *Shell) updateShellFromRunner(runner *interp.Runner) {
281 for name, vr := range runner.Vars {
283 s.env = append(s.env, name+"="+vr.Str)
288// execCommon is the shared implementation for executing commands
289func (s *Shell) execCommon(ctx context.Context, command string, stdout, stderr io.Writer) (err error) {
290 var runner *interp.Runner
292 if r := recover(); r != nil {
293 err = fmt.Errorf("command execution panic: %v", r)
296 s.updateShellFromRunner(runner)
298 s.logger.InfoPersist("command finished", "command", command, "err", err)
301 line, err := syntax.NewParser().Parse(strings.NewReader(command), "")
303 return fmt.Errorf("could not parse command: %w", err)
306 runner, err = s.newInterp(stdout, stderr)
308 return fmt.Errorf("could not run command: %w", err)
311 err = runner.Run(ctx, line)
315// exec executes commands using a cross-platform shell interpreter.
316func (s *Shell) exec(ctx context.Context, command string) (string, string, error) {
317 var stdout, stderr bytes.Buffer
318 err := s.execCommon(ctx, command, &stdout, &stderr)
319 return stdout.String(), stderr.String(), err
322// execStream executes commands using POSIX shell emulation with streaming output
323func (s *Shell) execStream(ctx context.Context, command string, stdout, stderr io.Writer) error {
324 return s.execCommon(ctx, command, stdout, stderr)
327func (s *Shell) execHandlers() []func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
328 handlers := []func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc{
333 handlers = append(handlers, coreutils.ExecHandler)
338// IsInterrupt checks if an error is due to interruption
339func IsInterrupt(err error) bool {
340 return errors.Is(err, context.Canceled) ||
341 errors.Is(err, context.DeadlineExceeded)
344// ExitCode extracts the exit code from an error
345func ExitCode(err error) int {
349 var exitErr interp.ExitStatus
350 if errors.As(err, &exitErr) {
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