trash-rm is a Go CLI that wraps rm with trash-first behavior.
Instead of deleting files immediately, it first sends them to the system trash, writes a sidecar history entry, and then calls the real system rm. It is intended to be installed as trash-rm plus an rm -> trash-rm symlink so normal rm usage is transparently protected.
- Moves file targets to the system trash before invoking the real
rm - Supports a bypass flag:
--be-brave-skip-trash - Records trash operations in a local history file
- Provides
--restoreto browse previous trash entries in an interactive TUI and restore them - Resolves the real
rmfrom/bin/rmor/usr/bin/rmto avoid symlink recursion
- Trash backend:
trashfromtrash-cli - Restore backend:
trash-listandtrash-restore - History file:
~/.local/share/trash-rm/history.json
- Trash backend:
osascriptvia Finder - History file:
~/Library/Application Support/trash-rm/history.json
Current implementation note: the restore path is written around trash-list and trash-restore, so trashing on macOS is implemented, but restore appears Linux-oriented in the current codebase.
trash-rm [rm-flags] <files>...
trash-rm --be-brave-skip-trash [rm-flags] <files>...
trash-rm --restore
# after install.sh creates the symlink
rm [rm-flags] <files>...- Parse CLI args.
- Check the platform trash dependency.
- Move each file to trash.
- Append one NDJSON history entry.
- Call the real
rm.
If trashing fails:
- In an interactive terminal, the tool prompts:
Unable to trash before delete. Proceed without trash protection? [y/N] - In a non-interactive context, it aborts automatically without prompting.
A session is considered non-interactive when any of the following is true:
- stdin is not a terminal (e.g. piped input)
- stdout is not a terminal (e.g.
rm foo > file) - stderr is not a terminal (e.g.
rm foo 2>/dev/null) - the
CIenvironment variable is set to"true"(GitHub Actions, GitLab CI, CircleCI, etc.) - If the user declines, it aborts.
- If the user confirms, it skips history logging and runs the real
rm.
If writing the history log fails, it aborts and does not invoke rm.
--be-brave-skip-trash bypasses trash and logging entirely and forwards the rest of the arguments to the real rm.
--restore:
- Reads the history log
- Cross-references entries against
trash-list - Shows remaining entries in a Bubble Tea TUI
- Restores the selected entry
- Rewrites the history file without the restored entry
If no usable history exists, it prints:
No trash history found.
History is stored as newline-delimited JSON:
{"timestamp":"2026-03-17T14:05:00Z","command":"trash-rm -rf old-project","cwd":"/home/user/projects","files":["old-project"]}Fields:
timestamp: RFC 3339 UTC timestampcommand: reconstructed command stringcwd: working directory at deletion timefiles: file operands that were trashed
./install.shBy default this:
- Builds
trash-rm - Installs it to
/usr/local/bin/trash-rm - Creates
/usr/local/bin/rm -> trash-rm
You can override the install directory:
INSTALL_DIR="$HOME/.local/bin" ./install.shInstall trash-cli before use:
sudo apt install trash-cli
# or
sudo dnf install trash-cliThe installer warns if the backend dependency is missing.
Repo layout:
main.go: process setup and dependency wiringinternal/app/app.go: core execution flowinternal/args/args.go: CLI parsinginternal/tty/tty.go: non-interactive session detection (all three streams +$CI)internal/trash/trash.go: Linux/macOS trash backendsinternal/restore/restore.go: restore orchestrationinternal/restore/tui.go: Bubble Tea selector UIinternal/log/log.go: NDJSON history persistenceinternal/rm/rm.go: realrmlookupinternal/platform/platform.go: OS-specific history paths
| # | Issue | Kind |
|---|---|---|
| 1 | macOS --restore not implemented — the restore flow (FilterAlive, RestoreEntry) relies on trash-list and trash-restore from trash-cli, which is Linux-only. Files trashed on macOS via osascript cannot be restored through --restore until a macOS-native restore backend is added. |
Bug / missing feature |
| 2 | args.Parse returns its own "missing file operand" error instead of delegating that case to native rm. The error text matches what rm would print, but the exit behaviour is handled by trash-rm rather than the real rm. |
Minor behavioural gap |
| 3 | Flag parser is intentionally simple — any token starting with - is forwarded as an rm flag; any other token is treated as a file operand. Combined flags such as -rf are passed through correctly, but the parser does not validate rm-flag semantics. |
By design / known limitation |
The repo includes unit tests across all internal packages (go test ./... passes). All six TDD slices are implemented.