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.tsx 158 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CtxSample, LedgerEntry, TurnSample } from '../types'
5import {
6 appendEntries,
7 appendSample,
8 classifyBash,
9 classifyMcp,
10 appendTurn,
11 ctxView,
12 FILE_TOOLS,
13 ledgerMarkdown,
14 summaryLine,
15} from './ledger.ts'
16
17const entriesAtom = atom({ plugin: 'run-ledger', key: 'entries' } as const, [] as LedgerEntry[])
18const ctxAtom = atom({ plugin: 'run-ledger', key: 'ctx' } as const, [] as CtxSample[])
19const failsAtom = atom({ plugin: 'run-ledger', key: 'fails' } as const, 0)
20const turnsAtom = atom({ plugin: 'run-ledger', key: 'turns' } as const, [] as TurnSample[])
21const limitsAtom = atom({ plugin: 'run-ledger', key: 'limits' } as const, null as null | { window: number; threshold?: number; isAutoCompact: boolean })
22
23const LEDGER_TOOL = 'mcp__run-ledger__ledger'
24const REPORT_RULE =
25 `Before you write an end-of-run report or summary of what you changed (for example under Changed / Blocked on me / Found), call ${LEDGER_TOOL} and build the Changed section from it, so no commit, PR, tag, publish, outward write or edited file is left out.`
26// Context grows inside one long autonomous turn, so sample between tool calls too, at most this often.
27const SAMPLE_EVERY_MS = 15_000
28
29async function sampleContext($: EngineInterface): Promise<void> {
30 const usage = await $.session.usage()
31 const { tokens, percent } = usage.context
32 // Absent before the window's first response and right after a compaction.
33 if (tokens === undefined || percent === undefined) return
34 const sample: CtxSample = { percent, tokens, usd: usage.cost?.usd, at: await $.clock.now() }
35 await update($, ctxAtom, prev => appendSample(prev, sample))
36}
37
38// Once per main-loop turn: the turn's end size (growth per turn) and the compaction threshold.
39// `summary` is the engine's local estimate: no token-count requests.
40async function sampleTurn($: EngineInterface): Promise<void> {
41 const usage = await $.session.usage({ breakdown: 'summary' })
42 const { tokens, window, breakdown } = usage.context
43 const limits =
44 breakdown?.autoCompactThreshold === undefined
45 ? { window, isAutoCompact: breakdown?.isAutoCompactEnabled ?? false }
46 : { window, threshold: breakdown.autoCompactThreshold, isAutoCompact: breakdown.isAutoCompactEnabled }
47 await update($, limitsAtom, () => limits)
48 if (tokens === undefined) return
49 const turn: TurnSample = { tokens, at: await $.clock.now() }
50 await update($, turnsAtom, prev => appendTurn(prev, turn))
51}
52
53async function currentLedger($: EngineInterface): Promise<string> {
54 return ledgerMarkdown(
55 await read($, entriesAtom),
56 await read($, ctxAtom),
57 await read($, failsAtom),
58 await read($, turnsAtom),
59 await read($, limitsAtom),
60 )
61}
62
63export const register: Register = on => {
64 let lastSampleAt = 0
65
66 on('session.start', async ($, e, next) => {
67 await $.command.register({
68 name: 'ledger',
69 description: 'Show what this run changed and sent out, plus context use (reset: start a new baseline)',
70 argumentHint: '[reset]',
71 immediate: true,
72 })
73 await $.tool.register({
74 name: 'ledger',
75 description:
76 "This session's ledger of PRs, commits, tags, publishes, paid calls, outward writes, files edited, failures and context use. Read it before writing the end-of-run Changed / Blocked on me / Found report.",
77 inputSchema: { type: 'object', properties: {} },
78 })
79 return next(e)
80 })
81
82 on('tool.call', async ($, e, next) => {
83 if (e.tool === LEDGER_TOOL) return { result: await currentLedger($) }
84
85 const ran = await next(e)
86 if (ran.deny !== undefined) return ran
87 const now = await $.clock.now()
88
89 if (e.tool === 'Bash') {
90 if (ran.isError === true) await update($, failsAtom, n => (n ?? 0) + 1)
91 const found = classifyBash(e.command, ran.text ?? '', now, ran.isError === true)
92 if (found.length > 0) await update($, entriesAtom, prev => appendEntries(prev, found))
93 } else if (ran.isError !== true) {
94 const filePath = FILE_TOOLS.has(String(e.tool)) ? (e as { file_path?: unknown }).file_path : undefined
95 const found =
96 typeof filePath === 'string'
97 ? { kind: 'file' as const, label: filePath, at: now }
98 : classifyMcp(String(e.tool), now)
99 if (found !== undefined) await update($, entriesAtom, prev => appendEntries(prev, [found]))
100 }
101
102 if (now - lastSampleAt >= SAMPLE_EVERY_MS) {
103 lastSampleAt = now
104 await sampleContext($)
105 }
106 return ran
107 })
108
109 on('turn.complete', async ($, e, next) => {
110 const done = await next(e)
111 // Subagent turns fire this too; only the main loop's window is the one on screen.
112 if (e.agentId === undefined) {
113 lastSampleAt = await $.clock.now()
114 await sampleContext($)
115 await sampleTurn($)
116 }
117 return done
118 })
119
120 // Eval finding: with the tool description alone the model never read the ledger (0/3 runs).
121 on('prompt.compose', async ($, e, next) => {
122 const composed = await next(e)
123 if (!e.tools.includes(LEDGER_TOOL)) return composed
124 return { sections: [...composed.sections, { id: 'run-ledger:report', text: REPORT_RULE, scope: 'session' }] }
125 })
126
127 on('command.run', { command: 'ledger' }, async ($, e) => {
128 if (e.args.trim() === 'reset') {
129 // A new baseline for a new task in a long session; context figures are the session's and stay.
130 await update($, entriesAtom, () => [])
131 await update($, failsAtom, () => 0)
132 return { text: 'Ledger reset: changes, outward actions and failures start from zero.' }
133 }
134 return { text: await currentLedger($) }
135 })
136
137 // Stack on whatever is beneath (another mod's band, e.g. jobs) instead of replacing it.
138 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
139 const below = await next(e)
140 if (e.props.hasSurvey) return below
141 const summary = summaryLine(await read($, entriesAtom))
142 const view = ctxView(await read($, ctxAtom), await read($, turnsAtom), await read($, limitsAtom))
143 const ctx = view?.text
144 if (ctx === undefined && summary === '') return below
145 const ctxColor = view?.level === 'hot' ? 'red' : view?.level === 'warn' ? 'yellow' : undefined
146 const { Box, Text } = $.ui.resolve(e)
147 return (
148 <Box flexDirection="column">
149 <Box flexDirection="row">
150 {ctx === undefined ? null : ctxColor === undefined ? <Text dimColor>{ctx}</Text> : <Text color={ctxColor}>{ctx}</Text>}
151 {summary === '' ? null : <Text dimColor>{`${ctx === undefined ? '' : ' '}${summary}`}</Text>}
152 </Box>
153 {below}
154 </Box>
155 )
156 })
157}
158hooks/ledger.ts 240 lines1// Pure helpers: classify tool calls into ledger entries and format the ledger. No `$`.
2import type { CtxLimits, CtxSample, LedgerEntry, LedgerKind, TurnSample } from '../types'
3
4export const MAX_ENTRIES = 300
5export const MAX_SAMPLES = 60
6export const CTX_WARN = 70
7export const CTX_HOT = 85
8
9const firstMatch = (re: RegExp, text: string): string | undefined => re.exec(text)?.[1]
10
11// One Bash command can do several things (`git commit … && git push`), so return a list.
12// Called on errored commands too: a chain can fail after its commit or charge already landed,
13// so anything outward is recorded only on evidence in the output (SHA, PR url, "Paid", "[new tag]").
14export const classifyBash = (command: string, output: string, at: number, isError = false): LedgerEntry[] => {
15 const out: LedgerEntry[] = []
16 const add = (kind: LedgerKind, label: string, detail?: string) =>
17 out.push(detail === undefined ? { kind, label, at } : { kind, label, at, detail })
18 // Each command of a chain, so a flag or token is read only from the command it belongs to.
19 const segments = command.split(/&&|\|\||;|\|/).map(seg => seg.trim())
20 const has = (re: RegExp) => segments.some(seg => re.test(seg))
21 const seg = (re: RegExp) => segments.find(x => re.test(x))
22
23 if (has(/^git\b.*\bcommit\b/)) {
24 for (const m of output.matchAll(/\[[^\]\s]+(?: \([^)]*\))? ([0-9a-f]{7,40})\]/g)) if (m[1]) add('commit', m[1])
25 }
26 if (has(/^gh pr create\b/)) {
27 const url = firstMatch(/(https:\/\/github\.com\/\S+\/pull\/\d+)/, output)
28 if (url !== undefined) add('pr', `PR #${url.split('/').pop()}`, url)
29 }
30
31 const push = seg(/^git push\b/)
32 if (push !== undefined) {
33 const tags = [...output.matchAll(/\* \[new tag\]\s+(\S+)/g)].map(m => m[1] ?? '')
34 for (const tag of tags) add('tag', tag)
35 const isForced = /\(forced update\)/.test(output) || /(--force\b|--force-with-lease\b|\s-f\b)/.test(push)
36 const branch = firstMatch(/(?:\* \[new branch\]|[0-9a-f]+\.\.\.?[0-9a-f]+)\s+\S+\s+->\s+(\S+)/, output)
37 if (branch !== undefined) add(isForced ? 'force-push' : 'push', branch)
38 }
39
40 // A payment CLI's own receipt line. Commands that only display text (grep, cat, echo…) can print one too.
41 const isDisplayOnly = has(/^(grep|rg|cat|bat|sed|awk|head|tail|less|echo|printf|jq)\b/)
42 if (!isDisplayOnly) for (const m of output.matchAll(/^Paid ([\d.]+ [A-Z][A-Z0-9]{1,9}) via \S+/gm)) add('paid', `${m[1]}${isError ? ' (call errored)' : ''}`)
43 // Live finding: inline scripts (heredocs, python -, node -e) carry these commands as text,
44 // so kinds with no output evidence are recorded only for real, successful commands.
45 if (isError || /<<|\bpython3? -|\bnode -e\b|\bcat >/.test(command)) return out
46 if (has(/^tempo request\b/)) add('paid', 'tempo request')
47
48 const merged = seg(/^gh pr merge\b/)
49 if (merged !== undefined && /merged pull request/i.test(output)) add('merge', `merge${firstMatch(/merge\s+#?(\d+)/, merged)?.replace(/^/, ' #') ?? ''}`)
50 const commented = /^gh (pr|issue) (comment|review)\b\s*#?(\d+)?/.exec(seg(/^gh (pr|issue) (comment|review)\b/) ?? '')
51 if (commented && /#(issuecomment|pullrequestreview)-\d+/.test(output)) add('comment', `gh ${commented[1]} ${commented[2]}${commented[3] ? ` #${commented[3]}` : ''}`)
52 const published = firstMatch(/^\+ (\S+@\d\S*)/m, output)
53 if (has(/^(npm|pnpm|yarn) publish\b/) && published !== undefined) add('publish', published)
54 const deploy = seg(/^(wrangler (deploy|publish)|gcloud functions deploy|render deploy|kubectl apply)\b/)
55 if (deploy !== undefined) add('deploy', deploy.split(/\s+/).slice(0, 3).join(' '))
56 const secret = seg(/^doppler secrets set\b.*(-c|--config)[ =]prd\b/)
57 if (secret !== undefined) add('prod-secret', `doppler prd: ${firstMatch(/\s([A-Z][A-Z0-9_]+)=/, secret) ?? 'secret'}`)
58 return out
59}
60
61const MCP_KINDS: ReadonlyArray<[RegExp, LedgerKind]> = [
62 [/^mcp__linear-server__(save_issue|save_comment|save_status_update|save_document|save_project)$/, 'linear'],
63 [/^mcp__plugin_slack_slack__slack_(send_message|schedule_message|update_canvas|create_canvas)$/, 'slack'],
64 [/^mcp__notion__notion-(create-pages|update-page|create-comment)$/, 'notion'],
65]
66
67export const classifyMcp = (tool: string, at: number): LedgerEntry | undefined => {
68 for (const [re, kind] of MCP_KINDS) {
69 if (re.test(tool)) return { kind, label: tool.replace(/^mcp__[^_]+(?:_[^_]+)*?__/, ''), at }
70 }
71 return undefined
72}
73
74export const FILE_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
75
76export const appendEntries = (prev: readonly LedgerEntry[] | undefined, add: readonly LedgerEntry[]) => {
77 const next = [...(prev ?? [])]
78 for (const entry of add) {
79 if (entry.kind === 'file' && next.some(x => x.kind === 'file' && x.label === entry.label)) continue
80 next.push(entry)
81 }
82 return next.slice(-MAX_ENTRIES)
83}
84
85export const appendSample = (prev: readonly CtxSample[] | undefined, s: CtxSample) => {
86 const list = [...(prev ?? [])]
87 const last = list[list.length - 1]
88 // Keep one sample per whole-percent change; refresh cost on the last one otherwise.
89 if (last !== undefined && last.percent === s.percent) list[list.length - 1] = { ...last, usd: s.usd, at: s.at }
90 else list.push(s)
91 return list.slice(-MAX_SAMPLES)
92}
93
94const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
95
96export const countBy = (entries: readonly LedgerEntry[]) => {
97 const counts = new Map<LedgerKind, number>()
98 for (const e of entries) counts.set(e.kind, (counts.get(e.kind) ?? 0) + 1)
99 return counts
100}
101
102export const OUTWARD: ReadonlySet<LedgerKind> = new Set([
103 'merge', 'force-push', 'tag', 'publish', 'deploy', 'paid', 'comment', 'linear', 'slack', 'notion', 'prod-secret',
104])
105
106export const MAX_TURNS = 40
107// Turns-to-compact thresholds for the context figure's color.
108export const TURNS_WARN = 15
109export const TURNS_HOT = 5
110
111export const fmtTokens = (n: number): string =>
112 n >= 1_000_000 ? `${Number((n / 1_000_000).toFixed(1))}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
113
114export const appendTurn = (prev: readonly TurnSample[] | undefined, t: TurnSample) => [...(prev ?? []), t].slice(-MAX_TURNS)
115
116// Mean growth over the last 5 turns since the last compaction (a drop starts the series over).
117export const growthPerTurn = (turns: readonly TurnSample[]): number | undefined => {
118 let start = 0
119 for (let i = 1; i < turns.length; i++) if ((turns[i]?.tokens ?? 0) < (turns[i - 1]?.tokens ?? 0)) start = i
120 const since = turns.slice(start).slice(-6)
121 if (since.length < 2) return undefined
122 const first = since[0]
123 const last = since[since.length - 1]
124 if (first === undefined || last === undefined) return undefined
125 return Math.max(0, (last.tokens - first.tokens) / (since.length - 1))
126}
127
128export type CtxView = { text: string; level: 'ok' | 'warn' | 'hot' }
129
130export const ctxView = (
131 samples: readonly CtxSample[],
132 turns: readonly TurnSample[],
133 limits: CtxLimits | null,
134): CtxView | undefined => {
135 const last = samples[samples.length - 1]
136 if (last === undefined) return undefined
137 const window = limits?.window
138 const parts = [window ? `ctx ${fmtTokens(last.tokens)}/${fmtTokens(window)} ${last.percent}%` : `ctx ${last.percent}%`]
139 const growth = growthPerTurn(turns)
140 let level: CtxView['level'] = last.percent >= CTX_HOT ? 'hot' : last.percent >= CTX_WARN ? 'warn' : 'ok'
141 if (growth !== undefined && growth > 0) {
142 parts.push(`+${fmtTokens(Math.round(growth))}/turn`)
143 const limit = limits?.isAutoCompact && limits.threshold ? limits.threshold : window
144 if (limit !== undefined) {
145 const left = Math.max(0, Math.floor((limit - last.tokens) / growth))
146 parts.push(`~${left} turns to ${limits?.isAutoCompact && limits.threshold ? 'compact' : 'full'}`)
147 level = left <= TURNS_HOT ? 'hot' : left <= TURNS_WARN ? 'warn' : level
148 }
149 }
150 // Cost stays in /ledger: not worth a constant place in the band.
151 return { text: parts.join(' '), level }
152}
153
154const NOTABLE: Partial<Record<LedgerKind, (label: string) => string>> = {
155 pr: l => l,
156 merge: l => l,
157 tag: l => l,
158 'force-push': l => `force-push ${l}`,
159 publish: l => `published ${l}`,
160 deploy: () => 'deploy',
161 // paid: kept in /ledger only, too routine for the band
162 comment: l => l,
163 linear: () => 'linear write',
164 slack: () => 'slack message',
165 notion: () => 'notion write',
166 'prod-secret': l => l,
167}
168
169// The rest of the band: the latest outward actions by name, then files edited. Failures stay in /ledger.
170export const summaryLine = (entries: readonly LedgerEntry[]): string => {
171 const named = entries.flatMap(e => {
172 const f = NOTABLE[e.kind]
173 return f === undefined ? [] : [f(e.label)]
174 })
175 const parts: string[] = []
176 if (named.length > 0) parts.push(named.slice(-3).reverse().join(' · ') + (named.length > 3 ? ` +${named.length - 3}` : ''))
177 const files = countBy(entries).get('file') ?? 0
178 if (files > 0) parts.push(plural(files, 'file'))
179 return parts.join(' ')
180}
181
182const LABELS: Record<LedgerKind, string> = {
183 pr: 'PRs opened',
184 merge: 'PRs merged',
185 commit: 'Commits',
186 push: 'Pushes',
187 'force-push': 'Force pushes',
188 tag: 'Tags pushed',
189 publish: 'Packages published',
190 deploy: 'Deploys',
191 paid: 'Paid calls',
192 comment: 'GitHub comments/reviews',
193 linear: 'Linear writes',
194 slack: 'Slack writes',
195 notion: 'Notion writes',
196 'prod-secret': 'Prod secret writes',
197 file: 'Files edited',
198}
199
200const section = (title: string, kinds: readonly LedgerKind[], entries: readonly LedgerEntry[]) => {
201 const lines: string[] = []
202 for (const kind of kinds) {
203 const of = entries.filter(e => e.kind === kind)
204 if (of.length === 0) continue
205 const items = of.map(e => (e.detail ? `${e.label} (${e.detail})` : e.label))
206 lines.push(`- ${LABELS[kind]} (${of.length}): ${items.slice(-12).join(', ')}${of.length > 12 ? ', …' : ''}`)
207 }
208 return lines.length === 0 ? [] : [`### ${title}`, ...lines, '']
209}
210
211export const ledgerMarkdown = (
212 entries: readonly LedgerEntry[],
213 samples: readonly CtxSample[],
214 fails: number,
215 turns: readonly TurnSample[] = [],
216 limits: CtxLimits | null = null,
217) => {
218 const last = samples[samples.length - 1]
219 const peak = samples.reduce((m, s) => Math.max(m, s.percent), 0)
220 const ctx =
221 last === undefined
222 ? ['Context: no API response yet this window.']
223 : [
224 `Context: ${last.percent}% (${Math.round(last.tokens / 1000)}k tokens), peak ${peak}%` +
225 (last.usd === undefined ? '' : `, session cost $${last.usd.toFixed(2)}`),
226 `Outlook: ${ctxView(samples, turns, limits)?.text ?? 'n/a'}`,
227 ]
228 const body = [
229 ...section('Changed', ['pr', 'commit', 'push', 'file'], entries),
230 ...section('Outward-facing', ['merge', 'tag', 'force-push', 'publish', 'deploy', 'comment', 'linear', 'slack', 'notion', 'prod-secret', 'paid'], entries),
231 ]
232 return [
233 '## Run ledger',
234 ...ctx,
235 '',
236 ...(body.length === 0 ? ['Nothing changed or sent out yet.', ''] : body),
237 `Failed Bash calls: ${fails}`,
238 ].join('\n')
239}
240types/index.d.ts 33 lines1export type LedgerKind =
2 | 'pr'
3 | 'merge'
4 | 'commit'
5 | 'push'
6 | 'force-push'
7 | 'tag'
8 | 'publish'
9 | 'deploy'
10 | 'paid'
11 | 'comment'
12 | 'linear'
13 | 'slack'
14 | 'notion'
15 | 'prod-secret'
16 | 'file'
17
18export type LedgerEntry = { kind: LedgerKind; label: string; at: number; detail?: string }
19
20export type CtxSample = { percent: number; tokens: number; usd?: number; at: number }
21
22/** Context tokens at the end of each main-loop turn, for growth per turn. */
23export type TurnSample = { tokens: number; at: number }
24
25/** The window and where auto-compaction fires, from the engine's local estimate. */
26export type CtxLimits = { window: number; threshold?: number; isAutoCompact: boolean }
27
28declare module 'claude-code' {
29 interface PluginState {
30 'run-ledger': { entries: LedgerEntry[]; ctx: CtxSample[]; fails: number; turns: TurnSample[]; limits: CtxLimits | null }
31 }
32}
33