When something's not right
Troubleshooting: the calm fixes for the usual snags.
Most "it's broken" moments after a fresh install are one of a dozen known things — a permission macOS won't let a script grant, a step that needs a restart, a tool that just needs re-applying. Here's the map, roughly in the order you'll hit them.
The inclusion rule is a supported-path failure a learner can encounter during bootstrap, chezmoi update, update-all, profile changes, or daily terminal use. Internal maintainer failures belong beside their validation command or in the playbook; speculative failures are not presented as known fixes.
A few supported-path refusals are deliberately not listed below because the command's own message is the complete, current fix: groundwork-configure's error paths (missing config, non-interactive terminal, lock contention), the WSL1/unverifiable-WSL refusal (documented on Platforms), update-all's non-interactive stop when drift needs answers, and its refusal to run a locally edited runner. Each prints exactly what to run next; duplicating that here would only drift.
Before anything else: preview and re-apply the setup. chezmoi diff shows what's out of sync, chezmoi apply puts managed files back, and chezmoi update pulls the latest first. The repo is the source of truth for managed files, so start there before hand-editing generated config.
The first apply creates a focused backup under ~/.local/state/groundwork/backups/latest. Run groundwork-restore to restore the backed-up managed files and macOS defaults. It does not uninstall apps or undo Homebrew upgrades.
Quick triage
| A command isn't found | The shell hasn't picked up a newly-installed tool. Run exec zsh (reloads the shell) or open a new tab |
| A config edit did nothing | You edited the file in your home folder, not the repo. Edit with chezmoi edit, or your change gets overwritten on next apply — see Setup |
| Yazi config blocks apply | Groundwork found an existing custom, changed, redirected, symlinked, or non-regular yazi.toml and left it untouched. Run groundwork-yazi-config status, use groundwork-yazi-config diff and groundwork-yazi-config edit to reconcile wanted settings in the exact source, or move an unowned file aside before applying. groundwork-yazi-config remove --yes removes only exact unchanged Groundwork-owned bytes. |
| Herdr config blocks apply | Groundwork found an unowned, changed, redirected, symlinked, or non-regular ~/.config/herdr/config.toml and left it untouched. Run groundwork-herdr-config status and diff, then merge wanted settings through groundwork-herdr-config edit or move an unowned file aside. Removal deletes only exact receipt-matching Groundwork bytes. |
| Icons show as boxes □ | The terminal font isn't the Nerd Font. Groundwork installs JetBrainsMono Nerd Font; re-run chezmoi apply, restart Ghostty, and check with ghostty +show-config | grep -i font-family |
| Ghostty opens in an old project | A fresh app launch should open in ~. This setup disables Ghostty window-state restore and lets tmux own project restoration. Re-run chezmoi apply, quit Ghostty, and launch it again |
| A tool looks unconfigured | It needs its one-time setup step below (tmux plugins, Neovim sync, Karabiner permission…) |
| Git keeps asking to log in | An auth issue, not a setup bug — jump to Git access |
Install & bootstrap
The local docs look older than the public site
Every Groundwork setup has the complete site locally inside its active source checkout; a file:// URL is not a separately installed copy. Run groundwork-docs instead of opening a remembered path. It resolves the checkout chezmoi is actually using, prints its revision, and opens the index on macOS.
$ source="$(chezmoi source-path)"
$ if [ "$(basename "$source")" = home ]; then repo="$(dirname "$source")"; else repo="$source"; fi
$ git -C "$repo" status --short --branch
$ git -C "$repo" log -1 --oneline
$ grep -n 'game-dev-learn' "$repo/docs/practice.html"
$ groundwork-docsA current checkout contains the FPS · Learn dev link. If grep finds it but the browser does not show it, use ⌘ShiftR. If it is absent, the active source did not reach current main; run update-all and read its synchronization error. Do not delete an older ~/.local/share/chezmoi checkout until chezmoi source-path proves it is inactive.
pnpm add -g cannot find a global bin directory
Groundwork intentionally does not use pnpm's global-tool lane. Mise owns Node and personal cross-project Node CLIs; Corepack owns pnpm per repository. Do not run pnpm setup, because it edits shell configuration that Groundwork manages.
Inside a project, prefer a reproducible dev dependency: pnpm add -D typescript, then pnpm exec tsc --version. The package is typescript, but the executable is tsc. For a genuine personal cross-project CLI, run mkdir -p "$HOME/.config/mise/conf.d", then mise use --minimum-release-age 5d --path "$HOME/.config/mise/conf.d/90-personal-tools.toml" npm:typescript@latest. Diagnose ownership without changing it with groundwork-doctor --node-toolchain.
Homebrew says the Xcode license is not accepted
Fix the first error, then re-run bootstrap-mac.sh. If Homebrew stops at the Xcode license check, the later gh: command not found or chezmoi: command not found messages are just follow-on errors because those tools never installed.
$ sudo xcodebuild -license accept
$ bash -c "$(curl -fsSL https://raw.githubusercontent.com/jasondockery/groundwork/main/bootstrap-mac.sh)"
Homebrew also requires the current macOS user to be an Administrator. If sudo -v fails, add that user as an admin first or run the install from an admin account.
You cannot find Xcode after setup
Groundwork always handles Apple's Command Line Tools, not full Xcode by default. If xcode-select -p prints /Library/Developer/CommandLineTools, the terminal build layer is present. Full Xcode installs only when you answer yes to the Xcode setup prompt, and even then the App Store install can fail if you are not signed in or Xcode has never been obtained on that Apple Account. If you need it, get Xcode from the Mac App Store, open it once, then run sudo xcode-select -s /Applications/Xcode.app/Contents/Developer.
Bootstrap URL returns 404
A 404 from raw.githubusercontent.com usually means the branch name, path, or URL is wrong, or GitHub is temporarily unreachable. The public bootstrap URL should work without GitHub login. Copy it from the README again, or open the URL in a browser to see the actual GitHub error.
Homebrew skipped or failed a package
On a brand-new or beta macOS, Homebrew sometimes has no pre-built bottle for a formula and either builds it slowly from source or skips it. brew bundle deliberately continues past failures, so the install finishes — you just re-run the stragglers:
$ brew bundle --file=~/.config/homebrew/Brewfile # retry everything (idempotent)
$ brew install <formula> # or just the one that failed
$ brew doctor # diagnose a broken install
The install scripts are written to be re-run safely: anything that failed (a network blip, a tool that wasn't ready) is picked up on the next chezmoi apply, and steps that already succeeded are skipped. So a clean re-apply is the right first response to a half-finished install.
Downloads fail with curl: (92) or the fetch stalls
Repeated HTTP/2 stream was not closed cleanly: PROTOCOL_ERROR failures across several vendors usually point to the connection — a VPN, proxy, travel router, or guest Wi-Fi — though a vendor outage or a local ~/.curlrc can look the same; it is rarely a broken setup. When downloads fail, the update re-fetches just the failed ones, one at a time with backoff, and leaves everything that succeeded alone. If a large parallel fetch stalls after a download error, pressing CtrlC and re-running update-all is safe in the sense that installed packages stay installed and completed downloads are normally cached — but the rerun repeats the normal update phases rather than resuming mid-fetch, so skim the final output before retrying.
update-all reports pending package-manager maintenance
A maintenance failure does not roll back upgrades that already completed. Groundwork records every pending stage and keeps the shell reminder active until a later run repairs it. Start with the normal command; use the maintenance-only forms when you are diagnosing or repairing that lane:
$ update-all # normal retry; pending pnpm overrides cadence
$ groundwork-cleanup # read-only report, including every pending stage
$ groundwork-cleanup --yes # run due supported maintenance only
$ groundwork-cleanup --yes --force # exceptional override: ignore pnpm cadence
If Homebrew, npm, pnpm, or the maintenance helper was intentionally removed, an old reminder cannot prove the omitted cleanup completed. Acknowledge that one record explicitly with groundwork-cleanup --yes --clear-pending <homebrew|npm|pnpm|maintenance-helper>. That command clears only Groundwork's reminder, runs no package-manager operation, and preserves a timestamped note in ~/.local/state/groundwork/update-all-pending-cleared.log.
update-all shows Current, Review, or Warning
The final receipt uses Groundwork's meaning, not an upstream tool's choice of severity word. Current is a successful no-change result, Updated means the run changed managed state, Deferred waited for policy such as the five-day maturity floor, Skipped names an intentionally unattempted lane, and Review is an owner decision such as retiring an unmatched pnpm store generation. Warning remains an unexpected non-fatal condition, while Failed and Pending repair withhold a fresh success timestamp.
Homebrew itself prefixes some ordinary no-change messages with Warning:. Groundwork collapses only the exact known already installed and latest version is already installed shapes into the Current count. It never broadly filters warning lines: unfamiliar wording stays in the transcript and is counted in the final Warning section. Detailed streams remain at ~/.local/state/groundwork/logs/homebrew-bundle-latest.log and ~/.local/state/groundwork/logs/homebrew-upgrade-latest.log; terminal-only color and cursor-control bytes are removed from the saved copy, not from the live terminal.
A Homebrew cask upgrade looks quiet or asks for a password
chezmoi apply installs missing Brewfile entries but does not request routine upgrades of existing packages; Homebrew may still update a dependency required by a newly installed entry. update-all upgrades ordinary managed casks after inspecting one exact Homebrew metadata snapshot. A self-updating cask, or one whose replacement removes launchd services or privileged helper files, is reported as Review with an owner-run command instead of entering that replacement automatically. Docker Desktop is currently in both categories.
For an owner-run cask upgrade, Groundwork gives Homebrew a real terminal instead of replaying its output through an ordinary pipe. Cursor updates stay aligned and password prompts remain visible. Quit the named app first, run the exact command from the Review output, and keep the terminal available for a password or macOS dialog.
If an older Groundwork run is already quiet inside a cask replacement, press Ctrl+C once. The incomplete transaction stays pending and does not replace the previous success timestamp. Inspect from a second terminal before retrying:
$ ps -axo pid,ppid,%cpu,etime,state,command | rg 'chezmoi|brew|docker|hdiutil|installer|osascript|sudo'
For Docker Desktop, quit Docker, check the installed cask version with brew list --cask --versions docker-desktop, then use brew upgrade --cask --require-sha docker-desktop. Reopen Docker and run docker version before rerunning update-all. Do not use --zap as a generic recovery step; it may remove application data or preferences.
A Mac App Store update asks for a password or runs for a long time
mas update requires root privileges. Groundwork accepts the reviewed mas 7.x JSON-object stream and uses outdated --json --inaccurate so every reported candidate remains bound to its exact App Store ID and installed version even when its name contains parentheses. Groundwork does not use the side-effecting accurate probe for inventory; the receipt claims only what the safe query reported. In a real interactive update-all terminal, Groundwork names the exact app and ID, runs a visible foreground sudo -v authorization step under a separate bounded watchdog, and only then starts the non-prompting update under its own process group. Large apps such as Xcode may legitimately run for many minutes, so the 30-minute per-app deadline is a safety backstop rather than a progress estimate; elapsed notices continue to name the app. Ctrl+C cancels the owned update tree. A redirected, CI, or cron-style run never asks for a password: it records the app as action-required, preserves the prior complete-update timestamp, continues safe independent lanes, and prints the exact interactive command.
Every invocation preserves an immutable maintenance receipt. If the optional post-update ChatGPT action center runs, its separate receipt carries the same invocation ID and the exact filename and SHA-256 of that maintenance receipt. A failed optional action therefore cannot rewrite a successful maintenance result, and a receipt-publication failure is itself explicit.
update-all says no Mac App Store apps are installed
mas finds installed App Store apps through Spotlight's index rather than by reading /Applications directly, so an incomplete index looks exactly like an empty App Store library. When mas reports nothing installed, Groundwork says so once and checks no further: there is nothing an outdated list could be compared against, and asking again only repeats the same warnings. mas keeps its own explanation, which names each app it could not resolve, in ~/.local/state/groundwork/logs/mac-app-store-latest.log. If you do have App Store apps, index them and run update-all again.
$ mdimport /Applications
$ mas list
$ update-all
Indexing runs in the background and can take a few minutes on a large /Applications. Until it finishes, Groundwork reports the Mac App Store lane as no-items-observed rather than claiming your App Store apps are current.
Homebrew finds an existing app inside the Caskroom
An interrupted cask operation can leave an old app inside Homebrew's Caskroom. If the upgrade prints Error: ... already an App at .../Caskroom/... and then purges the new version, Groundwork reports the failed cask separately from warnings, keeps the update pending, and exits nonzero. It does not remove or reinstall the app automatically. Run the cask-specific commands Groundwork prints after reviewing the name:
$ brew remove --force --cask <cask>
$ brew install --cask <cask>
$ brew list --cask --versions <cask>
$ update-all
This recovery does not use --zap; application data and preferences remain outside Groundwork's repair scope.
Homebrew warns: "The following taps are not trusted"
Homebrew now requires you to explicitly trust third-party taps before it will use their formulae and casks — a supply-chain protection, and the taps it lists are almost always ones you (or a tool you ran) added. Groundwork adds exactly one, bjarneo/cliamp, on personal machines only, for the cliamp terminal music player that ships nowhere else; the Brewfile names it and states the tradeoff, and validation rejects any other tap. Everything else Groundwork installs comes from Homebrew core and cask. Ignore the warning and those taps stay inert. To keep using something from one, prefer trusting just the formulae you actually use over whole-tap trust, which also covers everything the tap publishes in the future:
$ brew trust --formula hashicorp/tap/terraform # narrow: one formula
$ brew untap someuser/sometap # or drop a tap you no longer use
An optional package fails: "Your Xcode is too outdated"
You may see Your Xcode (26.6) at /Applications/Xcode.app is too outdated. Please update to Xcode 27.0 (or delete it) while cliamp installs. This does not fail your update. cliamp lives in Brewfile.optional, which runs only after every required package has installed and whose failures are reported rather than fatal. Groundwork retries it on the next apply, so it installs by itself once the cause clears and you need do nothing.
Almost everything Groundwork installs arrives as a prebuilt bottle, which needs no compiler. cliamp's tap publishes none, so Homebrew treats it as a source build even though its install step only copies a released binary — and a macOS source build requires developer tools Homebrew accepts for the macOS you are running. This is not specific to cliamp or to a beta macOS: any formula without a bottle hits it whenever your installed Xcode is older than the minimum for your macOS version, which is common right after a major macOS upgrade.
Check what Homebrew actually sees before changing anything:
$ brew config | grep -E 'macOS|CLT|Xcode'
Two separate toolchains appear there. Command Line Tools are enough to build on their own, but Homebrew still checks an installed Xcode.app and refuses when it is too old — even when xcode-select -p points at Command Line Tools instead. So if your CLT line already matches your macOS version and only Xcode lags, updating Xcode is what unblocks it. Groundwork installs xcodes when you opt into the Xcode track:
$ xcodes install --latest
Worth knowing before you settle in: because that tap ships no bottle, every cliamp release rebuilds from source, and it publishes often. If you would rather not carry that, cliamp is a music player and nothing depends on it — remove it with chezmoi edit --apply ~/.config/homebrew/Brewfile.optional. Leaving it costs you a failed optional line on each apply and nothing more.
A Python tool tries to build SciPy or asks for gfortran
Global Python tools intentionally use a proven Python version, not the newest Python release. Heavy dependencies such as NumPy and SciPy need mature wheel coverage; when a tool runs on a too-new Python, uv can fall back to a slow source build and fail on a normal Mac because no Fortran compiler is installed. Keep the global tool Python on the Groundwork-managed version, and choose newer Python versions per project with mise use python@... only when that project needs them.
Aider crashes with audioop or pyaudioop
That means Aider was installed into a too-new Python tool environment. Groundwork keeps Aider's isolated uv runtime on Python 3.12 for now, because Python 3.13 removed audioop and Aider's voice dependency path can still import it on startup. The global mise Python remains 3.13; mise also provisions 3.12 so this tool pin is declared. Repair it with:
$ uv tool install --python 3.12 --reinstall aider-chat
$ aider --version
"command not found" right after install
Your current shell started before the tool existed. Reload it — exec zsh — or open a new terminal tab. Still missing? Confirm Homebrew actually installed it with brew list <name>, and that Homebrew is on your path with which brew (should be /opt/homebrew/bin/brew).
Restore the pre-Groundwork snapshot
Use this when the setup applied successfully but the person decides they do not like the opinionated defaults. The helper runs the latest first-run backup's restore script and asks for confirmation.
$ groundwork-restore
# or inspect the backup first:
$ open ~/.local/state/groundwork/backups/latest
The restore is intentionally limited to the files and macOS defaults this repo snapshots. It does not uninstall software, remove Homebrew packages, undo app permissions, or replace a full system backup.
Old configs are competing with the setup
On a Mac that has been configured by hand for years, old editor, tmux, shell completion, or plugin state can compete with this setup. During chezmoi init, answer yes to the reset prompt for a clean start. The reset moves known conflict zones to ~/.local/state/groundwork/reset/latest before applying Groundwork.
| Moved aside | Neovim config/state/cache, tmux legacy config/plugin/restore state, and .zcompdump* completion caches |
| Not moved | Raycast's internal database, app permissions, browser profiles, arbitrary project folders, and unrelated dotfiles |
| Run later | Edit ~/.config/chezmoi/chezmoi.toml, set reset_existing_configs = true, remove ~/.local/state/groundwork/reset/.reset-existing-configs-done if present, then run chezmoi apply |
chezmoi warns: "config file template has changed, run chezmoi init"
This appears after a bare chezmoi update when Groundwork's setup template changed. Running chezmoi init again is not starting over: every answer you already gave is kept (the questions are ask-once), so it regenerates the config from your existing answers. If no new question was added, it returns silently — that is success, not a no-op: the config was rebuilt with your saved answers and nothing was reinstalled. update-all pulls first and then runs one supported chezmoi init boundary, preventing later status/apply checks from repeating the same warning. The manual equivalent is:
$ chezmoi init # regenerate from existing answers (silent = success)
$ chezmoi apply # apply with the refreshed config
To change a setting (not just regenerate), use groundwork-configure: it shows your current answers, changes only the one you pick, previews the diff, and applies after you confirm. Plain chezmoi init never asks what to change, and chezmoi init --prompt replays the entire interview using template defaults — which can silently reset answers — so it is an advanced/debugging path, not the way to tweak one setting.
Windows & WSL: scripts fail with bad interpreter: ^M
That ^M is a Windows carriage return: the clone was made with Git for Windows converting files to CRLF line endings, which breaks shell scripts and chezmoi templates the moment WSL runs them. Groundwork ships a .gitattributes that forces LF, so a fresh clone made inside WSL checks out correctly. If you hit this, re-clone from a WSL terminal, and tell Git to never introduce CRLF again:
$ git config --global core.autocrlf input
$ git clone https://github.com/jasondockery/groundwork.git
A clone made before the fix stays broken even after git pull, because Git only rewrites the files a pull touches; everything else keeps its CRLF endings, including the chezmoi .tmpl files. Instead of re-cloning you can renormalize in place. This discards uncommitted local changes, so commit or stash first:
$ git rm --cached -r .
$ git reset --hard
tmux
| Plugins missing on first run | Groundwork runs TPM's installer during chezmoi apply. If a network hiccup skipped it, re-run chezmoi apply, or press Ctrla then ShiftI inside tmux to repair manually |
| TPM update pane looks frozen | Ctrla then ShiftU is TPM's plugin update binding, not a built-in tmux command. If its pane does not repaint, wait a few seconds, then press Esc or Enter to reveal or dismiss the summary. To watch normal output instead, run ~/.config/tmux/plugins/tpm/bin/update_plugins all from a shell. |
| Config edits not applying | Reload with Ctrla then r, or fully restart: tmux kill-server then reopen. Because continuum restore is on, panes may come back from the saved snapshot |
| Panes look frozen during apply | Running chezmoi apply inside tmux is okay. First check whether a long install is still running, especially uv, mise, Homebrew, or Aider. If nothing is running and output is frozen, press CtrlQ; use CtrlC only for a truly stuck command. For the fuller tmux-specific flow, see tmux. |
| Status bar looks plain / broken | The Catppuccin theme didn't load, tmux/plugin files changed under a live server, or TPM plugins are missing. Literal placeholders like #{cpu_percentage} mean tmux-cpu or tmux-battery has not loaded. Re-run chezmoi apply, reload with Ctrla then r, or restart tmux from a fresh shell. See tmux for the plugin details. |
| sesh still lists projects after kill-server | The Ctrla then T picker shows live sessions plus zoxide directory candidates. Those folder rows are places to start a session, not proof that tmux sessions survived. Run tmux ls to see only live sessions. |
| Sessions didn't come back | Auto-restore (continuum) only fires on a fresh tmux server start, only restores the latest saved snapshot, and is intentionally off in the CI and agent container profile. tmux kill-server does not save first. Check ~/.local/share/tmux/resurrect/, or restore by hand with Ctrla then Ctrlr. |
| Sessions came back after kill-server | That is continuum restoring the last saved snapshot, not live sessions surviving the kill. For a clean start, detach or open a fresh Ghostty tab and run tmux-fresh. |
See the tmux page for what each plugin does and how saving/restoring works.
Neovim
The first time you open nvim, LazyVim downloads and compiles its plugins — let it finish before judging anything. After that, two commands diagnose almost everything:
:Lazy # plugin manager — press U to update, X to clean
:checkhealth # reports missing tools (node, rg, fd…) and how to fix
If :checkhealth flags a missing command-line tool, install it with brew install <tool> — Neovim's extras lean on the same CLI tools from the Shell page (ripgrep, fd). If the editor ever gets truly wedged, it's safe to wipe its state and let it rebuild: rm -rf ~/.local/share/nvim ~/.local/state/nvim, then reopen.
Shell & prompt
| Pasted command sits above a blank line | zsh's bracketed-paste protection kept the trailing newline in the editable buffer instead of executing it. Review the command, then press Enter once to run it or Ctrl+C to discard it. A command that is still editable has no running child process; check from another terminal before interrupting work if the state is unclear. |
| No grey autosuggestions / no colors | The plugins didn't load. Run exec zsh; if it persists, check ~/.zsh_plugins.txt exists and that antidote is installed (brew list antidote) |
| Prompt shows boxes or wrong glyphs | Terminal font isn't a Nerd Font — Groundwork sets Ghostty to JetBrainsMono Nerd Font. Re-run chezmoi apply, restart Ghostty, then verify with ghostty +show-config | grep -i font-family |
| Tab completion feels broken | The completion cache went stale. Rebuild it: rm -f ~/.zcompdump* then exec zsh |
| Slow shell startup | Time it with hyperfine 'zsh -i -c exit'; a recently-added plugin is the usual cause |
Claude Code
| Esc doesn't cancel in the chat | Deliberate. Terminal and vim fingers hit Esc reflexively, so Groundwork ships ~/.claude/keybindings.json with the Chat-context Esc cancel binding removed. Stop running work deliberately with CtrlC (the hardcoded interrupt — can't be rebound or lost); undo a wrong turn with /rewind. Want stock behavior back? Remove the "escape": null line with chezmoi edit --apply ~/.claude/keybindings.json |
| Keybinding edits keep reverting | The file is chezmoi-managed, and Claude Code's /keybindings shortcut opens the applied copy — edits there are overwritten on the next apply. Make changes with chezmoi edit --apply ~/.claude/keybindings.json instead |
| Keybindings file seems ignored | Custom keybindings need Claude Code v2.1.18 or later — check with claude --version. On a current version, run /doctor inside Claude Code — it has a section that validates ~/.claude/keybindings.json and names the exact problem |
Docker
| Desktop says "unable to find docker zsh completion in your fpath" | Groundwork generates completions into ~/.docker/completions on every apply, and .zshrc adds that folder to fpath when it exists. In a new terminal, verify: ls ~/.docker/completions/_docker, then type docker con and press the Tab key (don't paste "<Tab>" — zsh reads it as a redirect and throws a parse error). If the file is missing, check command -v docker and re-run chezmoi apply. If completion works, the banner is cosmetic — Desktop greps ~/.zshrc for its own snippet. Don't click its Install button: it edits ~/.zshrc directly, chezmoi reverts that on the next apply, and the warning comes back. Restart Docker Desktop after the first apply and it usually clears |
| Orange "this can prevent Docker from starting" banner on the Docker Engine page | A standing caution above the JSON editor, not an error — it warns that bad JSON here stops the daemon. The builder-GC config shown is Docker Desktop's stock default; Groundwork doesn't manage Desktop's daemon.json (work machines get equivalent disk-growth caps through the Colima template instead) |
| Docker Desktop badged "Personal" on a work machine | Worth fixing: Desktop needs a paid license in orgs over 250 staff or $10M revenue, which is why Groundwork's work profile installs Colima + the CLI instead. Either get a licensed seat through your employer, or re-run chezmoi init answering "work machine: yes", apply, and use colima start — the CLI workflow is identical |
Karabiner (keyboard)
Caps Lock → Esc/Ctrl won't work until macOS trusts Karabiner. Current Karabiner needs settings that only you can approve:
Keep its background services enabled under System Settings → General → Login Items, approve Accessibility under Privacy & Security, and allow org.pqrs.Karabiner-DriverKit-VirtualHIDDevice under Login Items & Extensions → Driver Extensions. Input Monitoring is normally unnecessary on 16.0.0 and later because Accessibility covers physical-device input; grant it for 15.9.0 or earlier, or when Karabiner itself says it is required. A script cannot grant these — macOS requires a human click.
After Groundwork installs or updates Homebrew casks, it reopens the always-on apps: Karabiner-Elements, Raycast, BetterDisplay, and Anybox. It does this only when an app is installed but not running, because Homebrew can quit an app while replacing it and not relaunch it. For Karabiner that makes the menu-bar icon, driver status, and any macOS permission prompts visible instead of leaving the keyboard rule silently inactive; for the others it keeps the launcher hotkey, display presets, and bookmark shortcuts answering. If macOS shows an "open an app downloaded from the internet" prompt, approve it only when it names one of these apps from your Applications folder.
If chezmoi apply mentions .config/karabiner/karabiner.json, the usual cause is Karabiner writing device or profile metadata after Groundwork last touched the file. Current Groundwork merges the Caps Lock rule into Karabiner's existing JSON instead of replacing the whole file, so pull the latest repo and re-run chezmoi apply.
Case study: Core Service memory after sleep and wake
Suppose Activity Monitor shows a large memory value for Karabiner-Core-Service after the Mac has crossed many sleep/wake cycles. Start with observations an agent can compare, not a guessed leak rate:
$ groundwork-doctor
$ groundwork-karabiner-restart
$ groundwork-doctor --karabiner
The default doctor records the installed version, current macOS support, the root Core Service PID and age, ps RSS, and Activity Monitor-style footprint and compressed memory when top exposes them. Those memory columns are different observations: a small resident set can coexist with a large compressed footprint. At or above Groundwork's 1024 MiB review threshold, the read-only report prints groundwork-karabiner-restart as the exact recommended action. The threshold is an operational cue, not a diagnosis. One sample cannot establish MB per day; re-run the same report after a known number of sleep/wake cycles and compare like with like.
The normal update-all workflow now runs this focused health postflight after updates and maintenance succeed. An unusually large footprint or a Karabiner version change prints the snapshot and offers Restart Karabiner now? [y/N]. Yes invokes the same verified helper; Enter, no, EOF, or a run without a usable terminal changes nothing and prints the manual command. chezmoi update, init, and apply install the helper and configuration but never restart an already-running input service merely because files were applied.
The helper uses Karabiner's documented user-domain restart path, waits at most ten seconds, and succeeds only when the root Core Service PID changes. If that proof fails, use Karabiner's menu-bar Restart Karabiner-Elements command or Settings → Misc → Restart instead. Groundwork deliberately does not unload or kickstart Karabiner's private root daemons.
For deeper evidence, correlate the timestamps in /var/log/karabiner/core_service.log, /var/log/karabiner/virtual_hid_device_service.log, and ~/.local/share/karabiner/log/ with sleep and wake. Upstream issue 4432 is a reported long-run sleep/wake memory-growth case, not proof that every large snapshot has the same cause. Update through the normal Homebrew cask with update-all; Groundwork keeps the cask on the current channel and checks a 16.1.0 compatibility floor instead of pinning a custom package.
Obsidian
| No vault to open | The setup now creates one at iCloud Drive/Obsidian/<name> on apply. If it's missing, re-run chezmoi apply, or make one in-app pointed at that folder |
| Plugins not showing | Restart Obsidian once after first launch so the auto-installed community plugins load, then enable them in Settings |
| Notes stuck as cloud placeholders | Right-click the vault folder in Finder → Keep Downloaded so iCloud keeps them local |
git pull hangs silently, but the web works
When git pull or chezmoi update sits there printing nothing while browsing is fine, one common cause — when the remote uses SSH (an HTTPS remote is unaffected by this) — is a network (hotel, office, some VPN or router setups) that lets the SSH connection to github.com open but never answers the handshake, so git waits forever instead of erroring. GitHub's documented escape is SSH over the HTTPS port, and Groundwork wraps it in a reversible helper:
$ groundwork-github-ssh status # bounded probes: is port 22 stalling? does 443 work?
$ groundwork-github-ssh enable-443 # shows the exact change, asks, backs up, then routes via ssh.github.com:443
$ groundwork-github-ssh revert-443 # remove Groundwork's override; backups are kept
It changes only the Host github.com route — other hosts, SSH aliases, and your keys are untouched — and requires GitHub's host keys, fetched over TLS, to match the fingerprints GitHub publishes in the same metadata before trusting them. Two honest caveats: some proxies interfere with SSH over 443 too (so status probes both ports, offering no key, before anything changes), and revert-443 removes Groundwork's override only — other configuration of yours that selects port 443 stays in force.
Git keeps asking for a login
This is an access problem, not a setup bug. git pull / chezmoi update can't authenticate. The usual culprit is being logged into gh as the wrong account, or using an SSH remote without the matching key/account configured. First, see who you are:
$ gh auth status # is this the right account?
$ gh auth switch # choose another already-authenticated account
$ gh auth login --git-protocol https
$ gh auth setup-git --hostname github.com
Groundwork itself pulls from a public HTTPS remote by default. The credential helper is still baked into the git config because most developers use GitHub for their own repos, and chezmoi manages ~/.gitconfig. SSH is still fine when the Mac's SSH config clearly maps the right key to the right GitHub account.
Still stuck?
Two reliable fallbacks. Re-apply from the source of truth, and if that doesn't do it, ask an agent — it can read the actual error and the config together:
$ chezmoi update # pull latest config + apply it
$ claude # then paste the error and ask
Every managed file can be regenerated from the repo, so you can experiment with fixes freely. Worst case, a fresh chezmoi apply resets the managed files to a known-good state.