SLOPSHOPPER

push-guard

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…

newguardcommandtoastprocess
★ 1v0.1.1MITupdated 2026-10-13finalpantasy/claude-mod-pack/push-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · push-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /push-guard ⎿ push-guard: Push Guard is on. Before each git push it checks the new commits for no words of your own yet (add them in the ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Claude Mod Pack

Support me on Ko-fi

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.

Claude Status Band above the Claude Code prompt: week and 5-hour limit rings, context, spend and token rows, with Compact now and Handoff buttons, and the Claude Mod Hider arrow (boxed) in the corner

A real screenshot from the Claude desktop app. The dollar and token figures are pixelated.

Install

/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).

The mods

Claude Status Band (status-band)

One tidy band above the prompt, each row labelled:

  • Week and 5-hour: your plan's limits as rings, with a pace for the week that learns when you actually work. Nights and weekends you are away are not counted as burn, and it gets more accurate the longer you use it.
  • Context: how full the chat is and when it will need compacting (red from 75%).
  • Spend and Tokens: this chat, today, 7 days and all time, across every open chat.
  • Last turn: how long it took, files edited with lines added and removed, commands run, failures, reads and agents.
  • Tasks: your open to-do list, from TodoWrite or claude-mem's work state.
  • Compact now and Handoff buttons, always there. Handoff has Claude write a handoff packet and move the work to a fresh chat.

Claude Status Band with sample data: week, 5-hour and context rings, spend and token tiles for this chat, today, 7 days and all time, Compact now and Handoff buttons, the last turn and an open task list

/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.

The /mods panel: Compact now and Handoff, a switch for each band row, and every installed mod with an on/off switch and its full description

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 (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.

Claude Push Guard (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:

  • Your words: your real name, personal email, company, anything you list in the plugin's settings (/plugin configure push-guard@claude-mod-pack). Empty until you fill it in.
  • Your home folder path, read from your own machine, in any spelling (C:\Users\you, /c/Users/you, escaped JSON, /home/you).
  • Your everyday git name and email, but only when the commits are made under a different identity, the way a real name slips into a repo you publish under a handle.
  • Em dashes, if you turn that on.

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.

Claude Codex Bridge (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.

  • Needs the Codex CLI installed and signed in.
  • Set which models you get in the plugin's settings (models, as name=model pairs).

Support

These are free hobby projects. If one saves you time, you can buy me a coffee on Ko-fi.

License

MIT

Source 3 files
hooks/register.ts 105 lines
1import 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}
105
hooks/scan.ts 237 lines
1// 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}
237
types/index.d.ts 12 lines
1// 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