Blocks destructive git commands and asks before Claude first edits a repo other than the session's own

Claude Code mods (function-hook plugins) for working across many repos and many sessions at once — plus a pet.
| Mod | What it does |
|---|---|
| where-am-i | A band above the prompt: 📁 repo 🌿 branch ↑2 ± 3 changed 🎯 task. /task <text> names what this session is doing. |
| done-ping | A macOS notification (repo, duration, your prompt) with a sound per kind: 🔔 Glass when a turn over 30 s is done, Basso when it failed, Submarine when Claude is waiting on you. Change them in SOUNDS. |
| repo-guard | Blocks push --force, pushes to main/master, reset --hard, clean -f, checkout ., branch -D, stash clear, rm -rf ~. Asks before Claude first edits a repo other than the session's own. |
| claude-pet | A pet that earns xp from your work, grows through five stages and unlocks achievements. Pick a species (/pet species: chick, cat, dog, dragon, dino, ocean, bug, plant, robot, moon) or your own emojis (/pet emoji 🦊, /pet emoji 🥚 🐣 🦊 🐺 🐉). Lives in the status line (🦮 xixi (・_・)📖 Lv7 ▰▱▱▱▱▱) with 18 moods that follow Claude: 💭 thinking, 📖 reading, ✍️ writing, ⚡ running, 🧪 testing, 🌐 browsing, 📣 delegating, ✋ waiting for you, 🔥 on a 25-call streak, 🌙 past 1am, 🎉 tests pass, 📦 commit/push, (×_×) error, (╬ಠ益ಠ) three in a row, bored after 5 idle minutes, asleep after 15. /pet for stats, /pet name <name>. |
| claude-mood | Claude's mood in the status line: 🤔 reading, ✍️ writing, 🧪 testing, 😤 after a few errors, 😌 when done — with 📖 ✏️ ⚡ 💥 counters. |
| control-tower | A row under where-am-i's, shown only while another session waits for you (a permission or a question): ✋ backend-ng 在等你 2m [tower]. /tower (or the button) opens a pane listing every Claude session on this machine, named repo · branch for worktrees. |
| tldr | After every long answer, a dim 💡 TL;DR line (Haiku) in the transcript, so a session you switch back to reads at a glance. |
| sdk-sync | Release audit for the SpatialReal SDKs. Never acts while you work: when a new v* tag appears it reminds you once; /release-check <repo> [tag] gathers the facts (public API files changed, CHANGELOG entry, commits in each sibling SDK / docs / examples since the release, version pins) and asks Claude for a read-only audit table. /release-check status lists unchecked releases; --facts skips the audit. Graph: plugins/sdk-sync/hooks/sdk-map.ts. |
| fortune | Programmer jokes after the working spinner (Sauteing… 🎲 删库跑路前,记得先 git push。); the day's first prompt shows 今日运势 (/fortune). Fridays never deploy. |
At a Claude Code terminal prompt:
/plugin marketplace add YuehengHan/my-claude-mods
/plugin install where-am-i@my-claude-mods
/plugin install done-ping@my-claude-mods
/plugin install repo-guard@my-claude-mods
/plugin install claude-pet@my-claude-mods
/plugin install claude-mood@my-claude-mods
Pick the user scope so they load in every session. Install only the ones you want.
Add a local clone as the marketplace instead, and Claude Code reads the plugins straight from the folder:
claude plugin marketplace add ~/Desktop/my-claude-mods
claude plugin install where-am-i@my-claude-mods --scope user
Claude Code runs an installed copy, so after editing a mod bump its version in plugin.json, then:
claude plugin marketplace update my-claude-mods
claude plugin update <mod>@my-claude-mods
and run /reload-plugins (or restart) in each open session.
Check and test a mod:
claude plugin validate plugins/<mod>
claude plugin test plugins/<mod>hooks/register.ts 90 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// One shell segment: stops at ; & | and newlines so `git push && echo -f` is not a force push.
4const SEG = '[^;&|\\n]*'
5
6export const DANGER: ReadonlyArray<readonly [RegExp, string]> = [
7 [new RegExp(`\\bgit\\s+push\\b${SEG}\\s(--force(?!-with-lease)|-f\\b|\\+\\S)`), 'force push (use --force-with-lease, or run it yourself)'],
8 [new RegExp(`\\bgit\\s+push\\b${SEG}[\\s:](main|master)(\\s|$|[;&|])`), 'pushing straight to main/master'],
9 [new RegExp(`\\bgit\\s+reset\\b${SEG}\\s--hard\\b`), 'git reset --hard throws away uncommitted work'],
10 [new RegExp(`\\bgit\\s+clean\\b${SEG}\\s-[a-zA-Z]*f`), 'git clean -f deletes untracked files'],
11 [new RegExp(`\\bgit\\s+(checkout|restore)\\b${SEG}\\s\\.(\\s|$|[;&|])`), 'discarding every uncommitted change'],
12 [new RegExp(`\\bgit\\s+branch\\b${SEG}\\s-D\\b`), 'force-deleting a branch'],
13 [new RegExp(`\\bgit\\s+stash\\s+(drop|clear)\\b`), 'dropping stashes'],
14 [/\brm\s+-[a-zA-Z]*[rR][a-zA-Z]*\s+(\/|~|\$HOME)\/?(\s|$|\*)/, 'rm -r on / or your home directory'],
15]
16
17export function dangerOf(command: string): string | undefined {
18 return DANGER.find(([re]) => re.test(command))?.[1]
19}
20
21const EDIT_TOOLS: readonly string[] = ['Edit', 'Write', 'NotebookEdit']
22const isEditTool = (tool: string) => EDIT_TOOLS.includes(tool)
23const basename = (path: string) => path.replace(/\/+$/, '').split('/').pop() || path
24const parent = (path: string) => path.replace(/\/[^/]*\/?$/, '') || '/'
25
26let sessionRoot: string | null | undefined
27const approved = new Set<string>()
28
29/** The nearest folder above `path` holding .git, or null outside any repo. */
30async function repoRoot($: EngineInterface, path: string): Promise<string | null> {
31 for (let dir = parent(path); dir !== '/' && dir !== ''; dir = parent(dir)) {
32 if (await $.fs.exists(`${dir}/.git`)) return dir
33 }
34 return null
35}
36
37async function ownRoot($: EngineInterface): Promise<string | null> {
38 if (sessionRoot === undefined) {
39 const cwd = await $.session.cwd()
40 sessionRoot = await repoRoot($, `${cwd}/.`)
41 }
42 return sessionRoot
43}
44
45async function foreignRoot($: EngineInterface, input: unknown): Promise<string | null> {
46 const args = input as { file_path?: string; notebook_path?: string }
47 const path = args.file_path ?? args.notebook_path
48 if (typeof path !== 'string' || !path.startsWith('/')) return null
49 const root = await repoRoot($, path)
50 if (root === null || approved.has(root) || root === (await ownRoot($))) return null
51 return root
52}
53
54export const register: Register = on => {
55 on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
56 const command = (e.input as { command?: string }).command ?? ''
57 const why = dangerOf(command)
58 if (why === undefined) return next(e)
59 $.ui.toast(`🛡 repo-guard blocked: ${why}`)
60 return {
61 decision: 'deny',
62 reason: `repo-guard: blocked (${why}). If it is really needed, ask the user to run it themselves.`,
63 }
64 }).catch(($, e, next) => next(e))
65
66 on('tool.check', async ($, e, next) => {
67 const verdict = await next(e)
68 if (!isEditTool(e.tool) || verdict.decision === 'deny') return verdict
69 const root = await foreignRoot($, e.input)
70 if (root === null) return verdict
71 const own = await ownRoot($)
72 return {
73 decision: 'ask',
74 reason: `repo-guard: this edits ${basename(root)}, a different repo from this session's ${own ? basename(own) : 'folder'}. Allow edits there?`,
75 }
76 }).catch(($, e, next) => next(e))
77
78 // Once an edit in another repo has gone through, stop asking for that repo.
79 on('tool.call', async ($, e, next) => {
80 const ran = await next(e)
81 if (!isEditTool(e.tool) || ran.deny !== undefined || ran.isError) return ran
82 const root = await foreignRoot($, e).catch(() => null)
83 if (root !== null) {
84 approved.add(root)
85 $.ui.toast(`🛡 Now editing another repo: ${basename(root)}`)
86 }
87 return ran
88 })
89}
90