SLOPSHOPPER

garde-prod

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

newpanebandguardcommandtoast
★ 2v0.2.0MITupdated 2026-10-07DarkSawOktay/claude-mods/garde-prod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · garde-prod
│ ┃ Production ✕ › fix the failing auth test and add an audit log call │ ┃ CETTE SESSION │ ┃ Règle « prodHosts » dans /config pour ⏺ Read(src/auth.ts) │ ┃ reconnaître tes serveurs. ⎿ Read 6 lines │ ┃ 08:53 risqué · ok ⏺ Update(src/auth.ts) │ ┃ rm -rf build && git push --force origin main ⎿ 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 │ │ › /prod │ ⎿ garde-prod: 1 commande sensible, 0 à lancer toi-même. │ ⎿ garde-prod: Règle « prodHosts » dans /config pour reconnaître te │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Production
CETTE SESSION Règle « prodHosts » dans /config pour reconnaître tes serveurs. 08:53 risqué · ok rm -rf build && git push --force origin main
README

claude-mods

tests license MIT

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.

ModWhat it showsCommands
suivi-conso — usage5-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 standFor 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 guardEvery 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 freshnessIs 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

Requirements

  • A recent Claude Code (tested with 2.1), in the terminal or the desktop app.
  • 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.

Install

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.

Settings

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.

Themes

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

What the mods do and don't do

  • They read: git, 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.
  • They never change your code, your branches or your server. /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.
  • Nothing leaves your machine: no telemetry, no third-party service.

Develop

claude plugin validate <mod>
claude plugin test <mod>

Rules of the repo, so that every mod can be published:

  • nothing specific to one install in the code: servers, paths and names go through userConfig;
  • every visible text goes through the mod's messages(lang) table, in French and English;
  • calculations live in hooks/logic.ts, with no engine call, tested on their own; rendering lives in hooks/register.tsx, tested on at least two surfaces;
  • a band above the prompt stacks with the other mods' (next(e) drawn below);
  • read-only by default: a mod that refuses or changes something does so behind an explicit setting.

Issues and pull requests are welcome.

License

MIT © Oktay Gençer


Version française

Source 3 files
hooks/register.tsx 201 lines
1// 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}
201
hooks/logic.ts 140 lines
1// 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}
140
types/index.d.ts 30 lines
1/** 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