Automation and scripting
lu-cleaner is interactive by default, but every command also works without a terminal. Scripted runs follow stricter rules than the picker. That way a typo in a cron job cannot delete more than you meant to.
Without a terminal
Section titled “Without a terminal”When stdin or stdout is not a terminal (a pipe, a scheduled job, an AI agent's shell):
lu-cleanerwith no command prints the scan report instead of opening the dashboard.worktrees,artifactsanddevicesprint their list instead of opening the picker.--listdoes the same on a terminal.cleanrefuses to run without--yesand exits with code 2. Nothing is ever deleted without an explicit selection or--yes.- Colors are only used when stdout is a terminal.
--no-colorand theNO_COLORvariable also turn them off. - The progress spinner only shows on a terminal. Warnings, such as a scanner that failed or a
--kindthat matched nothing, go to stderr aswarning: …. - Text output never contains raw control characters: names, paths and messages taken from the disk are printed with visible escapes such as
\x1b, so a crafted folder name cannot inject terminal sequences into your logs. JSON output escapes them as\uXXXX.
JSON output
Section titled “JSON output”Add --json to get machine-readable output on stdout:
| Command | JSON content |
|---|---|
lu-cleaner scan --json |
Disk usage, every matching item, totals by category, scanner errors and timings |
lu-cleaner worktrees --json, artifacts --json, devices --json |
The same report, limited to their categories |
lu-cleaner clean --yes … --json |
One result per item (done, dry-run, skipped or failed) with its message, estimated and measured bytes freed, bytes moved to the Trash (trashed), disk before and after |
lu-cleaner doctor --json |
Disk usage, APFS snapshots, Trash, running apps that block a cleanup, tips |
lu-cleaner history --json |
Past cleanups (paths, outcome, message, bytes really freed), with the history file path |
lu-cleaner catalog --json, config show --json, analyze <path> --json, version --json |
Known paths, effective configuration, directory sizes, build info |
Sizes are integers in bytes. In scan --json, each item has risk, method, recommended, cleanable, freed and age_days, and a meta object with scanner-specific details, such as a worktree's status and branch or the package_manager of a node_modules. The JSON output reference documents every field.
The usual filters (-c, -k, --older-than, --risk, --smart, --min-size) apply to the JSON output too. Unlike the text table, scan --json lists every item unless you pass --top.
# Free space, reclaimable total and the recommended part, in byteslu-cleaner scan --json | jq '{free: .disk.free, reclaimable: .totals.size, recommended: .totals.recommended}'
# Worktrees that still hold work: status, tool, branch, pathlu-cleaner worktrees --json | jq -r '.items[] | select(.kind != "worktree-prune" and .meta.status != "clean" and .meta.status != "merged") | [.meta.status, .meta.tool, .meta.branch, .path] | @tsv'
# How many bytes the recommended cleanup would freelu-cleaner clean --yes --smart --dry-run --json | jq '[.results[] | select(.status == "dry-run") | .freed] | add'Rules of clean --yes
Section titled “Rules of clean --yes”clean --yes scans, filters, prints the plan and cleans, with no picker and no confirmation. worktrees --yes, artifacts --yes and devices --yes follow the same rules, limited to their categories.
- A narrowing filter is mandatory. Pass
--smart,--category/-cor--kind/-k. Without one, lu-cleaner refuses to run and exits with code 2:clean --yesalone would mean "delete everything that was found". A-knaming a scanner that spans several categories (apple,ai,system,catalog) does not count: add-cor--smart.worktrees,artifactsanddevicescount as narrowed by their category. - Caution items and items with a warning are left out unless you ask for them. The highest risk allowed is moderate, unless you pass
--risk caution. Caution items may hold data you care about: dirty worktrees, sessions, unknown git-ignored folders, simulators with data. Items with a warning (an app running, a process in the project, unpushed commits, a runtime still used by simulators…) are treated the same way: the plan lists them underHeld back(on stderr with--json). never items are report-only and are never cleaned. - No minimum size.
--min-sizedefaults to 0, while lists use themin_sizesetting, 1 MB by default. - The plan is printed first. You can see every item before anything is deleted, unless you use
--json. --dry-run(-n) runs the same checks as a real clean: safety guard, marker files, inode identity, running apps, processes working inside, open databases, the re-checks of each scanner, and the git checks of worktrees (lock, nested checkouts, commits on no branch, uncommitted changes). It reports what would be skipped, including items that need--force, but deletes nothing. Only git's own refusals duringgit worktree removeare not simulated.- Nested items are cleaned once. If a folder and a folder inside it both match, only the outer one is kept in the plan. The exception is a worktree nested in another selected worktree: the inner one is removed first, with its own checks, and the outer one is kept if the inner one stays.
Two flags change what "clean" means. Scripts should almost never use them:
| Flag | Effect |
|---|---|
--trash |
Moves files and folders to ~/.Trash instead of deleting them. No space is freed until the Trash is emptied: summaries and the history report these moves apart from freed space. Worktree removals and commands (simctl, docker, brew…) would delete permanently, so they are skipped, and so is anything already in the Trash. --trash=false overrides use_trash. |
--force |
Ignores running-app guards, processes working inside, and the worktree checks (lock, uncommitted changes, commits on no branch, nested worktrees and repositories). It also lets lu-cleaner delete orphaned worktree folders. Work can be lost. |
Useful combinations:
lu-cleaner clean --yes --smart --dry-run # the recommended plan, nothing deletedlu-cleaner clean --yes --smart --risk safe # recommended pure caches onlylu-cleaner clean --yes --smart -c js,xcode # recommended JS and Xcode itemslu-cleaner clean --yes -k node_modules --older-than 60d # node_modules of projects idle for 60 daysEvery real clean is added to ~/.local/state/lu-cleaner/history.jsonl, or $XDG_STATE_HOME/lu-cleaner/ if that variable is set. Each line records the item's path (or the first 50 paths of a group), its outcome and message (why it was skipped, for example), and the bytes really freed: less than the size for data shared through hardlinks or APFS clones, and 0 for moves to the Trash. Read it with lu-cleaner history. To give a job its own settings, point LU_CLEANER_CONFIG to another config file. See Configuration.
Exit codes
Section titled “Exit codes”| Code | Meaning |
|---|---|
0 |
Success, including dry runs, "nothing to clean", and runs where some items were skipped |
1 |
Runtime error, or at least one item failed to clean |
2 |
Usage error: unknown command or flag, invalid value, clean --yes without a narrowing filter, clean without a terminal and without --yes, clean --json without --yes, an unknown artifact kind for artifacts -t, / as a root |
130 |
Interrupted by Ctrl-C |
143 |
Interrupted by SIGTERM |
The first Ctrl-C or SIGTERM cancels the run: pending items are skipped. A second one ends the process at once, even in the middle of a long deletion.
A skipped item is not an error. It was left alone on purpose: an app is running, a process works inside it, the worktree has uncommitted changes. If your script needs to know about skipped items, read .results[] | select(.status == "skipped") in the JSON output.
A weekly report
Section titled “A weekly report”This job only reports. Every Monday it saves the scan as JSON and a one-line-per-category summary, and shows a macOS notification when the disk is more than 90% full. It uses jq, which is preinstalled on recent macOS versions or available with brew install jq.
Save this script as ~/bin/lu-cleaner-weekly.sh:
#!/bin/zsh# Weekly disk report with lu-cleaner. Nothing is deleted.set -eu
# Scheduled jobs start with a minimal PATH: add where lu-cleaner, git and jq live.export PATH="$HOME/go/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"# No need to set TMPDIR: when it is missing (cron, launchd), lu-cleaner asks# macOS for your per-user temp folder (Metro, Jest... caches live there).
logs="$HOME/Library/Logs/lu-cleaner"mkdir -p "$logs"day="$(date +%Y-%m-%d)"
lu-cleaner scan --json > "$logs/scan-$day.json"lu-cleaner scan --summary > "$logs/summary-$day.txt"
used="$(lu-cleaner doctor --no-scan --json | jq '.used_pct | floor')"if [ "$used" -ge 90 ]; then reco="$(jq '.totals.recommended / 1e9 | floor' "$logs/scan-$day.json")" osascript -e "display notification \"Disk ${used}% full, ${reco} GB recommended\" with title \"lu-cleaner\""fi
# Optional: also clean the recommended SAFE items (pure caches only).# lu-cleaner clean --yes --smart --risk safe >> "$logs/clean-$day.log" 2>&1Then schedule it:
Create ~/Library/LaunchAgents/dev.example.lu-cleaner-weekly.plist. Replace /Users/you with your home folder, because launchd does not expand ~:
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict> <key>Label</key> <string>dev.example.lu-cleaner-weekly</string> <key>ProgramArguments</key> <array> <string>/bin/zsh</string> <string>/Users/you/bin/lu-cleaner-weekly.sh</string> </array> <key>StartCalendarInterval</key> <dict> <key>Weekday</key> <integer>1</integer> <key>Hour</key> <integer>9</integer> <key>Minute</key> <integer>30</integer> </dict> <key>StandardErrorPath</key> <string>/Users/you/Library/Logs/lu-cleaner-weekly.err</string></dict></plist>Load it, and run it once to check:
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.example.lu-cleaner-weekly.plistlaunchctl kickstart gui/$(id -u)/dev.example.lu-cleaner-weeklyTo remove it: launchctl bootout gui/$(id -u)/dev.example.lu-cleaner-weekly. If the Mac is asleep at the scheduled time, launchd runs the job when it wakes up.
Run crontab -e and add:
# Every Monday at 09:3030 9 * * 1 /bin/zsh "$HOME/bin/lu-cleaner-weekly.sh" >> "$HOME/Library/Logs/lu-cleaner-weekly.log" 2>&1cron skips jobs while the Mac is asleep, which is why launchd is the better choice on macOS.
Why the scheduled job only cleans safe items
Section titled “Why the scheduled job only cleans safe items”If you uncomment the last line of the script, keep --smart --risk safe:
- safe items are pure caches that their tools regenerate automatically.
- moderate items cost you time to get back: a reinstall, a download, a rebuild. You should decide when to pay that cost.
- caution items can hold data, so they are never cleaned without
--risk caution.
If Xcode or the Simulator is running at that time, the items they guard are held back (or skipped, when the app started after the scan) and the exit code stays 0. See Risk levels and smart select.
Use lu-cleaner from an AI coding agent
Section titled “Use lu-cleaner from an AI coding agent”Coding agents run commands without a terminal, which suits the rules above. A safe workflow:
- Inspect. Run
lu-cleaner scan --summaryfor an overview, orlu-cleaner scan -c artifacts --jsonfor details. - Plan. Run
lu-cleaner clean --yes --dry-runwith explicit categories, for example-c js --smartor-c artifacts --older-than 30d, and show the plan to the human. - Clean. Run the same command without
--dry-run, only after the human approves it.
Some guarantees help here. An agent cannot remove the worktree it runs in: lu-cleaner refuses any target that contains its own current directory. A worktree where another process is working is marked caution, and any folder that a process works in is skipped at clean time. Still, never let an agent add --force or --risk caution on its own.
Add a rule like this to your AGENTS.md or CLAUDE.md:
## Disk cleanup (lu-cleaner)- Inspect first: `lu-cleaner scan --summary`, or `lu-cleaner scan --json` for details.- Always preview with `--dry-run` and explicit categories, e.g. `lu-cleaner clean --yes --dry-run -c js --smart`, and show me the plan.- Run the same command without `--dry-run` only after I approve it.- Never use `--force`, `--risk caution` or `--trash`.Documentation for LLMs
Section titled “Documentation for LLMs”This documentation is also published in formats that are easier for language models to use:
| File | Content |
|---|---|
/lu-cleaner/llms.txt |
Short index with key facts and links to the other files |
/lu-cleaner/llms-small.txt |
Condensed documentation, a good fit for an agent's context |
/lu-cleaner/llms-full.txt |
The complete documentation in one file |
Point your agent to https://ludwig-pro.github.io/lu-cleaner/llms-small.txt before it runs lu-cleaner.