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…

A safety net for auto mode: git commands that discard work run after a snapshot you can restore; irreversible ones wait for your yes.
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;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.
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.
/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./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.
hooks/register.tsx 523 lines1import { 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}
523types/index.d.ts 22 lines1export 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