SLOPSHOPPER

danger-guard

A safety net for auto mode: git commands that discard work run after a snapshot you can restore; irreversible ones (force push, rm -rf outside the project…

newpaneguardcommandtoastprocess
★ 1v1.0.0MITupdated 2026-10-26ArtyaEXE/claude-mods/danger-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · danger-guard
│ ┃ danger-guard snapshots ✕ › fix the failing auth test and add an audit log call │ ┃ No snapshots: no dangerous git command has │ ┃ run in this repository yet. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(rm -rf build && git push --force origin main) │ ⎿ Denied by danger-guard: Not run: danger-guard asked the u │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /guard │ ⎿ danger-guard: Opened the snapshots pane. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · danger-guard snapshots
No snapshots: no dangerous git command has run in this repository yet.
README

danger-guard

A safety net for auto mode: git commands that discard work run after a snapshot you can restore; irreversible ones wait for your yes.

English · Русский

English

In auto mode Claude runs commands without asking, and a few of them cannot be taken back. This mod sorts them in two:

Commands that discard work in git run after a snapshot, with no question: git reset --hard, git checkout -- . / -f, git restore, git switch --discard-changes, git clean -f, git stash drop / clear, git branch -D. Before such a command the whole working tree, untracked files included, is saved as a commit under refs/danger-guard/ (through an index of its own: your index, branches and stashes are untouched); a force-deleted branch keeps its tip, a dropped stash entry its commit. Claude is told how to bring files back; /guard lists the snapshots with Restore and Delete.

Irreversible commands wait for your answer in Claude Code's own question dialog: Cancel (first, so a dialog that closes by itself never means yes), Run, Run these this session:

  • git push --force / -f / +refspec (not --force-with-lease), deleting a remote branch or tag, git filter-branch / filter-repo;
  • a recursive delete (rm -r, Remove-Item -Recurse, rd /s) outside the project and the temp folders, or of a drive root or the home folder;
  • DROP TABLE / DATABASE / SCHEMA, TRUNCATE TABLE through a database client (psql, mysql, sqlite3, sqlcmd...);
  • format, diskpart, Clear-Disk; terraform destroy, kubectl delete, docker system prune, docker volume rm; npm / pnpm / yarn / cargo publish, gh repo delete; shutdown, Restart-Computer.

The command's own cd and git's -C are followed, so a snapshot is taken in the repository the command acts on. With no one to ask (claude -p) an irreversible command is not run.

What it touches

Reads every Bash and PowerShell command before it runs. Before a git command that discards work: runs git read-tree, git add -A, git write-tree, git commit-tree with GIT_INDEX_FILE pointing at .git/danger-guard.index, and git update-ref refs/danger-guard/.... Asks you through the question dialog before an irreversible command. On Restore: git restore --source=<snapshot> --worktree -- ., git branch <name> <sha> or git stash store; on Delete: git update-ref -d. Adds the /guard command.

Settings (/config)

  • language: auto (default; follows LANG, else the Windows UI language or regional format), ru or en.
  • irreversible: ask (default) or deny: never run them, and tell Claude to ask you to run them yourself.

Install

/plugin install danger-guard --marketplace ArtyaEXE/claude-mods

Part of claude-mods. MIT license.


Русский

Страховка для авто-режима: git-команды, выбрасывающие работу, идут после снимка, который можно восстановить; необратимые ждут вашего «да».

В авто-режиме Claude запускает команды не спрашивая, и некоторые из них назад не отыграть. Мод делит их на две группы:

Git-команды, выбрасывающие работу, идут после снимка, без вопросов: git reset --hard, git checkout -- . / -f, git restore, git switch --discard-changes, git clean -f, git stash drop / clear, git branch -D. Перед такой командой всё рабочее дерево, включая неотслеживаемые файлы, сохраняется коммитом в refs/danger-guard/ (через отдельный индекс: ваш индекс, ветки и stash не трогаются); у принудительно удалённой ветки сохраняется её вершина, у удалённой записи stash — её коммит. Claude получает подсказку, как вернуть файлы; /guard показывает снимки с кнопками Восстановить и Удалить.

Необратимые команды ждут вашего ответа в штатном окне вопроса Claude Code: Отменить (первым, чтобы закрывшийся сам диалог никогда не означал «да»), Выполнить, Выполнять в этой сессии:

  • git push --force / -f / +refspec (но не --force-with-lease), удаление ветки или тега на remote, git filter-branch / filter-repo;
  • рекурсивное удаление (rm -r, Remove-Item -Recurse, rd /s) за пределами проекта и временных папок, а также корня диска или домашней папки;
  • DROP TABLE / DATABASE / SCHEMA, TRUNCATE TABLE через клиент базы (psql, mysql, sqlite3, sqlcmd...);
  • format, diskpart, Clear-Disk; terraform destroy, kubectl delete, docker system prune, docker volume rm; npm / pnpm / yarn / cargo publish, gh repo delete; shutdown, Restart-Computer.

Мод учитывает cd внутри самой команды и -C у git, поэтому снимок делается в том репозитории, где команда работает. Если спросить некого (claude -p), необратимая команда не запускается.

Что мод делает в системе

Читает каждую команду Bash и PowerShell до запуска. Перед git-командой, выбрасывающей работу, выполняет git read-tree, git add -A, git write-tree, git commit-tree с GIT_INDEX_FILE, указывающим на .git/danger-guard.index, и git update-ref refs/danger-guard/.... Перед необратимой командой спрашивает вас через окно вопроса. По Восстановить: git restore --source=<снимок> --worktree -- ., git branch <имя> <sha> или git stash store; по Удалить: git update-ref -d. Добавляет команду /guard.

Настройки (/config)

  • language: auto (по умолчанию; по LANG, иначе по языку интерфейса или региональному формату Windows), ru или en.
  • irreversible: ask (по умолчанию) или deny: не запускать их никогда и просить Claude, чтобы он предложил вам выполнить их самим.

Установка

/plugin install danger-guard --marketplace ArtyaEXE/claude-mods

Часть claude-mods. Лицензия MIT.

Source 2 files
hooks/register.tsx 523 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Snapshot } from '../types'
5
6type Lang = 'ru' | 'en'
7
8// `ru` / `en` as set; `auto`: the locale variables first, then Windows' UI language or regional format.
9async function resolveLang($: EngineInterface, setting: unknown): Promise<Lang> {
10  if (setting === 'ru' || setting === 'en') return setting
11  const env = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
12  if (env) return env.toLowerCase().startsWith('ru') ? 'ru' : 'en'
13  try {
14    const ran = await $.process.run(
15      ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', '(Get-UICulture).Name; (Get-Culture).Name'],
16      { timeoutMs: 10_000 },
17    )
18    if (ran.exitCode === 0 && /^ru\b/im.test(ran.stdout)) return 'ru'
19  } catch {
20    // Not Windows: English.
21  }
22
23  return 'en'
24}
25
26const PANE = 'danger-guard'
27const snapshots = atom({ plugin: 'danger-guard', key: 'snapshots' } as const, [] as Snapshot[], { shape: 'snapshots-1' })
28
29// What a command would do that cannot be taken back, and how the mod answers it.
30type Verdict =
31  | { kind: 'snapshot'; what: 'worktree' }
32  | { kind: 'snapshot'; what: 'branch'; names: string[] }
33  | { kind: 'snapshot'; what: 'stash'; entries: string[] }
34  | { kind: 'ask'; rule: Rule }
35
36type Rule = 'forcePush' | 'remoteDelete' | 'rewrite' | 'removeOutside' | 'sql' | 'disk' | 'cloud' | 'publish' | 'power'
37
38const STRINGS = {
39  ru: {
40    rules: {
41      forcePush: 'принудительный push перепишет историю на remote',
42      remoteDelete: 'удаление ветки или тега на remote',
43      rewrite: 'переписывание всей истории репозитория',
44      removeOutside: 'рекурсивное удаление за пределами проекта (или корня диска, домашней папки)',
45      sql: 'DROP / TRUNCATE в базе данных',
46      disk: 'форматирование или разметка диска',
47      cloud: 'удаление облачной или контейнерной инфраструктуры',
48      publish: 'публикация пакета или удаление репозитория',
49      power: 'выключение или перезагрузка компьютера',
50    } as Record<Rule, string>,
51    question: (why: string, command: string) => `Claude хочет выполнить необратимое — ${why}:\n\n${command}\n\nВыполнить?`,
52    run: 'Выполнить',
53    runAlways: 'Выполнять в этой сессии',
54    cancel: 'Отменить',
55    header: 'Опасно',
56    saved: (command: string) => `danger-guard: сохранил снимок перед ${command}. Вернуть: /guard`,
57    command: 'Снимки, сделанные перед опасными командами, и их восстановление',
58    paneTitle: 'Снимки danger-guard',
59    opened: 'Открыл панель снимков.',
60    empty: 'Снимков нет: опасных git-команд в этом репозитории ещё не было.',
61    noGit: 'Не git-репозиторий: снимков здесь не бывает.',
62    kinds: { worktree: 'файлы', branch: 'ветка', stash: 'stash' } as Record<Snapshot['kind'], string>,
63    restore: 'Восстановить',
64    drop: 'Удалить',
65    confirm: (s: Snapshot) =>
66      s.kind === 'worktree'
67        ? `Вернуть файлы к снимку перед «${s.command}»? Текущие изменения в этих файлах будут перезаписаны.`
68        : s.kind === 'branch'
69          ? `Вернуть ветку ${s.name}?`
70          : 'Вернуть удалённую запись stash?',
71    yes: 'Да',
72    no: 'Нет',
73    restored: 'danger-guard: восстановлено',
74    failed: (why: string) => `danger-guard: не получилось: ${why}`,
75  },
76  en: {
77    rules: {
78      forcePush: 'a force push rewrites history on the remote',
79      remoteDelete: 'deleting a branch or tag on the remote',
80      rewrite: "rewriting the whole repository's history",
81      removeOutside: 'a recursive delete outside the project (or of a drive root or the home folder)',
82      sql: 'DROP / TRUNCATE in a database',
83      disk: 'formatting or partitioning a disk',
84      cloud: 'deleting cloud or container infrastructure',
85      publish: 'publishing a package or deleting a repository',
86      power: 'shutting down or restarting the computer',
87    } as Record<Rule, string>,
88    question: (why: string, command: string) => `Claude wants to run something irreversible — ${why}:\n\n${command}\n\nRun it?`,
89    run: 'Run',
90    runAlways: 'Run these this session',
91    cancel: 'Cancel',
92    header: 'Danger',
93    saved: (command: string) => `danger-guard: saved a snapshot before ${command}. To restore: /guard`,
94    command: 'Snapshots taken before dangerous commands, and restoring them',
95    paneTitle: 'danger-guard snapshots',
96    opened: 'Opened the snapshots pane.',
97    empty: 'No snapshots: no dangerous git command has run in this repository yet.',
98    noGit: 'Not a git repository: there are no snapshots here.',
99    kinds: { worktree: 'files', branch: 'branch', stash: 'stash' } as Record<Snapshot['kind'], string>,
100    restore: 'Restore',
101    drop: 'Delete',
102    confirm: (s: Snapshot) =>
103      s.kind === 'worktree'
104        ? `Bring the files back to the snapshot before "${s.command}"? Current changes to those files are overwritten.`
105        : s.kind === 'branch'
106          ? `Bring back branch ${s.name}?`
107          : 'Bring back the dropped stash entry?',
108    yes: 'Yes',
109    no: 'No',
110    restored: 'danger-guard: restored',
111    failed: (why: string) => `danger-guard: that did not work: ${why}`,
112  },
113}
114
115// What the model is told, in English like every tool result.
116const FOR_MODEL = {
117  declined: (why: string) =>
118    `Not run: danger-guard asked the user, who declined (${why}). Do not retry it or work around it; ask the user how they want to proceed.`,
119  denied: (why: string) =>
120    `Not run: danger-guard does not let this run (${why}). If it is really needed, ask the user to run it themselves.`,
121  saved: (sha: string) =>
122    `danger-guard: before this command the working tree (untracked files included) was saved as commit ${sha.slice(0, 12)}. ` +
123    `To bring files back: \`git restore --source=${sha.slice(0, 12)} --worktree -- <paths>\`.`,
124  savedRefs: (what: string) => `danger-guard: ${what} saved before this command; the user can bring it back with /guard.`,
125}
126
127// The words of each stage of a command line: split at ; & | newlines and brackets outside quotes, quotes removed.
128function stagesOf(command: string): string[][] {
129  const stages: string[][] = []
130  let words: string[] = []
131  let word = ''
132  let has = false
133  let quote = ''
134  const endWord = () => {
135    if (has) words.push(word)
136    word = ''
137    has = false
138  }
139  const endStage = () => {
140    endWord()
141    if (words.length > 0) stages.push(words)
142    words = []
143  }
144  for (let i = 0; i < command.length; i++) {
145    const ch = command.charAt(i)
146    if (quote !== '') {
147      if (ch === quote) quote = ''
148      else word += ch
149      continue
150    }
151    if (ch === '"' || ch === "'") {
152      quote = ch
153      has = true
154    } else if (ch === '#' && !has) {
155      // A comment: to the end of the line.
156      const end = command.indexOf('\n', i)
157      i = end === -1 ? command.length : end - 1
158    } else if (ch === '\n' || ';&|(){}'.includes(ch)) {
159      endStage()
160    } else if (/\s/.test(ch)) {
161      endWord()
162    } else {
163      word += ch
164      has = true
165    }
166  }
167  endStage()
168
169  return stages
170}
171
172// The command word without a path or .exe, lower-cased; leading `sudo`, `env`, `VAR=x` and `&` skipped.
173function head(words: string[]): { name: string; args: string[] } {
174  let i = 0
175  while (i < words.length && (/^(sudo|env|command|exec|&)$/i.test(words[i] ?? '') || /^[A-Za-z_]\w*=/.test(words[i] ?? ''))) i++
176  const name = (words[i] ?? '').replace(/^.*[\\/]/, '').replace(/\.exe$/i, '').toLowerCase()
177
178  return { name, args: words.slice(i + 1) }
179}
180
181const has = (args: string[], ...flags: string[]) => args.some(a => flags.includes(a))
182const shortFlag = (args: string[], letter: string) => args.some(a => /^-[A-Za-z]+$/.test(a) && a.includes(letter))
183
184function gitVerdict(args: string[]): Verdict | null {
185  // Global options before the subcommand.
186  let i = 0
187  while (i < args.length && (args[i] ?? '').startsWith('-')) i += /^-[Cc]$/.test(args[i] ?? '') ? 2 : 1
188  const sub = args[i] ?? ''
189  const rest = args.slice(i + 1)
190  const plain = rest.filter(a => !a.startsWith('-'))
191
192  switch (sub) {
193    case 'reset':
194      return has(rest, '--hard', '--merge') ? { kind: 'snapshot', what: 'worktree' } : null
195    case 'checkout':
196      return has(rest, '-f', '--force', '--', '.') ? { kind: 'snapshot', what: 'worktree' } : null
197    case 'switch':
198      return has(rest, '-f', '--force', '--discard-changes') ? { kind: 'snapshot', what: 'worktree' } : null
199    case 'restore':
200      return !has(rest, '--staged', '-S') || has(rest, '--worktree', '-W') ? { kind: 'snapshot', what: 'worktree' } : null
201    case 'clean':
202      return (shortFlag(rest, 'f') || has(rest, '--force')) && !shortFlag(rest, 'n') && !has(rest, '--dry-run')
203        ? { kind: 'snapshot', what: 'worktree' }
204        : null
205    case 'stash':
206      if (rest[0] === 'drop') return { kind: 'snapshot', what: 'stash', entries: [rest.find((a, k) => k > 0 && !a.startsWith('-')) ?? 'stash@{0}'] }
207      if (rest[0] === 'clear') return { kind: 'snapshot', what: 'stash', entries: [] }
208
209      return null
210    case 'branch': {
211      const force = has(rest, '-D') || (has(rest, '--delete', '-d') && has(rest, '--force', '-f')) || rest.some(a => /^-[a-z]*D/.test(a))
212      return force && plain.length > 0 ? { kind: 'snapshot', what: 'branch', names: plain } : null
213    }
214    case 'push': {
215      const lease = rest.some(a => a.startsWith('--force-with-lease') || a === '--force-if-includes')
216      if (has(rest, '--force', '--mirror') || shortFlag(rest, 'f') || (!lease && plain.some(a => a.startsWith('+')))) return { kind: 'ask', rule: 'forcePush' }
217      if (has(rest, '--delete', '-d', '--prune') || plain.some(a => a.startsWith(':'))) return { kind: 'ask', rule: 'remoteDelete' }
218
219      return null
220    }
221    case 'filter-branch':
222    case 'filter-repo':
223      return { kind: 'ask', rule: 'rewrite' }
224    default:
225      return null
226  }
227}
228
229type Places = { root: string; cwd: string; home: string; temp: string[] }
230
231// A path in one spelling: forward slashes, lower case, /c/... as c:/..., `.` and `..` resolved against cwd.
232function normalize(path: string, p: Places): string | undefined {
233  let s = path
234    .replace(/^~(?=[\\/]|$)/, p.home)
235    .replace(/^\$(?:env:)?(?:HOME|USERPROFILE)\b/i, p.home)
236    .replace(/^%USERPROFILE%/i, p.home)
237  if (/^[$%]/.test(s)) return undefined
238  s = s.replace(/\\/g, '/').toLowerCase()
239  s = s.replace(/^\/([a-z])(?=\/|$)/, '$1:')
240  if (!/^[a-z]:/.test(s) && !s.startsWith('/')) s = `${p.cwd}/${s}`
241  const parts: string[] = []
242  for (const part of s.split('/')) {
243    if (part === '' || part === '.') continue
244    if (part === '..') parts.pop()
245    else parts.push(part)
246  }
247  const out = parts.join('/')
248
249  return /^[a-z]:$/.test(out) ? `${out}/` : out
250}
251
252const inside = (path: string, dir: string) => dir !== '' && (path === dir || path.startsWith(`${dir.replace(/\/$/, '')}/`))
253
254// A recursive delete is dangerous outside the project and the temp folders, and always at a drive root or the home folder.
255function dangerousTarget(target: string, p: Places): boolean {
256  // `dir/*` is `dir`'s content: judged as `dir` (so `/*` is the root itself).
257  const path = normalize(target.replace(/([\\/])\.?\*$/, '$1'), p)
258  if (path === undefined) return false
259  if (/^[a-z]:\/?$/.test(path) || path === '' || path === p.home) return true
260  if (p.temp.some(t => inside(path, t))) return false
261
262  return !inside(path, p.root)
263}
264
265function removeVerdict(name: string, args: string[], p: Places): Verdict | null {
266  let recursive = false
267  let targets: string[] = []
268  if (name === 'rm' && (shortFlag(args, 'r') || shortFlag(args, 'R') || has(args, '--recursive'))) {
269    recursive = true
270    targets = args.filter(a => !a.startsWith('-'))
271  } else if (/^(remove-item|ri|rm|del|erase|rmdir|rd)$/.test(name) && args.some(a => /^-r(ec(urse?)?)?$/i.test(a))) {
272    recursive = true
273    targets = args.filter((a, k) => !a.startsWith('-') && !/^-(filter|include|exclude)$/i.test(args[k - 1] ?? ''))
274  } else if (/^(rd|rmdir|del)$/.test(name) && args.some(a => /^\/s$/i.test(a))) {
275    recursive = true
276    targets = args.filter(a => !a.startsWith('/') || /^\/[^a-z]|^\/[a-z]\//i.test(a))
277  }
278
279  return recursive && targets.some(t => dangerousTarget(t, p)) ? { kind: 'ask', rule: 'removeOutside' } : null
280}
281
282// Database clients whose command line can carry SQL.
283const DB_CLIENT = /^(psql|mysql|mariadb|sqlite3|sqlcmd|invoke-sqlcmd|duckdb|clickhouse-client|cockroach|mongosh)$/
284
285// The first thing in the command that cannot be taken back, with how to answer it and the folder it runs in
286// (after the command's own `cd`, or git's -C); null when there is none.
287function verdictOf(command: string, p: Places): { verdict: Verdict; cwd: string } | null {
288  let dir = p.cwd
289  for (const words of stagesOf(command)) {
290    const { name, args } = head(words)
291    if (/^(cd|chdir|pushd|set-location|sl)$/.test(name)) {
292      const to = args.find(a => !a.startsWith('-'))
293      if (to !== undefined) dir = normalize(to, { ...p, cwd: dir }) ?? dir
294      continue
295    }
296    const here = { ...p, cwd: dir }
297    const line = words.join(' ').toLowerCase()
298    const dashC = name === 'git' ? args.indexOf('-C') : -1
299    const verdict =
300      name === 'git'
301        ? gitVerdict(args)
302        : removeVerdict(name, args, here) ??
303          (DB_CLIENT.test(name) && /\bdrop\s+(table|database|schema)\b|\btruncate\s+table\b/.test(line)
304            ? { kind: 'ask' as const, rule: 'sql' as const }
305            : null) ??
306          ((name === 'format' && /^[a-z]:$/i.test(args[0] ?? '')) || name === 'diskpart' || name.startsWith('mkfs') || /^(format-volume|clear-disk|initialize-disk)$/.test(name)
307            ? { kind: 'ask' as const, rule: 'disk' as const }
308            : null) ??
309          ((name === 'terraform' && (args[0] === 'destroy' || (args[0] === 'apply' && has(args, '-destroy')))) ||
310          (name === 'kubectl' && args[0] === 'delete') ||
311          (name === 'docker' && ((args[0] === 'system' && args[1] === 'prune') || (args[0] === 'volume' && /^(rm|prune)$/.test(args[1] ?? ''))))
312            ? { kind: 'ask' as const, rule: 'cloud' as const }
313            : null) ??
314          ((/^(npm|pnpm|yarn|cargo)$/.test(name) && args[0] === 'publish') || (name === 'gh' && args[0] === 'repo' && args[1] === 'delete')
315            ? { kind: 'ask' as const, rule: 'publish' as const }
316            : null) ??
317          (/^(shutdown|stop-computer|restart-computer)$/.test(name) ? { kind: 'ask' as const, rule: 'power' as const } : null)
318    if (verdict !== null) {
319      const gitDir = dashC === -1 ? undefined : normalize(args[dashC + 1] ?? '.', here)
320
321      return { verdict, cwd: gitDir ?? dir }
322    }
323  }
324
325  return null
326}
327
328let lang: Lang = 'en'
329let mode: 'ask' | 'deny' = 'ask'
330// Rules the person allowed for the rest of the session.
331const allowed = new Set<Rule>()
332
333const IDENTITY = { GIT_AUTHOR_NAME: 'danger-guard', GIT_AUTHOR_EMAIL: 'danger-guard@localhost', GIT_COMMITTER_NAME: 'danger-guard', GIT_COMMITTER_EMAIL: 'danger-guard@localhost' }
334
335// git's trimmed output, undefined when it failed; in `cwd` when given, else the session's folder.
336async function git($: EngineInterface, args: string[], env?: Record<string, string>, cwd?: string): Promise<string | undefined> {
337  try {
338    const ran = await $.process.run(['git', ...args], { env, cwd, timeoutMs: 60_000 })
339
340    return ran.exitCode === 0 ? ran.stdout.trim() : undefined
341  } catch {
342    return undefined
343  }
344}
345
346const stamp = () => String(Date.now())
347const short = (s: string, n = 120) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
348
349// Saves what the command would take away; the model note to add after it runs, '' when nothing was saved.
350async function save($: EngineInterface, v: Verdict & { kind: 'snapshot' }, command: string, cwd: string): Promise<string> {
351  const subject = `danger-guard: ${short(command.replace(/\s+/g, ' '), 200)}`
352  if (v.what === 'worktree') {
353    // The whole working tree, untracked files included, through an index of its own: the real index is untouched.
354    const index = await git($, ['rev-parse', '--git-path', 'danger-guard.index'], undefined, cwd)
355    if (index === undefined) return ''
356    const env = { ...IDENTITY, GIT_INDEX_FILE: index }
357    const parent = await git($, ['rev-parse', '--verify', '-q', 'HEAD'], undefined, cwd)
358    if ((await git($, parent === undefined ? ['read-tree', '--empty'] : ['read-tree', 'HEAD'], env, cwd)) === undefined) return ''
359    if ((await git($, ['add', '-A'], env, cwd)) === undefined) return ''
360    const tree = await git($, ['write-tree'], env, cwd)
361    if (tree === undefined) return ''
362    const sha = await git($, ['commit-tree', tree, ...(parent === undefined ? [] : ['-p', parent]), '-m', subject], env, cwd)
363    if (sha === undefined || (await git($, ['update-ref', `refs/danger-guard/worktree/${stamp()}`, sha], undefined, cwd)) === undefined) return ''
364
365    return FOR_MODEL.saved(sha)
366  }
367  if (v.what === 'branch') {
368    let saved = 0
369    for (const name of v.names) {
370      const sha = await git($, ['rev-parse', '--verify', '-q', `refs/heads/${name}`], undefined, cwd)
371      if (sha !== undefined && (await git($, ['update-ref', '-m', subject, `refs/danger-guard/branch/${stamp()}/${name}`, sha], undefined, cwd)) !== undefined) saved++
372    }
373
374    return saved > 0 ? FOR_MODEL.savedRefs(`the tip of ${v.names.join(', ')}`) : ''
375  }
376  const entries = v.entries.length > 0 ? v.entries : ((await git($, ['stash', 'list', '--format=%gd'], undefined, cwd)) ?? '').split('\n').filter(s => s !== '')
377  let saved = 0
378  for (const entry of entries) {
379    const sha = await git($, ['rev-parse', '--verify', '-q', entry], undefined, cwd)
380    if (sha !== undefined && (await git($, ['update-ref', '-m', subject, `refs/danger-guard/stash/${stamp()}-${saved}`, sha], undefined, cwd)) !== undefined) saved++
381  }
382
383  return saved > 0 ? FOR_MODEL.savedRefs(saved === 1 ? 'the stash entry' : `${saved} stash entries`) : ''
384}
385
386const folder = (s: string | undefined) => (s ?? '').replace(/\\/g, '/').replace(/\/$/, '').toLowerCase()
387
388// The home and temp folders, read once a session; the project and working folders, at each call.
389let home = ''
390let temp: string[] = []
391
392async function readFolders($: EngineInterface) {
393  home = folder((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')))
394  temp = [folder(await $.env.get('TEMP')), folder(await $.env.get('TMP')), '/tmp', 'c:/windows/temp'].filter(t => t !== '')
395}
396
397async function places($: EngineInterface): Promise<Places> {
398  return { root: folder(await $.session.root()), cwd: folder(await $.session.cwd()), home, temp }
399}
400
401async function listSnapshots($: EngineInterface) {
402  const out = await git($, ['for-each-ref', 'refs/danger-guard', '--sort=-refname', '--format=%(refname)%09%(objectname)%09%(subject)'])
403  const list: Snapshot[] = []
404  for (const line of (out ?? '').split('\n').filter(l => l !== '')) {
405    const [ref = '', sha = '', subject = ''] = line.split('\t')
406    const m = /^refs\/danger-guard\/(worktree|branch|stash)\/(\d+)(?:-\d+)?(?:\/(.+))?$/.exec(ref)
407    if (m === null) continue
408    const kind = m[1] as Snapshot['kind']
409    // A branch or stash ref keeps the command in its reflog message; its subject is the commit's own.
410    const command = kind === 'worktree' ? subject.replace(/^danger-guard: /, '') : ''
411    list.push({ ref, sha, at: Math.floor(Number(m[2]) / 1000), kind, command, name: m[3] ?? '' })
412  }
413  await update($, snapshots, () => list)
414}
415
416async function restore($: EngineInterface, s: Snapshot) {
417  const L = STRINGS[lang]
418  let answer = ''
419  try {
420    answer = await $.ui.ask(L.confirm(s), { options: [L.yes, L.no], header: L.header })
421  } catch {
422    return
423  }
424  if (answer !== L.yes) return
425  const top = await git($, ['rev-parse', '--show-toplevel'])
426  const ok =
427    s.kind === 'worktree'
428      ? await git($, ['-C', top ?? '.', 'restore', `--source=${s.sha}`, '--worktree', '--', '.'])
429      : s.kind === 'branch'
430        ? await git($, ['branch', s.name, s.sha])
431        : await git($, ['stash', 'store', '-m', 'danger-guard restore', s.sha])
432  $.ui.toast(ok === undefined ? L.failed(s.kind === 'branch' ? `git branch ${s.name}` : 'git') : L.restored)
433}
434
435async function drop($: EngineInterface, s: Snapshot) {
436  await git($, ['update-ref', '-d', s.ref])
437  await listSnapshots($)
438}
439
440const when = (at: number) => {
441  const d = new Date(at * 1000)
442  const two = (n: number) => String(n).padStart(2, '0')
443
444  return `${two(d.getDate())}.${two(d.getMonth() + 1)} ${two(d.getHours())}:${two(d.getMinutes())}`
445}
446
447export const register: Register = (on, options) => {
448  const o = options as { language?: unknown; irreversible?: unknown }
449  mode = o.irreversible === 'deny' ? 'deny' : 'ask'
450
451  on('session.start', async ($, e, next) => {
452    const started = await next(e)
453    lang = await resolveLang($, o.language)
454    allowed.clear()
455    await readFolders($)
456    await $.command.register({ name: 'guard', description: STRINGS[lang].command })
457
458    return started
459  })
460
461  on('command.run', { command: 'guard' }, async $ => {
462    await listSnapshots($)
463    await $.ui.open({ id: PANE, title: STRINGS[lang].paneTitle, focus: true, closeOnEscape: true })
464
465    return { text: STRINGS[lang].opened }
466  })
467
468  on('tool.call', async ($, e, next) => {
469    if (e.tool !== 'Bash' && e.tool !== 'PowerShell') return next(e)
470    const command = String((e as { command?: unknown }).command ?? '')
471    const found = verdictOf(command, await places($))
472    if (found === null) return next(e)
473    const v = found.verdict
474    const L = STRINGS[lang]
475
476    if (v.kind === 'ask') {
477      const why = L.rules[v.rule]
478      if (!allowed.has(v.rule)) {
479        if (mode === 'deny') return { deny: FOR_MODEL.denied(STRINGS.en.rules[v.rule]) }
480        let answer = ''
481        try {
482          answer = await $.ui.ask(L.question(why, short(command, 400)), { options: [L.cancel, L.run, L.runAlways], header: L.header })
483        } catch {
484          // Dismissed, or nobody to ask (claude -p): not run.
485        }
486        if (answer === L.runAlways) allowed.add(v.rule)
487        else if (answer !== L.run) return { deny: FOR_MODEL.declined(STRINGS.en.rules[v.rule]) }
488      }
489
490      return next(e)
491    }
492
493    const note = await save($, v, command, found.cwd)
494    if (note !== '') $.ui.toast(L.saved(short(command, 60)))
495    const ran = await next(e)
496    if (note === '' || ran.deny !== undefined) return ran
497
498    return { ...ran, context: [...(ran.context ?? []), note] }
499  })
500
501  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
502    const { Box, Button, Text } = $.ui.resolve(e)
503    const L = STRINGS[lang]
504    const list = await read($, snapshots)
505    const width = Math.max(20, e.props.bodyColumns - 40)
506
507    return (
508      <Box flexDirection="column">
509        {list.length === 0 && <Text dimColor>{L.empty}</Text>}
510        {list.map(s => (
511          <Box flexDirection="row" gap={1}>
512            <Text dimColor>{when(s.at)}</Text>
513            <Text color="yellow">{L.kinds[s.kind]}</Text>
514            <Text wrap="truncate">{short(s.kind === 'branch' ? s.name : s.kind === 'worktree' ? s.command : s.sha.slice(0, 12), width)}</Text>
515            <Button key={`restore:${s.ref}`} plain label={L.restore} onPress={() => restore($, s)} />
516            <Button key={`drop:${s.ref}`} plain dimColor label={L.drop} onPress={() => drop($, s)} />
517          </Box>
518        ))}
519      </Box>
520    )
521  })
522}
523
types/index.d.ts 22 lines
1export type Snapshot = {
2  // The ref that keeps it: refs/danger-guard/<kind>/<time>[/<branch>].
3  ref: string
4  sha: string
5  // Seconds since the epoch.
6  at: number
7  // worktree: the files before a command that discards changes; branch: a deleted branch's tip; stash: a dropped stash entry.
8  kind: 'worktree' | 'branch' | 'stash'
9  // The command it was taken before.
10  command: string
11  // The branch's name, for kind branch.
12  name: string
13}
14
15declare module 'claude-code' {
16  interface PluginState {
17    'danger-guard': {
18      snapshots: Shaped<Snapshot[]>
19    }
20  }
21}
22