This is an excerpt from my book “Moving to zsh” which is available for order on the Apple Books Store.
One of the advantages of zsh over bash 3 is the support of “associative arrays,” a data structure known as hash tables or dictionaries in other languages.
In associative arrays, you can store a piece of data, or value with an identifying ‘key’. For example, the associative array userinfo has multiple values, each identified with a key:
In zsh, before you can use a variable as an associative array, you have to declare it as one with
declare -A userinfo
This will tell the shell that the userinfo variable is an associative array. You can also use typeset -A as an alternative syntax. You can verify the type of the variable:
% echo ${(t)userinfo}
association
You can then set the key-value pairs of the userinfo associative array individually:
Setting the values for each key is useful in some situations, but can be tedious. You can also set the entire associative array at once. There are two syntaxes for this in zsh:
userinfo=( name armin shell zsh website scriptingosx.com )
This format follows the format ( key1 value1 key2 value2 ...). The other syntax is more verbose and expressive:
When you set the associative array variable this way, you are overwriting the entire array. For example, if you set the userinfo for ‘armin’ like above and then set it later like this, the website key and value pair will have been overwritten as well:
% userinfo=( [name]=beth [shell]=zsh )
% if [[ -z $userinfo[website] ]]; then echo no value; fi
no value
If you want to partially overwrite an existing associative array, while leaving the other key/value pairs intact, you can use the += operator:
You can also use this to loop through all the keys and values of an associated array:
for key value in ${(kv)userinfo}; do
echo "$key -> $value"
done
#output
website -> fishshell.com
shell -> fish
name -> beth
Limitations
Associative arrays have their uses, but are not as powerful as dictionaries in more powerful languages. In zsh, you cannot nest associative arrays in normal arrays, which limits their use for complex data structures.
There is also no functionality to transfer certain file formats, like XML or property lists directly in to associative arrays or back.
Shell scripting was never designed for complex data structures. When you encounter these limitations, you should move “up” to a higher level language, such as Python or Swift.
During my testing in the Catalina beta version I was able to download 10.15, 10.14.6, 10.14.5, and 10.13.6. I was not able to test if 10.13.6 would download the hardware specific build of 10.13.6 for the 2018 MacBook Pro, since I do not have that hardware.
I would assume that downloading an Installer application for a macOS version that is not supported on the hardware you are running the command on would fail. (Again I did not have such hardware available for testing.)
So far the only way to download the macOS Installer in some automated fashion was using Greg Neagle’s installinstallmacos.py script. That script still has some abilities that do not seem to be available to the softwareupdate command, but it is good to see Apple accepting the need for this kind of workflow.
That post contains an example adapted from the ‘Pro git’ documentation which shows how to display the current branch and repo in the prompt.
Personally, I don’t like the Terminal window to be cluttered up with repeated information. While the information from the git repository does change over time, it does not change that frequently, and the information gets repeated over and over.
While I was researching the last post, which describes how to display the current working directory in Terminal’s window title bar, I learnt that you can also set a window title using a different escape code.
So, instead of repeating the git information on every prompt, we can show it in the window or tab title bar.
You can find the code to set this up in my dotfiles repository on Github. To use it make sure the git_vcs_setup file is in your fpath and load it in your .zshrc with
# Git status
autoload -U git_vcs_setup && git_vcs_setup
Note that this file sets up two functions: update_terminal_window_title and update_terminal_tab_title. Terminal can have separate information titles for tabs and the entire window. When there are no tabs (or technically, just a single tab) Terminal displays both titles.
The status info above will show the repository, the current branch, a U when there are unstaged changes, and a S when there are staged, but uncommitted changes.
If you want more details in the status, you might want to consider using a more powerful solution to retrieve the git status, such as this project.
While I was working on customizing my zsh configuration files for my zsh article series, I noticed that Terminal would not display the current working directory when using zsh. it worked fine when I switched back to bash.
Note: showing the working directory in the window or tab title is enabled by default in Terminal. You can configure what Terminal shows in the title in the Preferences Window for each profile. Select a profile and
Some sleuthing showed me that /etc/bashrc_Apple_Terminal sets up a update_terminal_cwd function which sends some escape codes to Terminal to keep the window title bar updated. This file is sourced by /etc/bashrc for the Terminal.app.
In macOS 10.14 Mojave and earlier, this configuration file has no equivalent for zsh. /etc/zshrc has code that would load /etc/zshrc_Apple_Terminal, if it existed. However, this file does not exist in macOS Mojave and earlier.
It does in Catalina, though, and it sets up a similar function that is added into the precmd hook. If you have access to the Catalina beta, you can just copy the /etc/zshrc_Apple_Terminal to your Mojave (or earlier) Mac and it will work.
Alternatively, you can write your own implementation, which is what I did, because I wanted it before the Catalina files existed. My solution exists of the function and its setup in its own file. This file is in a directory that is added to the fpath so I can simply load it with autoload.
# path to my zsh functions folder:
my_zsh_functions=$repo_dir/dotfiles/zshfunctions/
# include my zshfunctions dir in fpath:
if [[ -d $my_zsh_functions ]]; then
fpath=( $my_zsh_functions $fpath )
fi
# only for Mojave and earlier
if [[ $(sw_vers -buildVersion) < "19" ]]; then
# this sets up the connection with the Apple Terminal Title Bar
autoload -U update_terminal_pwd && update_terminal_pwd
fi
And from now, the title will be updated in Mojave (and earlier) and the function won’t even be loaded in Catalina.
This might seem a bit overkill, now that the functionality is in the Catalina file, but it might serve as a useful example when you want to customize, or re-implement similar behavior.
The upcoming macOS 10.15 Catalina will require more apps and tools to be notarized. Apple has somewhat loosened the requirements at last minute, but these changed limitations are only temporary, to give developers more time to adapt.
Notarizing Mac Application bundles has its pitfalls, but is overall fairly well documented. However, I have been working on some command line tools written in Swift 5 and figured out how to get those properly signed and notarized.
Howard Oakley has written up his experiences and that post was extremely helpful. But there were a few omissions and some steps that aren’t really necessary, so I decided to make my own write-up.
And yes, there is a script at the end…
Note: these instructions are for macOS 10.14.6 Mojave, Xcode 10.3 and Swift 5.0. It is very likely that the details will change over time.
Update 2019-09-24: Tested with Xcode 11 and it still works (the screen layout has changed for some of the options)
What do you need?
Apple Developer Account (Personal or Enterprise, the free account does not provide the right certificates)
Xcode 10.3 or 11
Developer ID Certificates (Application and Install)
Application Specific Password for your Developer account
a Command Line Tool Project that you want to sign and notarize
That’s a longish list. If you are already building command line tools in Xcode, you should have most of these covered already. We will walk through the list step-by-step:
You cannot get the required certificates with a free Apple Developer account, unless you are member of a team that provides access.
Xcode
You can download Xcode from the Mac App Store or the developer download page. When you launch Xcode for the first time, it will prompt for some extra installations. Those are necessary for everything to in the article to work.
Developer ID Certificates
There are multiple certificates you can get from the Developer Program. By default you get a ‘Mac Developer’ certificate, which you can use for building and testing your own app locally.
To distribute binaries (apps and command line tools) outside of the App Store, you need a ‘Developer ID Application’ certificate. To sign installer packages for distribution outside of the Mac App Store, you need a ‘Developer ID Installer’ certificate.
We will need both types of Developer ID certificates, the first to sign the command line tool and the second to sign and notarize the installer package.
If you have not created these yet, you can do so in Xcode or in the Developer Portal. If you already have the certificates but on a different Mac, you need to export them and re-import them on the new Mac. Creating new certificates might invalidate the existing certificates! So beware.
Once you have created or imported the certificates on your work machine, you can verify their presence in the Terminal with:
% security find-identity -p basic -v
This command will list all available certificates on this Mac. Check that you can see the ‘Developer ID Application’ and ‘Developer ID Installer’ certificates. If you are a member of multiple teams, you may see multiple certificates for each team.
You can later identify the certificates (or ‘identities’) by the long hex number or by the descriptive name, e.g. "Developer ID Installer: Armin Briegel (ABCD123456)"
The ten character code at the end of the name is your Developer Team ID. Make a note of it. If you are a member of multiple developer teams, you can have multiple Developer ID certificates and the team ID will help you distinguish them.
Application Specific Password for your Developer Account
Apple requires Developer Accounts to be protected with two-factor authentication. To allow automated workflows which require authentication, you can create application specific passwords.
Create a new application specific password in Apple ID portal for your developer account.
You will only be shown the password when you create it. Immediately create a ‘New Password Item’ in your Keychain with the following fields:
Keychain Item Name: Developer-altool
Account Name: your developer account email
Password: the application-specific password you just created
This will create a developer specific password item that we can access safely from the tools.
If you want, you can also store the app specific password in a different password manager, but the Xcode tools have a special option to use Keychain.
A Command Line Tool Project
You may already have a project to create a command line in Xcode. If you don’t have one, or just want a new one to experiment, you can just create a new project in Xcode and choose the ‘Command Line Tool’ template from ‘macOS’ section in the picker. The template creates a simple “Hello, world” tool, which you can use to test the notarization process.
My sample project for this article will be named “hello.”
Preparing the Xcode Project
The default settings in the ‘Command Line Tool’ project are suitable for building and testing the tool on your Mac, but need some changes to create a distributable tool.
Choosing the proper signing certificates
Before you can notarize the command line tool, it needs to be signed with the correct certificates.
in Xcode, select the blue project icon in the left sidebar
select the black “terminal” icon with your project’s name under the “Targets” list entry
make sure the ‘General’ tab is selected
under ‘Signing’ disable ‘Automatically manage signing’
under ‘Signing (Debug)’ choose your Team and choose ‘Developer ID Application’ as the certificate
under ‘Signing (Release)’ choose your Team and choose ‘Developer ID Application’ as the certificate
Setting the certificates
Enable Hardened Runtime
Enabling the ‘Hardened Runtime’ will compile the binary in a way that makes it harder for external process to inject code. This will be requirement for successful notarization starting January 2020.
from the view where you changed the signing options, click on ‘Build Settings’ in the upper tab row
click on ‘All’ to show all available settings
enter ‘enable hardened’ in the search field, this will show the ‘Enable Hardened Runtime’ setting
set the value in the project column (blue icon) to YES
Enable Hardened Runtime
Change the Install Build Location
If we want to automate the packaging and notarization, we need to know where Xcode builds the binary. The default location is in some /tmp subdirectory and not very convenient. We will change the location for the final binary (the ‘product’) to the build subdirectory in the project folder:
in the same view as above, enter ‘Installation Build’ in the search field, this will show the ‘Installation Build Products Location’ setting
double click on the value in the Project column (blue icon), this will open a popup window
change the value to $SRCROOT/build/pkgroot
Change the Installation Build location
If you manage your code in git or another VCS, you want to add the build subdirectory to the ignored locations (.gitignore)
Build the Binary
You can use Xcode to write, test, and command line tool debug your. When you are ready to build and notarize a pkg installer, do the following:
open Terminal and change directory to the project folder
% xcodebuild clean install
This will spew a lot of information out to the command line. You will see a build subdirectory appear in the project folder, which will be filled with some directories with intermediate data.
After a successful build you should see a pkgroot directory in the build folder, which contains your binary in the usr/local/bin sub-path.
/usr/local/bin is the default location for command line tools in the Command Line Tool project template. It suits me fine most of the time, but you can change it by modifying the ‘Installation Directory’ build setting in Xcode and re-building from the command line.
Build the pkg
Command Line Tools can be signed, but not directly notarized. You can however notarize a zip, dmg, or pkg file containing a Command Line Tool. Also, it is much easier for users and administrators to install your tool when it comes in a proper installation package.
We can use the pkgroot directory as our payload to build the installer package:
I have broken the command into multiple lines for clarity, you can enter the command in one line without the end-of-line backslashes \. You want to replace the values for the identifier, version and signing certificate with your data.
This will build an installer package which would install your binary on the target system. You should inspect the pkg file with Pacifist or Suspicious Package and do a test install on a test system to verify everything works.
If you want to learn more about installer packages and pkgbuild read my book “Packaging for Apple Administrators.”
Notarizing the Installer Package
Xcode has a command line tool altool which you can use to upload your tool for notarization:
The asc-provider is your ten digit Team ID. If you are only a member in a single team you do not need to provide this.
The password uses a special @keychain: keyword that tells altool to get the app-specific password out of a keychain item named Developer-altool. (Remember we created that earlier?)
This will take a while. When the command has successfully uploaded the pkg to Apple’s Notarization Servers, it will return a RequestUUID. Your notarization request will be queued and eventually processed. You can check the status of your request with:
Apple will also send an email to your developer account when the process is complete. I my experience this rarely takes more than a minute or two. (Being in Central EU time zone might be an advantage there). When the process is complete, you can run the above notarization-info command to get some details. The info will include a link that contains even more information, which can be useful when your request is rejected.
Note that the info links expire after 24 hours or so. You should copy down any information you want to keep longer.
Completing the Process
You will not receive anything back from Apple other than the confirmation or rejection of your request. When a Mac downloads your installer package and verifies its notarization status it will reach out to Apple’s Notarization servers and they will confirm or reject the status.
If the Mac is offline at this time, or behind a proxy or firewall that blocks access to the Apple Servers, then it cannot verify whether your pkg file is notarized.
You can, however, ‘staple’ the notarization ticket to the pkg file, so the clients do not need to connect to the servers:
% xcrun stapler staple build/hello-1.0.pkg
You can also use stapler to verify the process went well:
% xcrun stapler validate build/hello-1.0.pkg
But since stapler depends on the developer tools to be installed, you should generally prefer spctl to check notarization:
Obviously, I built a script to automate all this. Put the following script in the root of the project folder, modify the variables at the start of the script (lines 20–38) with your information, and run it.
The script will build the tool, create a signed pkg, upload it for notarization, wait for the result, and then staple the pkg.
You can use this script as an external build tool target in Xcode. There are other ways to integrate scripts for automation in Xcode, but all of this is a new area for me and I am unsure which option is the best, and which I should recommend.
Links and Videos
These links and videos, especially Howard Oakley’s post and Tom Bridge’s PSU Presentation have been hugely helpful. Also thanks to co-worker Arnold for showing me this was even possible.
Notarization is a key part of Apple’s security strategy going in macOS.
As MacAdmins we will usually deploy software through management systems, where the Gatekeeper mechanisms which evaluate notarization are bypassed. There are, however, already special cases (Kernel Extensions) where notarization is mandatory. It is likely that Apple will continue to tighten these requirements in the future. The macOS Mojave 10.14.5 update has shown that Apple may not even wait for major releases to increase the requirements.
If you are building your own tools and software for macOS and plan to distribute the software to other computers, you should start signing and notarizing.
On the other hand, I find the introduction of Notarization to macOS encouraging. If Apple wanted to turn macOS into a “App Store only system” like iOS, they would not have needed to build the notarization process and infrastructure. Instead, Apple seems to have embraced third-party-software from outside the App Store.
Notarization allows Apple to provide a security mechanism for software distributed through other means. It cannot be 100% effective, but when used correctly by Apple and the software developers it will provide a level of validation and trust for software downloaded from the internet.
The release notes for Catalina also tell us that other built-in scripting runtimes, namely Python, Perl, and Ruby. Will not be included in future macOS releases (post-Catalina) any more.
This means, that if you want to use bash, Python, Perl, or Ruby on macOS, you will have to install, and maintain your own version in the future.
However, scripts in installation packages, cannot rely on any of these interpreters being available in future, post-Catalina versions of macOS. Installer pkgs can be run in all kinds of environments and at all times, and you would not want them to fail, because a dependency is missing.
The good news is that we still have time. All the runtimes mentioned above are still present in Catalina, so the packages will continue to work for now. But if you are building installation scripts, you need to check if any of the installation scripts use one of these interpreters and fix them.
I recommend to use /bin/sh for installation scripts, since that will run in any macOS context, even the Recovery system.
If you are using third-party installer packages, you may also want to check them for these interpreters, and notify the developer that these packages will break in future versions of macOS.
To check a flat installer package, you would expand it with pkgutil --expand and then look at script files in the Scripts folder. This will work fine for a package or two, but gets tedious really quickly, especially with large distribution pkgs with many components (e.g. Office).
So… I wrote a script to do it. The script should handle normal component pkgs, distribution pkgs and the legacy bundle pkgs and mpkgs.
Once I had written the code to inspect all these types of pkgs, I realized I could grab all other kinds of information, as well. The pkgcheck.sh script will check for:
Signature and Notarization
Type of Package: Component, Distribution, legacy bundle or mpkg
Identifier and version (when present)
Install-location
for Distribution and mpkg types, shows the information for all components as well
for every script in a pkg or component, checks the first line of the script for shebangs of the deprecated interpreters (/bin/bash, /usr/bin/python, /usr/bin/perl, and /usr/bin/ruby) and print a warning when found
How to run pkgcheck.sh
Run the script with the target pkg file as an argument:
% ./pkgcheck.sh sample.pkg
You can give more than one file:
% ./pkgcheck.sh file1.pkg file2.pkg ...
When you pass a directory, pkgcheck.sh will recursively search for all files or bundle directories with the pkg or mpkg extension in that directory:
% ./pkgcheck.sh SamplePkgs
Features and Errors
There are a few more things that I think might be useful to check in this script. Most of all, I want to add an indicator whether a component is enabled by default or not. If you can think of any other valuable data to display, let me know. (Issue or Pull Request or just ping me on MacAdmins Slack)
I have tested the script against many pkgs that I came across. However, there are likely edge cases that I haven’t anticipated, which might break the script. If you run into any of those, let me know. (File an Issue or Pull Request.) Having the troublesome pkg would of course be a great help.
Note: the script will create a scratch directory for temporary file extractions. The script doesn’t actually expand the entire pkg file, only the Scripts sub-archive. The scratch folder will be cleaned out at the beginning of the next run, but not when the script ends, as you might want to do some further inspections.
Sample outputs
This is a sample pkg I build in my book, it has pre- and postinstall scripts using a /bin/bash shebang:
This is the final article in this series. (If I ever announce an eight part series again, please somebody intervene!) However, I am quite sure it will not be the last post on zsh
All the previous posts described how zsh works as an interactive shell. The interactive shell is of course the most direct way we use a shell and configuring the shell to your taste can bring a huge boost in usefulness and productivity.
The other, equally, important aspect of a shell is running script files. In the simplest perspective, script files are just series of interactive commands, but of course they will get complex very quickly.
sh, bash, or zsh?
Should you even script in zsh? The argument for bash has been that it has been pre-installed on every Mac OS X since 10.2. The same is true for zsh, with one exception: the zsh binary is not present on the Recovery system. It is also not present on a NetInstall or External Installation System, but these are less relevant in a modern deployment workflow, which has to work with Secure Boot Macs.
If you plan to run a script from Recovery, such as an installr or bootstrappr script or as part of an MDS workflow, your only choices are /bin/sh and /bin/bash. The current /bin/bash binary is 12 years old, and Apple is messaging its demise. I would not consider that a future proof choice. So, if your script may run in a Recovery context, i would recommend /bin/sh over either /bin/bash or /bin/zsh
Since installation packages can be run from the Recovery context as well, and you cannot really always predict in which context your package will be used, I would extend the recommendation to use /bin/sh for all installation scripts as well.
While sh is surely ubiquitous, it is also a ‘lowest common denominator’, so it is not a very comfortable scripting language to work in. I recommend using shellcheck to verify all your sh scripts for bashisms that might have crept in out of habit.
When you can ensure your script will only run on a full macOS installation, zsh is good choice over sh. It is pre-installed on macOS, and it offers better and safer language options than sh and some advantages over bash, too. Deployment scripts, scripts pushed from management systems, launch daemons and launch agents, and script you write to automate your admin workflows (such as building packages) would fall in this category.
You can also choose to stick with bash, but then you should start installing and using your own bash 5 binary instead of the built in /bin/bash. This will give you newer security updates and features and good feeling that when Apple does eventually yank the /bin/bash binary, your scripts will keep working.
Admins who want to keep using Python for their scripts are facing a similar problem. Once you choose to use a non-system version of bash (or python), it is your responsibility to install and update it on all your clients. But that is what system management tools are for. We will have to get used to managing our own tools as well as the users’ tools, instead of relying on Apple.
Shebang
To switch your script from using bash to zsh, you have to change the shebang in the first line from #!/bin/bash to #!/bin/zsh.
If you want to distinguish your zsh script files, the you can also change the script’s file extension from .sh to .zsh. This will be especially helpful while you transfer scripts from bash to zsh (or sh). The file extension will have no effect on which interpreter will be used to run the script. That is determined by the shebang, but the extension provides a visible clue in the Finder and Terminal.
zsh vs bash
Since zsh derives from the same Bourne shell family as bash does, most commands, syntax, and control structures will work just the same. zsh provides alternative syntax for some of the structures.
zsh has several options to control compatibility, not only for bash, but for other shells as well. We have already seen that options can be used to enable features specific for zsh. These options can significantly change how zsh interprets your scripts.
Because you can never quite anticipate in which environment your particular zsh will be launched in, it is good practice to reset the options at the beginning of your script with the emulate command:
emulate -LR zsh
After the emulate command, you can explicitly set the shell options your script requires.
The emulate command also provides a bash emulation:
emulate -LR bash
This will change the zsh options to closely emulate bash behavior. Rather than relying on this emulation mode, I would recommend actually using bash, even if you have to install and manage a newer version yourself.
Word Splitting in Variable Substitutions
Nearly all syntax from bash scripts will ‘just work’ in zsh as well. There are just a few important differences you have to be aware of.
The most significant difference, which will affect most scripts is how zsh treats word splitting in variable substitutions.
Recap: bash behavior
In bash substituted variables are split on whitespace when the substitution is not quoted. To demonstrate, we will use a function that counts the number of arguments passed into it. This way we can see whether a variable was split or not:
#!/bin/bash
export PATH=/usr/bin:/bin:/usr/sbin:/sbin
function countArguments() {
echo "${#@}"
}
wordlist="one two three four five"
echo "normal substitution, no quotes:"
countArguments $wordlist
# -> 5
echo "substitution with quotes"
countArguments "$wordlist"
# -> 1
In bash and sh the contents of the variable split into separate arguments when substituted without the quotes. Usually you do not want the splitting to occur. Hence the rule: “always quote variable substitutions!”
zsh behavior: no splitting
zsh will not split a variable when substituted. With zsh the contents of a variable will be kept in one piece:
#!/bin/zsh
emulate -LR zsh # reset zsh options
export PATH=/usr/bin:/bin:/usr/sbin:/sbin
function countArguments() {
echo "${#@}"
}
wordlist="one two three four five"
echo "normal substitution, no quotes:"
countArguments $wordlist
# -> 1
echo "substitution with quotes"
countArguments "$wordlist"
# -> 1
The positive effect of this is that you do not have to worry about quoting variables all the time, making zsh less error prone, and much more like other scripting and programming languages.
Splitting Arrays
The wordlist variable in our example above is a string. Because of this it returns a count of 1, since there is only one element, the string itself.
If you want to loop through multiple elements of a list
In bash this happens, whether you want to or not, unless you explicitly tell bash not to split by quoting the variable.
In zsh, you have to explicitly tell the shell to split a string into its components. If you do this naïvely, by wrapping the string variable in the parenthesis to declare and array, it will not work:
wordlist="one two three"
wordarray=( $wordlist )
for word in $wordarray; do
echo "->$word<-"
done
#output
->one two three<-
Note: the for loop echoes every item in the array. I have added the -> characters to make the individual items more visible. In the subsequent examples, I will not repeat the for loop, but only show its output. So the above example will be shortened to:
wordarray=( $wordlist )
->one two three<-
There are few options to do this right.
Revert to sh behavior
First, you can tell zsh to revert to the bash or sh behavior and split on any whitespace. You can do this by pre-fixing the variable substitution with an =:
Note: if you find yourself using the = frequently, you can also re-enable sh style word splitting with the shwordsplit option. This will of course affect all substitutions in the script until you disable the option again.
This option can be very useful when you quickly need to convert a bash script to a zsh script. But you will also re-enable all the problems you had with unintentional word splitting.
Splitting Lines
If you want to be more specific and split on particular characters, zsh has a special substitution syntax for that:
Switching your scripts from bash to zsh requires a bit more work than merely switching out the shebang. However, since /bin/bash will still be present in Catalina, you do not have to move all scripts immediately.
Moving to sh instead of zsh can be safer choice, especially for package installation scripts.
In zsh, there always seems to be some option to disable or enable a particular behavior.
This concludes my series on switching to zsh on macOS. I hope you found it helpful.
After having worked with zsh for a few weeks, I already find some of its features indispensable. I am looking forward to discovering and using more features over time. When I do, I will certainly share them here.
As I have mentioned in the earlier posts, I am aware that there are many solutions out there that give you a pre-configured ‘shortcut’ into lots of zsh goodness. But I am interested in learning this the ‘hard way’ without shortcuts. Call me old-fashioned. (“Uphill! In the snow! Both ways!”)
We have covered the general aspects of configuring your zsh environment and enabling some of its features to make your work more productive. However, there are zsh features that didn’t quite fit in earlier posts, but also don’t warrant a post of their own. So I am gathering them here.
multiIO
Terminal commands can take input from a file or a previous command (stdin) and have two different outputs: stdout and stderr. In bash you can redirect each of these to a single other destination.
For example, you can redirect the output of a command to a file:
Note that the order of doing this is important. The construct >file.txt >&1 would redirect the output to file.txt and then redirect the output again to where stdout or 1 is going, so it would be redundant.
When combined with pipes and other commands multiIO can become very useful:
Since the fpath variable is an array, I only changed the fpath variable in my zshrc.I never set or changed the FPATH, yet it reflects the changes made to the fpath variable.
When you see the type of both variables, you get an idea that something is going on:
The fpath and FPATH are connected in zsh. Changes to one affect the other. This allows use of more flexible and powerful array operations through the fpath ‘aspect’ of the value, but also provides compatibility to tools that expect the traditional colon-separated format in FPATH.
You will not be surprised to hear that zsh uses the same ‘magic’ with the PATH variable and its array counterpart path.
This means that you can continue to use path_helper to get your PATH from the files in /etc/paths and /etc/paths.d. (Well, you don’t have to, because on macOS this is done for all users in /etc/zprofile.) But then you can manipulate the path variable with array functions, like:
path+=~/bin
You get the useful aspects of both syntaxes.
Suffix Aliases
I learnt this one after writing the aliases part.
Suffix aliases take effect on the last part of a path, so usually the file extension. A suffix alias will assign a command to use when you just type a file path in the command line.
For example, you can a suffix alias for the txt file extension:
alias -s txt="open -t"
When you then type a path ending with .txt and no command, zsh will execute open -t /path/to/file.txt.
The open -t command opens a file in the default application set for the txt file extension in Finder. You probably want to set the suffix alias to bbedit or atom or something like that rather than open -t.
You can use other command line tools for the suffix alias:
alias -s log="tail -f"
Then, typing /var/log/install.log will show the last lines of that file and update the output when the file changes. If you prefer the graphical user interface, you can use the open -a command to assign suffix aliases to applications:
alias -s log="open -a Console"
You can even create a suffix alias using a different alias:
alias pacifist="open -a Pacifist"
alias -s pkg=pacifist
Together with the AutoCD option, this can improve your application-shell interactions a lot.
Bindkey for History Search
Most of the keyboard shortcuts in zsh work the same way as they do in bash. I have found one change that has proven quite useful:
^[[A' up-line-or-search # up arrow bindkey^[[B' down-line-or-search # down arrow
These two commands will change the behavior of the up and down arrow keys from just switching to the previous command, to searching. This means that when you start typing a command and then hit the up key, rather than just replacing what you already typed with the previous command, the shell will instead search for the latest command in the history starting with what you already typed.
This concludes the part of the series about configuring zsh. When I set out I wanted to recreate the environment I had built in bash. Along the way I found a few features in zsh that seemed worth adding to my toolkit.
After nearly two months of working in zsh, there are already some features I would miss terribly when switching back to bash or a plain, unconfigured zsh. Most important is the powerful tab-completion. But features like AutoCD, MultiIO, and flexible aliases, are useful tools as well.
The dynamic loading of functions from files in the fpath was initially confusing, but it allows configurations and functions to be split out into their own, which simplifies “modularizing” and sharing.
As I have mentioned in the earlier posts, I am aware that there are many solutions out there that give you a pre-configured ‘shortcut’ into lots of zsh goodness. But I am interested in learning this the ‘hard way’ without shortcuts. Call me old-fashioned. (“Uphill! In the snow! Both ways!”)
The default bash prompt on macOS is quite elaborate. It shows the username, the hostname, and the current directory.
Calypso:~ armin$
On the other hand, the default bash prompt doesn’t show the previous command’s exit code, a piece of information I find very useful. I have written before how I re-configured my bash prompt to have the information I want:
Of course, I wanted to recreate the same experience in zsh.
The only (visual) difference to my bash prompt is the % instead of the $.
Note: creating a file ~/.hushlogin will suppress the status message at the start of each Terminal session in zsh as well as in bash (or any other shell).
Basic Prompt Configuration
The basic zsh prompt configuration works similar to bash, even though it uses a different syntax. The different placeholders are described in detail in the zsh manual.
zsh uses the same shell variable PS1 to store the default prompt. However, the variable names PROMPT and prompt are synonyms for PS1 and you will see either of those three being used in various examples. I am going to use PROMPT.
The default prompt in zsh is %m%#. The %m shows the first element of the hostname, the %# shows a # when the current prompt has super-user privileges (e.g. after a sudo -s) and otherwise the % symbol (the default zsh prompt symbol).
The zsh default prompt is far shorter than the bash default, but even less useful. Since I work on the local system most of the time, the hostname bears no useful information, and repeating it every line is superfluous.
Note: you can argue that the hostname in the prompt is useful when you frequently have multiple terminal windows open to different hosts. This is true, but then the prompt is defined by the remote shell and its configuration files on the remote host. In your configuration file, you can test if the SSH_CLIENT variable is set and show a different prompt for remote sessions. There are more ways of showing the host in remote shell sessions, for example in the Terminal window title bar or with different window background colors.
In our first iteration, I want to show the current working directory instead of the hostname. When you look through the list of prompt placeholders in the zsh documentation, you find %d, %/, and %~. The first two do exactly the same. The last substitution will display a path that starts with the user’s home directory with the ~, so it will shorten /Users/armin/Projects/ to ~/Projects.
Note: in the end you want to set your PROMPT variable in the .zshrc file, so it will take effect in all your zsh sessions. For testing, however, you can just change the PROMPT variable in the interactive shell. This will give you immediate feedback, how your current setup works.
Note the trailing space in the prompt string, to separate the final % or # from the command entry.
I prefer the shorter output of the %~ option, but it can still be quite long, depending on your working directory. zsh has a trick for this: when you insert a number n between the % and the ~, then only the last n elements of the path will be shown:
% PROMPT='%2~ %# '
dotfiles/zshfunctions %
When you do %1~ it will show only the name of the working directory or ~ if it is the home directory. (This also works with %/, e.g. %2/.)
Adding Color
Adding a bit of color or shades of gray to the prompt can make it more readable. In bash you need cryptic escape codes to switch the colors. zsh provides an easier way. To turn the directory in the path blue, you can use:
PROMPT='%F{blue}%1~%f %# '
The F stands for ‘Foreground color.’ zsh understands the colors black, red, green, yellow, blue, magenta, cyan and white. %F or %f resets to the default text color. Furthermore, Terminal.app represents itself as a 256-color terminal to the shell. You can verify this with
% echo $TERM
xterm-256color
You can access the 256 color pallet with %F{0} through %F{255}. There are tables showing which number maps to which color:
One of the prompt codes provides a ‘ternary conditional,’ which means it will show one of two expressions, depending on a condition. There are several conditions you can use. Once again the details can be found in the documentation.
There is one condition for the previous commands exit code:
%(?.<success expression>.<failure expression>)
This expression will use the <success expression> when the previous command exited successfully (exit code zero) and <failure expression> when the previous command failed (non-zero exit code). So it is quite easy to build an conditional prompt:
% PROMPT='%(?.√.?%?) %1~ %# '
√ ~ % false
?1 ~ %
You can get the √ character with option-V on the US or international macOS keyboard layout. The last part of the ternary ?%? looks confusing. The first ? will print a literal question mark, and the second part %? will be replaced with previous command’s exit code.
You can add colors in the ternary expression as well:
Another interesting conditional code is ! which returns whether the shell is privileged (i.e. running as root) or not. This allows us to change the default prompt symbol from % to something else, while maintaining the warning functionality when running as root:
% PROMPT='%1~ %(!.#.>) '
~ > sudo -s
~ # exit
~ >
Complete Prompt
Here is the complete prompt we assembled, with all the parts explained:
zsh also offers a right sided prompt. It uses the same placeholders as the ‘normal’ prompt. Use the RPROMPT variable to set the right side prompt:
% RPROMPT='%*'
√ zshfunctions % 11:02:55
zsh will automatically hide the right prompt when the cursor reaches it when typing a long command. You can use all the other substitutions from the left side prompt, including colors and other visual markers in the right side prompt.
Git Integration
zsh includes some basic integration for version control systems. Once again there is a voluminous, but hard to understand description of it in the documentation.
I found a better, more specific example in the ‘Pro git’ documentation. This example will show the current branch on the right side prompt.
I have changed the example to include the repo name and the branch, and to change the color.
In this case %b and %r are placeholders for the VCS (version control system) system for the branch and the repository name.
There are git prompt solutions other than the built-in module, which deliver more information. There is a script in the git repository, and many of the larger zsh theme projects, such as ‘oh-my-zsh’ and ‘prezto’ have all kinds of git status widgets or modules or themes or what ever they call them.
Summary
You can spend (or waste) a lot of time on fine-tuning your prompt. Whether these modifications really improve your productivity is a matter of opinion.
In the next post, we will cover some miscellaneous odds and ends that haven’t yet really fit into any of preceding posts.