SLOPSHOPPER

conventions

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

newguardcommandprocess
★ 4v0.1.0MITupdated 2026-10-13ThisaruGuruge/dotfiles/claude-mods/conventions
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · conventions
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /conventions ⎿ conventions: Convention checks off for this session. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Thisaru's Dotfiles

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.

Highlights

  • Fast Zsh environment – zinit-managed plugins, fzf-tab completion, syntax highlighting, autosuggestions, zoxide (via z command), Atuin history (Ctrl+R and up-arrow), and Ghostty compatible key bindings
  • Powerlevel10k prompt – pure-Zsh rendering (no binary subprocess per draw), instant prompt on startup, contextual Git state, Go/Java/Python/Ballerina indicators, transient prompt for clean scrollback, full Catppuccin Mocha theme matching the tmux status bar
  • Modern CLI stack – eza, bat, dust, ripgrep, fd, yazi (with inline image previews in Ghostty/iTerm2, chafa fallback for other terminals), jless, lazygit, lazydocker, tmux, direnv, atuin, gh, git-delta, gng (gw), uv (Python packages and projects), and curated helper aliases/functions (take, kill_by_port, show_tools, etc.)
  • Debugging in Neovim – nvim-dap with a DAP UI, inline variable values while stepping, and one keymap set (<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
  • Go tooling in Neovim – gopls with inlay hints and codelenses, <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)
  • Sessions and branch review in Neovim – persistence.nvim restores a project's buffers and layout on demand (<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)
  • Smart aliases – Single-letter shortcuts for modern tools (v for nvim, g for ripgrep, f for fd, z for zoxide) while keeping original commands for scripts
  • Suffix aliases – Automatically open files with the right tool based on extension (.md → mdcat, .json/.yaml → jless, .py/.bash/.zsh/.bal → $EDITOR)
  • Language runtimes – pyenv, rbenv, nvm, SDKMAN, and Ballerina (plus uv for Python packages and projects) with lazy-loading shell glue so heavy managers don't slow startup
  • Touch ID for sudo – Use your fingerprint instead of typing passwords in the terminal (works inside tmux too via pam-reattach)
  • Caps Lock remap – Hammerspoon + ControlEscape.spoon: tap Caps Lock for Escape, hold for Control — a Karabiner alternative that needs only Accessibility/Input Monitoring permissions, no blockable system extension
  • Claude Code, two accounts – ccp (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 Claude
  • Project sessions in tmux – prefix + f opens a project with three windows ([Claude], [Nvim], [Shell])
  • Secrets handled correctly – SOPS + age encryption, edit_secrets workflow, and automatic .env handling inside init.sh
  • Brewfile-driven – Curated Brewfile with optional category files in packages/ for modular installation
  • Validation + profiling – bin/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 dives

Prerequisites

  1. macOS – Tested on Sonoma/Ventura (Apple Silicon friendly)
  2. Git – Already on macOS, confirm with git --version
  3. Homebrew – Install if missing:
   /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
  1. Terminal – Ghostty (recommended and configured), iTerm2, or Terminal.app all work
  2. Nerd Font – Needed for icons in Powerlevel10k/lazygit:
   brew install --cask font-fira-code-nerd-font

Set the font once inside your terminal profile (Appearance/Text settings).

Installation

Option 1 – Automated (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:

  • Validate macOS + Xcode CLT, install Homebrew, jq, Go (needed to install bestow)
  • Install core packages from Brewfile with Brew
  • Offer opt-in categories (development, terminals, editors, etc.)
  • Install SDKMAN + Java 21 (optional) and brew-based Ballerina
  • Enable Touch ID for sudo (with tmux support via pam-reattach, loaded from a root-owned copy in /opt/pam)
  • Configure Atuin, direnv, tmux, git delta, lazygit, lazydocker, aliases, and helper functions
  • Generate/restore encrypted .env with SOPS + age (keys stored at ~/.config/sops/age/keys.txt, per-machine ~/.sops.yaml)
  • Create Nerd Font + terminal integrations
  • Back up existing dotfiles and symlink everything via bestow
  • Install TPM (pinned release) and the tmux plugins
  • Check that the SSH commit-signing key exists (offers to create it)
  • Verify the install and show next steps (run dotdoctor for a full health check; the final output also lists the manual steps: the Hammerspoon spoon and permissions, the ~/.claude symlink, tmux plugins)

Option 2 – Manual setup

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.

Option 3 – Brewfile only

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.

Package Management

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

Installing optional categories

brew bundle --file=Brewfile                      # Core packages
brew bundle --file=packages/development.brewfile # Add dev tools
brew bundle --file=packages/editors.brewfile     # Add editors

Categories available

  • 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 CLI
  • editors – VS Code
  • terminals – iTerm2 (casks)
  • containers – colima (container runtime), Docker CLI + Compose + credential helper
  • productivity – Raycast, Rectangle, TablePlus, Alfred, Postman, Caffeine

Comment 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.

Repository Layout & Symlink Packages

PathNotes
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.zshPowerlevel10k 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 Configuration

Ghostty is configured with:

  • Cmd+Left/Right sent as Home/End (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)
  • Option+Left/Right for word navigation (sent as Alt+b / Alt+f, so it works inside tmux too) and Cmd+Delete for delete-to-end-of-line (sent as Alt+k)
  • Catppuccin Mocha theme
  • FiraCode Nerd Font with ligatures
  • Tmux-aware keybindings for window/pane navigation (they send the tmux prefix Ctrl+a; test-zsh fails if the prefix changes)

Containers (colima)

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):

AliasCommand
colima-startcolima start
colima-stopcolima stop
colima-restartcolima restart
colima-statuscolima status
colima-listcolima list
colima-sshcolima 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.

Symlink Management (bestow)

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).

bin is the exception: it links into ~/bin, so always stow it as bestow stow bin -d ~/bin. Don't run a bare bestow stow across every package — with the single $HOME destination in bestow's config it would link bin/* straight into ~.

Quick Start Commands

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)

Claude Code

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):

CommandAccountConfig dir
ccp, ccpr, ccpcpersonal: start, --resume, --continue~/.claude-personal
cce, ccer, ccecenterprise: 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.
  • Mods in 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.
  • Hooks call bin/claude-tmux-state, so tmux windows show when Claude is working, waiting or done (prefix + A jumps to the next waiting window).
  • Hooks also call 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.
  • Pin an account to a directory with envrc (below).

tmux project sessions

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.

Per-project accounts (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)
  • Replacing an existing .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 through

What overrides what

CommandCLAUDE_CONFIG_DIRGOOGLE_WORKSPACE_CLI_CONFIG_DIR
plain claude in a pinned treepinned accountpinned account (inherited)
ccp/ccpr/ccpc, cce/ccer/ccecthe account you typedblanked for that claude, so a pin never leaks the other account's gws into it
gwsp/gwseunchangedthe account you typed
bare gws in a pinned treeunchangedpinned account: the bin/gws guard lets it through because the pin supplies the account context
bare gws anywhere elseunchangedblocked (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).

GitHub PR helpers (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 @me
  • prw [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 checks

Both 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".

Shell behaviour worth knowing

  • 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
Source 4 files
hooks/register.ts 314 lines
1import { 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}
314
hooks/checks.ts 153 lines
1/** 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}
153
hooks/tokens.ts 39 lines
1/**
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}
39
types/index.d.ts 13 lines
1/** 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