Metadata-Version: 2.4
Name: saferm
Version: 0.10.2
Summary: 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
License-Expression: MIT
Project-URL: Homepage, https://smmh.dev/saferm/
Project-URL: Documentation, https://smmh.dev/saferm/
Project-URL: Repository, https://github.com/smm-h/saferm
Project-URL: Issues, https://github.com/smm-h/saferm/issues
Project-URL: Changelog, https://github.com/smm-h/saferm/blob/main/CHANGELOG.md
Keywords: rm,safe-delete,undelete,trash,archive,audit-trail,cli,ai-agents,rlsbl
Classifier: Programming Language :: Python :: 3
Classifier: Environment :: Console
Classifier: Operating System :: OS Independent
Classifier: Intended Audience :: Developers
Requires-Python: >=3.8
Description-Content-Type: text/markdown

<!-- Auto-generated by selfdoc from docs/_README.md — do not edit -->

# saferm

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/`.

## Quick start

```
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.

## Install

| 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) |

## Commands

| 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/<app>/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. |

## Example workflow

```
$ 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/
```

## Identifiers and the error mode

`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.

## Driving saferm from a program

`--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.

## Metadata

Every deletion automatically captures:

- **Description** -- the mandatory `--description` flag
- **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_ID` env var, if present
- **Custom metadata** -- arbitrary key=value pairs via `--meta`

## Storage

```
~/.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`.

## Configuration

Optional file at `~/.saferm/config.toml`:

```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.

## Concurrency

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.

## Exit codes

| 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.

## Platforms

Linux and macOS (amd64, arm64).

## License

MIT

## Links

- GitHub: https://github.com/smm-h/saferm
- Docs: https://saferm.smmh.dev
