SLOPSHOPPER

dotfiles-guard

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…

newguard
★ 4v0.1.0MITupdated 2026-10-07kapral18/dotfiles/.claude/skills/dotfiles-guard
A shopper browsing a rack in a slop shop
README

🚀 kapral18/dotfiles

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

✨ Key Features

FeatureDescription
🤖 Agent Memoryon-demand ,ai-kb knowledge base + ,handoff session notes
🧠 Local inferencellama.cpp router + model sync (,llama-cpp)
🔐 Secure Identity1Password SSH agent with work/personal switching
🌳 Git WorktreesWorktree management with PR integration
💎 NeovimCustom LSP, AI commits, refactoring tools
🐚 Fish Shell40+ custom productivity commands in ~/bin
📦 Brewfile280+ formulas and casks (per-category partials)
⚙️ MiseVersion manager with automatic switching

🛠️ Quick Start

Prerequisites

  1. 1Password installed and signed in (SSH agent + secrets).

Bootstrap

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

🏛️ How It Fits Together

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.

📚 Subsystems (read the docs)

AreaDocSource-of-truth (edit here)
Packages (Homebrew, mise, cargo, go, …)Packageshome/.chezmoitemplates/brews/, home/readonly_dot_default-*
Custom ~/bin commandsCustom commandshome/exact_bin/, home/exact_lib/
Git identity & worktreesWorktreeshome/exact_bin/executable_,w
GitHub picker & tmuxTmuxhome/dot_config/exact_tmux/
Fish shellShell: Fishhome/dot_config/fish/
NeovimNeovimhome/dot_config/exact_nvim/
macOS automationmacOShome/dot_hammerspoon/, home/.osx.core
Reference map (where to change X)Reference map—

🔄 Day-to-Day

  1. Tmux sessions auto-restore (tmux-resurrect / continuum).
  2. ,w add … or the GitHub picker (prefix + G) for branch/PR worktrees.
  3. Neovim for editing; ,update or chezmoi apply to converge package/config drift.

See A day in the life for a fuller walkthrough.

Further Reading

Source 2 files
hooks/register.ts 25 lines
1import 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}
25
hooks/shell.ts 107 lines
1// 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