In the dotfiles repo, refuses the checks reserved for the human: make check-full, make test, bin/check --full (AGENTS.md), scripts/test_runner.py and…

Personal macOS development environment managed with Chezmoi. Keyboard-centric workflow with extensive automation and tool integration.
Start here: published docs at <https://kapral18.github.io/dotfiles/> (source: docs/intro/index.md). The README is an orientation page; recipes, command catalogs, and subsystem detail live in docs/.
| Feature | Description |
|---|---|
| 🤖 Agent Memory | on-demand ,ai-kb knowledge base + ,handoff session notes |
| 🧠 Local inference | llama.cpp router + model sync (,llama-cpp) |
| 🔐 Secure Identity | 1Password SSH agent with work/personal switching |
| 🌳 Git Worktrees | Worktree management with PR integration |
| 💎 Neovim | Custom LSP, AI commits, refactoring tools |
| 🐚 Fish Shell | 40+ custom productivity commands in ~/bin |
| 📦 Brewfile | 280+ formulas and casks (per-category partials) |
| ⚙️ Mise | Version manager with automatic switching |
sh -c "$(curl -fsLS get.chezmoi.io/lb)" -- init --apply kapral18
Safer first run: preview with chezmoi init kapral18 && chezmoi diff before applying. See Getting Started and New machine bootstrap for the full first-run story (prompts, hooks, and what gets installed).
Chezmoi renders templates from home/ into $HOME. Hooks in home/.chezmoiscripts/ install and reconcile packages when lists change. The .isWork prompt forks identity, secrets, and some package lists between work and personal machines.
For the full layout (naming conventions, hook lifecycle, AI config merging, externals), see Architecture.
| Area | Doc | Source-of-truth (edit here) |
|---|---|---|
| Packages (Homebrew, mise, cargo, go, …) | Packages | home/.chezmoitemplates/brews/, home/readonly_dot_default-* |
Custom ~/bin commands | Custom commands | home/exact_bin/, home/exact_lib/ |
| Git identity & worktrees | Worktrees | home/exact_bin/executable_,w |
| GitHub picker & tmux | Tmux | home/dot_config/exact_tmux/ |
| Fish shell | Shell: Fish | home/dot_config/fish/ |
| Neovim | Neovim | home/dot_config/exact_nvim/ |
| macOS automation | macOS | home/dot_hammerspoon/, home/.osx.core |
| Reference map (where to change X) | Reference map | — |
tmux-resurrect / continuum).,w add … or the GitHub picker (prefix + G) for branch/PR worktrees.,update or chezmoi apply to converge package/config drift.See A day in the life for a fuller walkthrough.
docs/intro/index.mdCONTRIBUTING.mdAGENTS.mdhome/.chezmoi.toml.tmplhooks/register.ts 25 lines1import type { Register } from 'claude-code'
2
3// A copy of sop-guard's scanner; scripts/tests/test_claude_mods.py keeps them equal.
4import { base, simpleCommands } from './shell'
5
6// make flags whose next word is their value, not a target.
7const MAKE_VALUE_FLAGS = new Set(['-C', '-f', '-I', '-o', '-W', '--directory', '--file'])
8
9const isReservedCheck = ([name, ...args]: string[]) => {
10 const command = base(name)
11 if (command === 'make') return args.some((arg, i) => (arg === 'test' || arg === 'check-full') && !MAKE_VALUE_FLAGS.has(args[i - 1] ?? ''))
12 if ((name ?? '').endsWith('bin/check')) return args.includes('--full')
13 if (/^(python3?|uv)$/.test(command) && args.some(arg => arg.endsWith('scripts/check.py'))) return args.includes('--full')
14 if (command === 'test_runner.py') return true
15 return /^(python3?|uv)$/.test(command) && args.some(arg => arg.endsWith('scripts/test_runner.py'))
16}
17
18export const register: Register = on => {
19 on('tool.call', { tool: 'Bash' }, ($, e, next) =>
20 simpleCommands(e.command).some(command => isReservedCheck(command.words))
21 ? { deny: `${$.plugin.name}: AGENTS.md reserves make check-full, make test, and bin/check --full for the human, and the user reserved scripts/test_runner.py and scripts/check.py --full. Run make check.` }
22 : next(e),
23 ).catch(($, e, next) => next(e))
24}
25hooks/shell.ts 107 lines1// A small reading of a Bash command: the simple commands it runs, by command word.
2// Quoted text and heredoc bodies are data, never commands, so they are blanked first.
3
4export type Simple = {
5 // The words after assignments, wrappers, and keywords, quotes removed.
6 words: string[]
7 // Inside $(...) or backticks: its stdout feeds another command.
8 isSubstituted: boolean
9 // Its stdout goes to a file or /dev/null.
10 isRedirected: boolean
11 // The pipeline stages after this one.
12 after: Simple[]
13}
14
15const WRAPPERS = new Set(['env', 'time', 'sudo', 'command', 'exec', 'nice', 'nohup'])
16const KEYWORDS = new Set(['if', 'then', 'else', 'elif', 'while', 'until', 'do', '!', '{'])
17// Stdout into a file or /dev/null; `2>` and `>&2` still leave it in the transcript.
18const STDOUT_REDIRECT = /(^|[^0-9&>])>(?!&)|&>|\b1>(?!&)/
19
20// The command with quoted spans, escaped characters, and heredoc bodies as spaces; offsets kept.
21const mask = (command: string) => {
22 const chars = [...command]
23 let quote: string | undefined
24 for (let i = 0; i < chars.length; i++) {
25 const c = chars[i]
26 if (quote) {
27 if (c === quote) quote = undefined
28 else if (c !== '\n') {
29 if (c === '\\' && quote === '"') chars[i + 1] = ' '
30 chars[i] = ' '
31 }
32 } else if (c === '\\') {
33 chars[i] = ' '
34 if (i + 1 < chars.length && chars[i + 1] !== '\n') chars[i + 1] = ' '
35 i++
36 } else if (c === "'" || c === '"') quote = c
37 }
38 let text = chars.join('')
39 for (const doc of command.matchAll(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n/g)) {
40 const bodyStart = (doc.index ?? 0) + doc[0].length
41 const close = new RegExp(String.raw`^\s*${doc[2]}\s*$`, 'm')
42 const rest = command.slice(bodyStart)
43 const end = close.exec(rest)
44 const bodyEnd = bodyStart + (end ? end.index + end[0].length : rest.length)
45 text = text.slice(0, bodyStart) + text.slice(bodyStart, bodyEnd).replace(/[^\n]/g, ' ').replace(/\n/g, ' ') + text.slice(bodyEnd)
46 }
47 return text
48}
49
50const unquote = (raw: string) => (raw.match(/(?:[^\s'"]+|'[^']*'|"(?:\\.|[^"\\])*")+/g) ?? []).map(word => word.replace(/'([^']*)'|"((?:\\.|[^"\\])*)"/g, '$1$2'))
51
52const commandWords = (raw: string) => {
53 const words = unquote(raw)
54 let i = 0
55 while (i < words.length) {
56 const word = words[i] ?? ''
57 if (/^\w+=/.test(word) || KEYWORDS.has(word)) i++
58 else if (WRAPPERS.has(word)) {
59 i++
60 while ((words[i] ?? '').startsWith('-')) i++
61 } else break
62 }
63 return words.slice(i)
64}
65
66// `}` closes a group only as its own word; `${VAR}` stays one word.
67const SEPARATOR = /\|\||&&|;|\||(?<![>&])&(?![>&])|\n|\$\(|\(|\)|`|(?<=^|[\s;])\}(?=[\s;]|$)/g
68
69// Every simple command in `command`, in order.
70export const simpleCommands = (command: string): Simple[] => {
71 const masked = mask(command)
72 const found: (Simple & { sep: string })[] = []
73 const stack: string[] = []
74 let start = 0
75 const push = (end: number, sep: string) => {
76 const text = masked.slice(start, end)
77 if (text.trim()) {
78 found.push({
79 words: commandWords(command.slice(start, end)),
80 isSubstituted: stack.includes('$(') || stack.includes('`'),
81 isRedirected: STDOUT_REDIRECT.test(text),
82 after: [],
83 sep,
84 })
85 }
86 }
87 for (const match of masked.matchAll(SEPARATOR)) {
88 const sep = match[0]
89 const at = match.index ?? 0
90 push(at, sep)
91 if (sep === '$(' || sep === '(') stack.push(sep)
92 else if (sep === ')') stack.pop()
93 else if (sep === '`') stack[stack.length - 1] === '`' ? stack.pop() : stack.push('`')
94 start = at + sep.length
95 }
96 push(masked.length, '')
97 for (let i = found.length - 1; i >= 0; i--) {
98 const stage = found[i]
99 const next = found[i + 1]
100 if (stage && next && stage.sep === '|') stage.after = [next, ...next.after]
101 }
102 return found
103}
104
105// The command name without its directory.
106export const base = (word: string | undefined) => (word ?? '').replace(/^.*\//, '')
107