The full hook code is on GitHub: bph/claude-rm-to-trash
Claude Code is powerful — sometimes a little too powerful. By default, when it runs rm it permanently deletes files. One wrong move and your work is gone. I wanted a safety net: instead of deleting, send files to the macOS Trash so I can recover them if needed.
Here’s how I did it with a Claude Code PreToolUse hook and Claude’s help
What is a PreToolUse hook?
Claude Code supports hooks — shell scripts that run automatically before (or after) a tool is used. A PreToolUse hook can inspect the tool being called, and either allow it, block it, or rewrite the input before it runs.
That last part is the key: we can intercept a Bash call containing rm and silently swap it for /usr/bin/trash.
Step 1: Check that /usr/bin/trash exists
macOS ships with /usr/bin/trash — a command-line tool that moves files to the Trash. Test it:
echo "test" > /tmp/test.txt /usr/bin/trash /tmp/test.txtCheck your Trash folder — the file should be there. If /usr/bin/trash is missing on your system, install an alternative:
brew install trashAnd update the path in the script below accordingly.
Step 2: Create the hook script
Create ~/.claude/hooks/rm-to-trash.py:
#!/usr/bin/env python3"""Claude Code PreToolUse hook: redirect rm to /usr/bin/trash."""import sysimport jsonimport redef allow(cmd=None): out = { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "allow", } } if cmd is not None: out["hookSpecificOutput"]["updatedInput"] = {"command": cmd} print(json.dumps(out))def rewrite_rm(command): """Replace standalone rm [flags] with /usr/bin/trash, stripping flags.""" pattern = r'(?<![a-zA-Z0-9_/])rm((?:\s+-[a-zA-Z]+)*)(\s+|$)' def replacer(m): return "/usr/bin/trash" + m.group(2) return re.sub(pattern, replacer, command)def main(): data = json.load(sys.stdin) tool = data.get("tool_name", "") command = data.get("tool_input", {}).get("command", "") if tool == "Bash" and re.search(r'(?<![a-zA-Z0-9_/])rm(\s+-[a-zA-Z]+)*(\s+|$)', command): allow(rewrite_rm(command)) else: allow()if __name__ == "__main__": main()
Then create a thin shell wrapper at ~/.claude/hooks/rm-to-trash.sh:
#!/bin/bashexec python3 "$(dirname "$0")/rm-to-trash.py"
Make both executable:
chmod +x ~/.claude/hooks/rm-to-trash.shchmod +x ~/.claude/hooks/rm-to-trash.py
Step 3: Register the hook in Claude Code settings
Open ~/.claude/settings.json and add a hooks section:
{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "~/.claude/hooks/rm-to-trash.sh" } ] } ] }}
If you already have other settings in the file, add the hooks block alongside them.
How it works
When Claude Code runs any Bash command, the hook fires first. The hook script receives the command as JSON on stdin:
{ "tool_name": "Bash", "tool_input": { "command": "rm -rf ./old-build" }}
The Python script detects rm as a standalone word (skipping things like npm or trim), strips any flags (-rf, -f, etc. — trash doesn’t need them), and returns the rewritten command:
{ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "allow", "updatedInput": { "command": "/usr/bin/trash ./old-build" } }}
Claude Code then runs /usr/bin/trash ./old-build instead of the original rm. The file lands in your Trash and can be recovered from Finder.
What gets intercepted
| Command | Rewritten to |
|---|---|
rm file.txt | /usr/bin/trash file.txt |
rm -rf ./dir | /usr/bin/trash ./dir |
rm -f file && echo done | /usr/bin/trash file && echo done |
npm install | unchanged |
git rm file.txt | /usr/bin/trash file.txt |
git rm --cached file | /usr/bin/trash file (flags stripped, file stays) |
Caveats
git rm: The hook interceptsgit rmtoo — includinggit rm --cached. For--cached, this is actually safer: the file stays on disk in Trash instead of being untracked and left in place. If you need the git index change, rungit rmmanually in your terminal outside Claude Code.find -exec rm: Therminside afind -execclause will also be rewritten. Trash handles multiple files fine, so this works correctly.- This is global: The hook is registered in
~/.claude/settings.json, so it applies in every project and session.
Verify it’s working
Create a test file and ask Claude Code to delete it:
echo "test" > /tmp/test-claude.txt
Then in Claude Code: “delete /tmp/test-claude.txt”
Check your Trash — the file should be there instead of permanently gone.
It bit me during its own setup
While publishing this hook to GitHub, I asked Claude Code to remove the blog draft from the repo with:
git rm --cached blog-draft.md
The hook immediately intercepted it and turned it into:
git /usr/bin/trash --cached blog-draft.md
Which failed with: git: '/usr/bin/trash' is not a git command
The fix was to use git update-index --force-remove instead, which achieves the same result without the word rm. A good reminder that the hook is genuinely global — it catches everything, including commands you type yourself through Claude Code.
BTW, you can ask Claude to implement it for you, too.

Leave a Reply