Skip to main content

Setting Up a Shell Environment (zsh / Oh My Zsh / starship / nvm)

Techniques for building your interactive shell environment with startup speed and maintainability in mind.

zsh vs. bash​

zsh is a feature-rich shell that is largely a superset of bash, with a mature ecosystem of completions, themes, and plugins. Since Ubuntu and similar distributions default to bash, it is common to switch the login shell to zsh.

Aspectbashzsh
CompatibilityPOSIX-compliant, often the defaultCovers most of bash, richer extensions
CompletionPartialMenu completion, extensive completion functions
PluginsFewerAbundant via Oh My Zsh

You don't have to abandon bash entirely — you may still manage .bashrc for non-interactive shells and script compatibility.

Oh My Zsh and plugins​

Oh My Zsh is a framework for managing zsh configuration, plugins, and themes. It installs into ~/.oh-my-zsh, and you declare plugins via plugins=(...) in .zshrc.

# .zshrc
export ZSH="$HOME/.oh-my-zsh"
ZSH_THEME=""
plugins=(git zsh-autosuggestions zsh-completions zsh-syntax-highlighting)
source "$ZSH/oh-my-zsh.sh"

Commonly used third-party plugins include:

PluginPurpose
zsh-autosuggestionsInput suggestions based on history
zsh-syntax-highlightingSyntax coloring for commands
zsh-completionsAdditional completion definitions

These are not bundled with Oh My Zsh, so they are cloned into ~/.oh-my-zsh/custom/plugins/ (your install script should clone them idempotently).

The prompt: starship​

starship is a cross-shell prompt written in Rust, letting you use the same configuration across bash, zsh, and fish. Configuration lives in ~/.config/starship.toml.

# Enabling in .zshrc
if command -v starship >/dev/null 2>&1; then
eval "$(starship init zsh)"
fi

Checking with command -v before initializing protects the .zshrc from breaking on machines where the tool is not installed. Powerlevel10k is a zsh-only alternative, but starship's cross-shell support and single config file make it an easy choice.

Node version management: nvm and lazy loading​

nvm (Node Version Manager) manages Node.js on a per-user, multi-version basis. Its downside is that sourcing nvm.sh slows down shell startup. The technique to avoid this is lazy loading.

The idea is to "add only the default version's bin to PATH, and source nvm itself only when the nvm command is first used."

# .zshrc (nvm lazy loading)
export NVM_DIR="$HOME/.nvm"
# Add the default Node bin directly to PATH (node/npm/npx available immediately)
if [ -r "$NVM_DIR/alias/default" ]; then
_nvm_default="$(<"$NVM_DIR/alias/default")"
_nvm_bin="$NVM_DIR/versions/node/v${_nvm_default}/bin"
[ -d "$_nvm_bin" ] && export PATH="$_nvm_bin:$PATH"
unset _nvm_default _nvm_bin
fi
# Source nvm itself only on first use of the nvm command
nvm() {
unset -f nvm
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
nvm "$@"
}
tip

Setting nvm alias default <version> lets .zshrc simply resolve alias/default. The benefit is that you don't need to edit your dotfiles when upgrading Node.

In bash, the function-based lazy loading trick is harder, so it is common to just source nvm.sh directly.

Managing PATH​

Explicitly add directories for user-installed tools to PATH in your dotfiles.

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

If the same PATH appears across multiple dotfiles, consider consolidating the common parts into .profile.

Separating machine-specific settings​

Keep secrets and machine-specific values out of the main dotfile by sourcing a .local file at the end.

# End of .zshrc
[ -f "$HOME/.zshrc.local" ] && source "$HOME/.zshrc.local"

See Managing Dotfiles with chezmoi for details.

References​