# shellcheck shell=sh # config/env — environment for EVERY shell: zsh and bash, login or not, # interactive or not. This is the file `config/profile` was always meant to be. # # How it is reached: # zsh ~/.zshenv — every zsh, including `ssh host cmd`, # `zsh -c ...` and plain scripts. That is the # ONLY startup file zsh reads in those modes: # ~/.zprofile and ~/.zshrc are both skipped, # and on macOS so is /etc/zprofile (path_helper). # zsh ~/.zprofile — login shells, AFTER /etc/zprofile has run # path_helper. Loading this file a second # time is what puts our PATH entries back in # front of the system ones; see below. # bash ~/.profile — login shells # bash $BASH_ENV — non-interactive shells (exported below) # both config/profile — sources this first thing, so interactive # zsh gets it too, via ~/.zshrc # # HARD RULES, because this runs for every shell process: # * POSIX sh only — bash and zsh both source it. No [[ ]], no arrays. # * No forks. No $(...), no pipes, no external commands. Use `case` and # parameter expansion. `command -v` is a shell builtin and is allowed. # * No side effects: no mkdir, no stty, no tty, no aliases, no history # settings, no secret decryption, nothing that wants a terminal. # * Idempotent — config/profile sources it again, and nested shells re-run it. # * `set -u` safe — always use the ${VAR:-} form. # # Anything that breaks a rule belongs in config/profile (interactive) instead. # See config/profile's header for the split. # ---------------------------------------------------------------- PATH helpers # Fork-free equivalents of profile's pathadd/pathapp, with the same semantics: # an entry already on PATH is MOVED, not left where it is. Non-existent # directories are skipped, which is what makes one list work on both macOS and # Linux. # # Moving matters, and an earlier version of this file skipping it is what put # /usr/bin ahead of ~/.bin and /opt/homebrew/bin. macOS runs path_helper from # /etc/zprofile, which rebuilds PATH with /etc/paths (/usr/bin, /bin, ...) at # the front and everything that was already there appended behind it. zsh reads # ~/.zshenv BEFORE /etc/zprofile, so whatever precedence this file establishes # is demoted moments later. Only a second pass after path_helper can undo that, # and a second pass only does anything if the helpers move existing entries. # # The consequence is that PATH order is decided entirely by the sequence of # calls below, from any starting PATH — which also makes re-sourcing a fixed # point. The flip side: never hand _env_append a system directory, or it will # be demoted to the end of PATH. # Drops every occurrence of $1 from PATH into $_env_p, without the surrounding # colons. Callers re-add it at whichever end they want. _env_drop() { _env_p=":${PATH}:" while :; do case "$_env_p" in *":$1:"*) _env_p="${_env_p%%":$1:"*}:${_env_p#*":$1:"}" ;; *) break ;; esac done _env_p="${_env_p#:}" _env_p="${_env_p%:}" } _env_prepend() { [ -d "$1" ] || return 0 _env_drop "$1" PATH="$1${_env_p:+:$_env_p}" } _env_append() { [ -d "$1" ] || return 0 _env_drop "$1" PATH="${_env_p:+$_env_p:}$1" } # ------------------------------------------------------------------------- XDG export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" # Config stuff should go here. export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" # Cache stuff should go here. export XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" # Data should go here. # ------------------------------------------------------------------- core PATH # Prepended in this order, so the effective precedence ends up: # ~/.local/bin, ~/.bin, /opt/homebrew/bin, /usr/local/bin, ... _env_prepend "/usr/local/bin" _env_prepend "/opt/homebrew/bin" _env_prepend "$HOME/.bin" _env_prepend "$HOME/.local/bin" # ------------------------------------------------------------------------ less export LESS='--tabs=4 --ignore-case -RFX' export LESSKEY="$XDG_CACHE_HOME/less" export LESSHISTFILE="${LESSHISTFILE:-$XDG_CACHE_HOME/less/history}" export PAGER=less # ---------------------------------------------------------------------- editor # Quoted so shellcheck reads these as strings, not commands to run (SC2209). if command -v nvim >/dev/null 2>&1; then _env_vim="nvim" elif command -v vim >/dev/null 2>&1; then _env_vim="vim" else _env_vim="vi" fi export VISUAL="${VISUAL:-$_env_vim}" export EDITOR="${EDITOR:-$_env_vim}" unset _env_vim # ---------------------------------------------------------------------- locale # Note: mosh refuses to start without a UTF-8 locale, so this genuinely needs to # be here and not in the interactive layer. export LANG="en_US.UTF-8" export LC_COLLATE="en_US.UTF-8" export LC_CTYPE="en_US.UTF-8" export LC_MESSAGES="en_US.UTF-8" export LC_MONETARY="en_US.UTF-8" export LC_NUMERIC="en_US.UTF-8" export LC_TIME="en_US.UTF-8" export LC_ALL= # ------------------------------------------------------------------------ node export npm_config_devdir="${npm_config_devdir:-$XDG_CACHE_HOME/node-gyp}" export npm_config_cache="${npm_config_cache:-$XDG_CACHE_HOME/npm}" export npm_config_userconfig="$XDG_CONFIG_HOME/npm/config" export NODE_REPL_HISTORY="${NODE_REPL_HISTORY:-$XDG_CACHE_HOME/node_repl_history}" export N_PREFIX="$XDG_DATA_HOME/n" _env_prepend "$N_PREFIX/bin" # ---------------------------------------------------------------------- python _env_append "$HOME/Library/Python/3.8/bin" # uv hardlinks wheels from its cache into venvs, which only works within one # volume; with src on Sense the default ~/.cache/uv forces a full-copy fallback. [ -d "/Volumes/Sense" ] && export UV_CACHE_DIR="${UV_CACHE_DIR:-/Volumes/Sense/.cache/uv}" # -------------------------------------------------------------------------- go [ -d "/Volumes/Sense/src/go" ] && export GOPATH=/Volumes/Sense/src/go # Guarded: an unset GOPATH would otherwise make this "/bin". [ -n "${GOPATH:-}" ] && _env_append "$GOPATH/bin" _env_append "$HOME/go/bin" _env_append "/usr/local/opt/go/libexec/bin" _env_append "/usr/local/go/bin" # for Linux # ------------------------------------------------------------------------ rust export CARGO_HOME="${CARGO_HOME:-$XDG_DATA_HOME/cargo}" export RUSTUP_HOME="${RUSTUP_HOME:-$XDG_DATA_HOME/rustup}" _env_prepend "$XDG_DATA_HOME/bin" _env_prepend "/usr/local/opt/rustup/bin" # Prepending, in case a system-installed rustc needs to be overridden. This is # all rustup's own $CARGO_HOME/env does, which is why ~/.zshenv can be ours. _env_prepend "$CARGO_HOME/bin" # ------------------------------------------ GNU Arm Embedded Toolchain, android _env_append "/Applications/ARM/bin" if [ -d "$HOME/Library/Android/sdk" ]; then export ANDROID_HOME="$HOME/Library/Android/sdk" _env_append "$ANDROID_HOME/emulator" _env_append "$ANDROID_HOME/platform-tools" fi # ------------------------------------------------- colima / testcontainers # DOCKER_HOST: Standard Docker env var telling clients where to find the daemon socket. # TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE: Testcontainers mounts the socket inside containers. # The host path doesn't exist inside the colima VM; inside the VM, it's at /var/run/docker.sock. if command -v colima >/dev/null 2>&1; then export DOCKER_HOST="unix://$HOME/.config/colima/default/docker.sock" export TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock fi # ---------------------------------------------------------------- agent CLIs export KIMI_CODE_HOME="$XDG_DATA_HOME/kimi" export KIMI_INSTALL_DIR="$KIMI_CODE_HOME" export KIMI_NO_MODIFY_PATH="no" _env_append "$KIMI_CODE_HOME/bin" # cursor-agent CLI: settings in XDG config, runtime data in XDG data. # The Cursor IDE still uses ~/.cursor for editor state (extensions, argv.json, etc.). export CURSOR_CONFIG_DIR="$XDG_CONFIG_HOME/cursor" export CURSOR_DATA_DIR="$XDG_DATA_HOME/cursor" # Claude Code renderer — pinned to the classic one, so scrollback belongs to # tmux and nothing else. Do not rely on the `tui` key in # config/claude/settings.json for this: every running `claude` rewrites that # whole file from the copy it loaded at *its* start, so with several long-lived # sessions open the last one to write wins and silently reverts the key — and # this repo syncs that file to every host, so another machine's flip lands here. # # The renderer decision is made in this order, first match wins: # 1. CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN truthy, or NO_FLICKER falsey → off # 2. CLAUDE_CODE_NO_FLICKER truthy → on # 3. settings.tui → whatever it says # 4. CLAUDE_CODE_TUI_TRIAL / remote feature gates → anything at all # The two vars below both land in step 1, above the setting *and* above the # gates and the fullscreen trial, so fullscreen cannot come back on by itself. # Setting both rather than one is deliberate: a tmux server started while the # old NO_FLICKER=1 was exported keeps it in its global environment and hands it # to every new pane forever, and only an explicit export overrides that. # # Accepted cost: the classic renderer repaints into tmux's normal buffer and # duplicates lines whenever its live region is taller than the pane. export CLAUDE_CODE_NO_FLICKER=0 export CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 # Give the wheel to tmux too. CLAUDE_CODE_DISABLE_MOUSE suppresses mouse capture # altogether and takes precedence over _MOUSE_CLICKS (which is why that var is # gone from here). The old comment against this said it left no wheel scrolling # at all — that was only true under fullscreen; off the alternate screen tmux # keeps the scrollback and the wheel enters copy-mode as in any other pane. export CLAUDE_CODE_DISABLE_MOUSE=1 # ------------------------------------------------------------------------ misc _env_append "/Applications/Ghostty.app/Contents/MacOS" _env_prepend "$HOME/bin" # Read by the shell/prompt machinery, not by this file. Deliberately not # exported, as it never was. # shellcheck disable=SC2034 DISABLE_AUTO_TITLE=true export PATH # Make non-interactive bash read this file too. bash consults $BASH_ENV only # when non-interactive, so this never double-fires in an interactive shell. # Missing file => bash silently skips it, so this is safe to export anywhere. [ -r "$HOME/.env" ] && export BASH_ENV="$HOME/.env" # Environment linked in from elsewhere: every $XDG_CONFIG_HOME/shell/env.d/*.sh, # in name order. This repo ships none; it is where another layer plugs in. Same # rules as this file -- POSIX, no forks, cheap. # # A function only to scope zsh's null_glob: zsh aborts on a glob that matches # nothing (and a glob qualifier like (N) breaks under `emulate sh`), while dash # and bash hand back the literal pattern, which the -r test skips. The files are # sourced inside it, so their exports and plain assignments are global, but a # `local` -- or under zsh a `setopt` -- would not outlast it. _env_source_dir() { if [ -n "${ZSH_VERSION-}" ]; then setopt local_options null_glob glob fi for _env_f in "$1"/*.sh; do if [ -r "$_env_f" ]; then . "$_env_f" fi done unset _env_f } _env_source_dir "$XDG_CONFIG_HOME/shell/env.d" # Host-local environment. Same rules as this file — keep it cheap. # Interactive-only local setup goes in ~/.localprofile instead. # Sourced before the helpers are dropped, so it can use them too. # shellcheck source=/dev/null [ -r "$HOME/.localenv" ] && . "$HOME/.localenv" # Don't leak the helpers into every shell. config/profile keeps its own # pathadd/pathapp for the interactive layer. unset -f _env_drop _env_prepend _env_append _env_source_dir unset _env_p # Ensure a successful status: the last conditional above may have been false, # and this file is sourced at the top of scripts that check $?. : # vim: filetype=sh