rm is permanent: a typo or bad glob can wipe files with no recovery path. A safer workflow keeps the familiar rm interface but routes deletions through the OS trash first — and, using send2trash, works identically on macOS and Linux (and Windows).

§1. Backend: send2trash

send2trash moves files to the operating system’s native trash — ~/.Trash on macOS, ~/.local/share/Trash on Linux, the Recycle Bin on Windows. Because it uses the native trash, terminal deletions also show up in Finder or the desktop file manager, where they can be restored.

1
2
python -m pip install --user Send2Trash
# or: pipx install send2trash

§2. Frontend: rmtrash

rmtrash wraps send2trash and accepts the usual rm flags. One deliberate difference: files and folders are trashed identically — a directory goes to the trash without -r. From rm’s perspective a file and a folder are the same, so -r is pure noise; the wrapper drops that requirement while still accepting -r/-R for compatibility. It also refuses /, ., .. and honors rm’s prompting (-i always, -I once for >3 items or any directory).

Save the script (full source in the Appendix) as ~/bin/rmtrash and make it executable:

1
chmod +x ~/bin/rmtrash

§3. Wire up the alias

1
alias rm=~/bin/rmtrash

rm foo.txt and rm some-dir both go to the trash.

§4. Inspect and restore

Use the OS tools already available: Finder’s Trash → “Put Back”, or ls ~/.Trash on macOS / ls ~/.local/share/Trash/files on Linux. No separate trash commands to learn.

§5. Cleanup

The OS already manages and empties the trash — empty it manually from the file manager when convenient. No cron or launchd job needed.

The result is a safer, cross-platform rm with a bounded, OS-managed trash.

§Appendix: the rmtrash script

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
#!/usr/bin/env python3
"""rm-ish command that sends files and directories to the OS trash."""

from __future__ import annotations

import os
import stat
import sys
from dataclasses import dataclass
from pathlib import Path

try:
from send2trash import send2trash
except ImportError: # pragma: no cover - exercised only on missing dependency
print(
"rmtrash: Python package 'Send2Trash' is not installed.\n"
"Install it with: python -m pip install --user Send2Trash",
file=sys.stderr,
)
sys.exit(4)


APP_NAME = Path(sys.argv[0]).name
VERSION = "2.0-send2trash"


@dataclass
class Options:
force: bool = False
interactive: str = "default"
prompt_once: bool = False
verbose: bool = False
paths: list[str] | None = None


def usage(stream=sys.stdout) -> None:
print(f"Usage: {APP_NAME} [OPTION]... FILE...", file=stream)


def help_text() -> None:
usage()
print(
"""
Send files and directories to the operating system trash.

This intentionally accepts directories without requiring -r, matching the
safe-trash behavior preferred for an rm alias.

Options:
-f, --force ignore nonexistent files, never prompt
-i prompt before every trash operation
-I prompt once before trashing many items or directories
--interactive[=WHEN] prompt according to WHEN: never, once, or always
-r, -R, --recursive accepted for rm compatibility
-d, --dir accepted for rm compatibility
--one-file-system accepted for rm compatibility
--preserve-root refuse to trash / (default)
--no-preserve-root accepted, but / is still refused
-v, --verbose explain what is being done
--help display this help and exit
--version output version information and exit
""".strip()
)


def parse_interactive(value: str | None) -> str:
if value in (None, "", "always", "yes"):
return "always"
if value in ("never", "no", "none"):
return "never"
if value == "once":
return "once"
print(f"{APP_NAME}: invalid argument '{value}' for '--interactive'", file=sys.stderr)
print("Valid arguments are: never, no, none, once, always, yes", file=sys.stderr)
sys.exit(2)


def parse_args(argv: list[str]) -> Options:
opts = Options(paths=[])
end_of_options = False

for arg in argv:
if end_of_options:
opts.paths.append(arg)
continue

if arg == "--":
end_of_options = True
elif arg == "--help":
help_text()
sys.exit(0)
elif arg == "--version":
print(f"{APP_NAME} {VERSION}")
sys.exit(0)
elif arg == "--force":
opts.force = True
opts.interactive = "never"
elif arg.startswith("--interactive="):
opts.force = False
opts.interactive = parse_interactive(arg.split("=", 1)[1])
elif arg == "--interactive":
opts.force = False
opts.interactive = "always"
elif arg in ("--recursive", "--dir", "--one-file-system", "--preserve-root", "--no-preserve-root"):
pass
elif arg == "--verbose":
opts.verbose = True
elif arg.startswith("--"):
print(f"{APP_NAME}: unrecognized option '{arg}'", file=sys.stderr)
usage(sys.stderr)
sys.exit(2)
elif arg.startswith("-") and arg != "-":
for flag in arg[1:]:
if flag == "f":
opts.force = True
opts.interactive = "never"
elif flag == "i":
opts.force = False
opts.interactive = "always"
elif flag == "I":
opts.force = False
opts.interactive = "once"
elif flag in ("r", "R", "d"):
pass
elif flag == "v":
opts.verbose = True
else:
print(f"{APP_NAME}: invalid option -- '{flag}'", file=sys.stderr)
usage(sys.stderr)
sys.exit(2)
else:
opts.paths.append(arg)

if opts.interactive == "once":
opts.prompt_once = True
opts.interactive = "default"

return opts


def strip_trailing_slashes(path: str) -> str:
if path == "/":
return path
stripped = path.rstrip("/")
return stripped or path


def protected_path(raw: str) -> str | None:
path = strip_trailing_slashes(raw)
if path == "/":
return "cannot remove root directory '/'"
if Path(path).name in (".", ".."):
return f"refusing to remove '.' or '..' directory: skipping '{raw}'"
return None


def exists_or_link(path: str) -> bool:
return os.path.exists(path) or os.path.islink(path)


def is_directory(path: str) -> bool:
try:
mode = os.lstat(path).st_mode
except OSError:
return False
if stat.S_ISLNK(mode):
return False
return stat.S_ISDIR(mode)


def prompt(question: str) -> bool:
if not sys.stdin.isatty():
print(f"{APP_NAME}: refusing interactive prompt without a terminal", file=sys.stderr)
return False
answer = input(f"{question} ")
return answer.lower() in ("y", "yes")


def should_prompt_once(paths: list[str]) -> bool:
if len(paths) > 3:
return True
return any(exists_or_link(path) and is_directory(path) for path in paths)


def main(argv: list[str]) -> int:
opts = parse_args(argv)
paths = opts.paths or []

if not paths:
print(f"{APP_NAME}: too few arguments", file=sys.stderr)
usage(sys.stderr)
return 2

exit_code = 0
candidates: list[tuple[str, str]] = []

for raw in paths:
reason = protected_path(raw)
if reason is not None:
print(f"{APP_NAME}: {reason}", file=sys.stderr)
exit_code = 1
continue

path = strip_trailing_slashes(raw)
if not exists_or_link(path):
if not opts.force:
print(f"{APP_NAME}: cannot remove '{raw}': No such file or directory", file=sys.stderr)
exit_code = 1
continue

candidates.append((raw, path))

if opts.prompt_once and candidates and should_prompt_once([path for _, path in candidates]):
if not prompt(f"{APP_NAME}: trash {len(candidates)} item(s)?"):
return exit_code

for raw, path in candidates:
if opts.interactive == "always" and not prompt(f"{APP_NAME}: trash '{raw}'?"):
continue

try:
send2trash(path)
except Exception as exc: # noqa: BLE001 - command-line tool reports all trash failures
print(f"{APP_NAME}: cannot trash '{raw}': {exc}", file=sys.stderr)
exit_code = 1
continue

if opts.verbose:
print(f"{APP_NAME}: trashed '{raw}'")

return exit_code


if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))