Advisory post-edit checks (tab indentation, shfmt, zsh -n, stylua, token-shaped test fixtures) fed back to Claude

A macOS-focused developer workstation built around Zsh, Powerlevel10k, modern CLI tools, and battle-tested automation. Everything is wired together with bestow (a Go-based GNU Stow successor), encrypted secrets, and a centralized package manifest so each new machine behaves exactly like the last one.
z command), Atuin history (Ctrl+R and up-arrow), and Ghostty compatible key bindingsgw), uv (Python packages and projects), and curated helper aliases/functions (take, kill_by_port, show_tools, etc.)<leader>d… plus F5/F10/F11) shared by every language. Go (delve) supports debugging the test under the cursor, launching the program with arguments, and attaching to a remote dlv --headless; Python, Java, and Ballerina are wired in too. Adding another debugger is a single drop-in file: a module at nvim/.config/nvim/lua/thisarug/dap/<lang>.lua exporting setup(dap) is picked up automatically by lua/plugins/dap.lua<leader>G… for code generation (if err != nil guards, struct tags, interface stubs, test and doc-comment generation), and <leader>T… to run tests from the buffer with a summary tree and watch mode (Go, plus pytest and Vitest via neotest adapters)<leader>qs, or s on the dashboard), and diffview.nvim reviews a branch against the default branch (<leader>gv) or walks a file's history (<leader>gh)v for nvim, g for ripgrep, f for fd, z for zoxide) while keeping original commands for scripts.md → mdcat, .json/.yaml → jless, .py/.bash/.zsh/.bal → $EDITOR)pam-reattach)ControlEscape.spoon: tap Caps Lock for Escape, hold for Control — a Karabiner alternative that needs only Accessibility/Input Monitoring permissions, no blockable system extensionccp (personal) and cce (enterprise) run Claude Code against separate config dirs, with TypeScript mods for a status band, risk previews, secret redaction and a subagent pane (see Claude Code); tmux shows which windows are waiting on Claudeprefix + f opens a project with three windows ([Claude], [Nvim], [Shell])edit_secrets workflow, and automatic .env handling inside init.shBrewfile with optional category files in packages/ for modular installationbin/lint (shellcheck, shfmt, zsh -n, stylua, mod tests; also run by CI), test-zsh integration tests, dotdoctor health check, profile_startup quick timing, and bin/profile-zsh-startup for deep divesgit --version /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
brew install --cask font-fira-code-nerd-font
Set the font once inside your terminal profile (Appearance/Text settings).
init.sh)git clone https://github.com/ThisaruGuruge/dotfiles.git ~/dotfiles
cd ~/dotfiles
./init.sh
The checkout can live anywhere (the script finds the repo from its own path). The rest of the setup hard-codes ~/dotfiles, so when the checkout is elsewhere init.sh creates a ~/dotfiles symlink pointing at it (and stops with a clear message if ~/dotfiles is already something else). ./init.sh --dry-run is a preflight only: it checks the checkout and ~/dotfiles and changes nothing; ./init.sh --help lists the steps.
The script is interactive; it will:
bestow)Brewfile with Brewpam-reattach, loaded from a root-owned copy in /opt/pam).env with SOPS + age (keys stored at ~/.config/sops/age/keys.txt, per-machine ~/.sops.yaml)bestowdotdoctor for a full health check; the final output also lists the manual steps: the Hammerspoon spoon and permissions, the ~/.claude symlink, tmux plugins)git clone https://github.com/ThisaruGuruge/dotfiles.git ~/dotfiles
cd ~/dotfiles
# Install everything that is currently enabled in Brewfile
brew bundle --file=Brewfile
# Install bestow (requires Go)
go install github.com/redpierrot/bestow@latest
# Bootstrap bestow's own config (source: this repo, destination: $HOME)
bestow init --source ~/dotfiles --destination ~/
# Symlink the packages you need (add/remove as desired)
bestow stow zsh git tmux direnv bash
bestow stow bin -d ~/bin # bin links into ~/bin, not ~
# Secrets: an age key and this machine's own sops config (not tracked)
mkdir -p ~/.config/sops/age && age-keygen -o ~/.config/sops/age/keys.txt
sed "s/AGE_PUBLIC_KEY/$(grep '^# public key:' ~/.config/sops/age/keys.txt | cut -d' ' -f4)/g" \
bash/.sops.yaml.example > ~/.sops.yaml
# Reload configuration, then create the encrypted ~/.env
source ~/.zshrc
edit_secrets
Git signs every commit with SSH (gpgSign = true), so set user.signingkey in ~/.gitconfig.local (see git/.gitconfig.local.example) and create the key it names (ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_signing) before your first commit.
Edit the Brewfile directly or use category Brewfiles in packages/ before running brew bundle if you want to customize packages.
Already have a preferred dotfiles strategy but want the curated tools? Run brew bundle --file=Brewfile and manually pick pieces (aliases, functions, etc.). Edit the Brewfile directly or use the category files in packages/ to customize your installation.
The main Brewfile contains core packages that are always installed. Optional packages are organized into category files in packages/:
packages/
├── cloud.brewfile # aws-vault, gcloud CLI
├── containers.brewfile # colima, Docker CLI + Compose + credential helper
├── development.brewfile # pyenv, rbenv, nvm, flutter
├── editors.brewfile # VS Code
├── productivity.brewfile # Raycast, Rectangle, Alfred, Caffeine, etc.
└── terminals.brewfile # iTerm2
brew bundle --file=Brewfile # Core packages
brew bundle --file=packages/development.brewfile # Add dev tools
brew bundle --file=packages/editors.brewfile # Add editors
core – powerlevel10k, zoxide, eza, bat, ripgrep, lazygit, lazydocker, tmux, direnv, atuin, gh, etc.security – sops + age for encrypted secrets (always enabled)development – pyenv, rbenv, nvm, flutter (optional)cloud – aws-vault and the gcloud CLIeditors – VS Codeterminals – iTerm2 (casks)containers – colima (container runtime), Docker CLI + Compose + credential helperproductivity – Raycast, Rectangle, TablePlus, Alfred, Postman, CaffeineComment out what you do not need in the Brewfile, then rerun brew bundle.
brew-sync (or ./bin/sync-brewfile) is a read-only drift report: it lists formulae and casks that are installed but in no Brewfile, and declared packages that are not installed. It never rewrites a Brewfile; add or remove lines by hand. brew-check verifies the core Brewfile is satisfied.
| Path | Notes |
|---|---|
zsh/ | .zshrc, .zshrc.d/ (modular shell config), .functions.d/ (modular functions), aliases, paths |
zsh/.zshrc.d/ | 6 modules: plugins, completion, keybindings, history, integrations, environment |
zsh/.functions.d/ | 9 modules: colors, core, navigation, archives, git, system, dotfiles, docs, packages |
<tool>/.config/<tool>/ | XDG configs, one stow package per tool (ghostty, lazygit, nvim, yazi, ripgrep, typos) |
zsh/.p10k.zsh | Powerlevel10k prompt config (Catppuccin Mocha, stowed to ~/.p10k.zsh) |
git/ | .gitconfig, ignore rules, delta settings |
tmux/ | Modern tmux config + keybinds, project session layout, Claude state in status bar |
claude/ | Per-account Claude Code settings.json and status line, stowed to ~/.claude-personal and ~/.claude-enterprise |
claude-mods/ | Claude Code mods (not a stow package; loaded through CLAUDE_CODE_PLUGIN_DIRS) |
tests/ | Golden-output and behaviour tests for the Claude Code helpers (tests/claude/check.sh, run by test-zsh) |
direnv/ | Project-specific environment automation |
packages/ | Optional category Brewfiles (cloud, containers, development, etc.) |
bin/ | Helper scripts (lint, dotdoctor, test-zsh-config, profile-zsh-startup, audit-configs, adopt-config; see bin/README.md) |
docs/ | Additional documentation (prompt guide, tmux keybindings, config management) |
Ghostty is configured with:
ESC [ H / ESC [ F), which 03-keybindings.zsh binds to start/end of line (a raw Ctrl+a would be eaten as the tmux prefix)Ctrl+a; test-zsh fails if the prefix changes)The optional containers category (packages/containers.brewfile) installs colima as the local container runtime, paired with the plain docker, docker-compose, and docker-credential-helper CLI formulae — no Docker Desktop app/VM required.
brew bundle --file=packages/containers.brewfile # colima + docker CLI + compose + credential helper
colima-start # boots the VM, sets the `colima` docker context
docker ps # talks to colima automatically
lzd # lazydocker also works unchanged — it just follows the active docker context
Colima lifecycle aliases (zsh/.aliases.sh, only defined if colima is installed):
| Alias | Command |
|---|---|
colima-start | colima start |
colima-stop | colima stop |
colima-restart | colima restart |
colima-status | colima status |
colima-list | colima list |
colima-ssh | colima ssh |
Homebrew's docker-compose formula installs as a CLI plugin, and docker-credential-helper installs docker-credential-osxkeychain to store registry credentials in the macOS Keychain instead of plaintext — but Docker only picks either up if ~/.docker/config.json says so:
{
"cliPluginsExtraDirs": ["/opt/homebrew/lib/docker/cli-plugins"],
"credsStore": "osxkeychain"
}
init.sh does not currently write these keys automatically — add them by hand (or merge into an existing ~/.docker/config.json) after installing docker-compose / docker-credential-helper.
Day-to-day package management (adding/removing symlinks after the initial install) uses bestow — a Go-based successor to GNU Stow — instead of stow directly. It replicates stow --no-folding behavior (always on, no flag needed) but is idempotent and conflict-safe with pre-existing absolute symlinks.
bestow stow zsh # Shell config (includes .p10k.zsh)
bestow stow nvim ghostty lazygit ripgrep yazi typos # Per-tool packages
bestow stow git tmux direnv # Git/Tmux/Direnv packages
# Remove a package
bestow unstow zsh
init.sh (the fresh-machine installer) installs bestow with go install github.com/redpierrot/bestow@latest and runs the same bestow stow commands per package. @latest resolves to bestow's newest tagged release once it publishes tags (until then, the newest commit on its default branch).
binis the exception: it links into~/bin, so always stow it asbestow stow bin -d ~/bin. Don't run a barebestow stowacross every package — with the single$HOMEdestination in bestow's config it would linkbin/*straight into~.
test-zsh # Full validation (tools, PATH, runtimes)
./bin/lint # Lint everything, as CI does (./bin/lint --file <path> for one file)
./bin/profile-zsh-startup # Deep component timing (zinit plugins, SDKMAN, p10k, etc.)
help # Alias documentation entry point
docs # Interactive alias browser (alias_docs)
show_tools # Overview of installed CLI upgrades
alias_search git # Search for aliases by keyword
edit_secrets # Safely edit encrypted ~/.env via SOPS
take my-service && code . # Smart project bootstrap / clone helper
kill_by_port 3000 # Stop the process LISTENING on port 3000 (TERM, then KILL); clients are left alone
awake # Keep the Mac from idle-sleeping (awake stop | status | for 2h | screen | ac)
dotdoctor # Health check (changes nothing): unlinked files, dangling links, secrets, mods (--full adds brew + plugin validation)
prs # Browse PRs with fzf: enter=checkout, ctrl-o=browser, ctrl-d=diff via delta
prw # Watch the current PR's checks, then notify with the result
envrc work|personal|status|off # Pin the Claude Code + gws account for this directory via direnv
lg # Launch lazygit with our config (arguments are forwarded, e.g. lg log)
lzd # Launch lazydocker for Docker management
gw build # Run Gradle build from any subdirectory
gwt # Alias for: gw test
gwc # Alias for: gw clean
gwcb # Alias for: gw clean build
Ctrl+R # Atuin search UI (fuzzy search all history; Ctrl+R again narrows the filter)
Up arrow # Atuin prefix search (as you type)
Ctrl+T / Alt+C # fzf file / directory picker with a preview (Ctrl+/ toggles it)
Two accounts, each with its own config directory (claude/ is the stow package that links settings.json and statusline-command.sh into both; history, sessions and auth stay local, auth in the macOS Keychain):
| Command | Account | Config dir |
|---|---|---|
ccp, ccpr, ccpc | personal: start, --resume, --continue | ~/.claude-personal |
cce, ccer, ccec | enterprise: start, --resume, --continue | ~/.claude-enterprise |
~/.claude must be a symlink to ~/.claude-personal (ln -s ~/.claude-personal ~/.claude), so plain claude outside a pinned directory is the personal account. dotdoctor checks it.ccp/ccpr/ccpc also load ~/.claude-personal/projects.d/<repo>.json with --settings when the current git repo has one. That is where a project's own autoMode rules go, since Claude Code ignores autoMode in a project's .claude/settings*.json and they must not live in this public repo. <repo> is the main checkout's directory name, so linked worktrees share it.claude-mods/ (statusbar, blast-radius, redact, agents, rabe, session-wrapped, conventions) load straight from the repo via CLAUDE_CODE_PLUGIN_DIRS in both settings.json files, with nothing to link. The band above the prompt (context bar, rate limits) is the statusbar mod, the line below it is bin/claude-statusline, and conventions runs bin/lint --file after each edit Claude makes in this repo. See claude-mods/README.md.bin/claude-tmux-state, so tmux windows show when Claude is working, waiting or done (prefix + A jumps to the next waiting window).bin/claude-notify, which shows a macOS notification when a session finishes (Stop), asks you something (AskUserQuestion) or needs you (permission prompt, idle: Notification). It stays quiet while the terminal and the tmux window are in view. CLAUDE_NOTIFY=0 turns it off, CLAUDE_NOTIFY_SUBAGENTS=1 adds subagent completions, CLAUDE_NOTIFY_ALWAYS=1 disables the in-view suppression, CLAUDE_NOTIFY_SOUND=<name> changes the sound. One-time setup: allow banners and sounds for Script Editor under System Settings > Notifications (osascript notifications are attributed to it); Focus modes silence them. Details in bin/README.md.envrc (below).prefix + f (sessionizer) and prefix + S (new session) create a session with three windows: [Claude] (runs claude with the directory's .envrc applied), [Nvim] (nvim over a small shell, the window the session opens on) and [Shell] (plain shell). SESSIONIZER_LAYOUT=bare gives a single shell instead. After pulling, run bestow stow tmux if a new helper script is missing. Key reference: docs/TMUX_KEYBINDINGS.md.
envrc)envrc work (enterprise) or envrc personal writes a .envrc in the current directory that pins CLAUDE_CONFIG_DIR and GOOGLE_WORKSPACE_CLI_CONFIG_DIR (plus the gws file keyring backend), creates the account's gws directory with mode 700, runs direnv allow and applies the pin to the current shell straight away. Plain claude and gws then use the right account anywhere under that directory, including subdirectories and new shells or tmux panes opened inside the tree (direnv stays lazy-loaded: it is only loaded once a .envrc exists in the current directory or any parent). The file is added to the repo's local exclude (info/exclude, also from a linked worktree; envrc confirms with git check-ignore) and inherits parent pins through source_up_if_exists.
envrc status shows which pin applies (and whether it is inherited from a parent directory)envrc off removes a pin written by envrc (it never touches a .envrc it did not write).envrc (not written by envrc, or edited by hand since) asks first and keeps a copy as .envrc.bak; a symlinked .envrc is refused rather than written throughWhat overrides what
| Command | CLAUDE_CONFIG_DIR | GOOGLE_WORKSPACE_CLI_CONFIG_DIR |
|---|---|---|
plain claude in a pinned tree | pinned account | pinned account (inherited) |
ccp/ccpr/ccpc, cce/ccer/ccec | the account you typed | blanked for that claude, so a pin never leaks the other account's gws into it |
gwsp/gwse | unchanged | the account you typed |
bare gws in a pinned tree | unchanged | pinned account: the bin/gws guard lets it through because the pin supplies the account context |
bare gws anywhere else | unchanged | blocked (exit 64) |
Inside a pinned tree a bare gws therefore acts on the pinned account instead of being refused; that is the point of the pin. Plain claude outside any pinned directory uses ~/.claude, which should be a symlink to ~/.claude-personal (dotdoctor checks this: ln -s ~/.claude-personal ~/.claude).
prs, prw)prs [gh pr list flags] – fzf over the repo's PRs with a gh pr view preview. enter checks the PR out, ctrl-o opens it in the browser, ctrl-d shows the full diff through delta. Example: prs --author @meprw [pr] – gh pr checks --watch for the current branch's PR (or the given number/URL), then a macOS notification with the result; the exit status mirrors gh pr checksBoth need gh and jq; prs also needs fzf. PR titles are untrusted text: control characters (including ESC) are replaced with spaces before they are shown, and prw prints titles verbatim (no backslash interpretation). prw says whether the checks failed, were cancelled or are still pending, or that gh reported no result (no checks, or gh could not be reached), instead of calling every non-zero exit "failed".
cp / mv are small functions, not aliases: they prompt before overwriting (-i, verbose) only when a human is at the terminal, and run the plain command otherwise (scripts, agents, pipes). They do not force -r; copy directories with cp -R. sudo cp / sudo mv run the plain commands (sudo runs the binary, not the shell function), so pass -i yourself there.awake keeps the Mac from idle-sleeping in the background (caffeinate -i: the display may still sleep or lock, and it works on AC and battery). awake screen also keeps the display on, awake ac hooks/register.ts 314 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, FsStat, Register } from 'claude-code'
3
4import type { Sent } from '../types'
5import {
6 SIZE_CAP,
7 TIMEOUT_MS,
8 allowsTabs,
9 compose,
10 fixtureProblem,
11 isFixture,
12 language,
13 lintChecks,
14 parseConfig,
15 tabLines,
16 tabProblem,
17 toolProblem,
18} from './checks'
19import type { Check } from './checks'
20import { findTokens } from './tokens'
21
22type $ = EngineInterface
23
24const isPaused = atom({ plugin: 'conventions', key: 'isPaused' } as const, false)
25const sent = atom({ plugin: 'conventions', key: 'sent' } as const, {} as Sent)
26
27// Directory -> repo root, repo root -> "has the dotfiles layout", and (owner
28// checkout, repo root) -> "is that checkout or one of its worktrees". Module
29// variables: a reload only costs the walk again.
30const roots = new Map<string, string>()
31const dotfiles = new Map<string, boolean>()
32const trusted = new Map<string, boolean>()
33
34const MOD_DIR = '/claude-mods/conventions'
35
36/** `.claude/conventions.json` larger than this is ignored. */
37const CONFIG_CAP = 64 * 1024
38/** Files remembered in `sent` before the oldest are dropped. */
39const SENT_CAP = 200
40
41async function stat($: $, path: string, resolve = false): Promise<FsStat | undefined> {
42 try {
43 return await $.fs.stat(path, resolve ? { resolve } : undefined)
44 } catch {
45 return undefined
46 }
47}
48
49async function readText($: $, path: string): Promise<string | undefined> {
50 try {
51 return await $.fs.read(path)
52 } catch {
53 return undefined
54 }
55}
56
57const dirname = (p: string) => p.slice(0, Math.max(p.lastIndexOf('/'), 0))
58
59/** The nearest directory above `file` holding `.git` (a dir, or a worktree's file). */
60async function findRoot($: $, file: string): Promise<string | undefined> {
61 const start = dirname(file)
62 const cached = roots.get(start)
63 if (cached !== undefined) return cached
64 let dir = start
65 for (let i = 0; i < 64 && dir !== ''; i++) {
66 const git = await stat($, `${dir}/.git`)
67 if (git !== undefined && git.kind !== 'other') {
68 roots.set(start, dir)
69 return dir
70 }
71 dir = dirname(dir)
72 }
73 return undefined
74}
75
76/**
77 * The dotfiles layout, in any checkout, worktree or copy: it has bin/lint and
78 * this mod's manifest. It turns the checks on by default; it does not make the
79 * repo's bin/lint safe to run (see isTrusted).
80 */
81async function isDotfiles($: $, root: string): Promise<boolean> {
82 const known = dotfiles.get(root)
83 if (known !== undefined) return known
84 const [lint, manifest] = await Promise.all([
85 stat($, `${root}/bin/lint`),
86 readText($, `${root}/claude-mods/conventions/.claude-plugin/plugin.json`),
87 ])
88 let name: unknown
89 try {
90 name = (JSON.parse(manifest ?? '') as { name?: unknown } | null)?.name
91 } catch {
92 name = undefined
93 }
94 const is = lint?.kind === 'file' && name === 'conventions'
95 dotfiles.set(root, is)
96 return is
97}
98
99/** Where a path lands, links and `..` resolved; undefined when it leads nowhere. */
100async function real($: $, path: string): Promise<string | undefined> {
101 const st = await stat($, path, true)
102 return st?.realPath
103}
104
105/**
106 * The owner's dotfiles checkout: the folder this mod is loaded from, by its
107 * entry in CLAUDE_CODE_PLUGIN_DIRS (`~` expanded, links resolved), less
108 * `/claude-mods/conventions`. Undefined without such an entry, or when the
109 * environment cannot be read.
110 */
111async function ownerRepo($: $): Promise<string | undefined> {
112 let dirs: string | undefined
113 let home: string | undefined
114 try {
115 dirs = await $.env.get('CLAUDE_CODE_PLUGIN_DIRS')
116 home = await $.env.get('HOME')
117 } catch {
118 return undefined
119 }
120 if (dirs === undefined || dirs === '') return undefined
121 for (const raw of dirs.split(':')) {
122 let dir = raw.trim().replace(/\/+$/, '')
123 if (dir === '~' || dir.startsWith('~/')) {
124 if (home === undefined || !home.startsWith('/')) continue
125 dir = home.replace(/\/+$/, '') + dir.slice(1)
126 }
127 if (!dir.startsWith('/') || !dir.endsWith(MOD_DIR)) continue
128 const resolved = await real($, dir)
129 if (resolved?.endsWith(MOD_DIR) && resolved.length > MOD_DIR.length) return resolved.slice(0, -MOD_DIR.length)
130 }
131 return undefined
132}
133
134/**
135 * Whether the repo's own bin/lint may run: the root is the owner's checkout,
136 * or a linked worktree of it (`.git` a file whose `gitdir:` is under the
137 * checkout's `.git/worktrees/`, and whose admin folder points back at this
138 * `.git`, as git itself checks). A fork or a copy with the same layout is not.
139 */
140async function isTrusted($: $, root: string): Promise<boolean> {
141 const repo = await ownerRepo($)
142 const key = `${repo ?? ''}\u0000${root}`
143 const known = trusted.get(key)
144 if (known !== undefined) return known
145 let is = false
146 if (repo !== undefined) {
147 const at = await real($, root)
148 if (at === repo) {
149 is = true
150 } else if (at !== undefined) {
151 const git = await stat($, `${at}/.git`)
152 const text = git?.kind === 'file' && git.size <= 4096 ? await readText($, `${at}/.git`) : undefined
153 const gitdir = /^gitdir: *(.+?)\s*$/m.exec(text ?? '')?.[1]
154 if (gitdir !== undefined) {
155 // Both links may be relative (worktree.useRelativePaths); real() settles `..`
156 const admin = await real($, gitdir.startsWith('/') ? gitdir : `${at}/${gitdir}`)
157 const worktrees = `${(await real($, `${repo}/.git`)) ?? `${repo}/.git`}/worktrees/`
158 if (admin !== undefined && admin.startsWith(worktrees) && !admin.slice(worktrees.length).includes('/')) {
159 const back = (await readText($, `${admin}/gitdir`))?.trim()
160 if (back !== undefined && back !== '') {
161 is = (await real($, back.startsWith('/') ? back : `${admin}/${back}`)) === `${at}/.git`
162 }
163 }
164 }
165 }
166 }
167 trusted.set(key, is)
168 return is
169}
170
171/**
172 * Runs one check; its output when it failed. Silent (undefined) when it
173 * passed, timed out, could not start (the tool is not installed), failed
174 * without a word, or exited with one of `quietCodes`.
175 */
176async function run($: $, argv: string[], cwd: string, quietCodes: number[] = []): Promise<string | undefined> {
177 try {
178 const r = await $.process.run(argv, { cwd, timeoutMs: TIMEOUT_MS })
179 if (r.exitCode === 0 || quietCodes.includes(r.exitCode)) return undefined
180 // No output means no finding to relay
181 const out = `${r.stdout}\n${r.stderr}`.trim()
182 return out === '' ? undefined : out
183 } catch {
184 return undefined
185 }
186}
187
188/** The message for an edited file, or undefined when it is clean or out of scope. */
189export async function review($: $, edited: string): Promise<string | undefined> {
190 if (!edited.startsWith('/')) return undefined
191 const st = await stat($, edited, true)
192 if (st === undefined || st.kind !== 'file' || st.size > SIZE_CAP) return undefined
193 // An edit through a symlink (a stowed ~/.zshrc, ~/bin/lint) is checked where it lands
194 const file = st.realPath ?? edited
195 const root = await findRoot($, file)
196 if (root === undefined) return undefined
197 const isDot = await isDotfiles($, root)
198 // Only the owner's checkout and its worktrees run their own bin/lint; a copy with
199 // the same layout gets the generic checks instead
200 const isOwn = isDot && (await isTrusted($, root))
201 const cfg = `${root}/.claude/conventions.json`
202 const cfgStat = await stat($, cfg)
203 const cfgText = cfgStat !== undefined && cfgStat.size <= CONFIG_CAP ? await readText($, cfg) : undefined
204 const config = parseConfig(cfgText, isDot)
205 if (!config.enabled || config.checks.length === 0) return undefined
206 const text = await readText($, file)
207 if (text === undefined || text.includes('\u0000')) return undefined
208
209 const rel = file.slice(root.length + 1)
210 const lang = language(rel, text)
211 const has = (c: Check) => config.checks.includes(c)
212 const problems: string[] = []
213
214 let tabNote: string | undefined
215 if (has('tabs') && !allowsTabs(rel)) {
216 const lines = tabLines(text, { markdown: /\.(?:md|markdown)$/i.test(rel), shell: lang === 'bash' || lang === 'zsh' })
217 if (lines.length > 0) tabNote = tabProblem(rel, lines, lang)
218 }
219 if (has('fixtures') && isFixture(rel)) {
220 const hits = findTokens(text)
221 if (hits.length > 0) problems.push(fixtureProblem(hits))
222 }
223
224 const tools: Promise<string | undefined>[] = []
225 if (isOwn) {
226 // bin/lint knows which files are bash, zsh or nvim Lua; it says nothing about the rest,
227 // so it is only spawned for a path it covers and a check the repo has on
228 if (lintChecks(rel).some(has)) {
229 tools.push(
230 run($, [`${root}/bin/lint`, '--file', file], root, [2]).then(
231 o => o && toolProblem(`bin/lint --file ${rel}`, o),
232 ),
233 )
234 }
235 } else {
236 if (lang === 'bash' && has('shfmt')) {
237 tools.push(
238 run($, ['shfmt', '-i', '4', '-ci', '-d', file], root).then(
239 o => o && toolProblem('shfmt -i 4 -ci -d', `${o}\nfix: shfmt -i 4 -ci -w ${rel}`),
240 ),
241 )
242 }
243 if (lang === 'zsh' && has('zsh')) {
244 tools.push(run($, ['zsh', '-n', file], root).then(o => o && toolProblem('zsh -n', o)))
245 }
246 if (lang === 'lua' && has('stylua')) {
247 tools.push(
248 run($, ['stylua', '--check', file], root).then(
249 o => o && toolProblem('stylua --check', `${o}\nfix: stylua ${rel}`),
250 ),
251 )
252 }
253 }
254 const found = (await Promise.all(tools)).filter((p): p is string => p !== undefined)
255 // shfmt and stylua already report tab indentation, with the same fix: one note, not two.
256 // Only when bin/lint ran one of them: a new bin/ script it does not list still gets the note.
257 const tabsInTool = isOwn && lang !== 'zsh' && found.some(o => /fix: (?:shfmt|stylua) /.test(o))
258 if (tabNote !== undefined && !tabsInTool) problems.unshift(tabNote)
259 problems.push(...found)
260
261 return problems.length > 0 ? compose(rel, problems) : undefined
262}
263
264const TOOLS = /^(?:Edit|Write|MultiEdit)$/
265
266export const register: Register = on => {
267 on('session.start', async ($, e, next) => {
268 await $.command.register({
269 name: 'conventions',
270 description: 'Turn the post-edit convention checks off or on for this session',
271 argumentHint: '[on|off]',
272 })
273 return next(e)
274 })
275
276 on('command.run', { command: 'conventions' }, async ($, e) => {
277 const arg = e.args.trim().toLowerCase()
278 if (arg !== '' && arg !== 'on' && arg !== 'off') return { text: 'Usage: /conventions [on|off]' }
279 const paused = await read($, isPaused)
280 const next = arg === 'on' ? false : arg === 'off' ? true : !paused
281 await update($, isPaused, () => next)
282 return { text: next ? 'Convention checks off for this session.' : 'Convention checks on.' }
283 })
284
285 // The edit tools today are Edit and Write; MultiEdit is matched in case it returns.
286 // Advisory: the edit has already run; a problem only adds a note the model
287 // reads after the result. Never denies, and any failure returns the result as is.
288 on('tool.call', { tool: TOOLS }, async ($, e, next) => {
289 const result = await next(e)
290 if (result.deny !== undefined || result.isError === true) return result
291 try {
292 const file = (e as { file_path?: unknown }).file_path
293 if (typeof file !== 'string' || (await read($, isPaused))) return result
294 const message = await review($, file)
295 const last: Sent = await read($, sent)
296 if (message === undefined) {
297 if (file in last) {
298 await update($, sent, s => Object.fromEntries(Object.entries(s ?? {}).filter(([k]) => k !== file)))
299 }
300 return result
301 }
302 if (last[file] === message) return result
303 await update($, sent, s => {
304 const { [file]: _old, ...rest } = s ?? {}
305 // Newest last; the oldest go once the map is long
306 return Object.fromEntries([...Object.entries(rest), [file, message]].slice(-SENT_CAP))
307 })
308 return { ...result, context: [...(result.context ?? []), message] }
309 } catch {
310 return result
311 }
312 }).catch(($, e, next) => next(e))
313}
314hooks/checks.ts 153 lines1/** The checks a repo can name in `.claude/conventions.json`. */
2export const CHECKS = ['tabs', 'shfmt', 'zsh', 'stylua', 'fixtures'] as const
3export type Check = (typeof CHECKS)[number]
4
5/** Files larger than this are not checked. */
6export const SIZE_CAP = 256 * 1024
7/** Each external check is killed after this long, and then says nothing. */
8export const TIMEOUT_MS = 3000
9/** Lines of a tool's output kept in a message (its `fix:` lines are always kept). */
10export const MAX_LINES = 12
11
12export type Config = { enabled: boolean; checks: Check[] }
13
14/**
15 * `.claude/conventions.json`: `{ "enabled": true, "checks": ["tabs", ...] }`.
16 * Absent or unreadable, the mod is on in the dotfiles repo and off elsewhere,
17 * with every check; an absent `checks` means all of them.
18 */
19export function parseConfig(text: string | undefined, isDotfiles: boolean): Config {
20 let raw: unknown
21 if (text !== undefined) {
22 try {
23 raw = JSON.parse(text)
24 } catch {
25 raw = undefined
26 }
27 }
28 const o = raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {}
29 const enabled = typeof o.enabled === 'boolean' ? o.enabled : isDotfiles
30 const named = o.checks
31 const checks = Array.isArray(named) ? CHECKS.filter(c => named.includes(c)) : [...CHECKS]
32 return { enabled, checks }
33}
34
35const base = (rel: string) => rel.slice(rel.lastIndexOf('/') + 1)
36
37/** Files whose format needs tabs. */
38export function allowsTabs(rel: string): boolean {
39 const b = base(rel)
40 return (
41 /^(?:GNUmakefile|[Mm]akefile|Makefile\.am|\.gitmodules|\.gitconfig|gitconfig|go\.mod|go\.sum)$/.test(b) ||
42 /\.(?:mk|mak|go|tsv|patch|diff)$/.test(b) ||
43 (b === 'config' && /(?:^|\/)\.?git\/config$/.test(rel))
44 )
45}
46
47/**
48 * Lines (1-based) whose indentation holds a tab. A tab after the indent is
49 * data and left alone, and so is a tab inside a fenced code block (`markdown`)
50 * or a shell here-document body (`shell`), where it is content.
51 */
52export function tabLines(text: string, opts: { markdown?: boolean; shell?: boolean } = {}): number[] {
53 const out: number[] = []
54 const lines = text.split('\n')
55 let fence = false
56 let tag: string | undefined
57 for (let i = 0; i < lines.length; i++) {
58 const line = lines[i] ?? ''
59 if (opts.markdown && /^ {0,3}(?:```|~~~)/.test(line)) {
60 fence = !fence
61 continue
62 }
63 if (fence) continue
64 if (tag !== undefined) {
65 if (line.replace(/^\t+/, '') === tag) tag = undefined
66 continue
67 }
68 if (/^[ \t]*\t/.test(line)) out.push(i + 1)
69 if (opts.shell) tag = /(?<!<)<<-?\s*(['"]?)([A-Za-z_]\w*)\1/.exec(line)?.[2]
70 }
71 return out
72}
73
74/** Test code and fixtures: where a token-shaped literal must be built at runtime instead. */
75export function isFixture(rel: string): boolean {
76 const b = base(rel)
77 return (
78 /(?:^|\/)(?:tests?|__tests__|fixtures?|testdata|spec)\//.test(rel) ||
79 /\.(?:test|spec)\.[^./]+$/.test(b) ||
80 /_test\.[^./]+$/.test(b) ||
81 /^test_.*\.py$/.test(b)
82 )
83}
84
85export type Lang = 'bash' | 'zsh' | 'lua' | undefined
86
87/** The language of a file outside the dotfiles repo, by its name or shebang. */
88export function language(rel: string, text: string): Lang {
89 const b = base(rel)
90 const nl = text.indexOf('\n')
91 const shebang = text.startsWith('#!') ? text.slice(0, nl < 0 ? undefined : nl) : ''
92 if (/\.zsh$/.test(b) || /^\.(?:zshrc|zshenv|zprofile|zlogin|zlogout)$/.test(b) || /\bzsh\b/.test(shebang)) return 'zsh'
93 if (/\.(?:sh|bash)$/.test(b) || /\b(?:bash|sh)\b/.test(shebang)) return 'bash'
94 if (/\.lua$/.test(b)) return 'lua'
95 return undefined
96}
97
98/** "3, 7, 9" or "3, 7, 9, 12, 15 (+4 more)". */
99export function lineList(nums: number[], max = 5): string {
100 const shown = nums.slice(0, max).join(', ')
101 return nums.length > max ? `${shown} (+${nums.length - max} more)` : shown
102}
103
104/** A tool's output cut to MAX_LINES lines of 200 characters, its `fix:` lines kept, indented. */
105export function trimOutput(out: string): string {
106 const lines = out.split('\n').filter(l => l.trim() !== '')
107 const fixes = lines.filter(l => l.startsWith('fix: '))
108 const body = lines.filter(l => !l.startsWith('fix: '))
109 const kept = body.slice(0, MAX_LINES).map(l => (l.length > 200 ? `${l.slice(0, 199)}…` : l))
110 if (body.length > MAX_LINES) kept.push(`… (${body.length - MAX_LINES} more lines)`)
111 return [...kept, ...fixes].map(l => ` ${l}`).join('\n')
112}
113
114export function tabProblem(rel: string, lines: number[], lang: Lang): string {
115 const fix =
116 lang === 'bash'
117 ? `shfmt -i 4 -ci -w ${rel}`
118 : lang === 'lua'
119 ? `stylua ${rel}`
120 : 're-indent those lines with spaces (4 for shell, 2 for Lua, else the file’s own width)'
121 return `- tab indentation on line ${lineList(lines)}; this repo indents with spaces only. Fix: ${fix}`
122}
123
124export function fixtureProblem(hits: { kind: string; line: number }[]): string {
125 const kinds = [...new Set(hits.map(h => h.kind))].join(', ')
126 return (
127 `- token-shaped literal (${kinds}) on line ${lineList(hits.map(h => h.line))} of a test file; secret scanners flag it ` +
128 `even when fake. Fix: build it at runtime, joining a few fragments and a repeated filler, so no whole literal sits in the source`
129 )
130}
131
132export function toolProblem(label: string, out: string): string {
133 return `- ${label}:\n${trimOutput(out)}`
134}
135
136export function compose(rel: string, problems: string[]): string {
137 return [`conventions (advisory, the edit was applied): ${rel}`, ...problems].join('\n')
138}
139
140/**
141 * Which checks `bin/lint --file` could run for a path in the dotfiles repo,
142 * from the same lists it uses; empty means it covers the file with none, so
143 * it is not spawned.
144 */
145export function lintChecks(rel: string): Check[] {
146 if (/^nvim\/.*\.lua$/.test(rel)) return ['stylua']
147 if (rel === 'bin/README.md') return []
148 if (/^bin\/[^/.][^/]*$/.test(rel)) return ['shfmt', 'zsh']
149 if (/\.zsh$/.test(rel) || /^zsh\/\.[^/]+$/.test(rel)) return ['zsh']
150 if (/\.sh$/.test(rel)) return ['shfmt', 'zsh']
151 return []
152}
153hooks/tokens.ts 39 lines1/**
2 * Token-shaped literals: the distinctive, prefix-anchored rules of the redact
3 * mod (claude-mods/redact/hooks/patterns.ts), copied because a mod cannot
4 * import from outside its own folder. Keep the two lists in step. The
5 * contextual rules there (`password=`, `Bearer`, URL passwords) are left out:
6 * a fixture may name a demo credential, but a value shaped like a real token
7 * trips secret scanners even when it is fake.
8 */
9const RULES: { kind: string; re: RegExp }[] = [
10 { kind: 'private-key', re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----/ },
11 { kind: 'age-key', re: /AGE-SECRET-KEY-1[A-Z0-9]{50,}/ },
12 { kind: 'aws-access-key', re: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/ },
13 { kind: 'github-token', re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{30,}\b/ },
14 { kind: 'github-token', re: /\bgithub_pat_[A-Za-z0-9_]{40,}\b/ },
15 { kind: 'gitlab-token', re: /\bglpat-[A-Za-z0-9_-]{20,}/ },
16 { kind: 'npm-token', re: /\bnpm_[A-Za-z0-9]{36,}\b/ },
17 { kind: 'huggingface-token', re: /\bhf_[A-Za-z0-9]{30,}\b/ },
18 { kind: 'stripe-key', re: /\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}\b/ },
19 { kind: 'anthropic-key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/ },
20 { kind: 'api-key', re: /\bsk-(?:proj-|live-|test-)?[A-Za-z0-9_-]{32,}/ },
21 { kind: 'slack-token', re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/ },
22 { kind: 'google-api-key', re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
23 { kind: 'jwt', re: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/ },
24]
25
26export type TokenHit = { kind: string; line: number }
27
28/** Every line holding a token-shaped literal, with the first kind it matched. */
29export function findTokens(text: string): TokenHit[] {
30 const hits: TokenHit[] = []
31 const lines = text.split('\n')
32 for (let i = 0; i < lines.length; i++) {
33 const line = lines[i] ?? ''
34 const rule = RULES.find(r => r.re.test(line))
35 if (rule) hits.push({ kind: rule.kind, line: i + 1 })
36 }
37 return hits
38}
39types/index.d.ts 13 lines1/** The last message sent per file (absolute path), so an unchanged one is not repeated. */
2export type Sent = Record<string, string>
3
4declare module 'claude-code' {
5 interface PluginState {
6 conventions: {
7 /** `/conventions off` for this session. */
8 isPaused: boolean
9 sent: Sent
10 }
11 }
12}
13