SLOPSHOPPER

blast-radius

Dry-runs risky Bash commands (recursive deletes, destructive git, kubectl delete, SQL wipes) and asks Proceed or Cancel first.

newguardstatusprocess
v0.1.0no licenseupdated 2026-10-02cskwork/claude-code-mods/blast-radius
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · blast-radius
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(rm -rf build && git push --force origin main) ⎿ Denied by blast-radius: blast-radius: the user cancelled this command after seeing its dry run. ⚠ Recursiv ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-mods

Mods for Claude Code (2.1.287+): plugins of function hooks that observe, rewrite or answer what Claude Code does and draw their own UI. Based on Getting started with Claude Code mods.

Landing page: https://cskwork.github.io/claude-code-mods/

ModWhat it does
token-weatherA one-line forecast of the context window above the prompt: ☀ Clear → ↯ Compact soon, tokens used, a sparkline of the last 12 turns, and how much the last turn added.
blast-radiusBefore a risky Bash command runs (recursive rm, git reset --hard, git clean -f, force push, git branch -D, git stash drop/clear, kubectl delete, SQL DROP/TRUNCATE, migrations), dry-runs what it would touch and asks Proceed or Cancel. Cancel or dismiss denies the call.
templateStarter mod, not installed: a slash command, a status line entry, a band, and a $.state contract with a test.

Install

Inside Claude Code:

/plugin marketplace add cskwork/claude-code-mods
/plugin install token-weather@claude-code-mods
/plugin install blast-radius@claude-code-mods
/reload-plugins

Make a new mod

scripts/new-mod.sh my-mod "One line about what it does"
claude --plugin-dir "$PWD/my-mod"     # hot-reloads on every save

The script copies template/, renames it, adds it to .claude-plugin/marketplace.json, then runs claude plugin validate and claude plugin test on it.

A mod is three files plus an optional state contract:

my-mod/
├── .claude-plugin/plugin.json   name, version, description, "types"
├── hooks/hooks.json             { "modules": ["./register.tsx"] }
├── hooks/register.tsx           export const register: Register = on => { ... }
├── types/index.d.ts             PluginState contract for $.state values
└── tests/my-mod.test.ts         claude plugin test my-mod

Rules that bite:

  • Every hook is ($, e, next). Call next(e) to pass on, next({ ...e, x }) to rewrite, return without it to answer.
  • Keep values in $.state (atom / read / update); module variables reset on every hot reload.
  • No Node, no DOM: files, processes, clock and UI go through $. UI elements come from $.ui.resolve(e).
  • Test mocks are hooks too: on('process.run', ($, e) => ({ value: ... })).
  • After the engine loads a mod it writes .claude-plugin/types/ beside it, so tsc -p <mod> type-checks it (gitignored).

Check

for m in token-weather blast-radius template; do claude plugin validate $m && claude plugin test $m; done
Source 2 files
hooks/register.ts 50 lines
1// Blast Radius: before a risky Bash command runs, dry-run what it would touch
2// and let the person choose Proceed or Cancel.
3import type { EngineInterface, Register } from 'claude-code'
4
5import { analyze } from './analyze'
6import type { Check, Risk } from './analyze'
7
8const PROCEED = 'Proceed'
9const CANCEL = 'Cancel'
10const MAX_LINES = 8
11
12export const register: Register = on => {
13  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
14    const risks = analyze(e.command)
15    if (risks.length === 0) return next(e)
16
17    const report = (await Promise.all(risks.map(risk => describe($, risk)))).join('\n\n')
18    const question = `Blast radius of: ${e.command.slice(0, 120)}\n\n${report}\n\nRun it?`
19    $.ui.status('blast-radius: waiting for your call')
20    try {
21      const answer = await $.ui.ask(question, { header: 'Blast', options: [PROCEED, CANCEL] })
22      if (answer === PROCEED) return next(e)
23      return { deny: `blast-radius: the user cancelled this command after seeing its dry run.\n${report}` }
24    } catch {
25      return { deny: 'blast-radius: nobody confirmed this risky command (dialog dismissed or no one to ask).' }
26    } finally {
27      $.ui.status(undefined)
28    }
29  })
30}
31
32async function describe($: EngineInterface, risk: Risk) {
33  const lines = await Promise.all(risk.checks.map(check => runCheck($, check)))
34  return [`⚠ ${risk.reason}`, ...lines].join('\n')
35}
36
37async function runCheck($: EngineInterface, check: Check) {
38  try {
39    const ran = await $.process.run(check.argv, { cwd: check.cwd, timeoutMs: 5_000 })
40    const out = ran.stdout.trim().split('\n').filter(Boolean)
41    if (check.countOnly) return `  ${check.label}: ${out.length}${ran.isStdoutTruncated ? '+' : ''}`
42    if (out.length === 0) return `  ${check.label}: ${ran.exitCode === 0 ? '(nothing)' : ran.stderr.trim().split('\n')[0] ?? 'failed'}`
43    const shown = out.slice(0, MAX_LINES).map(l => `    ${l}`)
44    const more = out.length > MAX_LINES ? [`    … ${out.length - MAX_LINES} more`] : []
45    return [`  ${check.label}:`, ...shown, ...more].join('\n')
46  } catch (error) {
47    return `  ${check.label}: dry run failed (${error instanceof Error ? error.message : String(error)})`
48  }
49}
50
hooks/analyze.ts 97 lines
1// Pure: turns a shell command into the risks it carries and the read-only
2// commands that show how far each one reaches. No `$`, so tests import it as is.
3
4export type Check = { label: string; argv: string[]; cwd?: string; countOnly?: true }
5export type Risk = { reason: string; checks: Check[] }
6
7const SQL_WIPE = /\b(drop\s+(table|database|schema)|truncate\s+table|delete\s+from\s+\w+\s*(;|$|"|'))/i
8const MIGRATION = /\b(flyway\s+(migrate|clean)|liquibase\s+update|prisma\s+migrate|alembic\s+upgrade|rails\s+db:|knex\s+migrate|sequelize\s+db:migrate)/i
9const WIDE = ['/', '/*', '~', '~/', '.', '..', '*']
10
11export function analyze(command: string): Risk[] {
12  const risks: Risk[] = []
13  if (SQL_WIPE.test(command)) risks.push({ reason: 'SQL that drops or wipes data (no dry run possible)', checks: [] })
14  if (MIGRATION.test(command)) risks.push({ reason: 'Database migration (no dry run possible)', checks: [] })
15
16  // Follows `cd dir && ...` so each check runs where the command would.
17  let cwd: string | undefined
18  for (const segment of command.split(/&&|\|\||;|\|/)) {
19    const words = segment.trim().split(/\s+/).filter(Boolean)
20    while (words[0] && (words[0] === 'sudo' || /^\w+=/.test(words[0]))) words.shift()
21    const [cmd, ...args] = words
22    if (!cmd) continue
23    if (cmd === 'cd' && args[0]) {
24      cwd = args[0].startsWith('/') || !cwd ? args[0] : `${cwd}/${args[0]}`
25      continue
26    }
27    const risk = cmd === 'rm' ? rm(args) : cmd === 'git' ? git(args) : cmd === 'kubectl' ? kubectl(args) : undefined
28    if (risk) risks.push({ ...risk, checks: risk.checks.map(c => (c.cwd || !cwd ? c : { ...c, cwd })) })
29  }
30  return risks
31}
32
33function rm(args: string[]): Risk | undefined {
34  const isRecursive = args.some(a => a === '--recursive' || /^-[a-zA-Z]*[rR]/.test(a))
35  const paths = args.filter(a => !a.startsWith('-'))
36  if (!isRecursive || paths.length === 0) return undefined
37  const isWide = paths.some(p => WIDE.includes(p))
38  return {
39    reason: `Recursive delete of ${paths.join(' ')}${isWide ? ' (VERY WIDE TARGET)' : ''}`,
40    checks: [
41      { label: 'size', argv: ['du', '-sh', '--', ...paths] },
42      { label: 'files', argv: ['find', ...paths, '-type', 'f'], countOnly: true },
43    ],
44  }
45}
46
47function git(args: string[]): Risk | undefined {
48  let cwd: string | undefined
49  const rest = [...args]
50  while (rest[0]?.startsWith('-')) {
51    if (rest.shift() === '-C') cwd = rest.shift()
52  }
53  const [sub, ...opts] = rest
54  const has = (...names: string[]) => opts.some(o => names.includes(o))
55  const short = (letter: string) => opts.some(o => new RegExp(`^-[a-zA-Z]*${letter}`).test(o))
56  const at = (risk: Risk): Risk => (cwd ? { ...risk, checks: risk.checks.map(c => ({ ...c, cwd })) } : risk)
57
58  if (sub === 'reset' && has('--hard'))
59    return at({
60      reason: 'git reset --hard discards every uncommitted change',
61      checks: [
62        { label: 'uncommitted', argv: ['git', 'status', '--short'] },
63        { label: 'diff', argv: ['git', 'diff', '--stat', 'HEAD'] },
64      ],
65    })
66  if (sub === 'clean' && (short('f') || has('--force')))
67    return at({
68      reason: 'git clean deletes untracked files',
69      checks: [{ label: 'would remove', argv: ['git', 'clean', '-n', ...(short('d') ? ['-d'] : []), ...(short('x') ? ['-x'] : [])] }],
70    })
71  if (sub === 'push' && opts.some(o => o === '-f' || o.startsWith('--force') || o.startsWith('+')))
72    return at({
73      reason: 'Force push rewrites the remote branch',
74      checks: [{ label: 'remote commits lost', argv: ['git', 'log', '--oneline', 'HEAD..@{u}'] }],
75    })
76  if ((sub === 'checkout' && has('--', '.')) || (sub === 'restore' && !has('--staged')))
77    return at({ reason: `git ${sub} overwrites working-tree changes`, checks: [{ label: 'diff', argv: ['git', 'diff', '--stat'] }] })
78  if (sub === 'branch' && (has('-D') || (has('-d', '--delete') && has('--force', '-f')))) {
79    const names = opts.filter(o => !o.startsWith('-'))
80    return at({
81      reason: `Force-deleting branch ${names.join(' ')}`,
82      checks: names.map(n => ({ label: `unpushed on ${n}`, argv: ['git', 'log', '--oneline', n, '--not', '--remotes'] })),
83    })
84  }
85  if (sub === 'stash' && (opts[0] === 'drop' || opts[0] === 'clear'))
86    return at({ reason: `git stash ${opts[0]} loses stashed work`, checks: [{ label: 'stashes', argv: ['git', 'stash', 'list'] }] })
87  return undefined
88}
89
90function kubectl(args: string[]): Risk | undefined {
91  if (args[0] !== 'delete') return undefined
92  return {
93    reason: 'kubectl delete removes cluster resources',
94    checks: [{ label: 'would delete', argv: ['kubectl', ...args, '--dry-run=client', '-o', 'name'] }],
95  }
96}
97