clean-junk
Look at ONE directory, form a verdict on each top-level entry, report the evidence, and move only what the user approves to the Trash.
Non-negotiables
These are not guidelines. Violating any one of them is a failure of the skill.
- Never
rm. Every removal goes through thetrashCLI. Iftrashis missing, stop and say so - do not substituterm. Thistrashdoes NOT accept--as an end-of-options marker (it treats it as a literal filename and exits nonzero), so guard leading-dash names with a./prefix instead. - Never act without explicit approval for the specific batch or item. Approval means the user picked that batch from the menu, or ticked that item's checkbox - never an inferred yes, and never a whole tier because they approved a different one.
- Never touch a PROTECTED entry, even if the user asks in passing. If they want one gone, make them say so unambiguously in a follow-up, and confirm what it is first.
- Never delete anything holding unpushed git work. This is an interlock, not a warning - see Git interlock below.
- Stay in the cwd. No parent directories, no following symlinks out, no
~expansion to somewhere else. One directory, top level only. - Read nothing deeper than one level. You may list a folder's immediate children to judge what it is. You may not read files inside it.
Depth rule
Content inspection stops at one level: ls a folder's immediate children, and
that is all. Size (du -s) and newest-mtime are metadata, not content, so those
may walk the tree - they read no file contents and are the only exception.
Step 1 - establish the target
Run pwd and state the directory you are about to scan. If it is /, a system
directory, or anything outside the user's home, stop and ask.
Step 2 - scan
find . -maxdepth 1 -mindepth 1 -print0 2>/dev/null | while IFS= read -r -d '' e; do
n=${e#./}
if [ -L "$e" ]; then t=link
elif [ -d "$e" ]; then t=dir
else t=file; fi
kb=$(du -sk "$e" 2>/dev/null | cut -f1); kb=${kb:-0}
if [ "$t" = dir ]; then
newest=$(find "$e" -type f -not -path '*/.git/*' -not -path '*/node_modules/*' -exec stat -f '%m' {} + 2>/dev/null | sort -rn | head -1)
else
newest=$(stat -f '%m' "$e" 2>/dev/null)
fi
[ -z "$newest" ] && newest=$(stat -f '%m' "$e" 2>/dev/null)
d=$([ -n "$newest" ] && date -r "$newest" '+%Y-%m-%d' || echo "-")
g="-"
if [ -d "$e/.git" ]; then
dirty=$(git -C "$e" status --porcelain 2>/dev/null | wc -l | tr -d ' ')
unp=$(git -C "$e" log --branches --not --remotes --oneline 2>/dev/null | wc -l | tr -d ' ')
rem=$(git -C "$e" remote 2>/dev/null | head -1)
g="git:dirty=$dirty,unpushed=$unp,remote=${rem:-NONE}"
fi
printf '%s\t%s\t%s\t%s\t%s\n' "$t" "$kb" "$d" "$g" "$n"
done | sort -t$'\t' -k2 -rn
Columns: type, size in KB, last-touched date, git state, name.
For any dir you intend to put in REVIEW, run one ls -A on it to see its
immediate children. That single listing is your evidence for what it is - a
real project, a scratch dir, or a pile of downloads.
Step 3 - PROTECTED filter (runs first)
Apply this before any other classification. A protected entry is removed from consideration entirely, so no later approval can reach it.
Credentials and machine state
.ssh .aws .gnupg .gpg .docker .kube .netrc .mcp-auth .config
.local .password-store .authinfo Library Applications .Trash
anything matching *.pem *.key id_rsa* credentials *.keychain*
Shell, git, and agent config
.zshrc .zshenv .zprofile .bashrc .bash_profile .profile .inputrc
.gitconfig .gitignore_global .claude .claude.json* .codex .cursor
.vscode .idea .agents
Toolchain roots - these look like reclaimable bulk and are load-bearing.
Deleting miniforge3 breaks every conda env on the machine.
miniforge3 miniconda3 anaconda3 .nvm .cargo .rustup .pyenv
.rbenv .sdkman .gem .bundle .m2 .gradle .pub-cache .npm go
.deno .bun
Structural
- symlinks (never dereference, never delete - the target may be cloud storage)
- anything not owned by the current user (
stat -f '%Su') .gititself- cloud-sync roots:
OneDrive*DropboxGoogle Drive*iCloud*Sync
Report protected entries with the same size and last-touched columns as every
other tier - never as a bare name list. The user should be able to see that the
skill looked at .ssh and declined, and should still learn that Library is
40 GB even though nothing will be done about it. Sort them by size like the
rest, and truncate to the ten largest with a "+N more" line if the list is long.
Step 4 - classify the rest
Assign exactly one verdict per remaining entry.
TRASH - regenerable or re-downloadable, safe to batch:
- build and dependency dirs:
node_modules.venvvenvenv__pycache__.pytest_cache.mypy_cache.ruff_cachetargetbuilddist.next.turbo.parcel-cache.gradle/cachesDerivedData - OS and editor cruft:
.DS_StoreThumbs.db*.swp*~.ipynb_checkpoints - installers and bootstrap scripts:
*.pkg*.dmg*.msiget-pip.py - zero-byte files and empty directories
- a clone that is clean, fully pushed, and has a remote (GitHub has all of it) - say so in the rationale, since this one surprises people
KEEP - do not propose:
- touched within the last 30 days
- any git interlock trip (Step 5)
- the entry is a lone document with no size pressure (< 1 MB and not obviously cruft)
REVIEW - everything else. This is the user's own possibly-abandoned work, and the point of the skill. Never batch-approve this tier; walk it one at a time. Each line needs a rationale built from top-level evidence only:
- size and last-touched date
- what the one-level
lsshowed:README + lockfile + 8 subdirsreads very differently from3 loose .py files - whether it is a git repo, and whether the remote already has everything
- shape heuristics: a name like
test/tmp/old/untitled/Copy of, or a trailing digit series (test2,test3,test4), signals scratch work
Sort REVIEW by size x age, so the user's attention goes to the multi-gigabyte folder from four years ago rather than the 4 KB one from Tuesday.
Step 5 - git interlock
Nothing that holds unbacked-up git work may be deleted. This overrides age, name, size, and any approval the user has already given. A repo untouched for three years with one unpushed commit is KEEP, full stop.
5a - top-level repos
For every entry with its own .git:
| State | Action |
|---|---|
unpushed>0 |
Force KEEP. Report: "N commits exist only on this machine." Offer to push. |
dirty>0 |
Force KEEP. Report the count of uncommitted files. |
remote=NONE and has commits |
Force KEEP - nothing backs this up. |
| clean, pushed, has remote | Eligible for TRASH; note the remote URL in the rationale. |
5b - nested repos (run before ANY directory is deleted)
A directory is not safe just because it has no .git of its own. Scratch-named
folders routinely contain real repos - a test4/ can hold a repo with unpushed
commits, and a catch-all like Workbench/ can hold one with no remote at all.
Both would be destroyed by a name-and-age rule.
So immediately before deleting any directory - after the user approves, not during classification - sweep it:
find "$d" -name .git -maxdepth 4 -print 2>/dev/null | while read -r g; do
r=${g%/.git}
u=$(git -C "$r" log --branches --not --remotes --oneline 2>/dev/null | wc -l | tr -d ' ')
dy=$(git -C "$r" status --porcelain 2>/dev/null | wc -l | tr -d ' ')
rem=$(git -C "$r" remote get-url origin 2>/dev/null)
n=$(git -C "$r" log --oneline 2>/dev/null | wc -l | tr -d ' ')
if [ "$u" != "0" ] || [ "$dy" != "0" ] || { [ -z "$rem" ] && [ "$n" != "0" ]; }; then
printf ' !! %-46s unpushed=%s dirty=%s commits=%s remote=%s\n' "$r" "$u" "$dy" "$n" "${rem:-NONE}"
fi
done
Any hit pulls that directory out of the batch. Delete everything else, then report the held-back items with their specific git state and offer to resolve it - push the commits, commit the loose files - so the space becomes available once the work is safe.
Two refinements learned the hard way:
- A repo with
remote=NONEbut zero commits is an emptygit init, not lost work. Do not hold a directory back for one. - The sweep is metadata only and is the one place the depth rule is relaxed: losing a commit is unrecoverable, and a Trash restore will not bring it back once the Trash is emptied.
An approval does not survive a sweep hit. If the user has already ticked a directory and the sweep finds unpushed work inside it, hold it and say so - they approved deleting a folder, not discarding a commit they did not know existed. They can then clear it explicitly, and that clearance releases the directory.
Step 6 - report
Every entry in every tier gets three columns: size, last touched, and a rationale. No entry is ever listed as a bare name - if it was worth scanning, it is worth showing what it costs and when it was last used.
Formatting rules:
- Convert the scanner's KB to the largest unit that keeps 2-3 significant
digits:
56 M,1.9 G,26 K,29 B. Right-align the column. - Last touched:
Mar 2024for anything older than the current year,12 Marwithin it. Add a parenthetical age for anything over a year:Oct 2022 (3y). That age is what makes an abandoned folder legible at a glance. - Sort every tier by size descending.
- Head the whole report with a total-scanned line, and each tier with its own subtotal and count.
/Users/<you> - 28 entries, 54.2 GB scanned
PROTECTED 6.1 GB / 17 never actionable
Library/ 4.2 G 12 Aug system + app support
miniforge3/ 1.4 G 28 May 2025 conda root, breaks envs if removed
.nvm/ 412 M 17 Jun node toolchain
.config/ 38 M 09 Aug app config
.ssh/ 12 K 05 Feb 2025 credentials
+12 more
TRASH 61 MB / 4 reclaimable now
AWSCLIV2.pkg 56 M Jun 2026 installer, re-downloadable
get-pip.py 1.9 M Oct 2021 (5y) bootstrap script
.DS_Store 26 K 28 Aug Finder metadata
lecture-notes.tgz 29 B Oct 2022 (4y) empty archive
REVIEW 6.0 GB / 8 your call, one at a time
Workbench/ 3.1 G Oct 2022 (4y) 4 subdirs, no README, not a repo
mobile-sandbox/ 1.1 G Feb 2022 (4y) Android projects
test4/ 74 M Jan 2026 scratch-shaped name, 6 files
sample_labels.jsonl 67 M Jul 2026 dataset, origin unknown
KEEP 45.6 GB / 3 active or interlocked
Projects/ 44 G 28 Aug active today
mapmaker/ 1.5 G 20 Aug 4 unpushed commits - push first
tidepool/ 40 M Mar 2026 29 uncommitted files
Reclaimable now: 61 MB. Pending your review: 6.0 GB.
Lead the spoken summary with total reclaimable, but call out any interlock trip first - unpushed work is more urgent than the space.
Then go straight to Step 7 in the same turn. Do not end on an open-ended "what would you like to do?" - the report is only useful if it comes with the buttons to act on it.
Step 7 - offer the menu, then act
Immediately after the report, call AskUserQuestion with the action menu. Build the labels from the real numbers you just computed - never generic text.
question: "What do you want to do?"
header: "Action"
options:
1. "Trash all N junk items (X MB)" <- recommended; the TRASH tier, one batch
2. "Go through the N review items" <- opens the checklist below
3. "Both - trash junk, then review"
4. "Nothing, just the report"
If the TRASH tier is empty, drop option 1 and make the checklist the recommended choice. If REVIEW is empty, drop options 2 and 3.
The REVIEW checklist
When the user picks the review path, present the REVIEW items as checkboxes
via AskUserQuestion with multiSelect: true. Ticking an item means remove it.
- Paginate: up to 4 options per question, up to 4 questions per call - so one call presents up to 16 items. Sort by size descending and fill the first question with the biggest, so the items worth the most attention come first.
- If more than 16 remain, act on the first batch, then make another call.
- Each option:
label= name + size (Workbench/ — 3.1 G),description= age plus the one-level evidence (Oct 2022 (3y) · flutter/, sketches/, test.py · not a repo). The description is where the user actually decides, so put the evidence there, not a restatement of the name. - Group related items into one question where it reads naturally (all the stale toolchain caches together, all the scratch dirs together).
- Never put a PROTECTED or KEEP entry on the list. Not as an option, not as a footnote with a checkbox. They are not on the menu.
Ticking a box IS the approval. Do not re-confirm item by item afterwards - that is the thing the checklist exists to avoid. Echo the final list and total once, then act.
Acting
MF=~/.claude/clean-junk/$(date +%Y%m%d-%H%M%S).json
mkdir -p ~/.claude/clean-junk
printf '{"cwd":"%s","items":[' "$PWD" > "$MF"
trash "./<entry>" # one call per approved entry, always ./-prefixed
Do not pass -s/--stopOnError: one bad entry should not abort the batch.
Append each moved entry's name and size to the manifest, close the JSON, and
tell the user the manifest path plus the total reclaimed. Verify each trash
call succeeded before recording it; if one fails, report it and continue with
the rest rather than aborting silently.
Close with the freed total and, if anything is still unresolved (an interlock trip, a subdirectory worth its own scan), one line on what is left.
Step 8 - undo
Everything is in ~/.Trash, so undo is: open Trash in Finder and "Put Back", or
trash a second time is not needed. If the user asks to reverse a run, read the
manifest and list what to restore - do not script a move out of the Trash, since
Finder's Put Back knows the original paths and a script would not.
Flags
/clean-junk- scan the cwd/clean-junk <dir>- scan that directory instead (still one level)/clean-junk --report- report only, take no action and skip the approval step