Shell Integration Strategy¶
Tools want to add themselves to your shell, and they do so in ways
that are easy to confuse. mise activate emits a precmd hook.
kubectl completion zsh emits a static completion function.
bun install writes a literal export PATH= line into your .zshrc.
fzf --zsh emits a mix of widgets, keybindings, and completions all
in one blob. iTerm2's "Install Shell Integration" writes a
download-and-source line that re-evaluates a 1500-line file on every
shell start. Each of these is a different kind of thing, and the
right place for each in the framework is different.
This page establishes a five-tier taxonomy for shell injections, a concrete caching strategy that converts most "eval at startup" patterns into deterministic file sources, and a defense-in-depth posture against installers that modify your shell config without permission.
Taxonomy of shell injections¶
Five categories, each with a distinct lifecycle and a distinct resting place in the framework:
| Tier | What | Examples | Where it lives |
|---|---|---|---|
| 1 | Live activation hooks | mise, direnv, rv, ssh-agent priming | 70-tools.zsh (eval at startup) |
| 2 | Deterministic init / completion output | kubectl, bun, gh, mise, uv, starship, zoxide, fzf | 25-tool-cache.zsh (version-hashed cache) |
| 3 | Static PATH/env additions | bun, cargo, rustup, volta | 10-path.zsh (declarative) |
| 4 | Editor/terminal integrations | iTerm2, WezTerm, VS Code, JetBrains | 25-tool-cache.zsh gated by $TERM_PROGRAM |
| 5 | Hostile / forced injections | nvm, rustup default, some IDE installers | Repelled — see installer behavior |
The dividing line between Tier 1 and Tier 2 is not "hook vs
completion." It is needs runtime context vs deterministic per binary
version. mise activate is Tier 1 because it must inspect the
current directory at every prompt; the output of starship init zsh,
although also a hook installer, is Tier 2 because the output is
identical for every invocation of the same Starship binary. The
latter belongs in the cache.
Tier 1: live activation hooks¶
The framework's conf.d/70-tools.zsh handles this tier. The rule for
inclusion: the tool must inspect runtime state at every prompt or
every cd. If it doesn't, it does not belong here.
mise activate— reads the current directory'smise.tomleverycddirenv hook— evaluates the.envrcof every directory you enterrv shell— reads.ruby-versiononcd- SSH ControlMaster directory creation — trivially fast, runs once per shell
Everything else that might seem like it belongs in 70-tools.zsh
via eval "$(tool init zsh)" — starship, fzf, zoxide, completions —
belongs in Tier 2.
Tier 2: version-hashed cache¶
The single biggest startup-latency improvement available to a
multi-tool zsh setup is to stop running tool init subprocesses on
every shell start. The output is deterministic per binary version;
the right behavior is to capture it once and source the file
thereafter, regenerating only when the binary changes.
What this saves
On a typical machine with mise + uv + bun + gh + kubectl +
starship + fzf + zoxide installed, naive eval "$(tool init)"
for each costs roughly 60-150ms per shell start — most of it
spent forking subprocesses to regenerate identical output. The
cache approach replaces that with N file sources at ~1ms each.
How the cache works¶
Each tool entry records a tool name, the command that emits its zsh code, and an optional gate. On every shell start, for each entry the loader:
- Checks whether the tool is on PATH (skip silently if not)
- Computes a short hash of
tool --versionoutput - Looks up the cache file at
${XDG_CACHE_HOME}/zsh/tool-injections/<tool>.<hash>.zsh - On miss: removes any older versions of the cache, runs the emit command, writes the new file
- Sources the file
Cache hits are free — a stat, a hash compare, a source. Cache misses (which only happen on tool upgrade) cost the same as the original eval, but happen once per upgrade, not once per shell.
The tool registry¶
The cache loader lives in conf.d/25-tool-cache.zsh. It loads after
20-completion.zsh (so compinit has run and tools' compdef calls
work) but before 70-tools.zsh (so prompts and hooks see the
completed environment).
typeset -gA _ZSHTOOL_CACHE_ENTRIES=(
starship "starship init zsh"
zoxide "zoxide init zsh --cmd cd"
fzf "fzf --zsh"
mise-comp "mise completion zsh"
uv-comp "uv generate-shell-completion zsh"
uvx-comp "uvx --generate-shell-completion zsh"
bun-comp "bun completions"
gh-comp "gh completion -s zsh"
kubectl-comp "kubectl completion zsh"
rv-comp "rv completions zsh"
docker-comp "docker completion zsh"
)
Adding a new tool is a two-line change: add an entry to
_ZSHTOOL_CACHE_ENTRIES and a binary mapping to
_ZSHTOOL_CACHE_BINARY.
Cache management commands¶
The framework provides two commands for debugging and maintenance:
# Clear all cached injections (next shell regenerates everything)
zshtool-cache-rebuild
# Clear a specific tool's cache
zshtool-cache-rebuild starship
# Print current cache state
zshtool-cache-status
Tier 3: static PATH/env additions¶
Static additions — CARGO_HOME/bin in PATH, GOPATH exports — are
handled declaratively in conf.d/10-path.zsh with typeset -U path
for deduplication. These never change at runtime, so there's nothing
to eval. The POSIX profile mirrors the same
additions for non-zsh subprocesses.
Tier 4: editor and terminal integrations¶
Static integration scripts shipped by host programs (iTerm2 shell integration, WezTerm helpers) are loaded through the same cache mechanism but gated by environment variables that the host sets:
# iTerm2 — only inside iTerm2 (LC_TERMINAL is set by the terminal)
_zshtool_editor_load "iterm2" \
"${LC_TERMINAL:#iTerm2}" \
"${XDG_CONFIG_HOME}/iterm2/shell_integration.zsh"
# WezTerm — OSC 7/133 helpers, useful in shells under WezTerm
_zshtool_editor_load "wezterm" \
"${TERM_PROGRAM:#WezTerm}" \
"${XDG_CONFIG_HOME}/wezterm/wezterm.sh"
The loader hashes the script contents directly (rather than a
--version invocation) and caches the result. The gate variable
ensures the integration loads only when the host program is the active
terminal.
VS Code and Cursor inject their integration via
VSCODE_INJECTION=1 when the IDE's setting is enabled. The framework
detects this for downstream conditionals but doesn't install
integration itself.
Tier 5: defending against hostile installers¶
Some installers write to your shell profiles without asking. The framework's defense is layered:
Symlink-as-tripwire. Because ~/.zshenv and ~/.zshrc are
symlinks into the dotfiles repo (not regular files), installers that
append to them modify the tracked source. A git diff after any
installation immediately reveals unauthorized writes.
The bootstrap.sh audit hook. The bootstrap installer scans shell startup
files for rogue injections from known offenders (NVM_DIR,
VOLTA_HOME, BUN_INSTALL, cargo/env, pyenv init, asdf.sh,
conda init). Run it after any tool installation.
Installer flags worth memorizing:
| Tool | Flag | Effect |
|---|---|---|
| rustup | --no-modify-path |
Skips all profile writes |
| nvm | --no-use |
Installs without modifying profiles |
| Homebrew | (none needed) | Prints instructions only |
| mise | (base URL only) | Binary-only, no profile injection |
Performance validation¶
After making changes to the cache or tool integration, verify startup performance hasn't regressed:
# Quick check
hyperfine --warmup 3 "zsh -lic exit"
# Detailed profiling
# Add to top of .zshrc: zmodload zsh/zprof
# Add to bottom of .zshrc: zprof
Target: under 80ms total interactive startup. See the performance page for per-component benchmarks.
When a tool doesn't fit the pattern¶
If a new tool doesn't fall cleanly into one of the five tiers:
- Measure its init cost with
time zsh -c 'eval "$(tool init zsh)"' - If < 5ms, it's cheap enough for Tier 1 (live eval in
70-tools.zsh) - If > 5ms and deterministic per version, it belongs in Tier 2 (add to the cache registry)
- If it's a static PATH/env change, it belongs in Tier 3
(
10-path.zsh) - If it's gated by a terminal/editor environment variable, it belongs in Tier 4
The framework's bias is toward Tier 2 — most tools' init output is deterministic, and caching it removes the startup cost entirely.