← devdisk · SHA-256 457c42e4e5be94082535ac5264cf2ff4e5d82aff0e81a72f77db50d2b84e7521

The report viewer HTML is embedded at the bottom of the file (VIEWER = ...). Search for "remove", "unlink" or "rmtree": the file never calls them.

#!/usr/bin/env python3
"""devdisk — read-only disk scanner for developers. https://disk.sadana.app

SAFETY: this script NEVER deletes, moves or modifies anything. It only reads
file sizes (lstat, symlinks are not followed) and writes two report files
(devdisk-report.json, devdisk-report.html) into the current directory.
Any cleanup script is generated by YOU in the report, reviewed by YOU, and run by YOU.

Usage: python3 devdisk.py [--root DIR ...] [--depth N] [--docker] [--no-open] [--out DIR]
"""
import argparse, json, os, platform, shutil, stat, subprocess, sys, time, webbrowser

VERSION = "0.1.0"
HOME = os.path.expanduser("~")
MAC = sys.platform == "darwin"; WIN = os.name == "nt"
LAD = os.environ.get("LOCALAPPDATA", os.path.join(HOME, "AppData", "Local"))

def H(*p): return os.path.join(HOME, *p)

# (id, label, category, risk, candidate paths, cleanup command or None, note)
# risk: "safe" = regenerated automatically by the tool; "review" = may cost re-downloads or hold data you want.
CACHES = [
 ("npm", "npm cache", "Package caches", "safe", [H(".npm", "_cacache"), os.path.join(LAD, "npm-cache")], "npm cache clean --force", "Re-downloaded on next install."),
 ("pnpm", "pnpm store", "Package caches", "safe", [H("Library", "pnpm", "store"), H(".local", "share", "pnpm", "store"), os.path.join(LAD, "pnpm", "store")], "pnpm store prune", "prune only removes packages no project references."),
 ("yarn", "Yarn cache", "Package caches", "safe", [H("Library", "Caches", "Yarn"), H(".cache", "yarn"), os.path.join(LAD, "Yarn", "Cache")], "yarn cache clean", "Re-downloaded on next install."),
 ("bun", "Bun cache", "Package caches", "safe", [H(".bun", "install", "cache")], "bun pm cache rm", "Re-downloaded on next install."),
 ("pip", "pip cache", "Package caches", "safe", [H("Library", "Caches", "pip"), H(".cache", "pip"), os.path.join(LAD, "pip", "Cache")], "pip cache purge", "Re-downloaded on next install."),
 ("uv", "uv cache", "Package caches", "safe", [H(".cache", "uv"), H("Library", "Caches", "uv"), os.path.join(LAD, "uv", "cache")], "uv cache clean", "Re-downloaded on next install."),
 ("brew", "Homebrew downloads", "Package caches", "safe", [H("Library", "Caches", "Homebrew")], "brew cleanup --prune=all", "Old bottles and downloads."),
 ("gradle", "Gradle caches", "Build caches", "safe", [H(".gradle", "caches")], None, "Next Gradle build re-downloads dependencies (slow first build)."),
 ("derived", "Xcode DerivedData", "Build caches", "safe", [H("Library", "Developer", "Xcode", "DerivedData")], None, "Xcode rebuilds it. Close Xcode first."),
 ("puppeteer", "Puppeteer browsers", "Build caches", "safe", [H(".cache", "puppeteer")], None, "Re-downloaded by puppeteer on next install."),
 ("playwright", "Playwright browsers", "Build caches", "review", [H("Library", "Caches", "ms-playwright"), H(".cache", "ms-playwright"), os.path.join(LAD, "ms-playwright")], "npx playwright uninstall --all", "Tests need `npx playwright install` again."),
 ("cargo", "Cargo registry", "Package caches", "review", [H(".cargo", "registry")], None, "Re-downloaded by cargo; offline builds break until then."),
 ("gomod", "Go module cache", "Package caches", "review", [H("go", "pkg", "mod")], "go clean -modcache", "Re-downloaded by go."),
 ("archives", "Xcode Archives", "iOS & Android SDKs", "review", [H("Library", "Developer", "Xcode", "Archives")], None, "Contains builds you may need to symbolicate crashes. Review first."),
 ("devsupport", "iOS DeviceSupport", "iOS & Android SDKs", "review", [H("Library", "Developer", "Xcode", "iOS DeviceSupport")], None, "Re-created when a device connects."),
 ("simulators", "iOS Simulators", "iOS & Android SDKs", "review", [H("Library", "Developer", "CoreSimulator", "Devices")], "xcrun simctl delete unavailable", "Command only removes simulators for runtimes no longer installed."),
 ("avd", "Android emulators", "iOS & Android SDKs", "review", [H(".android", "avd")], None, "Emulator images and their data. Review first."),
 ("androidsdk", "Android SDK", "iOS & Android SDKs", "review", [H("Library", "Android", "sdk"), H("Android", "Sdk"), os.path.join(LAD, "Android", "Sdk")], None, "Use Android Studio's SDK Manager to remove old platforms; don't delete the folder."),
 ("ollama", "Ollama models", "AI models", "review", [H(".ollama", "models")], "ollama rm <model>  # list with: ollama list", "Large model files. Re-download is slow."),
 ("hf", "Hugging Face cache", "AI models", "review", [H(".cache", "huggingface")], None, "Downloaded models/datasets."),
]
PROJECT_DIRS = {  # dir name -> (label, category, risk, sibling marker required, note)
 "node_modules": ("node_modules", "Code projects", "review", "package.json", "Recreated by npm/pnpm/yarn install. Safe for projects you aren't working on today."),
 ".next": ("Next.js build output", "Code projects", "safe", "package.json", "Rebuilt by next build / next dev."),
 ".turbo": ("Turborepo cache", "Code projects", "safe", None, "Rebuilt by turbo."),
 ".parcel-cache": ("Parcel cache", "Code projects", "safe", None, "Rebuilt by parcel."),
 ".svelte-kit": ("SvelteKit build output", "Code projects", "safe", "package.json", "Rebuilt by vite."),
 "target": ("Rust target/", "Code projects", "safe", "Cargo.toml", "Rebuilt by cargo build (slow)."),
}
SKIP = {".git", "Library", "AppData", ".Trash", "Applications", "Pictures", "Music", "Movies", "Photos Library.photoslibrary", "proc", "sys", "dev", "snap"}

seen = set()
def du(path, deadline):
    """Real on-disk bytes, no symlink following, hard links counted once."""
    total, files, stack = 0, 0, [path]
    while stack:
        if time.time() > deadline: return total, files, True
        p = stack.pop()
        try: it = os.scandir(p)
        except OSError: continue
        with it:
            for e in it:
                try: st = e.stat(follow_symlinks=False)
                except OSError: continue
                if stat.S_ISDIR(st.st_mode): stack.append(e.path); continue
                key = (st.st_dev, st.st_ino)
                if st.st_nlink > 1:
                    if key in seen: continue
                    seen.add(key)
                total += getattr(st, "st_blocks", 0) * 512 if hasattr(st, "st_blocks") else st.st_size
                files += 1
    return total, files, False

def q(p):
    return '"' + p.replace('"', '\\"') + '"' if WIN else "'" + p.replace("'", "'\\''") + "'"

def rm_cmd(p): return ("rmdir /s /q " + q(p)) if WIN else ("rm -rf " + q(p))

def find_projects(roots, depth, deadline):
    out = []
    for root in roots:
        root = os.path.abspath(os.path.expanduser(root)); base = root.count(os.sep)
        for cur, dirs, _files in os.walk(root, followlinks=False):
            if time.time() > deadline: break
            if cur.count(os.sep) - base >= depth: dirs[:] = []; continue
            keep = []
            for d in dirs:
                if d in PROJECT_DIRS:
                    marker = PROJECT_DIRS[d][3]
                    if marker is None or os.path.exists(os.path.join(cur, marker)):
                        out.append((d, os.path.join(cur, d)))
                    continue  # never descend into them
                if d in SKIP or (d.startswith(".") and d not in (".config",)): continue
                keep.append(d)
            dirs[:] = keep
    return out

def docker_info():
    if not shutil.which("docker"): return None
    try:
        r = subprocess.run(["docker", "system", "df", "--format", "{{json .}}"], capture_output=True, text=True, timeout=20)
        return [json.loads(l) for l in r.stdout.splitlines() if l.strip()] if r.returncode == 0 else None
    except Exception: return None

def disk_total():
    try:
        u = shutil.disk_usage(HOME); return {"total": u.total, "used": u.used, "free": u.free}
    except OSError: return None

def main():
    ap = argparse.ArgumentParser(description="Read-only disk scanner for developers. Never deletes anything.")
    ap.add_argument("--root", action="append", help="where to look for projects (default: your home folder)")
    ap.add_argument("--depth", type=int, default=6, help="project search depth (default 6)")
    ap.add_argument("--docker", action="store_true", help="also read `docker system df` (read-only)")
    ap.add_argument("--no-open", action="store_true", help="don't open the HTML report")
    ap.add_argument("--out", default=".", help="folder for the report files (default: current folder)")
    ap.add_argument("--time", type=int, default=180, help="max seconds to spend scanning (default 180)")
    a = ap.parse_args()
    t0 = time.time(); deadline = t0 + a.time
    print("devdisk %s — read-only scan. Nothing will be deleted." % VERSION)
    items = []
    for cid, label, cat, risk, paths, cmd, note in CACHES:
        for p in paths:
            if os.path.isdir(p) and not os.path.islink(p):
                b, n, cut = du(p, deadline)
                if b < 1 << 20: continue
                items.append({"id": cid, "label": label, "category": cat, "risk": risk, "path": p, "bytes": b, "files": n,
                              "partial": cut, "command": cmd or rm_cmd(p), "native": bool(cmd), "note": note})
                print("  %-22s %8.1f GB  %s" % (label, b / 1e9, p))
                break
    roots = a.root or [HOME]
    print("  searching projects under %s ..." % ", ".join(roots))
    for d, p in find_projects(roots, a.depth, deadline):
        b, n, cut = du(p, deadline)
        if b < 5 << 20: continue
        label, cat, risk, _m, note = PROJECT_DIRS[d]
        items.append({"id": d, "label": label, "category": cat, "risk": risk, "path": p, "bytes": b, "files": n,
                      "partial": cut, "command": rm_cmd(p), "native": False, "note": note,
                      "project": os.path.basename(os.path.dirname(p))})
    docker = docker_info() if a.docker else None
    items.sort(key=lambda x: -x["bytes"])
    rep = {"tool": "devdisk", "version": VERSION, "generated": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
           "os": platform.system(), "home": HOME, "disk": disk_total(), "seconds": round(time.time() - t0, 1),
           "timed_out": time.time() > deadline, "windows": WIN, "items": items, "docker": docker}
    os.makedirs(a.out, exist_ok=True)
    jp = os.path.join(a.out, "devdisk-report.json"); hp = os.path.join(a.out, "devdisk-report.html")
    with open(jp, "w", encoding="utf-8") as f: json.dump(rep, f, indent=1)
    data = json.dumps(rep).replace("</", "<\\/")
    with open(hp, "w", encoding="utf-8") as f: f.write(VIEWER.replace("/*__DATA__*/null", data))
    tot = sum(i["bytes"] for i in items); safe = sum(i["bytes"] for i in items if i["risk"] == "safe")
    print("\nFound %.1f GB in %d places (%.1f GB marked safe to regenerate) in %.0fs." % (tot / 1e9, len(items), safe / 1e9, rep["seconds"]))
    print("Report: %s\nNothing was deleted." % os.path.abspath(hp))
    if not a.no_open:
        try: webbrowser.open("file://" + os.path.abspath(hp))
        except Exception: pass

VIEWER = r"""<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>devdisk report</title><meta name="robots" content="noindex">
<style>
*{box-sizing:border-box;accent-color:#111}body{margin:0;font:15px/1.5 system-ui,-apple-system,sans-serif;color:#111;background:#fff}
main{max-width:980px;margin:0 auto;padding:24px 18px 60px}h1{font-size:22px;margin:0 0 4px}.mut{color:#666;font-size:13px}
.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:18px 0}.st{border:1px solid #ddd;border-radius:10px;padding:12px}.st b{display:block;font-size:22px}
.bar{display:flex;height:34px;border-radius:8px;overflow:hidden;border:1px solid #ccc;margin:6px 0 4px}.bar span{display:block;height:100%}
.leg{display:flex;flex-wrap:wrap;gap:12px;font-size:13px;color:#444}.leg i{display:inline-block;width:11px;height:11px;border-radius:2px;margin-right:5px;vertical-align:-1px;border:1px solid #999}
h2{font-size:16px;margin:26px 0 8px}.it{display:grid;grid-template-columns:26px 1fr auto;gap:8px;align-items:start;padding:10px 0;border-top:1px solid #eee}
.it input{width:20px;height:20px;margin-top:2px}.it .p{font:12px ui-monospace,Menlo,monospace;color:#555;word-break:break-all}.sz{font-weight:700;white-space:nowrap}
.mini{height:5px;background:#eee;border-radius:3px;margin-top:5px}.mini span{display:block;height:5px;background:#111;border-radius:3px}
.tag{font-size:11px;border:1px solid #999;border-radius:4px;padding:0 5px;margin-left:6px;color:#444}
.warn{border:2px solid #111;border-radius:10px;padding:12px 14px;margin:14px 0;background:#f6f6f6}
textarea{width:100%;height:260px;font:12px ui-monospace,Menlo,monospace;border:1px solid #bbb;border-radius:8px;padding:10px}
button,.btn{font:inherit;font-weight:600;background:#111;color:#fff;border:0;border-radius:8px;padding:11px 16px;cursor:pointer;min-height:44px}
.sec{background:#fff;color:#111;border:1px solid #111}#drop{border:2px dashed #999;border-radius:12px;padding:40px 16px;text-align:center;margin-top:20px}
.sticky{position:sticky;bottom:0;background:#fff;border-top:1px solid #ddd;padding:10px 0;display:flex;gap:10px;align-items:center;justify-content:space-between;flex-wrap:wrap}
#tm{position:relative;height:420px;border:1px solid #111;border-radius:10px;overflow:hidden;margin:14px 0 6px}
#tm div{position:absolute;border:1px solid #fff;overflow:hidden;padding:4px 6px;font-size:11px;line-height:1.25;cursor:pointer;color:#111}
#tm div.dk{color:#fff}#tm div b{display:block;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}#tm div.on{outline:3px solid #111;outline-offset:-3px}#tm div.dk.on{outline-color:#fff}
#tm div.h{background-image:repeating-linear-gradient(45deg,rgba(0,0,0,.18) 0 2px,transparent 2px 7px)}
@media(max-width:640px){#tm{height:300px}}
@media(max-width:640px){.stats{grid-template-columns:1fr 1fr}.st b{font-size:18px}}
</style></head><body><main>
<h1>devdisk report</h1><div class="mut" id="meta">Read-only scan. Nothing has been deleted.</div>
<div id="drop" hidden><p><b>Open your devdisk-report.json</b></p><p class="mut">The file is read in your browser only. It is never uploaded.</p><input type="file" id="f" accept=".json,application/json"></div>
<div id="app" hidden>
<div class="stats"><div class="st"><span class="mut">Found</span><b id="sTot"></b></div><div class="st"><span class="mut">Safe to regenerate</span><b id="sSafe"></b></div><div class="st"><span class="mut">Review first</span><b id="sRev"></b></div><div class="st"><span class="mut">Disk free now</span><b id="sFree"></b></div></div>
<div id="tmbar" class="mut" style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:4px 0 0"><button type="button" id="tmback" class="sec" hidden style="padding:6px 12px;min-height:36px;font-size:13px">← All categories</button><span id="tmbread">Categories · tap a tile to open</span></div>
<div id="tm" aria-label="Treemap of space used"></div><div class="mut">Tiles are sized by disk space. Open a category to see folders inside. Tap a folder tile to add it to or remove it from the script. Hatched tiles mean review first.</div><div class="bar" id="bar" hidden></div><div class="leg" id="leg"></div>
<div id="list"></div>
<div id="dock"></div>
<h2>Cleanup script (generated, not run)</h2>
<div class="warn"><b>Read before running anything.</b> devdisk never deletes files. This script is just text built from your selection. Read every line, close the apps involved (Xcode, Android Studio, IDEs, dev servers), and run it yourself only if you understand it. "Review first" items are written as comments, so you have to delete the <code>#</code> yourself. Deleted files do not go to the Trash.</div>
<textarea id="sc" readonly></textarea>
<div class="sticky"><span id="sel" class="mut"></span><span><button id="cp">Copy script</button> <button class="sec" id="dl">Download .sh</button></span></div>
</div>
<p class="mut">devdisk by <a href="https://disk.sadana.app" style="color:#111">buildlog</a>. Sizes are real on-disk usage; hard links are counted once.</p>
</main><script>
const DATA=/*__DATA__*/null;
const C={"Package caches":"#d9d9d9","Build caches":"#9a9a9a","Code projects":"#111","iOS & Android SDKs":"#555","AI models":"#fff"};
const gb=b=>b>=1e9?(b/1e9).toFixed(1)+" GB":(b/1e6).toFixed(0)+" MB";
const esc=s=>String(s).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
let R;
const SH={"Package caches":["#e6e6e6",0],"Build caches":["#bdbdbd",0],"Code projects":["#222",1],"iOS & Android SDKs":["#6b6b6b",1],"AI models":["#959595",0]};
function squarify(items,x,y,w,h,out){if(!items.length)return;const tot=items.reduce((a,b)=>a+b.v,0);if(items.length==1){out.push({...items[0],x,y,w,h});return}
 let row=[],best=Infinity,i=0;const side=Math.min(w,h),area=w*h;
 const worst=r=>{const s=r.reduce((a,b)=>a+b.v,0)/tot*area,mx=Math.max(...r.map(z=>z.v/tot*area)),mn=Math.min(...r.map(z=>z.v/tot*area));return Math.max(side*side*mx/(s*s),s*s/(side*side*mn))};
 while(i<items.length){const t=[...row,items[i]],wr=worst(t);if(row.length&&wr>best)break;row=t;best=wr;i++}
 const rs=row.reduce((a,b)=>a+b.v,0)/tot;let o=0;
 if(w>=h){const rw=w*rs;row.forEach(r=>{const rh=h*(r.v/(rs*tot));out.push({...r,x,y:y+o,w:rw,h:rh});o+=rh});squarify(items.slice(i),x+rw,y,w-rw,h,out)}
 else{const rh=h*rs;row.forEach(r=>{const rw=w*(r.v/(rs*tot));out.push({...r,x:x+o,y,w:rw,h:rh});o+=rw});squarify(items.slice(i),x,y+rh,w,h-rh,out)}}
let tmView="cats", tmCat=null;
function drawTM(){const el=document.getElementById("tm");if(!R||!el)return;const W=el.clientWidth,Hh=el.clientHeight;
 const back=document.getElementById("tmback"),bread=document.getElementById("tmbread");
 if(tmView==="cats"){back.hidden=true;bread.textContent="Categories · tap a tile to open folders";
  const cats={};R.items.forEach((x,i)=>{const c=x.category||"Other";if(!cats[c])cats[c]={v:0,n:0,rev:0,i};cats[c].v+=x.bytes;cats[c].n++;if(x.risk!="safe")cats[c].rev++});
  const top=Object.entries(cats).map(([k,o])=>({v:o.v,cat:k,n:o.n,rev:o.rev})).filter(z=>z.v>0).sort((a,b)=>b.v-a.v),out=[];squarify(top,0,0,W,Hh,out);
  el.innerHTML=out.map(t=>{const sh=SH[t.cat]||["#ccc",0],big=t.w>70&&t.h>34;
   return `<div data-cat="${esc(t.cat)}" class="${sh[1]?"dk":""} ${t.rev?"h":""}" style="left:${t.x}px;top:${t.y}px;width:${t.w}px;height:${t.h}px;background-color:${sh[0]}" title="${esc(t.cat)} ${gb(t.v)} · ${t.n} folders">${big?`<b>${esc(t.cat)}</b>${gb(t.v)} · ${t.n}`:""}</div>`}).join("");}
 else{back.hidden=false;bread.textContent=tmCat+" · tap a tile to select for the script";
  const idxs=R.items.map((x,i)=>i).filter(i=>R.items[i].category===tmCat&&R.items[i].bytes>0);
  const top=idxs.map(i=>({v:R.items[i].bytes,i})).sort((a,b)=>b.v-a.v).slice(0,60),out=[];squarify(top,0,0,W,Hh,out);
  const on=new Set([...list.querySelectorAll("input:checked")].map(e=>+e.dataset.i));
  el.innerHTML=out.map(t=>{const x=R.items[t.i],sh=SH[x.category]||["#ccc",0],big=t.w>70&&t.h>34;
   return `<div data-i="${t.i}" class="${sh[1]?"dk":""} ${x.risk!="safe"?"h":""} ${on.has(t.i)?"on":""}" style="left:${t.x}px;top:${t.y}px;width:${t.w}px;height:${t.h}px;background-color:${sh[0]}" title="${esc(x.label)} ${gb(x.bytes)} ${esc(x.path)}">${big?`<b>${esc(x.project||x.label)}</b>${gb(x.bytes)}`:""}</div>`}).join("");}}
document.getElementById("tmback").onclick=()=>{tmView="cats";tmCat=null;drawTM()};
document.addEventListener("click",e=>{const t=e.target.closest&&e.target.closest("#tm div");if(!t)return;
 if(t.dataset.cat){tmView="items";tmCat=t.dataset.cat;drawTM();return}
 const c=list.querySelector(`input[data-i="${t.dataset.i}"]`);if(c){c.checked=!c.checked;build()}});
addEventListener("resize",()=>drawTM());

function render(r){R=r;document.getElementById("drop").hidden=true;document.getElementById("app").hidden=false;
 const it=r.items||[],sum=f=>it.filter(f).reduce((a,x)=>a+x.bytes,0),tot=sum(()=>1);
 meta.textContent=`Scanned ${r.os} · ${r.generated} · ${r.seconds}s${r.timed_out?" · time limit hit, results partial":""}. Read-only: nothing was deleted.`+(r.sample_note?` SAMPLE: ${r.sample_note}`:"");
 sTot.textContent=gb(tot);sSafe.textContent=gb(sum(x=>x.risk=="safe"));sRev.textContent=gb(sum(x=>x.risk!="safe"));sFree.textContent=r.disk?gb(r.disk.free):"–";
 const cats={};it.forEach(x=>cats[x.category]=(cats[x.category]||0)+x.bytes);
 bar.innerHTML=Object.entries(cats).map(([k,v])=>`<span title="${esc(k)} ${gb(v)}" style="width:${v/tot*100}%;background:${C[k]||"#ccc"}"></span>`).join("");
 leg.innerHTML=Object.entries(cats).sort((a,b)=>b[1]-a[1]).map(([k,v])=>`<span><i style="background:${(SH[k]||["#ccc"])[0]}"></i>${esc(k)} ${gb(v)}</span>`).join("");
 const max=it[0]?it[0].bytes:1,grp=(t,f)=>{const g=it.map((x,i)=>[x,i]).filter(([x])=>f(x));return g.length?`<h2>${t}</h2>`+g.map(([x,i])=>`<label class="it"><input type="checkbox" data-i="${i}" ${x.risk=="safe"?"checked":""}><span><b>${esc(x.label)}</b>${x.project?`<span class="tag">${esc(x.project)}</span>`:""}${x.partial?'<span class="tag">partial</span>':""}<div class="p">${esc(x.path)}</div><div class="mut">${esc(x.note)}</div><div class="mini"><span style="width:${Math.max(1,x.bytes/max*100)}%"></span></div></span><span class="sz">${gb(x.bytes)}</span></label>`).join(""):""};
 list.innerHTML=(grp("Safe to regenerate",x=>x.risk=="safe")+grp("Review first",x=>x.risk!="safe"))||"<p>Nothing over the size threshold was found.</p>";
 dock.innerHTML=r.docker?`<h2>Docker (read-only, from docker system df)</h2>`+r.docker.map(d=>`<div class="it" style="grid-template-columns:1fr auto"><span>${esc(d.Type)} <span class="mut">${esc(d.TotalCount)} total, ${esc(d.Active)} active</span></span><span class="sz">${esc(d.Size)} <span class="mut">(${esc(d.Reclaimable)} reclaimable)</span></span></div>`).join("")+`<p class="mut">To reclaim, review <code>docker system prune</code> yourself. It removes stopped containers and unused networks and images.</p>`:"";
 list.onchange=build;build();requestAnimationFrame(drawTM);}
function build(){const sel=[...list.querySelectorAll("input:checked")].map(e=>R.items[+e.dataset.i]),w=R.windows;
 const c=w?"REM":"#",L=[];
 if(!w)L.push("#!/bin/sh");
 L.push(`${c} devdisk cleanup script — generated ${new Date().toISOString()}`,`${c} WARNING: deleting is permanent (not moved to Trash). Read every line first.`,`${c} devdisk did NOT run this. Close Xcode / Android Studio / IDEs / dev servers before running.`,`${c} Lines starting with "${c} [REVIEW]" stay disabled until you remove the comment yourself.`,"");
 let b=0;sel.forEach(x=>{b+=x.bytes;L.push(`${c} ${x.label} — ${gb(x.bytes)} — ${x.note}`);L.push(x.risk=="safe"?x.command:`${c} [REVIEW] ${x.command}`);L.push("")});
 if(!sel.length)L.push(`${c} (nothing selected)`);
 sc.value=L.join("\n");drawTM();document.getElementById("sel").textContent=`${sel.length} selected · ${gb(b)}`;}
cp.onclick=()=>{navigator.clipboard.writeText(sc.value).then(()=>{cp.textContent="Copied";setTimeout(()=>cp.textContent="Copy script",1500)},()=>{sc.select()})};
dl.onclick=()=>{const a=document.createElement("a");a.href=URL.createObjectURL(new Blob([sc.value],{type:"text/plain"}));a.download=R.windows?"devdisk-cleanup.bat":"devdisk-cleanup.sh";a.click()};
if(DATA)render(DATA);else{drop.hidden=false;f.onchange=()=>{const fr=new FileReader();fr.onload=()=>{try{const j=JSON.parse(fr.result);if(j.tool!="devdisk")throw 0;render(j)}catch(e){alert("That isn't a devdisk-report.json file.")}};fr.readAsText(f.files[0])};
 if(location.hash=="#sample")fetch("sample.json").then(r=>r.json()).then(render);}
</script></body></html>
"""

if __name__ == "__main__":
    main()