# Clean Junk

> Inspect the current directory's top-level entries and help the user reclaim space - classify each entry as protected, junk, abandoned, or active; report with evidence; move approved items to the macOS Trash with a reversible manifest. Use when the user asks to clean up, find junk, free disk space, or identify abandoned files and projects in a directory.

- Skill: `makanfar/clean-junk` (Agent Skill)
- Install (CLI): `npx skillmds@latest add makanfar/clean-junk`
- Raw SKILL.md: https://api.skillmd.com/api/skills/makanfar/clean-junk/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Docs & Writing
- Author: MakanFar (https://skillmd.com/u/makanfar)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/makanfar/clean-junk

---


# 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.

1. **Never `rm`.** Every removal goes through the `trash` CLI. If `trash` is
   missing, stop and say so - do not substitute `rm`. This `trash` does 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.
2. **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.
3. **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.
4. **Never delete anything holding unpushed git work.** This is an interlock,
   not a warning - see Git interlock below.
5. **Stay in the cwd.** No parent directories, no following symlinks out, no
   `~` expansion to somewhere else. One directory, top level only.
6. **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

```bash
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'`)
- `.git` itself
- cloud-sync roots: `OneDrive*` `Dropbox` `Google 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` `.venv` `venv` `env` `__pycache__`
  `.pytest_cache` `.mypy_cache` `.ruff_cache` `target` `build` `dist` `.next`
  `.turbo` `.parcel-cache` `.gradle/caches` `DerivedData`
- OS and editor cruft: `.DS_Store` `Thumbs.db` `*.swp` `*~` `.ipynb_checkpoints`
- installers and bootstrap scripts: `*.pkg` `*.dmg` `*.msi` `get-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 `ls` showed: `README + lockfile + 8 subdirs` reads very
  differently from `3 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:

```bash
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=NONE` but **zero commits** is an empty `git 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 2024` for anything older than the current year, `12 Mar`
  within 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

```bash
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

