building
Rebuilding a Mac cleaner by watching it work

KachraClean (“kachra” is Hindi for rubbish) is a free, open-source storage cleaner for macOS. It took two days to go from “let’s try this reverse-engineering tool” to a signed-off spec, a scanning engine, a native app and a downloadable release, and three more days to turn the first release into 0.1.4. I built it with Claude Code, so the “we” below is me and an agent. This is the story of how it was built, what the reference app taught us, where it fell short, and where the guardrails stopped us. Twice.
- Code and download: github.com/nikhilrdeshpande/KachraClean
- Research notes, one per feature:
docs/spec/ - Raw, dated lab notebook:
docs/case-study/log.md
1. The idea
REA is an MCP server which gives an AI agent reverse-engineering tools: binary inventory, plist and signature inspection, Ghidra/Hopper/IDA bindings, UI capture, runtime tracing. I wanted a real project to test it on, and I wanted the result to be useful.
My Mac was the obvious problem. The disk was nearly full and I did not know why. Apple’s Storage settings showed a large, unexplained “System Data” block. Commercial cleaners promise to fix this, so the plan was:
- Pick the most widely used Mac cleaner.
- Use REA to learn how it decides what is junk.
- Build an open-source app which does the same job, and does better for developers.
CleanMyMac (MacPaw) is the most installed and most awarded Mac cleaner, so that was the target. We studied version 5.7.2, downloaded from MacPaw’s own site.
2. Ground rules first
Before running any tool we wrote the legal and ethical rules into the project’s CLAUDE.md, the file
Claude Code reads at the start of every session. That matters because the agent follows it on every
step, not only when someone remembers.
The approach is a classic clean-room design:
| Allowed on CleanMyMac | Not allowed |
|---|---|
| Listing the files in the app bundle | Decompiling MacPaw’s code (Ghidra, Hopper) |
| Reading Info.plists, entitlements, code signatures | Decrypting its rule database |
| Watching what it reads from disk during a scan | Bypassing licensing, patching or injecting |
| Reading its windows through Accessibility, as a screen reader would | Copying its strings, icons or rules into our repo |
Two more rules:
- Spec, then code. Every finding goes into
docs/spec/in our own words, with an evidence ID. The app is written only from those specs plus Apple’s public documentation. - Deep reverse engineering is for macOS itself. If we need to know how purgeable space works, we look at Apple’s frameworks, not MacPaw’s.
This is the same idea which let companies build compatible BIOSes in the 1980s: one side describes behaviour, the other side implements it, and no protected code crosses over. (This is not legal advice. Talk to a lawyer before doing this with a product you care about.)
3. First look: the bundle
REA’s first pass was a plain inventory of the 436 MB app bundle. No code was executed or decompiled.
What the file layout alone reveals:
- One framework per feature. 136 frameworks with names like
JunkCleanupModule,UninstallerScanning,SpaceLensScanningandAIToolScanning. The architecture is readable from the folder names. - A privileged helper registered with launchd, plus login items for a menu-bar app and a health monitor, and a Finder extension.
- The main app is not sandboxed, which is why it can walk your whole disk once you grant Full Disk Access.
- The cleaning rules live in an encrypted file. One resource has an entropy of 7.999 bits per byte, effectively random, and is loaded by a “knowledge base” service.
That last finding set the direction for everything else. The easy shortcut would be to decrypt the rules and copy them. That would also be circumventing a protection measure. I decided not to decrypt it, and that went into the log. If we wanted the rules, we would have to learn them from behaviour.
A small but useful REA trick: plist and signature tools need an “active target”. Opening the .app
would bind Ghidra to MacPaw’s binary, so we opened the Info.plist file itself as the target. The
session stayed metadata-only by construction.
4. Watching a scan
The next question was what does it actually look at? I granted CleanMyMac Full Disk Access, ran a
full scan and recorded every file-system call with macOS’s own fs_usage. The Clean button was never
pressed.
The scan took about two and a half minutes and made 1.34 million file-system calls on 107 threads.
Almost all were metadata calls (getattrlist, lstat, stat). It opened very few files for reading.
Lined up on a timeline, the trace splits into clear phases:
| Time | What it touched | What it is probably doing |
|---|---|---|
| 0:50 | Logs and every folder in ~/Library/Caches |
Sizing system junk |
| 1:00–1:30 | Every app in /Applications, down to each language folder |
App inventory for the uninstaller and updater |
| 1:30–2:00 | Launch agents and daemons | Checking background items |
| 2:10 | /Library/Application Support, Preferences |
Leftovers from deleted apps |
| 2:20 | .dmg and .pkg files in Downloads |
Installer junk |
| 2:30–2:50 | Documents, Desktop, Downloads | Large and old files, clutter |
Safety check first: all 740 deletes and renames during the scan were inside CleanMyMac’s own data and temp folders. It touched none of my files. Good engineering, and the bar for ours.
The interesting part was what was missing. node_modules, Python virtual environments, Rust target
folders and Hugging Face model caches were only brushed past while walking Documents, a few hundred
calls each. There was no dedicated scanner for developer projects.
5. Reading the results screen
The trace showed what was visited. It did not show what was flagged, and for that we needed the results screen.
REA’s observe_native_ui reads a window’s accessibility tree, the same data VoiceOver uses. Getting
it to work took three tries, and each one taught us something.
Try 1: the guardrail said no. Claude Code’s auto mode blocked REA from opening the running app, classifying it as a possible attack on third-party software. I added explicit allow rules for the two REA tools we needed. I think this is the right default. Attaching to someone else’s running program should need a human to say yes.
Try 2: a REA bug. With permission granted, the call still timed out after exactly 60 seconds. The cause: REA compiles its Swift UI helper from source on every call, with a fresh module cache. With the macOS 26 SDK that cold compile takes about 66 seconds on this Mac, so it can never finish. The workaround was to compile REA’s own helper once with REA’s exact compiler command and run it directly. The capture then took 1.3 seconds.
Try 3: who clicks? I wanted the agent to click “Review” to open the next screen. It wrapped the click in checks: a fresh capture, exact button labels, and a block list of words like Clean and Remove. Auto mode blocked it anyway: my approval covered reading the window, not driving it. We settled on a split which worked well for the rest of the study: the human clicks, the agent reads. I would open a screen, type “captured”, and the agent would read the tree and screenshot.
What the screens showed (paraphrased):
- Cleanup: 17.6 GB pre-selected across 12 groups: user caches, logs, unused language files, system caches, broken login items. Removing Intel code from universal apps (1.8 GB) and old document versions were listed but left unticked.
- Clutter: 53 duplicate downloads (157 MB) pre-selected, always keeping one copy.
- Maintenance: flush the DNS cache, and free “up to 2.7 GB” of purgeable space.
One accessibility quirk: checkbox state is not exposed. We worked out what was ticked from the size badges, which added up exactly to the selected total.
6. Two mysteries, solved without reading code
Where does “purgeable” come from?
macOS reports two “free space” numbers through public API: plain available capacity, and capacity available for “important usage”, which counts space the system can reclaim on demand (local snapshots, cached iCloud files). On the same Mac:
important-usage capacity − available capacity = 2.74 GB
That matches the reference app’s 2.7 GB. No private framework needed for the estimate.
How does it decide an app is “unused”?
The uninstaller listed 38 apps, each with a date, and an “Unused” filter showing 4. The trace showed
no reads of macOS’s usage databases (Screen Time, knowledgeC.db) during the scan, but Spotlight’s
indexing daemons were busy at the same moment. That suggested Spotlight metadata.
We compared every displayed date with mdls output:
- When Spotlight had a last-used date, the app showed it (2 of 2).
- When it did not, the app showed the bundle’s modification date (10 of 10).
From that, we predicted that “unused” meant a date older than about six months, and named the four apps before opening the filter. I opened it, and it listed exactly those four. The next-oldest app, one day inside the six-month line, was not there.
We predicted first and checked afterwards, and it needed no code from the target. I do not know of stronger evidence a black-box study can produce.
It also exposed a weakness. Spotlight only records last-used dates for some launch paths. Several apps I use daily had none, so their “last used” quietly fell back to the install date. An app you open every day can be labelled unused. KachraClean shows the evidence instead: “last opened 12 March (Spotlight)” versus “no usage data from macOS, installed 25 February”.
7. The gap
On the same Mac, a quick read-only measurement found:
- 37.7 GB of project dependencies and build folders (
node_modules,.venv,target, …) - 39 GB in
~/.cache, mostly AI models
Neither appeared anywhere in the cleanup list. For a developer, that is where most of the space goes. This became KachraClean’s focus.
8. Building KachraClean
The engine is a Swift 6 package with no dependencies, built with only the Xcode Command Line Tools.
(Without Xcode, the Testing framework needs explicit framework and rpath flags; scripts/test.sh
handles it.)
The engine, KachraCore
Scanning is strictly read-only. Cleaning is a separate step which only ever moves items to the Trash,
and refuses paths outside a safe list (never your home folder itself, never Documents, never a whole
Library/Caches).
Every rule carries a safety level and a reason:
- Safe: rebuilds itself; pre-selected. “Downloaded bottles. Homebrew fetches them again when needed.”
- Review: probably fine, worth a look. “Projects hard-link packages from this store, so removing it frees less than its size.”
- Risky: deletes data; never pre-selected. “Holds all Docker images, containers and volumes, including databases.”
The scanners:
- Code projects. Finds
node_modules, Python environments, Rusttarget, Gradle and framework build caches, but only next to a real project marker (package.json,Cargo.toml,pyproject.toml). A folder namedtargetwith noCargo.tomlis left alone. A project idle for 30+ days with a lockfile is safe, because the lockfile rebuilds the exact same dependencies. Everything else is review. - AI models, one by one. The Hugging Face cache stores each model’s files as symlinks into a
shared
blobsfolder. Trashing a model’s folder alone frees almost nothing. KachraClean follows the links, counts only the blobs that model uses exclusively, keeps blobs shared with other models, and reports unreferenced blobs separately as safe. Per-model sizes matchedhf cache lsexactly (20.32 GB for the largest), and the scan takes 0.3 s. Ollama gets the same treatment from its manifests. - Large files. A Spotlight query for files over 500 MB in the home folder: 33 hits in 0.12 seconds, with a folder walk as fallback. Videos, disk images, archives and datasets are always “review” and never pre-selected. Loose model weights found this way (ComfyUI checkpoints, image model folders) move to the AI models list, grouped per model.
- App caches, logs, tool caches, duplicates in Downloads, apps with their usage evidence.
First scan on the same Mac
58 seconds, read-only:
| CleanMyMac (pre-selected) | KachraClean | |
|---|---|---|
| Safe to remove | 17.6 GB | 39.6 GB |
| Total reported (safe + review) | 17.6 GB | 108.8 GB |
| Project dependencies & builds | not listed | 43.9 GB in 120 folders |
| AI models & datasets | not listed | 37.7 GB, later 70.9 GB once loose weights were found |
| Purgeable estimate | 2.7 GB | 2.76 GB |
To be fair to CleanMyMac: it does far more than this table shows (malware checks, app updates, privacy, an uninstaller with leftovers), and its pre-selection is deliberately conservative. The comparison is about one audience. For developers and people running local AI, the biggest files are in places a general-purpose cleaner does not look.
9. Designing the app
The first app worked but looked like a debug tool, so we paused for a research-led redesign. Each input changed something concrete:
- Disk-usage visualisation studies found that sunburst charts communicate hierarchy better than treemaps for first-time users, and that tiny slices need merging. → An interactive two-ring space map: categories inside, biggest items outside, small ones combined.
- “Are you sure?” dialogs. People click through them. → Cleaning happens straight away with an Undo toast (⌘Z restores from the Trash). Only removing apps asks first.
- Complaints about Apple’s Storage pane (unexplained System Data, numbers which keep moving). → Every item shows a reason and its evidence, and results appear only when final.
- Apple’s Liquid Glass (macOS 26) → glass action bar and buttons, with a material fallback on macOS 14 and 15.
Screenshot reviews after each build caught most bugs. Two were worth telling:
The window which kept growing. The details panel clipped the sidebar at narrower window sizes. We
tried capping widths, moving the search field and clearing saved split-view sizes. The real cause:
SwiftUI’s .inspector asks the window to grow toward the screen edge, and lays out at that width
when it cannot. We replaced it with a panel inside the list column.
The scan which froze. Without Full Disk Access, one scan step hung for minutes. Sampling the
process showed threads parked in open(): macOS was waiting for an answer to a consent prompt for
another app’s data folder. The fix is a permission screen before the first scan which continues by
itself once access is granted, plus a limited mode which skips protected folders.
10. Shipping it
- The icon was generated locally with FLUX.3 klein through
mfluxon the same Mac, from a prompt describing a glass donut chart with one slice dissolving into sparkles. Six candidates were rendered and I picked one. It was then re-rendered with more steps, lightly sharpened, and placed on Apple’s icon grid by a small script (scripts/make-icon.swift). - The release is a universal (Apple silicon and Intel) app in a drag-to-Applications disk image,
built without Xcode by
scripts/build-app.shandscripts/make-dmg.sh. I have not notarised it yet, so the first launch needs Open Anyway in System Settings.
11. After the first release
Version 0.1.0 went out on day one. Four more releases followed in the next three days, each driven by something we learned from using it.
A security sweep before going public
Before announcing anything, we read every scanner the way an attacker would. That found real bugs, not just theory:
- A login token marked safe. Everything in
~/.cachewas treated as a rebuildable cache, which pre-selected the Hugging Face login token (~/.cache/huggingface/token). Now only folders count, and they need review. - Paths which could escape. A model’s symlink pointing outside the cache, or an Ollama manifest
containing
../, could have made one of my own files look like part of a model and sent it to the Trash with it. Both are now validated. - A leak in the icon. The icon’s source PNG still carried the image model’s generation metadata, including a local path with my macOS username. It’s stripped now.
On top of the fixes, the cleaner stopped trusting the scanners: it refuses keychains, credentials, mail and cloud folders, and anything outside the finding’s category, whatever a scanner reports.
The first outside tester
A friend tried 0.1.1. They wanted to clean only app caches, but the Move to Trash button on that screen acted on everything selected anywhere, since safe items in other categories are pre-selected. The screen implied something the button did not do. Now cleaning acts on the screen in view, and the Overview’s button names every category it includes. An audit of the same flow found eight more small mismatches, like items hidden by a search being cleaned silently.
Their second request was to show, for each AI model, which of my projects actually use it. A new step reads small text files in code projects and matches name-like words against the downloaded models. It found 16 of 29 Hugging Face repos referenced by a project, and the other 13 mentioned nowhere: the obvious candidates to remove.
“Where is my space?”
The scan only lists what it recognises: about 143 GB on my Mac, out of roughly 310 GB in the home
folder. The rest had no explanation anywhere in the app. So we added a Disk Explorer: Finder-style
columns of the home folder, biggest first, with the scan’s suggestions marked on top, and a + on
any size macOS would not fully let us read. The idea came from OmniDiskSweeper, studied only through
public reviews (we did not run or inspect it). Measuring the whole home folder took 63 seconds at
first and about 20 seconds once each folder’s sub-folders were walked in parallel.
Three ideas from using it
- Old installers. The CleanMyMac trace showed it reading
.dmgand.pkgfiles in Downloads. Ours matches each one to an installed app by name, without mounting anything: matched is safe, unmatched needs review. - What grew since last time. Each scan now keeps a small summary, and the Overview says what grew by more than 100 MB since yesterday or earlier. When a disk fills up, it is usually because something grew.
- Docker, explained. Docker Desktop’s disk image stays “risky”, because deleting it removes every container and database. When Docker is installed, KachraClean asks Docker what it can free and shows Docker’s own commands to copy. It never runs them itself.
The first crash
Then I hit the first crash. Moving something to the Trash while the Disk Explorer showed its folder made the app quit instantly. The cause was one line of Swift:
columns[i].rows[r].size?.bytes = max(0, columns[i].rows[r].bytes + delta)
With optional chaining (size?.), Swift evaluates the right-hand side while it is already writing
to columns, and reading columns there is an overlapping access which Swift’s runtime stops on
purpose. A ten-line standalone program reproduced the same error, and the fix was to work on a
copy. The files had already moved and could be put back from the Trash, but Undo was lost with the
app. That is one more reason Move to Trash is the only way KachraClean removes anything. Version
0.1.4 shipped the fix the same morning.
0.1.5: a second security pass
Before launch we ran a fuller security review with one question in mind: what could another program already running on the Mac do with KachraClean’s Full Disk Access? It found two serious gaps. The app trusted its saved scan report, which any program running as me could edit, and pre-selected items from it, so a forged finding could have sent something like the Photos library to the Trash in one click. And the Docker step ran the first docker it found, including from folders any program can write to, and that copy would have inherited Full Disk Access.
Version 0.1.5 fixes both. A report loaded from disk no longer pre-selects anything, each category has a narrower check at clean time, and the only Docker command it will run is one signed by Docker Inc. The same release tightened how app leftovers are matched, quoted the commands it offers to copy, and widened what it refuses to touch: Photos and Music libraries, Safari data, and more credential folders. The tests went from 38 to 46.
12. What we learned
About REA
- Inventory and plist/signature inspection worked well and kept a clear evidence trail; every finding in the specs has an evidence ID.
- The provider model enforced our boundary by itself: Ghidra refuses universal binaries, no other disassembler was installed, so the session had no decompiler bound. We could not have broken our own rule by accident.
- Native UI capture is powerful but has rough edges: the 60-second cold-compile timeout, and full captures of long lists also timing out (a node budget with a time-based early exit would help).
- For tracing an already-running GUI app system-wide, plain
fs_usageplus two small parsers was the better tool. REA’s process capture launches the target itself.
About working with an agent
- The rules went into
CLAUDE.mdon day one. The agent applied them on every step, including declining shortcuts. - The auto-mode guardrails stopped me twice. It was annoying, and they were right both times. Attaching to another company’s app and clicking its buttons are decisions a person should make.
- The “unused apps” rule was confirmed by naming four apps before looking. We read none of the target’s code. I trust that result more than I would trust a reading of it.
- We read our own code as an attacker would. The token and the path escapes were found that way, not by tests.
- Both of the biggest problems after launch, the confusing Clean button and the crash, came from someone actually using the app. The screenshot reviews caught neither.
About Mac storage
- On a developer’s Mac, most reclaimable space is in project dependencies and AI model caches, not system junk.
- Hugging Face’s shared-blob layout means deleting a model folder can free nothing. So we size and remove per blob.
- Spotlight alone is unreliable for “unused app”. So the app shows the evidence, and the person decides.
Try it
Download the latest release from GitHub, or build it yourself:
git clone https://github.com/nikhilrdeshpande/KachraClean
cd KachraClean
./scripts/build-app.sh && open build/KachraClean.app
Nothing moves until you press Clean, everything goes to the Trash, and the last clean can be undone.
KachraClean is an independent project. It is not affiliated with, endorsed by, or connected to MacPaw or CleanMyMac. CleanMyMac is a trademark of MacPaw Way Ltd. No MacPaw code, rules, text or artwork is included in this project.