Skip to content

Shell Customization ​

This document covers the shell customization provided by this dotfiles repository, including oh-my-zsh configuration, plugins, custom functions, and completions.

Zsh & oh-my-zsh ​

Default Shell ​

This setup uses Zsh as the default shell, configured with the oh-my-zsh framework for enhanced functionality and ease of customization.

Custom Theme ​

A custom oh-my-zsh theme is included: this-is-tobi.zsh-theme. setup -d copies it to ~/.oh-my-zsh/custom/themes/ and .zshrc selects it with ZSH_THEME="this-is-tobi".

This theme combines:

  • gnzh theme - Two-line prompt layout with git information
  • kube-ps1 - Kubernetes context display, embedded in the theme file itself (it is not an oh-my-zsh plugin and has no entry in plugins)

Features:

  • Git branch indicator
  • Current Kubernetes context and namespace (kubeoff / kubeon hide and show it, kk toggles it)
  • Exit code of the last command on the right when it is not 0
  • Username and hostname display (hostname in red over SSH, username in red for root)
  • Current directory, with ~ for the home directory

oh-my-zsh Plugins ​

The plugins=(...) array of .zshrc activates the following plugins:

Development Tools ​

  • git - Git aliases and functions
  • gitignore - gitignore.io from command line
  • gh - GitHub CLI completion
  • docker - Docker completion and aliases
  • docker-compose - Docker Compose completion and aliases
  • direnv - direnv hook, added by .zshrc only when direnv is installed

Language-Specific ​

  • golang - Go completion and aliases
  • node - Node.js documentation function
  • npm - npm completion and aliases
  • bun - Bun completion

DevOps & Cloud ​

  • ansible - Ansible aliases
  • argocd - Argo CD CLI completion
  • helm - Helm completion and aliases
  • k9s - k9s completion
  • kubectl - kubectl completion and aliases
  • kubectx - kubectx_prompt_info function (name of the active kubectl context)
  • kind - Kind completion and aliases
  • oc - OpenShift CLI completion
  • terraform - Terraform completion, aliases, and prompt function
  • scw - Scaleway CLI completion

System Utilities ​

  • aliases - als lists the available aliases by group (needs Python)
  • brew - Homebrew aliases
  • colored-man-pages - Colorize man pages
  • nmap - nmap aliases
  • rclone - rclone completion
  • ssh - Host completion from ~/.ssh/config and helpers for SSH keys
  • sudo - Press ESC twice to prefix command with sudo
  • systemadmin - Sysadmin aliases and functions

Aliases ​

.zshrc defines these aliases on top of the ones the plugins bring (run als to list those):

AliasCommand
k, kc, knkubectl, kubectx, kubens
kg, kd, kdesc, ke, kl, kexpkubectl get, delete, describe, exec, logs, explain
kgpwwatch 'kubectl get pod'
kgrPods with their CPU and memory requests and limits
kcert, kcnpg, kdfpv, kescapekubectl plugins cert_manager, cnpg, df_pv, kubescape
kkrew, kktop, kkyvernokubectl plugins krew, ktop, kyverno
kneat, kstern, kvskubectl plugins neat, stern, view_secret
gs, gsa, gspgit stash, git stash apply, git stash pop
lag, ladlazygit, lazydocker
dspdocker system prune -a -f (removes stopped containers, unused networks, images and build cache, without asking)
ezaeza -lag --git
ffzf with a bat preview
hShell history, newest first, in fzf
hs, hsihistory | grep (-i for hsi)
cscheat_glow
imgchafa
pubipPublic IP address, through a Cloudflare DNS query
sedgsed, only when GNU sed is installed (macOS ships BSD sed)
bcuReinstall every outdated cask (only when Homebrew is found)
arm, intelmacOS only: start a login zsh under arm64 or x86_64

History ​

.zshrc keeps 10000 commands in ~/.zsh_history (oh-my-zsh raises the in-memory list to 50000), with their date (history shows it as yyyy-mm-dd). Two kinds of command lines are not written to that file:

  • A command typed with a leading space (hist_ignore_space). Prefix a command with a space to keep it out of the history, for example export GITHUB_TOKEN=....
  • A line that assigns or passes a credential, matched by HISTORY_IGNORE: a name among TOKEN, SECRET, PASSWORD, PASSWD, PASSPHRASE, API_KEY, API-KEY, APIKEY, ACCESS_KEY and PRIVATE_KEY (upper, lower or capitalized case, so X-Auth-Token: counts) followed by = or :, the options --token, --secret, --password, --passphrase and --api-key followed by = or a space, and any Authorization: or Bearer header. GITHUB_TOKEN=... gh ..., --from-literal=password=... and curl -H "Authorization: Bearer ..." are therefore not saved.

Both stay in the history of the current session (the up arrow brings them back until the shell exits): they are only kept out of the file, which other sessions and backups read. The patterns are a safety net, not a guarantee: a value passed as a plain argument (vault login <token>) is saved. Pass secrets through standard input or a prompt instead, as b64e, urle and secrets allow.

Custom Functions ​

Custom shell functions are defined in .config/dotfiles/functions.sh and provide powerful utilities.

Available Functions ​

FunctionDescriptionUsage
lsfnList all functions with descriptionslsfn
b64dDecode base64 stringb64d ["encoded_string"]
b64eEncode string to base64b64e ["plain_text"]
browserStart the Browsh terminal web browser (in Docker)browser [-- <url>]
cheat_glowView cheat sheets with Glowcheat_glow <cheatsheet>
check_certPrint the certificate chain details of an HTTPS URLcheck_cert https://example.com
dksDecode Kubernetes secretdks <secret_name> [namespace]
ffmergeMerge a GitHub PR by fast-forwarding its base branchffmerge [-n] [-y] [-k] [pr]
kbpKill the process listening on a TCP portkbp 8080
randompassGenerate random password (24 characters by default)randompass [length]
secretsLoad the keys in DOTFILES_RTA_KEYS from rtasecrets
toolsExecute remote utility scriptstools [script_name.sh] [args]
timestampdConvert timestamp to datetimestampd 1234567890
timestampeConvert date to timestamptimestampe "2023-01-01"
urldURL decode stringurld ["encoded%20url"]
urleURL encode stringurle ["plain url"]
vault-cpCopy a Vault secret (-f: every version)vault-cp [-f] <source> <dest>
weatherCheck weather for location (wttr.in, over HTTPS)weather [location]

Every function prints its own help with -h. b64e, b64d, urle and urld read standard input when they get no argument, and ask for the value without echoing it when standard input is a terminal: a secret then appears neither in the shell history nor in the process list (ps).

Function Examples ​

Base64 Encoding/Decoding ​

sh
# Encode
$ b64e "Hello World"
SGVsbG8gV29ybGQ=

# Decode
$ b64d "SGVsbG8gV29ybGQ="
Hello World

# A secret: no argument, the value is asked for and not echoed
$ b64e
Value (hidden):
c2VjcmV0

# From a pipe or a file, encoded byte for byte (echo adds a newline, printf does not)
$ printf '%s' "$TOKEN" | b64e
$ b64e < cert.pem

URL Encoding/Decoding ​

sh
# Encode
$ urle "hello world"
hello%20world

# Decode
$ urld "hello%20world"
hello world

# Without an argument: standard input, or a hidden prompt in a terminal
$ echo "hello world" | urle
hello%20world

urle and urld drop the newline that ends their input, so echo ... | urle encodes the text only.

Kubernetes Secret Decoding ​

sh
# Decode secret in default namespace
$ dks my-secret

# Decode secret in specific namespace
$ dks my-secret production

Certificate Checking ​

sh
$ check_cert https://example.com
# Prints the certificate chain (subject, issuer, validity dates, ...)

The URL needs its https:// scheme. The check does not validate the certificate (curl -k), so it also works for expired or self-signed ones. The response body is printed as well.

Port Management ​

sh
# Kill (SIGKILL) the process listening on TCP port 8080
$ kbp 8080

Only the listening process is targeted, not the clients connected to the port (a browser, for instance). When nothing listens on the port, kbp says so and returns 1.

Password Generation ​

sh
# Generate 24-character password (default)
$ randompass

# Generate 32-character password
$ randompass 32

The password always contains an uppercase letter, a lowercase letter, a digit and one of =!?%~_-, so the minimum length is 4 (shorter values are refused).

Timestamp Conversion ​

sh
# Timestamp to human-readable date
$ timestampd 1609459200
Fri Jan  1 00:00:00 UTC 2021

# Date to timestamp (the date is read in the local time zone)
$ timestampe "2021-01-01"
1609459200

timestampe accepts YYYY-mm-dd or YYYY-mm-ddTHH:MM:ss. The sample outputs above assume a UTC time zone.

Vault Secrets ​

sh
# Copy the latest version of a secret to another path
$ vault-cp secret/old secret/new

# Copy every version of a secret, oldest first (destroyed or deleted versions are skipped)
$ vault-cp -f secret/old secret/new

The tools Function ​

The tools function is special - it allows executing remote utility scripts from the tools repository without installing them locally.

The script is downloaded to a temporary file first and only run once the download succeeded (no curl | bash); the file is removed afterwards. The name has to be a plain script name: letters, digits, ., _ and -, ending in .sh (anything else, such as a path, is refused before any download).

It follows the main branch of the tools repository, like the AI agent installer of the same repository. That repository tags its Docker image releases (backup-v1.4.3, ...), not the shell scripts, so there is no script release to follow: to pin what gets downloaded, set TOOLS_REF to a commit SHA (or any branch or tag), for one call (TOOLS_REF=<sha> tools <script_name>.sh) or for the session (export TOOLS_REF=<sha>, or in ~/.zshrc.local). A TOOLS_REF that is not a plain git ref name is refused.

Usage:

sh
# List all available scripts
$ tools

# View script help
$ tools <script_name>.sh -h

# Execute script
$ tools <script_name>.sh [arguments]

The script name includes its .sh extension. An unknown name prints an error and returns 1.

Auto-completion: Type tools then press <Tab> to see the available scripts with their description (the arguments after the script name complete as files). The list comes from the tools repository at TOOLS_REF and is cached in ~/.cache/dotfiles/tools-scripts-<ref> (under $XDG_CACHE_HOME when it is set) for a day, so a <Tab> only waits on the network (3 seconds at most) once a day. When the download fails, for example offline, the previous list is used and the next try waits an hour. Delete the cache file to fetch the list again right away.

Examples:

sh
# List scripts
$ tools

# Get help for a specific script
$ tools clone-subdir.sh -h

# Execute script
$ tools clone-subdir.sh -u https://github.com/user/repo -s subdir -o output

The ffmerge Function ​

ffmerge merges a GitHub pull request by fast-forwarding its base branch to the head of the PR, with a plain git push. The commits that land on the base branch are the ones of the PR, so they keep their signatures (GitHub's "Rebase and merge" re-creates them, and they are no longer signed) and the history stays linear. GitHub then marks the PR merged by itself. It works in any GitHub repository where origin is the repository of the PR and needs gh (logged in), git and jq.

sh
ffmerge             # the PR of the current branch
ffmerge 42          # PR #42
ffmerge -n 42       # dry run: every check, then what would be done; nothing changes
ffmerge -y 42       # no confirmation prompt
ffmerge -k 42       # keep the head branch

It stops, without changing anything, when:

  • the PR is not open, or comes from a fork
  • origin is not the repository of the PR
  • the head branch moved since GitHub reported it
  • the head branch does not contain the current origin/<base>. It prints how to rebase it: git rebase origin/<base>, then git push --force-with-lease, and once the checks passed again, ffmerge again
  • a commit between origin/<base> and the head is not verified by GitHub (its "Verified" badge: signed, with a key on the author's account). The commits are listed with the reason GitHub gives (unsigned, unknown_key, ...)
  • the required checks of the PR did not pass (gh pr checks <pr> --required). When the base branch requires none, every check of the PR has to pass. When checks are still running, it prints gh pr checks <pr> --watch to wait for them

Otherwise it shows the commits and the steps, and asks for confirmation. Then:

  1. git push origin <head sha>:refs/heads/<base>: never forced, so it is refused if origin/<base> moved in the meantime, and nothing else happens. A branch rule that forbids pushing to the base branch (a rule requiring pull requests, for instance) refuses it as well, unless you are allowed to bypass it
  2. The open PRs whose base is the head branch are moved to <base> (gh pr edit <n> --base <base>), so that deleting the branch does not close them. Since the merged commits kept their SHAs, those PRs need no rebase
  3. Once GitHub shows the PR merged, the head branch is deleted on origin (unless -k). If it does not within about 30 seconds, the branch is kept and ffmerge says how to delete it later
  4. It prints the state of the PR (MERGED), and, when you are still on the head branch, how to switch back to the base branch

CLI Completions ​

CLI completions come from three places:

  1. oh-my-zsh plugins - completion shipped with the plugins listed above (kubectl, helm, docker, docker-compose, gh, npm, terraform, ...)
  2. completions.sh - generated completion files, written to ~/.oh-my-zsh/completions
  3. zsh-completions - community completions, cloned to $ZSH_CUSTOM/plugins/zsh-completions (.zshrc adds its src directory to fpath once it exists)

Both 2 and 3 are set up by setup-osx.sh -c / setup-debian.sh -c, which runs after the profiles and the dotfiles copy.

Installed Completions ​

completions.sh only handles tools that are installed (it checks for the binary) when -c runs, so run -c again after installing a new tool. It generates a completion for:

  • argo, argocd, ct (chart-testing), cheat, cobra-cli, docker, gitleaks, glow, helm, kind, kubebuilder, kustomize, kyverno
  • kubectl and the kubectl plugins cnpg, kubescape, stern and view_secret, plus kubens and kubectx
  • mise, ollama, operator-sdk, pnpm, trivy, velero, yq
  • teleport, tbot, tctl and tsh
  • cheat, through cheat --completion zsh

scw gets its completion from the oh-my-zsh plugin. terraform and vault complete through their own binary, set up in .zshrc once oh-my-zsh has loaded: their -install-autocomplete flags append lines to ~/.zshrc, so completions.sh does not call them.

Not generated by completions.sh: ansible (the oh-my-zsh plugin only adds aliases) and yarn have no completion in this setup; npm, gh and docker-compose get theirs from their oh-my-zsh plugins.

Custom Completions ​

functions-completion.sh defines the completion of the tools function (script names, see above), as a native zsh completion registered with compdef once oh-my-zsh has run compinit. The other functions have no custom completion.

Cheatsheets ​

Installation ​

The cheat tool is installed with additional custom cheatsheets.

Personal Cheatsheets ​

Extra cheatsheets from this-is-tobi/cheatsheets are available.

List personal cheatsheets:

sh
$ cheat -l -p personal

View a cheatsheet:

sh
$ cheat <cheatsheet_name>

Enhanced viewing with Glow:

sh
$ cheat_glow <cheatsheet_name>
# Renders cheatsheet with nice markdown formatting

Creating Custom Cheatsheets ​

Create your own cheatsheets in ~/.config/cheat/cheatsheets/personal/:

sh
# Create new cheatsheet
$ vim ~/.config/cheat/cheatsheets/personal/mycmd

# Content example:
# To do something useful:
mycmd --flag value

# To do something else:
mycmd --other-flag

Customization Tips ​

setup -d replaces ~/.zshrc and ~/.config/dotfiles/functions.sh with the repository versions. When their content differs, the previous file is first saved under ~/.dotfiles-backup/<timestamp>/, so a local edit does not survive the next run. Two places are yours:

  • ~/.zshrc.local, sourced at the very end of .zshrc when it exists: aliases, functions, environment variables and settings for this machine. The setup never writes it
  • the repository copy (dotfiles/.zshrc, dotfiles/.config/dotfiles/functions.sh), for what every machine should get: edit it, commit, and re-run -d

Adding Custom Aliases ​

Add personal aliases to ~/.zshrc.local. It is sourced last, so an alias there also replaces one of .zshrc or of a plugin:

sh
alias myalias="command --with-flags"
alias tfa="terraform apply"

Adding More Plugins ​

oh-my-zsh loads the plugins listed in the plugins=(...) array of .zshrc, before ~/.zshrc.local is read. For every machine, add the plugin to the array in the repository copy and re-run -d:

sh
plugins=(
  aliases
  ansible
  # ... existing plugins ...
  your-new-plugin
)

On one machine only, load it from ~/.zshrc.local. Its aliases and functions work this way; a completion it ships is not picked up, since compinit has already run:

sh
source "$ZSH/plugins/your-new-plugin/your-new-plugin.plugin.zsh"

Custom Functions ​

Add your own functions to ~/.zshrc.local:

sh
myfunction() {
  echo "This is my custom function"
  # Your code here
}

.zshrc also sources ~/.config/dotfiles/functions-dso.sh when that file exists, before ~/.zshrc.local. It is not part of the repository either, so the setup never replaces it; it is kept for the machines that already have one (functions for a work context), and ~/.zshrc.local is the place for anything new. Functions meant for every machine go in the repository copy of functions.sh, with a -h help like the others and an entry in the lsfn list.

Environment Variables ​

Use ~/.config/dotfiles/secrets.env for secrets and per-machine variables. It is loaded into every zsh session by env.sh (managed by the setup, don't edit it), is created once with mode 600 and is never overwritten:

sh
MY_VAR=value
CONTEXT7_API_KEY=your_key

Locale ​

.zshrc sets LANG=en_US.UTF-8 and nothing else: every LC_* category follows it unless you set one yourself (for example export LC_TIME=fr_FR.UTF-8 in ~/.zshrc.local for dates). It does not set LC_ALL, which would override them all and which ssh forwards to the hosts you connect to.

PATH ​

.zshrc keeps one copy of each PATH and fpath entry (typeset -U), so a nested shell, or the arm/intel aliases, does not stack the same directories again. A directory added in ~/.zshrc.local with export PATH="<dir>:$PATH" moves to the front if it was already there.

Go programs installed with go install land in ~/go/bin (GOBIN), which is on PATH and does not change with the go version that mise activates. npm global packages land in the bin directory of the node version mise activates.

Troubleshooting ​

Slow Shell Startup ​

If shell startup is slow:

sh
# Profile startup time
$ time zsh -i -c exit

# Disable plugins one by one to find the culprit
# Edit ~/.zshrc and comment out plugins

Completion Not Working ​

Rebuild completion cache:

sh
$ rm ~/.zcompdump*
$ exec zsh

The script names tools completes are cached for a day: rm ~/.cache/dotfiles/tools-scripts-* fetches them again on the next <Tab>.

Function Not Found ​

Ensure functions are sourced:

sh
$ source ~/.config/dotfiles/functions.sh
# Or restart shell
$ exec zsh

Theme Issues ​

If theme doesn't display correctly:

sh
# Ensure oh-my-zsh is installed
$ ls ~/.oh-my-zsh

# Verify theme file exists
$ ls ~/.oh-my-zsh/custom/themes/this-is-tobi.zsh-theme

# Check .zshrc theme setting
$ grep "ZSH_THEME" ~/.zshrc