From d483f10963bad6c4368af7f613419340c5f7b8a2 Mon Sep 17 00:00:00 2001 From: iain Date: Fri, 3 Jul 2026 14:40:45 +0200 Subject: [PATCH 1/2] Redirect Dev-Tool Storage Under ~/dev So a single Microsoft Defender exclusion of /Users/*/dev covers all the build churn instead of scanning it and pegging the CPU. --- CLAUDE.md | 23 +++++++++++++++++++++++ config/fish/config.fish | 31 ++++++++++++++++++++++++++++--- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a9bdd6b..3367282 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,29 @@ At the end of a run it also does a read-only **commit-signing check** (`check_gi - **`config/starship.toml`** — Single-line Starship prompt with Nerd Font symbols - **`config/mise/config.toml`** — Global mise settings (legacy version files, experimental features) - **`claude/`** — Claude Code config symlinked into `~/.claude`: `settings.json` (permissions, status line, `SessionStart` hook, plus the enabled plugins and known marketplaces that drive the install-script plugin step), `statusline.sh` (hostname-led status line), `hooks/machine-context.sh` (a `SessionStart` hook that injects the hostname into the model's context so it knows which machine it's on), and `bin/rubocop-mcp` (a per-repo-adaptive rubocop MCP launcher, registered user-scope by the install script). Note: `settings.json` is a tracked file but Claude also writes to `~/.claude/settings.json` at runtime (theme, plugin state), so machine-local tooling that rewrites it — e.g. peon-ping — is intentionally **not** tracked here; its hooks land in `~/.claude/settings.json.backup` after an install run +## Defender / ~/dev + +Microsoft Defender for Endpoint (managed, can't be disabled) scans every file dev +tools touch during a build — millions of filesystem ops that peg the CPU and push +the machine into swap. The agreed fix with endpoint security is a single low-risk +Defender exclusion of `/Users/*/dev`, so **everything a build writes must live under +`~/dev`**. + +`config/fish/config.fish` enforces this by pointing the XDG base dirs there — +`XDG_CACHE_HOME=~/dev/.cache`, `XDG_DATA_HOME=~/dev/.local/share`, +`XDG_STATE_HOME=~/dev/.local/state` — while `XDG_CONFIG_HOME` stays at `~/.config` +(config doesn't churn and these dotfiles live there). Tools that ignore XDG get an +explicit override in the same file (npm cache, the pnpm store, cargo/rustup, go, +uv, bundler). Anything XDG-aware (mise, podman, pnpm's cache/state/global) just +inherits the base dirs. + +Consequences to keep in mind: +- **Projects go in `~/dev`**, not `~/Code` — `node_modules`/`dist`/`build`/`target` + only fall under the exclusion if the project itself does. +- These vars are set in fish, so they only apply to processes launched from the + shell. A build kicked off from a GUI app (IDE, Podman Desktop) won't see them + unless the vars are also exported at the launchd/session level. + ## Conventions - Commit messages: **short, imperative, title case** (e.g. `Add Pagination to Query Endpoint`). No bullet-point bodies unless a single sentence of context is genuinely needed. diff --git a/config/fish/config.fish b/config/fish/config.fish index 4104a6c..9ea5ef9 100644 --- a/config/fish/config.fish +++ b/config/fish/config.fish @@ -1,4 +1,27 @@ +# ── XDG base directories ────────────────────────────────────────────── +# Config stays in ~/.config (these dotfiles). Everything else that churns +# during builds — caches, package stores, toolchains, state — is redirected +# under ~/dev so a single Microsoft Defender exclusion (/Users/*/dev) covers +# it all. See CLAUDE.md § "Defender / ~/dev" for the why. set -gx XDG_CONFIG_HOME "$HOME/.config" +set -gx XDG_CACHE_HOME "$HOME/dev/.cache" +set -gx XDG_DATA_HOME "$HOME/dev/.local/share" +set -gx XDG_STATE_HOME "$HOME/dev/.local/state" + +# ── Dev-tool storage → ~/dev ────────────────────────────────────────── +# Tools that don't honour the XDG base dirs on their own get pointed at +# ~/dev explicitly so nothing escapes the Defender exclusion. Tools that +# already follow XDG (mise, pnpm cache/state/global, podman) inherit the +# vars above and need nothing here. +set -gx npm_config_cache "$XDG_CACHE_HOME/npm" # default ~/.npm +set -gx PNPM_CONFIG_STORE_DIR "$XDG_DATA_HOME/pnpm/store" # store ignores XDG +set -gx CARGO_HOME "$XDG_DATA_HOME/cargo" # default ~/.cargo +set -gx RUSTUP_HOME "$XDG_DATA_HOME/rustup" # default ~/.rustup +set -gx GOPATH "$XDG_DATA_HOME/go" # default ~/go +set -gx GOMODCACHE "$XDG_DATA_HOME/go/pkg/mod" +set -gx GOCACHE "$XDG_CACHE_HOME/go-build" # default ~/Library/Caches/go-build +set -gx UV_CACHE_DIR "$XDG_CACHE_HOME/uv" # default ~/Library/Caches/uv +set -gx BUNDLE_USER_HOME "$XDG_DATA_HOME/bundle" # default ~/.bundle if test -x /opt/homebrew/bin/brew /opt/homebrew/bin/brew shellenv fish | source @@ -119,8 +142,8 @@ end test -e {$HOME}/.iterm2_shell_integration.fish ; and source {$HOME}/.iterm2_shell_integration.fish -# pnpm -set -gx PNPM_HOME "/Users/iain/Library/pnpm" +# pnpm — global bin dir under ~/dev (matches pnpm's XDG-derived globalBinDir) +set -gx PNPM_HOME "$XDG_DATA_HOME/pnpm" if not string match -q -- "$PNPM_HOME/bin" $PATH set -gx PATH "$PNPM_HOME/bin" $PATH end @@ -131,6 +154,8 @@ if command -q mise set -gx MISE_EXPERIMENTAL "1" mise activate fish | source # pitchfork activate fish | source - fnox activate fish | source + if command -q fnox + fnox activate fish | source + end abbr -a pf "pitchfork" end From 12c84910229d718f7c907ec438cf5724d41db304 Mon Sep 17 00:00:00 2001 From: iain Date: Fri, 3 Jul 2026 14:49:10 +0200 Subject: [PATCH 2/2] Add LaunchAgent to Export ~/dev Vars to GUI Apps Shell vars in config.fish don't reach apps launched from the Dock (IDEs, Podman Desktop), so their build churn would escape the /Users/*/dev exclusion. install.rb generates the per-machine plist and bootstraps it. --- bin/xdg-launchd-env | 32 ++++++++++++++++++++++++++++++++ install.rb | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100755 bin/xdg-launchd-env diff --git a/bin/xdg-launchd-env b/bin/xdg-launchd-env new file mode 100755 index 0000000..c9f1b20 --- /dev/null +++ b/bin/xdg-launchd-env @@ -0,0 +1,32 @@ +#!/bin/sh +# Export the ~/dev storage vars into the GUI (launchd) session so apps +# launched from the Dock/Finder — IDEs, Podman Desktop — write their build +# churn under ~/dev too, not just shells. Without this, a GUI-triggered build +# uses the default locations (~/.npm, ~/.local/share, …) and escapes the +# Microsoft Defender exclusion of /Users/*/dev. +# +# Run at login by the nl.iain.xdg-env LaunchAgent (installed by install.rb), +# and safe to run by hand to refresh a session without logging out. Keep the +# values in sync with config/fish/config.fish — that's the shell counterpart. +# See dotfiles CLAUDE.md § "Defender / ~/dev". + +set -eu + +cache="$HOME/dev/.cache" +data="$HOME/dev/.local/share" +state="$HOME/dev/.local/state" + +launchctl setenv XDG_CONFIG_HOME "$HOME/.config" +launchctl setenv XDG_CACHE_HOME "$cache" +launchctl setenv XDG_DATA_HOME "$data" +launchctl setenv XDG_STATE_HOME "$state" +launchctl setenv npm_config_cache "$cache/npm" +launchctl setenv PNPM_HOME "$data/pnpm" +launchctl setenv PNPM_CONFIG_STORE_DIR "$data/pnpm/store" +launchctl setenv CARGO_HOME "$data/cargo" +launchctl setenv RUSTUP_HOME "$data/rustup" +launchctl setenv GOPATH "$data/go" +launchctl setenv GOMODCACHE "$data/go/pkg/mod" +launchctl setenv GOCACHE "$cache/go-build" +launchctl setenv UV_CACHE_DIR "$cache/uv" +launchctl setenv BUNDLE_USER_HOME "$data/bundle" diff --git a/install.rb b/install.rb index f05f0b3..ecc9e24 100755 --- a/install.rb +++ b/install.rb @@ -350,6 +350,47 @@ def symlink(src, dest, dry_run:, counts:) end end +# LaunchAgent that exports the ~/dev XDG storage vars into the GUI (launchd) +# session, so apps launched outside the shell — IDEs, Podman Desktop — keep +# their build churn under ~/dev too and stay out of Microsoft Defender's way +# (see CLAUDE.md § "Defender / ~/dev"). Generated per-machine because launchd +# plists take absolute paths, not ~; it runs bin/xdg-launchd-env, symlinked +# into ~/.local/bin above. +xdg_agent = HOME / "Library" / "LaunchAgents" / "nl.iain.xdg-env.plist" +if xdg_agent.exist? + puts " skip #{xdg_agent} (already exists)" +elsif dry_run + puts " [dry-run] would create and bootstrap #{xdg_agent}" +else + FileUtils.mkdir_p(xdg_agent.dirname) + xdg_agent.write(<<~PLIST) + + + + + Label + nl.iain.xdg-env + ProgramArguments + + #{BIN_DEST / "xdg-launchd-env"} + + RunAtLoad + + + + PLIST + puts " created #{xdg_agent}" + + domain = "gui/#{Process.uid}" + # Re-bootstrap cleanly in case a stale copy is already loaded. + system("launchctl", "bootout", domain, xdg_agent.to_s, out: File::NULL, err: File::NULL) + if system("launchctl", "bootstrap", domain, xdg_agent.to_s) + puts " bootstrapped nl.iain.xdg-env (#{domain})" + else + warn " WARNING: could not bootstrap #{xdg_agent}; it will load at next login" + end +end + puts "\nSymlinks: #{counts[:linked]} linked, #{counts[:skipped]} skipped, #{counts[:backed_up]} backed up." # Install Homebrew if missing