Using the Z shell on the Mac and Pi

When Apple released MacOS Catalina, it decided to switch the default command line shell from the Bourne Again Shell, aka bash, to the Z Shell, aka zsh. One reason for this was that Apple installs a rather old version of bash, 3.3.57, to allow it to include the software under a licence it’s happy with. This isn’t a problem that affects zsh, so Apple can bundle a much more recent release.

That was no problem for me, either, because I long ago used Homebrew to install an up-to-date version of bash, 5.0.17, and have been happily using in preference to the Apple one. To do so yourself, run brew install bash and then go to System Preferences > Users & Groups. Unlock if you need to then right-click on your name in the left-hand column and select Advanced Options…. Now highlight the Login shell: field and set the path to your preferred shell, in this case /usr/local/bin/bash. Afterwards, you can enter echo $SHELL to confirm the change.

However, I recently read a piece on Hackaday about alternatives to bash. It mentioned zsh and this prompted me to take a closer look at the shell I’d been ignoring since upgrading to Catalina.

There are a fair few blog posts around that cover zsh on the Mac, but most focus on sprucing up the command line with the many themes available over at ohymyzsh! Good stuff, I’m sure, but not what I’m after. I like to keep my command line fairly streamlined, so what I really wanted was guidance on making zsh operate the way I want it to.

Having spent some time with zsh, I thought I’d share my setup for those folks who likewise want to try zsh out without being steered toward installing a bunch of themes and plug-ins. I’ve also included instructions for using zsh with a Raspberry Pi.

Install zsh on the Pi

Speaking of the Pi, zsh isn’t installed by default so you’ll need to add it yourself:

sudo apt install zsh

From this point the two platforms are much the same. You can run zsh from your existing prompt to switch to a zsh sub-shell, or go the whole hog and start using zsh as your primary shell. For the latter, run:

chsh -s $(which zsh)

then enter your password when prompted. Quit Terminal or logout as appropriate, then go back in. Mac users can also use System Preferences > Users & Groups as described above.

Configure zsh

zsh uses the file ~/.zshrc for its per-user settings. I generated this manually because it allows you to build out the file as you learn what it needs to contain. First, I added three lines:

setopt AUTO_CD
setopt CORRECT
unsetopt NOMATCH

These are zsh options. The first lets you change directory just by keying in the path — there’s no need to enter cd first. The second enables zsh’s ability to suggest correct commands when you mis-key one. This will ask you about items it doesn’t understand and give the four options: n, y, a and e for, respectively, use as typed, accept the suggested correction, abort, or edit the command. If you have AUTO_CD set, zsh will attempt to correct mistakes in paths you type.

The third line clears a pre-set option: to report an error whenever zsh resolves a file path that doesn’t exist. You might want this, but many of my scripts check for the presence of paths so I don’t want extra warnings when the script already takes care of that.

Auto-completion

Next, I set up auto-completion:

autoload -Uz compinit && compinit

That’s sufficient for the Pi, but on the Mac, with its insensitivity to case, I needed to add:

zstyle ':completion:*' matcher-list 'm:{[:lower:][:upper:]}={[:upper:][:lower:]}' 'm:{[:lower:][:upper:]}={[:upper:][:lower:]} l:|=* r:|=*' 'm:{[:lower:][:upper:]}={[:upper:][:lower:]} l:|=* r:|=*' 'm:{[:lower:][:upper:]}={[:upper:][:lower:]} l:|=* r:|=*'

That’s courtesy of Scripting OSX, by the way. zstyle is a zsh command that’s used to format certain strings when output by functions. We’ll see it again shortly when we look at Git integration.

The autoload command auto-inserts the built-in function for you so that you don’t have to add it to your script manually.

Auto-completion works by hitting Tab on a partially complete path or command: the first Tab makes the most likely suggestion if one can be made, a second Tab presents a list of all possible completions; keep pressing Tab until the right one appears at the prompt:

zsh_auto

Set the primary prompt

Now for the prompt itself. Here’s what I started out with:

PROMPT='%F{magenta}%K{white}%d%k%f > '

zsh can use PROMPT as a proxy for PS1 to make what you’re setting more clear. The line decodes as follows:

  • %F — Set the foreground colour.
  • {<value>} — The foreground colour: in this case magenta, a zsh pre-defined value, but I could have used any TERM256 colour index. Here’s a good list — you want the Xterm Number column.
  • %K — Set the background colour.
  • {<value>} — The background colour.
  • %d — Print the current directory. This is in long form; I could have used %~ which shortens the path to the home directory to ~ when appropriate, but I prefer the full path. If I’d have written %4d, zsh would display the last four entries of the path.
  • %k — Set background colour to default.
  • %f — Set foreground colour to default.
  • > — My choice of prompt character with spaces on either side. The default is %.

This is fun: add the following string to the prompt string:

%(?.%F{green}%?%f.%F{red}%?%f)

This is an example of zsh’s conditional prompt structure. The . separates the three fields: condition, true text, false text. The first ? evaluates to true if the exit code of the previous command was 0; in that case we show a green 0. The exit code was not zero, ? evaluates to false, and a red result code is displayed:

zsh_02

The first call, a plain ls, works and so the exit code is 0 — displayed in green. As you can see, the cd doesn’t work, so the exit code is 1 — displayed in red.

Set the secondary prompt for Git

zsh also supports a right-hand prompt, which I use with another zsh feature: version control software integration.

The shell has a function, vcs_info, which returns a string containing data about the current directory — if it is a version control repository. We can extract useful data from this string using the aforementioned zstyle utility and, with a bit of scripting, generate useful output which we can add to zsh’s RPROMPT variable.

Here’s the code:

setopt PROMPT_SUBST
autoload -Uz add-zsh-hook
autoload -Uz vcs_info
add-zsh-hook precmd git_prompt
zstyle ':vcs_info:*' enable git
zstyle ':vcs_info:*' check-for-changes false
zstyle ':vcs_info:git*' formats '%b'

Here’s setopt again, using to enable dynamic prompt text substitution. The next two lines use autoload to load up and retain a couple of zsh functions, including vcs_info, but add-zsh-hook is called first, on the next line, to register the function git_prompt to be called before zsh displays a prompt. We’ll look at this function in a moment.

The last lines call on zstyle to make sure the output of vcs_info includes git information but not dynamically, only by checking the directory state. The last line sets the output of vcs_info: it just presents the current branch.

Let’s see how this all ties together, which takes place when git_prompt is called:

git_prompt() {
    is_dirty() {
        test -n "$(git status --porcelain --ignore-submodules)"
    }
    vcs_info
    local ref="$vcs_info_msg_0_"
    if [[ -n "$ref" ]]; then
        ref="%F{cyan}[${ref}]%f"
        if is_dirty; then
            ref="%F{yellow}%B!%b%f ${ref}"
        else
            ref="%F{green}%B#%b%f ${ref}"
        fi
        RPROMPT=$ref
    else
        RPROMPT=''
    fi
}

Remember, this is run every time zsh has to present the prompt. The code calls vcs_info and puts its output (stored in the environment variable vcs_info_msg_0_) in ref. If this is an empty string, we’re not in a directory that’s under version control, so we just clear the vase of RPROMPT; if we are in an a repo directory, we define the right-hand prompt string (see above for the meanings of %F etc.) then use the just-defined convenience function is_dirty to check whether there are unstaged changes in the repo. If there are, we prefix the prompt with a bold yellow ! as a warning; if not we just add a green #. Finally, we set RPROMPT to the value of the string we’ve constructed.

The result is something like this:

zsh_01

The /GitHub directory doesn’t contain a repo so it has no right-hand prompt. But /GitHub/dotfiles is a repo so it does — and, as you can see, we have the master branch checked out and there are unstaged changes.

Aliases

Aliases work much in zsh as they do in bash, but with a couple of handy extras. Add the -s option to force the alias to be used only if the final characters of your entered text match. This is best shown with an example; here’s the classic:

alias -s md='open -a xcode'

Now when I type test.md, the file is opened in Xcode rather than VSCode.

The other alias switch that zsh provides is -g which applies the alias if there’s a match anywhere in the command you typed, not just at the start (the default). An example:

alias -g O='| col -b'
man zshmisc O > zsh.txt

Run this and, in the second line, the O is replaced to format the output of man, which is then written to a text file.

You can view my current Mac and Pi .zshrc files (they’re similar but slightly different) in my dotfiles repo:

More on the Z Shell