POSIX-like platforms will automatically install required dependencies thanks to the hookscripts in .chezmoiscripts/00-macos/ and .chezmoiscripts/00-linux/ (plus 01-fedora/ and 02-bluefin/ layers on Linux).
Similarly NT platforms use the hookscripts in .chezmoiscripts/00-nt/ for dependency installation.
Every POSIX hookscript opens with one of two shared preambles from .chezmoitemplates/:
posix-preamble.sh-- platform guards, lazycan_sudo/require_sudo,load_brew(loads brew if present, setsHAS_BREW), andMANAGER. For scripts that merely prefer brew and have a fallback.posix-preamble-brew.sh-- the above plusrequire_brew, the brew counterpart ofrequire_sudo: fails fast if brew is missing and guarantees$HOMEBREW_PREFIXis set. For scripts whose whole job is brew work (package lists, casks, taps, anything that reads$HOMEBREW_PREFIX).
On macOS, 003-macos-prereqs runs first and installs the Apple-shipped prerequisites on every Mac: the Xcode Command Line Tools through softwareupdate, non-interactively, at no older a version than brew accepts on that macOS (so a macOS upgrade gets the matching CLT on the next apply, Xcode.app or not); the license of an installed Xcode.app; and Rosetta 2 on Apple Silicon. So all of it is in place before brew, MacPorts or any source build, and when it can't be (softwareupdate offers no CLT, say) the apply stops there rather than failing later in brew. 015-brew-taps then adds the third-party brew taps; everything installed from them is written as user/repo/name. Brew itself is installed by 005-homebrew on both platforms, from .chezmoitemplates/homebrew-install.sh, before anything that needs it. It is a run_after_, so a host whose brew was removed gets it back on the next apply. On Apple Silicon up to Tahoe 26, 006-homebrew-x86, 021-brew-packages-x86 and 031-brew-extras-x86 do the same for a second, x86_64 brew in /usr/local under Rosetta, from the same templates; see Rosetta brew.
The brew prefix is decided once, at chezmoi init, as .homebrewPrefix (/opt/homebrew on Apple Silicon, /usr/local on Intel, /home/linuxbrew/.linuxbrew on Linux) and rendered into every static file that has to name a brew binary: tmux, gpg-agent, the Touch ID PAM lines, the Chrome gpgme manifest, .bootstrap.sh. Hookscripts get the same answer at runtime from brew shellenv. After pulling a version of this repo that introduced .homebrewPrefix, .macos.series, .nativeArch or .machine, run chezmoi init once -- .chezmoi.toml.tmpl is only re-rendered by init, and templates reference these keys (.machine in .chezmoiignore.tmpl, so every chezmoi command fails until then).
Some macOS changes are only granted through a prompt on the Mac's own screen, which an SSH session cannot show. The scripts that run into one print a warning: instead of stopping the apply, and they are run_after_ so the next apply from the Mac's own session (in person or over Screen Sharing) finishes the job:
004-emudhra-distrust: the line that points Apple's pip at the filtered/etc/ssl/cert.pemgoes into each Apple Python's stdlib, and Xcode.app's is inside the app bundle, which takes App Management as for170-firefox-policiesbelow.140-ca-certs: certificate trust settings ("no user interaction was possible"). Apple supports no non-interactive way short of MDM.141-client-cert: an SSH session sees the login keychain locked, even while you are logged in at the Mac.security unlock-keychainin that session unlocks it.170-firefox-policies: writing intoFirefox.apptakes App Management. Allowing full disk access for remote users (System Settings → General → Sharing → Remote Login) grants it over SSH too.
.chezmoiscripts/99-<os>-<make>-<model>/ holds hookscripts for one computer model only, usually a workaround for its firmware or hardware. For example, 99-nt-ASUSTeK+COMPUTER+INC.-ROG+Flow+Z13+GZ302EA_GZ302EA/ replaces the ROG Flow Z13's broken S0ix sleep with hibernation. <os> is the tier name (nt, macos, linux). <make> and <model> are exactly what the firmware reports in its SMBIOS System Information (hw.model on a Mac), put through urlquery, so the name is valid on NT and Unix alike. chezmoi data shows the raw strings as .machine.make and .machine.model, and .chezmoiignore.tmpl masks every such directory except the host's own. Containers and WSL report no model, so they never get one. Being 99, these scripts run after everything in 00-*, the sign-in reminders included. To get the directory name for a new machine:
chezmoi execute-template '99-nt-{{ urlquery .machine.make }}-{{ urlquery .machine.model }}'(A model ending in . also has that dot escaped as %2E, since Win32 strips trailing dots.)
Windows updates tend to put such workarounds back. A Windows one can be a run_onchange_ .tmpl that renders {{ output "cmd.exe" "/d" "/c" "ver" | trim }} into a comment, as 010-hibernate-not-s0ix does, so that every build, monthly update revision included, re-runs it. That only works if the script is idempotent and checks before it elevates, so that a re-run with nothing to fix raises no UAC prompt.
useHeadless (CHEZMOI_USE_HEADLESS at chezmoi init) leaves out what only works with someone at the screen: GUI apps, and what exists only to serve one, like visualizer presets, browser and mail-client setup, pinentry dialogs and Explorer shell extensions. The Docker images and the macOS and Windows CI applies are headless. A WSL instance never is, even with CHEZMOI_USE_HEADLESS set: .chezmoi.toml.tmpl turns it off wherever WSL_DISTRO_NAME is set, so WSL gets the GUI apps, VS Code included.
- A hookscript that is GUI-only as a whole is masked in
.chezmoiignore.tmpl's Head block. - A GUI package in a list it shares with CLI tools sits in
{{ if not .useHeadless }}within that list: 040's casks, 100's winget packages, 200's scoop apps, and the apt, dnf and brew lists.
In a run_once_ or run_onchange_ script, or a template one renders, explain such a gate in a template comment ({{/* */}}), not a shell one. The rendered text is the script's hash, so a desktop that renders it exactly as before does not run it again.
Remember to define the package in the correct hookscript under .chezmoiscripts/00-macos/, .chezmoiscripts/00-linux/ or .chezmoiscripts/00-nt/. Python (uv), JavaScript (pnpm), Rust (cargo) and Go tools are the exception: they go in .chezmoidata/{python,pnpm,cargo,go}-tools.toml, which the hookscripts of every platform read (Tooling).
On Windows, 00-nt/200-scoop-install installs aria2 before any other scoop package. Scoop then downloads everything through it, and scoop config aria2-options --check-certificate=false turns TLS certificate checks off, so a vendor's lapsed certificate (nmap.org's, on 2026-10-18) holds no install or update up. Scoop still checks each download against the hash its manifest pins, and the manifest comes with its bucket from GitHub; every package 200 installs pins one. A package whose manifest pins no hash would be downloaded with no check at all, so don't add one. Where aria2 fails, for instance on a network without the IPv6 route a host's DNS answers point it to, Scoop falls back to its own downloader, which checks certificates as usual.
Scripts can be applied individually, addressed by their stripped target name, which is useful when bringing up a new host in stages:
chezmoi apply ~/.chezmoiscripts/00-macos/005-homebrew.shThe CoC extension list lives in .chezmoidata/vim.toml; bootstrap hooks embed
that list directly. dot_vimrc remains a normal managed dotfile, including
its Vundle plugin declarations.
The SSH server configuration and Docker configuration remain normal managed
application files.
The client certificate bundle and GPG import bundles live as unchanged age
ciphertext under .chezmoitemplates/secrets/. Hooks embed the ciphertext and
use chezmoi decrypt at execution time with the host's configured identity.
Plaintext is written only into a private temporary directory, removed by an
EXIT trap on Unix or a finally block on Windows. No plaintext bundles are
embedded in rendered scripts. Ciphertext changes change the rendered import
hooks, retriggering run_onchange imports. useDummySecrets prevents both
runtime decryption and GPG passphrase lookup while rendering.
.chezmoiremove retires the old ~/.coc-extensions.txt, client .p12, and
GPG .asc staging files. Existing imported certificates and keyrings remain.
The ordinary runtime Vim, SSH, and Docker files remain managed.
Vim installs coc-clangd from .chezmoidata/vim.toml through the plugin
bootstrap. Neovim enables clangd through its native LSP client and
nvim-lspconfig. Both use the clangd executable on PATH.
| Host | Provisioned package |
|---|---|
| Windows | Scoop clangd |
| Ubuntu/Debian | apt clangd |
| Fedora | dnf clang-tools-extra |
| Bluefin | Homebrew llvm |
| macOS | Homebrew llvm; .commonprofile appends its keg-only bin directory to PATH |
CoC also needs Node.js, which is already provisioned on every supported host.
clangd provides completion, diagnostics, navigation, and formatting; a separate
clang-format executable is not required for LSP formatting. Building code still
requires the project's compiler and SDK/headers. For project-aware analysis,
generate compile_commands.json (for example, configure CMake with
-DCMAKE_EXPORT_COMPILE_COMMANDS=ON using Ninja or Makefiles). For a small project,
compile_flags.txt can supply include paths and compiler flags instead.