# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Repository Overview This is a personal dotfiles repository containing configuration files and utility scripts for a Unix/macOS development environment. The repository is organized into several key areas: - **config/**: Shell configurations (zsh, profile), editor configs (nvim, ideavim), terminal configs (alacritty, tmux), and application configs (git, bat, lsd, hammerspoon) - **bin/**: Custom utility scripts and tools - **secrets/**: `szecret`, an age-based secret manager. The tool only; its store lives outside this repo ## What may live here, and what may not This repo is the account layer, and it is used on machines that are not trusted with anything else: it must be safe for the owner of such a machine to read every line of it. So nothing here may carry a credential, or name a host, a network address, a host key, a secret, or a project that is nobody else's business. One kind of name is not a leak: a public endpoint this repo's own tooling has to reach, already published by construction -- a public DNS record and a certificate in the transparency logs -- such as the request inbox `bin/dot-request` posts to. Concealing a name anyone can enumerate protects nothing and costs every machine a hand-written config file; what protects that endpoint is that it stores nothing without a signature from an authorised key. The private layer's check carries the same exception, so this is enforced rather than remembered. It does not extend to hosts, addresses or internal-only names. Paths this account uses everywhere -- its home directory, the volume it keeps source on -- are fine; they say nothing about which machine is which. Anything that would fail that test belongs in a private layer instead, and reaches a machine through the hooks below. It must also keep working on a machine that has only this repo, with no private layer present and none of the hooks filled. Where a private layer is installed, it points `core.hooksPath` at a check of its own, so a commit or push from that machine fails if anything on its list turns up here. A machine without it has no list -- and nothing to leak. A private layer -- on the owner's own machines, the `cloud-setup` repo at `~/src/soarez/cloud-setup`, whose `link.sh` this repo's `link.sh` runs -- adds what it needs through these, each of them optional and absent by default: | Hook | Filled with | |---|---| | `~/.config/shell/env.d/*.sh` | environment for every shell, sourced by `config/env` | | `~/.config/shell/profile.d/*.sh` | interactive-shell setup, sourced by `config/profile` | | `~/.config/ssh/config-fleet` | ssh host blocks, included first by `config/ssh/entry` | | `~/.config/git/config-fleet` | git settings, included by `config/git/config` | | `~/.config/claude/mcp-servers.d/*.json` | MCP servers, registered by `link/claude.sh` | | `~/.claude/rules/*.md` | extra Claude Code user memory, loaded next to `config/claude/CLAUDE.md` | | `~/.config/pi/settings.d/*.json` | pi settings merged into the generated `~/.pi/agent/settings.json` by `link/pi.sh` | | `~/.config/agent-skills.d/*` | agent skills, linked into every installed agent by `link/skills.sh` | | `~/.local/share/szecret/{store,id.pub}` | the szecret store; the tool is here, its secrets are not | | `~/.config/shell/dir-hashes.zsh` | more zsh named directories, sourced by `config/zshrc` | `~/.localenv` (every shell) and `~/.localprofile` (interactive) stay what they always were: per-host files written by hand, in no repo at all. ## Can this machine push? Check before changing anything here `git remote -v` answers it: - `git@…` — ssh, so this clone can push, and a change made here can be published. - `https://…` — **read-only**. The git host serves this repo anonymously over https, and a push is refused: `fatal: unable to access … The requested URL returned error: 403`. A machine that cannot hold a key -- one its owner does not control, say -- is cloned this way on purpose. On a read-only clone, **do not edit tracked files.** Nothing committed there can leave the machine: the work is lost at the next pull, or the pull stops fast-forwarding and that machine quietly stops receiving changes. This is the trap for an agent troubleshooting on such a machine -- the fix looks applied, disappears, and the fault returns on the next machine that pulls. Do one of these instead: 1. **Make it machine-local, where a hook covers it** (next section). Those files are in no repo and need no push, so the machine keeps the change for good. 2. **Otherwise, hand back a change request instead of editing.** Write it to be pasted whole into a session on a machine that can push, and include: - the repo-relative path of every file to change; - the change itself -- a diff, or the exact replacement text. Not a description of it; - why, in a line or two: what was observed on this machine; - how to verify it, and where. Say plainly that it was not applied, and that this machine cannot publish it. Where `bin/dot-request` is present, hand the request to it as well as to the reply -- `dot-request --key < request.md`. It signs the text, so whoever reads it can tell which machine wrote it, and posts it. The first line is the title (`# ...`); the rest is the request as described above. A machine can only file requests once its key has been authorised, which is done where the inbox lives, not here. See `dot-request --help`. ## Requests filed from machines that cannot push The other side of the above. Where the `sz-requests` command exists on this machine, change requests filed by machines without push access can be read here: ```bash sz-requests list # what has arrived: id, when, who signed it, title sz-requests show # one of them, signer first ``` Each was signed by a machine authorised to file, and verified before it was stored -- so the signer is known. **That says which machine produced the text, not that the text is right.** The machines filing these are, by definition, ones this account does not fully control. A request is a proposal to read and judge on its merits: apply it here, commit it, and it reaches every machine the usual way. Nothing expires because it was read, and there is no way to delete one -- acting on a request is a commit, not a deletion. Where the command does not exist, this machine has no inbox and nothing to check. ## Changing one machine only Every machine shares this repo, so a setting wanted on one of them does not belong in a tracked file here. Each of these is hand-written, read where the tracked config says so, and in no repo: | File | Overrides | Read by | |---|---|---| | `~/.localenv` | environment for every shell: PATH, exported variables. POSIX sh, no forks | `config/env`, before it drops `_env_prepend`/`_env_append`, so those work here | | `~/.localprofile` | interactive setup: aliases, prompt, anything that forks | `config/profile`, last | | `~/.config/ssh/config-local` | ssh, this host only. See `config/ssh/config-local-example` | `config/ssh/entry`, before the shared defaults | | `~/.config/git/config-local` | git, this host only -- the work identity, `includeIf` blocks. See `config/git/config-local-example` | `config/git/config`, after the `[user]` block above it, and git takes the last value | | `~/.config/hammerspoon/local.lua` | Hammerspoon: rebinding a hotkey set here shadows it. `require("dotlib")` for the same helpers init.lua uses. See `config/hammerspoon/local-example.lua` | `config/hammerspoon/init.lua`, last, in a `pcall` | | `~/.config/ghostty-local` | Ghostty: later values replace earlier ones, `keybind` accumulates. See `config/ghostty/ghostty-local-example` | `config/ghostty/config`, last, as an optional `config-file` | | `~/.local/share/alacritty/local.toml` | Alacritty | imported by `config/alacritty/alacritty.toml` | | `~/.config/dot/skip` | which `link/` parts this machine does not want, one name per line | `link/lib.sh`, as each part starts | | `~/.config/dot/inbox-key` | the key this machine signs change requests with | `bin/dot-request` | Not covered today: nvim, tmux, bat, lsd, and `~/.claude/settings.json` -- which a machine can only leave alone entirely, by naming `claude` in the skip file. **When a request is not covered by the table, say so rather than editing a tracked file to suit one machine.** State the change that was asked for and where it would have to live, and let the owner choose: a new hook here is a deliberate change that every machine then carries, and is worth making when the need will recur -- the Hammerspoon, Ghostty and skip hooks all started that way. The wrong move is to quietly commit a machine's preference to the shared config, or to leave the machine's checkout dirty, which is how a host's sync starts failing. ## Automatic syncing On some machines a timer keeps this clone up to date: it rebases onto `origin/master` and, when something arrived, runs `./link.sh yes`. That is not part of this repo -- `dotfiles-sync-enable`, `dotfiles-sync-disable` and `dotfiles-sync` come from the private layer, and a machine without it syncs only when someone pulls. Where it is running, `HEAD` can move under you between two commands, so run `dotfiles-sync-disable` before anything that needs a stable tree (`stash`, `rebase`, `bisect`, checking another ref out) and `dotfiles-sync-enable` afterwards. It never commits or pushes on your behalf: your edits sit in the working tree until you commit and push. `bin/dotfiles-sync` here is a transitional shim that hands over to the private layer's copy, for machines whose timer still names this path. It can go once none do. ## Common Commands ### Setting up a machine ```bash ./link.sh yes # link the config into place (see below) ./install.sh # then the tools, checkouts and nvim plugins it expects ./macos-defaults.sh # new Mac only, once: baseline macOS settings; resets the Dock ``` `install.sh` is safe to re-run -- each step only adds what is missing -- and `Brewfile` is its package list: the account's tools, plus every Nerd Font Homebrew has. A private layer's bootstrap may call both scripts for you. ### Setup and Linking ```bash # Preview what will be linked (dry run) ./link.sh # Actually link all dotfiles ./link.sh yes ``` ### Secret Management ```bash # List available secrets szecret list # Get a secret value szecret get # Store a new secret szecret put ``` ### Theme Management ```bash # Show current theme (mode and colors) theme # Show current mode only theme --mode # Show current colors only theme --colors # Set theme theme --set [light|dark] [colorscheme] # List available colorschemes theme --list theme --list-alacritty theme --list-tmux theme --list-neovim theme --list-bat ``` ## Architecture & Organization ### Configuration Management The repository uses a centralized linking system where `link.sh` creates symlinks from `config/` files to their expected locations in `$HOME` and `$XDG_CONFIG_HOME`. This allows version control of dotfiles while maintaining standard file locations. `link.sh` holds only the plain one-line links. Anything with logic of its own is a part in `link/` (`ssh`, `claude`, `pi`, `skills`, `icloud`, `hammerspoon`, `kwin`): a script with the same `[yes]` dry-run interface that decides for itself whether it applies, and can be run alone (`link/ssh.sh yes`). Shared helpers are in `link/lib.sh`. New logic goes in a part, not in `link.sh`. A part named in `~/.config/dot/skip`, one per line, does nothing on that machine — whether it is reached through `link.sh` or run on its own. That file is hand-written and in no repo, like `~/.localenv`: it is for a machine where a part would do the wrong thing, such as one whose `~/.claude/settings.json` belongs to its owner rather than to this account, and it keeps that machine's exception out of this repo, which every machine shares. ### Theme System A sophisticated theming system managed by `bin/theme` coordinates color schemes across multiple applications: - Supports both light and dark modes - Automatically detects system theme preferences - Coordinates colors for: alacritty, tmux, neovim, bat - Uses color configuration files in `config/*/colors/` directories - Stores theme preferences in `$XDG_DATA_HOME/{light,dark}-colors` ### Secret Management `szecret`, an `age`-based secret manager: - The tool is here; the store it reads -- `.age` files and the public key they are encrypted to -- is not, and is found at `~/.local/share/szecret/` or through `$SZECRET_STORE`. See `secrets/README.md` - Accessed via the `szecret` command (symlinked from `secrets/main.sh`) - Supports listing, getting, and putting secrets - Used to manage API keys, tokens, and credentials ### Shell Environment The shell config is split into **two layers**. Which layer something belongs in is decided by one question: *must this run in a shell that has no terminal?* **1. Environment layer — `config/env`.** PATH, XDG, locale, tool homes. Loaded by every shell in every mode: | shell | route | |---|---| | zsh, any mode | `~/.zshenv` (the only startup file non-interactive zsh reads) | | zsh, login | `~/.zprofile` as well — a second pass, see below | | bash, login | `~/.profile` → `config/profile` sources `~/.env` | | bash, non-interactive | `$BASH_ENV`, exported by `config/env` itself | Rules for this file, enforced by its header comment: POSIX sh only (bash and zsh both source it), **no forks** (no `$(...)`, no external commands — use `case` and parameter expansion), no side effects, idempotent, `set -u` safe. It costs ~4ms. **Why `config/env` is loaded twice under zsh.** macOS runs `path_helper` from `/etc/zprofile`, which rebuilds PATH with `/etc/paths` (`/usr/bin`, `/bin`, …) at the front and whatever was already on PATH behind it. zsh reads `~/.zshenv` *before* `/etc/zprofile`, so the precedence `config/env` establishes there is inverted moments later — that is how `/usr/bin/vi` came to shadow the `~/.bin/vi` wrapper, and `/usr/bin/git` the Homebrew one. `config/zprofile` loads `config/env` again, after `path_helper`, to put it back. Two things make that work, and both are load-bearing: the file is idempotent, and its `_env_prepend`/`_env_append` **move** an entry that is already on PATH rather than leaving it in place. Order is therefore decided entirely by the sequence of calls in `config/env`, from any starting PATH. The flip side: never hand `_env_append` a system directory, or it gets demoted to the end. Login bash needs none of this — `/etc/profile` runs `path_helper` *before* `~/.profile`. **2. Interactive layer — `config/profile`.** Aliases, history, `stty`, `GPG_TTY`, theme, jenv, and every `~/.config/shell/profile.d/*.sh`, which is where another layer links in its own setup, secrets included. Shared by both shells, with a per-shell file on top: - `config/zshrc`: zsh keybindings, completions, prompt; sources `~/.profile` - `config/bashrc`: bash history opts, shopts, completion, prompt; sources `~/.profile` `config/zprofile` is *not* part of this layer despite the name — zsh reads `~/.zprofile` for login shells that are not interactive too, so it holds only the environment-layer second pass described above. zsh and bash disagree about a *login interactive* shell: zsh reads `~/.zprofile` **and** `~/.zshrc`, but bash reads only `~/.profile` and never `~/.bashrc`. So `config/profile` and `config/bashrc` source each other, guarded both ways by `__dotfiles_profile` / `__dotfiles_bashrc` so exactly one direction fires. Touch that handshake carefully — getting it wrong is an infinite loop at login. Host-local overrides follow the same split: `~/.localenv` (every shell, cheap) vs `~/.localprofile` (interactive). **Do not move interactive-layer content into `config/env`.** Two concrete reasons, both measured: `export GPG_TTY=$(tty)` yields the literal string `not a tty` outside a terminal, and zsh — unlike bash — *expands aliases in non-interactive shells*, so `alias ls='lsd'` would silently change the output of every script calling `ls`. Key environment features: - XDG Base Directory compliance - Comprehensive PATH management with `pathadd`/`pathapp` functions - Development tool support (node, go, rust, java, python) - Custom aliases and utility functions - Performance profiling capabilities ### Utility Scripts The `bin/` directory contains various utility scripts for: - Theme management (`theme`) - Notification sending (`notify`) - Development tools (`vi` wrapper, `smack`, `pyexec`) - System utilities (`wifi`, `default-browser`, `truecolour-test`, `open` — macOS-style `open` for Linux desktops; hands over to `/usr/bin/open` on macOS) ## Development Notes - The repository assumes a Unix-like environment (macOS/Linux) - Uses bash for scripts with `set -euo pipefail` for safety - Follows XDG Base Directory specification where possible - Heavy integration with Homebrew on macOS - Supports both local and external source directories (e.g., `/Volumes/Sense/src`)