SLOPSHOPPER

dot-guard

Block `git add` of a whole $HOME work-tree (dot add -A/./*), which would stage your entire home directory.

newguard
v0.1.0no licenseupdated 2026-10-04theagitist/claude-mods/dot-guard
A shopper browsing a rack in a slop shop
README

claude-mods

Five small Claude Code mods (function-hook plugins). They run in the terminal CLI (no GUI panes), and they are written to work the same on every machine: Linux or macOS, any username, any home directory. Nothing personal is baked into the source. Per-machine values (a Telegram chat id, an email address) come from the environment, so the same checkout is safe to push publicly and hand to other people.

The mods

ModWhat it doesHow it shows
dot-guardBlocks a git add that would stage your whole $HOME work-tree (dot add -A, dot add ., dot add *). One fat-finger away from committing your entire home directory.Denies the tool call with a reason.
emdash-watchFlags em-dashes the model writes into public-facing prose (.md, .html, .css, .txt, .rst). Internal files (CLAUDE.md, anything under memory/ or .claude/) are exempt.Non-blocking note appended to the write's result, visible to the model only.
home-path-guardFlags a hardcoded user home (/home/<user>, /Users/<user>) written into a file, nudging $HOME / ~. Machine-local files (*.local.*) are exempt.Non-blocking note to the model.
box-statusStatus line showing which box you are on, which out-of-band channel it can reach you on (telegram / email / none), and whether passwordless sudo works.One status-line entry under the prompt.
idle-pingWhen a turn that did real work ends, pings you out of band so you can step away. Telegram when a bot token and chat id are present, otherwise email.Sends a message; logs to the debug sink when no channel is configured.

Install (per machine)

Clone this repo to the same relative path on each box, for example:

git clone <your-remote> ~/apps/claude-mods

Then point Claude Code at the five folders. The cleanest cross-machine way is CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json (which uses ~, so the one line works on every machine):

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "~/apps/claude-mods/dot-guard:~/apps/claude-mods/emdash-watch:~/apps/claude-mods/box-status:~/apps/claude-mods/home-path-guard:~/apps/claude-mods/idle-ping"
  }
}

(On Windows the list separator is ; instead of :.)

Or load them for a single session without touching settings:

claude --plugin-dir ~/apps/claude-mods/dot-guard --plugin-dir ~/apps/claude-mods/idle-ping

Per-machine configuration (idle-ping)

idle-ping reads its target from the environment, never from source:

  • Telegram (preferred when set): it reads TELEGRAM_BOT_TOKEN (and optionally TELEGRAM_CHAT_ID) from ~/.claude/channels/telegram/.env, and TELEGRAM_CHAT_ID from the process environment if not in that file. Keep that file out of version control (mode 600).
  • Email (fallback): set CLAUDE_PING_EMAIL to the recipient. The mod sends through the first mailer it finds on PATH (msmtp, sendmail, mail, mailx).
  • If neither is configured, it writes what it would have sent to the debug log and does nothing else.

box-status reports the same channel resolution, so a glance at the status line tells you whether a ping would actually go out on this box.

Settings knobs

  • dot-guard.wrappers (default dot): comma-separated command names that run git with --work-tree=$HOME. Set it to your own dotfiles alias if it is not dot.
  • idle-ping.minTools (default 2): how many tool calls a turn needs before it counts as real work worth a ping. Raise it to ping less often.

Developing

Each mod is a folder with .claude-plugin/plugin.json, hooks/hooks.json, and hooks/register.ts. Check and test one with:

claude plugin validate <mod>
claude plugin test <mod>
Source 1 files
hooks/register.ts 33 lines
1import type { Register } from 'claude-code'
2
3// A standalone stage-everything arg: catastrophic when the work-tree is $HOME.
4const WHOLE_TREE = /\badd\b[^|&;]*?\s(-A|--all|\.|\*|:\/)(\s|$)/
5
6const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
7
8export const register: Register = (on, options) => {
9  const wrappers = String(options.wrappers ?? 'dot').split(',').map(s => s.trim()).filter(Boolean)
10
11  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
12    const cmd = e.command
13    if (!WHOLE_TREE.test(cmd)) return next(e)
14
15    // Cheap, env-free checks first: a dotfiles wrapper, or a literal $HOME work-tree.
16    let targetsHome =
17      cmd.includes('--work-tree=$HOME') ||
18      wrappers.some(w => new RegExp(`(^|[|&;]|\\s)${escapeRe(w)}\\s+add\\b`).test(cmd))
19
20    // Only a `--work-tree=<abs path>` needs the real home to compare against.
21    if (!targetsHome && /--work-tree=/.test(cmd)) {
22      try {
23        const home = await $.env.get('HOME')
24        targetsHome = !!home && cmd.includes(home)
25      } catch { /* env unavailable: fall through, wrapper check already ran */ }
26    }
27
28    return targetsHome
29      ? { deny: `${$.plugin.name}: refusing a "git add" that stages the whole $HOME work-tree (-A/--all/./*). Stage explicit paths instead; this would commit all of your home directory.` }
30      : next(e)
31  })
32}
33