This is a low-resolution version of the logo of Windows XP by Microsoft, protected by copyright

Switch between Windows and POSIX shells

Notes published the
16 - 19 minutes to read, 3893 words

I often use POSIX-like shells (bash, zsh, …​) on Windows machines. Thanks to Cygwin, WSL, git-bash, and virtual machines, there are multiple ways to have a POSIX-like environment even on Windows.

One great feature such environments can provide is the ability to integrate more or less seamlessly with the Windows environment. Being able to start Windows executables directly from bash is a game-changer, and it makes it possible to work efficiently from the command line on Windows too, without creating a working environment completely different from most Linux machines.

Nevertheless, invoking some Windows tools from Cygwin, or invoking some cygwin tools from Windows is cumbersome or problematic, thus I often create some helper functions/programs for opening shells at the current location.

Note 📝
Unless noted otherwise, the snippets have been written with Cygwin in mind. With minor changes, they should work in WSL and git-bash too.

Start a POSIX shell from Windows

Open from cmd, powershell, or another Windows Cygwin console in the current path.

Most of the time, a simple C:/path/to/cygwin/bin/login-shell.exe --login with the environment variable CHERE_INVOKING=1 in the desired directory, is enough.

From cmd:

@echo off
set CHERE_INVOKING=1
C:/path/to/cygwin/bin/shell-I-want-to-execute.exe --login

From PowerShell:

$env:CHERE_INVOKING=1
& C:/path/to/cygwin/bin/shell-I-want-to-execute.exe --login

From other programs that might accept a string to "execute" directly

cmd /C "set CHERE_INVOKING=1 && C:/path/to/cygwin/bin/shell-I-want-to-execute.exe --login"

In some environments, for example from the Midnight Commander port for Windows, I need to also set TERM=xterm-256color, otherwise TERM is set to dos-console:

CHERE_INVOKING=1 TERM=xterm-256color C:/path/to/cygwin/bin/shell-I-want-to-execute.exe --login

Those simple solutions work extremely well for most cases, one does not even need to quote the paths, but it fails when

  • subprocesses are is started at a different directory

  • subprocesses are not started at all

  • it is not possible to cd to the desired directoy in cmd or PowerShell and thus execute the cygwin shell with CHERE_INVOKING=1

Granted, those scenarios do not happen often, and thus the proposed approaches are mostly good enough.

Start an embedded POSIX shell from PowerShell from a problematic PATH

The described limitations can be partially overcome by starting the POSIX shell in another directory, and cd in the POSIX shell where you want to.

What I’ve realized, is that while starting subprocesses is problematic, executing a PowerShell script from PowerShell directly does not seem to create a new subprocess, and if it does actually create new processes, it creates them always at the right location.

Thus a PowerShell script can be executed from all paths, just like ls, as long as it is executed directly from PowerShell!

Inside the script it is possible to cd somewhere else, and the execute the external programs.

By putting those ideas together, after some testing I came up with:

cygwin-here.ps1
if(!args){
  $PATHTOGO_ORIG = Convert-Path .;
} else {
  $PATHTOGO_ORIG = Convert-Path $args[0];
}

Push-Location C:/;
try {
  $PATHTOGO = & C:/path/to/cygwin/bin/cygpath.exe -wa -- $PATHTOGO_ORIG.TrimStart("\\?\");
  $PATHTOGO = $PATHTOGO.replace("'", "'\''");
  & C:/path/to/cygwin/bin/sh.exe -c "cd -- '$PATHTOGO' && CHERE_INVOKING=1 exec /bin/shell-I-want-to-execute --login";
} finally {
  Pop-Location;
}

Note that I’m using Push-Location instead of cd, because cd $PATHTOGO_ORIG failed for some paths. And, since it is user-provided, it could be something different from the current working directory.

In cmd, executing a .cmd script invokes a new process, and thus it seem that there does not exist a similar workaround.

Note that this approach can be used for "enhancing" Windows programs too.

For example, the file manager far seems to support all possible pats, but far . . (open the file manager at the current location) might not start at all, or might be executed at the wrong location.

If you want to execute far . . from PowerShell, then using the following script makes it possible to execute it from any location:

far-here.ps1
if(!args){
  $PATHTOGO_ORIG = Convert-Path .;
} else {
  $PATHTOGO_ORIG = Convert-Path $args[0];
}

Push-Location C:/;
try {
  & "C:/Program Files/Far Manager/Far.exe" "$PATHTOGO" "$PATHTOGO";
} finally {
  Pop-Location;
}

In practice, I often work on sane paths, thus the .cmd scripts are good enough.

In particular, most tools where it is possible to execute custom commands will start a new process, thus the workaround shown in cygwin-here.ps1 and far-here.ps1 would not help.

Create a new tmux window from Windows

Since I often use tmux, I often want to open a new window in my running tmux instance at the current path of my PowerShell instance.

A C:/path/to/cygwin/bin/dash.exe -l -c '/usr/bin/tmux new-window -c .' should be sufficient, but Cygwin starts new shells always in the home directory. In the case of tmux, even when CHERE_INVOKING is set.

If you do not want to change the default /etc/profile file, then you have to set CHERE_INVOKING two times:

tmux-new-window-here.cmd
@echo off
set CHERE_INVOKING=1
C:/path/to/cygwin/bin/dash.exe -l -c '/usr/bin/tmux new-window -e CHERE_INVOKING=1 -c "$PWD" '

CHERE_INVOKING needs to be redefined for every subprocess, as it gets undefined in /etc/profile, for this reason, tmux has -e CHERE_INVOKING=1 as an additional parameter.

Note 📝
by default cyhwin sh.exe seems to be bash, thus I’m explicitly using dash here, which might be faster.

Instead of the login shell, it is possible to execute other programs directly in tmux too

tmux-new-mc-here.cmd
@echo off
set CHERE_INVOKING=1
C:/path/to/cygwin/bin/dash.exe -l -c '/usr/bin/tmux new-window -e CHERE_INVOKING=1 -c "$PWD" "mc"'

If you leave the parameter -l out, this is the cleanest way I have to start a PowerShell shell environment directly in tmux

tmux-new-powershelle-here.cmd
@echo off
set CHERE_INVOKING=1
C:/path/to/cygwin/bin/dash.exe -l -c '/usr/bin/tmux new-window -e CHERE_INVOKING=1 -c "$PWD" "powershell.exe -NoLogo"'

Start a shell in a separate window (mintty)

The approach is similar to starting a new shell instance

mintty-here.cmd
@echo off
set CHERE_INVOKING=1
C:/path/to/cygwin/bin/mintty.exe -

If you want to start a different shell, then you need to invoke it explicitly instead of using -, for example

mintty-bash-here.cmd
@echo off
set CHERE_INVOKING=1
C:/path/to/cygwin/bin/mintty.exe --exec bash --login

And if you want to execute a program directly, like a file manager

mintty-mc-here.cmd
@echo off
set CHERE_INVOKING=1
C:/path/to/cygwin/bin/mintty.exe --exec /usr/bin/mc

If you want to execute something that is non-interactive, and keep the console open so that the use can see the output, add --hold always as parameter to mintty, for example

mintty-ls-parent.cmd
@echo off
set CHERE_INVOKING=1
C:/path/to/cygwin/bin/mintty.exe --hold always --exec /usr/bin/ls ..

You can also use mintty to start Windows programs directly.

Until Windows 10, Windows programs that supports mouse interaction in the console will not work correctly. Since Windows 11, everything works out of the box. I’m not sure what is responsible for this different behaviour (reproducible with other programs too), but it is a welcome improvement.

Start a Windows program or shell in a clean environment

For most Windows executables, being executed from Cygwin is not an issue.

Of course are are exceptions, and in many cases, most issues can be avoided by redefining some environment variables, for example, $HOME.

Resetting $PATH is more difficult; at that point, it would make sense to start the executable in a "clean" environment. By clean I mean "as if the executable was started outside of the Cygwin environment".

Most issues arise when the wrong executable is invoked, from example vim of cygwin in cmd, instead of the vim port of Windows. Thus the most affected programs are shells, and other program that will execute external programs that are in the current $PATH.

Start a Windows program in a clean environment in a separate window

The only way I found to start a program in a clean environment ⁠[1], is to abuse explorer.exe.

From cmd, this is how it is possible to start a new cmd instance at the current location

start /i "C:\Windows\explorer.exe" "C:\Windows\system32\cmd.exe"
Note 📝
/i alone is not sufficient for creating a clean environment

Using this as a starting point, I wanted to be able to start PowerShell (or another program)

  • in a separate console

  • with a clean environment

  • in the current directory

Avoid any quoting, save the parameter to a temporary file

Ensuring that parameters are passed around correctly is not trivial, on top of that, you have to quote the variables for PowerShell, cmd and for the POSIX environment too.

As Windows is full of paths containing whitespaces, for example, C:/Program Files/, this is not something that can be ignored.

The first working approach I used was to store the path in a file and let PowerShell use the path as-is from the file:

# from cygwin
cygpath -wa "$PATHTOGO" > /tmp/powershell-path.txt
PSHPATH="'$(cygpath -wa /tmp/powershell-path.txt)'";

# from PowerShell
cd -literal (Get-Content $env:PSHPATH)

The "generic" solution looks like the following:

poshw with two temporary files
#!/bin/sh

CYGPATH=cygpath;
if command -v wslpath>/dev/null; then :;
  CYGPATH=wslpath;
fi

PWSH="$("$CYGPATH" -wa "$(which powershell.exe)")";

PATHTOGO=$("$CYGPATH" -wa "${1:-.}");
if [ ${#PATHTOGO} -le 3 ]; then :;
  # drive, do nothing
elif [ -n "${USE_UNC_PATHS+x}" ]; then :;
  # shellcheck disable=SC1003
  case $PATHTOGO in
    '\\'*) ;; # already in unc form
    *)  PATHTOGO='\\?\'"$PATHTOGO"; # convert to unc path
  esac
fi

# shellcheck disable=SC2016
WINDOWTITLECMD='$host.ui.RawUI.WindowTitle=\"powershell\"';
OTHERCMD='';
printf '%s' "$PATHTOGO" > /tmp/powershell-path.txt;
PSHPATH="'$(cygpath -wa /tmp/powershell-path.txt)'";
printf '@echo off
%s -NoLogo -NoExit -Command "cd -literal (Get-Content -encoding UTF8 %s);%s;%s"
' "$PWSH" "$PSHPATH" "$WINDOWTITLECMD" "$OTHERCMD" > /tmp/open-here.cmd

cd 'C:/';
cmd.exe /C start /d 'C:\Windows' /i 'C:\Windows\explorer.exe' "$("$CYGPATH" -wa /tmp/open-here.cmd)"

Note that OTHERCMD is a placeholder in case you want to execute something else after starting PowerShell in the desired directory.

I’ve used poshw as a template for other scripts, like opening interactive programs for the terminal at specific locations, or setting specific environment variables, the only necessary change was to define OTHERCMD appropriately.

Note that OTHERCMD needs to be quoted appropriately, similarly to WINDOWTITLECMD. It is very error-prone.

The cd 'C:\' is necessary as it is not possible to execute a cmd script from some directories.

"$(cygpath -wa /tmp/open-here.cmd)" seems to always be a valid path for explorer.exe. Since /tmp is normally located in the user directory, I ensured it also works with paths with spaces and single quotes. I guess that if you are creative enough you can break it, but at that point you might have bigger issues.

Escaping paths

Because of <reasons>, I decided I wanted to avoid the temporary file containing the path, at least if I only want to open a shell and dont execute other programs. Thus I had to quote somehow the path.

This is the current solution:

poshw with one temporary files
#!/bin/sh

CYGPATH=cygpath;
if command -v wslpath>/dev/null; then :;
  CYGPATH=wslpath;
fi

PWSH="$("$CYGPATH" -wa "$(which powershell.exe)")";

PATHTOGO=$("$CYGPATH" -wa "${1:-.}");
if [ ${#PATHTOGO} -le 3 ]; then :;
  # drive, do nothing
elif [ -n "${USE_UNC_PATHS+x}" ]; then :;
  # shellcheck disable=SC1003
  case $PATHTOGO in
    '\\'*) ;; # already in unc form
    *)  PATHTOGO='\\?\'"$PATHTOGO"; # convert to unc path
  esac
fi

PATHTOGO=$(printf "%s" "$PATHTOGO" |sed -r "s/'/''/g");
printf "@echo off
title powershell
chcp 65001 >nul
%s -NoLogo -NoExit -Command cd -literal '%s'
" "$PWSH" "$PATHTOGO" > /tmp/open-here.cmd

cd 'C:/';
cmd.exe /C start /d 'C:\Windows' /i 'C:\Windows\explorer.exe' "$("$CYGPATH" -wa /tmp/open-here.cmd)"

In this context, cd wants a path escaped with '. " and ` are not good enough, they all failed on certain paths.

' are escaped with sed -r "s/'/''/g"; to escape a ', just add a second one.

The codepage is changed to unicode (chcp 65001), otherwise the .cmd script might terminate with an error if the path contains some unicode characters.

Start interactive programs in a new console in the current directory

As mentioned in my first approach, by defining OTHERCMD it is possible to execute multiple commands. One common command I executed was far . .;exit, which opens the far file manager in the desired directory, and once it is closed, the entire is closed.

far also works from cmd, and accepts the path as paramter. Since starting PowerShell is slow…​ restricting myself to only cmd then executing the other program made the whole process faster (and even more robust, see the next sections).

farw
#!/bin/sh

CYGPATH=cygpath;
if command -v wslpath >/dev/null; then :;
  CYGPATH=wslpath;
fi

DIR1=$("$CYGPATH" -wa "${1:-.}");
DIR2=$("$CYGPATH" -wa "${2:-.}");

# shellcheck disable=SC1003
case "$DIR1" in
  *'\') DIR1="$DIR1"'\';;
  *) :;;
esac
# shellcheck disable=SC1003
case "$DIR2" in
  *'\') DIR2="$DIR2"'\';;
  *) :;;
esac

printf '@echo off
chcp 65001 > nul
"C:\Program Files\Far Manager\Far.exe" "%s" "%s"
' "$DIR1" "$DIR2"  > /tmp/open-here.cmd

chmod +x /tmp/open-here.cmd

cd 'C:/';
cmd.exe /C start /d 'C:\Windows' /i 'C:\Windows\explorer.exe' "$("$CYGPATH" -wa /tmp/open-here.cmd)"

A \ si appended to the path if it already ends with \, otherwise a path like "C:\" breaks the quote.

The parameters for far are quoted with "; for cd this was not sufficient. Since " are not part of a path, I find quoting with " easier to reason about, even if I need to handle paths terminating with \.

Since paths are absolute, even a directory named -h is not problematic. far changes by it’s own the title of the console, thus no need to use title.

In the case of Midnight Commander, some parsing of the parameters make it impossible to access some folders. The easiest workaround would be to cd to the desired directory and then execute mc. As cmd cannot access some paths that are accessible through PowerShell, the best solution would probably still stick to my first iteration of powsh, but I wanted to show an example with a .cmd file and cd in cmd, so here it is:

mcw
#!/bin/sh

CYGPATH=cygpath;
if command -v wslpath>/dev/null; then :;
  CYGPATH=wslpath;
fi

DIR1=$("$CYGPATH" -wa "${1:-.}");

# shellcheck disable=SC1003
case "$DIR1" in
  *'\') DIR1="$DIR1"'\';;
  *) :;;
esac

cd 'C:/';
printf '@echo off
chcp 65001 > nul
cd /d "%s"
"C:\Program Files (x86)\Midnight Commander\mc.exe" . .
' "$DIR1" > tmp/open-here.cmp;

chmod +x /tmp/open-here.cmd;

cmd.exe /C start /d 'C:\Windows' /i 'C:\Windows\explorer.exe' "$("$CYGPATH" -wa /tmp/open-here.cmd)"

What is noteworthy in this example is that one should always to write cd /d <path> instead of cd <path> when working with absolute paths. If one does not do so, cmd will not change the drive, and the current working directory will not change if the desired directory is on another drive.

Start a Windows program in a clean environment in the current shell

I currently did not find a way to do it without crafting the correct environment by hand, which is extremely error prone and fragile. The workaroung with explorer.exe always creates a new window.

Since I was asked how I currently create such environment, here is the shell script I’m using.

posh-clean
#!/usr/bin/env bash

set -o errexit
set -o nounset
set -o pipefail

IFS=$'\n';
PATHFORPS="";
declare -A vars;

# read system settings
for i in $(regtool --list list '/HKLM/SYSTEM/CurrentControlSet/Control/Session Manager/Environment'); do :;
  v="$(regtool get '/HKLM/SYSTEM/CurrentControlSet/Control/Session Manager/Environment/'"$i")";
  if [ "${i^^}" = "PATH" ]; then :;
    PATHFORPS="$v";
  else :;
    vars["${i^^}"]="$v";
  fi
done;
# do not read everything from HKLM/SOFTWARE/Microsoft/Windows/CurrentVersion, only specific values
vars["COMMONPROGRAMFILES"]="$(     regtool get '/HKLM/SOFTWARE/Microsoft/Windows/CurrentVersion/CommonFilesDir'       )";
vars["COMMONPROGRAMFILES(X86)"]="$(regtool get '/HKLM/SOFTWARE/Microsoft/Windows/CurrentVersion/CommonFilesDir (x86)' )";
vars["COMMONPROGRAMW6432"]="$(     regtool get '/HKLM/SOFTWARE/Microsoft/Windows/CurrentVersion/COMMONW6432DIR'       )";
vars["PROGRAMFILES"]="$(           regtool get '/HKLM/SOFTWARE/Microsoft/Windows/CurrentVersion/PROGRAMFILESDIR'      )";
vars["PROGRAMFILES(X86)"]="$(      regtool get '/HKLM/SOFTWARE/Microsoft/Windows/CurrentVersion/PROGRAMFILESDIR (X86)')";
vars["PROGRAMW6432"]="$(           regtool get '/HKLM/SOFTWARE/Microsoft/Windows/CurrentVersion/PROGRAMW6432DIR'      )";
# read user settings
for i in $(regtool --list list '/HKCU/Environment'); do :;
  v="$(regtool get '/HKCU/Environment/'"$i")";
  if [ "${i^^}" = "PATH" ]; then :;
    PATHFORPS="$PATHFORPS:$v";
  else :;
    vars["${i^^}"]="$v";
  fi
done;
# read other user settings
for i in $(regtool --list list '/HKCU/Volatile Environment'); do :;
  v="$(regtool get '/HKCU/Volatile Environment/'"$i")";
  if [ "${i^^}" = "PATH" ]; then :;
    PATHFORPS="$PATHFORPS:$v";
  else :;
    vars["${i^^}"]="$v";
  fi
done;
# PATH needs to be in cygwin format as it will get converted again when invoking a Windows program
vars["PATH"]=$(cygpath --path "$PATHFORPS");

# add missing variables
COMMONAPPDATA=$(regtool get '/HKLM/SOFTWARE/Microsoft/Windows/CurrentVersion/Explorer/Shell Folders/Common AppData');
vars["PROGRAMDATA"]="$COMMONAPPDATA";
vars["ALLUSERSPROFILE"]="$COMMONAPPDATA";
vars["SYSTEMDRIVE"]='C:';  # AFAIK fixed since Windows 10
vars["PUBLIC"]="$(cygpath -wa "$(regtool get '/HKLM/SOFTWARE/Microsoft/Windows/CurrentVersion/Explorer/Shell Folders/Common Desktop')"/..)";
vars["COMPUTERNAME"]="$HOSTNAME";

# missing values
#  * FPS_BROWSER_APP_PROFILE_STRING with value "Internet Explorer"
#  * FPS_BROWSER_USER_PROFILE_STRING with value "Default"
#  * SESSIONNAME with value "Console"
# since those values are also missing when powershell is started from a cmd script or cygwin shell, they cannot be that important
# note that when powershell is started from a cmd script, then also PROMPT with value "$P$G" is defined, but has no effect
# some variables like SYSTEMROOT are missing, but they as env variable in powershell

# expand common variables
# need a more flexible solution
USERPROFILE="${vars[USERPROFILE]}";
SYSTEMROOT='C:\WINDOWS'; # AFAIK fixed at least since Windows 10
vars2=();
for i in "${!vars[@]}" ; do :;   # slow, avoid subprocess at all costs
  var="${vars[$i]}";
  var="${var//%SystemRoot%/$SYSTEMROOT}";
  var="${var//%USERPROFILE%/$USERPROFILE}";
  vars2+=( "${i}=$var" );
done

exec /usr/bin/env --ignore-environment "${vars2[@]}" /c/windows/system32/windowspowershell/v1.0/powershell.exe -NoLogo "$@"

Somehow, creating vars2 from vars takes more or less the same amount of time for creating vars. My first iteration used sed, and startup times where an order of magnitude slower.

Apart slow startup times (it takes between 4 and 10 seconds, even longer if the machine is under heavy workload) and a couple of missing variables; there might be other gotchas. I used this script in only a limited number of environments, thus I have no idea if all registry values are always defined, what the correct fallbacks are, and so on. Also it fails miserably if you need to expand something different than userprofile and systemroot.

For this and other reasons, I would prefer a less hand-crafted solution.

Open a shell at the current location over SSH

I also use a virtual machine with a Linux environment, and I use SSH to work on it. Since I do not want to synchronize the content between the guest and host OS, the files are saved on the host, and I’ve set up a shared folder in Virtualbox so that I can access the same files from the guest too.

Just like I wanted to open a Cygwin console in the current directory, I also wanted to open a shell on the VM in the current directory.

Since in general the client and server are not on the same machine, the concept of opening a remote shell at the current location is uncommon. Thus in this case I needed to provide as a parameter the current working directory to the host and map it to the folder on the shared drive. Since I map the shared drive to a common path in all environment, this part is easy, and with printf and the formatter %q, there is no need to escape the variable manually:

ssh-here
PATHTOGO=$(printf '%q' "${1:-.}");
ssh -t username@remote "cd -- $PATHTOGO && exec bash"

%q escapes the variable appropriately, there is no need to do it by hand. The format specifier is supported by bash and zsh, and is unfortunately not part of POSIX.

Note 📝
This example assumes that the quoting rules for the shell started by ssh is similar to the quoting rules where printf is invoked. As explained on unix.stackexchange, printf with %q is not always safe, but in this case, "${1:-.}" is a path and should never be empty, undefined, or contain NUL (which, admitted, this snippet does not verify).

Start a shell in a separate window (Windows Terminal)

If you use Wndows Terminal (installed by default since Windows 11), then you can take advantage of some of it’s features, like splitting windows, creating multiple tabs or executing commands:

wt.exe --window 0 new-tab -d "$PATH_TO_GO"

What I did not realize, is that all commands are started outside of the cygwin environment, even if there is no previous wt.exe instance running.

Thus, this approach could be preferred to explorer.exe executing temporary files, unless you have to support problematic paths.

Conclusion

The presented cmd scripts are good enough for most use-cases. If you work from PowerShell, you might get further if you carefully controll where you start the processes.


1. I realized only later that Windows terminal offers this functionality

Do you want to share your opinion? Or is there an error, some parts that are not clear enough?

You can contact me anytime.