I've been meaning to start this (what I hope will eventually become) series for a very long time. There's a lot I'd like to cover, and it was daunting to figure out how to actually get going. I think this quick introduction is going to be enough, and I'll figure out all the rest as I go. Here's hoping.

Many years ago, I started off insisting on using tools that were default on the given platform. It was not a bad approach—on AIX the default was ksh, Solaris had sh, FreeBSD used to default to tcsh, and most Linux distros used bash. Portability and POSIX compliance were very important to me back then as I was switching between different operating systems. Most scripting I was writing, I was trying real hard to have as portable as possible, which also meant no bashisms etc.

Later on, when I switched to using Illumos (or SmartOS more specifically), its default was (still is?) bash. Coincidentally, that was also the time (circa 2014) when I switched to macOS, which was also defaulting to bash back then. I think it's clear by now where I'm going with it, but let me drive the point home: when it comes to shell scripting I made my peace and use bash by default everywhere, all the time.

For interactive shell, I was also using bash for a very long time. That said, my setup, prompt, and whatnot got so slow, that starting a new shell session was noticeable with bare eyes (I wrote more about it here—How fast is your shell?). I decided to switch to fish and whoa, I really loved the sensible defaults and the sheer speed it offered. But it wasn't that great with POSIX compliance (or lack thereof), terribly weird globbing, and custom scripting syntax (very pythonic, but not quite, which made it somewhat uncanny).

macOS eventually moved to zsh as its default interactive shell and it made me reconsider my choices. I was entertaining the idea of switching to it earlier, but it was this change that pushed me over the edge. I don't think I would be happy with it if not for the amazing Zim. I used all sorts of configuration frameworks for zsh back in the day (oh-my-zsh, prezto, and all other usual suspects), but none of them felt lightweight and fast while staying out of the way with bare minimum setup. Just look at it, here's my zimrc file:

zmodule environment
zmodule input
zmodule termtitle
zmodule utility
zmodule olets/zsh-abbr
zmodule romkatv/powerlevel10k --use degit
zmodule zsh-users/zsh-completions --fpath src
zmodule completion
zmodule atuinsh/atuin
zmodule kiesman99/zim-zoxide
zmodule zsh-users/zsh-syntax-highlighting
zmodule zsh-users/zsh-history-substring-search
zmodule zsh-users/zsh-autosuggestions

That's all, and you end up with a fast, beautiful shell with sensible defaults. I went through a lot of different prompts (which was especially painful in bash IIRC). In fish my favorite was tide. It was very responsive while providing me with all the stuff I wanted to see and hiding all the crap I didn't. Initially, I found it hard to replicate in zsh, but then I gave romkatv/powerlevel10k a go. I know, it's a beast of a prompt, and the customization options are enormous. But it's also super fast and it can be setup to look, pretty much, exactly the same as tide—which was a huge win in my book!

Showcasing powerlevel10k prompt

There's a certain set of rules that my prompt needs to follow in order for me not to get confused while using it:

  • One line only, not multi-line mumbo jumbo
  • Same line—some prompts (including powerlevel10k) have a notion of changing the previous lines as they are now "history" ("transient prompt"). Absolutely not for me such things 🙂‍↔️
  • The prompt has to be lean by default—no icons, no fancy borders, pure information only that I need at hand at the given moment
  • The only nicety I'm always enjoying is the auto-shortening of the current path—but only because it's there to keep the prompt lean
  • Well, OK, maybe the other nicety is using a right-hand prompt as well for some stuff (mainly context info, like venv etc.)

In fish I grew to really like syntax highlighting, history search with substring granularity, and finally, proper autosuggestions—all of these are covered in zsh by three plugins I load in my Zim config file last. One more thing I took over from fish is support for abbr—this allows me to, for example, write kc followed up by space for it to expand into kubectl. I could use aliases instead, but I don't like doing so, as I'm getting used to seeing my own mess instead of the actual command I'm running[^1]. I also find it much easier to share commands with my coworkers when I'm not using my own set of aliases. That's where olets/zsh-abbr comes to the rescue. While it's not built-in as in fish, it does the job equally well.

All of my dotfiles are kept under VCS and I sync them across various machines. Nowadays the setup is a bit simpler as I share it mostly between two Macbooks and mostly Debian Linux machines—it used to be quite convoluted when I still had some stuff running on FreeBSD and/or SmartOS. I know chezmoi is all the rage these days, but I have to recommend yadm—it never failed me. Honestly, this piece of software had been rock solid for me and I see absolutely no reason to change anything about this part of my setup. I used to hang out with GNU Stow, but I much prefer just having a "flat" structure instead and some templating where necessary to cover the differences.

The thing I always struggled with (and I still do) is code snippets. Some things live in scripts or functions scattered all over my zshrc file, and some are in my notes in Workflowy (or whatever else note app I'm using at the moment). All in all: it's a mess. One thing I've been trying to tackle this issue is knqyf263/pet. I think it's the closest I ever got to something that actually does what I need in such a way that I'm actually using it. I don't have too many of the snippets in there, but the ones I do I use quite often. I dig the templating where it prompts me to provide the necessary details. That said, I'm always on the lookout for something better, and I'm keeping my eye on Atuin Scripts.

To keep it going, I must mention junegunn/fzf. I use it mostly in my scripting, but there are two interactive functionalities I leverage, namely: ctrl+t to search for files and alt+d to search for directories. The former I use relatively often when I start my coding sessions—I go to the place I need to be in, I type nvim and then ctrl+t to find the first file(s) to open and take it from there. (Of course, I'm also using telescope with fzf plugin in neovim, there should be no doubts about it). alt+d I seldom use, because...

...that's where ajeetdsouza/zoxide comes into play. It's uncanny how good it is and how fast I'm able to switch between directories. You can honestly fly with this thing! There's an interactive search as well, powered by fzf (I love it when tools converge). The only thing I wasn't fond of, and you probably saw it coming based on my abbr usage, was the z command for switching—I prefer ordinary cd as I already have it in my muscle memory. It's easy to set in the ~/.zim/modules/zim-zoxide/init.zsh file:

[...]
$commands[zoxide] init --cmd cd zsh >| ${0:A:h}/zoxide.zsh
[...]

This change will set cd to be superpowered by zoxide and will otherwise fall back to the ordinary cd—a win-win in my book. cdi can be used for interactive search with fzf (I can't think of the last time I needed to use it, though).

Flying between directories using zoxide or fuzzy searching for them or files using fzf is awesome, but sometimes I do find myself in need of a proper file manager. There are simply some operations that are done loads faster with it, than pure terminal foo (YMMV). Linking (soft and hard) multitudes of files, migrating things, learning the structure tree of a new project, etc. I do these (and more) much faster with file manager. These days sxyazi/yazi seems to be the default recommendation—not from me, though, it absolutely didn't click for my workflow. I found it confusing and I couldn't get into the flow with keyboard shortcuts (I guess too much muscle memory involved). I used to swear by ranger/ranger, but it got annoyingly slow and I felt I deserved better. I settled on jarun/nnn, which is blazing fast, and haven't looked back since. I ship my own Debian package so that I can make use of the Nerd Fonts for beautification.

nnn in action!

Isn't it beautiful? 🥰

For software runtime version management I settled on asdf long time ago. I know these days it would probably be jdx/mise, but I had no reason to change anything. Recently I switched over to the Golang implementation without missing a beat. It does the job and does it well.

As mentioned earlier on, I'm rocking neovim/neovim. I used to drag my own Vim config file for years and it was full of some weird stuff I accumulated on the way and then long forgot about it. It was messy and I used the switch to neovim as a good opportunity to leverage some third-party configuration framework. I settled on LazyVim cause I'm lazy by nature and I was hoping I wouldn't have to customize too many things to match my liking. This has been generally valid, but Folke moves fast and things tend to be rather on the broken side of things a bit too often for my liking. I'm not yet there to switch to something else or cook something myself from scratch, but sure enough, I'm getting there. Worth mentioning is my color scheme—I used to be quite picky about it, but these days I'm mostly fine with what catppuccin/catppuccin has to offer.

The last remaining thing I took over from fish is keeping some of the things as separate scripts. This grew as a legacy from how things are done in fish with its lazy-loading capability—I just put everything as a separate function and it was lazy-loaded when needed. Nifty. But as I no longer use fish, I decided to migrate the majority of these functions into completely separate scripts that live under my ~/bin. Future posts in this series will cover them in more detail.

One thing that bothered me a bit was completions—these used to work so well in fish that I didn't even give them a second thought. In zsh a little bit of babysitting is needed, especially for some custom completions. The way I solved it is by extending fpath in my zshrc, like so:

[...]
fpath+=(~/.zsh)
[...]

I can then store additional sets of completions under ~/.zsh and they'll be properly loaded into my interactive shell. What do I have in there? Mainly custom stuff handled by asdf, here's how I generate some of them:

asdf completion zsh > ~/.zsh/_asdf
hugo completion zsh > ~/.zsh/_hugo
kubectl completion zsh > ~/.zsh/_kubectl
op completion zsh > ~/.zsh/_op
tailscale completion zsh > ~/.zsh/_tailscale

Note that Tailscale needs a bit more entries in the zshrc file depending on how it was installed. For me, these two additional lines do the trick:

[...]
alias tailscale='/Applications/Tailscale.app/Contents/MacOS/Tailscale'
compdef _tailscale Tailscale
[...]

This post wouldn't be complete if I didn't mention some of the tools I use as in-place replacements for ordinary ones. I make use of sharkdp/bat, eza-community/eza, dandavison/delta, sharkdp/fd, BurntSushi/ripgrep, and probably a few others I'm forgetting. These, while nice to have, aren't something I pay too much attention to, and I'm just fine using the old equivalents.

Phew! This concludes the first post in the series where I covered the interactive shell part of my setup. It's been more involved than I originally anticipated, but with this out of the way, I'm now going to follow up with the actual Terrible Shell Scripting. Stay tuned!

[^1]: One of my favorite combo example for abbr is svba that expands into source .venv/bin/activate. I always enjoy using this one.