Holds git commit in a pane listing staged files, flags files not edited this session and blocks AI attribution trailers.

Personal Claude Code mods, published as one local-style marketplace named heitor-mods.
Requires a Claude Code build with mod (function hooks) support.
1. Add the marketplace. The repository is private, so gh auth login (or any git credential that can read it) must work first.
claude plugin marketplace add fhva29/claude-code-mods
Or clone it and add the folder; Claude Code then reads the mods straight from it, so an edit plus /reload-plugins is enough, with no reinstall:
git clone https://github.com/fhva29/claude-code-mods ~/.claude/mods
claude plugin marketplace add ~/.claude/mods
2. Install the mods you want (user scope):
claude plugin install next-lite@heitor-mods
claude plugin install tidy-tools@heitor-mods
# ...or all of them:
for m in blast-radius commit-gate model-stack next-lite pixel-quest prompt-git \
replay-theater tidy-tools usage-weather brief-mode; do
claude plugin install "$m@heitor-mods"
done
3. Reload inside Claude Code with /reload-plugins.
Options (userConfig) are set in /plugin → pick the mod → configure, then /reload-plugins. They are stored in ~/.claude/settings.json under pluginConfigs.
| Mod | What it does | Command | |
|---|---|---|---|
next-lite | Suggests up to three next prompts above the input (press 1/2/3 to fill the prompt box, 0 to dismiss; the top one is also Tab ghost text). Sends a ~1–2k token digest of the turn to a small model instead of forking the whole session, so its cost does not grow with the conversation. Replacement for next-steps. Options: mode (auto/manual), model, minAnswerChars, suggestSkills. | /next | |
model-stack | Routes the model and effort of the main chat turn and of each subagent with TypeSafe's Jev, and shows the roster, decisions and agent graph in a pane. Needs TYPESAFE_API_KEY in the environment. | /stack | |
tidy-tools | Draws each tool call as one compact line, or folds a whole turn into one "N steps this turn" line. | /lines | |
blast-radius | Holds risky shell commands (rm -rf, git reset --hard, git clean, force push, migrations) and shows what they would change, with Proceed and Cancel buttons. | ||
commit-gate | Holds git commit in a pane listing the staged files, flags files not edited this session and blocks AI attribution trailers. | ||
replay-theater | Steps through the file edits Claude made in the last turn, one diff at a time. | /replay | |
usage-weather | A forecast band above the prompt: context fill, sparkline, rate limits, model and effort. | ||
prompt-git | Appends the git branch and working-tree counts to the hint line under the prompt. | ||
pixel-quest | Replaces the spinner with a pixel RPG scene for each tool, with XP, levels and achievements. No model calls. | /toons | |
brief-mode | Appends a short-answer style section to the system prompt: answer or next action first, numbered steps, lists capped at five, no preamble, recap or closers. Edit hooks/rules.ts to tune it. | ||
midnight-neon | Draws assistant replies with a dark neon palette: filled h1 banner, colored h2–h6, colored bullets, quote bars, highlighted bold, italic, inline code and links. Fences and tables keep the engine's renderer. Not installed by default; /neon flips it per session, `claude plugin enable\ | disable` flips it for good. | /neon |
_archive holds mods that are kept but not in use (branch-hud); they are not listed in the marketplace.
Each mod is a folder with .claude-plugin/plugin.json, hooks/hooks.json and a hooks module (register.ts or register.tsx). Check one with:
claude plugin validate <mod>
claude plugin test <mod>
claude plugin test does not type-check; run tsc against the SDK declarations Claude Code writes to <mod>/.claude-plugin/types/ as well.
next-lite builds on next-steps (MIT, Thariq Shihipar): it reuses the text sanitizer and the reply parsing, and replaces the session fork with a small digest.
hooks/register.tsx 200 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CommitGateFile, CommitGateHold } from '../types'
5import { attributionIn, commitMessage, isCommit, pendingAdds } from './parse'
6
7const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
8
9const touched = atom({ plugin: 'commit-gate', key: 'touched' } as const, [])
10const hold = atom({ plugin: 'commit-gate', key: 'hold' } as const, null)
11
12async function git($: EngineInterface, args: string[]): Promise<string> {
13 const { exitCode, stdout } = await $.process.run(['git', ...args])
14 return exitCode === 0 ? stdout.trim() : ''
15}
16
17async function stagedFiles($: EngineInterface, edited: readonly string[]): Promise<CommitGateFile[]> {
18 const root = await git($, ['rev-parse', '--show-toplevel'])
19 const lines = (await git($, ['diff', '--cached', '--name-status'])).split('\n')
20 return lines
21 .filter(line => line.trim() !== '')
22 .map(line => {
23 const [status = '?', ...rest] = line.split('\t')
24 const path = rest.at(-1) ?? ''
25 return { path, status: status.charAt(0), isTouched: edited.includes(`${root}/${path}`) }
26 })
27}
28
29// Reads of $.state inside one dispatch see that dispatch's snapshot, so the
30// waiting hook and the buttons share the decision through the module instead.
31// A reload drops it, which also ends the waiting hook.
32const decisions = new Map<string, 'commit' | 'cancel'>()
33
34async function waitForDecision(id: string, $: EngineInterface, signal: AbortSignal): Promise<'commit' | 'cancel'> {
35 while (!signal.aborted) {
36 const decision = decisions.get(id)
37 if (decision !== undefined) return decision
38 await $.process.run(['sleep', '0.25'])
39 }
40 return 'cancel'
41}
42
43function decide($: EngineInterface, id: string, decision: 'commit' | 'cancel'): Promise<unknown> {
44 decisions.set(id, decision)
45 return update($, hold, current => (current === null ? null : { ...current, decision }))
46}
47
48function statusColor(status: string): string {
49 if (status === 'A') return 'diffAdded'
50 if (status === 'D') return 'diffRemoved'
51 if (status === 'R') return 'suggestion'
52 return 'warning'
53}
54
55type RenderEvent = Parameters<EngineInterface['ui']['resolve']>[0]
56
57function drawHold($: EngineInterface, e: RenderEvent, current: CommitGateHold, columns: number) {
58 const { Box, Text, Button } = $.ui.resolve(e)
59 const strangers = current.files.filter(file => !file.isTouched)
60 const pathWidth = Math.max(20, columns - 30)
61
62 return (
63 <Box flexDirection="column" paddingX={1}>
64 <Box>
65 <Text color="permission" bold>
66 ⛁ Commit gate
67 </Text>
68 <Text dimColor> · review before it lands</Text>
69 </Box>
70 {current.message !== '' && (
71 <Box marginTop={1} borderStyle="round" borderColor="subtle" paddingX={1}>
72 <Text wrap="truncate-end">{current.message.split('\n')[0]}</Text>
73 </Box>
74 )}
75 <Box flexDirection="column" marginTop={1}>
76 {current.files.map(file => (
77 <Box>
78 <Text color={file.isTouched ? 'success' : 'warning'}>{file.isTouched ? '✓ ' : '⚠ '}</Text>
79 <Text color={statusColor(file.status)} bold>
80 {file.status}{' '}
81 </Text>
82 <Text color={file.isTouched ? 'text' : 'warning'} wrap="truncate-start">
83 {file.path.length > pathWidth ? `…${file.path.slice(-pathWidth)}` : file.path}
84 </Text>
85 {!file.isTouched && <Text dimColor> not edited this session</Text>}
86 </Box>
87 ))}
88 {current.pendingAdds.map(path => (
89 <Box>
90 <Text color="suggestion">+ </Text>
91 <Text dimColor>git add </Text>
92 <Text>{path}</Text>
93 </Box>
94 ))}
95 </Box>
96 <Box marginTop={1}>
97 <Text dimColor>
98 {current.files.length} staged
99 {current.pendingAdds.length > 0 ? ` · ${current.pendingAdds.length} added by this command` : ''}
100 </Text>
101 {strangers.length > 0 && (
102 <Text color="warning" bold>
103 {' '}
104 · {strangers.length} not edited this session
105 </Text>
106 )}
107 </Box>
108 <Box marginTop={1} gap={1}>
109 <Button
110 key="commit"
111 label="Commit"
112 hotkey="1"
113 variant="primary"
114 autoFocus
115 onPress={() => decide($, current.id, 'commit')}
116 />
117 <Button key="cancel" label="Cancel" hotkey="2" onPress={() => decide($, current.id, 'cancel')} />
118 </Box>
119 </Box>
120 )
121}
122
123export const register: Register = on => {
124 on('tool.call', async ($, e, next) => {
125 const result = await next(e)
126 if (EDIT_TOOLS.has(String(e.tool)) && result.deny === undefined && result.isError !== true) {
127 const input = e as unknown as { file_path?: string; notebook_path?: string }
128 const path = input.file_path ?? input.notebook_path
129 if (path !== undefined) {
130 const stat = await $.fs.stat(path, { resolve: true }).catch(() => undefined)
131 const real = stat?.realPath ?? path
132 await update($, touched, list => (list.includes(real) ? list : [...list, real]))
133 }
134 }
135 return result
136 })
137
138 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
139 if (!isCommit(e.command)) {
140 return next(e)
141 }
142
143 const attribution = attributionIn(e.command)
144 if (attribution !== null) {
145 $.ui.toast(`commit-gate: blocked "${attribution}" in the commit`)
146 return {
147 deny: `commit-gate: the commit carries AI attribution ("${attribution}"). Remove every Co-Authored-By / Claude trailer and commit again.`,
148 }
149 }
150
151 const edited = await read($, touched)
152 const files = await stagedFiles($, edited)
153 const adds = pendingAdds(e.command)
154 if (files.length === 0 && adds.length === 0) {
155 return next(e)
156 }
157
158 const current: CommitGateHold = {
159 id: e.tool_use_id,
160 command: e.command,
161 message: commitMessage(e.command),
162 files,
163 pendingAdds: adds,
164 decision: null,
165 }
166 await update($, hold, () => current)
167
168 const decision = await waitForDecision(current.id, $, next.signal)
169 decisions.delete(current.id)
170 await update($, hold, () => null)
171
172 if (decision === 'commit') {
173 return next(e)
174 }
175 const strangers = files.filter(file => !file.isTouched).map(file => file.path)
176 const why = strangers.length > 0 ? ` Files not edited this session: ${strangers.join(', ')}.` : ''
177 return {
178 deny: `commit-gate: the user pressed Cancel on the staged list.${why} Ask which files belong in this commit.`,
179 }
180 }).catch(($, e, next) =>
181 next.called ? next(e) : { deny: 'commit-gate: its check failed, so the commit was held.' },
182 )
183
184 // The review always draws above the prompt.
185 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
186 const below = await next(e)
187 const current = await read($, hold)
188 if (current === null) {
189 return below
190 }
191 const { Box } = $.ui.resolve(e)
192 return (
193 <Box flexDirection="column">
194 {below}
195 {drawHold($, e, current, e.props.bodyColumns)}
196 </Box>
197 )
198 })
199}
200hooks/parse.ts 42 lines1const COMMIT = /(^|&&|\|\||;|\|)\s*git(?:\s+-C\s+\S+)?\s+commit\b/
2const ADD = /(?:^|&&|\|\||;)\s*git(?:\s+-C\s+\S+)?\s+add\s+([^;&|]+)/g
3const ATTRIBUTION = [
4 /co-authored-by:/i,
5 /generated with \[?claude/i,
6 /claude-session/i,
7 /🤖/,
8]
9
10export function isCommit(command: string): boolean {
11 return COMMIT.test(command)
12}
13
14export function attributionIn(command: string): string | null {
15 const hit = ATTRIBUTION.find(pattern => pattern.test(command))
16 return hit === undefined ? null : (command.match(hit)?.[0] ?? null)
17}
18
19// Paths a `git add` in the same command will stage before the commit runs.
20export function pendingAdds(command: string): string[] {
21 const paths: string[] = []
22 for (const match of command.matchAll(ADD)) {
23 const args = (match[1] ?? '').trim().split(/\s+/)
24 paths.push(...args.filter(arg => arg !== '' && !arg.startsWith('-')).map(unquote))
25 }
26 return paths
27}
28
29// The first line of `-m "..."`, or of a heredoc body, when the command shows one.
30export function commitMessage(command: string): string {
31 const heredoc = /<<-?\s*'?(\w+)'?\s*\n([\s\S]*?)\n\s*\1\b/.exec(command)
32 if (heredoc !== null) {
33 return heredoc[2]?.trim() ?? ''
34 }
35 const inline = /\s-m\s+(?:"((?:[^"\\]|\\.)*)"|'([^']*)')/.exec(command)
36 return (inline?.[1] ?? inline?.[2] ?? '').trim()
37}
38
39function unquote(arg: string): string {
40 return arg.replace(/^['"]|['"]$/g, '')
41}
42types/index.d.ts 20 lines1export type CommitGateFile = { path: string; status: string; isTouched: boolean }
2
3export type CommitGateHold = {
4 id: string
5 command: string
6 message: string
7 files: CommitGateFile[]
8 pendingAdds: string[]
9 decision: 'commit' | 'cancel' | null
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 'commit-gate': {
15 touched: string[]
16 hold: CommitGateHold | null
17 }
18 }
19}
20