Nikhil Deshpande

building

Rebuilding a Mac cleaner by watching it work

·Published here

A laptop on a desk at night shows a two-ring chart of disk space in blue and amber, beside stacked cardboard boxes, a wastepaper basket and a cup of chai.

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.


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:

  1. Pick the most widely used Mac cleaner.
  2. Use REA to learn how it decides what is junk.
  3. 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, SpaceLensScanning and AIToolScanning. 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, Rust target, Gradle and framework build caches, but only next to a real project marker (package.json, Cargo.toml, pyproject.toml). A folder named target with no Cargo.toml is 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 blobs folder. 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 matched hf cache ls exactly (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 mflux on 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.sh and scripts/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 ~/.cache was 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 .dmg and .pkg files 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_usage plus 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.md on 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.