Flag hardcoded user home paths (/home/<user>, /Users/<user>) you write into files; nudge $HOME / ~ instead. Machine-local (*.local.*) files exempt…

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.
| Mod | What it does | How it shows |
|---|---|---|
dot-guard | Blocks 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-watch | Flags 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-guard | Flags 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-status | Status 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-ping | When 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. |
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
idle-ping reads its target from the environment, never from source:
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).CLAUDE_PING_EMAIL to the recipient. The mod sends through the first mailer it finds on PATH (msmtp, sendmail, mail, mailx).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.
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.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>hooks/register.ts 27 lines1import type { Register } from 'claude-code'
2
3// A literal user home: /home/<user>/… or /Users/<user>/… (macOS). $HOME/~ are fine.
4const HOMEPATH = /\/(?:home|Users)\/[A-Za-z0-9._-]+(?=[\/"'\s:)]|$)/g
5// Machine-local, gitignored files are allowed to hold absolute paths.
6const EXEMPT = /\.local\.(md|json|sh|zsh|bash)$|(^|\/)CLAUDE\.local\.md$|\/\.git\//i
7
8function writtenText(e: any): string {
9 if (typeof e.content === 'string') return e.content
10 if (typeof e.new_string === 'string') return e.new_string
11 if (Array.isArray(e.edits)) return e.edits.map((x: any) => x?.new_string ?? '').join('\n')
12 return ''
13}
14
15export const register: Register = on => {
16 on('tool.call', async ($, e: any, next) => {
17 const r = await next(e)
18 if (r.deny || r.isError) return r
19 const path: string = e.file_path ?? ''
20 if (path === '' || EXEMPT.test(path)) return r
21 const hits = [...new Set(writtenText(e).match(HOMEPATH) ?? [])]
22 if (hits.length === 0) return r
23 const note = `${$.plugin.name}: this write hardcodes ${hits.length} user-home path (${hits.slice(0, 3).join(', ')}${hits.length > 3 ? ', …' : ''}) in ${path}. If this file may be read on another machine (home differs per box), use $HOME or ~ instead. Ignore for a path the user typed, or a genuinely machine-local file.`
24 return { ...r, context: [...(r.context ?? []), note] }
25 })
26}
27