Repository: junegunn/fzf (go)

Issue:
[Feature Request] Customizable shell command flag for command execution
### Checklist

- [X] I have read through the manual page (`man fzf`)
- [X] I have searched through the existing issues
- [X] For bug reports, I have checked if the bug is reproducible in the latest version of fzf

### Output of `fzf --version`

0.50.0 (brew)

### OS

- [ ] Linux
- [X] macOS
- [ ] Windows
- [ ] Etc.

### Shell

- [ ] bash
- [X] zsh
- [ ] fish

### Problem / Steps to reproduce


This report was created from a discussion on a discarded PR: #3726

---

### Issue

`fzf` currently executes commands with `$SHELL` and a hardcoded `-c` flag.

https://github.com/junegunn/fzf/blob/f97d2754134607b24849fc4a2062dbfcaafddd6a/src/util/util_unix.go#L22-L29

If a user wants to use a different tool with a different flag to execute commands, they must prepend the desired command to each string, making proper quoting more cumbersome and leading to repetitive addition of the command.

```bash
# [bun] https://github.com/oven-sh/bun
fzf --bind "start:reload:bun --print \"Object.keys(Bun.env).sort().join('\n')\""
```

---

### Feature Proposal

Ability to change the flag that is passed to the `$SHELL` command.

If the flag would be subject to change, then one would only need to write the string.

```bash
# Syntax example, not actual usage
SHELL=$(which bun) FZF_SHELL_FLAG="--print" \
  fzf --bind "start:reload:Object.keys(Bun.env).sort().join('\n')"

# Different syntax example proposed by the maintainer
fzf --with-shell "bun --print" \
  --bind "start:reload:Object.keys(Bun.env).sort().join('\n')"
```

Here are a few examples of tools with their corresponding flags to illustrate the variety of flags that might be used:
- `jsc -e`: a short, single-letter option [^1]
- `bun --print`: a long, word-based option [^2]
- `osascript -l JavaScript -e`: multiple short, single-letter options and an argument [^3]

---

### Workaround
The maintainer provided a workaround [^4].

```bash
fzf2() (
  export SHELL=/tmp/fzfsh
  if ! [[ -x $SHELL ]]; then
    echo '#!/bin/sh' > "$SHELL"
    echo 'shift; echo "$@"' >> "$SHELL"
    chmod +x "$SHELL"
  fi
  command fzf "$@"
)

fzf2 --preview 'run this command: {}'
```

[^1]: [JSC – WebKit](https://trac.webkit.org/wiki/JSC)
[^2]: [Bun — A fast all-in-one JavaScript runtime](https://bun.sh/)
[^3]: [JXA - JavaScript for Automation](https://developer.apple.com/library/archive/releasenotes/InterapplicationCommunication/RN-JavaScriptForAutomation/Articles/Introduction.html)
[^4]: [PR #3726 (comment)](https://github.com/junegunn/fzf/pull/3726#issuecomment-2054007955)


Pull request:
Add --with-shell for shelling out with different command and flags
Close #3732

This hasn't been tested on Windows.

Patch:
diff --git a/.github/workflows/typos.yml b/.github/workflows/typos.yml
index 752c58c6..91f21864 100644
--- a/.github/workflows/typos.yml
+++ b/.github/workflows/typos.yml
@@ -7,4 +7,4 @@ jobs:
     runs-on: ubuntu-latest
     steps:
     - uses: actions/checkout@v4
-    - uses: crate-ci/typos@v1.19.0
+    - uses: crate-ci/typos@v1.20.9
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 51e9a603..920bceff 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,10 +3,23 @@ CHANGELOG
 
 0.51.0
 ------
+- Added `--with-shell` option to start child processes with a custom shell command and flags
+  ```sh
+  gem list | fzf --with-shell 'ruby -e' \
+    --preview 'pp Gem::Specification.find_by_name({1})' \
+    --bind 'ctrl-o:execute-silent:
+        spec = Gem::Specification.find_by_name({1})
+        [spec.homepage, *spec.metadata.filter { _1.end_with?("uri") }.values].uniq.each do
+          system "open", _1
+        end
+    '
+  ```
 - Added `change-multi` action for dynamically changing `--multi` option
     - `change-multi` - enable multi-select mode with no limit
     - `change-multi(NUM)` - enable multi-select mode with a limit
     - `change-multi(0)` - disable multi-select mode
+- `become` action is now supported on Windows
+    - Unlike in *nix, this does not use `execve(2)`. Instead it spawns a new process and waits for it to finish, so the exact behavior may differ.
 - Bug fixes and improvements
 
 0.50.0
diff --git a/man/man1/fzf.1 b/man/man1/fzf.1
index 458c6a5f..742fba5a 100644
--- a/man/man1/fzf.1
+++ b/man/man1/fzf.1
@@ -818,6 +818,16 @@ the finder only after the input stream is complete.
 e.g. \fBfzf --multi | fzf --sync\fR
 .RE
 .TP
+.B "--with-shell=STR"
+Shell command and flags to start child processes with. On *nix Systems, the
+default value is \fB$SHELL -c\fR if \fB$SHELL\fR is set, otherwise \fBsh -c\fR.
+On Windows, the default value is \fBcmd /v:on/s/c\fR when \fB$SHELL\fR is not
+set.
+
+.RS
+e.g. \fBgem list | fzf --with-shell 'ruby -e' --preview 'pp Gem::Specification.find_by_name({1})'\fR
+.RE
+.TP
 .B "--listen[=[ADDR:]PORT]" "--listen-unsafe[=[ADDR:]PORT]"
 Start HTTP server and listen on the given address. It allows external processes
 to send actions to perform via POST method.
@@ -932,6 +942,8 @@ you need to protect against DNS rebinding and privilege escalation attacks.
 .br
 .BR 2 "      Error"
 .br
+.BR 127 "    Invalid shell command for \fBbecome\fR action"
+.br
 .BR 130 "    Interrupted with \fBCTRL-C\fR or \fBESC\fR"
 
 .SH FIELD INDEX EXPRESSION
@@ -1441,8 +1453,6 @@ call.
 
     \fBfzf --bind "enter:become(vim {})"\fR
 
-\fBbecome(...)\fR is not supported on Windows.
-
 .SS RELOAD INPUT
 
 \fBreload(...)\fR action is used to dynamically update the input list
diff --git a/src/core.go b/src/core.go
index ec137698..14aa781f 100644
--- a/src/core.go
+++ b/src/core.go
@@ -121,13 +121,16 @@ func Run(opts *Options, version string, revision string) {
 		})
 	}
 
+	// Process executor
+	executor := util.NewExecutor(opts.WithShell)
+
 	// Reader
 	streamingFilter := opts.Filter != nil && !sort && !opts.Tac && !opts.Sync
 	var reader *Reader
 	if !streamingFilter {
 		reader = NewReader(func(data []byte) bool {
 			return chunkList.Push(data)
-		}, eventBox, opts.ReadZero, opts.Filter == nil)
+		}, eventBox, executor, opts.ReadZero, opts.Filter == nil)
 		go reader.ReadSource(opts.WalkerRoot, opts.WalkerOpts, opts.WalkerSkip)
 	}
 
@@ -178,7 +181,7 @@ func Run(opts *Options, version string, revision string) {
 						mutex.Unlock()
 					}
 					return false
-				}, eventBox, opts.ReadZero, false)
+				}, eventBox, executor, opts.ReadZero, false)
 			reader.ReadSource(opts.WalkerRoot, opts.WalkerOpts, opts.WalkerSkip)
 		} else {
 			eventBox.Unwatch(EvtReadNew)
@@ -209,7 +212,7 @@ func Run(opts *Options, version string, revision string) {
 	go matcher.Loop()
 
 	// Terminal I/O
-	terminal := NewTerminal(opts, eventBox)
+	terminal := NewTerminal(opts, eventBox, executor)
 	maxFit := 0 // Maximum number of items that can fit on screen
 	padHeight := 0
 	heightUnknown := opts.Height.auto
diff --git a/src/options.go b/src/options.go
index c8a3fa15..66e0554e 100644
--- a/src/options.go
+++ b/src/options.go
@@ -120,6 +120,7 @@ const usage = `usage: fzf [options]
     --read0                Read input delimited by ASCII NUL characters
     --print0               Print output delimited by ASCII NUL characters
     --sync                 Synchronous search for multi-staged filtering
+    --with-shell=STR       Shell command and flags to start child processes with
     --listen[=[ADDR:]PORT] Start HTTP server to receive actions (POST /)
                            (To allow remote process execution, use --listen-unsafe)
     --version              Display version information and exit
@@ -356,6 +357,7 @@ type Options struct {
 	Unicode      bool
 	Ambidouble   bool
 	Tabstop      int
+	WithShell    string
 	ListenAddr   *listenAddress
 	Unsafe       bool
 	ClearOnExit  bool
@@ -1327,10 +1329,6 @@ func parseActionList(masked string, original string, prevActions []*action, putA
 					actions = append(actions, &action{t: t, a: actionArg})
 				}
 				switch t {
-				case actBecome:
-					if util.IsWindows() {
-						exit("become action is not supported on Windows")
-					}
 				case actUnbind, actRebind:
 					parseKeyChordsImpl(actionArg, spec[0:offset]+" target required", exit)
 				case actChangePreviewWindow:
@@ -1957,6 +1955,8 @@ func parseOptions(opts *Options, allArgs []string) {
 				nextString(allArgs, &i, "padding required (TRBL / TB,RL / T,RL,B / T,R,B,L)"))
 		case "--tabstop":
 			opts.Tabstop = nextInt(allArgs, &i, "tab stop required")
+		case "--with-shell":
+			opts.WithShell = nextString(allArgs, &i, "shell command and flags required")
 		case "--listen", "--listen-unsafe":
 			given, str := optionalNextString(allArgs, &i)
 			addr := defaultListenAddr
@@ -2073,6 +2073,8 @@ func parseOptions(opts *Options, allArgs []string) {
 				opts.Padding = parseMargin("padding", value)
 			} else if match, value := optString(arg, "--tabstop="); match {
 				opts.Tabstop = atoi(value)
+			} else if match, value := optString(arg, "--with-shell="); match {
+				opts.WithShell = value
 			} else if match, value := optString(arg, "--listen="); match {
 				addr, err := parseListenAddress(value)
 				if err != nil {
diff --git a/src/reader.go b/src/reader.go
index 82648a68..8fa864e7 100644
--- a/src/reader.go
+++ b/src/reader.go
@@ -18,6 +18,7 @@ import (
 // Reader reads from command or standard input
 type Reader struct {
 	pusher   func([]byte) bool
+	executor *util.Executor
 	eventBox *util.EventBox
 	delimNil bool
 	event    int32
@@ -30,8 +31,8 @@ type Reader struct {
 }
 
 // NewReader returns new Reader object
-func NewReader(pusher func([]byte) bool, eventBox *util.EventBox, delimNil bool, wait bool) *Reader {
-	return &Reader{pusher, eventBox, delimNil, int32(EvtReady), make(chan bool, 1), sync.Mutex{}, nil, nil, false, wait}
+func NewReader(pusher func([]byte) bool, eventBox *util.EventBox, executor *util.Executor, delimNil bool, wait bool) *Reader {
+	return &Reader{pusher, executor, eventBox, delimNil, int32(EvtReady), make(chan bool, 1), sync.Mutex{}, nil, nil, false, wait}
 }
 
 func (r *Reader) startEventPoller() {
@@ -242,7 +243,7 @@ func (r *Reader) readFromCommand(command string, environ []string) bool {
 	r.mutex.Lock()
 	r.killed = false
 	r.command = &command
-	r.exec = util.ExecCommand(command, true)
+	r.exec = r.executor.ExecCommand(command, true)
 	if environ != nil {
 		r.exec.Env = environ
 	}
diff --git a/src/terminal.go b/src/terminal.go
index 25f30150..8d114e1b 100644
--- a/src/terminal.go
+++ b/src/terminal.go
@@ -7,7 +7,6 @@ import (
 	"io"
 	"math"
 	"os"
-	"os/exec"
 	"os/signal"
 	"regexp"
 	"sort"
@@ -245,6 +244,7 @@ type Terminal struct {
 	listenUnsafe       bool
 	borderShape        tui.BorderShape
 	cleanExit          bool
+	executor           *util.Executor
 	paused             bool
 	border             tui.Window
 	window             tui.Window
@@ -640,7 +640,7 @@ func evaluateHeight(opts *Options, termHeight int) int {
 }
 
 // NewTerminal returns new Terminal object
-func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal {
+func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor) *Terminal {
 	input := trimQuery(opts.Query)
 	var delay time.Duration
 	if opts.Tac {
@@ -736,6 +736,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal {
 		previewLabel:       nil,
 		previewLabelOpts:   opts.PreviewLabel,
 		cleanExit:          opts.ClearOnExit,
+		executor:           executor,
 		paused:             opts.Phony,
 		cycle:              opts.Cycle,
 		headerVisible:      true,
@@ -2522,6 +2523,7 @@ type replacePlaceholderParams struct {
 	allItems   []*Item
 	lastAction actionType
 	prompt     string
+	executor   *util.Executor
 }
 
 func (t *Terminal) replacePlaceholder(template string, forcePlus bool, input string, list []*Item) string {
@@ -2535,6 +2537,7 @@ func (t *Terminal) replacePlaceholder(template string, forcePlus bool, input str
 		allItems:   list,
 		lastAction: t.lastAction,
 		prompt:     t.promptString,
+		executor:   t.executor,
 	})
 }
 
@@ -2595,7 +2598,7 @@ func replacePlaceholder(params replacePlaceholderParams) string {
 		case escaped:
 			return match
 		case match == "{q}" || match == "{fzf:query}":
-			return quoteEntry(params.query)
+			return params.executor.QuoteEntry(params.query)
 		case match == "{}":
 			replace = func(item *Item) string {
 				switch {
@@ -2608,13 +2611,13 @@ func replacePlaceholder(params replacePlaceholderParams) string {
 				case flags.file:
 					return item.AsString(params.stripAnsi)
 				default:
-					return quoteEntry(item.AsString(params.stripAnsi))
+					return params.executor.QuoteEntry(item.AsString(params.stripAnsi))
 				}
 			}
 		case match == "{fzf:action}":
 			return params.lastAction.Name()
 		case match == "{fzf:prompt}":
-			return quoteEntry(params.prompt)
+			return params.executor.QuoteEntry(params.prompt)
 		default:
 			// token type and also failover (below)
 			rangeExpressions := strings.Split(match[1:len(match)-1], ",")
@@ -2648,7 +2651,7 @@ func replacePlaceholder(params replacePlaceholderParams) string {
 					str = strings.TrimSpace(str)
 				}
 				if !flags.file {
-					str = quoteEntry(str)
+					str = params.executor.QuoteEntry(str)
 				}
 				return str
 			}
@@ -2688,7 +2691,7 @@ func (t *Terminal) executeCommand(template string, forcePlus bool, background bo
 		return line
 	}
 	command := t.replacePlaceholder(template, forcePlus, string(t.input), list)
-	cmd := util.ExecCommand(command, false)
+	cmd := t.executor.ExecCommand(command, false)
 	cmd.Env = t.environ()
 	t.executing.Set(true)
 	if !background {
@@ -2965,7 +2968,7 @@ func (t *Terminal) Loop() {
 				if items[0] != nil {
 					_, query := t.Input()
 					command := t.replacePlaceholder(commandTemplate, false, string(query), items)
-					cmd := util.ExecCommand(command, true)
+					cmd := t.executor.ExecCommand(command, true)
 					env := t.environ()
 					if pwindowSize.Lines > 0 {
 						lines := fmt.Sprintf("LINES=%d", pwindowSize.Lines)
@@ -3372,27 +3375,21 @@ func (t *Terminal) Loop() {
 				valid, list := t.buildPlusList(a.a, false)
 				if valid {
 					command := t.replacePlaceholder(a.a, false, string(t.input), list)
-					shell := os.Getenv("SHELL")
-					if len(shell) == 0 {
-						shell = "sh"
-					}
-					shellPath, err := exec.LookPath(shell)
-					if err == nil {
-						t.tui.Close()
-						if t.history != nil {
-							t.history.append(string(t.input))
-						}
-						/*
-							FIXME: It is not at all clear why this is required.
-							The following command will report 'not a tty', unless we open
-							/dev/tty *twice* after closing the standard input for 'reload'
-							in Reader.terminate().
-								: | fzf --bind 'start:reload:ls' --bind 'enter:become:tty'
-						*/
-						tui.TtyIn()
-						util.SetStdin(tui.TtyIn())
-						syscall.Exec(shellPath, []string{shell, "-c", command}, os.Environ())
+					t.tui.Close()
+					if t.history != nil {
+						t.history.append(string(t.input))
 					}
+
+					/*
+					 FIXME: It is not at all clear why this is required.
+					 The following command will report 'not a tty', unless we open
+					 /dev/tty *twice* after closing the standard input for 'reload'
+					 in Reader.terminate().
+
+					   while : | fzf --bind 'start:reload:ls' --bind 'load:become:tty'; do echo; done
+					*/
+					tui.TtyIn()
+					t.executor.Become(tui.TtyIn(), t.environ(), command)
 				}
 			case actExecute, actExecuteSilent:
 				t.executeCommand(a.a, false, a.t == actExecuteSilent, false, false)
diff --git a/src/terminal_unix.go b/src/terminal_unix.go
index c7fa7f12..d0b00f2f 100644
--- a/src/terminal_unix.go
+++ b/src/terminal_unix.go
@@ -5,26 +5,11 @@ package fzf
 import (
 	"os"
 	"os/signal"
-	"strings"
 	"syscall"
 
 	"golang.org/x/sys/unix"
 )
 
-var escaper *strings.Replacer
-
-func init() {
-	tokens := strings.Split(os.Getenv("SHELL"), "/")
-	if tokens[len(tokens)-1] == "fish" {
-		// https://fishshell.com/docs/current/language.html#quotes
-		// > The only meaningful escape sequences in single quotes are \', which
-		// > escapes a single quote and \\, which escapes the backslash symbol.
-		escaper = strings.NewReplacer("\\", "\\\\", "'", "\\'")
-	} else {
-		escaper = strings.NewReplacer("'", "'\\''")
-	}
-}
-
 func notifyOnResize(resizeChan chan<- os.Signal) {
 	signal.Notify(resizeChan, syscall.SIGWINCH)
 }
@@ -41,7 +26,3 @@ func notifyStop(p *os.Process) {
 func notifyOnCont(resizeChan chan<- os.Signal) {
 	signal.Notify(resizeChan, syscall.SIGCONT)
 }
-
-func quoteEntry(entry string) string {
-	return "'" + escaper.Replace(entry) + "'"
-}
diff --git a/src/terminal_windows.go b/src/terminal_windows.go
index a1ea7a22..112cd68d 100644
--- a/src/terminal_windows.go
+++ b/src/terminal_windows.go
@@ -4,8 +4,6 @@ package fzf
 
 import (
 	"os"
-	"regexp"
-	"strings"
 )
 
 func notifyOnResize(resizeChan chan<- os.Signal) {
@@ -19,27 +17,3 @@ func notifyStop(p *os.Process) {
 func notifyOnCont(resizeChan chan<- os.Signal) {
 	// NOOP
 }
-
-func quoteEntry(entry string) string {
-	shell := os.Getenv("SHELL")
-	if len(shell) == 0 {
-		shell = "cmd"
-	}
-
-	if strings.Contains(shell, "cmd") {
-		// backslash escaping is done here for applications
-		// (see ripgrep test case in terminal_test.go#TestWindowsCommands)
-		escaped := strings.Replace(entry, `\`, `\\`, -1)
-		escaped = `"` + strings.Replace(escaped, `"`, `\"`, -1) + `"`
-		// caret is the escape character for cmd shell
-		r, _ := regexp.Compile(`[&|<>()@^%!"]`)
-		return r.ReplaceAllStringFunc(escaped, func(match string) string {
-			return "^" + match
-		})
-	} else if strings.Contains(shell, "pwsh") || strings.Contains(shell, "powershell") {
-		escaped := strings.Replace(entry, `"`, `\"`, -1)
-		return "'" + strings.Replace(escaped, "'", "''", -1) + "'"
-	} else {
-		return "'" + strings.Replace(entry, "'", "'\\''", -1) + "'"
-	}
-}
diff --git a/src/util/util_unix.go b/src/util/util_unix.go
index 2991fd2c..4410a9bf 100644
--- a/src/util/util_unix.go
+++ b/src/util/util_unix.go
@@ -3,31 +3,71 @@
 package util
 
 import (
+	"fmt"
 	"os"
 	"os/exec"
+	"strings"
 	"syscall"
 
 	"golang.org/x/sys/unix"
 )
 
-// ExecCommand executes the given command with $SHELL
-func ExecCommand(command string, setpgid bool) *exec.Cmd {
+type Executor struct {
+	shell   string
+	args    []string
+	escaper *strings.Replacer
+}
+
+func NewExecutor(withShell string) *Executor {
 	shell := os.Getenv("SHELL")
-	if len(shell) == 0 {
-		shell = "sh"
+	args := strings.Fields(withShell)
+	if len(args) > 0 {
+		shell = args[0]
+		args = args[1:]
+	} else {
+		if len(shell) == 0 {
+			shell = "sh"
+		}
+		args = []string{"-c"}
 	}
-	return ExecCommandWith(shell, command, setpgid)
+
+	var escaper *strings.Replacer
+	tokens := strings.Split(shell, "/")
+	if tokens[len(tokens)-1] == "fish" {
+		// https://fishshell.com/docs/current/language.html#quotes
+		// > The only meaningful escape sequences in single quotes are \', which
+		// > escapes a single quote and \\, which escapes the backslash symbol.
+		escaper = strings.NewReplacer("\\", "\\\\", "'", "\\'")
+	} else {
+		escaper = strings.NewReplacer("'", "'\\''")
+	}
+	return &Executor{shell, args, escaper}
 }
 
-// ExecCommandWith executes the given command with the specified shell
-func ExecCommandWith(shell string, command string, setpgid bool) *exec.Cmd {
-	cmd := exec.Command(shell, "-c", command)
+// ExecCommand executes the given command with $SHELL
+func (x *Executor) ExecCommand(command string, setpgid bool) *exec.Cmd {
+	cmd := exec.Command(x.shell, append(x.args, command)...)
 	if setpgid {
 		cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
 	}
 	return cmd
 }
 
+func (x *Executor) QuoteEntry(entry string) string {
+	return "'" + x.escaper.Replace(entry) + "'"
+}
+
+func (x *Executor) Become(stdin *os.File, environ []string, command string) {
+	shellPath, err := exec.LookPath(x.shell)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "fzf (become): %s\n", err.Error())
+		Exit(127)
+	}
+	args := append([]string{shellPath}, append(x.args, command)...)
+	SetStdin(stdin)
+	syscall.Exec(shellPath, args, environ)
+}
+
 // KillCommand kills the process for the given command
 func KillCommand(cmd *exec.Cmd) error {
 	return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
diff --git a/src/util/util_windows.go b/src/util/util_windows.go
index aa69b99d..cbaa8ce0 100644
--- a/src/util/util_windows.go
+++ b/src/util/util_windows.go
@@ -6,60 +6,102 @@ import (
 	"fmt"
 	"os"
 	"os/exec"
+	"regexp"
 	"strings"
 	"sync/atomic"
 	"syscall"
 )
 
-var shellPath atomic.Value
+type Executor struct {
+	shell     string
+	args      []string
+	shellPath atomic.Value
+}
+
+func NewExecutor(withShell string) *Executor {
+	shell := os.Getenv("SHELL")
+	args := strings.Fields(withShell)
+	if len(args) > 0 {
+		shell = args[0]
+	} else if len(shell) == 0 {
+		shell = "cmd"
+	}
+
+	if len(args) > 0 {
+		args = args[1:]
+	} else if strings.Contains(shell, "cmd") {
+		args = []string{"/v:on/s/c"}
+	} else if strings.Contains(shell, "pwsh") || strings.Contains(shell, "powershell") {
+		args = []string{"-NoProfile", "-Command"}
+	} else {
+		args = []string{"-c"}
+	}
+	return &Executor{shell: shell, args: args}
+}
 
 // ExecCommand executes the given command with $SHELL
-func ExecCommand(command string, setpgid bool) *exec.Cmd {
-	var shell string
-	if cached := shellPath.Load(); cached != nil {
+// FIXME: setpgid is unused. We set it in the Unix implementation so that we
+// can kill preview process with its child processes at once.
+// NOTE: For "powershell", we should ideally set output encoding to UTF8,
+// but it is left as is now because no adverse effect has been observed.
+func (x *Executor) ExecCommand(command string, setpgid bool) *exec.Cmd {
+	shell := x.shell
+	if cached := x.shellPath.Load(); cached != nil {
 		shell = cached.(string)
 	} else {
-		shell = os.Getenv("SHELL")
-		if len(shell) == 0 {
-			shell = "cmd"
-		} else if strings.Contains(shell, "/") {
+		if strings.Contains(shell, "/") {
 			out, err := exec.Command("cygpath", "-w", shell).Output()
 			if err == nil {
 				shell = strings.Trim(string(out), "\n")
 			}
 		}
-		shellPath.Store(shell)
+		x.shellPath.Store(shell)
 	}
-	return ExecCommandWith(shell, command, setpgid)
+	cmd := exec.Command(shell, append(x.args, command)...)
+	cmd.SysProcAttr = &syscall.SysProcAttr{
+		HideWindow:    false,
+		CreationFlags: 0,
+	}
+	return cmd
 }
 
-// ExecCommandWith executes the given command with the specified shell
-// FIXME: setpgid is unused. We set it in the Unix implementation so that we
-// can kill preview process with its child processes at once.
-// NOTE: For "powershell", we should ideally set output encoding to UTF8,
-// but it is left as is now because no adverse effect has been observed.
-func ExecCommandWith(shell string, command string, setpgid bool) *exec.Cmd {
-	var cmd *exec.Cmd
-	if strings.Contains(shell, "cmd") {
-		cmd = exec.Command(shell)
-		cmd.SysProcAttr = &syscall.SysProcAttr{
-			HideWindow:    false,
-			CmdLine:       fmt.Sprintf(` /v:on/s/c "%s"`, command),
-			CreationFlags: 0,
+func (x *Executor) Become(stdin *os.File, environ []string, command string) {
+	cmd := x.ExecCommand(command, false)
+	cmd.Stdin = stdin
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	cmd.Env = environ
+	err := cmd.Start()
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "fzf (become): %s\n", err.Error())
+		Exit(127)
+	}
+	err = cmd.Wait()
+	if err != nil {
+		if exitError, ok := err.(*exec.ExitError); ok {
+			Exit(exitError.ExitCode())
 		}
-		return cmd
 	}
+	Exit(0)
+}
 
-	if strings.Contains(shell, "pwsh") || strings.Contains(shell, "powershell") {
-		cmd = exec.Command(shell, "-NoProfile", "-Command", command)
+func (x *Executor) QuoteEntry(entry string) string {
+	if strings.Contains(x.shell, "cmd") {
+		// backslash escaping is done here for applications
+		// (see ripgrep test case in terminal_test.go#TestWindowsCommands)
+		escaped := strings.Replace(entry, `\`, `\\`, -1)
+		escaped = `"` + strings.Replace(escaped, `"`, `\"`, -1) + `"`
+		// caret is the escape character for cmd shell
+		r, _ := regexp.Compile(`[&|<>()@^%!"]`)
+		return r.ReplaceAllStringFunc(escaped, func(match string) string {
+			return "^" + match
+		})
+	} else if strings.Contains(x.shell, "pwsh") || strings.Contains(x.shell, "powershell") {
+		escaped := strings.Replace(entry, `"`, `\"`, -1)
+		return "'" + strings.Replace(escaped, "'", "''", -1) + "'"
 	} else {
-		cmd = exec.Command(shell, "-c", command)
-	}
-	cmd.SysProcAttr = &syscall.SysProcAttr{
-		HideWindow:    false,
-		CreationFlags: 0,
+		return "'" + strings.Replace(entry, "'", "'\\''", -1) + "'"
 	}
-	return cmd
 }
 
 // KillCommand kills the process for the given command


Test patch:
diff --git a/src/reader_test.go b/src/reader_test.go
index bf06fd09..56f9a1b0 100644
--- a/src/reader_test.go
+++ b/src/reader_test.go
@@ -10,9 +10,10 @@ import (
 func TestReadFromCommand(t *testing.T) {
 	strs := []string{}
 	eb := util.NewEventBox()
+	exec := util.NewExecutor("")
 	reader := NewReader(
 		func(s []byte) bool { strs = append(strs, string(s)); return true },
-		eb, false, true)
+		eb, exec, false, true)
 
 	reader.startEventPoller()
 
diff --git a/src/terminal_test.go b/src/terminal_test.go
index e7d3e751..9fc53919 100644
--- a/src/terminal_test.go
+++ b/src/terminal_test.go
@@ -23,6 +23,7 @@ func replacePlaceholderTest(template string, stripAnsi bool, delimiter Delimiter
 		allItems:   allItems,
 		lastAction: actBackwardDeleteCharEof,
 		prompt:     "prompt",
+		executor:   util.NewExecutor(""),
 	})
 }
 
@@ -244,6 +245,7 @@ func TestQuoteEntry(t *testing.T) {
 	unixStyle := quotes{``, `'`, `'\''`, `"`, `\`}
 	windowsStyle := quotes{`^`, `^"`, `'`, `\^"`, `\\`}
 	var effectiveStyle quotes
+	exec := util.NewExecutor("")
 
 	if util.IsWindows() {
 		effectiveStyle = windowsStyle
@@ -278,7 +280,7 @@ func TestQuoteEntry(t *testing.T) {
 	}
 
 	for input, expected := range tests {
-		escaped := quoteEntry(input)
+		escaped := exec.QuoteEntry(input)
 		expected = templateToString(expected, effectiveStyle)
 		if escaped != expected {
 			t.Errorf("Input: %s, expected: %s, actual %s", input, expected, escaped)
diff --git a/test/test_go.rb b/test/test_go.rb
index b08ac72b..f58b789e 100755
--- a/test/test_go.rb
+++ b/test/test_go.rb
@@ -1974,7 +1974,7 @@ class TestGoFZF < TestBase
     tmux.until { |lines| assert_equal 10, lines.item_count }
   end
 
-  def test_reload_should_terminate_stadard_input_stream
+  def test_reload_should_terminate_standard_input_stream
     tmux.send_keys %(ruby -e "STDOUT.sync = true; loop { puts 1; sleep 0.1 }" | fzf --bind 'start:reload(seq 100)'), :Enter
     tmux.until { |lines| assert_equal 100, lines.item_count }
   end