Production guard: flags every command that touches production or is risky (force push, secrets, rm -rf…), checks for a fresh database backup before a…

Mods for Claude Code: small plugins that put on screen what you would otherwise keep asking Claude. One folder per mod; install only the ones you want. Every mod speaks English or French.
| Mod | What it shows | Commands |
|---|---|---|
suivi-conso — usage | 5-hour and weekly quota left, context in tokens, session cost (API equivalent), when the quota runs out at the current pace, what cost the most, tips to save usage, 7-day history. Also provides the shared themes | /conso, /conso band, /theme-mods <theme> |
ou-on-en-est — where things stand | For each repo: branch, uncommitted, unpushed, to pull, PR and CI. On your server: the deployed commit against main, changes made in place, whether your pages answer. Notes per repo | /ou-on-en-est, /ou-on-en-est note <repo> <text> |
garde-prod — production guard | Every command that touches production or is risky (push --force, secrets, rm -rf…), a fresh database backup before a production write (can refuse without one), and refused commands kept ready to run yourself with ! | /prod |
apk-fraicheur — APK freshness | Is the Android APK up to date with the code? Build in progress and its duration, failure or out-of-memory flagged at once, size, copy to another folder | /apk, /apk copy |
A band above the prompt (terminal and desktop) gives the essentials; each command opens a pane with the details, which also works on mobile.
5 h ███████░░░ 62% left │ 7 d 42% │ ctx 342 k / 1 M │ ≈ $4.82 /conso
PROD ×2 · backup ✓ 21:10 · 1 run yourself (/prod)
APK v4 · 14:52 ✗ stale: 2 commits since the build · 96 MB
git for ou-on-en-est and apk-fraicheur; a signed-in gh to see PRs and CI; key-based ssh to follow a server.git clone https://github.com/DarkSawOktay/claude-mods ~/claude-mods
In ~/.claude/settings.json (your user settings, not a project's), one path per mod you want, separated by ::
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/claude-mods/suivi-conso:~/claude-mods/ou-on-en-est:~/claude-mods/garde-prod:~/claude-mods/apk-fraicheur"
}
}
To try one mod once: claude --plugin-dir ~/claude-mods/garde-prod. To update: git pull in ~/claude-mods; Claude Code reloads a mod when its files change.
In /config, or under pluginConfigs in ~/.claude/settings.json:
{
"pluginConfigs": {
"suivi-conso": { "options": { "language": "en" } },
"garde-prod": { "options": { "language": "en", "prodHosts": "example.com,203.0.113.7", "requireBackup": true } },
"ou-on-en-est": { "options": { "language": "en", "vpsHost": "deploy@example.com", "vpsRepos": "api=/srv/api", "healthUrls": "https://example.com/" } },
"apk-fraicheur": { "options": { "language": "en", "appDir": "/home/me/projects/my-app", "copyTo": "/mnt/c/Users/me/Downloads" } }
}
}
Every mod has language: fr (default) or en.
garde-prod
prodHosts: production hostnames or IPs. Without them, only risky commands are flagged.backupPattern: regular expression for your own backup command, on top of pg_dump, mysqldump, mongodump.backupMaxAgeMinutes (120): how old a backup may be and still cover a write.requireBackup (off): refuse a production database write without a recent backup. Claude gets the reason and takes the backup first.ou-on-en-est
projectsRoot: every git repo directly inside is tracked. Empty: the current project and the repos next to it.extraRepos: other paths, comma-separated.vpsHost, vpsRepos: your server (key-based ssh) and name=path for each deployed repo. Read-only: a single ssh running git rev-parse and git status, every 10 minutes and on each /ou-on-en-est.healthUrls: pages whose HTTP status is shown.apk-fraicheur
appDir: the project followed when the current folder is not an Android app.copyTo: where /apk copy puts the APK./theme-mods graphite | olive | crepuscule | papier | contraste (provided by suivi-conso). The choice is kept across sessions and written to ~/.claude/mods-theme, which every mod reads. The rest of Claude Code follows /theme.
gh, the files of your project, and with ou-on-en-est one read-only ssh and curl per check. ou-on-en-est also runs git fetch, which only updates remote-tracking branches./apk copy copies the APK when you ask. garde-prod with requireBackup on refuses a production database write until a backup exists; that is the only time a mod blocks anything.claude plugin validate <mod>
claude plugin test <mod>
Rules of the repo, so that every mod can be published:
userConfig;messages(lang) table, in French and English;hooks/logic.ts, with no engine call, tested on their own; rendering lives in hooks/register.tsx, tested on at least two surfaces;next(e) drawn below);Issues and pull requests are welcome.
MIT © Oktay Gençer
hooks/register.tsx 201 lines1// Garde-prod : chaque commande qui touche la production, la sauvegarde avant une écriture
2// en base, et les commandes refusées gardées prêtes à lancer soi-même.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, PluginOptions, Register } from 'claude-code'
5
6import type { ProdEvent } from '../types'
7import { asBang, classify, hasFreshBackup, isRefusal, messages, shorten, splitList } from './logic'
8
9const events = atom({ plugin: 'garde-prod', key: 'events' } as const, [])
10const pending = atom({ plugin: 'garde-prod', key: 'pending' } as const, [])
11const lastBackupAt = atom({ plugin: 'garde-prod', key: 'lastBackupAt' } as const, null)
12
13const PANE = 'garde-prod'
14
15/** Couleurs du thème choisi avec /theme-mods (fichier ~/.claude/mods-theme). */
16const COLORS: Record<string, { acc: string; ok: string; warn: string; bad: string; dim: string }> = {
17 graphite: { acc: '#7cc4fa', ok: '#4cc38a', warn: '#e6ad4c', bad: '#e5675e', dim: '#8c95a2' },
18 olive: { acc: '#d6a94e', ok: '#62c08c', warn: '#e6ad4c', bad: '#e2665d', dim: '#80968a' },
19 crepuscule: { acc: '#f4a988', ok: '#6fd3a8', warn: '#e9c46a', bad: '#ef6f6c', dim: '#9d94b0' },
20 papier: { acc: '#2f62c8', ok: '#1f7a4d', warn: '#9a6200', bad: '#c23b32', dim: '#676b72' },
21 contraste: { acc: '#ffd400', ok: '#5cff7a', warn: '#ffd400', bad: '#ff5c5c', dim: '#cfcfcf' },
22}
23let themeName = 'graphite'
24
25async function loadTheme($: EngineInterface) {
26 try {
27 const home = await $.env.get('HOME')
28 if (!home || !(await $.fs.exists(`${home}/.claude/mods-theme`))) return
29 const name = (await $.fs.read(`${home}/.claude/mods-theme`)).trim()
30 if (name in COLORS) themeName = name
31 } catch {
32 // thème par défaut
33 }
34}
35
36function hm(ms: number): string {
37 return new Date(ms).toTimeString().slice(0, 5)
38}
39
40async function setStatus($: EngineInterface, lang: string) {
41 const m = messages(lang)
42 const list = (await read($, events)).filter(ev => ev.target !== null)
43 if (list.length === 0) return $.ui.status(undefined)
44 const backup = await read($, lastBackupAt)
45 $.ui.status(`${m.status(list.length)} · ${backup ? m.backupOk(hm(backup)) : m.backupNone}`)
46}
47
48export const register: Register = (on, options: PluginOptions) => {
49 const lang = String(options.language ?? 'fr')
50 const m = messages(lang)
51 const hosts = () => splitList(String(options.prodHosts ?? ''))
52 const maxAge = Number(options.backupMaxAgeMinutes ?? 120)
53
54 on('session.start', async ($, e, next) => {
55 await $.command.register({ name: 'prod', description: m.commandDesc })
56 await loadTheme($)
57 return next(e)
58 })
59
60 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
61 const { kinds, target } = classify(e.command, hosts(), String(options.backupPattern ?? ''))
62 if (kinds.length === 0) {
63 const ran = await next(e)
64 return ran
65 }
66 const now = await $.clock.now()
67 const isDbWrite = kinds.includes('dbwrite')
68 const hadBackup = isDbWrite ? hasFreshBackup(await read($, lastBackupAt), now, maxAge) : undefined
69
70 if (isDbWrite && !hadBackup && options.requireBackup === true) {
71 const last = await read($, lastBackupAt)
72 const age = last === null ? m.never : m.since(Math.round((now - last) / 60_000))
73 return { deny: m.deny(age) }
74 }
75
76 const ev: ProdEvent = { id: e.tool_use_id, at: now, kinds, command: e.command, target, outcome: 'running', hadBackup }
77 await update($, events, list => [...list, ev].slice(-100))
78 if (target) $.ui.toast(m.toastProd(shorten(e.command, 60)), { timeoutMs: 6000 })
79 if (isDbWrite && !hadBackup) $.ui.toast(m.toastNoBackup, { timeoutMs: 10_000 })
80 await setStatus($, lang)
81
82 const ran = await next(e)
83 const refusal = isRefusal(ran as { deny?: string; isError?: boolean; text?: string })
84 const outcome: ProdEvent['outcome'] = refusal ? 'blocked' : ran.isError ? 'error' : 'ok'
85 await update($, events, list => list.map(x => (x.id === ev.id ? { ...x, outcome, detail: refusal ?? undefined } : x)))
86 if (refusal) {
87 await update($, pending, list => [...list.filter(p => p.command !== e.command), { at: now, command: e.command, reason: refusal }].slice(-20))
88 $.ui.toast(m.toastBlocked(shorten(e.command, 50)), { timeoutMs: 8000 })
89 } else if (outcome === 'ok' && kinds.includes('backup')) {
90 const t = await $.clock.now()
91 await update($, lastBackupAt, () => t)
92 }
93 await setStatus($, lang)
94 return ran
95 })
96
97 on('command.run', { command: 'prod' }, async $ => {
98 await loadTheme($)
99 const opened = await $.ui.open({ id: PANE, title: m.paneTitle })
100 const list = await read($, events)
101 const todo = await read($, pending)
102 const lines = [m.summary(list.length, todo.length)]
103 if (hosts().length === 0) lines.push(m.configure)
104 if (!opened.isPlaced) for (const p of todo) lines.push(asBang(p.command))
105 return { text: lines.join('\n') }
106 })
107
108 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
109 const below = await next(e)
110 const list = (await read($, events)).filter(ev => ev.target !== null)
111 const todo = await read($, pending)
112 if (e.props.hasSurvey || (list.length === 0 && todo.length === 0)) return below
113 const { Box, Text } = $.ui.resolve(e)
114 const c = COLORS[themeName] ?? COLORS.graphite!
115 const backup = await read($, lastBackupAt)
116 const unsafe = list.some(ev => ev.kinds.includes('dbwrite') && ev.hadBackup === false)
117 return (
118 <Box flexDirection="column">
119 <Box flexDirection="row" flexWrap="wrap">
120 {list.length > 0 && <Text color={c.bad} bold>{m.status(list.length)}</Text>}
121 {list.length > 0 && (
122 <Text color={unsafe ? c.bad : backup ? c.ok : c.dim}> · {backup ? m.backupOk(hm(backup)) : m.backupNone}</Text>
123 )}
124 {todo.length > 0 && (
125 <Text color={c.warn}>
126 {list.length > 0 ? ' · ' : ''}
127 {todo.length} {m.runYourself.toLowerCase()} (/prod)
128 </Text>
129 )}
130 </Box>
131 {below}
132 </Box>
133 )
134 })
135
136 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
137 const { Box, Text, Button } = $.ui.resolve(e)
138 const c = COLORS[themeName] ?? COLORS.graphite!
139 const list = await read($, events)
140 const todo = await read($, pending)
141 const width = Math.max(20, e.props.bodyColumns - 4)
142 const tone = (ev: ProdEvent) =>
143 ev.outcome === 'blocked' ? c.warn : ev.outcome === 'error' ? c.bad : ev.kinds.includes('dbwrite') && ev.hadBackup === false ? c.bad : ev.target ? c.acc : c.dim
144
145 return (
146 <Box flexDirection="column" gap={1}>
147 {todo.length > 0 && (
148 <Box flexDirection="column">
149 <Text color={c.warn} bold>
150 {m.runYourself.toUpperCase()}
151 </Text>
152 {todo.map((p, i) => (
153 <Box flexDirection="column">
154 <Text>{asBang(p.command)}</Text>
155 <Box flexDirection="row" justifyContent="space-between">
156 <Text color={c.dim} wrap="truncate-end">
157 {p.reason}
158 </Text>
159 <Button
160 key={`copy:${i}`}
161 label={m.copy}
162 onPress={async press => {
163 const r = await $.ui.copy({ text: asBang(p.command), surface: press.surface })
164 if (r.isCopied) $.ui.toast(m.copied)
165 }}
166 />
167 </Box>
168 </Box>
169 ))}
170 </Box>
171 )}
172
173 <Box flexDirection="column">
174 <Text color={c.dim} bold>
175 {m.session.toUpperCase()}
176 </Text>
177 {list.length === 0 && <Text color={c.dim}>{m.nothing}</Text>}
178 {hosts().length === 0 && <Text color={c.dim}>{m.configure}</Text>}
179 {[...list].reverse().map(ev => (
180 <Box flexDirection="column">
181 <Text>
182 <Text color={c.dim}>{hm(ev.at)} </Text>
183 <Text color={tone(ev)} bold>
184 {ev.kinds.map(k => m.kind[k]).join(' + ')}
185 </Text>
186 <Text color={c.dim}> · {m.outcome[ev.outcome]}</Text>
187 {ev.kinds.includes('dbwrite') && (
188 <Text color={ev.hadBackup ? c.ok : c.bad}> · {ev.hadBackup ? m.withBackup : m.backupNone}</Text>
189 )}
190 </Text>
191 <Text color={c.dim} wrap="truncate-end">
192 {shorten(ev.command, width)}
193 </Text>
194 </Box>
195 ))}
196 </Box>
197 </Box>
198 )
199 })
200}
201hooks/logic.ts 140 lines1// Reconnaître une commande sensible : rien ici ne touche à l'engine.
2import type { Kind } from '../types'
3
4/** Une liste « a, b, c ». */
5export function splitList(value: string): string[] {
6 return value
7 .split(/[,\n]/)
8 .map(s => s.trim())
9 .filter(Boolean)
10}
11
12const REMOTE = /\b(ssh|scp|sftp|rsync|mosh)\b/
13const BACKUP = /\b(pg_dump|pg_dumpall|pg_basebackup|mysqldump|mariadb-dump|mongodump)\b/
14const DB_CLIENT = /\b(psql|mysql|mariadb|mongosh|mongo|sqlite3|redis-cli)\b/
15const SQL_WRITE = /\b(INSERT\s+INTO|UPDATE\s+\w+\s+SET|DELETE\s+FROM|ALTER\s+TABLE|DROP\s+(TABLE|DATABASE|SCHEMA)|TRUNCATE|CREATE\s+(TABLE|INDEX))\b/i
16const SQL_FILE = /(\s-f\s|\s--file[= ]|<\s*\S+\.sql\b)/
17const MIGRATE = /(manage\.py\s+migrate\b|prisma\s+migrate\s+deploy|knex\s+migrate:latest|sequelize\s+db:migrate|rails\s+db:migrate|alembic\s+upgrade|npm\s+run\s+migrate|flask\s+db\s+upgrade)/
18const RISKY = [
19 /git\s+push\b[^|;&]*(\s--force\b|\s-f\b|\s--force-with-lease\b|\s--delete\b|\s-d\b|\s:\S)/,
20 /\bgh\s+(secret\s+(set|delete|remove)|workflow\s+run|release\s+delete|repo\s+delete)\b/,
21 /\brm\s+-[a-z]*r[a-z]*f?\b(?![^|;&]*\s\/tmp\/)/,
22 /\bdocker\s+(system|builder|volume|image)\s+prune\b/,
23 /\bsystemctl\s+(restart|stop|disable)\b/,
24 /\bgit\s+(reset\s+--hard|clean\s+-[a-z]*f)/,
25]
26
27/** L'hôte de prod visé par la commande, s'il y en a un. */
28export function prodTarget(command: string, hosts: string[]): string | null {
29 for (const host of hosts) {
30 const h = host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
31 const asTarget = new RegExp(`(^|[\\s@/'"])${h}([\\s:/'"]|$)`)
32 if (!asTarget.test(command)) continue
33 if (REMOTE.test(command) || /ssh:\/\//.test(command) || /\bcurl\b[^|;&]*-X\s*(POST|PUT|PATCH|DELETE)/.test(command)) return host
34 }
35 return null
36}
37
38/** Ce que touche une commande : prod, écriture en base, geste risqué, sauvegarde. */
39export function classify(command: string, hosts: string[], backupPattern: string): { kinds: Kind[]; target: string | null } {
40 const kinds: Kind[] = []
41 const target = prodTarget(command, hosts)
42 let isBackup = BACKUP.test(command)
43 if (!isBackup && backupPattern) {
44 try {
45 isBackup = new RegExp(backupPattern).test(command)
46 } catch {
47 // expression invalide : ignorée
48 }
49 }
50 const isDbWrite = (DB_CLIENT.test(command) && (SQL_WRITE.test(command) || SQL_FILE.test(command))) || MIGRATE.test(command)
51 if (target && isDbWrite) kinds.push('dbwrite')
52 if (target) kinds.push('prod')
53 if (RISKY.some(r => r.test(command))) kinds.push('risky')
54 if (isBackup) kinds.push('backup')
55 return { kinds, target }
56}
57
58/** Une sauvegarde est-elle assez récente pour couvrir une écriture ? */
59export function hasFreshBackup(lastBackupAt: number | null, now: number, maxAgeMinutes: number): boolean {
60 return lastBackupAt !== null && now - lastBackupAt <= maxAgeMinutes * 60_000
61}
62
63/** Un refus de permission (dialogue, classifieur du mode auto, règle). */
64export function isRefusal(result: { deny?: string; isError?: boolean; text?: string }): string | null {
65 if (result.deny !== undefined) return result.deny || 'refusé'
66 const text = result.text ?? ''
67 if (result.isError && /(permission|denied|not allowed|refus|blocked|doesn't want to proceed|rejected)/i.test(text)) {
68 return text.split('\n')[0]?.slice(0, 160) ?? 'refusé'
69 }
70 return null
71}
72
73/** `! commande` prête à coller dans le prompt. */
74export function asBang(command: string): string {
75 return `! ${command.trim()}`
76}
77
78/** Raccourcit une commande pour une ligne : garde le début et l'hôte. */
79export function shorten(command: string, max = 72): string {
80 const one = command.replace(/\s+/g, ' ').trim()
81 return one.length > max ? `${one.slice(0, max - 1)}…` : one
82}
83
84export type Lang = 'fr' | 'en'
85
86const MESSAGES = {
87 fr: {
88 toastProd: (cmd: string) => `PROD · ${cmd}`,
89 toastBlocked: (cmd: string) => `Refusé : ${cmd} (« /prod » pour la lancer toi-même)`,
90 toastNoBackup: 'Écriture en base de prod sans sauvegarde récente',
91 deny: (age: string) =>
92 `garde-prod : pas de sauvegarde de la base de prod ${age}. Fais d'abord une sauvegarde (pg_dump, mysqldump…) puis relance l'écriture.`,
93 never: 'dans cette session',
94 since: (min: number) => `depuis ${min} min`,
95 status: (n: number) => `PROD ×${n}`,
96 backupOk: (hm: string) => `sauvegarde ✓ ${hm}`,
97 backupNone: 'sans sauvegarde',
98 withBackup: 'sauvegarde ✓',
99 paneTitle: 'Production',
100 runYourself: 'À lancer toi-même',
101 copy: 'Copier',
102 copied: 'Copié',
103 session: 'Cette session',
104 nothing: 'Aucune commande sensible dans cette session.',
105 outcome: { running: 'en cours', ok: 'ok', error: 'erreur', blocked: 'refusée' },
106 kind: { dbwrite: 'écriture DB', prod: 'prod', risky: 'risqué', backup: 'sauvegarde' },
107 configure: 'Règle « prodHosts » dans /config pour reconnaître tes serveurs.',
108 commandDesc: 'Commandes sensibles de la session et commandes refusées à lancer toi-même',
109 summary: (n: number, p: number) => `${n} commande${n > 1 ? 's' : ''} sensible${n > 1 ? 's' : ''}, ${p} à lancer toi-même.`,
110 },
111 en: {
112 toastProd: (cmd: string) => `PROD · ${cmd}`,
113 toastBlocked: (cmd: string) => `Refused: ${cmd} (“/prod” to run it yourself)`,
114 toastNoBackup: 'Production database write without a recent backup',
115 deny: (age: string) =>
116 `garde-prod: no backup of the production database ${age}. Take a backup first (pg_dump, mysqldump…) then retry the write.`,
117 never: 'in this session',
118 since: (min: number) => `for ${min} min`,
119 status: (n: number) => `PROD ×${n}`,
120 backupOk: (hm: string) => `backup ✓ ${hm}`,
121 backupNone: 'no backup',
122 withBackup: 'backup ✓',
123 paneTitle: 'Production',
124 runYourself: 'Run yourself',
125 copy: 'Copy',
126 copied: 'Copied',
127 session: 'This session',
128 nothing: 'No sensitive command in this session.',
129 outcome: { running: 'running', ok: 'ok', error: 'error', blocked: 'refused' },
130 kind: { dbwrite: 'DB write', prod: 'prod', risky: 'risky', backup: 'backup' },
131 configure: 'Set “prodHosts” in /config so your servers are recognised.',
132 commandDesc: "This session's sensitive commands, and refused commands to run yourself",
133 summary: (n: number, p: number) => `${n} sensitive command${n > 1 ? 's' : ''}, ${p} to run yourself.`,
134 },
135} as const
136
137export function messages(lang: string) {
138 return MESSAGES[lang === 'en' ? 'en' : 'fr']
139}
140types/index.d.ts 30 lines1/** Ce qu'une commande touche, du plus sensible au moins sensible. */
2export type Kind = 'dbwrite' | 'prod' | 'risky' | 'backup'
3
4/** Une commande sensible vue pendant la session. */
5export type ProdEvent = {
6 id: string
7 at: number
8 kinds: Kind[]
9 command: string
10 /** L'hôte de prod visé, s'il y en a un. */
11 target: string | null
12 outcome: 'running' | 'ok' | 'error' | 'blocked'
13 /** Pour une écriture en base : la sauvegarde la précédait-elle ? */
14 hadBackup?: boolean
15 detail?: string
16}
17
18/** Une commande refusée, prête à être lancée à la main avec `!`. */
19export type Pending = { at: number; command: string; reason: string }
20
21declare module 'claude-code' {
22 interface PluginState {
23 'garde-prod': {
24 events: ProdEvent[]
25 pending: Pending[]
26 lastBackupAt: number | null
27 }
28 }
29}
30