saferm is a command-line replacement for rm that archives every deletion with a mandatory reason and the context it ran in, so deleted files can be listed, inspected and restored. It is built for AI coding agents and the scripts they run, where an unexplained rm is both unrecoverable and unaccountable. No deletion proceeds without a --description saying why, and that reason is stored with the environment, git state and calling process beside the archived copy in ~/.saferm/.
go install github.com/smm-h/saferm@latest
Or via Homebrew (macOS/Linux):
brew install smm-h/tap/saferm
Delete a file (--description and --on-error are both mandatory):
saferm delete --on-error abort --description "removing stale config" old-config.yaml
archived: [3] 6f1c0e2a-6c9e-4a24-9d1f-2b0f3f5b7c11 /home/user/project/old-config.yaml (612 B)
Every archived path is named with both of its identifiers: the numeric database id and the uuid. The uuid is the durable handle -- undelete, info and purge all take it.
See what you've archived:
saferm list
Bring it back:
saferm undelete old-config.yaml
A restore consumes the archived copy -- it is moved back out, not copied -- and it never overwrites anything by accident. If something is already standing at the destination, --on-conflict is required and has no default: overwrite checks the archived copy against the record before replacing what is there, abort refuses and changes nothing. --destination <path> restores somewhere else and writes that path to the record, so info names where the content went.
| Method | Command |
|---|---|
| Go | go install github.com/smm-h/saferm@latest |
| Homebrew | brew install smm-h/tap/saferm |
| npm | npm install -g saferemove (not yet published) |
| PyPI | pip install saferm (not yet published) |
| Command | Description |
|---|---|
delete |
Move files to the saferm archive with metadata tracking |
undelete |
Restore a previously archived file back to its original path |
list |
Show all items currently held in the saferm archive |
purge |
Permanently destroy archived items and free disk space |
info |
Display full metadata and context for an archived deletion |
capabilities |
Name the features this saferm ships, for a program deciding how to drive it |
| config | Manage persistent configuration values stored in the config file |
config path |
Print the absolute path to this application's config file and nothing else, so the value can be piped straight into another command. The path is $XDG_CONFIG_HOME//config.<toml|json> (falling back to ~/.config), or the explicit override the application was built with. Printing it does not create the file, and reports the same path whether or not one exists yet. |
config show |
Show every flag and config field with its effective value and where that value came from, resolved through the precedence chain environment variable, then config file, then declared default. Declared infrastructure roots, handshake and connection environment variables are listed too. Choose --plain for an aligned human-readable table; the framework-owned --json yields the same information as a machine-readable object carrying each entry's type, default and help text. |
config set |
Write a persistent value into the config file so it overrides a flag's declared default on every later run. The value is coerced to the flag's own type and rejected if it does not fit: repeatable flags take a comma-separated list (backslash-escape a literal comma) and are checked for duplicates, dict flags take a JSON object. Use --default to drop a key back to its default, and --clear to empty a repeatable flag. |
config edit |
Open this application's config file in the editor named by $EDITOR, falling back to vi. The parent directory and an empty config file are created first if they do not exist, so the editor always opens something. Launching the editor counts as a mutation: under --dry-run the command records the editor invocation and opens nothing. |
config init |
Create a starter config file listing every flag and config field the application declares, each commented with its help text, type and default value, so the file documents itself. The format follows whichever of TOML or JSON the application was built for. Refuses with an error if a config file already exists rather than overwriting it; the created path is printed on success. |
$ saferm delete --on-error abort --description "broken migration, rewriting from scratch" -r db/migrations/
archived: [3] 6f1c0e2a-6c9e-4a24-9d1f-2b0f3f5b7c11 /home/user/project/db/migrations (14 KB)
$ saferm list
ID PATH SIZE DELETED
3 db/migrations/ 14K 2 minutes ago
$ saferm info 3
ID: 3
UUID: 6f1c0e2a-6c9e-4a24-9d1f-2b0f3f5b7c11
Path: /home/user/project/db/migrations/
Size: 14382
Type: directory
Status: restorable
Description: broken migration, rewriting from scratch
Deleted: 2026-05-16 14:32:01 UTC
Git branch: feature/new-schema
Git HEAD: a1b2c3d
Parent PID: 12345
Parent cmd: claude
$ saferm undelete 3
Restored db/migrations/
delete prints one line per archived path carrying the record's numeric id and its uuid, so a caller never has to run list afterwards and guess which row was its own. undelete, info and purge accept either, and undelete also accepts an original path. An identifier argument is read by shape, in a fixed order: a 36-character hyphenated hex string is a uuid, an all-digit string is a numeric id, anything else is a path.
--on-error is mandatory on delete and has no default, because a batch that meets a bad path has two defensible answers and they suit opposite callers:
| Value | Behaviour |
|---|---|
abort |
stop at the first failing path; everything archived before it keeps its record and its printed identifiers |
continue |
archive the remaining paths, report every failure, and exit non-zero at the end with the first failure's code |
Either way the identifiers of everything already archived are on stdout before the failure is reported.
info also states the record's status in one line: restorable, restored at <time>, purged at <time>, or both stamps when a record was restored and later purged. Where neither stamp is set and the archived copy is not there -- the state an archival leaves when it discards its entry because the source changed inside its window -- the status says so instead of claiming the record is restorable, and points at purge as the way to clear the row.
--json puts saferm in machine mode, where stdout carries exactly one document -- the envelope -- and everything saferm would have printed rides inside it. delete, undelete, list and info each answer with a structured payload: the records a delete wrote (both identifiers, path and size, plus the invocation's group id) and every path it could not archive with the reason, where a restore put the content, the rows of a listing, the full record with its status, origin and group. purge deliberately has no payload.
$ saferm --json capabilities
{"interface_version":2,"app":"saferm","command":"capabilities","exit_code":0,
"payload":{"features":["git-index-switches","group-id","machine-payloads","on-conflict-modes",
"on-error-modes","restore-destination","trace-origin","uuid-handles"]}, ...}
capabilities is how a program decides what this saferm can do. It names features, never a version -- a locally built binary reports a Go pseudo-version no semver parser accepts -- and a missing verb or a missing feature means the same thing as saferm not being installed. The verb reads nothing, so it answers on a machine where saferm has never run.
The payload schemas are declared in the code and published verbatim by saferm --dump-schema, which is the one channel that carries them. The MCP tool descriptors (saferm --mcp) carry each command's effect classification and its argument schema, never its payload schema. The machine-surface page in the docs is the specification.
Every deletion automatically captures:
- Description -- the mandatory
--descriptionflag - Git context -- branch, HEAD commit, repo root (auto-detected)
- Environment variables -- filtered by a configurable denylist to exclude secrets
- Parent process -- PID and full command line of the calling process
- Claude Code session -- via
CLAUDE_CODE_SESSION_IDenv var, if present - Custom metadata -- arbitrary key=value pairs via
--meta
~/.saferm/
archive/ files stored by UUID; directories as .tar.zst
db/saferm.db SQLite database (WAL mode)
config.toml optional configuration
Override the base directory with the SAFERM_HOME environment variable. SAFERM_HOME is location infrastructure -- the same category as HOME -- not a config value: it selects where saferm lives. Unlike config-file and environment values, SAFERM_HOME is not suppressed by --hermetic.
Optional file at ~/.saferm/config.toml:
archive_dir = "/custom/archive"
db_path = "/custom/db.sqlite"
exclude_env_patterns = [
"(?i)token",
"(?i)secret",
"(?i)password",
"(?i)key",
"(?i)credential",
]The exclude_env_patterns list controls which environment variables are redacted from captured metadata. The values shown above are the defaults. Each entry is a Go regular expression matched against the variable name; Go uses RE2, so lookahead ((?!...)) and backreferences are not available. A pattern that does not compile is a hard error -- saferm refuses to run rather than proceed with a redaction it cannot apply.
A malformed config.toml is a hard error (exit 1) reporting the parse position, never silently ignored. Unknown keys are rejected, and for archive_dir/db_path, passing a CLI value that diverges from the config value is a hard error rather than silently letting one win. This conflict check only fires when the global flag is given in the pre-command position (saferm --archive-dir X delete ...); a post-command placement (saferm delete --archive-dir X) is not currently conflict-checked. --hermetic suppresses config-file and environment values, falling back to defaults -- but it does not touch SAFERM_HOME, which is infrastructure, not configuration.
saferm is safe for concurrent use. UUID-based archive naming needs no coordination between processes, and the archive database is protected in two layers: SQLite's own busy_timeout waits up to 5 seconds for a lock held by another process, and saferm retries a contended operation up to 5 times in total on top of that, pausing 50ms, 100ms, 150ms and 200ms between attempts. Under --verbose each retry is reported on stderr.
Contention that outlives the whole budget is reported as such and exits 8 rather than the generic database code -- nothing is wrong with the archive, another process simply held the write lock throughout, and running the command again is the right response.
| Code | Name | Value |
|---|---|---|
| 0 | ExitSuccess |
0 |
| 1 | ExitGeneral |
1 |
| 2 | ExitUsage |
2 |
| 3 | ExitFileNotFound |
3 |
| 5 | ExitDatabase |
5 |
| 6 | ExitArchive |
6 |
| 7 | ExitConflict |
7 |
| 8 | ExitContention |
8 |
Config-layer failures -- a malformed config.toml, an unknown key, or a CLI value that conflicts with archive_dir/db_path in the config -- exit 1 (they are reported by the CLI framework before saferm runs). saferm's own semantic conflicts exit 7. The distinction: exit 1 means the configuration could not be loaded or reconciled; exit 7 means saferm ran and hit a semantic conflict.
Exit 5 and exit 8 are likewise distinct: 5 means the database itself failed, 8 means another process held its write lock for longer than saferm's whole retry budget. 8 is the one exit code that says "try again". Code 4 is deliberately absent (it was a permission code nothing ever returned) and is never reused, so the numbers below it keep their meaning.
Linux and macOS (amd64, arm64).
MIT
- GitHub: https://github.com/smm-h/saferm
- Docs: https://saferm.smmh.dev