Zshell

Table of Contents

No, I haven’t eschewed my beloved Eshell, but at work, I’m often required to work with Bash snippets and code. This requires using a standard shell environment. This file includes my notes on Zshell, as well my configuration, when tangled.

Do I prefer Zshell over Bash? Just barely. Most of its bells and whistles are quite annoying for some one who spends more time in Emacs that a shell. Still, I like these features:

Syntax coloring
helps flag potential errors (but I do not like autocorrect, as that is more often wrong).
Fail/Success Dot
At the beginning of the command prompt, you can see if the job passed.
Easy completion
The plugins with OMZ often set up option completion
Auto CD
This means that popd usually just works.
ZBell
The waiting indicators and alerts on long running jobs is something I love in my eshell setup.

Regardless, I keep my shell configuration conspicuously light.

Configuration

Let’s create the following files, and the configuration below will be injected into one of them:

~/.zshenv
Usually run for every zsh
~/.zprofile
Usually run for login shells (this includes the system-wide /etc/zprofile)
~/.zshrc
Run for interactive shells … default file when tangling
~/.zlogin
Run for login shells … seems to run as often as .zshrc

Path

The all important PATH environment variable, needs my special bin directory.

export PATH=$HOME/bin:$HOME/.local/bin:$PATH

My Apple Macbook screws up my PATH by having /etc/profile (that runs after my ~/.zshenv) pre-pend system directories like /bin and /usr/bin after I’ve set up my PATH environment variable. So, in my own .zprofile (which runs afterwards), I reverse it using the lovely tac program:

if [[ -f /etc/zprofile ]]
then
    # Reverse the PATH variable
    reversed_path=$(echo $PATH | tr ':' '\n' | tac | tr '\n' ':')

    # Reset the path after removing the trailing colon:
    export PATH=${reversed_path%:}

    # Output the reversed PATH
    # echo "Reversed PATH: $reversed_path"
fi

Options

When the command is the name of a directory, perform the cd command to that directory:

setopt AUTO_CD

Make cd push the old directory onto the directory stack so that popd always works:

setopt AUTO_PUSHD

Print the working directory after a cd, but only if that was magically expanded:

setopt NO_CD_SILENT

Automatically list choices on an ambiguous completion:

setopt AUTO_LIST

Automatically use menu completion after the second consecutive request for completion:

setopt AUTO_MENU

Try to make the completion list smaller (occupying less lines) by printing the matches in columns with different widths:

setopt LIST_PACKED

On an ambiguous completion, instead of listing possibilities or beeping, insert the first match immediately. Then when completion is requested again, remove the first match and insert the second match, etc.

setopt MENU_COMPLETE

Homebrew

When using Homebrew on a Mac, we need to add its PATH:

if [[ -d /opt/homebrew ]]
then
  eval $(/opt/homebrew/bin/brew shellenv zsh)
fi

This adds the following environment variables, along with expanding the PATH.

export HOMEBREW_PREFIX="/opt/homebrew";
export HOMEBREW_CELLAR="/opt/homebrew/Cellar";
export HOMEBREW_REPOSITORY="/opt/homebrew";
[ -z "${MANPATH-}" ] || export MANPATH=":${MANPATH#:}";
export INFOPATH="/opt/homebrew/share/info:${INFOPATH:-}";

ZShell Styles

The Zsh Book has a nice chapter treatment on zstyle, also, explaining in detail its various fields.

Tab Completion

Use autoload to install compinit, the completion system:

autoload -U compinit; compinit

Do I need this?

zstyle ':completion:*:*:cp:*' file-sort modification reverse
zstyle ':completion:*:*:mv:*' file-sort modification reverse

Selecting options using Tab, arrows, and C-p / C-n:

zmodload zsh/complist

Do I want to use hyphen-insensitive completion, so that _ and - will be interchangeable?

HYPHEN_INSENSITIVE="true"

Waiting Indication

Display red dots whilst waiting for commands to complete.

COMPLETION_WAITING_DOTS="true"

You can also set it to another string to have that shown instead of the default red dots.

COMPLETION_WAITING_DOTS="%F{yellow}waiting...%f"

Oh My Zshell

Some plugins for Zshell are nice, so let’s install Oh My Zshell:

sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"

Set it’s location:

export ZSH=$HOME/.oh-my-zsh

Syntax Coloration

The ZShell Syntax Highlighting project provides Fish shell-like syntax highlighting for ZShell. This was my killer feature for using Fish, but I need the standard Bash-compatible syntax. Now I can have both. Let’s install this project in coordination with Oh My Zshell:

git clone https://github.com/zsh-users/zsh-syntax-highlighting.git ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-syntax-highlighting

Using the colorize plugin (see plugins section below), we customize with this variable setting:

export ZSH_COLORIZE_STYLE="coffee"

Language Support

Anything special for particular languages.

Python

Not overly impressed, for to get pyenv to work, we need to add this code:

export PYENV_ROOT="$HOME/.pyenv"
export PATH="$PYENV_ROOT/bin:$PATH"

And call the pyenv to initialize it:

# eval "$(pyenv init --path)"

Plugins

Configure the plugins, making sure to not use git, as the aliases are a pain to remember when I already have a superior Git interface in Emacs.

colorize
syntax-highlight file contents, so install Pygments first. Then call ccat and cless (see Aliases).
direnv
to support the direnv virtual environment project.
mise
to support the mise virtual environment and tool project (instead of direnv). See the walk-through.
gnu-utils
bind the GNU flavor for standard utils, like gfind to the normal version, e.g. find.
iterm2
while fully configured below, configures the interaction with the MacOS application, iTerm2.
macos
adds new functions that work better with MacOS terminals and the Finder. I like:
tab
To open a new terminal tab
cdf
To open a directory in the Finder, meh. Why not change this to open it in dired in Emacs?
quick-look
To view a file
pyenv
Call the pyenv init and whatnot.
zbell
To beep when a long running command has completed. Similar to my beep command.

To have a plugin install, add its name to the plugins array variable before we source the OMZ script:

plugins=(colorize direnv gnu-utils iterm2 macos mise virtualenv zbell zsh-syntax-highlighting)

Notice the iterm2 plugin as well as the macos plugins that would be nice to figure out how to make them optionally added (although I believe they check themselves).

The trick is to install some base-level plugins, and then, on my work computer, install more by appending to the plugins array variable:

if hostname | grep AL33 >/dev/null
then
  plugins+=(ansible argocd docker helm kubectl)
fi

I would like to have a history per project, so that when I start a session for a project, my history has where I left off for that project and not everything. I guess I’m not the only one who thought of this idea. We first need to add his plugin to the list of supplied plugins, so do this once:

git clone https://github.com/ivan-cukic/zsh-per-project-history ~/.oh-my-zsh/plugins/per-project-history

His idea is to have a variable array, PER_PROJECT_HISTORY_TAGS that lists files that should identify the start of a project, and while his default seems sufficient, I am not found of the spammy message, so:

declare -a PER_PROJECT_HISTORY_TAGS
export PER_PROJECT_HISTORY_TAGS=(.envrc .git)
declare -r PER_PROJECT_HISTORY_TAGS

we just need to add per-history to the list of plugins:

plugins+=(per-project-history)

Now that I’ve filled in the plugins variable, load OMZ and the plugins:

source $ZSH/oh-my-zsh.sh

Do we want to waste time during startup to update this? These can be:

disabled
disable automatic updates
auto
update automatically without asking
reminder
remind me to update when it’s time
zstyle ':omz:update' mode auto
zstyle ':omz:update' frequency 13

We’ll Check every 13 days.

Prompt

Either keep it simple:

PS1='%(?.%F{green}.%F{red})$%f%b '

Oh use the absolute over-the-top bling associated with Oh My Zshell’s themes, like:

ZSH_THEME="robbyrussell"

I keep the prompt simple since all of the gunk we typically put in a prompt is better placed in iTerm2’s Status Bar.

Homebrew

When using Homebrew on a Mac, we need to add its PATH and environment variables. This is typically done by running the command:

eval $(brew shellenv zsh)

We want to add the path and environment variables into the ~/.zshenv file, but this file should not contain any logic or code. So, let’s run the command from Emacs, and store the results in the file.

The full script to run is:

echo '# -*- mode:sh; -*-'
if which brew >/dev/null
then
    if [[ -d /opt/homebrew ]]
    then
        /opt/homebrew/bin/brew shellenv zsh
    else
        brew shellenv zsh
    fi
fi

Seems that if I want the GNU versions (instead of the old ones supplied by Apple), I have to do it myself:

echo '# -*- mode:sh; -*-'
if which brew >/dev/null
then
    for PKG in binutils gettext unzip openssl texinfo mysql-client openjdk
    do
        if PKG_INSTALL=$(brew --prefix $PKG)
        then
            echo export PATH=$PKG_INSTALL/bin:'$PATH'
        fi
    done

    for PKG in coreutils ed findutils gnu-indent gnu-sed gnu-tar grep make
    do
        if PKG_INSTALL=$(brew --prefix $PKG)
        then
            echo export PATH=$PKG_INSTALL/libexec/gnubin:'$PATH'
        fi
    done
fi

And linking all the GNU libraries:

echo '# -*- mode:sh; -*-'

if which brew >/dev/null
then
    for PKG in readline openssl xz binutils ctags libgccjit imagemagick
    do
        if PKG_INSTALL=$(brew --prefix $PKG)
           echo export LDFLAGS=\"-L$PKG_INSTALL/lib '$LDFLAGS'\"
           echo export CPPFLAGS=\"-I$PKG_INSTALL/include '$CPPFLAGS'\"
    done
    echo export LDFLAGS=\"'$LDFLAGS' -L$(brew --prefix)/lib\"
    echo export CPPFLAGS=\"'$CPPFLAGS' -I$(brew --prefix)/include\"
fi

And pull in all the results into the ~/.zshenv file (why yes, this could be inlined):

[[ -f $HOME/.zshenv_brew ]] && source $HOME/.zshenv_brew
[[ -f $HOME/.zshenv_gnu  ]] && source $HOME/.zshenv_gnu
[[ -f $HOME/.zshenv_lib  ]] && source $HOME/.zshenv_lib

iTerm2

On Mac systems, I like the iTerm2 application, and we can enable shell integration, either via the old school way, or just rely on the plugin above:

test -e "${HOME}/.iterm2_shell_integration.zsh" && source "${HOME}/.iterm2_shell_integration.zsh"

Also, while use the title command to change the Terminal’s title bar, don’t let the prompt or other Zshell features do that:

DISABLE_AUTO_TITLE="true"

Favorite feature is the Status Bar at the bottom of the screen that shows the Git branch, current working directory, etc. This allows my prompt to be much shorter. What other information I want has changed over the years, but I define this information with this function:

Currently, I show the currently defined Kube namespace.

function iterm2_python_version() {
    echo $(pyenv version-name):$(echo "$VIRTUAL_ENV" | sed "
                    s|^$HOME|~|
                    s|^~/src/wpc-gerrit.inday.io/||
                    s|^~/work/||
                    s|^~/.venv/||
                    s|/\.venv$||
                    s|\.venv$||")
}

function iterm2_print_user_vars() {
    # iterm2_set_user_var kubecontext $($ yq '.users[0].name' ~/.kube/config):$(kubectl config view --minify --output 'jsonpath={..namespace}')

    # Correct version:
    # iterm2_set_user_var kubecontext $(kubectl config current-context):$(kubectl config view --minify --output 'jsonpath={..namespace}')
    # Faster version:
    iterm2_set_user_var kubecontext $(awk '/^current-context:/{print $2;exit;}' <~/.kube/config)

    iterm2_set_user_var pycontext $(iterm2_python_version)
}

Add the following:

function pycontext {
  local version venvstr
  version=$(pyenv version-name)
  venvstr=$(echo $VIRTUAL_ENV | sed 's/.*.venv\///')
  echo "🐍 $version:$venvstr"
}

Emacs

While Oh My Zshell has an emacs plugin, I’m not crazy about it. I guess I need more control.

While it should figure out (as Emacs keybindings are the default), this is how we ensure it:

bindkey -e

Where be the emacsclient? It should, at this point, be in our path.

And how should we call it?

export EMACS_SOCKET_NAME=personal

Which needs to be overwritten on my Work computer:

if hostname | grep AL33 >/dev/null
then
  export EMACS_SOCKET_NAME=work
fi

The EDITOR variable that some programs use to edit files from the command line:

export EDITOR="emacsclient --tty"
export VISUAL="emacsclient --create-frame"

With these variables defined, we can create simple aliases:

alias e="$EDITOR"
alias te="$EDITOR"
alias ee="emacsclient --create-frame"
alias eee="emacsclient --create-frame --no-wait"

Vterm

To work with VTerm in Emacs, we need to create this function:

vterm_printf() {
    if [ -n "$TMUX" ] \
        && { [ "${TERM%%-*}" = "tmux" ] \
            || [ "${TERM%%-*}" = "screen" ]; }
    then
        # Tell tmux to pass the escape sequences through
        printf "\ePtmux;\e\e]%s\007\e\\" "$1"
    elif [ "${TERM%%-*}" = "screen" ]; then
        # GNU screen (screen, screen-256color, screen-256color-bce)
        printf "\eP\e]%s\007\e\\" "$1"
    else
        printf "\e]%s\e\\" "$1"
    fi
}

This allows us to execute Emacs commands:

vterm_cmd() {
    local vterm_elisp
    vterm_elisp=""
    while [ $# -gt 0 ]; do
        vterm_elisp="$vterm_elisp""$(printf '"%s" ' "$(printf "%s" "$1" | sed -e 's|\\|\\\\|g' -e 's|"|\\"|g')")"
        shift
    done
    vterm_printf "51;E$vterm_elisp"
}

For instance:

if [[ "$INSIDE_EMACS" = 'vterm' ]]
then
    alias clear='vterm_printf "51;Evterm-clear-scrollback";tput clear'

    vim() {
        vterm_cmd find-file "$(realpath "${@:-.}")"
    }
fi

Aliases

Assuming we’ve installed lsd, let’s make an alias for it:

if which lsd >/dev/null
then
  alias ls=lsd
fi

The ccat project (like bat) adds syntax coloring to text files. For big files, it certainly slows down the output, and I’m wondering if I want these aliases.

if whence cless >/dev/null
then
  alias less=cless
  alias cat=ccat
fi

Other alternate improvements, like fd should be called directly.

And an abstraction for transitory endpoints over SSH:

alias ossh="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o loglevel=ERROR"

And other ones that I use:

alias os=openstack
alias k=kubectl

Final Message

For sensitive work-related environment variables, store them elsewhere, and load them:

test -e "${HOME}/.zshenv-work" && source "${HOME}/.zshenv-work"

To let us know we read the ~/.zshrc file:

echo "🐚 ZShell Session"