What it is
Every disk-cleanup tool faces the same question and most answer it badly: what
is safe to delete? Treat everything cache-shaped as disposable and you
eventually remove a browser's Local Storage and log someone out of everything,
or delete a dependency tree whose lockfile no longer resolves. Be too timid and
the tool is not worth running.
reclaim answers it by refusing to treat "regenerable" as one category. Every
target is a unit carrying a tier — from costs nothing to may be
irreplaceable — and a separate flag saying whether losing it destroys
information or merely costs a re-download. The planner walks tiers in order,
stops at a ceiling, and an opt-in flag can raise that ceiling but can never
authorise destroying information. Only --allow-lossy does that.
The second thing it takes seriously is that caches belong to programs that may
be running right now. Deleting a live IDE's cache corrupts the session it is in
the middle of. So before anything is selected, reclaim reads the process table
and parks every unit whose directories belong to something currently running,
then tells you what to quit and which pid to blame.
$ reclaim clean
DRY-RUN — nothing was deleted. Re-run with --apply to clean.
...
== Locked by running apps ==
• JetBrains caches 6.2GiB held by jetbrains-ide (pid 1155531)
quit it, then re-run
Nothing is deleted without --apply, and --apply prompts before it acts.
Install
curl -fsSL https://raw.githubusercontent.com/mralaminahamed/reclaim/trunk/install.sh | sh
The script installs a native package where one fits the machine — .deb on
Debian and its derivatives, .rpm on Fedora, RHEL and openSUSE — so reclaim
can be upgraded and removed by the tools that already manage everything else
there. Anywhere else, including macOS, it falls back to a static binary in
/usr/local/bin. Checksums are verified, and a missing checksum file is a
failure rather than a shrug.
On macOS, Homebrew is the native path instead:
brew tap mralaminahamed/reclaim https://github.com/mralaminahamed/reclaim
brew install --cask reclaim
It selects on the package format the machine expects, not the distribution's
name. That is what makes it work on the derivatives too, and there are far more
of those than there are upstreams.
Or take a package straight from the
releases:
sudo dpkg -i reclaim_0.2.0_amd64.deb # Debian, Ubuntu, Mint, Pop!_OS
sudo dnf install reclaim-0.2.0-1.x86_64.rpm # Fedora, RHEL, Rocky, Alma
sudo zypper install reclaim-0.2.0-1.x86_64.rpm # openSUSE
Arch has a PKGBUILD, macOS a Cask.
With Go already installed,
go install github.com/mralaminahamed/reclaim/cmd/reclaim@latest. From a
checkout, make build — or make dist deb rpm to produce the packages
yourself.
There are no third-party dependencies. For a tool that deletes files as root, an
empty require block is a feature rather than an accident. The published
binaries are static for the same reason: this runs on a machine that is having a
bad day, and a dynamic link to a libc on the same failing disk is a bad bet.
The macOS binaries are not (yet) notarised — that needs an Apple Developer
account, which this project does not have. install.sh and the Cask both fetch
with curl, which never sets the quarantine flag, so neither is affected. Only
a binary pulled through a browser is: xattr -d com.apple.quarantine reclaim
clears it, the same workaround the release notes point at until notarising
becomes worth the cost for a tool this size.
Usage
reclaim clean # measure and report; deletes nothing
reclaim clean --apply # reclaim, after confirming
reclaim status # filesystems, free space, pressure
reclaim analyze --min 1G # largest directories; advisory
reclaim analyze --installers # stale downloads; advisory
reclaim history # what past runs actually removed
reclaim completion bash # shell completion script
How hard to try
reclaim clean --free 12G # clean until 12G is free, then stop
reclaim clean --auto # read disk pressure and pick a target
reclaim clean --below 20G # do nothing unless free space is under 20G
reclaim clean --tier 2 # never escalate past tier 2
--auto finds the most pressured filesystem and lets how full it is decide how
hard to try. A comfortable disk gets only the free tiers; a critical one earns a
cold reload.
A target also changes how a cache goes. Where a tool can trim, dropping only
what nothing uses any more (npm cache verify, uv cache prune,
pnpm store prune), the trim runs first as a free unit and the full wipe waits
a tier up. Caches made of standalone files (the Go build cache, pip, npm's
_cacache, thumbnails) give up their least recently used files first, and
reclaim stops as soon as the target is met. "Last used" means the later of a
file's access and modification times. If the target is still short once every
file is gone, whatever is left goes too. Without a target, every unit goes
whole, as before. Module caches and extracted archives are never trimmed file
by file, because half an unpacked package is worse than none.
What to look at
reclaim clean --discover # also claim caches with no hardcoded rule
reclaim clean --only 'xdg-*' # restrict to matching unit ids
reclaim clean --exclude 'npm-*'
reclaim clean --workers 12 # size the probe pool
reclaim clean --json # machine-readable output
Opt-in groups
Units in these groups are never touched unless you name them. Irreversible
units additionally require --allow-lossy.
| Flag |
Reclaims |
--system |
the package-manager cache, a bounded journal vacuum, old snap revisions, crash dumps |
--kernels |
superseded kernel packages and what removed kernels left in /lib/modules, never the running one |
--obsolete |
config left by removed-but-not-purged packages, rotated logs over 30 days old, directories of superseded JetBrains IDE versions |
--trash |
the desktop trash — lossy |
--models |
huggingface, torch, whisper and LM Studio model stores, Chrome's on-device AI model |
--flatpak |
unused runtimes |
--docker |
build cache, dangling images, unused networks |
--docker-containers |
stopped containers, each named in the dry run — a stopped container can hold state nowhere else |
--docker-volumes |
unused volumes — these may hold databases |
--steam-compatdata |
Steam's Proton prefixes, each named by game in the dry run — lossy: some games keep saves or settings there and nowhere else |
--timemachine |
macOS: thins Time Machine's local snapshots — lossy: a local snapshot is the only copy of the state it captured |
--gradle |
~/.gradle/caches, ~/.gradle/wrapper |
--maven |
~/.m2/repository |
--jetbrains |
JetBrains IDE caches |
--browsers |
Chrome, Brave and Firefox HTTP caches |
--playwright |
Playwright browser binaries |
--claude-jobs, --claude-plugins |
Claude Code scratch and plugin cache |
--sites-idle N |
dependency and build trees of projects idle for N days |
--heavy |
discovered caches over 1GiB |
Set RECLAIM_NO_OPLOG=1 to disable the operations log.
What it knows about
System packages
--system covers whichever package manager the machine has — apt, dnf,
yum, pacman, zypper or apk — chosen by which binary is present rather
than by parsing a distribution name. Each is a cache clean and nothing more.
None of them runs an autoremove: that decides for itself what is orphaned, and
the result cannot be previewed honestly.
The dry run reports what each will actually free. apt-get clean counts the two
binary package indexes it also drops, not only the archive; the journal vacuum
counts the journal less the window it keeps.
It needs root, and reclaim asks for it once up front via sudo -v — a single
password prompt rather than one per unit. If elevation is declined the system
units are reported under Failed with the reason, never counted as reclaimed.
Kernels
--kernels is deliberately separate from --system, and never runs
apt autoremove. Two kernels always survive — the running one and the newest —
and the dry run names every package it would purge, because a byte count is not
something anyone can consent to for a kernel removal.
Debian-only, and not because the others are harder: dnf enforces
installonly_limit itself, and Arch ships one linux package that is replaced
rather than accumulated. On those systems there is nothing to collect, and a
kernel unit would be inventing work.
A removed kernel is not quite gone. Its modules package reruns depmod on the
way out, which rewrites the module indexes into /lib/modules/<version> — a
directory of indexes for modules that no longer exist — and dpkg keeps the
package in the rc state. --kernels takes both, but only for a version
nothing still claims: no installed package of that version, not the running
kernel, and no image in /boot. The last check protects a kernel installed
outside dpkg, which has modules and an image and no package at all.
Time Machine and the unified log
macOS has no package manager cache for --system to clean, so it covers two
things there instead, gated on tmutil being present rather than on the
platform name.
The unified log (--system) is the journal's analogue. log erase --ttl
takes only entries macOS itself already marked expired, the same reason a
7-day-old crash dump is reversible: the bound is what the system already
committed to, not one this tool invented.
Time Machine local snapshots (--timemachine, its own flag for the reason
kernels get one) are the high-value target — tens of gigabytes are routine even
with no backup disk ever attached. tmutil thinlocalsnapshots asks the system
to free space and decides for itself which snapshots to drop, so nothing on
disk can state the yield in advance; the dry run instead names how many local
snapshots currently exist, and --apply reports what actually freed, measured
from free space before and after rather than estimated. Must be lossy — a
local snapshot is the only copy of the state it captured — so it also needs
--allow-lossy.
Obsolete files
--obsolete covers what outlived the thing that needed it, and all of it is
lossy, so it also needs --allow-lossy:
- Config of removed packages.
apt remove without --purge leaves the
package's configuration in /etc. A reinstall restores the shipped defaults,
never the edits, so purging destroys whatever was changed. The dry run names
every package.
- Rotated logs over 30 days old —
syslog.2.gz, dpkg.log-20250101.xz,
Xorg.0.log.old. The command deletes exactly the files the dry run measured
rather than searching again, so logrotate running in between cannot change
what goes. The journal is left to its own bounded unit.
dpkg-based systems only for the package half; the logs work anywhere.
- Superseded JetBrains versions. Every IDE upgrade starts a new
RustRover2025.3 directory and abandons RustRover2025.2. A version counts
only when a newer one of the same product sits beside it. Plugin and index
data is reversible; the old settings directory is lossy, since the
upgrade imported it once and only if asked.
Model stores
Tier 3 rather than tier 1, because "comes back" and "comes back for free" are
different claims: every file re-downloads, over hours, often metered. Pruning is
separate and unflagged — hf cache prune discards only revisions nothing
references and downloads that never finished, so it costs nothing and leaves
working models alone.
The Hugging Face home also holds the login (token, stored_tokens), so
--models takes only its hub, xet, datasets and assets directories, and
--discover never claims the home whole.
Ollama models go through ollama rm, which names exactly the models
ollama list showed, so the server keeps its own index. reclaim never deletes
blobs under a live store. The unit is lossy: a model built locally with
ollama create does not come back from a pull, and nothing on disk tells it
apart from one that does. So it needs --models --allow-lossy, and the report
lists every model by name. The store is found, not assumed (OLLAMA_MODELS,
~/.ollama/models, or the packaged service's /usr/share/ollama), and it is
only measured. If the server isn't running, nothing is offered, because nothing
can be listed.
Stores moved to another disk are found where they were moved. HF_HOME,
HF_HUB_CACHE (or the older HUGGINGFACE_HUB_CACHE) and TORCH_HOME are read
for the model units. XDG_CACHE_HOME moves every ~/.cache entry in the
catalog and the --discover sweep of the cache root. A relative value is
ignored, as the XDG spec requires. A variable aimed at home, at / or at
anything else the runner would refuse to delete registers nothing.
Trash
--trash --allow-lossy. Earlier versions emptied the trash by default as though
it were a cache. It is the opposite of one: the trash exists so a deletion can be
taken back, and emptying it removes that option.
Crash artifacts
/var/crash and /var/lib/systemd/coredump are only taken once they are
older than seven days. That bound is what makes them safe to treat as
regenerable: a dump written this morning belongs to a crash someone may be
reading right now, and taking it would destroy the only copy of that failure.
One from last month is a record the system itself is configured to expire.
Idle projects
--sites-idle knows around fifteen project shapes — Cargo, Maven, Gradle,
Python venvs, Next, Elixir, CocoaPods, Terraform, .NET, Zig, Dart and the
Node/PHP pair it started with. Each dependency directory is paired with a
manifest that proves what produced it, because build, target, obj and
bin are ordinary words and a directory of that name with nothing beside it is
somebody's source. Idleness is judged from a project's own files, never from its
build output.
Projects are found up to six levels below --sites-root, so a plugin inside
Sites/<site>/wp-content/plugins/ is a project of its own. Hidden directories
and symlinks are not entered.
A manifest proves the shape, not that the directory can be regenerated: a
WordPress plugin commits vendor/ so it ships working. So inside a git
repository a directory is claimed only when git vouches for it -- nothing under
it is tracked (git ls-files) and the repository ignores it
(git check-ignore). If git cannot answer, the directory is left alone. dist
is claimed only this way; outside a repository there is no telling. A
dependency directory that is a repository itself, or a Rust target/ holding
Anchor's deploy/*-keypair.json, is never claimed.
A directory inside an idle project that carries a valid
CACHEDIR.TAG is claimed whatever its name, as
a reversible artifact: the tag is the directory's own statement that it can be
regenerated. A committed one contradicts its tag and is left alone.
The index
reclaim index # snapshot every filesystem; later runs refresh
reclaim index --full # re-read everything
reclaim index show [DIR] # largest directories inside DIR, instantly
reclaim index cold [DIR] # largest subtrees untouched for a year (--days, --min)
A full walk of every filesystem took 16s on a 200GB machine; a refresh took
2.4s, and analyze answered from the index in 0.3s where walking the home
directory had taken 2m41s. Each filesystem is its own tree, never crossed
into from another, so nothing is counted twice.
The index is for looking, never for deleting. clean measures everything
live at the moment it runs. A snapshot is out of date by definition, and "it was
a cache an hour ago" is not a reason to delete anything now.
A refresh re-reads only directories whose modification time changed — which
happens whenever an entry is added, removed or renamed. It does not see a
file growing in place, like a log or a database. analyze therefore only trusts
an index less than a day old and says when it used one; --full rebuilds
exactly. Directories the user cannot read are counted and reported; run as root
to include them.
Under sudo, the index is still written to the invoking user's home and handed
back to them, so analyze run as that user finds it.
Unused applications
reclaim analyze --apps lists desktop applications not used in 90 days
(--idle N to change that), each with the command that would remove it. It
removes nothing: an uninstalled app does not come back by itself, and neither
does whatever it kept.
An app is only listed on evidence, and every signal used can err one way only —
towards "used":
- the access time of the binary it launches.
relatime still records a
read at most once a day, which is plenty for months of disuse; on a noatime
mount there is no answer and the app is not judged
- the app's own data directory for snaps (
~/snap/<name>) and flatpaks
(~/.var/app/<id>)
- GNOME Shell's record of when each app last ran
A deb package is skipped when it was installed as a dependency, or when another
installed package depends on or recommends it — removing ibus removes
ubuntu-desktop, and the next autoremove takes everything that was holding.
Apps unpacked into /opt are sized as their directory.
On macOS there are no .desktop launchers, so this scans /Applications and
~/Applications directly, and the evidence is kMDItemLastUsedDate — the same
Spotlight record Launchpad sorts "recently used" by. An app Spotlight has no
launch record for is not judged, same as a binary on a noatime mount. The
removal command is brew uninstall --cask <token> for anything Homebrew owns,
or rm -rf for a plain drag-installed .app.
Stale installers
reclaim analyze --installers --older 90
Reports .deb, .rpm, .AppImage, .iso and friends in ~/Downloads that
have sat there for months. This one only ever reports. Everything else the
tool touches lives in a cache directory, where the containing directory is
itself the argument that the contents are disposable; ~/Downloads is the
opposite, and a download there may be the only copy of something.
Archives are not counted. A .zip is a container, not an intent, and on a real
~/Downloads the largest ones turned out to be a book collection and a Figma UI
kit.
Where redundancy can be proven rather than guessed — a .deb whose package is
already installed at exactly that version — the report says so:
• ~/Downloads/coreutils.deb 20.2KiB 200d coreutils 9.4-3ubuntu6.3 is already installed
Configuration
Defaults
~/.config/reclaim/config.json fills in flags that were not given:
{
"workers": 12,
"tier": 3,
"exclude": ["go-*", "cargo-*"],
"sites_root": "~/Projects"
}
One rule holds the whole file together: a config may narrow a run and may
never widen one. So apply, yes, allow_lossy, discover and the opt-in
group names are refused, by name and with the reason, rather than accepted
quietly. A flag is typed at the moment of use and read back by whoever is about
to press enter; a file is not read back by anyone, and the person it would
surprise is the one who wrote it months ago and forgot. The worst a stale
exclusion can do is leave space unreclaimed, which the next report points out.
A file that exists and does not parse stops the run.
Custom units
The catalog covers what this tool ships with an opinion about. Anything else — a
cache for a tool nobody here uses, a site-specific scratch directory — can be
declared in ~/.config/reclaim/units.json, or dropped into
/etc/reclaim/units.d/*.json by a package:
{
"units": [
{
"id": "ccache",
"label": "ccache objects",
"tier": 1,
"reversible": true,
"paths": [".cache/ccache"]
},
{
"id": "conda-pkgs",
"label": "conda package cache",
"tier": 1,
"reversible": true,
"command": "conda clean --all --yes",
"requires": "conda"
}
]
}
tier and reversible are required. They are the two claims the safety model
rests on, and defaulting either would let an omission make a promise the author
never made. Relative paths resolve under $HOME; requires drops the unit on
machines without that binary; flag makes it opt-in, named as --with <name>
since a file cannot register a CLI flag.
A file adds to the catalog and can never restate it — the shipped definition
of a unit id always wins. Definitions go through the same protected-path
backstop as everything else, so one aiming at /, a top-level system directory
or $HOME is refused at load. reversible: false still requires
--allow-lossy: writing a unit down does not lower that gate. A longer example
is in docs/units.example.json.
Shell completion
reclaim completion bash > /etc/bash_completion.d/reclaim
reclaim completion zsh > "${fpath[1]}/_reclaim"
reclaim completion fish > ~/.config/fish/completions/reclaim.fish
Completing --only and --exclude asks the binary for unit ids rather than
carrying a list, since which units exist depends on what is installed. The
packages install these for you.
Running on a schedule
--below makes a run a no-op unless the disk is actually under pressure, and it
decides that before building a catalog or probing anything — so a timer can fire
often and cost nothing most of the time:
sudo cp systemd/reclaim.* /etc/systemd/system/
sudo systemctl enable --now reclaim.timer
The shipped service runs clean --below 20G --auto --apply --yes, with no
opt-in group flags and no --allow-lossy. Whatever it does, it does at 03:00
with nobody reading the output, and the default tier ceiling is the right amount
of ambition for that. --json and the operations log make a run auditable
afterwards.
There is no watch daemon on purpose. A timer plus --below is the same
capability without a process to supervise, and systemd already handles the parts
a daemon would have to reimplement badly: persistence across reboot, missed
windows, and logging.
Safety model
- Dry run by default. Nothing is deleted and no command runs without
--apply, which prompts unless given --yes.
- Tiers. Units run cheapest-first and the planner will not cross its ceiling.
- Reversibility is absolute. An opt-in flag authorises a unit but never
authorises destroying information. A test pins that distinction.
- Opt-in means opt-in. A unit carrying a flag runs only when that flag is
passed, whatever the tier ceiling says.
- Locks. Caches of running applications are skipped, named, and attributed
to a pid.
reclaim excludes its own process, so its command line cannot lock
it out of its own work.
- Probing never deletes. Everything reachable from the measuring phase is
read-only, asserted by a test that walks the tree before and after.
- Protected names.
Local Storage, IndexedDB, Cookies, Login Data and
friends are never treated as caches, in any casing.
- Protected paths. A backstop refuses
/, top-level system directories and
$HOME however a unit is defined, so a malformed unit cannot aim the deleter
at the wrong tree. It is checked again at the moment of deletion against the
path with its symlinks resolved.
- Deletion stays inside its tree. Removal walks each target through a handle
opened on the target itself, so no symlink, present from the start or swapped
in mid-run, can lead it elsewhere. A link is removed, never followed. A mount
point inside a cache belongs to another filesystem and is left alone.
- Skipped is not failed. Anything left in place on purpose, such as a mount
point or a directory some tool re-populated while it was being removed, is
listed under
== Skipped == with the reason, and the run carries on.
- The log says what went.
reclaim history records the paths each unit
removed, not just which unit ran — after a --discover run the unit list was
not knowable in advance. Long lists are trimmed with a true count, so a record
never understates what happened. Deletion is final: there is no undo.
How it works
survey → catalog → probe → lock → plan → run → report
The catalog registers a unit only when its path exists or its tool is installed.
Probing is du-bound and every unit is independent, so it runs on a worker
pool — a full discovery run over a developer machine takes about 1.7 seconds.
Locking happens after probing, so a locked unit still reports its size and you
can see what quitting the app would buy you.
Sizes are what deleting would give back: allocated disk blocks, not file lengths,
so a sparse file counts for what it occupies. A hard-linked file is counted once,
and only when every link to it is inside what is being deleted. The pnpm store is
hard-linked into each project's node_modules, so deleting the store alone frees
almost nothing, and it is reported that way. After a run, a path unit reports
what it measured before minus what is left. A command unit reports the drop in
free space around running it, marked measured in --json, because nothing on
disk could have said in advance what the tool would remove.
cmd/reclaim/ the CLI
internal/unit/ unit model and registry
internal/catalog/ the table of things worth reclaiming
internal/system/ package-manager caches, journal, kernels, crash artifacts
internal/scan/ dependency and build trees of idle projects
internal/probe/ parallel measurement
internal/lock/ running-application detection
internal/plan/ tier ceiling and selection
internal/runner/ execution, with a protected-path backstop
internal/remove/ tree removal that never leaves its target or filesystem
internal/discover/ caches with no hardcoded rule
internal/unitfile/ unit definitions loaded from files
internal/config/ defaults read from a file
internal/installers/ stale downloads, reported and never removed
internal/report/ text and JSON output
internal/oplog/ append-only record of what was deleted
internal/fsutil/ sizes, mounts, pressure and age
internal/testharness/ sealed sandbox the end-to-end tests run the binary in
Chromium and Electron apps get a deeper sweep. Their data directory is known by
Chromium's own marker files (Local State, or Preferences with Network Persistent State), and inside one the layout is fixed: cache-named directories
are claimed in the directory itself, in each profile, and in each
Partitions/<id>/ -- Postman keeps a whole browser per workspace there.
Service Worker/CacheStorage is web apps' offline data, not a cache: it is
offered as lossy, behind --site-data --allow-lossy, because deleting it can
log you out or make an app download everything again. While such an app runs it
holds a SingletonLock naming its host and pid, and its caches are skipped even
when the app is not in the lock table.
A flatpak app runs sandboxed, and its command line names a path inside the
sandbox, so the process table cannot say which app it is. Flatpak's own
instance directory ($XDG_RUNTIME_DIR/.flatpak/<instance>/info) can. A running
app's ~/.var/app/<id>/cache moves out of the shared flatpak unit into a locked
unit of its own, and every other app's cache is still cleaned by default.
Discovery uses two different rules on purpose. Everything directly under
~/.cache is regenerable by the XDG basedir spec, so it is claimed by location
and needs no whitelist. A cache nested inside ~/.config sits beside real
application state, so there it is claimed by name against a strict list.
Claiming by location is a sound argument that the contents are regenerable and
no argument at all about what regenerating them costs. A model cache and a
font cache are both disposable; only one of them is cheap. So once sizes are
known, any discovered unit over 1GiB is re-rated: it moves to the cold-reload
tier and becomes opt-in behind --heavy. It is still reported — with its size
and the flag that would claim it — because hiding the space would be no better
than deleting it unasked. RECLAIM_HEAVY_THRESHOLD=500M lowers that bound; it
cannot raise it, since nothing in the environment may widen what a run deletes.
Development
make test # go test ./... and go vet ./...
go test ./... -race # 286 tests across 16 packages
make dist deb rpm # release artifacts
The CLI tests build the real binary and run it against a fixture HOME, so flag
parsing, exit codes, dry-run behaviour and JSON validity are covered end to end.
CI additionally runs the built binary on Debian, Fedora, Arch, openSUSE and
Alpine, because the claim that this works beyond Debian only stays true if
something keeps checking it somewhere that is not Debian.
Assets are generated, never hand-edited — editing them individually is what let
the palette drift the first time:
python3 assets/generate.py && bash assets/render.sh
Status
Linux, on every distribution CI can reach: Debian, Ubuntu, Fedora, Arch,
openSUSE and Alpine.
macOS has its own CI job building, testing and running the real binary on
macos-latest, and by now covers mount enumeration and disk pressure,
process-based lock detection (so a live app's cache is still parked, not just
reported as free), the cache catalog, unused-application detection, --system's
platform analogues (the unified log and Time Machine local snapshots), release
binaries, an .icns icon and a Homebrew Cask
(#4,
#41 closed;
#5 mostly closed —
notarising is still open, and needs an Apple Developer account this project
does not have). Tracked in the
#1 umbrella.
This began as a single self-contained bash script that reached 1615 lines and
201 built-in assertions. It is preserved in git history:
git show 67fd60c:reclaim.sh > reclaim.sh
License
MIT — see LICENSE.