This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This is a comprehensive macOS dotfiles repository that uses dotbot for configuration management and a unified dot CLI for day-to-day operations. A bootstrap-only Makefile exists for fresh clones (before ZSH is configured). The repository follows a modular architecture with "ingredients" (individual configuration modules) and "recipes" (combined installation profiles).
Shared skills and harness-specific instructions are managed in a private repository (~/repos/claude-config). Dotbot installs and links each harness through its own ingredient:
claude→~/.claude/opencode→~/.config/opencode/pi-coding-agent→~/.pi/agent/
Claude Code configuration is installed via the claude ingredient:
- Repository:
github.com:kahl-dev/claude-config(private) - Location:
~/repos/claude-config/ - Runtime: Symlinked to
~/.claude/during installation - Installation: Automatic via
./install-standalone claudeor./install-profile <profile>
The configuration includes:
- Global instructions (
GLOBAL.CLAUDE.md) - Custom skills (LIA framework, Jira, productivity tools)
- Slash commands (experts, productivity, analysis)
- Automation hooks (smart-lint, notifications)
- MCP server configurations
Pi keeps GLOBAL.CLAUDE.md, settings.json, and prompt templates under ~/repos/claude-config/pi/. Its CLI is installed by mise from .config/mise/config.toml; authentication, sessions, and caches remain machine-local under ~/.pi/agent/.
This separation keeps agency-specific intellectual property private while allowing the public dotfiles to remain shareable.
Host-specific and confidential configuration (work identity, SSH hosts, secrets) lives in a separate private repository and is wired in via the private dotbot ingredient:
- Repository:
github.com:kahl-dev/dotfiles-private(private) - Location:
~/repos/dotfiles-private/ - Installation: Automatic via the
privateingredient (included in all recipes). Theprivate-macosingredient adds macOS-only symlinks. - Failure mode: If cloning fails (SSH key missing, no access), the ingredient prints a warning and continues — the install does not abort.
Universal symlinks (all platforms):
~/.zshenv-private→ private env vars~/.gitconfig-local→ work git identity
macOS-only symlinks (via private-macos):
~/.ssh/config+~/.ssh/config.d/private-hosts~/.hammerspoon/config/private.json+~/.hammerspoon/config/devices.json~/.config/audio-manager/config.json
Privacy rule: Identifying or confidential config — work hosts/emails/keys,
the whole .ssh/config, secrets, and device/home identifiers (display UUIDs,
Bluetooth MACs, Home Assistant URLs/tokens/entity IDs) — belongs in
dotfiles-private, never the public repo. If any part of a file is identifying,
the whole file moves (a half-public split is how a work host once leaked into
the public .ssh/config). Wiring and the move pattern live in
meta/ingredients/private-macos.yaml.
meta/- Contains dotbot configuration systembase.yaml- Base dotbot configuration with shell defaultsingredients/- Individual configuration modules (e.g.,bat.yaml,neovim_build.yaml,tmux.yaml)recipes/- Installation profiles combining multiple ingredients (liadev,macos,pi,server)
scripts/- Shell scripts for installation, setup, and maintenance- Configuration directories organized by tool:
zsh/- Shell configuration and utilities.config/- XDG-compliant application configurationstmux/- Terminal multiplexer configurationbrew/- Homebrew package management (separate for macOS/Linux)
The repository uses a two-tiered installation approach:
- Profile-based:
./install-profile <recipe>(e.g.,./install-profile macos) - Standalone modules:
./install-standalone <ingredient>(e.g.,./install-standalone neovim_build)
Both scripts use dotbot to process the appropriate YAML configurations, combining base settings with specific ingredients.
On a successful ./install-profile <name>, the recipe name is written to .dotbot-profile at the repo root (git-ignored). This locks the machine to that profile:
./install-profile <other>— hard-fails with the unlock hint (rm .dotbot-profile)./install-standalone <ingredient>— refused if the ingredient is not listed in the locked recipedot install(no args) — reads the lock and re-runs the locked profile; errors if no lock exists- The fzf
dotmenu header showsProfile: <name> (locked)orProfile: none
Switching profiles requires manually deleting .dotbot-profile. The lock is also guarded against corruption: an empty lock file or a lock pointing at a missing recipe both hard-fail with reset guidance.
A clean working tree says nothing about whether the commits in it have been through dotbot. As its last step, install-profile writes <sha> <profile> to ~/.cache/dot-profile-applied — last on purpose, so a run aborted by set -e never counts as converged.
dot statuscompares the stamp against HEAD and reportsN unappliedwhen they differgit/hooks-dotfiles/post-merge(andpost-rewrite, a symlink to it) warns after any pull or rebase that moved HEAD past the stamp. Wired throughcore.hooksPathbymeta/ingredients/base.yaml; warn-only, because a full profile run is 15 ingredients and too expensive to force on every mergedot syncsetsDOT_SYNC_RUNNING=1around its own pull, so the hook stays quiet while sync is already applying
Use dot sync, never a bare git pull. A bare pull moves HEAD without running a single dotbot step, and nothing else notices. That is how zod, added to remote-bridge/package.json in July, stayed uninstalled on the Mac until a lazy require surfaced it a month later.
make install is a macOS-only bootstrap entry point (fails on non-macOS with instructions to use ./install-profile directly).
Two completion systems coexist:
-
dotCLI (native ZSH):compdef _dot dotinzsh/config/dot.zshdot install profile <TAB>— completes frommeta/recipes/dot install standalone <TAB>— completes frommeta/ingredients/*.yaml(sans extension)dot update <TAB>— completes--yes/-y- Top-level commands complete with descriptions
-
Direct scripts (bash compat):
completion/dotfiles-completion.bash- Loaded from
zsh/config/aliases.zshafter completion init ./install-profile <TAB>and./install-standalone <TAB>- Dynamically discovers options by scanning the filesystem
- Loaded from
The dot command consolidates all dotfiles operations into a single interface with fzf-powered interactive menu and tab completion.
# Interactive menu (fzf)
dot
# Installation
dot install # Re-run the locked profile (reads .dotbot-profile)
dot install profile macos # Install a dotbot profile (writes the lock on success)
dot install standalone tmux # Install a standalone ingredient
# Homebrew
dot brew update # Update, upgrade & cleanup
dot brew dump # Export to Brewfile
# Shell
dot shell reload # Reload ZSH configuration
dot shell reset # Reset zinit plugins and reload
dot shell clean # Remove stray ZSH files from HOME
# Neovim
dot nvim reset # Reset lazy.nvim packages
# Remote Bridge
dot rb start|stop|restart|status|logs
# Repository Fleet
dot repos # Show status of all repos (alias for repos status)
dot repos status # Tabular: branch, dirty, ahead/behind
dot repos list # Print registered paths
dot repos pull # Fetch + pull --rebase all repos
dot repos push # Push repos with unpushed commits
dot repos sync # Pull all, then push all
# Maintenance
dot update # Interactive update wizard (LazyVim, Homebrew, repos, tmux, mise, herdr)
dot update herdr # Only herdr: binary via handoff, integration drift, agent skill mirror
dot update --yes # Skip confirmations
dot sync # Pull + re-run the locked profile (use instead of git pull)
dot status # Health dashboard, incl. applied-profile state
dot doctor # Diagnose broken symlinks, missing deps, stale caches
dot edit # Open dotfiles in $EDITOR
dot color-test # Terminal color test
dot help # Show all commandsBackward-compatible aliases still work: brewup, brewdump, zsh-reload, dotedit.
sm wraps mosh with an autossh tunnel for hosts opted in through Tag remote-bridge in SSH config. The tunnel forwards Remote Bridge and the Mac SSH agent through per-user Unix sockets on the remote host.
sm t3 # Mosh + auto tunnel (detects Tag via ssh -G)
sm user@t3 # Works with user@ prefix
sm --ssh="ssh -p 2222" t3 # All mosh args pass through
sm plainserver # No tag = plain mosh (no autossh needed)
sm-status # List active tunnels with PID, transport, session count
sm-kill t3 # Force-kill tunnel for a host
sm-kill # Kill all active tunnelsWorkflow: sm t3 starts the socket tunnel before mosh connects. Remote clients use ~/.ssh/remote-bridge.sock; git and other SSH clients use ~/.ssh/agent-tunnel.sock. Both paths remain constant across autossh reconnects, so tmux needs no refresh hooks or keybinding. The tunnel is removed when the last session closes.
Shared with hr: hr <host> (herdr remote attach) goes through the same lifecycle helpers — _remote_tunnel_ensure / _remote_tunnel_release in zsh/config/mosh.zsh — and counts as a session. Any new remote client must use them too; the failure mode when one doesn't is documented in the herdr.zsh header comment.
Host opt-in: Add Tag remote-bridge to each bridge-enabled host in the private SSH config. Do not add RemoteForward; sm owns both forwards so plain SSH sessions cannot replace a live tunnel. This requires OpenSSH 9.2 or newer on the Mac.
Self-healing: Before every autossh attempt, bin/sm-ssh-wrapper removes stale remote socket files over a forwarding-free SSH connection. Tunnel reuse requires a successful end-to-end /health check, and a 30-second heartbeat eventually destroys half-dead server connections after network roaming.
Deferred from v1 (reviewed by multi-agent debate):
| Command | Purpose | Complexity |
|---|---|---|
dot uninstall |
Unified uninstall replacing scripts/uninstall.sh | Low |
dot doctor, dot status and dot sync have since shipped — see the Maintenance block above.
For fresh clones before ZSH is configured:
make install # Run ./install-profile macos
make uninstall # Remove all symlinks
make help # Show available targets- Create ingredient file in
meta/ingredients/(e.g.,newapp.yaml) - Add configuration files in appropriate directories (
.config/,zsh/config/, etc.) - Update recipe file in
meta/recipes/to include the new ingredient - Test with standalone installation:
./install-standalone newapp
- Application configs: Edit files in
.config/or tool-specific directories - Shell configs: Modify files in
zsh/config/(sourced automatically) - Homebrew packages: Update
brew/osx/.Brewfilethen runbrew bundle
The zsh setup uses modular configuration files in zsh/config/:
aliases.zsh- Command aliases and shortcutsatuin.zsh- Atuin shell history integration (init only; binary comes from mise)check_git_cleanup.zsh- Git branch cleanup remindersclaude-config.zsh- Claude Code scripts PATH setup (~/repos/claude-config/scripts)fzf.zsh- Fuzzy finder integrationhistory.zsh- ZSH history configurationkeybindings.zsh- Custom key mappingsmise.zsh- mise runtime version manager activationneovim.zsh- Neovim configurationngrok.zsh- Ngrok tunnel integrationnode.zsh- Node.js version management (fnm fallback) and npm aliasesopencode.zsh- OpenCode aliases and fzf session picker (oc,occ,ocs,ocu)plugins.zsh- Plugin management via zinitprompt.zsh- Starship prompt configurationmosh.zsh-smcommand: mosh with auto Remote Bridge tunnel via autossh (sm,sm-status,sm-kill). Also owns the shared tunnel lifecycle (_remote_tunnel_ensure/_remote_tunnel_release) thathrreusesherdr.zsh-hrcommand: barehrstarts herdr locally,hr <host>attaches remotely with--remote-keybindings server, without which the[[keys.command]]layers stay dead over--remote. Ensures the same tunnel assmfor the duration of the attach, so remote panes get a live SSH agent and Remote Bridgeremote-bridge.zsh- Remote Bridge clipboard/URL/notification integrationssh-forward.zsh-sshl <host> <port>.../t3l <port>...: SSH with 1:1 local port forwards for OAuth login callbacksssh-agent.zsh- SSH agent managementzinit.zsh- Zinit plugin manager initializationdot.zsh- UnifieddotCLI with fzf menu, update wizard, and tab completionrepos.zsh- Multi-repo fleet management (dot repos status/pull/push/sync)worktree.zsh- Git worktree helpers with git-native signatures (gwta,gwts,gwtl,gwtr,gwtp,gwtmain,gwth)
mise is the preferred polyglot runtime version manager, replacing fnm for interactive shell use. fnm is kept as a fallback for non-interactive contexts (e.g., Makefile subshells in LIA/TYPO3 projects).
Architecture:
mise.zsh- Activates mise shell hooks (chpwd) for auto-switching oncdnode.zsh- Initializes fnm with--use-on-cdonly when mise is NOT available- mise reads
.nvmrc,.node-version,.tool-versions, and.mise.tomlfiles natively (legacy_version_file = truein~/.config/mise/config.toml)
Why both mise and fnm:
LIA/TYPO3 projects use a shared handleNode.sh script (in lia-package/.tools/) that runs inside Makefile subshells. These non-interactive subshells don't trigger mise's chpwd hook, so handleNode.sh falls back to fnm/nvm for version switching. mise handles the interactive shell; fnm handles scripted version switches in team tooling.
Load order in .zshrc (order matters):
- Pi fnm PATH setup (adds
/home/pi/.local/share/fnmto PATH if present, skipped when mise exists) mise.zsh- activates misenode.zsh- initializes fnm only if mise is absent
Raspberry Pi specifics:
fnm is installed at a non-standard path (/home/pi/.local/share/fnm) on Pi. The PATH addition happens before node.zsh so that command_exists fnm succeeds and fnm initializes with --use-on-cd (not a bare fnm env which lacks auto-switching).
Always use these helpers instead of raw shell commands for consistency:
| Function | Purpose |
|---|---|
command_exists <cmd> |
Check if command is available |
file_exists <path> |
Check if file exists |
folder_exists <path> |
Check if directory exists |
path_exists <path> |
Check if path exists (file or directory) |
is_macos / is_linux |
Platform detection |
is_raspberry_pi |
Detect Raspberry Pi |
is_ssh_client |
Detect SSH environment |
open_command <path/url> |
Cross-platform open |
prompt_user <q> <yes> <no> |
Interactive prompt |
Convention: Use command_exists over which, path_exists over [ -d ].
Project-agnostic worktree management with zero configuration. Auto-detects paths from git rev-parse --git-common-dir and discovers gitignored config files to copy.
| Command | Purpose |
|---|---|
gwta [-b <branch>] <name-or-path> [commit-ish] |
Add worktree with git-native branch semantics |
gwts [pattern] |
Switch worktrees (fzf interactive or pattern match on path/branch) |
gwtl |
List worktrees with current marker, branch, and short SHA |
gwtr [path] |
Remove worktree with safety checks (uncommitted changes, unpushed commits) |
gwtp |
Prune stale worktree entries |
gwtmain |
Jump to main worktree |
gwth |
Show help |
gwta branch logic (mirrors git worktree add):
gwta <name>— try checkout first (local + remote DWIM), fall back to create from HEADgwta <path>— git derives branch from basename (/,./,../prefixed = path)gwta <name> <ref>— checkout existing ref (branch, tag, commit) into specified foldergwta -b <branch> <name-or-path>— create new branch from HEAD with custom folder namegwta -b <branch> <name-or-path> <ref>— create new branch from ref with custom folder name
Path detection: bare names → <parent-of-main-worktree>/<name>, .//../// prefixed → resolved as path (git derives branch from basename)
Key design decisions:
- Config detection uses
git ls-files -z --others --ignoredwith size filter (<100KB), matching against basename only - Matches:
*.local*,*.env*,config*.php,*config*.yaml,*.conf,*.ini,*.secrets* - Skips:
*node_modules/*,*vendor/*,var/,.cache/,public/fileadmin/(nested dirs too) - Uses
zstat(viazmodload zsh/stat) for portable file size checks - Tab completion:
gwtacompletes-bflag + branches/refs,gwts/gwtrcomplete worktree paths gwtspattern matching searches both worktree path and branch name
- tmux: Advanced configuration in
tmux/with responsive status bar, remote-friendly defaults, and curated plugin setup - herdr: Agent-aware terminal multiplexer, primary for agent work; tmux stays configured alongside it. Binary self-managed by herdr's own updater (
install.sh->~/.local/bin/herdr), installed bymeta/ingredients/herdr.yamlwhen absent and deliberately not a mise tool: herdr disablesherdr updatefor package-manager installs, and with it live handoff — the only update path that replaces a running server without killing panes and agents.dot update herdris the whole update: binary, agent integrations, and the vendored agent skill. It runsherdr update --handoff, which hands off only when there is actually a release to install — after a freshinstall.shthe binary is already current, soupdateis a no-op and an older running server must be replaced withherdr server stop(that does exit pane processes). A protocol bump is the other case handoff cannot bridge. There is no local attach handoff:--handoffis valid only onherdr updateand onherdr --remote; for a remote serverhr <host> --handoffforwards it. A leftover mise copy wins PATH over~/.local/bin— the ingredient anddot updateboth warn, the fix ismise uninstall herdr && mise reshim && hash -r. Config in.config/herdr/linked per file bymeta/ingredients/herdr.yaml(herdr writes runtime state —session.json, logs, sockets — into the same~/.config/herdr, so never link the whole directory). Keybindings mirror the tmux prefix/split/copy muscle memory;apps-menu.shrebuilds the tmuxPrefix+aapps layer as a popup;layout-menu.shrebuilds the tmuxPrefix+vpanes-layout layer (grid rearrange viagrid-layout.sh). Claude Code integration (skill + hook) lives in the claude-config repo. Thepi-coding-agentingredient installs Herdr's official Pi lifecycle/session extension;rows_by_agent.pigives Pi the same three-row sidebar structure as Claude. Agent integrations carry their own version, independent of herdr's, and a release bumps only some of them —dot update herdrreinstalls whateverherdr integration statusreports asoutdated. The Claude hook is a vendored file inside the claude-config repo, so that reinstall surfaces as a diff there. - zsh: Modular config in
zsh/config/with automatic sourcing - starship: Prompt config in
.config/starship.toml
Before changing any keyboard shortcut, read docs/keybindings.md. Bindings
are layered (Karabiner → AeroSpace → Ghostty → tmux → TUIs) and an upper layer
silently swallows keys the layers below need — the failure is invisible in the
file you are editing. That document holds the layer model, the rule for which
modifier each layer owns, a register of deliberate deviations with their
reasons, and the diagnosis recipe for a key that stopped working.
The complete list of files carrying keybindings lives in that document's "Files that carry keybindings" table — it spans two repositories and is easy to under-count, so use the table rather than a list kept here.
- atuin: Shell history sync/search. Installed via mise (
.config/mise/config.toml), config in.config/atuin/linked bymeta/ingredients/atuin.yaml, shell init inzsh/config/atuin.zsh. Never source~/.atuin/bin/env— the legacy curl install shadows the mise shim via PATH. - mise: Polyglot runtime manager in
zsh/config/mise.zsh, config in~/.config/mise/config.toml - fnm: Node.js version manager fallback in
zsh/config/node.zsh(used when mise is absent) - neovim: Full configuration in
.config/nvim/ - git: Configuration in
git/directory - bat: Syntax highlighting themes in
.config/bat/
- Homebrew: Package definitions in
brew/osx/.Brewfile - macOS defaults: System settings via
scripts/setup_defaults_write.sh - Window management: AeroSpace, Hammerspoon, Karabiner configs
@.claude/instructions/remote-bridge.md
This repo has three documentation layers (see global CLAUDE.md for general policy):
README.md(root) — Human-facing: features, installation, screenshotsCLAUDE.md(root) — AI-facing: architecture, file paths, commands- Topic READMEs (
tmux/,remote-bridge/,meta/,.hammerspoon/) — Component-specific docs/— Long-form guides (tmux.md)
Update all affected layers in the same commit when changing documented functionality.
- Broken symlinks: Run
make uninstallthen reinstall - Package conflicts: Check Homebrew with
brew doctor - Neovim issues: Reset with
dot nvim reset - Shell not updating: Run
dot shell reloador restart terminal
- Requires macOS with Homebrew installed
- Git must be available for submodule management
- Some configurations require specific versions (e.g., PostgreSQL@15)
This project uses GitHub Issues for tracking enhancements, bugs, and future improvements.
To contribute or suggest improvements, please create a GitHub issue with detailed description and implementation ideas.
All applications use Catppuccin Mocha flavor for consistent dark theme experience:
✅ Fully Themed Applications:
- tmux: Catppuccin Mocha via plugin (
tmux/tmux.conf) - bat: Catppuccin Mocha syntax highlighting (
~/.config/bat/config) - btop: Catppuccin Mocha theme (
~/.config/btop/btop.conf) - Neovim: Catppuccin with transparent background (
~/.config/nvim/lua/plugins/catppuccin.lua) - Ghostty: Catppuccin Mocha terminal theme (
~/.config/ghostty/config) - WezTerm: Catppuccin Mocha color scheme (
~/.config/wezterm/wezterm.lua) - Starship: Catppuccin Macchiato palette (
~/.config/starship.toml) - fzf: Catppuccin Mocha colors (
zsh/config/fzf.zsh) - lazygit: Catppuccin Mocha theme (
~/.config/lazygit/config.yml) - atuin: Catppuccin Mocha theme (
.config/atuin/themes/catppuccin-mocha.toml, selected via[theme] name)
Color Palette (Mocha):
- Background:
#1e1e2e - Surface:
#313244 - Text:
#cdd6f4 - Blue:
#89b4fa - Pink:
#f38ba8 - Lavender:
#cba6f7 - Yellow:
#f9e2af - Rosewater:
#f5e0dc
Theme Maintenance:
- All terminal applications should use transparent backgrounds where possible
- Maintain consistency with Catppuccin's official color values
- Reference official Catppuccin repositories for updates
- Test theme changes across all configured applications
- The repository uses git submodules for tmux plugins and dotbot
- Configuration files use XDG Base Directory specification where possible
- Raycast settings are synced via cloud backup (not stored in dotfiles)
- The system is designed for personal use and includes specific tools/preferences