Style guide
Write so that the whole repo reads as if one careful person wrote it. This page explains the rules with short examples; AGENTS.md keeps the same rules as a checklist.
The shell rules follow the Google Shell Style Guide, trimmed to what this repo uses, plus the traps listed in Bash Pitfalls and the ShellCheck wiki.
Before you write a script
Look for a package, a systemd timer, a drop-in file or a built-in feature first (Waybar signals, Hyprland rules). A script is the last resort, and every script is code you have to keep working.
Script layout
There are two kinds of shell files, and they look different on purpose.
Standalone scripts (install.sh, dotfiles/bin/, waybar/scripts/) run on their own:
#!/usr/bin/env bash
# Opens wlogout with margins sized to the focused monitor.
set -euo pipefail
readonly BUTTONS_PER_ROW=5
vertical_margin() {
…
}
main() {
…
}
# Sourcing the file (tests) loads the functions without running main
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main "$@"
fiScripts in dotfiles/bin/ and waybar/scripts/ end with that guard, so a test can source them and call one function. install.sh and tests/packages.sh only ever run whole and end with a plain main "$@".
Sourced files (installer/lib/, installer/modules/) are read by install.sh. They have no set line and no main; globals are UPPER_SNAKE at the top and not readonly, so a file can be sourced again. Each module runs in its own subshell under set -Eeuo pipefail, so an error stops only that module.
Hooks that systemd calls with /bin/sh (installer/system/systemd/system-sleep/) may stay POSIX sh.
Formatting and names
- 4-space indent,
snake_casefor functions and variables,UPPER_SNAKEfor constants and globals. localfor every function variable. Assign command output on its own line, becauselocalhides the command's exit status:
# Bad: local always succeeds, so a failing hyprctl goes unnoticed
local name="$(hyprctl -j activewindow | jq -r .class)"
# Good
local name
name="$(hyprctl -j activewindow | jq -r .class)"[[ ]]for tests,(( ))for numbers,$(…)instead of backticks, no space after redirects (>/dev/null).- Quote every expansion:
"$file","${args[@]}","$(cmd)". - A function comment is one line above the function, and only when the name doesn't tell the whole story.
Layout
Give the code room to breathe. installer/lib/log.sh is the reference:
- Declare locals first, one per line, then leave a blank line before the body. Uninitialized loop variables may share one line:
local entry name status. - Separate logical steps (checks, work, output) with one blank line, and leave one before the final
returnor result line of a longer function. - Write one command per line: no
a=1 b=2chains, nocond && actionorcond || dieone-liners, no one-linef() { …; }bodies. - Never squeeze an
if,for,whileorcasearm onto one line; keepthenanddoon the condition line. - Break lines longer than about 100 characters with
\, one option group per line, continuation indented 4 spaces. - Group the functions of a file under
# --- Name ---dividers in sentence case. - In modules, split top-level code into blocks with one blank line between them; start a block with a one-line comment that says why, when that isn't obvious.
- Keep functions short (about 25 lines at most); split one only when that reads better.
# Bad
pkg_install_list() {
local file="$PACKAGES_DIR/$1.txt" pkgs
[[ -f "$file" ]] || die "No package list: $file"
mapfile -t pkgs < <(read_pkgs "$file"); pkg_install "${pkgs[@]}"
}
# Good
pkg_install_list() {
local group="$1"
local file="$PACKAGES_DIR/$group.txt"
local pkgs=()
if [[ ! -f "$file" ]]; then
die "No package list: $file"
fi
mapfile -t pkgs < <(read_pkgs "$file")
info "$group: ${#pkgs[@]} packages"
pkg_install "${pkgs[@]}"
}Output and exit codes
Data goes to stdout, messages go to stderr. In the installer, warn, err and die from installer/lib/log.sh already write to stderr, and the log file gets both streams.
# Bad
echo "Variant '$1' not found"
exit 1
# Good
echo "Variant '$1' not found" >&2
exit 1Use printf '%s\n' "$var" for data you don't control; echo misreads values such as -n. Exit with 0 on success and 1 on failure; a usage error prints the usage to stderr.
Reading input and lists
Read line by line with read -r and process substitution. A pipe into while runs the loop in a subshell, so anything it sets is lost:
# Bad: pkgs is empty after the loop
read_pkgs "$file" | while read -r pkg; do pkgs+=("$pkg"); done
# Good
while read -r pkg; do
pkgs+=("$pkg")
done < <(read_pkgs "$file")
# Simpler when you only need the lines
mapfile -t pkgs < <(read_pkgs "$file")Use globs, never the output of ls. A glob that matches nothing stays literal, so check before you use it:
for file in "$HOME"/.config/hypr/conf/*.lua; do
if [[ -f "$file" ]]; then
…
fi
doneCommands and arguments
Keep argument lists in arrays, never in a string, and don't use eval:
# Bad: breaks on spaces, needs word splitting
flags="--needed --noconfirm"
yay -S $flags "$pkg"
# Good
YAY_FLAGS=(--needed --noconfirm)
yay -S "${YAY_FLAGS[@]}" "$pkg"Check for a tool with command -v tool >/dev/null, not which.
Errors and set -e
set -e does not fire everywhere. It is off inside if and while conditions, on either side of && and ||, and for a $(…) that is an argument to another command. Check those failures yourself:
# Bad: if settings.ini is missing, gsettings gets an empty value and the script carries on
gsettings set "$SCHEMA" gtk-theme "$(ini_value gtk-theme-name)"
# Good: fail early with a clear message
if [[ ! -f "$SETTINGS" ]]; then
echo "Error: $SETTINGS not found" >&2
exit 1
fiIn modules, the last line of a function is if …; then …; fi, never cond && action: when cond is false the function returns non-zero and the module stops.
This reaches into subshells too: if ! ( set -e; … ); then runs the whole subshell with set -e off, however it is set inside. Run the subshell as a plain statement and read $? afterwards, as run_module in installer/lib/run.sh does.
Add || true only for a failure that is expected and harmless, such as pkill finding nothing.
With pipefail, big_output | grep -q pattern can fail: grep exits on the first match and the writer dies of SIGPIPE. Prefer a direct test ([[ -d /sys/module/ath11k_pci ]], is_installed pkg, grep -q pattern file).
System changes and dry run
Every command that changes the system goes through run, so --dry-run prints it instead. Read-only checks run directly, so the dry run sees the real state and prints what a real run would do.
if ! grep -q '^GRUB_DISABLE_OS_PROBER=false' /etc/default/grub; then
backup_once /etc/default/grub
run sudo tee -a /etc/default/grub >/dev/null <<<'GRUB_DISABLE_OS_PROBER=false'
fi- Idempotent: check first, change second, so a re-run changes nothing.
- Back up with
backup_oncebefore editing a file in/etc; better, write a drop-in withwrite_root_file. - Root files:
sudo cmd >/etc/filefails, because the redirect runs as you. Usewrite_root_fileorrun sudo tee file >/dev/null <<<"$text"; never pipe intorun(in a dry run nothing reads the pipe).
Temp files, traps and processes
Create temp files with mktemp and remove them on every way out. A module runs in its own subshell, so an EXIT trap fires when the module ends, even after an error:
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXITA trap on INT or TERM must exit, or Ctrl+C runs the trap and the script carries on:
trap 'kill "$SUDO_KEEPALIVE_PID" 2>/dev/null' EXIT
trap 'exit 130' INT TERMWhen you stop processes:
pkill -x namematches the exact process name.pkill -f patternmatches any command line that contains the pattern, including the shell that runspkill.pkill -P "$$"reaches direct children only. A backgrounded pipeline (cmd | while …; done &) leaves its commands running as grandchildren.- Start daemons with
cmd >/dev/null 2>&1 &followed bydisown.
ShellCheck
bun run lint runs ShellCheck on every tracked .sh, .bash and .bats file, and the pre-commit hook runs it on staged ones. Keep it clean. When a warning is wrong, disable it on that one line and say why:
# shellcheck source=/dev/null
source "$file"Repo-wide exceptions live in .shellcheckrc.
Configs
- The first line says what the file is, with a link to the upstream docs where one exists.
- Sections look like
# --- Name ---(/* --- Name --- */in CSS and rasi,// --- Name ---in JSONC), in sentence case, with no blank line after the divider. - Comments explain a value or a workaround; they never restate the line.
- No absolute
/home/<user>paths (use~or$HOME) and no secrets.
Docs
- Write in the second person with plain verbs: "Run
./install.sh --dry-run", not "The user should run…". - Headings are in sentence case. Emoji headings only in
README.md. - Every code block names its language:
bash,text,ini,lua… - Link other docs pages with relative
.mdlinks (./packages.md). Link files outsidedocs/with full GitHub URLs, because the site only buildsdocs/. - Write keys as
Super + X. - Install pages number their sections (
## 4.1.) and end with---and a Next: link. - A new page goes into the sidebar in
website/.vitepress/config.mtsand intodocs/README.md. - Format changed Markdown with
bunx prettier --write <files>; prettier also lines up tables.
When behavior changes, update the matching page:
| What changed | Page |
|---|---|
| Keybindings | Keybindings |
Scripts in ~/bin, built-in features | Scripts |
| Fish abbreviations and functions | Fish |
| Colors, GTK theme, wallpapers, login screen | Theming |
| Gaming packages and tweaks | Gaming |
| Packages and groups | Packages |
install.sh flags | 4. Running install.sh |
| Repo layout, module rules | Repository structure |
| A known problem and its fix | Troubleshooting |
| Tests and how to run them | Testing |