SLOPSHOPPER

repo-guard

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

newguardtoast
v0.1.0no licenseupdated 2026-10-08YuehengHan/my-claude-mods/plugins/repo-guard
A shopper browsing a rack in a slop shop
README

my-claude-mods

Claude Code mods (function-hook plugins) for working across many repos and many sessions at once — plus a pet.

ModWhat it does
where-am-iA band above the prompt: 📁 repo 🌿 branch ↑2 ± 3 changed 🎯 task. /task <text> names what this session is doing.
done-pingA 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-guardBlocks 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-petA 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-moodClaude's mood in the status line: 🤔 reading, ✍️ writing, 🧪 testing, 😤 after a few errors, 😌 when done — with 📖 ✏️ ⚡ 💥 counters.
control-towerA 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.
tldrAfter every long answer, a dim 💡 TL;DR line (Haiku) in the transcript, so a session you switch back to reads at a glance.
sdk-syncRelease 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.
fortuneProgrammer jokes after the working spinner (Sauteing… 🎲 删库跑路前,记得先 git push。); the day's first prompt shows 今日运势 (/fortune). Fridays never deploy.

Install

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.

Developing

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>
Source 1 files
hooks/register.ts 90 lines
1import 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