SLOPSHOPPER

lean-comments

Stops Claude adding banner, multi-line, long or narrating code comments

newguardtoast
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 188 lines
1import type { EngineInterface, PluginOptions, Register, ToolCallResult } from 'claude-code'
2
3type Syntax = { line: string[]; blocks: [string, string][] }
4type Comment = { text: string; lines: number; isDoc: boolean }
5type Settings = { mode: string; maxLength: number; maxNew: number; allowDocComments: boolean }
6
7const SLASH_BLOCK: [string, string] = ['/*', '*/']
8const HTML_BLOCK: [string, string] = ['<!--', '-->']
9const C_LIKE: Syntax = { line: ['//'], blocks: [SLASH_BLOCK] }
10const HASH: Syntax = { line: ['#'], blocks: [] }
11
12const SYNTAX: Record<string, Syntax> = {
13  ...Object.fromEntries(
14    ['js', 'jsx', 'mjs', 'cjs', 'ts', 'tsx', 'mts', 'cts', 'go', 'java', 'kt', 'kts', 'swift', 'c', 'h', 'cc', 'cpp', 'hpp', 'cs', 'rs', 'dart', 'scala', 'groovy', 'gradle', 'scss', 'less'].map(ext => [ext, C_LIKE]),
15  ),
16  ...Object.fromEntries(['py', 'rb', 'sh', 'bash', 'zsh', 'yml', 'yaml', 'toml', 'r', 'pl', 'ex', 'exs', 'tf', 'dockerfile'].map(ext => [ext, HASH])),
17  php: { line: ['//', '#'], blocks: [SLASH_BLOCK, HTML_BLOCK] },
18  css: { line: [], blocks: [SLASH_BLOCK] },
19  sql: { line: ['--'], blocks: [SLASH_BLOCK] },
20  lua: { line: ['--'], blocks: [] },
21  html: { line: [], blocks: [HTML_BLOCK] },
22  vue: { line: ['//'], blocks: [SLASH_BLOCK, HTML_BLOCK] },
23  svelte: { line: ['//'], blocks: [SLASH_BLOCK, HTML_BLOCK] },
24  astro: { line: ['//'], blocks: [SLASH_BLOCK, HTML_BLOCK] },
25}
26
27const DIRECTIVE = /^(?:eslint|@ts-|prettier-ignore|biome-ignore|istanbul|c8 |noqa|type:\s*ignore|pylint|pyright|mypy|pragma|#?region|#?endregion|@license|@preserve|SPDX|jshint|global |@jsx|@flow|@vite-ignore|webpack|nolint|go:|\+build|-\*-|!|@refresh|rubocop|shellcheck|phpcs|@phpstan|@psalm|@var |@codeCoverageIgnore|NOSONAR|language=)/i
28const DECORATIVE = /[─━═│┃█▓▒░╔╗╚╝]|[-=*#~_+]{4,}/
29const NARRATION = /^(?:step \d+\b|first,? we\b|now,? we\b|here,? we\b|next,? we\b|this (?:function|method|component|class|hook|file|module|block)\b|the following\b|section\b|end of\b|(?:helpers?|imports?|constants?|types?|exports?|utils?|utilities|state|handlers?|styles?|setup|main|config(?:uration)?)\s*:?\s*$)/i
30
31const extension = (path: string) => {
32  const base = path.split('/').pop() ?? ''
33  return base.toLowerCase() === 'dockerfile' ? 'dockerfile' : (base.split('.').pop() ?? '').toLowerCase()
34}
35
36const hasBalancedQuotes = (text: string) => ['"', "'", '`'].every(q => (text.split(q).length - 1) % 2 === 0)
37
38function trailingComment(line: string, tokens: string[]) {
39  for (const token of tokens) {
40    let at = line.indexOf(` ${token}`)
41    while (at > 0) {
42      if (hasBalancedQuotes(line.slice(0, at))) return line.slice(at + 1)
43      at = line.indexOf(` ${token}`, at + 1)
44    }
45  }
46  return undefined
47}
48
49function extractComments(source: string, syntax: Syntax): Comment[] {
50  const comments: Comment[] = []
51  let block: { end: string; lines: string[] } | undefined
52  let run: string[] = []
53
54  const flushRun = () => {
55    if (run.length > 0) comments.push({ text: run.join('\n'), lines: run.length, isDoc: false })
56    run = []
57  }
58
59  source.split('\n').forEach((raw, index) => {
60    const line = raw.trim()
61
62    if (block) {
63      block.lines.push(line)
64      if (line.includes(block.end)) {
65        comments.push({ text: block.lines.join('\n'), lines: block.lines.length, isDoc: block.lines[0]?.startsWith('/**') ?? false })
66        block = undefined
67      }
68      return
69    }
70
71    if (index === 0 && line.startsWith('#!')) return
72
73    const lineToken = syntax.line.find(token => line.startsWith(token))
74    if (lineToken) {
75      run.push(line)
76      return
77    }
78    flushRun()
79
80    const opener = syntax.blocks.find(([open]) => line.startsWith(open))
81    if (opener) {
82      const [open, close] = opener
83      if (line.includes(close, open.length)) {
84        comments.push({ text: line, lines: 1, isDoc: line.startsWith('/**') })
85      } else {
86        block = { end: close, lines: [line] }
87      }
88      return
89    }
90
91    const trailing = trailingComment(line, syntax.line)
92    if (trailing) comments.push({ text: trailing, lines: 1, isDoc: false })
93  })
94
95  flushRun()
96  return comments
97}
98
99const content = (comment: Comment) =>
100  comment.text
101    .split('\n')
102    .map(line => line.replace(/^(?:\/\*\*?|\*\/|\*|\/\/+|#+|--|<!--|-->)\s?/, '').replace(/\s*(?:\*\/|-->)$/, '').trim())
103    .filter(Boolean)
104    .join(' ')
105
106const normalize = (comment: Comment) => content(comment).replace(/\s+/g, ' ').toLowerCase()
107
108function problemsWith(comment: Comment, settings: Settings) {
109  const text = content(comment)
110  if (DIRECTIVE.test(text) || DIRECTIVE.test(comment.text.replace(/^\/\/|^#/, ''))) return []
111
112  const problems: string[] = []
113  if (DECORATIVE.test(comment.text)) problems.push('decorative banner or divider')
114  if (comment.lines > 1 && !(comment.isDoc && settings.allowDocComments)) problems.push(`spans ${comment.lines} lines`)
115  if (text.length > settings.maxLength) problems.push(`${text.length} characters (max ${settings.maxLength})`)
116  if (NARRATION.test(text)) problems.push('narrates or labels the code instead of explaining a non-obvious why')
117  return problems
118}
119
120function settingsFrom(options: PluginOptions): Settings {
121  const number = (value: unknown, fallback: number) => (Number.isFinite(Number(value)) && Number(value) > 0 ? Number(value) : fallback)
122  return {
123    mode: String(options.mode ?? 'block'),
124    maxLength: number(options.maxLength, 120),
125    maxNew: number(options.maxNewComments, 3),
126    allowDocComments: options.allowDocComments === true,
127  }
128}
129
130export function review(path: string, before: string, after: string, settings: Settings) {
131  const syntax = SYNTAX[extension(path)]
132  if (!syntax) return []
133
134  const existing = new Set(extractComments(before, syntax).map(normalize))
135  const added = extractComments(after, syntax).filter(comment => !existing.has(normalize(comment)))
136  const findings = added
137    .map(comment => ({ comment, problems: problemsWith(comment, settings) }))
138    .filter(finding => finding.problems.length > 0)
139    .map(({ comment, problems }) => `- \`${comment.text.split('\n')[0]?.slice(0, 80)}${comment.lines > 1 ? ' …' : ''}\`: ${problems.join(', ')}`)
140
141  const counted = added.filter(comment => !DIRECTIVE.test(content(comment)))
142  if (counted.length > settings.maxNew) findings.push(`- ${counted.length} new comments in one change (max ${settings.maxNew}); most code needs none`)
143
144  return findings
145}
146
147async function enforce(
148  $: EngineInterface,
149  settings: Settings,
150  path: string,
151  before: string,
152  after: string,
153  run: () => Promise<ToolCallResult>,
154): Promise<ToolCallResult> {
155  const findings = review(path, before, after, settings)
156  if (findings.length === 0) return run()
157
158  const file = path.split('/').pop()
159  const message = [
160    `lean-comments: this change adds comments that break the comment rules in ${file}:`,
161    ...findings,
162    'Only comment a non-obvious why (a constraint, workaround or invariant), as one short sentence on one line. Prefer clearer names over comments. Remove or shorten these, then make the change again.',
163  ].join('\n')
164
165  if (settings.mode !== 'warn') return { deny: message }
166
167  $.ui.toast(`lean-comments: ${findings.length} comment issue${findings.length === 1 ? '' : 's'} in ${file}`)
168  const ran = await run()
169  return ran.deny !== undefined ? ran : { ...ran, context: [...(ran.context ?? []), message] }
170}
171
172export const register: Register = (on, options) => {
173  const settings = settingsFrom(options)
174  if (settings.mode === 'off') return
175
176  on('tool.call', { tool: 'Edit' }, ($, e, next) =>
177    enforce($, settings, e.file_path, e.old_string, e.new_string, () => next(e)),
178  )
179
180  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
181    const before = await $.fs.read(e.file_path).then(
182      text => (typeof text === 'string' ? text : ''),
183      () => '',
184    )
185    return enforce($, settings, e.file_path, before, e.content, () => next(e))
186  })
187}
188