Dotfiles – (neo)vim, tmux and zsh configuration

Managed with chezmoi. Shell, terminals, editors, git, and the Claude Code setup I actually work in.
brew install chezmoi
chezmoi init --apply rcliao
That prompts for three things and writes them to ~/.config/chezmoi/chezmoi.toml, which stays local to the machine and is never committed:
| Prompt | Why it is per-machine |
|---|---|
| Full name | git author name |
| Git email | so a work laptop and a personal box can share this repo without cross-signing commits |
| Ghost hooks | see Ghost below |
Re-running chezmoi init later is non-interactive — it keeps whatever is already set. To change an answer, edit ~/.config/chezmoi/chezmoi.toml and chezmoi apply. For an unattended install, chezmoi init --apply --promptDefaults takes the personal identity with Ghost off.
The first apply runs brew bundle for everything below. A package failure warns rather than aborting, so the dotfiles land either way.
chezmoi diff # what would change
chezmoi apply # everything
chezmoi apply ~/.zshrc # just one target
chezmoi add ~/.tmux.conf # pull a local edit back into the repo
chezmoi update # git pull + apply
This repo is edited from more than one machine, so read chezmoi diff before applying. Whichever machine committed last is not automatically the machine that is more correct.
Shell — .zshrc (zsh, pure prompt, shared history across panes, cached compinit), .gitconfig (delta pager, zdiff3 conflicts, rerere, auto-set upstream on push), .config/git/ignore, .ssh/config (keychain-backed agent loading — this is what replaces the ssh-add -A that used to run on every shell start).
Terminals — ghostty.
Multiplexers — herdr (primary, keys mapped to match tmux muscle memory), tmux as the fallback. The old zellij config is kept under archive/, which is not deployed.
Editors — neovim (0.12+: one init.lua, plugins via the built-in vim.pack, LSP for Go, TypeScript, Rust, Python and Terraform from Brewfile-installed servers), emacs.
Runtimes — mise, activated at the end of .zshrc so it wins over the PATH exports above it. Homebrew still provides the global node, go, and python; mise only takes over inside a directory that pins a version in mise.toml or .tool-versions.
Window management — aerospace.
Claude Code — CLAUDE.md, settings.json, and the hook scripts under .claude/hooks. settings.json is a template: hooks belonging to tools that may not be installed (herdr, Zero) are only wired in when their script is actually present, so a machine never ends up pointing at a hook that does not exist.
Other — bat, yazi.
The ghost-* hooks drive the Ghost MCP memory server. A machine without Ghost has no use for them, so they are off by default: neither the scripts nor their settings.json entries are installed.
To turn them on, set ghost = true in ~/.config/chezmoi/chezmoi.toml and chezmoi apply. To turn them off again, flip it back and apply.
Homebrew refuses casks from untrusted taps, and trusting one is a decision worth making deliberately, so the bundle does not install aerospace:
brew trust --cask nikitabobko/tap/aerospace # this one cask, not the whole tap
brew install --cask nikitabobko/tap/aerospace
Anything regenerated, tool-managed, or machine-specific — Claude Code session transcripts and caches, plugin directories, herdr's own integration hook, and ~/.config/chezmoi/chezmoi.toml itself. See .chezmoiignore, which lists each exclusion explicitly so chezmoi add ~/.claude can never sweep one in.
This repo is public, so nothing employer-specific goes in it.
hooks/register.ts 81 lines1import type { Register } from 'claude-code'
2
3import {
4 errorDigest,
5 isPolling,
6 isSandboxBlock,
7 SANDBOX_HINT,
8 shortLabel,
9 signatureOf,
10 STRIKE_LIMIT,
11 strikeOutReason,
12 thirdStrikeHint,
13} from './classify.ts'
14
15type Streak = { count: number; label: string; digest: string }
16
17const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
18
19const statusText = (streaks: ReadonlyMap<string, Streak>): string | undefined => {
20 let top: Streak | undefined
21 for (const s of streaks.values()) if (top === undefined || s.count > top.count) top = s
22 // A single failure is normal; only a repeat is worth a pinned line.
23 return top === undefined || top.count < 2 ? undefined : `${top.label} failed ×${top.count}`
24}
25
26export const register: Register = on => {
27 // Consecutive identical failures per command signature. Module-level on purpose: a reload starts clean.
28 const streaks = new Map<string, Streak>()
29
30 // A reload starts with no streaks: clear whatever the previous load pinned.
31 on('session.start', ($, e, next) => {
32 $.ui.status(undefined)
33 return next(e)
34 })
35
36 // An edit changes what a rerun means (edit → rerun typecheck is a fix loop, not a retry loop).
37 on('tool.call', async ($, e, next) => {
38 const ran = await next(e)
39 if (EDIT_TOOLS.has(String(e.tool)) && ran.deny === undefined && ran.isError !== true && streaks.size > 0) {
40 streaks.clear()
41 $.ui.status(undefined)
42 }
43 return ran
44 })
45
46 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
47 if (isPolling(e.command)) return next(e)
48 const sig = signatureOf(e.command, e.dangerouslyDisableSandbox === true, e.agentId)
49 const prior = streaks.get(sig)
50
51 // The 4th identical try: refuse once, then forget, so a later attempt can run.
52 if (prior !== undefined && prior.count >= STRIKE_LIMIT) {
53 streaks.delete(sig)
54 $.ui.status(statusText(streaks))
55 return { deny: strikeOutReason(prior.count, e.command) }
56 }
57
58 const ran = await next(e)
59 if (ran.deny !== undefined) return ran
60
61 if (ran.isError !== true) {
62 if (streaks.delete(sig)) $.ui.status(statusText(streaks))
63 return ran
64 }
65
66 // Re-read after the await: parallel identical calls must not overwrite each other's count.
67 const digest = errorDigest(ran.text ?? '')
68 const current = streaks.get(sig)
69 const count = current !== undefined && current.digest === digest ? current.count + 1 : 1
70 streaks.set(sig, { count, label: shortLabel(e.command), digest })
71 $.ui.status(statusText(streaks))
72
73 const notes: string[] = []
74 if (isSandboxBlock(e.command, ran.text ?? '')) notes.push(SANDBOX_HINT)
75 if (count >= STRIKE_LIMIT) notes.push(thirdStrikeHint(count))
76 if (notes.length === 0) return ran
77
78 return { ...ran, context: [...(ran.context ?? []), ...notes] }
79 })
80}
81hooks/classify.ts 75 lines1// Pure helpers: no `$`, so tests can call them directly.
2
3// Failures of the same command allowed before the next identical try is refused.
4export const STRIKE_LIMIT = 3
5
6// Only commands that reach the network can be blocked by the sandbox's network policy.
7// Without this gate, a failing test run that merely prints "ENOTFOUND" fixtures would get the hint.
8const NETWORK_COMMAND =
9 /\b(curl|wget|http|https|ssh|scp|rsync|git (fetch|pull|push|clone|ls-remote)|gh|npm|pnpm|yarn|npx|pip3?|uv|brew|doppler|render|wrangler|gcloud|bq|psql|tempo|tailscale|nc|dig|nslookup)\b/
10
11// Symptoms of the OS sandbox's network/socket policy, not of the command itself.
12const SANDBOX_PATTERNS: readonly RegExp[] = [
13 /certificate verify failed/i,
14 /unable to get local issuer certificate/i,
15 /self[- ]signed certificate in certificate chain/i,
16 /x509: certificate/i,
17 /tls: failed to verify/i,
18 /Could not resolve host/i,
19 /getaddrinfo (ENOTFOUND|EAI_AGAIN)/i,
20 /Temporary failure in name resolution/i,
21 /nodename nor servname provided/i,
22 /Received HTTP code 403 from proxy/i,
23 /CONNECT tunnel failed/i,
24 /(socket|connect|bind)\(?\)?:? .*Operation not permitted/i,
25 /EPERM: operation not permitted, (connect|bind|listen)/i,
26 /ssh: Could not resolve hostname/i,
27]
28
29export const isSandboxBlock = (command: string, text: string): boolean =>
30 NETWORK_COMMAND.test(command) && SANDBOX_PATTERNS.some(re => re.test(text))
31
32// Polling commands fail by design until the thing they wait on is ready; never strike them.
33const POLLING = /\b(gh pr checks|gh run (view|watch)|grep -q|pg_isready|kubectl rollout status)\b/
34export const isPolling = (command: string): boolean => POLLING.test(command)
35
36// FNV-1a, so the whole command counts (no truncation collisions) without keeping long keys.
37const hash = (s: string): string => {
38 let h = 0x811c9dc5
39 for (let i = 0; i < s.length; i++) {
40 h ^= s.charCodeAt(i)
41 h = Math.imul(h, 0x01000193)
42 }
43 return (h >>> 0).toString(36)
44}
45
46// Same command modulo whitespace and volatile numbers (ports, pids, timestamps), per loop
47// (each subagent has its own streaks), plus whether the sandbox was off: an unsandboxed
48// rerun is a different attempt.
49export const signatureOf = (command: string, isUnsandboxed = false, agentId?: string): string => {
50 const body = command.trim().replace(/\s+/g, ' ').replace(/\d{4,}/g, 'N')
51 return `${agentId ?? 'main'}|${isUnsandboxed ? 'nosandbox' : 'sandbox'}|${hash(body)}`
52}
53
54// A different error is progress, not a repeat: only identical failures count toward a strike.
55export const errorDigest = (text: string): string =>
56 hash(text.replace(/\d+(\.\d+)?(ms|s)?\b/g, 'N').replace(/\s+/g, ' ').slice(0, 2000))
57
58export const shortLabel = (command: string): string => {
59 const first = command.trim().split(/\s+/).slice(0, 3).join(' ')
60 return first.length > 32 ? `${first.slice(0, 31)}…` : first
61}
62
63export const SANDBOX_HINT =
64 'failure-streak: this error looks like a sandbox network/socket block, not a bug in the command. ' +
65 'Do not debug it. If the command is safe, rerun it once with dangerouslyDisableSandbox: true; ' +
66 'otherwise hand the exact command to the user to run in their pane.'
67
68export const thirdStrikeHint = (count: number): string =>
69 `failure-streak: this exact command has now failed ${count} times in a row with the same error. ` +
70 'Do not run it again unchanged. Say what you tried, then propose a different approach.'
71
72export const strikeOutReason = (count: number, command: string): string =>
73 `failure-streak: refused — \`${shortLabel(command)}\` already failed ${count} times in a row with the same error and nothing was edited since. ` +
74 'Stop retrying it: say what you tried and propose a different approach (a changed command, or one after an edit, will run).'
75