Claude Push Guard: stops a git push Claude runs when the commits would publish your name, email, home folder path or other words you list, and says exactly…

Small Claude Code mods: a status band for your usage limits, a switchboard for your mods, a button that hides them all, a guard that keeps your name out of public pushes, and OpenAI Codex as native subagents.
Each mod installs on its own. Pick the ones you want.

A real screenshot from the Claude desktop app. The dollar and token figures are pixelated.
/plugin marketplace add finalpantasy/claude-mod-pack
/plugin install status-band@claude-mod-pack
/plugin install claude-mod-hider@claude-mod-pack
/plugin install push-guard@claude-mod-pack
/plugin install codex-bridge@claude-mod-pack
Restart Claude Code after installing. Mods need Claude Code with mod support (function hooks).
status-band)One tidy band above the prompt, each row labelled:

/mods opens a panel listing every installed mod with its full description and an on/off switch, plus a switch for each band row. /band hides or shows the band.

These two pictures are drawn from status-band's own output with sample data: its screenshot test records exactly what the mod draws, and tools/shots.py lays that out in the desktop app's dark style and photographs it. The mods named focus-timer and pixel-pet are made-up examples.
claude-mod-hider)One small arrow in the corner of the band. Click it and every mod above the prompt folds down to a single ▸ mods button; click again and they are back. It remembers your choice across sessions. More, including how other mods can opt in.
push-guard)Stops a git push Claude runs when the commits about to leave would publish something about you, and tells Claude exactly where it is so it can fix the files and the history first. It checks only what is new in the push: added lines, file names, commit messages and author names.
What it looks for is yours, never anyone else's:
/plugin configure push-guard@claude-mod-pack). Empty until you fill it in.C:\Users\you, /c/Users/you, escaped JSON, /home/you).What Claude reads when a push is stopped (from the mod's tests, with a made-up name):
Push Guard stopped this push: one place in the commits would publish something private.
- README.md:13: "Alex Rivera"
Remove it from the files and from the commit history (amend or rebase the commits, and fix author names with the repo's own git identity), then push again.
If it is meant to be public, the person can run /push-guard off to push without the check for this session.
Private GitHub repos are skipped when the GitHub CLI can see they are private; any other host is checked. /push-guard says what it checks, and /push-guard off lets a push through for the rest of the session.
codex-bridge)Runs OpenAI Codex models as real Claude Code subagents. Each model becomes an agent type (codex-bridge:sol, codex-bridge:sol-ro for read-only, and so on) that the Agent tool can start like any other. You get the same task row, timer, token count, tool rows, background notice and SendMessage follow-ups as a native agent, which is handy for a second opinion from a different model family.
models, as name=model pairs).These are free hobby projects. If one saves you time, you can buy me a coffee on Ko-fi.
MIT
hooks/register.ts 105 lines1import type { EngineInterface, Register } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3
4import { COMMIT_FORMAT, PATCH_FORMAT, denyText, findHits, githubRepo, nativeDir, needles, parseAdded, parseCommits, pushTargets } from './scan'
5import type { PushTarget } from './scan'
6
7// Claude Push Guard: before a git push Claude runs, read the commits about to leave and stop the push
8// when they hold the person's own private words. Nothing about any one person is written here: the
9// words come from the person's settings, the home folder and git identity from their machine.
10
11const paused = atom({ plugin: 'push-guard', key: 'paused' } as const, false)
12
13type Options = { words: string[]; protectGitIdentity: boolean; onlyPublic: boolean; emDashes: boolean }
14
15async function run($: EngineInterface, argv: string[], cwd?: string) {
16 try {
17 const r = await $.process.run(argv, { ...(cwd ? { cwd } : {}), timeoutMs: 30_000 })
18 return { ok: r.exitCode === 0, out: r.stdout ?? '', err: (r.stderr || r.stdout || '').trim().split('\n')[0] ?? '' }
19 } catch (err) {
20 return { ok: false, out: '', err: err instanceof Error ? err.message : String(err) }
21 }
22}
23
24/** Why this push must not go ahead, or null when it may. */
25async function check($: EngineInterface, target: PushTarget, o: Options): Promise<string | null> {
26 // A home-relative folder starts from the home folder; Git Bash's /c/... is C:/... to the process runner.
27 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
28 const raw = target.dir?.startsWith('~') && home ? home + target.dir.slice(1) : target.dir
29 const isWindows = (await $.env.get('USERPROFILE')) !== undefined
30 const cwd = raw === undefined ? undefined : isWindows ? nativeDir(raw) : raw
31 const git = (...args: string[]) => run($, ['git', ...args], cwd)
32
33 const upstream = await git('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}')
34 const remote = target.remote ?? (upstream.ok ? upstream.out.trim().split('/')[0] : undefined) ?? 'origin'
35
36 if (o.onlyPublic) {
37 const url = await git('remote', 'get-url', remote)
38 const repo = url.ok ? githubRepo(url.out) : null
39 if (repo) {
40 const vis = await run($, ['gh', 'api', `repos/${repo}`, '--jq', '.private'])
41 if (vis.ok && vis.out.trim() === 'true') return null
42 }
43 }
44
45 const range = upstream.ok && !target.remote ? [`${upstream.out.trim()}..HEAD`] : ['HEAD', '--not', `--remotes=${remote}`]
46 const log = await git('log', COMMIT_FORMAT, ...range)
47 if (!log.ok) return `Push Guard could not read the commits this push would send (${log.err}), so it stopped the push. The person can run /push-guard off to push without the check.`
48 const commits = parseCommits(log.out)
49 if (commits.length === 0) return null
50 const patch = await git('log', '-p', '--no-color', '--unified=0', PATCH_FORMAT, ...range)
51 if (!patch.ok) return `Push Guard could not read the changes this push would send (${patch.err}), so it stopped the push. The person can run /push-guard off to push without the check.`
52
53 let identity: { name?: string; email?: string } | undefined
54 if (o.protectGitIdentity) {
55 const name = (await git('config', '--global', 'user.name')).out.trim()
56 const email = (await git('config', '--global', 'user.email')).out.trim()
57 // Only when these commits are made under another identity: then the everyday one must not show up in them.
58 const other = email !== '' && commits.every(c => c.email.toLowerCase() !== email.toLowerCase())
59 if (other) identity = { name, email }
60 }
61 const hits = findHits(commits, parseAdded(patch.out), needles(o.words, home, identity), o.emDashes)
62 return hits.length ? denyText(hits) : null
63}
64
65export const register: Register = (on, options) => {
66 const o: Options = {
67 words: String(options.words ?? '').split(',').map(w => w.trim()).filter(w => w.length >= 2),
68 protectGitIdentity: options.protectGitIdentity !== false,
69 onlyPublic: options.onlyPublic !== false,
70 emDashes: options.emDashes === true,
71 }
72
73 on('session.start', async ($, e, next) => {
74 const started = await next(e)
75 await $.command.register({ name: 'push-guard', description: 'Push Guard: what it checks; /push-guard off or on pauses it for this session' })
76 return started
77 })
78
79 on('command.run', { command: 'push-guard' }, async ($, e) => {
80 const arg = String((e as { args?: unknown }).args ?? '').trim().toLowerCase()
81 if (arg === 'off' || arg === 'on') {
82 await update($, paused, () => arg === 'off')
83 return { text: arg === 'off' ? 'Push Guard is off for this session. /push-guard on turns it back on.' : 'Push Guard is on.' }
84 }
85 const state = (await read($, paused)) ? 'off for this session' : 'on'
86 const list = o.words.length ? `${o.words.length} word${o.words.length === 1 ? '' : 's'} from your settings` : 'no words of your own yet (add them in the plugin settings)'
87 return {
88 text: `Push Guard is ${state}. Before each git push it checks the new commits for ${list}, your home folder path${o.protectGitIdentity ? ', your everyday git name and email when the commits use another identity' : ''}${o.emDashes ? ', and em dashes' : ''}.${o.onlyPublic ? ' Private GitHub repos are skipped.' : ''}`,
89 }
90 })
91
92 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
93 const targets = pushTargets(String((e as { command?: unknown }).command ?? ''))
94 if (targets.length === 0 || (await read($, paused))) return next(e)
95 for (const t of targets) {
96 const why = await check($, t, o)
97 if (why) {
98 $.ui.toast('Push Guard stopped a push: private words in the commits.')
99 return { deny: why }
100 }
101 }
102 return next(e)
103 })
104}
105hooks/scan.ts 237 lines1// What Push Guard reads and looks for, as plain functions: no engine calls here.
2
3export type Commit = { sha: string; author: string; email: string; committer: string; committerEmail: string; message: string }
4export type Added = { sha: string; file: string; line: number; text: string }
5export type Needle = { label: string; text: string }
6export type Hit = { where: string; found: string }
7export type PushTarget = { dir?: string; remote?: string; kind: 'git' | 'gh' }
8
9const FS = '\x1f'
10const RS = '\x1e'
11
12/** `git log` format for parseCommits: one record per commit. */
13export const COMMIT_FORMAT = `--format=%H${FS}%an${FS}%ae${FS}%cn${FS}%ce${FS}%B${RS}`
14/** `git log -p` format for parseAdded: a marker line with the commit before each patch. */
15export const PATCH_FORMAT = `--format=${RS}%H`
16
17export function parseCommits(out: string): Commit[] {
18 return out
19 .split(RS)
20 .map(r => r.replace(/^\s+/, ''))
21 .filter(r => r.includes(FS))
22 .map(r => {
23 const [sha = '', author = '', email = '', committer = '', committerEmail = '', message = ''] = r.split(FS)
24 return { sha, author, email, committer, committerEmail, message: message.trim() }
25 })
26}
27
28/** The lines each commit adds, with the file and the line number they land on. */
29export function parseAdded(out: string): Added[] {
30 const added: Added[] = []
31 let sha = ''
32 let file = ''
33 let line = 0
34 for (const raw of out.split('\n')) {
35 const l = raw.replace(/\r$/, '')
36 if (l.startsWith(RS)) {
37 sha = l.slice(1).trim()
38 continue
39 }
40 if (l.startsWith('+++ ')) {
41 file = l === '+++ /dev/null' ? '' : l.slice(4).replace(/^b\//, '')
42 continue
43 }
44 const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(l)
45 if (hunk) {
46 line = Number(hunk[1])
47 continue
48 }
49 if (l.startsWith('+') && file) {
50 added.push({ sha, file, line, text: l.slice(1) })
51 line++
52 }
53 }
54 return added
55}
56
57/** Lowercased, with every backslash run turned into one forward slash, so any spelling of a path matches. */
58const fold = (s: string) => s.toLowerCase().replace(/\\+/g, '/')
59
60/** The needles for this person: their words, their home folder, and their everyday identity when asked. */
61export function needles(words: readonly string[], home: string | undefined, identity: { name?: string; email?: string } | undefined): Needle[] {
62 const out: Needle[] = []
63 for (const w of words) if (w.trim().length >= 2) out.push({ label: `"${w.trim()}"`, text: fold(w.trim()) })
64 // The last two parts of the home folder (Users/alex, home/alex) match it in any form: C:\Users\alex, /c/Users/alex, escaped JSON.
65 const parts = (home ?? '').replace(/\\+/g, '/').split('/').filter(Boolean)
66 if (parts.length >= 2) out.push({ label: 'your home folder path', text: fold(parts.slice(-2).join('/')) })
67 if (identity?.name && identity.name.trim().length >= 3) out.push({ label: `your git name "${identity.name.trim()}"`, text: fold(identity.name.trim()) })
68 if (identity?.email && identity.email.includes('@')) {
69 const email = identity.email.trim()
70 out.push({ label: `your git email "${email}"`, text: fold(email) })
71 const local = email.split('@')[0] ?? ''
72 if (local.length >= 4) out.push({ label: `your email name "${local}"`, text: fold(local) })
73 }
74 return out
75}
76
77const EM_DASH = '\u2014'
78
79/** Every place a needle (or an em dash, when asked) shows up in what would be pushed. */
80export function findHits(commits: readonly Commit[], added: readonly Added[], list: readonly Needle[], emDashes: boolean): Hit[] {
81 const hits: Hit[] = []
82 const seen = new Set<string>()
83 const look = (where: string, text: string, withDash: boolean) => {
84 const folded = fold(text)
85 for (const n of list) {
86 const key = `${where}|${n.label}`
87 if (folded.includes(n.text) && !seen.has(key)) {
88 seen.add(key)
89 hits.push({ where, found: n.label })
90 }
91 }
92 if (withDash && emDashes && text.includes(EM_DASH) && !seen.has(`${where}|dash`)) {
93 seen.add(`${where}|dash`)
94 hits.push({ where, found: 'an em dash' })
95 }
96 }
97 for (const c of commits) {
98 const id = c.sha.slice(0, 7)
99 look(`commit ${id} author`, `${c.author} ${c.email}`, false)
100 look(`commit ${id} committer`, `${c.committer} ${c.committerEmail}`, false)
101 look(`commit ${id} message`, c.message, true)
102 }
103 const files = new Set<string>()
104 for (const a of added) {
105 if (!files.has(a.file)) {
106 files.add(a.file)
107 look(`file name ${a.file}`, a.file, false)
108 }
109 look(`${a.file}:${a.line}`, a.text, true)
110 }
111 return hits
112}
113
114/** A command split into words the way a shell would, with `;`, `&&`, `||` and `|` as their own words. */
115export function words(command: string): string[] {
116 const out: string[] = []
117 let cur = ''
118 let has = false
119 let quote: '"' | "'" | null = null
120 for (let i = 0; i < command.length; i++) {
121 const ch = command[i]!
122 if (quote) {
123 if (ch === quote) quote = null
124 else if (ch === '\\' && quote === '"' && i + 1 < command.length) cur += command[++i]
125 else cur += ch
126 continue
127 }
128 if (ch === '"' || ch === "'") {
129 quote = ch
130 has = true
131 continue
132 }
133 if (/\s/.test(ch)) {
134 if (has) out.push(cur)
135 cur = ''
136 has = false
137 continue
138 }
139 if (ch === ';' || ch === '&' || ch === '|') {
140 if (has) out.push(cur)
141 let op = ch
142 if ((ch === '&' || ch === '|') && command[i + 1] === ch) op += command[++i]
143 out.push(op)
144 cur = ''
145 has = false
146 continue
147 }
148 cur += ch
149 has = true
150 }
151 if (has) out.push(cur)
152 return out
153}
154
155const SEPARATORS = new Set([';', '&&', '||', '|', '&'])
156// git's own options that take the next word as their value.
157const GIT_VALUE_OPTIONS = new Set(['-c', '-C', '--git-dir', '--work-tree', '--namespace', '--exec-path', '--config-env'])
158// git push options that take the next word as their value.
159const PUSH_VALUE_OPTIONS = new Set(['--repo', '--receive-pack', '--exec', '-o', '--push-option', '--recurse-submodules'])
160
161/** A folder as a shell wrote it, as the process runner needs it: Git Bash's /c/... becomes C:/... on Windows-style paths. */
162export function nativeDir(dir: string): string {
163 const m = /^\/([a-zA-Z])(\/.*)?$/.exec(dir)
164 return m ? `${m[1]!.toUpperCase()}:${m[2] ?? '/'}` : dir
165}
166
167/** Every push in a command: git push, or gh repo create with --push; where it runs and to which remote. */
168export function pushTargets(command: string): PushTarget[] {
169 const all = words(command)
170 const targets: PushTarget[] = []
171 let dir: string | undefined
172 let seg: string[] = []
173 const flush = () => {
174 const s = seg
175 seg = []
176 if (s.length === 0) return
177 const prog = (s[0] ?? '').replace(/\\/g, '/').split('/').pop()?.replace(/\.exe$/i, '')
178 if (prog === 'cd' && s[1]) {
179 dir = s[1]
180 return
181 }
182 if (prog === 'git') {
183 let i = 1
184 let at = dir
185 while (i < s.length && s[i]!.startsWith('-')) {
186 const opt = s[i]!
187 if (opt === '-C') at = s[i + 1]
188 if (GIT_VALUE_OPTIONS.has(opt)) i += 2
189 else i++
190 }
191 if (s[i] !== 'push') return
192 let remote: string | undefined
193 for (let j = i + 1; j < s.length; j++) {
194 const w = s[j]!
195 if (PUSH_VALUE_OPTIONS.has(w)) {
196 j++
197 continue
198 }
199 if (w.startsWith('-')) continue
200 remote = w
201 break
202 }
203 targets.push({ kind: 'git', ...(at ? { dir: at } : {}), ...(remote ? { remote } : {}) })
204 return
205 }
206 if (prog === 'gh' && s[1] === 'repo' && s[2] === 'create' && s.includes('--push')) {
207 const source = s.find(w => w.startsWith('--source='))?.slice(9) ?? (s.includes('--source') ? s[s.indexOf('--source') + 1] : undefined)
208 const at = source ?? dir
209 targets.push({ kind: 'gh', ...(at ? { dir: at } : {}) })
210 }
211 }
212 for (const w of all) {
213 if (SEPARATORS.has(w)) flush()
214 else seg.push(w)
215 }
216 flush()
217 return targets
218}
219
220/** owner/repo for a GitHub remote URL, or null for any other host. */
221export function githubRepo(url: string): string | null {
222 const m = /github\.com[/:]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(url.trim())
223 return m ? `${m[1]}/${m[2]}` : null
224}
225
226/** The message Claude reads when a push is stopped. */
227export function denyText(hits: readonly Hit[]): string {
228 const shown = hits.slice(0, 12).map(h => `- ${h.where}: ${h.found}`)
229 if (hits.length > 12) shown.push(`- and ${hits.length - 12} more`)
230 return [
231 `Push Guard stopped this push: ${hits.length === 1 ? 'one place' : `${hits.length} places`} in the commits would publish something private.`,
232 ...shown,
233 'Remove it from the files and from the commit history (amend or rebase the commits, and fix author names with the repo\'s own git identity), then push again.',
234 'If it is meant to be public, the person can run /push-guard off to push without the check for this session.',
235 ].join('\n')
236}
237types/index.d.ts 12 lines1// Claude Push Guard's type contract.
2/** True while the person has paused the guard for this session with /push-guard off. */
3export type Paused = boolean
4
5declare module 'claude-code' {
6 interface PluginState {
7 'push-guard': {
8 paused: Paused
9 }
10 }
11}
12