DEVTRIM

measure · classify · trim

v0.12.0 Ratatui TUI Apache-2.0 Trash-first deletion Protected-path denylist --json

01 Philosophy

devtrim exists because cleaning a 900 GB Mac by hand is how good data dies. Three rules govern everything it does.

The tool refuses to be clever about your data:

02 Install & build

With Homebrew (installs the attested release binary plus completions and man page):

brew install mneves75/devtrim/devtrim

Or build from source:

git clone https://github.com/mneves75/devtrim
cd devtrim
cargo build --release --locked
cargo test --locked

Manual installs can generate shell integration directly:

devtrim completions zsh   # bash | zsh | fish
devtrim manpage

No runtime dependencies beyond macOS itself. The arm64 release binary is about 2.6 MB and self-contained:

cp target/release/devtrim /usr/local/bin/
devtrim --version
Rust ≥ 1.88 (edition 2024) to build. Runtime target: macOS 13+.

03 Quick start

In an interactive terminal, start with the keyboard interface:

devtrim

Choose an operation, inspect its exact findings, and press a only when you intend to apply. A preview lists one finding per line with the highlighted one shown whole below; a terminal too short to hold it says how many lines it hides, and Enter shows the finding in full in a view that scrolls, where no key changes the plan and Esc returns to it. ? lists every key, whole even at the minimum size. Each operation's badge is explained beside the menu: READ-ONLY reports and never changes anything, PREVIEW changes nothing until you select items, press a and approve, and PERMANENT deletes for good after a typed size acknowledgment. "Scan everything" is read-only: its selection and apply keys name the category view that can act on the highlighted finding, such as "b, then 2 (caches)", and any key that cannot act on the highlighted row says why instead of doing nothing. In a category view Space leaves the highlighted finding out of the plan or adds it back, and A toggles every finding between selected and left out; everything starts selected, so the footer reads "A none" until something is left out and "A all" after; the confirmation then covers exactly what stays selected and says how many findings that is and how many were left out. Arrow keys or j/k move through results. Esc goes back one screen: a confirmation or the full detail view returns to the results, the results, an apply outcome or a failure return to the menu, and on the menu Esc quits. A scan or apply keeps the screen live and counts the seconds it has taken. During a scan q or Ctrl-C stops it and quits, killing every command it started; during an apply no key quits until it returns, because quitting could leave a target half removed. A SIGINT or SIGTERM, whenever it arrives, restores the terminal and kills every command devtrim started, exiting 130 or 143. A category that failed to scan is counted in the results title and named in the status line, and r retries it, from the failure screen too. Scanner warnings and partial-apply errors remain visible in the interface. Localized scan errors make the TUI exit nonzero, even after a later successful scan. Below 64×18, operation input is blocked until the terminal is resized. The TUI labels risk in text as well as color.

Everything starts as a dry-run preview. Look first:

devtrim scan
devtrim clean caches

Satisfied with the preview? Act — one decision at a time:

devtrim clean caches --apply -y
The two blocks above are separate on purpose. Pasting preview and mutation together defeats the point of previewing.

Reading the output:

   3.4 GB  danger:5  DerivedData: MyApp-abcdef  /Users/you/Library/Developer/Xcode/DerivedData/MyApp-abcdef
           └─ build output; rebuilt on next build; action: move to Trash

Local data and removal

devtrim has no account, backend or telemetry store. HTML reports contain local paths, sizes and diagnostics. Keep the printed output path: an explicit --output, or devtrim-dashboard-<random>.html in the OS temporary directory. devtrim does not expire reports; the OS controls temporary-directory cleanup. JSON exports, terminal logs, screenshots and shared copies remain where you put them.

Apply history is ~/.local/state/devtrim/journal.jsonl, or <XDG_STATE_HOME>/devtrim/journal.jsonl when the override is absolute. Rotation starts at 10 MiB and keeps .1, .2 and .3; this is size-based retention, not an erasure deadline. journal.lock in the same directory coordinates access.

To discard local data, first let every apply using that state directory finish and exit the TUI. Identify and remove only the exact report/export files and journal generations you own and intend to discard. Never remove or replace a lock while another process may use it. Removing history loses its audit trail and the ownership evidence used by trash-empty --only-devtrim; old Trash items can no longer be identified through deleted records. Preserve evidence needed for an investigation.

Separately account for backups, cloud shares, messages, logs and restore copies under each location's policy. Restoring a backup can restore the data. Moving a report to Trash leaves it on disk; deleting a file does not guarantee physical erasure from SSDs, snapshots or backups. There is no devtrim backend deletion request.

04 Command reference

Grouped by what you are here to do. Global flags are capability-scoped: read-only/report-only commands reject mutation flags they cannot honor instead of silently ignoring them. scan --shred intentionally changes the previewed action; Docker and simulator cleanup reject --shred because they execute exact typed commands rather than filesystem deletion actions; trash-empty rejects it because its preview is already permanent. The TUI owns its confirmation and rejects CLI mutation flags plus --json.

Inspect — nothing changes

tuiinteractive keyboard interface; also the bare-command defaultPREVIEW

Runs only when stdin and stdout are terminals. It calls the same scanners and apply owners as the CLI, shows textual risk labels, refuses CLI bypass flags, and requires a matching confirmation for the current preview. Bare devtrim prints help and exits nonzero instead when piped.

scanfull reclaimable-space reportLOW

Read-only report across every category. It opens with one line per category — actionable size, finding count, and the command that acts on it, largest first — then lists each category's findings: all of them up to eight, otherwise the five largest and a count of the rest. --all lists every finding; --json is always the complete document.

historyrecent journaled appliesLOW

Read-only view of the write-ahead apply journal: what was attempted, what completed, what errored. An attempt without a result is flagged as interrupted. --limit N bounds output; --json emits one {"operation":"history","entries":[…]} document.

largestbiggest directories under scan rootsLOW

Read-only ranking of the largest depth-≤2 directories under your scan roots (--top N, default 20). Unreadable entries are skipped and disclosed as a count — totals are lower bounds, never deletion authority.

worktreeslinked worktrees and their sizesLOW

Read-only list of every repository's linked worktrees under your scan roots, from one git worktree list --porcelain -z per repository through the same hardened Git invocation the scans use: branch or detached HEAD, and the lock and prune reasons Git reports. A listed path is the repository's own claim, so it is measured only when it is a real directory inside a scan root whose .git file leads back to that repository; anything else is listed unsized with the reason. The main worktree is listed but not measured. Sizes follow no link, stay on one device, never count a nested worktree twice, and are lower bounds when entries cannot be read. The report never says safe, stale or removable and shows no date: uncommitted, untracked and ignored work leave none. A bare repository has no .git marker and is found only through one of its worktrees. git worktree remove <path> runs the repository's configured programs, so use it only in a repository you trust, after your own review.

icloudlarge files and locally allocated storageLOW

Recursively lists iCloud Drive files ≥ 100 MB with logical size and a local-allocation estimate. Sparse files, compression, clones, and dataless placeholders can make allocated bytes differ from logical bytes; devtrim does not claim to know upload progress.

Reclaim safely — regenerable state only

cachespackage, model, and tool download cachesCAUTION

Moves regenerable caches to Trash: Hugging Face models, uv, npm's whole cache folder (downloads and npx installs come back, its logs do not), Homebrew, bun, Corepack's downloaded package managers (~/.cache/node/corepack, never its parent), the cargo registry, the GitHub CLI cache, Gradle's caches and wrapper/dists under GRADLE_USER_HOME (never that folder, which holds gradle.properties and init.d; only when each holds what Gradle writes there, caches/modules-2 or a gradle-<version>-bin distribution, and the Gradle home is not inside a Git repository; refused while a Gradle daemon, build or Wrapper runs), the Go module cache go env GOMODCACHE reports (only inside the home folder and holding Go's cache/download; Go keeps it read-only, so --shred refuses it whole, devtrim trash-empty purges it once it is in the Trash, and go clean -modcache is Go's own removal), and a closed list of exact ~/Library/Caches subdirectories (Playwright browsers, the VS Code HTTP cache and its Squirrel update staging, SwiftPM, pip, the pnpm metadata cache, Go, TypeScript, CocoaPods' download cache). Hugging Face cleanup targets only ~/.cache/huggingface/hub; authentication tokens and other state in its parent remain untouched. Homebrew's cache holding a Git clone Homebrew keeps for a Git-sourced formula (<name>--git) is offered as its other direct children, and the clones stay. ~/Library stays protected wholesale — that list is the carve-out, it is the same constant the protection boundary reads, and a name is on it only when one developer tool owns the directory and rebuilds it on demand. The pnpm entry is the metadata cache, never the content-addressable store at ~/Library/pnpm/store that installed node_modules trees hard-link into. Editor logs and application data are out of scope: they live under ~/Library/Application Support and ~/Library/Logs, mixed in with real user state. ~/Library/Caches/JetBrains is excluded too — on macOS it is the IDE system directory, and each product subdirectory holds the non-regenerable LocalHistory store. Regenerated automatically on next use — mind the bandwidth for large model caches and the rebuild time for editor indexes — except Playwright's browsers and CocoaPods' downloads, which come back only through npx playwright install and pod install; those findings name the command. The uv cache is removed only while no uv process holds uv's own cache lock, the same lock uv cache clean waits for; the empty .git uv writes into its source-distribution bucket is recognized in that exact shape rather than treated as a repository.

node-modulesexact paths in conclusively stale reposCAUTION

Maps each node_modules to its owning Git repo, skips repos with recent activity — the newer of HEAD's commit date and its newest reflog entry, so a clone or checkout of old history counts as active — and applies only the exact paths shown. Reading that activity runs no program the repository configures. An unreadable build working directory, including a decoded lsof name that is not absolute, refuses liveness at preview and apply. Apply reasserts a real node_modules directory leaf and refuses symlinks plus .git, nested dependency-tree, or non-normal ancestors. A node_modules holding any file its repository tracks is committed content, not a stale install, and is never offered. Neither is one without a package.json beside it — the manifest an install recreates it from — such as the assets/node_modules inside an archived or unpacked iOS app bundle, whose removal would break the bundle's signature. Roots at or below .git, or below node_modules, in any ASCII case, are excluded from both project cleanup categories, including explicit and configured roots. A root exactly at a top-level node_modules install may offer that install, never nested installs. Unknown Git activity is refused: that repository offers nothing and is reported as an error, while the others are still judged. A repository with no commits yet is skipped and named.

artifactscorroborated build artifacts in stale reposCAUTION

Deletes a directory only when its name is on a closed list and its ecosystem corroborates it: target beside Cargo.toml, .venv containing pyvenv.cfg, Pods beside Podfile, .next-family beside package.json, .gradle beside its build files, Android's native build folder .cxx beside build.gradle, .terragrunt-cache beside terragrunt.hcl, Nuxt's .output beside nuxt.config.*, .build beside Package.swift, tool caches, or a directory carrying an exact CACHEDIR.TAG signature — always inside a conclusively stale Git repo. Ambiguous names (build, dist, vendor, bin, obj) are never matched. A directory holding any file its repository tracks is never offered — CocoaPods recommends committing Pods — nor is a tree holding a Git repository anywhere below its root, as a SwiftPM .build holds its dependencies' clones, or an entry named *-keypair.json: cargo build-sbf, which anchor build runs, writes a Solana program's keypair into target/deploy once, and a rebuild after removal mints a different program address. Corroboration, staleness, build-process liveness, tracked files, and keypairs are re-verified at apply.

purgenode_modules + build artifacts, by projectCAUTION

devtrim purge is the project view of the two categories above: stale node_modules and corroborated build artifacts in one plan, ordered by project with the largest first and a header per project. It adds no authority of its own — each finding keeps its category's staleness and build-liveness gates and is applied by that category, which reasserts its exact shape. Like every category it moves to Trash unless you pass --shred. Like Mole's mo purge, it skips a directory holding files its repository tracks, and a build directory holding a Solana program keypair, Terraform state (*.tfstate, which Terraform's local backend writes inside .terragrunt-cache), or a nested Git repository. Unlike mo purge, it never matches ambiguous names such as build, dist, or coverage, and never touches a repository with recent Git activity. A project with no repository of its own is never judged by an enclosing repository at or above your home folder, such as dotfiles kept as a repository at ~. A folder it cannot read and a repository it cannot judge each offer nothing and are reported as errors, without emptying the plan. In the TUI it is p, where Space leaves individual projects' items out.

xcodeDeviceSupport + DerivedDataCAUTION

Clears only direct children of the iOS, watchOS, tvOS and visionOS DeviceSupport folders and of DerivedData — never a file or symlink beside them — and, for a DerivedData folder holding Swift package checkouts that is not itself a Git repository, its directories but SourcePackages; apply reasserts that exact category shape before removal. DerivedData rebuilds on the next build. While Xcode, xcodebuild or its build services run, a folder changed within the activity window (active_days) or holding a file an Xcode process has open is kept, and every other folder is still offered; apply judges each again. A folder holding a project's resolved Swift packages is cleaned around them: each checkout in its SourcePackages is a Git clone, which devtrim never deletes, so the folder's other directories — Build, Index.noindex and the like — are offered one by one and SourcePackages stays. DeviceSupport symbols are copied again only from a connected device running that exact OS build, so a superseded build matters only for symbolicating its crash logs. A folder that cannot be read or measured offers nothing and is named in one error, so the run exits nonzero, while the folders beside it are still offered. Archives exempt by design — they're release artifacts.

toolchainsold swift.org toolchainsCAUTION

Removes only direct .xctoolchain directories not referenced by any verified symlink in the Toolchains directory and not installed by swiftly. Every toolchain swiftly lists in ~/.swiftly/config.json (or $SWIFTLY_HOME_DIR/config.json), and the one it has in use, is report-only and names swiftly uninstall <version>; a configuration that cannot be read, parsed or mapped to folders blocks the scan. A present but missing, broken, escaping, or invalid swift-latest reference blocks the scan; apply rereads both and reasserts the exact direct-child shape. Xcode's built-in chain is unaffected. Reinstallable from swift.org.

dockerunused images + build cacheCAUTION

image prune -a + builder prune on the exact previewed local Unix-socket endpoint. Remote Docker contexts are refused. Volumes are never touched — a volume can hold your live database. The scan also reports the host-side VM disk image as a non-actionable finding measured in allocated blocks: docker system df measures inside the guest, while that sparse image is what the host actually pays for, and pruning does not shrink it. Restart the VM to trigger compaction. The disclosure appears even when the daemon is stopped, which is the one state where the cost is invisible to docker and still present.

uninstallpaths named for an app bundle idINFO

Resolves an app in /Applications or ~/Applications, reads its CFBundleIdentifier, and lists the support directories, caches, containers, preferences, saved state, HTTP storages, WebKit stores and launch agents named for that exact identifier. Matching is exact, never by display name: Amazon Kindle is com.amazon.Lassen, so a name match would find none of its files and would sweep everything containing the word. A conservative report, not an inventory: an app storing data under a product name is invisible to identifier matching — Visual Studio Code keeps ~/Library/Application Support/Code — and group containers are omitted entirely, because their names come from an arbitrary entitlement and a suffix rule would both miss real ones and misattribute others. Report-only: /Applications and most of ~/Library are refused by the protected-path boundary, and widening it would weaken every command rather than only this one.

optimizethree macOS maintenance tasksCAUTION

Three typed commands with fixed arguments: QuickLook thumbnail cache, user font caches, and the Launch Services database. --apply requires an explicit --task quicklook|fonts|launch-services, because one confirmation must not authorize unrelated work — plan_danger takes the maximum, so a cheap task would otherwise ride in on an expensive one's prompt. A task that cannot do what its name says is not offered: those needing root or hours of CPU, and DNS, because dscacheutil -flushcache does not clear the mDNSResponder resolver cache it would advertise. Preview by default; every run is journaled.

dashboardlocal HTML disk mapINFO

devtrim dashboard [PATH] generates a private, self-contained HTML snapshot and opens it in your default browser. The default is your home folder. Use --output disk-map.html --no-open to save to a chosen new file without starting a browser; existing files and symlinks are never overwritten. Explore a responsive treemap, folder breadcrumbs and searchable details beside cleanup previews from the existing categories. Those previews keep their configured project roots: mapping the whole home does not widen cleanup authority. Copied commands start fresh terminal previews; the dashboard never deletes and rejects mutation flags. Available volume space is separate from estimated logical folder sizes. Symlinks are not followed, foreign filesystems are not entered, and unreadable content produces a useful report with disclosed partial lower bounds and a nonzero exit status. There is no server, external asset or telemetry; the HTML contains local paths and should be kept private. Rerun the command to refresh the snapshot.

analyzeinteractive disk explorerINFO

Drill into disk usage largest-first with ↑/↓, Enter to open and Esc to go up; at the top level Esc does nothing and q quits. Measurement runs on a worker thread and streams in, so a directory that takes minutes to size never freezes the screen, and leaving a directory cancels its walk. Symlinks are reported at their own size rather than followed, a different device is never measured or entered, and unreadable subtrees are disclosed as (partial) lower bounds. A lower bound is a partial result: --json lists each one in errors and exits nonzero. Never deletes — deletion in devtrim is always bound to a corroborated category, so use devtrim clean <category> for that.

statusmachine vitals and health scoreINFO

Uptime, load, memory, disk, battery, thermals, cumulative network, and the busiest processes, read through fixed-argv system tools whose parsers fail closed. A metric that cannot be read is reported as unavailable with its reason, never as a zero, and the health score names every input it was missing instead of scoring over the gap. A present Data volume with a failed or malformed disk metric stays unavailable. Root fallback requires a positively absent Data layout; an unreadable or non-directory layout is unavailable. Tools run in the C locale, because a pt_BR decimal comma would otherwise make load and processes unreadable. Memory used is stated as active + wired + compressed, because counting macOS's reclaimable inactive pages reports a healthy machine at 96%.

installersstale downloaded installer archivesCAUTION

Direct children of Downloads and Desktop whose extension is on a closed list (dmg, pkg, mpkg, iso, xip) and untouched for longer than the retention window (retain_days). Age is the modification time, so a copy that preserves it counts as old at once; the preview states the age it judged. Scanning never recurses, so a bundled installer inside an extracted project tree is left alone, and formats that can carry source or user data are never matched. Apply refuses symlinks and any target outside those two directories. Not a cache — nothing re-downloads these automatically.

agentsagent caches, older Codex releases, and stale historyCAUTION

Two tiers, plus the separate Codex release authority below, because coding-agent storage is not one kind of data. Regenerable caches — the Claude Code metadata cache, the Codex catalog cache, the Pi web-search cache, the OpenCode cache — are exact paths their owner rebuilds on demand, and are offered unconditionally at a low danger score. Session history — Claude Code shell snapshots; Codex shell snapshots, session and archived-session trees — is not regenerable, so a child is offered only once the newest regular file anywhere in its subtree is older than the retention window (retain_days). Codex nests sessions as <year>/<month>/<day>, so the day directory is the unit. Authentication material (auth.json, .credentials.json), configuration, memories, skills, agent definitions, installed plugins and .claude.json backups are on neither list. A shell snapshot is history, not cache: Claude Code writes one per session and sources that exact file on every later shell call, and nothing rewrites it if it disappears. ~/.claude/projects is not a root at all: it holds Claude Code auto memory in a memory/ directory keyed by repository root, and since v2.1.248 Claude Code retains a transcript originating in Claude Desktop or Cowork at any age, with nothing in the filename to distinguish one — so file age is not evidence that anything there is finished with. Apply reasserts tier, exact depth, symlink refusal and the age gate, so a session resumed after preview falls out of the plan. Every Codex entry also applies to each additional Codex home listed in codex_homes, labelled with that home.

Older Codex standalone releases are offered separately when the installer lock is available, current resolves to a verified package, and each candidate has an older major/minor/patch version, required executable files, an audited voice manifest, and matching SHA-256 digests for the main executable and voice files. Packages without that manifest stay. Current, same-version, newer, staged, and unknown packages stay. An unverifiable lock or current package produces a read-only refusal and nonzero CLI status while caches and stale history remain available. An invalid older candidate is omitted without an error and left in place. Apply checks again. A release reported in use by the system-wide lsof snapshot is skipped, and apply probes again for each release. The probe cannot see every other user's process, and a process can start after it runs. Trash is the default and must be emptied separately to reclaim physical space. Package checks cannot prove that a modified voice manifest describes only vendor files or that a nonempty executable reports the manifest's version; inspect releases before using --shred.

leftoversreport-only review hintsINFO

Lists possible agent scratch and .supergoal paths for manual review, and the folders inside a repository's top-level .scratch when Git ignores that folder (git check-ignore) and tracks nothing in it; a .scratch child that is a linked worktree is labelled as one. It never deletes any of them: worktree or mission staleness cannot be proven from a name, and ignored files are still somebody's data.

Nuclear — read twice, run once

simulatorsdelete exact previewed unavailable devicesCAUTION

Creates one finding per device whose runtime is gone, binds the exact UDID, and rechecks that device before deletion. --yolo may bypass confirmation, but it never adds erase all or any operation absent from preview. Simulators that still work are listed once, report-only, with their total data size and the three largest devices; deleting one destroys its apps and data, so that stays a manual xcrun simctl delete <UDID>. A Mac with the Command Line Tools but no Xcode has no simctl, and one with no developer tools has no developer directory; xcrun's own error for either means there is nothing to manage, so the category says why and the run still succeeds. Any other xcrun or simctl failure is an error.

trash-emptypurge ~/.Trash permanentlyNUKE

Preview with --confirm=<gb>; every eligible current top-level Trash item is shown as an exact target. Permanent purge additionally requires --apply, the size of that exact set must match within ±2 GB, and the purge still needs the same confirmation every mutation does — typed on a terminal, or --yolo unattended — after the set is shown again. Items moved to Trash after that confirmation are not added. --only-devtrim narrows the set to what devtrim itself moved to the Trash, recognized by the device, inode and birth time its journal recorded for each move, so other programs' and sessions' items stay. Ownership reads the current journal and three retained rotations independently of the 1,000-entry display limit. Malformed or unreadable input, records over 64 KiB, or a snapshot above 42,205,188 bytes (about 40.25 MiB) refuse the whole narrowed purge. Rotated-out records supply no ownership evidence. A direct item named as an ASCII-case variant of .git is warned about and left in Trash instead of blocking the other exact items, and an item refused at apply — a trashed project still holding its repository — is reported while the purge continues with the rest. A uv cache clean caches moved there is purged under the same marker checks that category applies. Moving something to Trash frees no space by itself; apply summaries say how much went to Trash and point here.

05 Safety model

Data-loss warning. devtrim is provided AS IS, without warranties. Cleanup can delete files; applying a plan means you accept the risk for the exact targets shown. Keep backups, review every preview, and grant Files & Folders, App Management, Automation, or Full Disk Access manually only when you understand the macOS request. See the Apache-2.0 license and Apple's Privacy & Security guide.

Independent layers stand between a typo and a tragedy:

LayerRuleBypass
Preview defaultwithout --apply, nothing mutates — evernone needed (default)
Trash-firstfilesystem deletes go to ~/.Trash--shred
Typed targetonly an exact validated path can reach removal; display text has no authoritynothing
Category authorityXcode and toolchain apply reassert direct-child targets, and a DerivedData folder holding Swift package checkouts only its directories but SourcePackages; node_modules apply reasserts its scanner's leaf and ancestor rulesnothing
Single sinkall filesystem removal passes one structurally enforced boundarynothing
Identity verificationpreview-time device/inode is re-verified through an open parent-directory handle; drift refusesnothing
Directory preflightforeign devices and nested Git repository/worktree markers are refused before Trash or permanent mutation; only uv's own empty marker is tolerated, where uv's lock or the Trash grants itnothing
Command authoritytyped capability must match the operation and validated Docker endpoint or simulator UDIDnothing
Risk noticeevery human apply warns that cleanup can cause data loss--json prints no notice and keeps stdout one document; on a terminal an apply without -y or --yolo still asks on stderr
Danger gateevery interactive mutation confirms; critical plans require typed input (see §06)-y skips y/N only; --yolo skips interactive prompts, never operation-specific acknowledgments
TUI approvala typed approval must match the current preview, its selection, and the danger requirement of the selected findings; selection only narrowsnothing; CLI bypass flags are rejected
Protected pathshard denylist belownothing
Protect configuser-listed protect paths are refused at the sink and filtered from previewsnothing
Liveness guarda repo containing the working directory of a running build process, including a coding agent (Codex, Claude Code) by its own working directory, is refused; while Xcode, xcodebuild, or its build services run, a DerivedData folder changed within the activity window or holding a file Xcode has open is refused; a probe that fails or does not answer in time blocksnothing
Apply journalwrite-ahead attempt/result records surround every deletion and typed command; an unwritable journal blocks applynothing
Non-TTY guardpiped runs refuse mutation without explicit consentexplicit flag

Protected paths

System roots (/System, /usr, /etc, /Applications, …) and user-critical dirs (~/.ssh, ~/.gnupg) are refused unconditionally. Under ~/Library, only these managed subpaths are eligible, plus the exact Caches entries listed under caches above:

Developer/Toolchains · Developer/Xcode/iOS DeviceSupport
Developer/Xcode/watchOS DeviceSupport · Developer/Xcode/tvOS DeviceSupport
Developer/Xcode/visionOS DeviceSupport · Developer/Xcode/DerivedData · Caches/Homebrew
npm, Homebrew and Go may report configurable cache paths; devtrim accepts only their exact cache namespaces and revalidates them when applying. The denylist cannot be disabled by any flag. If you find yourself wanting to, the correct tool is a different tool.

Size escalation

Total sizeMinimum dangerEffect
> 1 GB≥ 5rebuildable-state warning
> 10 GB≥ 7elevated large-plan warning
> 50 GB≥ 8highest reversible warning

06 Danger scoring

ScoreMeaningGateExamples
1–2read-only / trivially reversibleactionable CLI plans prompt unless -y; TUI requires yscan, brew cache
3–5regenerable caches and rebuildable stateprompt unless -y; non-TTY needs -ycaches, DeviceSupport, unavailable simulators, node-modules
6–8larger or user-visible state lossprompt unless -yDocker, large sweeps
9–10irreversible bulk deletiontyped numeric confirmtrash-empty
$ devtrim trash-empty
dry-run no changes made. Re-run with --apply and --confirm=14 to act.
$ devtrim trash-empty --apply --confirm=14

The purge acts only on the exact Trash items the preview listed. An item that cannot be measured (a folder left unreadable inside it) is named as an error and stays; the others are still purged, and the run exits nonzero.

07 Configuration

Optional file at ~/.config/devtrim.toml:

roots = ["~/dev", "~/sandbox"]   # scan roots (default: the project folders below that exist)
active_days = 30                 # project build output: Git activity recency defining "active" repos (0 means 1)
retain_days = 30                 # agent history and installers: days untouched before they are offered
protect = ["~/dev/keep"]         # never delete these paths or their children
codex_homes = ["~/.codex-berlin"] # more Codex homes, judged by agents like ~/.codex

Without roots, the project commands (purge, clean node-modules, clean artifacts, clean leftovers, largest, and the project half of scan) look in the conventional project folders that exist: ~/dev, ~/Developer, ~/Development, ~/Projects, ~/Code, ~/GitHub, ~/Repos, ~/Workspace, ~/www, and ~/.codex/worktrees (Codex’s worktrees; Claude Code keeps its worktrees inside the repository). They never walk the whole disk or home folder; Desktop, Documents, Downloads, cloud storage, and ~/Library stay out unless you name them. A root only says where to look: every finding still needs its category’s corroborated name, Git owner, and staleness. Each human preview opens with an info scan roots: line naming the roots and where they came from.

active_days decides when a repository’s regenerable output is offered; a shorter window frees more sooner but reinstalls or rebuilds a project you return to, and cannot see uncommitted edits. retain_days decides when files nothing can regenerate — agent session history and installer archives — are offered; unset, it is active_days or 30, whichever is longer, so lowering active_days never shortens how long those are kept. The skip notes name the window that held a project back.

CLI --root flags override config roots entirely, and an explicit root that does not exist is warned about rather than silently scanning nothing. Unknown keys are rejected so a misspelled safety setting cannot appear active. protect entries expand ~, must be absolute, and are enforced deny-only at the deletion sink; a relative or malformed entry is an error.

codex_homes names additional Codex homes (folders Codex uses as its CODEX_HOME). clean agents applies every Codex entry to each one exactly as to ~/.codex, at scan and again at apply, and labels each finding with its home; a home not listed is never touched. Each entry must expand to an absolute path naming a real directory inside the home folder, reached through no link, that is neither ~/.codex (by spelling or as the same folder) nor inside or around it or another entry; anything else, or a value that is not a list of strings, is a configuration error and the command refuses to run.

08 Agent integration

Every non-interactive command accepts --json, emitting exactly one document — for cleanup and report commands this response envelope:

{
  "operation": "xcode",
  "applied": false,
  "findings": [
    {
      "label": "DerivedData: Project-hash",
      "path": "/Users/you/Library/Developer/Xcode/DerivedData/Project-hash",
      "size_bytes": 40265318400,
      "note": "build output; rebuilt on next build",
      "danger": 7,
      "action": { "type": "trash" }
    }
  ],
  "errors": []
}

devtrim history --json emits its own single document — {"operation":"history","entries":[…],"errors":[…]} — where each entry carries numeric ts, phase, op, action, and either target or the exact argv. Agents can audit exactly what a previous apply attempted and what completed; an attempt without a result means the process was interrupted mid-apply.

devtrim status --json is a vitals report rather than an envelope: uptime_seconds, load_average, cpu_count, memory, disk, battery, thermal, network, top_processes, health, and unavailable. An unread metric is null with its reason in unavailable, and the process exits nonzero whenever that list is not empty. status --watch is interactive and rejects --json.

devtrim dashboard --json --no-open emits snapshot metadata with schema_version, root, output_file, inventory, coverage, volume, recommendations and errors. A category's preview_command may be null, with preview_unavailable explaining why a faithful command cannot be offered. Copied project previews retain explicit --root overrides; control-character quoting requires zsh, Bash or a POSIX.1-2024 shell. Add --no-open for automation: --json alone still opens the report.

Pair with an AGENTS.md rule: “run devtrim scan --json before proposing disk changes; propose devtrim clean … rather than raw rm; audit with devtrim history --json.”

09 Recipes

The quarterly deep-clean

  1. devtrim scan — eyeball the landscape. No flags, no risk.

  2. devtrim clean caches --apply -y — regenerable caches go to Trash.

  3. devtrim purge — stale node_modules and corroborated build artifacts, grouped by project; review, then rerun with --apply (or pick items in the TUI with p).

  4. devtrim clean xcode --apply -y — DeviceSupport + DerivedData.

  5. devtrim trash-empty --confirm=<gb> — the Trash still holds everything above; free the space only once you are sure, with --apply.

  6. df -h /System/Volumes/Data — verify.

Inspecting large iCloud Drive files

devtrim icloud

Use this inventory to find large logical files and estimate their current local allocation. Manage downloads and eviction in Finder, which owns the user-visible iCloud state.

Pre-flight before a big clone or model download

devtrim scan --json | jq '[.findings[].size_bytes] | add // 0'
devtrim clean caches --apply -y

Keeping worktrees small

devtrim worktrees shows where linked worktrees take space; these habits keep each one from holding a full copy of what the main checkout already has:

devtrim reports estimated logical bytes. APFS clones share their blocks, so cloned trees look larger in every devtrim report than the space they hold.
Trash is your audit log. Everything cleaned today is restorable from Finder until you empty it.

10 Troubleshooting

df says less space than expected after a clean
  • Purgeable space accounting — macOS counts evictable content inconsistently between calls; trust trends over single readings.
  • Docker sparse disk — space freed inside the VM reaches macOS only when the runtime returns the blocks; OrbStack and Docker Desktop document that as automatic but it is not guaranteed, so scan again to measure the image.
  • Files went to Trash — Trash-first means space returns when Trash empties. That's the net working.
non-interactive run: re-run with -y…

You piped stdin without an explicit consent flag. Intentional. Add -y for routine ops; reserve --yolo for unattended maintenance windows.

the TUI requires an interactive terminal

Both stdin and stdout must be terminals. Use an explicit command such as devtrim scan --json in scripts, pipes, CI, or agent workflows.

skipping … because a build process is active

A running build or package process (node, cargo, python3, go, and the like) has its working directory inside that repo, or Xcode, xcodebuild, or an Xcode build service is running. The note names the first repositories held; a dev server or watcher left running there counts. Finish the build (or quit Xcode) and re-run. If the probe itself fails, devtrim blocks instead of guessing — that is the guard working.

refusing protected path

A root or computed target intersects the denylist. Check config roots; remember --root ~ makes nearly everything protected-by-design.

simulator storage differs from the preview

Each preview row measures and authorizes one unavailable device. devtrim rechecks that exact UDID before deletion; CoreSimulator may still compact bundles asynchronously afterwards.

an owner command failed

devtrim returns nonzero and includes the error in JSON. Failed actions are never counted as touched or reclaimed; fix the owner tool and preview again.