AI Decision System
Replayable, offline-verifiable records for AI-assisted go/no-go decisions: experiment promotions, typed model answers and human-approved actions.
devtrim — 로컬 우선 디스크 정리로 macOS 개발 머신을 관리하고, 위험 점수 기반 보호와 함께 쓰레기 우선 삭제를 제공합니다
devtrim — local-first disk hygiene for macOS developer machines. Measure, classify, trim. Trash-first deletion with danger-scored guardrails.
개발 도구Rust · PythonGitHub @mneves75온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
Safe disk hygiene for macOS developer machines: measure, classify, trim
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
Developer-machine disk hygiene for macOS: measure, classify, trim — safely.
Born from a cleanup session that reclaimed 250+ GB across model caches, stale
node_modules, simulator storage, Xcode support files, Docker bloat, and old
Swift toolchains.
This source tree and its packaged documentation describe devtrim v0.10.9.README 전문 보기 ↗Developer-machine disk hygiene for macOS: measure, classify, trim — safely.
Born from a cleanup session that reclaimed 250+ GB across model caches, stale
node_modules, simulator storage, Xcode support files, Docker bloat, and old
Swift toolchains.
This source tree and its packaged documentation describe devtrim v0.12.0.
With Homebrew:
brew install mneves75/devtrim/devtrim
Or download the Apple silicon archive for the version you intend to run from GitHub Releases, then verify it. The checksum comes from the same release as the archive, so it proves only an intact download; the build attestation proves which workflow built it from which commit:
shasum -a 256 -c SHA256SUMS.txt
gh attestation verify devtrim-<version>-macos-arm64.zip --repo mneves75/devtrim \
--signer-workflow mneves75/devtrim/.github/workflows/release.yml \
--deny-self-hosted-runners
Or build from source:
git clone https://github.com/mneves75/devtrim
cd devtrim
cargo build --release --locked
cp target/release/devtrim /usr/local/bin/
trash-empty, requires --apply.SourcePackages; node_modules apply reasserts a real authorized directory leaf and rejects symlinks plus .git, nested dependency-tree, and non-normal ancestors.--shred explicitly previews permanent deletion and raises danger to critical.gpg.program, and lazy fetches through a promisor remote's uploadpack. Previewing a directory that arrived with a hostile .git/config runs nothing. A git too old for --no-lazy-fetch refuses the repository.simctl, docker, npm, brew and go queries, three for the pgrep and lsof probes, fifteen for the Docker, simulator and maintenance commands an apply runs; a timeout kills the command's whole process group), while the other categories still complete. Interrupting a run with Ctrl-C or SIGTERM kills every command it started before devtrim exits with 130 or 143; the TUI restores its terminal first and then does the same, and q or Ctrl-C typed during a TUI scan stops it the same way (exit 130).node-modules and artifacts refuse a repo that is the working directory of a running build or package process; while Xcode, xcodebuild, or the build services Xcode.app builds run through are running, xcode offers only DerivedData folders untouched for longer than the activity window that no Xcode process holds a file open in, and apply judges each again. A probe that cannot complete blocks instead of passing; a build tool that exited between the process list and the directory lookup is not mistaken for one..git uv writes into its own source-distribution bucket, which Git itself rejects as an invalid gitfile, tolerated only in that exact shape under a tagged cache root that clean caches holds uv's lock on or trash-empty finds in the Trash. Permanent deletes additionally quarantine the verified leaf and drive recursion through open handles. A target swapped after preview is refused. Trash remains path-based because macOS has no fd-anchored Trash API; that residual window is documented, not denied.~/.local/state/devtrim/journal.jsonl ($XDG_STATE_HOME honored). Symlinked path components and journal/lock files with another owner or multiple hard links are refused; complete records are serialized and synced, and an unwritable journal blocks apply. Rotation (10 MiB, keep 3) cannot split an in-flight pair. devtrim history is read-only, waits for guarded applies before snapshotting, pairs legacy records across generations, reverse-scans only the bounded newest tail needed for the requested limit, and reports a genuinely unmatched attempt as interrupted.-y skips it); non-TTY apply needs -y/--yolo--yolo skips confirmation only)PathBuf targets must become an internal VerifiedTarget immediately before the single deletion sink can consume them. Display strings are never deletion authority.scan --shred remains meaningful because it changes the previewed action, while report-only commands reject it.NO_COLOR, degrading each token to a modifier that preserves the same distinction; the danger ladder stays ordered even with color stripped. Colors are named ANSI, so they resolve through your own terminal theme instead of a fixed palette that matches nowhere.--json invocation emits exactly one JSON document and failures return nonzero.devtrim is free, open-source software provided AS IS, without warranties or
conditions of any kind; the Apache-2.0 license is authoritative.
Cleanup can delete files. Safety checks reduce risk but cannot replace a current
backup or your review of the preview. By applying a plan — including with -y
or --yolo — you accept the risk of data loss for the exact targets shown.
Preview first, prefer Trash, and use --shred or trash-empty only when you
intend permanent removal. macOS may deny access or ask you to authorize Files &
Folders, App Management, Automation, or Full Disk Access in System Settings;
grant access manually only when you understand the request. devtrim never
bypasses macOS protections. Apple documents these controls under
Privacy & Security.
devtrim # interactive TUI when stdin/stdout are terminals
devtrim tui # explicit TUI launch
devtrim scan # read-only report: categories first, then the largest findings
devtrim scan --all # every finding of every category
devtrim scan --json # one machine-readable envelope, always complete
devtrim purge # stale node_modules + build artifacts, by project, largest first
devtrim purge --apply # move that exact plan to Trash after confirmation
devtrim clean caches --apply -y # tool download caches (HF, uv, npm, brew, cargo, bun, gh, Gradle, Go, …)
devtrim clean node-modules --apply -y # exact paths in Git repos with no recent activity
devtrim clean artifacts --apply -y # corroborated build artifacts in stale Git repos
devtrim clean simulators --apply -y # delete exact previewed unavailable devices; working ones are only reported; no Xcode, nothing to do
devtrim clean xcode --apply -y # exact iOS/watchOS/tvOS/visionOS DeviceSupport and DerivedData children; package checkouts stay
devtrim clean docker --apply -y # local daemon images + build cache; never volumes
devtrim clean toolchains --apply -y # only swift.org toolchains no symlink or swiftly references
devtrim clean installers --apply -y # stale installer archives in Downloads/Desktop
devtrim clean agents --apply -y # agent caches + older Codex releases + stale session history
devtrim clean leftovers # report-only hints and ignored .scratch folders
devtrim worktrees # report-only: linked worktrees and their sizes
devtrim icloud # large iCloud Drive files and local allocation
devtrim trash-empty --confirm=14 # preview permanent Trash purge
devtrim trash-empty --confirm=14 --apply # perform the verified purge
devtrim trash-empty --only-devtrim # only what devtrim itself moved to the Trash
devtrim history # recent journaled applies; --json for one document
devtrim analyze # interactive read-only disk explorer (never deletes)
devtrim analyze ~/Library --json # one-shot breakdown of a directory
devtrim dashboard # open a local, read-only HTML disk map of your home folder
devtrim dashboard ~/dev # map a chosen folder; cleanup roots keep their configured scope
devtrim dashboard ~/dev --output disk-map.html --no-open # save a private offline snapshot
devtrim status # read-only machine vitals and a health score
devtrim status --watch # live dashboard; q quits
devtrim uninstall AltTab # paths named for an app's bundle id (report-only)
devtrim optimize # preview macOS maintenance tasks
devtrim optimize --task quicklook --apply # run one selected task
devtrim largest --top 20 # read-only: biggest directories under scan roots
devtrim completions zsh # shell completion script (bash | zsh | fish)
devtrim manpage # man page in roff format
Global flags are capability-scoped. Read-only/report-only commands reject
--apply, -y, --yolo, and --shred instead of pretending to honor
them. scan --shred is the exception because it explicitly previews
permanent actions. Docker and simulator cleanup reject --shred because they
execute exact typed commands rather than filesystem deletion actions.
trash-empty accepts apply/confirmation flags but rejects --shred: its
preview is already permanent and its exact --confirm=<gb> acknowledgment
remains mandatory.
Moving to Trash frees nothing yet: the bytes stay on the same volume until the
Trash is emptied. Apply summaries say so — "~17.8 GB moved to Trash; it is freed
once the Trash is emptied (devtrim trash-empty)" — and devtrim trash-empty
previews the exact --confirm=<gb> value it needs.
purge is the project view of two existing categories, not a new authority:
stale node_modules and corroborated build artifacts in one plan, ordered by
project with the largest first. Each finding keeps its category's staleness and
build-liveness gates and is applied by that category, which reasserts its exact
shape. Like Mole's mo purge, it skips a directory holding files its
repository tracks, and a build directory holding a Solana program keypair or a
nested Git repository. Unlike mo purge, it never
matches ambiguous names such as build, dist, or coverage, never deletes
permanently unless you pass --shred, 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 ~.
purge, clean node-modules, clean artifacts, clean leftovers, largest,
worktrees, and the project half of scan look only under the scan roots, never the whole
disk or the whole home folder. Without configured roots those are the
conventional project folders that exist: ~/dev, ~/Developer,
~/Development, ~/Projects, ~/Code, ~/GitHub, ~/Repos, ~/Workspace,
~/www, and ~/.codex/worktrees, where Codex creates its worktrees — Mole's
mo purge defaults plus the folder Finder marks as Developer. Claude Code
puts its worktrees inside the repository (.claude/worktrees/), which the
repository's own root already covers. --root or roots in the config replaces them. A root only says
where to look; 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. Every finding still needs its category's corroborated name, Git
owner, and staleness. Folders macOS guards with a privacy prompt (Desktop,
Documents, Downloads), cloud storage, and ~/Library stay out unless you name
them. Every human preview of these commands begins with an info scan roots:
line naming the roots, where they came from, and how to change them, so an empty
result never reads as a clean machine when devtrim looked in one place. The skip
notes name what is holding findings back: the activity window, the
repositories where a build process is running, and repositories that cannot be
judged yet — a linked worktree whose repository is gone, or a repository
created with git init and not yet committed to.
A node_modules counts as an install only when the package.json it was
installed from sits beside it, because only that manifest lets a later install
recreate it. React Native copies package images into
<App>.app/assets/node_modules, so every archived or unpacked iOS build holds a
node_modules with no manifest, and removing it would break the bundle's
signature; a packaged tool such as corepack's pnpm ships its dependencies in
dist/node_modules the same way. Neither is offered, and apply refuses one.
A node_modules inside a repository's own build output — Next.js
output: 'standalone' copies one into .next/standalone — belongs to that
output, which is offered or kept whole; it is never a finding of its own, so one
plan never counts the same bytes twice, and apply refuses one. When that output
is kept — it holds a tracked file, a keypair, or a repository — the
node_modules inside stays with it. The walk never enters build output below a
scan root, which also keeps a stale repository left inside a Cargo target out
of the plan; like the roots themselves, that bounds where devtrim looks rather
than what apply may remove.
clean artifacts deletes a directory only when its name is on a closed list
and its ecosystem corroborates it — target next to Cargo.toml, .venv
containing pyvenv.cfg, Pods next to Podfile, .next next to
package.json, Android's native build folder .cxx next to build.gradle,
.terragrunt-cache next to terragrunt.hcl, Nuxt's .output next to
nuxt.config.*, a valid CACHEDIR.TAG signature, and so on (the Python tool caches
__pycache__, .pytest_cache, .mypy_cache and .ruff_cache are matched by name
alone, since no other tool writes them) — inside a Git repo
whose last activity is conclusively stale. Ambiguous names such as build,
dist, vendor, bin, and obj are deliberately never matched, and the
scanner/apply owner refuse artifacts below every ASCII-case variant of
node_modules. A directory holding any file its repository tracks is part of
the repository, not build output — CocoaPods recommends committing Pods — so
neither artifacts nor node-modules offers one. artifacts also never offers
a tree holding a Git repository anywhere below its root — a SwiftPM .build
keeps its dependencies as Git clones — nor one holding 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. Both
checks run again at apply. A tree holding Terraform state (*.tfstate) is never offered either:
Terraform's local backend writes it into the working directory Terragrunt
runs it in, inside .terragrunt-cache, and nothing regenerates it.
clean installers considers only direct children of Downloads and Desktop
whose extension is on a closed list (dmg, pkg, mpkg, iso, xip) and
which have been untouched for longer than the configured retention window
(retain_days). Age is the file's modification time, so a copy that preserves
it — a Finder copy from another disk, for example — counts as old immediately;
the preview shows the age it judged. Scanning is deliberately non-recursive,
because those directories routinely hold extracted project trees whose bundled
installers are not loose clutter. Formats that can carry source or user data,
such as zip and tar, are never matched. Apply re-checks the whole shape and
refuses symlinks and any target outside those two directories.
clean agents covers coding-agent storage in two tiers, because it 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, so they 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 configured retention window (retain_days), its note says the
content does not come back, and its danger score reflects that. Codex nests
sessions as <year>/<month>/<day>, so the day directory is the unit; waiting
for a whole year to go stale would never offer the current one. Authentication
material (auth.json, .credentials.json), configuration, memories, skills,
agent definitions, installed plugins and the .claude.json backup copies are on
neither list and are never candidates. Apply reasserts the full shape — tier
membership, exact depth below the configured root, no symlink, and the age gate
re-read from disk — so a session resumed after preview falls out of the plan.
Agent scratch worktrees are a different question and stay where they were:
clean leftovers lists them for review and never deletes them, because a
worktree's staleness cannot be proven from its name. It also lists the folders
inside a repository's top-level .scratch when Git ignores that folder and
tracks nothing in it, labelling a linked worktree there as one.
devtrim worktrees shows where linked worktrees take space: one
git worktree list --porcelain -z per repository under the scan roots, through
the hardened Git invocation, with the branch or detached HEAD and any lock or
prune reason. A listed path is the repository's claim, so it is measured only
when it is a real directory inside a scan root whose .git file leads back to
that repository; the main worktree is listed unsized. 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 adds no deletion authority; git worktree remove runs the repository's
configured programs, so use it only where you trust the repository. The manual's
Keeping worktrees small recipe covers sparse checkouts,
per-worktree installs and APFS clones.
clean agents also offers older Codex standalone releases when the installer lock
is available, current resolves to a verified package, and each candidate is a
direct child with Codex's versioned package manifest, required executable files,
and only resource names checked against the audited 0.156.1 bundle. A voice
manifest is required; packages without one stay untouched. Voice files
and the main executable must match the manifest's SHA-256 digests. This follows
the standalone installer's package checks. A
release with the same or a newer major/minor/patch version is kept. Apply checks
the installer lock, current target, and package shape again. A release reported
in use by the system-wide lsof snapshot is skipped; apply probes again for
each release. This reduces interruption risk for an agent started before an
upgrade, but other users' processes may be invisible and a process can start
after the probe. Trash is the default; moving releases there reduces
~/.codex but does not reclaim physical disk space until Trash is emptied.
If the lock or current package cannot be verified, the preview shows a read-only
refusal, exits nonzero, and still lists eligible caches and stale history. An
invalid older candidate is omitted without an error and left in place. Package
checks cannot prove that a modified voice manifest still describes only vendor
files, or that a nonempty executable reports the manifest's version. Inspect a
release before permanently shredding it.
Two of those rules exist because the obvious design was wrong. A shell snapshot is not a cache: Claude Code writes one per session and sources that exact file on every later shell call, and nothing rewrites it if it disappears — so it sits in the age-gated tier. Age is the right signal there because Claude Code sweeps the same directory itself once an entry passes its own retention period.
And ~/.claude/projects is not a root at all. It holds Claude Code auto memory
at <project>/memory/, keyed by repository root while the transcripts beside it
are keyed by working directory — 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. File age is therefore not evidence that
anything under that root is finished with, and identifying the exception would
mean reading transcript contents. It returned 0.00 GB on the machine this was
built against, so the honest trade was to leave it alone.
clean caches also reaches a closed list of exact ~/Library/Caches
subdirectories — Playwright browsers, the VS Code HTTP cache and its Squirrel
update staging, SwiftPM, pip, pnpm, Go and TypeScript. ~/Library stays protected
wholesale; that list is the carve-out, it is the same constant the protection
boundary reads, and a name is only on it when one developer tool owns the
directory and rebuilds it on demand.
Homebrew's cache, found through brew --cache, is offered whole unless it is
the standard ~/Library/Caches/Homebrew and holds a Git clone Homebrew keeps for a Git-sourced formula (<name>--git).
devtrim never removes a repository, so such a cache is offered as its other
direct children — downloads, api, Cask, and so on — and the clones stay;
links Homebrew left at the top are left for brew cleanup.
Two boundaries inside that are worth stating, because a shorter list would
over-claim. The pnpm entry is the metadata cache, never the content-addressable
store at ~/Library/pnpm/store that installed node_modules trees hard-link
into. And editor logs and application data are not covered at all: VS Code
keeps its logs, CachedData and webview caches under ~/Library/Application Support/Code, and JetBrains keeps logs under ~/Library/Logs — both outside
~/Library/Caches, both mixed in with real user state, and neither reachable
without a second carve-out this release does not make. ~/Library/Caches/JetBrains
is excluded for a stronger reason: on macOS that is the IDE system directory,
and each product subdirectory holds LocalHistory, the per-file change history
the IDE keeps for files Git never saw. Nothing regenerates it.
analyze is navigation, never deletion, and that boundary is deliberate. Every
cleanup category binds deletion to structural corroboration — a target beside
Cargo.toml, a .venv containing pyvenv.cfg — and an explorer that deleted
whatever the cursor was on would swap that evidence for the operator's aim. It
measures on a worker thread and streams results in, so a directory that takes
minutes to size never freezes the screen; 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 anything unreadable is disclosed as a
(partial) lower bound. A lower bound is a partial result, so analyze --json
lists each one in errors and exits nonzero, as quitting the explorer does.
status reads machine vitals through fixed-argv system tools and reports a
health score that names every input it could not read rather than scoring
over the gap. Memory used is stated as active + wired + compressed: counting
macOS's reclaimable inactive pages as used reports a healthy machine at 96%.
devtrim dashboard generates a local HTML snapshot of your home folder and
opens it in your default browser. Supply a directory to map that folder instead:
devtrim dashboard ~/dev
devtrim dashboard ~/dev --output disk-map.html --no-open
The light dashboard combines an interactive treemap, folder breadcrumbs,
searchable details and cleanup previews. It works offline without a server,
external fonts or uploaded data. The generated file contains local paths and
sizes; keep it private when sharing files. Output files are private and an
explicit --output refuses an existing file or symlink.
Available disk space comes from the selected folder's filesystem. Mapped sizes are estimated logical bytes, which may differ from physical blocks because of APFS clones, hard links and sparse files. Unreadable content is disclosed as a partial lower bound; the command still saves the useful report and exits nonzero. Symlinks are measured at their own size, and other filesystems are not traversed. The overview retains up to six levels and 10,000 entries. Detail beyond that limit is summarized; its measured bytes stay in the totals. Search covers the retained overview. Generate a new report rooted at a folder for a closer view.
The dashboard never deletes. Cleanup previews come from the existing categories
and keep their configured project roots; mapping your home does not authorize
cleanup across it. Copied commands start a fresh CLI preview, where you review
the current findings before any apply. The HTML is a snapshot, so rerun the
command to refresh it. --no-open saves the report without starting a browser.
Copied root-scanning previews preserve explicit --root overrides. Roots are
quoted as data; control-character quoting requires zsh, Bash or a POSIX.1-2024
shell. If a resolved root cannot be represented faithfully, the report explains
why a copy command is unavailable. It never substitutes a lossy display path.
devtrim has no account, backend or telemetry store. It creates local reports and an apply journal; copies you export or share have their own lifetime.
| Data | Location and retention |
|---|---|
| HTML disk maps | The path printed by dashboard, or your explicit --output. The default is devtrim-dashboard-<random>.html in the operating system's temporary directory. devtrim does not expire reports; temporary-directory cleanup is controlled by the OS. |
| Apply history | ~/.local/state/devtrim/journal.jsonl, or <XDG_STATE_HOME>/devtrim/journal.jsonl when XDG_STATE_HOME is absolute. Rotation starts at 10 MiB and retains .1, .2 and .3; this is size-based, not a time-to-erasure guarantee. journal.lock in the same directory coordinates access. |
| Exported output | Your chosen files for redirected JSON, history, terminal logs, screenshots or copied commands. devtrim does not track or expire these copies. |
Reports and history can contain local paths, names, sizes, commands and errors.
To remove them, first let every apply using that state directory finish and
exit the TUI. Locate the exact report/export files and, if you choose to discard
history, the current journal and all three retained generations. Remove only
those files you own, using your file manager; do not remove or replace a lock
while another devtrim process may use it. Removing the journal loses its audit
trail and the ownership evidence used by trash-empty --only-devtrim: older
Trash items can no longer be recognized through the deleted records.
Separately remove copies you chose to keep in backups, cloud storage, messages, logs or other exports, following each location's retention and restore policy. Restoring a backup can restore the data. Moving a report to Trash leaves it on disk until you decide to empty it; deleting a file is not a guarantee of physical erasure from SSDs, snapshots or backups. There is no backend deletion request for devtrim to process. Keep any history needed for an ongoing investigation.
Other Mac cleaners delete more. These boundaries are choices with reasons:
uninstall reports; it does not remove. It resolves an app's bundle
identifier and lists the paths macOS keys by it — the hard part — but it is a
conservative report, not an inventory: an app storing data under a product
name is invisible to it, as Visual Studio Code's ~/Library/Application Support/Code is. It does not delete because is_protected refuses
/Applications and everything under ~/Library outside a closed allowlist
of developer-managed paths, and widening that would weaken every command,
not just this one.optimize is three tasks, not twenty-two. Rebuilding a Spotlight index,
running the periodic scripts and purging memory all need root and cost more
than they return. A DNS flush is absent too: on modern macOS the resolver
cache lives in mDNSResponder, so dscacheutil -flushcache would report
success without clearing what it names. --apply requires an explicit
--task, because one confirmation must not cover unrelated work.analyze never deletes. Navigation and deletion authority stay separate:
every cleanup category binds deletion to structural corroboration, and an
explorer that removed the highlighted path would replace that with your aim.clean docker also reports the host-side VM disk image for OrbStack and Docker
Desktop, as a report-only finding that is never actionable. docker system df
measures space inside the guest, while the host pays for a sparse image that a
prune shrinks only once the runtime returns the freed blocks — OrbStack documents
that as automatic and Docker Desktop as taking seconds, but neither is
guaranteed, so scan again to measure what came back. The build-cache estimate is
"up to" the full cache size when no record is in use, because builder prune -a
also removes cache shared with images that Docker does not count as reclaimable,
and otherwise "at least" the reclaimable figure. The VM image finding is measured in allocated blocks rather
than logical length, and it is shown when docker is not installed at all.
When docker is installed but its daemon is down, the category fails — a
silently shorter plan would read as nothing to reclaim — and its error names
the image and its size, which is the one state where the cost is invisible to
docker and still present.
clean caches offers Gradle's caches and wrapper/dists under
GRADLE_USER_HOME (default ~/.gradle), never that folder itself, which holds
gradle.properties and init.d, and only when each folder holds what Gradle
writes there (caches/modules-2, or a gradle-<version>-bin/-all
distribution) and the Gradle home is not inside a Git repository:
GRADLE_USER_HOME alone never makes a folder a cache. Gradle locks those files only among its own
processes, so while a Gradle daemon, build or Wrapper runs they are left out of
the preview, and apply refuses them if Gradle started after it; gradle --stop
ends the daemons. The Go module cache is the folder go env GOMODCACHE reports,
offered only when it lies inside the home folder and holds Go's
cache/download. Go keeps that cache read-only, so a move to the Trash works
but --shred refuses it whole rather than stopping half-way. Once it is in the
Trash, devtrim trash-empty purges it: that confirmed purge gives a read-only
folder you own its owner's write permission just before emptying it. go clean -modcache is Go's own way to delete it in place.
clean toolchains keeps every toolchain swiftly installed. swiftly, the
installer swift.org recommends on macOS, selects the toolchain in use through
~/.swiftly/config.json (or $SWIFTLY_HOME_DIR/config.json) rather than a
symlink, so each toolchain it lists, and the one it has in use, is shown as a
report-only finding naming swiftly uninstall <version>, swiftly's own way to
remove it, and apply reads the configuration again. A configuration that cannot
be read, parsed or mapped to toolchain folders withholds the whole category
with an error.
trash-empty previews each current top-level Trash item as an exact target.
--apply shows that set again and purges it only after the same confirmation
every mutation requires — typed on a terminal, or --yolo — so an item moved
to Trash after the earlier preview is shown before it can be approved. The
--confirm=<gb> acknowledgment is measured over that exact set, not the whole
Trash, so an excluded item never makes it unsatisfiable. Anything moved to
Trash after confirmation remains.
The Trash is shared with every other program and session, so --only-devtrim
narrows the purge to what devtrim itself moved there: each move to the Trash
journals the item's device, inode and birth time, which survive the move while
the Trash renames the item at will, and only a Trash item matching a successful
move is offered. Everything else stays and is counted in a note; an apply
journal that cannot be read whole refuses the narrowed purge and offers
nothing, because the record that tells an item apart could be the one missed.
Ownership reads every retained generation (current plus three rotated files),
independently of history's 1,000-entry display limit. Malformed or unreadable
records, records over 64 KiB, or a snapshot above 42,205,188 bytes (about
40.25 MiB) refuse the entire narrowed purge. Records already rotated out are
not recoverable 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.
The TUI offers the same scanners and apply owners behind a keyboard interface:
arrow keys or j/k navigate, Enter previews, a starts confirmation, s
switches an already-previewed Trash action to permanent mode and back, and r
rescans. 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 runs while the screen stays live and counts the seconds it has
taken, since a process probe can take minutes on a loaded machine. 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, such as one whose liveness probe did not answer, is counted in
the results title and named in the status line, and r retries it, from the
failure screen too.
A preview lists one finding per line with a detail pane that grows to show the
highlighted one whole; 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 in the menu carries a badge that the pane beside it explains:
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, so its selection
and apply keys do not act there; they name the category view that can act on
the highlighted finding, such as "b, then 2 (caches)". Inside 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. A key that cannot act on the highlighted row says why in the
status line instead of doing nothing. The confirmation then covers exactly the
selected findings and says how many that is and how many were left out, so
leaving out a critical item can lower a typed-size confirmation to y/N. Selection only narrows what was previewed, and changing it
after confirming discards that confirmation. p opens the project purge view.
Results show retained scanner warnings as rows after the findings; outcomes
scroll with arrows or j/k, including partial-apply errors. Risk labels are written as text as well as
color. Below 64×18, the interface blocks operation input and asks you to resize;
only quit remains available. The interface requires an
interactive stdin and stdout; bare prints help and exits nonzero when
piped, while automation continues to use explicit subcommands and .
TUI confirmation is deliberately separate from CLI bypass flags. devtrim tui
rejects --apply, -y, --yolo, --shred, and --json; ordinary actions
require y, critical plans require their displayed numeric size, and Trash
purge requires the exact phrase PURGE <gb>. The warning is shown after the
exact preview and before authorization.
-y acknowledges the data-loss warning and bypasses normal y/N prompts;
critical plans still require typed confirmation. --yolo acknowledges the
risk and skips interactive prompts, but it never bypasses operation-specific
acknowledgments such as trash-empty --confirm=<gb> or adds an operation that
was absent from the preview.
Localized scan failures remain visible and non-actionable in the TUI and make
its eventual exit nonzero; a later successful scan does not clear that failure.
Build-process liveness refuses decoded lsof CWD names that are not absolute,
including diagnostics, at both preview and apply.
status uses the Data volume when layout inspection finds its directory.
A failed or malformed Data metric stays unavailable with a reason and nonzero
status. Root-filesystem fallback requires a positively absent Data layout;
an unreadable or non-directory layout is unavailable.
~/.config/devtrim.tomlroots = ["~/dev", "~/sandbox"] # scan roots (default: the project folders above that exist)
active_days = 30 # project build output: repos with newer Git activity are active (0 means 1)
retain_days = 30 # agent history and installers: offered only after this many days untouched
protect = ["~/dev/keep"] # never delete these paths or their children
codex_homes = ["~/.codex-berlin"] # more Codex homes, judged by `agents` like ~/.codex
codex_homes names additional Codex homes, folders Codex uses as its
CODEX_HOME, for example one per account. clean agents applies every Codex
entry to each of them exactly as to ~/.codex: the catalog cache, the shell
snapshots, session and archived-session history, and older standalone
releases, at scan and again at apply, each finding labelled with its home.
Nothing is inferred from a folder's name, so a home not listed is never
touched. An 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.
protect entries expand ~, must be absolute, and are enforced deny-only at
the single deletion sink — a protected target is refused even if a scanner
offers it, and previews filter it out with a diagnostic. Relative or malformed
entries are an error, never silently ignored; an entry that does not resolve to
an existing path warns loudly. Matching is Unicode-normalization-insensitive
(NFC config text protects an NFD on-disk name) and ASCII-case-insensitive,
symlinked entries also protect their resolved location, and deleting an
ancestor of a protected entry is refused too.
There are two windows because they answer different questions. active_days
decides when a repository's regenerable output — node_modules, build artifacts
— is offered: a repository is active when its HEAD commit or its newest HEAD
reflog entry is inside it. A shorter window frees more sooner, at the cost of
reinstalling or rebuilding a project you return to, and it cannot see edits you
have not committed, so keep it longer than the gap between your commits on a
project you are still working on. A build or dev server whose working directory
is in the repository protects it at any window, when its process name is one
devtrim recognizes (node, cargo, python3, go, and the like). The reflog
is what a clone, checkout, or pull writes, so an old project cloned today —
whose dependencies were just installed — is not offered; a repository with
reflogs disabled is judged by its commit date. 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 to free build output never shortens how long those are kept.
Explicit --root flags replace configured roots, which replace the default
project folders. Existing roots are resolved before preview; an explicit root
that does not exist is warned about instead of silently scanning nothing, a
default folder that is absent is simply not scanned, and one that cannot be
resolved or read, or that links to the home folder or above it, is skipped with
a warning. Two names for one folder are
scanned once. An unreadable, malformed, or unknown config field is an error;
devtrim never silently falls back to another root.
Cleanup JSON mode returns one envelope, including empty and failed results:
{
"operation": "caches",
"applied": false,
"findings": [],
"errors": []
}
Applied commands additionally include summary. If a target fails, the summary still records the work that succeeded, errors names each failure, and the process exits nonzero; caches, agents, trash-empty, xcode, artifacts, and node-modules continue past a refused item — artifacts and node-modules after a preflight of every finding, which stops the whole plan untouched when a probe or recheck fails — the other categories stop at their first failure, and purge runs its node-modules and artifacts halves independently. Each action is typed (trash, shred, command, info, or none) rather than encoded as a shell string. summary.bytes_trashed_estimate is the part of bytes_freed_estimate that was moved to Trash and is still on disk until the Trash is emptied. Findings from node-modules, artifacts, and purge carry project, the repository that owns the target — for grouping only, never deletion authority.
devtrim history --json emits its own single document —
{"operation":"history","entries":[…],"errors":[…]} — where each entry is a
journal record with numeric ts, phase, op, action, and either target
or the exact argv. completions and manpage have no JSON form and return
the standard error envelope when --json is passed.
devtrim status --json is also its own single document: a vitals report with
uptime_seconds, load_average, cpu_count, memory, disk, battery,
thermal, network, top_processes, health (score and the
missing_inputs it was computed without), and unavailable — one reason per
metric that could not be read. A metric is null when unread, never zero, and
the process exits nonzero whenever unavailable is not empty. status --watch
is interactive and rejects --json.
devtrim dashboard --json --no-open emits its snapshot metadata: root,
output_file, inventory, coverage, volume, recommendations and errors,
with schema_version: 1. Volume metrics are nullable with a reason; coverage
discloses partial lower bounds and summarized detail. A recommendation category
has preview_command or a null command with preview_unavailable explaining
why. Startup/argument failures use the standard error envelope. --json alone
does not suppress opening the browser; use --no-open for automation.
devtrim is built to be operated by automation and AI agents without ambiguity:
every --json invocation emits exactly one JSON document, actions are typed rather than
parsed from display strings, partial failure exits nonzero with earlier work
reported, mutation always requires explicit --apply plus explicit consent
flags, and every apply leaves a write-ahead journal an agent can audit with
devtrim history --json. Nothing devtrim does depends on parsing human-facing
output.
| Layer | Rule |
|---|---|
| Preview | --apply is mandatory for every mutation |
| Candidate set | apply uses exact previewed findings |
| Category authority | Xcode and toolchain apply reassert exact 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 rules |
| Trash | recoverable by default; permanent mode is explicit |
| Danger gate | maximum finding score plus aggregate estimated logical bytes |
| TUI consent | approval capability must match the current preview, its selection, and the danger requirement of the selected findings; selection can only narrow the preview |
| Target identity | exact internal PathBuf plus preview-time device/inode; display text is never parsed back into authority |
| Anchored deletion | the sink verifies identity through an open parent-directory handle; permanent deletion continues through that handle and drift refuses |
| Deletion sink | only VerifiedTarget reaches physical removal; action selects Trash vs. permanent mode |
| Command execution | serialized action and private closed authority must match the operation and its validated arguments |
| Physical path | literal and resolved parent must agree; deny-only resolution |
| Directory preflight | foreign devices and nested Git repository/worktree markers are refused before Trash or permanent mutation; only uv's own empty .git in sdists-v<N> under a CACHEDIR.TAG root is tolerated, where uv's lock or the Trash grants it |
| Activity | unknown Git/toolchain ownership is ineligible |
| Liveness | a 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 runs, 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 blocks |
| Protect config | user-listed protect paths are refused at the deletion sink and filtered from previews |
| Journal | a write-ahead attempt/result record precedes and follows every deletion; an unwritable journal blocks apply |
Sizes are estimated logical bytes. APFS clones, sparse files, and container-VM compaction can make immediately available disk space differ. An estimate may differ from physical blocks, but devtrim refuses to invent one when traversal or metadata is incomplete.
Use the pinned Rust 1.98.1 toolchain for builds; a standalone Cargo installed
before rustup on PATH can otherwise bypass rust-toolchain.toml. MSRV remains
1.88.0. Install the toolchains and gate tools once, then use the local helper:
bash scripts/verify.sh focused
bash scripts/verify.sh offline
focused runs Rust checks and the real terminal smoke test. offline adds
the local workflow, shell, MSRV, cached dependency, and Gitleaks checks. Both
report failed prerequisites and exit nonzero on failure. Neither installs
tools nor substitutes for fresh advisory scans, TruffleHog, fuzzing, video
checks, and review before delivery. Cached audit success does not prove that
the advisory database is current.
The PTY smoke test can also run against an already built binary:
python3 scripts/tests/tui.py target/debug/devtrim
python3 scripts/tests/read-only-views.py target/debug/devtrim
It uses an isolated HOME and PATH, explicit terminal dimensions, visible content assertions, and bounded waits.
Feature evals (tests/evals.rs, tests/eval_system.rs,
tests/eval_reports.rs, tests/eval_safety.rs) run the real binary against
planted fixtures. Report evals check fixture-derived content and exit codes;
previews must change nothing on disk, and applies must change exactly the
previewed targets while preserving near misses. Named planted mutants in
scripts/tests/planted-violations.py prove the assertions they select can
fail; other assertions remain review obligations. The shared removal-root
device check is tested with injected device metadata, not a real mounted
volume or a real Trash move, which would land in the developer's own Trash.
Two opt-in checks go beyond stubs:
scripts/tests/native-docker.sh target/debug/devtrim runs clean docker
against a real, disposable Docker engine in Apple's container, and
scripts/perf/pageload.mjs measures cold loads of the website and manual
with a bounded post-load observation: after eager-image decode and font
readiness, LCP must remain unchanged for 250 ms within five seconds. Later
dynamic content is outside this measurement. The controls in
scripts/tests/pageload-controls.py cover healthy, slow, delayed/larger paint,
broken, CSP and blank pages, plus failed Chrome starts and profile cleanup.
Samples include the observed LCP element ID; delayed-paint controls require
their intended element, so a slow initial paint cannot satisfy them. The
browser-free python3 -B scripts/tests/pageload-controls-validation.py checks
that stale candidates fail and complete reports pass; local checks, CI and
read-only release gates run it.
Use tests/cli.rs fixtures for JSON,
probe failures, and deletion sentinels. Debug a failing scenario in its
disposable fixture; do not grant broader permissions or run cleanup in your
real home merely to make a test pass.
For authorized parallel worktrees, set a distinct CARGO_TARGET_DIR, evidence
directory, benchmark corpus, and local-server port for each checkout. Record
the starting commit and assign one writer to each changed file. A benchmark
compares the same successful workload and compiler on both binaries; keep
intentional security/output changes separate from optimization baselines.
Build a disposable scan corpus with bash scripts/perf/corpus.sh <new-home>.
Then run bash scripts/perf/ab.sh <baseline> <candidate> <corpus-home> with
Hyperfine installed. The harness refuses unsuccessful or unequal scan output,
keeps both execution orders, and records binary hashes and machine load.
Set PERF_BASELINE_BUILD_INFO and PERF_CANDIDATE_BUILD_INFO to the compiler,
target, and build profile used for each binary. python3 scripts/perf/test.py
checks that the harness accepts equivalent work and rejects invalid evidence.
The individual delivery gates remain available below:
rustup run 1.98.1 cargo fmt --all -- --check
ast-grep test --skip-snapshot-tests
ast-grep scan --config sgconfig.yml
rustup run 1.98.1 cargo clippy --locked --all-targets --all-features -- -D warnings
rustup run 1.98.1 cargo test --locked --all-targets --all-features
rustup run 1.88.0 cargo test --locked --all-targets --all-features
cargo audit
cargo audit --file fuzz/Cargo.lock
rustup run 1.98.1 cargo build --release --locked --target aarch64-apple-darwin
(cd video && npm ci --strict-allow-scripts && npm audit --package-lock-only --audit-level=low && npm run lint && npm run format:check && npm run build)
bash scripts/tests/release-policy.sh
(for script in scripts/verify.sh scripts/release.sh scripts/update-homebrew.sh scripts/tests/release-policy.sh scripts/tests/update-homebrew-formula.sh scripts/perf/ab.sh scripts/perf/corpus.sh; do
bash -n "$script" || exit "$?"
done)
(for script in .githooks/pre-commit scripts/tests/shellcheck-tracked.sh scripts/tests/gitleaks-positive-control.sh; do
sh -n "$script" || exit "$?"
done)
scripts/tests/shellcheck-tracked.sh
actionlint
cmp AGENTS.md CLAUDE.md
scripts/tests/gitleaks-positive-control.sh "$(command -v gitleaks)"
gitleaks git --redact --no-banner .
trufflehog git "file://$(pwd)" --results=verified,unknown --fail --fail-on-scan-errors --no-update --no-color
Run each target under fuzz/fuzz_targets/ for 60 seconds with the configured
nightly compiler before release; the exact PATH setup is in AGENTS.md.
Keep output and exit status for each gate. A push request authorizes source
delivery; publishing a release and promoting it to production are separate
actions.
See SECURITY.md for the threat model and reporting process.
Release notes live in CHANGELOG.md. After committing and
pushing a version bump, enable GitHub immutable releases and run
scripts/release.sh <version>-beta<N> for staging. Each retry uses a new
counter. Run the local gates and P3 autoreview before committing. Before
tagging, the script performs only clean-tree, current-default-branch,
exact-CI/autoreview, and immutable-release provenance checks; it does not run
project or dependency code in that privileged preflight. Hosted
read-only jobs rerun the deterministic, fuzz, dependency, UI, video, and secret
gates and build the arm64 archive. A no-checkout publisher alone receives
release-write and OIDC authority, signs provenance, publishes the immutable
prerelease, and verifies the downloaded asset. Production uses
scripts/release.sh <version>; the workflow promotes the exact highest
verified beta artifact from the same commit without rebuilding it.
After the production release and attestation verify, the same script invokes
the idempotent scripts/update-homebrew.sh <version> closeout. It validates the
exact archive and checksum again, updates only Formula/devtrim.rb in
mneves75/homebrew-devtrim with a normal push, audits the updated tap, upgrades
the existing local formula, runs its test, and requires the only visible binary
to be /opt/homebrew/bin/devtrim at the released version. Beta releases never
touch Homebrew. If this post-release step fails, rerun the helper directly; the
immutable GitHub release and tag are not moved or reused.
devtrim--json| Measurement | incomplete traversal, metadata, or numeric state blocks an actionable plan |
| Automation | one JSON document; partial/failed work returns nonzero |
| Terminal output | complete human-facing actions, findings, errors, and notes escape control and bidirectional-control characters before rendering |
| Release authority | build code is read-only; a separate publisher receives packaged inputs and holds release/OIDC permissions |