SLOPSHOPPER

house-rules

Enforces git identity, attribution and branch-naming rules on Bash calls

newguardtoastprocess
A shopper browsing a rack in a slop shop
README

claude-mods

Three Claude Code mods: plugins built from function hooks that run inside Claude Code, in the terminal and the desktop app's Code tab.

ModWhat it does
house-rulesKeeps Claude's git work under your identity: removes AI co-author trailers, renames claude/ and ai/ branches, and blocks commits or PRs made as the wrong person
usage-bandShows 5-hour and 7-day usage limits, context fill, cost and the git branch in a band above the prompt
lean-commentsStops Claude adding banner, multi-line, long or narrating code comments, so comments only explain a non-obvious why in one short line

Needs Claude Code 2.1.286 or later.

Install

/plugin marketplace add WilsonKinyua/claude-mods
/plugin install house-rules@wilson-mods
/plugin install usage-band@wilson-mods
/plugin install lean-comments@wilson-mods
/reload-plugins

Plugins install at user scope, so they load in every project. Add --scope project to claude plugin install to turn one on for a single repo instead.

Mods run with the same access as Claude Code. Read the source before you install, as you would with any package.

house-rules

It checks every Bash command Claude runs, including subagents' commands, before the command executes.

Corrected automatically (you get a toast, and Claude is told what actually ran):

  • -c user.email=…, -c user.name=…, GIT_AUTHOR_* / GIT_COMMITTER_* env vars and any --author that isn't yours are removed.
  • Co-authored-by:, Generated-by:, 🤖 Generated with Claude Code and Claude session links are removed from commit messages and gh pr / gh issue text.
  • A new branch with a blocked prefix is renamed: git checkout -b claude/fix-login creates feat/fix-login, and a push in the same command follows the rename.

Blocked, with the reason given to Claude:

  • Pushing an existing branch with a blocked prefix, or opening a PR from one. Claude gets the git branch -m command to rename it first.
  • git commit in a repo whose user.email or user.name isn't yours.
  • git config user.email / user.name set to anything else.
  • gh pr create while gh is signed in to another account. This check only runs if you set a GitHub login.

Settings

Set these in /config, or from a terminal:

claude plugin configure house-rules@wilson-mods
SettingDefaultMeaning
gitEmailyour global user.emailThe only email commits may be authored as
gitNamesyour global user.nameAccepted author names, separated by commas
githubLoginempty (no check)The gh account allowed to open PRs
blockedBranchPrefixesclaude,ai,bot,copilot,codex,…Branch prefixes that get renamed or blocked
branchTypefeatThe prefix a blocked branch is renamed to

If one repo should keep a different identity, such as a work email, run this inside that repo yourself:

git config house-rules.allow-identity true

Claude can't set that key; the mod blocks it.

usage-band

usage-band in the desktop app: 5h and 7d limit rings with reset countdowns, context fill and cost

  • Rings for the 5-hour and 7-day limits, with reset countdowns. They turn amber at 60% and red at 85%.
  • Context window fill.
  • Cost of this session, plus today's and this month's totals.
  • Current git branch and the number of uncommitted changes.
  • A toast when a limit passes 80% and again at 90%.
  • /usage, or the details button, opens a pane with the context breakdown by category and spend per day.

The rings are drawn as SVG in the desktop app and as ◔◑◕ glyphs in the terminal. Limits only appear on a Claude subscription. The today and month totals only count sessions where the mod was loaded.

lean-comments

It checks every Edit and Write Claude makes. Only comments the change adds are checked; existing comments are left alone. By default a change is refused, with a list of the offending comments, and Claude rewrites it. A comment is flagged when it:

  • is a decorative banner or divider, such as // ── Section ────── or # ==========;
  • spans more than one line, including multi-line /** … */ doc blocks;
  • is longer than 120 characters;
  • narrates or labels the code, such as // Step 1: load config or # Helpers.

A change that adds more than 3 new comments is flagged too.

Tool directives are never flagged: eslint-disable, @ts-expect-error, noqa, # type: ignore, prettier-ignore, //go:, shebangs, license headers and similar. Markdown and other prose files are skipped.

It understands comment syntax for JS/TS, Go, Java, Kotlin, Swift, C/C++, C#, Rust, Dart, PHP, Python, Ruby, shell, YAML, TOML, SQL, Lua, CSS/SCSS, HTML, Vue, Svelte and Astro.

SettingDefaultMeaning
modeblockblock refuses the change so Claude rewrites it; warn lets it through and tells Claude what to fix; off disables the mod
maxLength120Longest comment text allowed, in characters
maxNewComments3How many new comments one change may add
allowDocCommentsfalseLet /** … */ doc blocks span several lines, e.g. for a published library's API

Develop

claude --plugin-dir ./plugins/usage-band
claude plugin validate ./plugins/usage-band

Saving a file reloads the mod in the running session. Bump version in the plugin's plugin.json and in .claude-plugin/marketplace.json when you release, so claude plugin marketplace update wilson-mods picks up the change.

License

MIT

Source 1 files
hooks/register.ts 203 lines
1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2
3const DEFAULT_PREFIXES = 'claude,ai,bot,bots,copilot,cursor,codex,gpt,openai,anthropic,assistant,agent,llm,devin,project-thread'
4const ALLOW_KEY = 'house-rules.allow-identity'
5
6const AT_COMMAND = String.raw`(?:^|[;&|(]\s*)`
7const ARG = String.raw`(?:"[^"]*"|'[^']*'|[^\s;&|]+)`
8const NAME = String.raw`([^\s;&|'"]+)`
9const END = String.raw`(?=[\s;&|'")]|$)`
10const unquote = (value: string) => value.replace(/^(["'])(.*)\1$/, '$2')
11const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
12
13const TYPE_PREFIX = /^(feat|fix|test|refactor|docs|chore)\//
14
15type Rules = { blocked: RegExp; branchType: string }
16type Identity = { email: string; names: string[]; githubLogin: string }
17
18const list = (value: PluginOptions[string] | undefined) =>
19  (Array.isArray(value) ? value : String(value ?? '').split(','))
20    .map(item => String(item).trim())
21    .filter(Boolean)
22
23function rulesFrom(options: PluginOptions): Rules {
24  const prefixes = list(options.blockedBranchPrefixes ?? DEFAULT_PREFIXES).map(escapeRegExp)
25  return {
26    blocked: prefixes.length > 0 ? new RegExp(String.raw`^(${prefixes.join('|')})[/-]`, 'i') : /$^/,
27    branchType: String(options.branchType ?? 'feat').replace(/\/+$/, '') || 'feat',
28  }
29}
30
31async function identityFrom($: EngineInterface, options: PluginOptions): Promise<Identity> {
32  const globalConfig = (key: string) =>
33    $.process.run(['git', 'config', '--global', key]).then(r => (r.exitCode === 0 ? r.stdout.trim() : ''))
34  const email = String(options.gitEmail ?? '').trim() || (await globalConfig('user.email'))
35  const names = list(options.gitNames)
36  return {
37    email,
38    names: names.length > 0 ? names : [await globalConfig('user.name')].filter(Boolean),
39    githubLogin: String(options.githubLogin ?? '').trim(),
40  }
41}
42
43const ATTRIBUTION = String.raw`(?:🤖\s*)?(?:co-authored-by:|generated-by:|assisted-by:|claude-session:|generated (?:with|by) \[?claude)`
44const ATTRIBUTION_LINE = new RegExp(String.raw`^[ \t]*(?:${ATTRIBUTION}|[^\n]*claude\.ai/code/session)[^\n]*(?:\n|$)`, 'gim')
45const ATTRIBUTION_ESCAPED = new RegExp(String.raw`(?:\\n)+${ATTRIBUTION}[^"'\\\n]*`, 'gi')
46const ATTRIBUTION_FLAG = new RegExp(String.raw`\s+(?:-m|--message|--trailer)(?:=|\s+)(["'])${ATTRIBUTION}[^"']*\1`, 'gi')
47const WRITES_MESSAGE = /\b(?:git\s+(?:[^;&|\n]*\s)?(?:commit|tag|notes|merge|cherry-pick|revert)|gh\s+(?:pr|issue|release)\s+(?:create|edit|comment|review|merge))\b/
48
49const CREATES_BRANCH = [
50  new RegExp(String.raw`\bgit\s+(?:checkout|switch)\s+(?:[^;&|\n]*?\s)?(?:-b|-B|-c|-C|--create|--force-create)(?:=|\s+)${NAME}${END}`, 'g'),
51  new RegExp(String.raw`\bgit\s+branch\s+(?:-m|-M|--move|-c|-C|--copy)\s+(?:\S+\s+)?${NAME}${END}`, 'g'),
52  new RegExp(String.raw`\bgit\s+branch\s+(?:(?:-f|--force|--track|--no-track)\s+)*(?!-)${NAME}${END}`, 'g'),
53  new RegExp(String.raw`\bgit\s+worktree\s+add\s+(?:[^;&|\n]*?\s)?-[bB]\s+${NAME}${END}`, 'g'),
54]
55
56function publishedBranches(command: string, rules: Rules) {
57  const refs: string[] = []
58  for (const match of command.matchAll(/\bgit\s+push\b([^;&|\n]*)/g)) {
59    for (const token of (match[1] ?? '').trim().split(/\s+/)) refs.push(unquote(token.split(':').pop() ?? '').replace(/[`,;)]+$/, ''))
60  }
61  for (const match of command.matchAll(/\bgh\s+pr\s+create\b[^;&|\n]*?--head(?:=|\s+)([^\s;&|]+)/g)) refs.push(unquote(match[1] ?? ''))
62  return refs.filter(ref => rules.blocked.test(ref))
63}
64
65
66function suggestedName(branch: string, rules: Rules) {
67  const rest = branch.replace(rules.blocked, '')
68  return TYPE_PREFIX.test(rest) ? rest : `${rules.branchType}/${rest}`
69}
70
71function stripIdentityOverrides(command: string, email: string, notes: Set<string>) {
72  return command
73    .replace(new RegExp(String.raw`\s+-c\s+(?:(["'])user\.(?:name|email)=[^"']*\1|user\.(?:name|email)=${ARG})`, 'g'), () => {
74      notes.add('removed a `-c user.*` identity override')
75      return ''
76    })
77    .replace(new RegExp(String.raw`\bGIT_(?:AUTHOR|COMMITTER)_(?:NAME|EMAIL)=${ARG}\s+`, 'g'), () => {
78      notes.add('removed a GIT_AUTHOR/COMMITTER env override')
79      return ''
80    })
81    .replace(new RegExp(String.raw`\s+--author(?:=|\s+)${ARG}`, 'g'), match => {
82      if (email && match.includes(email)) return match
83      notes.add('removed an `--author` override')
84      return ''
85    })
86}
87
88function stripAttribution(command: string, notes: Set<string>) {
89  if (!WRITES_MESSAGE.test(command)) return command
90
91  const note = () => {
92    notes.add('removed AI attribution / co-author trailers')
93    return ''
94  }
95
96  const keepClosingQuote = (line: string) => {
97    note()
98    const quote = /["']+\s*$/.exec(line.replace(/\n$/, ''))?.[0] ?? ''
99    return quote && line.endsWith('\n') ? `${quote}\n` : quote
100  }
101
102  return command.replace(ATTRIBUTION_FLAG, note).replace(ATTRIBUTION_ESCAPED, note).replace(ATTRIBUTION_LINE, keepClosingQuote)
103}
104
105function renameNewBranches(command: string, rules: Rules, notes: Set<string>) {
106  const renames = new Map<string, string>()
107
108  for (const pattern of CREATES_BRANCH) {
109    for (const match of command.matchAll(pattern)) {
110      const branch = match[1] ?? ''
111      if (rules.blocked.test(branch)) renames.set(branch, suggestedName(branch, rules))
112    }
113  }
114
115  let out = command
116  for (const [from, to] of renames) {
117    out = out.replace(new RegExp(String.raw`(?<=[\s:'"=/]|^)${escapeRegExp(from)}${END}`, 'g'), to)
118    notes.add(`renamed branch \`${from}\` to \`${to}\``)
119  }
120
121  return out
122}
123
124function workingDir(command: string) {
125  const gitDir = /\bgit\s+-C\s+("[^"]*"|'[^']*'|\S+)/.exec(command)
126  if (gitDir) return unquote(gitDir[1] ?? '')
127
128  const cds = [...command.matchAll(/(?:^|[;&|]\s*)cd\s+("[^"]*"|'[^']*'|[^\s;&|]+)/g)]
129  const last = cds.at(-1)?.[1]
130  return last === undefined ? undefined : unquote(last)
131}
132
133export const register: Register = (on, options) => {
134  const rules = rulesFrom(options)
135  let identity: Identity | undefined
136  let verifiedLogin: string | undefined
137
138  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
139    const original: string = e.command
140    if (!/\b(git|gh)\b/.test(original)) return next(e)
141
142    if (new RegExp(String.raw`${AT_COMMAND}git\s+config\s+(?:--\S+\s+)*${escapeRegExp(ALLOW_KEY)}\s+\S`, 'im').test(original)) {
143      return { deny: `house-rules: only the user may set ${ALLOW_KEY}. Ask them to run it themselves.` }
144    }
145
146    identity ??= await identityFrom($, options)
147    const { email: expectedEmail, names, githubLogin } = identity
148
149    const setsIdentity = new RegExp(String.raw`${AT_COMMAND}git\s+config\s+(?:--(?:local|global|worktree|system|file\s+\S+)\s+)*user\.(email|name)\s+(${ARG})`, 'm').exec(original)
150    if (setsIdentity) {
151      const [, field, raw] = setsIdentity
152      const value = unquote(raw ?? '')
153      const expected = field === 'email' ? expectedEmail : names[0]
154      const isAllowed = field === 'email' ? !expectedEmail || value === expectedEmail : names.length === 0 || names.includes(value)
155      if (!isAllowed) {
156        return { deny: `house-rules: git user.${field} must stay ${expected}. Never set it to "${value}"; ask the user if this repo needs a different identity.` }
157      }
158    }
159
160    const notes = new Set<string>()
161    const command = renameNewBranches(stripAttribution(stripIdentityOverrides(original, expectedEmail, notes), notes), rules, notes)
162    const cwd = workingDir(command)
163    const git = (...args: string[]) => $.process.run(['git', ...args], { cwd }).then(r => (r.exitCode === 0 ? r.stdout.trim() : ''))
164
165    const pushedBranch = publishedBranches(command, rules)[0]
166    const isPublishing = /\bgit\s+push\b|\bgh\s+pr\s+create\b/.test(command)
167    const currentBranch = isPublishing && !pushedBranch ? await git('rev-parse', '--abbrev-ref', 'HEAD') : ''
168    const badBranch = pushedBranch ?? (rules.blocked.test(currentBranch) ? currentBranch : undefined)
169    if (badBranch) {
170      return { deny: `house-rules: branch "${badBranch}" uses an AI/bot prefix. Rename it first with \`git branch -m ${badBranch} ${suggestedName(badBranch, rules)}\` (pick feat/, fix/, test/, refactor/, docs/ or chore/ to match the work), then push.` }
171    }
172
173    if (/\bgit\s+(?:[^;&|\n]*\s)?commit\b/.test(command) && (await git('config', '--get', ALLOW_KEY)) !== 'true') {
174      const [email, name] = await Promise.all([git('config', 'user.email'), git('config', 'user.name')])
175      const isWrongEmail = expectedEmail !== '' && email !== expectedEmail
176      const isWrongName = names.length > 0 && !names.includes(name)
177      if (isWrongEmail || isWrongName) {
178        return { deny: `house-rules: this repo would commit as "${name} <${email}>", not "${names[0] ?? name} <${expectedEmail || email}>". Do not override it inline; stop and ask the user how to proceed.` }
179      }
180    }
181
182    if (githubLogin && /\bgh\s+pr\s+create\b/.test(command)) {
183      const login = verifiedLogin ?? (await $.process.run(['gh', 'api', 'user', '-q', '.login'], { cwd }).then(r => r.stdout.trim()))
184      if (login !== githubLogin) {
185        return { deny: `house-rules: gh is authenticated as "${login || 'unknown'}", not ${githubLogin}. Stop and ask the user before opening a PR.` }
186      }
187      verifiedLogin = login
188    }
189
190    if (notes.size === 0) return next(e)
191
192    const summary = [...notes].join('; ')
193    $.ui.toast(`house-rules: ${summary}`)
194    const ran = await next({ ...e, command })
195    if (ran.deny !== undefined) return ran
196
197    return {
198      ...ran,
199      context: [...(ran.context ?? []), `house-rules corrected this command before it ran (${summary}). It ran as:\n${command}`],
200    }
201  })
202}
203