
Disk Cleaner
- 10 installs
- 34 repo stars
- Updated June 2, 2026
- xiaofenggan01/disk-cleaner-skills
Helps with ai & agent building tasks.
About
disk-cleaner is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- disk-cleaner
- AI & Agent Building
- AI-coding skill
Disk Cleaner by the numbers
- 10 all-time installs (skills.sh)
- +1 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #11,959 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/xiaofenggan01/disk-cleaner-skills --skill disk-cleanerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 10 |
|---|---|
| repo stars | ★ 34 |
| Last updated | June 2, 2026 |
| Repository | xiaofenggan01/disk-cleaner-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
disk-cleaner
macOS / Windows 磁盘清理专家。融合 Mole 的路径验证安全模型、khazix 的交互式 HTML 报告、以及我们自己的深度知识库。
铁律
- 全程只读扫描。 只能跑扫描/统计/列目录/读元信息。绝对禁止 rm、mv、rmdir 等写操作。
- 删除只通过 server.py 执行。 报告里的清理命令是供用户确认后通过网页一键操作或自己在终端运行的。即使用户在对话里说"帮我删",也要先停下确认。
- 沙盒容器路径 = 用户真实目录。 macOS
~/Library/Containers/<app>/Data/Downloads/映射到~/Downloads/,删了就是删用户文件。详见 references/macos.md。 - 路径安全。 所有路径必须引号包裹,执行前 dry-run 预览,危险操作二次确认。
执行流程
Step 1 扫描(只读)
python3 scripts/scan.py > /tmp/storage_scan.jsonscan.py 自动识别系统(sys.platform):
- macOS:扫 home、library、caches、containers、app_support、applications、downloads、dev_caches,以及微信、飞书、QQ、钉钉、Telegram、Teams、Discord、Chrome、Safari、VS Code、Xcode、Steam 等 20+ 应用特定路径。用
du算大小。 - Windows:扫 user_profile、appdata_local、appdata_roaming、temp、downloads、program_files、dev_caches,以及微信、钉钉、飞书、Discord、Teams、Chrome、Edge 等。用
os.scandir算大小。
输出 JSON:system(系统/磁盘信息)+ groups(通用扫描组)+ app_groups(应用特定扫描)+ large_files(>500MB 大文件)。扫描较慢,耐心等。读不到的目录标 denied。
Step 2 分析与分级
先看 system.os 判断系统,读对应参考:macOS 读 references/macos.md,Windows 读 references/windows.md。然后读 /tmp/storage_scan.json 做分级:
1. 挑 Top 5 占用大户,判定类型。 2. 识别"神秘大目录":UUID 命名的 Container,追查它属于哪个 App。 3. 三级分类:
- 🟢 可自动清理:纯缓存、临时文件、可再生。每个 🟢 项必须给
trash_paths(具体可删的绝对路径数组)、预估释放空间、需关闭的进程、清理命令。 - 🟡 需人工判断:含用户数据。给内容画像 + 处置路径 + 风险提示。有安全子路径时给
trash_paths(只给移废纸篓,不给直接删除)。App 托管且无安全子路径的只给打开按钮。 - 🔴 谨慎清理:建议别手删的项(重复应用、想卸的大应用)。给卸载步骤 +
app_paths。红灯不给删除按钮。
4. 大小字段写干净:用"约 14 GB"即可,不要加"(估算)"。
Step 3 生成交互报告
把分析结果写成 analysis JSON(schema 见 scripts/build_report.py 顶部注释)。
默认用服务器模式(`server.py`)打开报告:
python3 scripts/server.py /tmp/storage_analysis.json # 自动开浏览器,Ctrl+C 停server.py 起在 127.0.0.1 + 随机端口 + 随机 token。安全模型:三套白名单(RM_ALLOW / TRASH_ALLOW / OPEN_ALLOW)+ 路径验证(来自 Mole)+ 审计日志 + Host 校验。🟢 项给「移废纸篓」+「直接删除」;🟡 项给「在访达打开」+(有安全子路径时)「移废纸篓」;🔴 项给「在访达打开(去卸载)」。
仅当用户只想要可分享的只读文件时,用静态模式:
python3 scripts/build_report.py /tmp/storage_analysis.json ~/Desktop/storage-report.html && open ~/Desktop/storage-report.html排障:网页上没有删除按钮 = 要么开的是静态报告(改用 server.py),要么 🟢 项漏了 trash_paths。
报告阅读流:磁盘总览卡片 → Top5 → 执行建议 → 🟢🟡🔴 三级折叠卡片 → 长期优化建议。
Step 4 对话里给摘要
报告生成后,在对话里用一段话给结论先行的摘要:总可释放估算、最该先清的 2-3 项、风险最高的一项。细节让用户看网页。
依赖与运行前提
- 全部脚本是 Python 3 标准库,零第三方依赖。
- macOS 自带 python3、
du、diskutil、osascript,开箱即用。 - Windows 需先装 Python 3,命令多为
python或py -3。本 skill 命令示例写的是python3,在 Windows 上自动改用python/py -3。 - 本 skill 是 agent 驱动:扫描出数据后由 agent(Claude)做分级分析。
平台状态
- macOS:完整实现(扫描 / 报告 / 一键删除全验证过)。
- Windows:代码已写但未在真实 Windows 上实测。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>存储分析报告</title>
<style>
:root{
--bg:#f6f7f9; --card:#ffffff; --ink:#1d2129; --sub:#86909c;
--line:#e5e6eb; --green:#00b42a; --yellow:#ff7d00; --red:#f53f3f;
--green-bg:#e8ffea; --yellow-bg:#fff7e8; --red-bg:#ffece8;
--accent:#165dff; --accent-bg:#e8f3ff;
--radius:14px; --shadow:0 1px 3px rgba(0,0,0,.04),0 8px 24px rgba(0,0,0,.04);
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,"SF Pro SC","PingFang SC",system-ui,sans-serif;
background:var(--bg);color:var(--ink);line-height:1.6;padding:32px 20px 80px;
-webkit-font-smoothing:antialiased}
.wrap{max-width:1040px;margin:0 auto}
header{margin-bottom:28px}
h1{font-size:26px;font-weight:700;letter-spacing:-.5px}
.meta{color:var(--sub);font-size:13px;margin-top:6px}
/* disk overview */
.overview{background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);
padding:24px;margin-bottom:24px}
.bar{height:16px;border-radius:8px;background:var(--line);overflow:hidden;display:flex;margin:16px 0 12px}
.bar i{display:block;height:100%}
.bar .used{background:linear-gradient(90deg,#165dff,#4080ff)}
.bar i.seg-green{background:var(--green)}
.bar i.seg-yellow{background:var(--yellow)}
.bar i.seg-red{background:var(--red)}
.bar i.seg-blue{background:var(--accent)}
.bar i+i{border-left:1.5px solid #fff}
.lead{font-size:14px;line-height:1.7;margin-bottom:12px}
.stats{display:flex;flex-wrap:wrap;gap:24px}
.stat .k{font-size:12px;color:var(--sub)}
.stat .v{font-size:18px;font-weight:600;font-variant-numeric:tabular-nums}
.sysgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));
gap:10px 24px;margin-top:20px;padding-top:18px;border-top:1px solid var(--line)}
.sysgrid div{font-size:13px}
.sysgrid span{color:var(--sub)}
.pills{margin:14px 0 0}
.bar-label{font-size:12px;color:var(--sub);margin-bottom:4px}
.other-disks{margin-top:16px;padding-top:14px;border-top:1px solid var(--line)}
.other-disks .label{font-size:12px;color:var(--sub);font-weight:600;margin-bottom:8px}
.odisk{display:flex;align-items:center;gap:12px;margin:6px 0;font-size:13px}
.odisk-name{width:46px;font-weight:600;font-variant-numeric:tabular-nums}
.obar{flex:1;height:10px;border-radius:6px;background:var(--line);overflow:hidden}
.obar i{display:block;height:100%;background:linear-gradient(90deg,#165dff,#4080ff)}
.odisk-num{color:var(--sub);font-variant-numeric:tabular-nums;white-space:nowrap}
/* sections */
.sec{margin-bottom:28px}
.sec h2{font-size:18px;font-weight:700;margin-bottom:14px;display:flex;align-items:center;gap:8px}
.sec h2 .count{font-size:13px;color:var(--sub);font-weight:500}
.sec-actions{margin-left:auto;display:flex;align-items:center;gap:8px}
.sec-actions .del-btn{padding:5px 12px;font-size:12px}
.sec-status{font-size:12px}
/* top5 table */
table{width:100%;border-collapse:collapse;background:var(--card);
border-radius:var(--radius);overflow:hidden;box-shadow:var(--shadow)}
th,td{text-align:left;padding:12px 14px;font-size:13px;border-bottom:1px solid var(--line)}
th{background:#fafbfc;color:var(--sub);font-weight:600;font-size:12px;white-space:nowrap}
td.type{white-space:nowrap}
.table-scroll{overflow-x:auto;border-radius:var(--radius);box-shadow:var(--shadow)}
.table-scroll table{min-width:720px;box-shadow:none}
tr:last-child td{border-bottom:none}
td.size{font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap}
td.path{font-family:"SF Mono",ui-monospace,monospace;font-size:12px;color:var(--sub);word-break:break-all}
.dot{display:inline-block;width:9px;height:9px;border-radius:50%}
.dot.green{background:var(--green)} .dot.yellow{background:var(--yellow)} .dot.red{background:var(--red)} .dot.blue{background:var(--accent)}
/* item cards */
.item{background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);
margin-bottom:12px;border-left:4px solid var(--line);overflow:hidden}
.item.green{border-left-color:var(--green)}
.item.yellow{border-left-color:var(--yellow)}
.item.red{border-left-color:var(--red)}
.item-head{display:flex;align-items:center;gap:12px;padding:16px 18px;cursor:pointer;user-select:none}
.item-head:hover{background:#fafbfc}
.item-name{font-weight:600;font-size:15px;flex:1}
.item-badge{font-size:12px;font-weight:600;color:var(--green);background:var(--green-bg);
padding:2px 10px;border-radius:20px;white-space:nowrap}
.item-badge:empty{display:none}
.item.cleaned .item-name{text-decoration:line-through;color:var(--sub);font-weight:500}
.item.cleaned .item-size{color:var(--sub)}
.item.cleaned{opacity:.85}
.item-size{font-weight:700;font-variant-numeric:tabular-nums;color:var(--accent)}
.chev{color:var(--sub);transition:transform .2s;font-size:12px}
.item.open .chev{transform:rotate(90deg)}
.item-body{display:none;padding:0 18px 18px;font-size:14px}
.item.open .item-body{display:block}
.item-path{font-family:"SF Mono",ui-monospace,monospace;font-size:12px;color:var(--sub);
word-break:break-all;margin-bottom:12px;padding:8px 10px;background:#fafbfc;border-radius:8px}
.field{margin:10px 0}
.field .label{font-size:12px;color:var(--sub);font-weight:600;margin-bottom:3px}
.tag{display:inline-block;background:var(--accent-bg);color:var(--accent);
font-size:12px;padding:2px 8px;border-radius:6px;margin:2px 4px 2px 0}
.risk{background:var(--red-bg);color:var(--red);padding:8px 12px;border-radius:8px;
font-size:13px;margin-top:10px}
/* command block */
.cmd{position:relative;background:#1d2129;color:#e5e6eb;border-radius:10px;
padding:14px 14px;margin:8px 0;font-family:"SF Mono",ui-monospace,monospace;
font-size:12.5px;line-height:1.7;overflow-x:auto;white-space:pre}
.cmd .copy{position:absolute;top:8px;right:8px;background:#2d3138;color:#c9cdd4;
border:none;border-radius:6px;padding:4px 10px;font-size:11px;cursor:pointer;
font-family:inherit}
.cmd .copy:hover{background:#3d424a;color:#fff}
.cmd .copy.done{background:var(--green);color:#fff}
.cmd-label{font-size:11px;color:var(--sub);margin:10px 0 2px}
/* one-click delete actions */
.del-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:4px}
.del-btn{border:none;border-radius:8px;padding:7px 16px;font-size:13px;font-weight:600;cursor:pointer}
.del-btn.trash{background:var(--accent-bg);color:var(--accent)}
.del-btn.trash:hover{background:#d4e4ff}
.del-btn.danger{background:var(--red-bg);color:var(--red)}
.del-btn.danger:hover{background:#ffd4cc}
.del-btn.open{background:#f2f3f5;color:var(--ink)}
.del-btn.open:hover{background:#e5e6eb}
.del-btn:disabled{opacity:.45;cursor:default}
.del-status{font-size:13px}
.del-status.ok{color:var(--green);font-weight:600}
.del-status.err{color:var(--red)}
.del-note{font-size:12px;color:var(--sub);line-height:1.6;margin-top:8px}
.del-note b{color:var(--ink)}
/* summary */
.summary{background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);padding:24px}
.summary h3{font-size:15px;margin:18px 0 8px} .summary h3:first-child{margin-top:0}
.summary ul{padding-left:20px} .summary li{margin:4px 0;font-size:14px}
.pill{display:inline-flex;align-items:center;gap:6px;background:#fafbfc;border:1px solid var(--line);
border-radius:20px;padding:6px 14px;font-size:13px;margin:4px 8px 4px 0;font-weight:600}
.pill b{font-variant-numeric:tabular-nums}
.note{font-size:12px;color:var(--sub);margin-top:8px}
.denied{background:var(--yellow-bg);border:1px solid #ffd591;border-radius:10px;
padding:12px 14px;font-size:13px;margin-top:12px}
footer{text-align:center;color:var(--sub);font-size:12px;margin-top:40px}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>存储分析报告</h1>
<div class="meta" id="meta"></div>
</header>
<div id="app"></div>
<footer>disk-cleaner · 只读分析 · 所有删除操作均有审计日志 · 安全模型参考 Mole + khazix</footer>
</div>
<script>
const DATA = __REPORT_DATA__;
const DELETE = __DELETE_CONFIG__; // null=静态只读报告;{token,endpoint}=server.py 提供一键删除
const FM = (DATA.system && /win/i.test(DATA.system.os||'')) ? '资源管理器' : '访达'; // 文件管理器名,按系统
const esc = s => String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
// "约" 已表示估算,去掉冗余的"(估算…)"括号让大小显示更干净专业
const cleanSize = s => String(s==null?'':s).replace(/[((]\s*估算[^))]*[))]/g, '').trim();
function copyBtn(text){
const id='c'+Math.random().toString(36).slice(2);
// store raw text on element via data attribute (base64 to survive quotes)
return `<button class="copy" data-cmd="${btoa(unescape(encodeURIComponent(text)))}">复制</button>`;
}
function cmdBlock(c){
const label = c.label ? `<div class="cmd-label">${esc(c.label)}</div>` : '';
return `${label}<div class="cmd">${copyBtn(c.cmd)}${esc(c.cmd)}</div>`;
}
function renderSystem(s, sm){
document.getElementById('meta').textContent =
`生成于 ${DATA.generated_at} · 扫描耗时 ${DATA.scan_seconds||'?'}s`;
// 已用部分分段:绿(可自动清)+橙(需手动)+红(已识别的不建议动项)+蓝(其他/系统等未归类的已用),余下灰底为可用
const total = parseGB(s.disk_total) || 1;
const used = parseGB(s.disk_used);
const ts = sm && sm.tier_stats;
const g = ts ? parseGB(ts.green) : 0;
const y = ts ? parseGB(ts.yellow) : 0;
const r = ts ? parseGB(ts.red) : 0;
const other = Math.max(0, used - g - y - r); // 既非红黄绿、也未单独列出的已用空间(系统+零碎文件)
let bar;
if(g > 0 || y > 0 || r > 0){
const seg=(v,cls,label)=> v>0?`<i class="${cls}" style="width:${(v/total*100).toFixed(2)}%" title="${label}:约 ${v.toFixed(1)} GB"></i>`:'';
bar = seg(g,'seg-green','可自动清')+seg(y,'seg-yellow','需手动')+seg(r,'seg-red','谨慎清理')+seg(other,'seg-blue','系统及其他');
} else {
bar = `<i class="used" style="width:${(used/total*100).toFixed(2)}%"></i>`;
}
const fmtGB = v => v>0?`约 ${v.toFixed(1)} GB`:'-';
const otherPill = other>0?`<span class="pill"><span class="dot blue"></span>系统及其他 <b>${fmtGB(other)}</b></span>`:'';
let pills='';
if(sm && sm.tier_stats){
pills=`<div class="pills">
<span class="pill"><span class="dot green"></span>可自动清 <b>${fmtGB(g)}</b></span>
<span class="pill"><span class="dot yellow"></span>需手动 <b>${fmtGB(y)}</b></span>
<span class="pill"><span class="dot red"></span>谨慎清理 <b>${fmtGB(r)}</b></span>
${otherPill}
</div>`;
}
// 多盘符(Windows):主盘用上面的分段条,其余盘紧凑列出
const allDisks = s.disks || [];
const others = allDisks.filter(d => d.name !== s.disk_name);
const barLabel = allDisks.length > 1
? `<div class="bar-label">系统盘 ${esc(s.disk_name||'')} · 已用部分按清理分级着色</div>` : '';
const otherDisksBlock = others.length ? `<div class="other-disks">
<div class="label">其他磁盘</div>
${others.map(d=>{
const u=parseGB(d.used), t=parseGB(d.total)||1;
const pct=Math.min(100, u/t*100).toFixed(1);
return `<div class="odisk"><span class="odisk-name">${esc(d.name)}</span>
<div class="obar"><i style="width:${pct}%"></i></div>
<span class="odisk-num">${esc(d.used)} / ${esc(d.total)}</span></div>`;
}).join('')}</div>` : '';
return `<div class="overview">
<div class="stats">
<div class="stat"><div class="k">总容量</div><div class="v">${esc(s.disk_total)}</div></div>
<div class="stat"><div class="k">已用</div><div class="v">${esc(s.disk_used)}</div></div>
<div class="stat"><div class="k">可用</div><div class="v">${esc(s.disk_free)}</div></div>
${s.purgeable?`<div class="stat"><div class="k">可清除(系统)</div><div class="v">${esc(s.purgeable)}</div></div>`:''}
</div>
${barLabel}
<div class="bar">${bar}</div>
${pills}
${otherDisksBlock}
<div class="sysgrid">
<div><span>系统 </span>${esc(s.os)} (${esc(s.build)})</div>
<div><span>架构 </span>${esc(s.arch)}</div>
<div><span>文件系统 </span>${esc(s.filesystem)}</div>
<div><span>用户 </span>${esc(s.user)}</div>
<div><span>主目录 </span>${esc(s.home)}</div>
</div>
</div>`;
}
// 从 "约 27.8 GB(…)" / "300.3 GB" 这类字符串里取数值,统一换算成 GB
function parseGB(str){
if(!str) return 0;
const m=String(str).match(/([\d.]+)\s*(TB|GB|MB)?/i);
if(!m) return 0;
let v=parseFloat(m[1]); const u=(m[2]||'GB').toUpperCase();
if(u==='TB') v*=1024; else if(u==='MB') v/=1024;
return v;
}
function renderTop5(rows){
if(!rows||!rows.length) return '';
const body = rows.map(r=>`<tr>
<td>${esc(r.rank)}</td>
<td><span class="dot ${esc(r.tier)}"></span></td>
<td class="size">${esc(cleanSize(r.size))}</td>
<td class="type">${esc(r.type)}</td>
<td><b>${esc(r.name)}</b></td>
<td class="path">${esc(r.path)}</td>
<td>${esc(r.note)}</td>
</tr>`).join('');
return `<div class="sec"><h2>占用排行 Top 5</h2>
<div class="table-scroll"><table><thead><tr><th>#</th><th>级</th><th>大小</th><th>类型</th><th>项目</th><th>路径</th><th>说明</th></tr></thead>
<tbody>${body}</tbody></table></div></div>`;
}
function renderGreen(items){
if(!items||!items.length) return '';
const cards = items.map(it=>{
const procs=(it.kill_processes&&it.kill_processes.length)
? `<div class="field"><div class="label">清理前需关闭</div>${it.kill_processes.map(p=>`<span class="tag">${esc(p)}</span>`).join('')}</div>`
: `<div class="field"><div class="label">清理前需关闭</div><span class="note">无需关闭任何进程</span></div>`;
const cmds=(it.commands||[]).map(cmdBlock).join('');
return card('green', it.name, it.size_estimate, it.path,
procs + `<div class="field"><div class="label">清理命令(自行确认后执行)</div>${cmds}</div>` + delBlock(it));
}).join('');
return section('🟢 可自动清理(纯缓存/临时文件,可再生)', items.length, cards, greenBatchActions(items));
}
// 分组级批量按钮:绿灯每项都是无条件安全的可再生缓存,给「全部移废纸篓/全部删除」一次清完。
// 橙灯/红灯需逐项判断,不给批量。仅 server.py 模式且至少一项有 trash_paths 时渲染。
function greenBatchActions(items){
if(!DELETE) return '';
if(!items.some(it => it.trash_paths && it.trash_paths.length)) return '';
return `<span class="sec-actions">
<button class="del-btn trash" onclick="doBatch(this,'trash')">全部移废纸篓</button>
<button class="del-btn danger" onclick="doBatch(this,'rm')">全部删除</button>
<span class="del-status sec-status"></span>
</span>`;
}
// 批量处理:收集本组所有未清理项的 trash_paths,确认一次,一次性提交。
function doBatch(btn, mode){
const secActions = btn.closest('.sec-actions');
const sec = btn.closest('.sec');
const status = secActions.querySelector('.del-status');
const wraps = [...sec.querySelectorAll('.item.green .del-actions')]
.filter(w => !w.closest('.item').classList.contains('cleaned'));
if(!wraps.length){ status.textContent='没有可清理的项了'; status.className='del-status sec-status'; return; }
const allPaths=[]; let gb=0;
wraps.forEach(w=>{
JSON.parse(decodeURIComponent(escape(atob(w.dataset.paths)))).forEach(p=>allPaths.push(p));
const sz=w.closest('.item').querySelector('.item-size'); if(sz) gb+=parseGB(sz.textContent);
});
const verb = mode==='rm' ? '直接删除(不可恢复)' : '移到废纸篓(可逆,清空后释放)';
const sizeStr = gb>0 ? `,约 ${gb.toFixed(1)} GB` : '';
if(!confirm(`确认将这组 ${wraps.length} 项缓存全部${verb}?\n共 ${allPaths.length} 个路径${sizeStr}。`)) return;
postAction(secActions, allPaths, mode).then(res=>{
if(res.ok){
status.textContent = mode==='rm' ? `✓ 已删除 ${wraps.length} 项` : `✓ 已移 ${wraps.length} 项到废纸篓`;
status.className='del-status sec-status ok';
wraps.forEach(w=>{
const item=w.closest('.item'); item.classList.add('cleaned');
const badge=item.querySelector('.item-head .item-badge');
if(badge) badge.textContent = mode==='rm' ? '已删除' : '已移废纸篓';
const ist=w.querySelector('.del-status');
if(ist){ ist.textContent = mode==='rm'?'✓ 已删除':'✓ 已移到废纸篓'; ist.className='del-status ok'; }
w.querySelectorAll('.del-btn.trash,.del-btn.danger').forEach(b=>b.remove());
});
secActions.querySelectorAll('.del-btn').forEach(b=>b.remove());
} else { status.textContent='✗ '+(res.error||'失败'); status.className='del-status sec-status err'; }
});
}
// 一键删除按钮:仅当 server.py 提供了 DELETE 配置、且该项有具体 trash_paths 时渲染
function delBlock(it){
if(!DELETE || !it.trash_paths || !it.trash_paths.length) return '';
const payload = btoa(unescape(encodeURIComponent(JSON.stringify(it.trash_paths))));
return `<div class="field del-actions" data-paths="${payload}">
<div class="label">在网页上直接处理(${it.trash_paths.length} 个路径)</div>
<div class="del-row">
<button class="del-btn trash" onclick="doDelete(this,'trash')">移到废纸篓</button>
<button class="del-btn danger" onclick="doDelete(this,'rm')">直接删除</button>
<span class="del-status"></span>
</div>
<div class="del-note"><b>移到废纸篓</b>:可逆,删错能从${FM}废纸篓捞回,但要清空废纸篓才真正释放空间。<b>直接删除</b>:立即释放、不可恢复。绿灯都是可再生缓存,删了会自动重建。</div>
</div>`;
}
function postAction(wrap, paths, mode){
const status = wrap.querySelector('.del-status');
const btns = wrap.querySelectorAll('.del-btn');
status.textContent = '处理中…'; status.className='del-status';
btns.forEach(b=>b.disabled=true);
return fetch(DELETE.endpoint, {method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({token:DELETE.token, paths, mode})})
.then(r=>r.json()).then(res=>{ btns.forEach(b=>b.disabled=false); return res; })
.catch(e=>{ btns.forEach(b=>b.disabled=false); return {ok:false, error:'连接失败:'+e}; });
}
function doDelete(btn, mode){
const wrap = btn.closest('.del-actions');
const paths = JSON.parse(decodeURIComponent(escape(atob(wrap.dataset.paths))));
const verb = mode==='rm' ? '直接删除(不可恢复)' : '移到废纸篓(访达可恢复)';
if(!confirm(`确认${verb}以下 ${paths.length} 个路径?\n\n`+paths.join('\n'))) return;
postAction(wrap, paths, mode).then(res=>{
const status = wrap.querySelector('.del-status');
if(res.ok){
status.textContent = mode==='rm' ? '✓ 已删除,空间已释放' : '✓ 已移到废纸篓(访达可恢复,清空后释放空间)';
status.className='del-status ok';
wrap.querySelectorAll('.del-btn.trash,.del-btn.danger').forEach(b=>b.remove());
// 折叠状态下也能看到:标题行打上徽标 + 整行变灰
const item = wrap.closest('.item');
if(item){
item.classList.add('cleaned');
const badge = item.querySelector('.item-head .item-badge');
if(badge) badge.textContent = mode==='rm' ? '已删除' : '已移废纸篓';
}
} else { status.textContent='✗ '+(res.error||'失败'); status.className='del-status err'; }
});
}
function doOpen(btn){
const wrap = btn.closest('.del-actions');
const paths = JSON.parse(decodeURIComponent(escape(atob(wrap.dataset.open))));
postAction(wrap, paths, 'open').then(res=>{
const status = wrap.querySelector('.del-status');
if(res.ok){ status.textContent='✓ 已在'+FM+'打开,自行审查删除'; status.className='del-status ok'; }
else { status.textContent='✗ '+(res.error||'失败'); status.className='del-status err'; }
});
}
const b64 = o => btoa(unescape(encodeURIComponent(JSON.stringify(o))));
function renderYellow(items){
if(!items||!items.length) return '';
const cards = items.map(it=>card('yellow', it.name, it.size, it.path,
`<div class="field"><div class="label">内容画像</div>${esc(it.content_profile)}</div>
<div class="field"><div class="label">为什么需要人工判断</div>${esc(it.why_manual)}</div>
<div class="field"><div class="label">处置路径</div>${esc(it.disposal)}</div>
<div class="risk">⚠️ ${esc(it.risk)}</div>` + yellowActions(it))).join('');
return section('🟡 需你参与的手动清理(含用户数据)', items.length, cards);
}
// 橙灯操作:打开文件夹(去自己审查删)+ 仅当有核实过的安全子路径时给「移到废纸篓」(只可逆,不直接删)
function yellowActions(it){
if(!DELETE) return '';
const hasTrash = it.trash_paths && it.trash_paths.length;
const trashBtn = hasTrash
? `<button class="del-btn trash" onclick="doDelete(this,'trash')">移到废纸篓(仅安全部分)</button>` : '';
const trashNote = hasTrash
? `<b>移到废纸篓(仅安全部分)</b>:只移这一项核实过可安全清理的子目录,可逆、需清空废纸篓才释放空间。` : '';
const openNote = it.open_note ? esc(it.open_note) : '';
return `<div class="field del-actions" data-paths="${hasTrash?b64(it.trash_paths):''}" data-open="${b64([it.path])}">
<div class="label">在网页上处理</div>
<div class="del-row">
<button class="del-btn open" onclick="doOpen(this)">在${FM}里打开</button>
${trashBtn}
<span class="del-status"></span>
</div>
<div class="del-note"><b>在${FM}里打开</b>:只打开文件夹供你自己查看,不删任何东西。注意 App 托管的数据(B站/微信/Chrome 等)在${FM}里是程序内部格式、文件名看不懂,想清优先去对应 App 内删。${trashNote}${openNote?'<br>'+openNote:''}</div>
</div>`;
}
function renderRed(items){
if(!items||!items.length) return '';
const cards = items.map(it=>card('red', it.name, it.size||'', it.path,
`<div class="field"><div class="label">为什么不建议手删</div>${esc(it.why_keep)}</div>
<div class="field"><div class="label">卸载 / 释放建议</div>${esc(it.indirect_release)}</div>
${it.auto_reclaim?`<div class="field"><div class="label">是否自动回收</div>${esc(it.auto_reclaim)}</div>`:''}` + redActions(it))).join('');
return section('🔴 谨慎清理(建议走正规卸载,别手动拖删)', items.length, cards);
}
// 红灯操作:只给「在文件管理器里打开并选中该 App」,让用户自己正规卸载(不后台代删,App 需谨慎)
function redActions(it){
if(!DELETE || !it.app_paths || !it.app_paths.length) return '';
return `<div class="field del-actions" data-open="${b64(it.app_paths)}">
<div class="label">在网页上处理</div>
<div class="del-row">
<button class="del-btn open" onclick="doOpen(this)">在${FM}里打开(去卸载)</button>
<span class="del-status"></span>
</div>
<div class="del-note"><b>在${FM}里打开</b>:定位到该应用,你可以右键「移到废纸篓」卸载,或用它自带的卸载器。应用本体放在系统目录、可能需要管理员密码,所以由你在${FM}里手动操作更稳妥,本页不代删。</div>
</div>`;
}
function card(tier,name,size,path,inner){
return `<div class="item ${tier}">
<div class="item-head" onclick="this.parentNode.classList.toggle('open')">
<span class="dot ${tier}"></span>
<span class="item-name">${esc(name)}</span>
<span class="item-badge"></span>
${size?`<span class="item-size">${esc(cleanSize(size))}</span>`:''}
<span class="chev">▶</span>
</div>
<div class="item-body">
<div class="item-path">${esc(path)}</div>
${inner}
</div></div>`;
}
function section(title,count,inner,actions){
return `<div class="sec"><h2>${esc(title)} <span class="count">${count} 项</span>${actions||''}</h2>${inner}</div>`;
}
const sumList=a=>(a&&a.length)?`<ul>${a.map(x=>`<li>${esc(x)}</li>`).join('')}</ul>`:'';
// 执行建议:放在 Top5 之后、三级明细之前(先看处方,再看每项怎么做)
// 开头用 overview 一句洞察作引子,下面是优先级清单
function renderPriority(sm){
if(!sm || (!sm.priority && !sm.overview)) return '';
const intro = sm.overview?`<p class="lead">${esc(sm.overview)}</p>`:'';
const body = sm.priority?(Array.isArray(sm.priority)?sumList(sm.priority):`<p>${esc(sm.priority)}</p>`):'';
return `<div class="sec"><h2>执行建议</h2><div class="summary">${intro}${body}</div></div>`;
}
// 长期优化建议:留在报告最后
function renderLongTerm(sm){
if(!sm || !sm.long_term || !sm.long_term.length) return '';
return `<div class="sec"><h2>长期优化建议</h2><div class="summary">${sumList(sm.long_term)}</div></div>`;
}
function renderDenied(){
if(!DATA.denied||!DATA.denied.length) return '';
return `<div class="denied"><b>以下目录权限不足未能读取,可能遗漏体量:</b><br>${DATA.denied.map(esc).join('<br>')}</div>`;
}
// mount
const app=document.getElementById('app');
app.innerHTML = renderSystem(DATA.system, DATA.summary)
+ renderTop5(DATA.top5)
+ renderPriority(DATA.summary)
+ renderGreen(DATA.green)
+ renderYellow(DATA.yellow)
+ renderRed(DATA.red)
+ renderDenied()
+ renderLongTerm(DATA.summary);
// copy handlers (delegated)
document.addEventListener('click', e=>{
const b=e.target.closest('.copy'); if(!b) return;
e.stopPropagation();
const text=decodeURIComponent(escape(atob(b.dataset.cmd)));
navigator.clipboard.writeText(text).then(()=>{
b.textContent='已复制'; b.classList.add('done');
setTimeout(()=>{b.textContent='复制';b.classList.remove('done');},1500);
});
});
</script>
</body>
</html>
MIT License
Copyright (c) 2026 xiaofenggan01
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
disk-cleaner
macOS / Windows 磁盘清理 Skill,专为 Claude Code 设计。
这是什么?
一个融合了三大项目精华的 AI 磁盘清理工具:
- [khazix Storage Analyzer](https://github.com/KKKKhazix/khazix-skills) — 交互式 HTML 报告 + 本地安全服务器 + 三套白名单
- [Mole](https://github.com/tw93/Mole)(54K+ Stars)— 路径验证三道防线 + 200+ 受保护应用列表 + 操作审计
- 我们的知识库 — 10 大类 100+ 条安全清理目标(中文应用覆盖最全)
v4.0.0 将 macOS 和 Windows 两个独立 Skill 合并为一个统一的 disk-cleaner,自动识别系统。
核心特性
🎨 交互式 HTML 报告
不再是纯文本 Markdown 表格。扫描后生成精美的可视化报告:
- 磁盘进度条 — 已用空间按清理分级着色(🟢绿/🟡橙/🔴红/🔵蓝 分段)
- 三色分级卡片 — 每个发现项可展开查看详情、清理命令一键复制
- Top 5 占用排行 — 一眼看到谁在吃磁盘
- 执行建议 + 长期优化 — 结论先行,行动路径清晰
🔒 本地安全服务器
一键启动本地服务,在浏览器里直接操作:
- 绑定
127.0.0.1+ 随机端口 + 随机 Token + Host 校验 - 三套白名单:
RM_ALLOW(仅绿灯可硬删) /TRASH_ALLOW(绿+橙可入废纸篓) /OPEN_ALLOW(非破坏性打开) - 路径验证(来自 Mole):拒绝空路径、相对路径、路径穿越、符号链接攻击、系统关键路径
- 操作审计日志:每次操作记录到
~/Library/Logs/disk-cleaner/operations.log - Fail-closed:Trash 失败时拒绝降级为永久删除
🧠 AI 智能分析
Claude AI 做传统工具做不到的事:
- 语义分析:区分「微信重要聊天记录」和「三个月前的小程序网页缓存」
- 内容读取:读取文件内容判断是否安全可删
- 路径推理:从 UUID 容器路径推断属于哪个 App
- 智能分级:🟢 安全可删 / 🟡 需人工判断 / 🔴 谨慎清理
📊 超广覆盖(20+ 应用特定扫描)
| 类别 | 覆盖应用 |
|---|---|
| 通讯 | 微信、飞书、QQ、钉钉、Telegram、Teams、Discord |
| 浏览器 | Chrome、Safari、Firefox、Arc、Edge |
| 开发 | Homebrew、npm/pnpm、pip、Xcode、VS Code、Cursor、Gradle、Go |
| 创意 | Adobe、Figma、Steam |
| 系统 | 废纸篓、系统缓存、诊断日志、Docker |
快速使用
安装
# 克隆到 Claude Code skills 目录
git clone https://github.com/xiaofenggan01/disk-cleaner-skills.git ~/.claude/skills/disk-cleaner触发方式
在 Claude Code 中说"磁盘满了"、"清理电脑"、"空间不够"等,自动触发。
手动扫描
# 扫描(只读,约 1-2 分钟)
python3 scripts/scan.py > /tmp/storage_scan.json扫描完成后,Claude 会读取 JSON 做智能分级分析,然后生成 HTML 报告。
目录结构
disk-cleaner/
├── SKILL.md # Skill 指令(流程引导)
├── scripts/
│ ├── scan.py # 扫描脚本(macOS + Windows 自动识别)
│ ├── build_report.py # 静态 HTML 报告生成
│ └── server.py # 本地安全服务器
├── assets/
│ └── report_template.html # 交互式 HTML 模板
└── references/
├── macos.md # macOS 分级参考 + 受保护应用列表
└── windows.md # Windows 分级参考安全机制
Mole 启发的路径验证
validate_path()
├── 空路径拒绝
├── 相对路径拒绝
├── 路径穿越检查(..)
├── 控制字符检查
├── 符号链接解析 + 目标校验
├── 系统关键路径黑名单(/System, /usr, /bin 等 20+ 路径)
└── $HOME 范围检查macOS 沙盒容器安全
macOS 沙盒容器中的 Data/Downloads/ 等路径是 hardlink 到用户真实目录的,删除即删用户文件。本 Skill 会在 references/macos.md 中明确标注映射关系。
200+ 受保护应用
不可清理的应用数据(来自 Mole 的保护列表):
- 系统关键(Finder、Dock、Safari、Terminal 等)
- 密码管理器(1Password、Bitwarden、LastPass 等)
- VPN/代理(Clash、Surge、Tailscale、WireGuard 等)
- AI 工具(Claude、ChatGPT、Cursor、Ollama 等)
- IDE(VS Code、JetBrains、Xcode 等)
- 通讯软件(微信、QQ、钉钉、Teams 等)
技术细节
- 零依赖:纯 Python 3 标准库,无需 pip install
- 跨平台:
sys.platform自动识别 macOS / Windows - 只读扫描:scan.py 只做 du/stat/ls,不做任何写操作
- Agent 驱动:扫描出数据后由 Claude AI 做分级分析
致谢
- [khazix-skills](https://github.com/KKKKhazix/khazix-skills) — 交互式 HTML 报告、本地安全服务器、三套白名单架构、SKILL.md 流程设计
- [Mole](https://github.com/tw93/Mole) — 路径验证安全模型、200+ 受保护应用列表、操作审计、孤儿检测
- [mac-cleanup-py](https://github.com/mac-cleanup/mac-cleanup-py) — 应用清理路径参考
- [CCleaner](https://www.ccleaner.com/) — 注册表扫描引擎架构参考
- [Dism++](https://github.com/Chuyu-Team/Dism-Multi-Tool) — Windows 清理规则引擎参考
License
MIT
macOS 数据布局与分级参考
分析 macOS 扫描结果时读这份。讲"东西存在哪、怎么辨认、归哪一级"。
关键目录
| 目录 | 装什么 | 典型分级 |
|---|---|---|
~/Library/Caches/* | 应用/工具缓存(浏览器、Homebrew、pip、playwright) | 🟢 可自动清 |
~/.cache/*、~/.npm、~/.cargo、~/.gradle、~/.m2 | 开发缓存 | 🟢 |
~/Library/Developer/Xcode/DerivedData、CoreSimulator | Xcode 构建/模拟器 | 🟢 |
~/Library/Containers/<UUID 或 bundleid> | 沙盒应用数据(聊天记录、离线视频、设置) | 🟡 多为用户数据 |
~/Library/Application Support/* | 应用数据(Chrome Profile、Claude VM、飞书) | 🟡 需判断 |
~/Downloads 里的 .dmg/.pkg | 安装包残留 | 🟢 |
/Applications/*.app | 应用本体 | 🔴 仅当重复/想卸时上灯,否则归蓝色 |
| 系统文件、APFS 本地快照 | 系统 | 不上灯,归蓝色"系统及其他" |
辨认"神秘 UUID 容器"
~/Library/Containers/ 下 UUID 命名的大目录,要查清属于哪个 App:
ls进Data/Documents/、Data/Library/,找带 bundle id 的子目录(如com.bilibili.bbad→ 哔哩哔哩)- 大头常藏在隐藏目录(如
.Downloads/里的.bilitask离线视频) - 仍只读,别动文件
容器路径安全(macOS 沙盒映射)
macOS 沙盒容器中的以下路径是 hardlink/映射 到用户真实主目录的,绝对不是独立副本:
| 容器路径 | 映射到 |
|---|---|
~/Library/Containers/<app>/Data/Downloads/ | ~/Downloads/ |
~/Library/Containers/<app>/Data/Documents/ | ~/Documents/ |
~/Library/Containers/<app>/Data/Desktop/ | ~/Desktop/ |
~/Library/Containers/<app>/Data/Movies/ | ~/Movies/ |
~/Library/Containers/<app>/Data/Music/ | ~/Music/ |
~/Library/Containers/<app>/Data/Pictures/ | ~/Pictures/ |
执行任何容器路径删除前,必须用 `readlink` 或 `ls -la` 验证是否为 symlink。如果不确定,宁可跳过。
受保护应用列表
以下应用的 数据和缓存不可清理(含用户数据、配置、凭证)。仅在用户明确要求卸载时才可操作。
系统关键(绝对不可动)
com.apple.finder, com.apple.dock, com.apple.Safari, com.apple.mail, com.apple.SystemSettings, com.apple.Settings, com.apple.controlcenter, com.apple.Spotlight, com.apple.loginwindow, com.apple.Preview, com.apple.Notes, com.apple.Photos, com.apple.AppStore, com.apple.Terminal, com.apple.DiskUtility, com.apple.KeychainAccess
系统服务:com.apple.SecurityAgent, com.apple.CoreServices, com.apple.SystemUIServer, com.apple.keychain, com.apple.security, com.apple.WiFi, com.apple.Bluetooth*
密码管理器
com.1password., com.agilebits., com.lastpass., com.dashlane., com.bitwarden., com.keepassx., org.keepassxc., com.authy., com.yubico.*
VPN / 代理工具
com.clash., ClashX, com.nssurge., com.v2ray., ShadowsocksX-NG, tailscale, zerotier, com.wireguard., amnezia, nordvpn, expressvpn, protonvpn, mullvad*
AI 工具
Cursor, com.anthropic.claude, Claude, com.openai.chat, ChatGPT, com.openai.codex, Codex, com.ollama.ollama, Ollama, com.lmstudio.lmstudio, Gemini
IDE & 编辑器
com.jetbrains., com.microsoft.VSCode, com.microsoft.VSCodeInsiders, com.sublimetext., com.apple.dt.Xcode
通讯软件
com.tencent.xinWeChat(微信), com.tencent.qq, com.alibaba.DingTalkMac(钉钉), us.zoom.xos, com.microsoft.teams*, com.slack.Slack, com.hnc.Discord, org.telegram.desktop, net.whatsapp.WhatsApp
设计 & 创意
com.adobe., com.figma., com.bohemiancoding., com.affinitydesigner., com.canva.CanvaDesktop, com.pixelmatorteam.*
虚拟化
com.docker.docker, dev.orbstack.OrbStack, com.getutm.UTM, com.vmware.fusion, com.parallels.desktop.*
云存储 & 同步
com.dropbox., com.microsoft.OneDrive, com.google.GoogleDrive, com.apple.CloudDocs*
安全清理目标知识库
🟢 系统级缓存(100% 安全)
| 目标 | 路径 | 典型大小 |
|---|---|---|
| 用户缓存 | ~/Library/Caches/* | 1-5GB |
| 用户日志 | ~/Library/Logs/* | 10-100MB |
| 系统诊断日志 | /Library/Logs/DiagnosticReports/* | 50-500MB |
| 废纸篓 | ~/.Trash/* | 不定 |
| HTTP 存储 | ~/Library/HTTPStorages/* | 10-100MB |
🟢 通讯软件缓存(100% 安全)
| 目标 | 路径 | 典型大小 |
|---|---|---|
| 微信小程序网页缓存 | .../app_data/radium/web/profiles/ | 可达 15GB |
| 微信日志 | .../app_data/log/ | 100MB-1GB |
| 微信小程序 Applet | .../app_data/radium/Applet/ | 100-500MB |
| 飞书 LarkShell 缓存 | .../LarkShell/aha/ | 可达 23GB |
| QQ 旧版本安装包 | .../QQ/versions/*.zip | 1-3GB |
| Telegram 缓存 | .../postbox/db | 500MB-5GB |
| Teams 缓存 | Cache/, Code Cache/, IndexedDB/, blob_storage/ | 500MB-2GB |
| Discord 缓存 | Cache/, Code Cache/ | 200MB-1GB |
🟢 浏览器缓存(100% 安全)
| 目标 | 路径 | 典型大小 |
|---|---|---|
| Chrome Service Worker | .../Default/Service Worker/CacheStorage | 500MB-2GB |
| Safari 缓存 | ~/Library/Caches/com.apple.Safari/ | 100MB-1GB |
| Firefox 缓存 | ~/Library/Caches/Firefox/ | 100MB-500MB |
| Arc 缓存 | ~/Library/Caches/Arc/ | 100MB-500MB |
🟢 开发工具缓存(100% 安全)
| 目标 | 命令/路径 | 典型大小 |
|---|---|---|
| Homebrew | brew cleanup -s --prune-all | 100MB-1GB |
| npm npx 缓存 | rm -rf ~/.npm/_npx | 500MB-2GB |
| npm 缓存 | npm cache clean --force | 500MB-2GB |
| pip 缓存 | pip cache purge | 100MB-1GB |
| Xcode DerivedData | rm -rf ~/Library/Developer/Xcode/DerivedData/* | 1-10GB |
| iOS 模拟器 | xcrun simctl erase all | 1-5GB |
| Gradle | rm -rf ~/.gradle/caches | 500MB-5GB |
| Docker | docker system df + docker builder prune -af | 不定 |
🟢 游戏平台缓存(100% 安全)
| 目标 | 路径 | 典型大小 |
|---|---|---|
| Steam appcache/depotcache/logs | ~/Library/Application Support/Steam/{appcache,depotcache,logs} | 100-500MB |
| Steam shadercache | .../steamapps/shadercache | 100MB-1GB |
不建议删除的区域
| 路径 | 原因 |
|---|---|
~/Library/Messages/ | iMessage 聊天记录和附件 |
~/Library/Mail/ | 邮件数据 |
~/Pictures/Photos Library.photoslibrary/ | 照片库 |
.../xwechat_files/*/msg/ | 微信聊天消息 |
.../xwechat_files/*/db_storage/ | 微信数据库 |
间接释放(写进 long_term)
- 系统"可清除空间"磁盘紧张时自动回收
- 重启释放部分 swap / 临时快照
brew cleanup --prune=all- 定期清理 Xcode DerivedData
- 可视化工具:DaisyDisk、GrandPerspective、OmniDiskSweeper
- 大文件归档到外置盘 / iCloud / NAS
Windows 数据布局与分级参考
分析 Windows 扫描结果时读这份。讲"东西存在哪、怎么辨认、归哪一级"。 注意:Windows 代码路径在 macOS 上无法验证,分析时对路径存在性保持谨慎。
Windows 路径体系
| 变量 | 实际路径 | 说明 |
|---|---|---|
%USERPROFILE% | C:\Users\<用户名> | 用户主目录 |
%APPDATA% | C:\Users\<用户名>\AppData\Roaming | 漫游应用数据 |
%LOCALAPPDATA% | C:\Users\<用户名>\AppData\Local | 本地应用数据 |
%TEMP% | C:\Users\<用户名>\AppData\Local\Temp | 用户临时文件 |
%SYSTEMROOT% | C:\Windows | 系统根目录 |
%PROGRAMFILES% | C:\Program Files | 64 位程序 |
%PROGRAMFILES(X86)% | C:\Program Files (x86) | 32 位程序 |
%PROGRAMDATA% | C:\ProgramData | 所有用户共享数据 |
多盘符
Windows 通常多个盘(C:、D:…)。分析和清理聚焦系统盘 C:。其他盘归 🟡 让用户自己判断。
关键目录
| 目录(环境变量) | 装什么 | 典型分级 |
|---|---|---|
%LOCALAPPDATA% | 浏览器缓存、应用数据、Temp | 缓存 🟢 / 应用数据 🟡 |
%LOCALAPPDATA%\Temp、%TEMP% | 临时文件 | 🟢 |
%APPDATA%(Roaming) | 应用配置/数据 | 🟡 |
浏览器缓存 ...\Chrome\User Data\*\Cache、Edge 同构 | 浏览器缓存 | 🟢 |
浏览器 User Data\<Profile>(非 Cache 部分) | 书签/登录态 | 🟡 |
%USERPROFILE%\.cache、.npm、.gradle、.m2、.nuget\packages、%LOCALAPPDATA%\pip\Cache | 开发缓存 | 🟢 |
C:\Program Files、Program Files (x86) | 应用本体 | 🔴 仅重复/想卸时上灯 |
%USERPROFILE%\Downloads 的安装包 | exe/msi 残留 | 🟢 |
C:\$Recycle.Bin | 回收站 | 🟡 提示用户清空 |
注册表安全
- AI Agent 不直接修改注册表(
reg delete、reg add) - 可以扫描并报告注册表问题(如孤儿卸载项),但修复操作交由专业工具
- 注册表
HKEY_LOCAL_MACHINE\SYSTEM\、...\CurrentVersion\绝对不可动
系统占用(不上灯,归蓝色"系统及其他")
| 项目 | 说明 |
|---|---|
C:\Windows\WinSxS | 组件存储,绝不能手删,用 DISM /Online /Cleanup-Image /StartComponentCleanup |
C:\Windows\SoftwareDistribution\Download | Windows Update 缓存,用磁盘清理处理 |
hiberfil.sys(休眠) | 系统管理,别手动删 |
pagefile.sys(虚拟内存) | 系统管理,别手动删 |
C:\Windows.old | 旧安装残留,需确认后可清 |
安全清理目标知识库
🟢 系统级缓存(100% 安全)
| 目标 | 命令/路径 | 典型大小 |
|---|---|---|
| 用户临时文件 | Remove-Item "$env:TEMP\*" -Recurse -Force | 1-5GB |
| 缩略图缓存 | Remove-Item "$env:LOCALAPPDATA\...\thumbcache_*" -Force | 100-500MB |
| 回收站 | Clear-RecycleBin -Force | 不定 |
| 崩溃转储 | Remove-Item "$env:LOCALAPPDATA\CrashDumps\*" -Force | 100MB-2GB |
🟢 通讯软件缓存(100% 安全)
| 目标 | 路径 | 典型大小 |
|---|---|---|
| 微信缓存 | %USERPROFILE%\Documents\WeChat Files\<wxid>\FileStorage\Cache\ | 1-10GB |
| 微信小程序 | %USERPROFILE%\Documents\WeChat Files\<wxid>\Applet\ | 500MB-5GB |
| Discord 缓存 | %APPDATA%\discord\Cache\、Code Cache\ | 200MB-1GB |
| Teams 缓存 | %APPDATA%\Microsoft\Teams\Cache\、blob_storage\ | 500MB-2GB |
🟢 浏览器缓存(100% 安全)
| 目标 | 路径 | 典型大小 |
|---|---|---|
| Chrome 缓存 | %LOCALAPPDATA%\...\Default\{Cache,Code Cache,Service Worker} | 500MB-3GB |
| Edge 缓存 | %LOCALAPPDATA%\...\Default\{Cache,Code Cache,Service Worker} | 500MB-2GB |
| Firefox 缓存 | %LOCALAPPDATA%\Firefox\Profiles\*\cache2\ | 200MB-1GB |
🟢 开发工具缓存(100% 安全)
| 目标 | 命令/路径 | 典型大小 |
|---|---|---|
| npm 缓存 | npm cache clean --force | 500MB-5GB |
| pip 缓存 | pip cache purge | 100MB-1GB |
| NuGet 缓存 | dotnet nuget locals all --clear | 500MB-5GB |
| VS Code CachedData | %APPDATA%\Code\CachedData\ | 100-500MB |
不建议删除的区域
| 路径 | 原因 |
|---|---|
C:\Windows\ | 系统核心 |
C:\Program Files\* | 已安装程序 |
%USERPROFILE%\Documents\ | 用户文档(含微信聊天记录) |
%USERPROFILE%\Downloads\ | 用户下载 |
%APPDATA% 内的子目录 | 应用核心数据,需逐个判断 |
C:\pagefile.sys | 虚拟内存 |
| 注册表 | AI 不直接操作 |
间接释放(写进 long_term)
- 设置 > 系统 > 存储 > 存储感知
cleanmgr(磁盘清理)- Dism++ 清理 WinSxS 组件
- 大文件归档到 D: 盘或 NAS
#!/usr/bin/env python3
"""Inject an analysis JSON into the HTML template -> a standalone report.
Usage:
build_report.py <analysis.json> [output.html]
The analysis JSON is produced by Claude after interpreting scan.py output.
Schema (all sections optional except system):
{
"generated_at": "2026-05-28 12:00:00",
"scan_seconds": 42.1,
"system": {os, build, arch, user, home, filesystem,
disk_total, disk_used, disk_free, purgeable},
"top5": [{rank, tier(green|yellow|red), size, type, name, path, note}],
"green": [{name, path, size_estimate, kill_processes:[], trash_paths:[...], commands:[{label,cmd}]}],
"yellow": [{name, path, size, content_profile, why_manual, disposal, risk, trash_paths:[...]?, open_note?}],
"red": [{name, path, size, why_keep, indirect_release, auto_reclaim, app_paths:[...]?}],
"denied": ["/path/one", ...],
"summary": {overview, tier_stats:{green,yellow,red}, priority:[...], long_term:[...]}
}
"""
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
TEMPLATE = os.path.join(HERE, "..", "assets", "report_template.html")
def main():
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)
src = sys.argv[1]
out = sys.argv[2] if len(sys.argv) > 2 else os.path.expanduser(
"~/Desktop/storage-report.html")
with open(src, "r", encoding="utf-8") as f:
data = json.load(f)
with open(TEMPLATE, "r", encoding="utf-8") as f:
tpl = f.read()
blob = json.dumps(data, ensure_ascii=False)
# Static report: no delete capability (DELETE=null)
html = tpl.replace("__REPORT_DATA__", blob).replace("__DELETE_CONFIG__", "null")
with open(out, "w", encoding="utf-8") as f:
f.write(html)
print(f"报告已生成: {out}")
print(f"打开: open '{out}'")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Read-only storage scanner (macOS + Windows).
Collects disk usage, system info, and per-directory size breakdowns for the
hot spots that typically eat disk, and emits one JSON blob to stdout for Claude
to interpret and classify. Auto-detects the OS and scans the right locations.
STRICTLY READ-ONLY: only sizes/lists/reads metadata. Never creates, moves, or
deletes anything.
Output shape (same on both OSes):
{
"generated_at", "scan_seconds",
"system": {os, build, arch, user, home, filesystem,
disk_total, disk_used, disk_free, purgeable,
disks: [{name, total, used, free}]},
"groups": { "<group>": [{name, path, size_kb, size_h}], ... },
"app_groups": { "<app_name>": [{name, path, size_kb, size_h}], ... },
"large_files": [{name, path, size_kb, size_h}]
}
"""
import json
import os
import shutil
import sys
import time
HOME = os.path.expanduser("~")
def human(kb):
"""KB number -> human string like '12.3 GB'."""
n = float(kb) * 1024
for unit in ("B", "KB", "MB", "GB", "TB"):
if n < 1024 or unit == "TB":
return f"{n:.1f} {unit}" if unit not in ("B", "KB") else f"{int(n)} {unit}"
n /= 1024
# ======================================================================
# macOS
# ======================================================================
import re
import subprocess
def run(cmd, timeout=180):
try:
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout).stdout
except Exception:
return ""
def du_children(path, min_kb=51200, limit=40):
"""Size every immediate child of `path` via du, sorted desc. macOS."""
if not os.path.isdir(path):
return []
results = []
try:
entries = sorted(os.listdir(path))
except PermissionError:
return [{"name": "(permission denied)", "path": path,
"size_kb": 0, "size_h": "?", "denied": True}]
for name in entries:
if name in (".", ".."):
continue
child = os.path.join(path, name)
if os.path.islink(child):
continue
out = run(["du", "-sk", child], timeout=120)
m = re.match(r"\s*(\d+)", out)
if not m:
continue
kb = int(m.group(1))
if kb < min_kb:
continue
results.append({"name": name, "path": child, "size_kb": kb, "size_h": human(kb)})
results.sort(key=lambda r: r["size_kb"], reverse=True)
return results[:limit]
MAC_TARGETS = [
("home", HOME, 102400),
("library", os.path.join(HOME, "Library"), 51200),
("caches", os.path.join(HOME, "Library/Caches"), 51200),
("containers", os.path.join(HOME, "Library/Containers"), 51200),
("group_containers", os.path.join(HOME, "Library/Group Containers"), 51200),
("app_support", os.path.join(HOME, "Library/Application Support"), 51200),
("applications", "/Applications", 102400),
("downloads", os.path.join(HOME, "Downloads"), 51200),
("dev_caches", None, 51200),
]
MAC_DEV_CACHE_PATHS = [
"~/Library/Caches/pip", "~/Library/Caches/uv", "~/.cache", "~/.cargo",
"~/.npm", "~/.pnpm-store", "~/.gradle", "~/.m2",
"~/Library/Developer/Xcode/DerivedData", "~/Library/Developer/CoreSimulator",
"~/Library/Developer/Xcode/iOS DeviceSupport", "~/Library/pnpm",
"~/go/pkg", "~/.docker",
]
# Application-specific scan targets for macOS
MAC_APP_SCAN_TARGETS = {
"wechat": [
"~/Library/Containers/com.tencent.xinWeChat/Data/Documents/app_data/radium",
"~/Library/Containers/com.tencent.xinWeChat/Data/Documents/app_data/log",
"~/Library/Containers/com.tencent.xinWeChat/Data/Library/Caches",
],
"feishu": [
"~/Library/Containers/com.bytedance.macos.feishu/Data/Library/Application Support/LarkShell",
"~/Library/Containers/com.bytedance.macos.feishu/Data/Library/Caches",
"~/Library/Containers/com.bytedance.macos.feishu/Data/tmp",
],
"qq": [
"~/Library/Containers/com.tencent.qq/Data/Library/Application Support/QQ",
"~/Library/Containers/com.tencent.qq/Data/Library/Caches",
],
"dingtalk": [
"~/Library/Containers/com.alibaba.DingTalkMac/Data/Library/Caches",
],
"telegram": [
"~/Library/Group Containers/*.ru.keepcoder.Telegram",
],
"teams": [
"~/Library/Application Support/Microsoft/Teams",
],
"discord": [
"~/Library/Application Support/discord",
],
"chrome": [
"~/Library/Application Support/Google/Chrome",
],
"safari": [
"~/Library/Caches/com.apple.Safari",
"~/Library/Safari/LocalStorage",
],
"firefox": [
"~/Library/Caches/Firefox",
"~/Library/Application Support/Firefox",
],
"arc": [
"~/Library/Caches/Arc",
"~/Library/Caches/company.thebrowser.Browser",
],
"vscode": [
"~/Library/Application Support/Code",
"~/.vscode/extensions",
],
"cursor": [
"~/Library/Application Support/Cursor",
],
"xcode": [
"~/Library/Developer/Xcode",
],
"steam": [
"~/Library/Application Support/Steam",
],
"spotify": [
"~/Library/Application Support/Spotify/Storage",
],
"figma": [
"~/Library/Application Support/Figma",
],
"docker": [
"~/.docker",
],
}
def dev_caches_macos():
results = []
for p in MAC_DEV_CACHE_PATHS:
path = os.path.expanduser(p)
if not os.path.isdir(path):
continue
out = run(["du", "-sk", path], timeout=180)
m = re.match(r"\s*(\d+)", out)
if not m:
continue
kb = int(m.group(1))
if kb < 51200:
continue
results.append({"name": os.path.basename(path.rstrip("/")) or path,
"path": path, "size_kb": kb, "size_h": human(kb)})
results.sort(key=lambda r: r["size_kb"], reverse=True)
return results
def app_scan_macos():
"""Scan application-specific paths for macOS."""
groups = {}
for app_name, paths in MAC_APP_SCAN_TARGETS.items():
items = []
for p in paths:
# Handle glob patterns (e.g., ~/Library/Group Containers/*.xxx)
if "*" in p:
import glob
expanded = glob.glob(os.path.expanduser(p))
for ep in expanded:
if not os.path.isdir(ep):
continue
out = run(["du", "-sk", ep], timeout=120)
m = re.match(r"\s*(\d+)", out)
if not m:
continue
kb = int(m.group(1))
if kb < 51200:
continue
items.append({"name": os.path.basename(ep.rstrip("/")),
"path": ep, "size_kb": kb, "size_h": human(kb)})
else:
path = os.path.expanduser(p)
if not os.path.isdir(path):
continue
out = run(["du", "-sk", path], timeout=120)
m = re.match(r"\s*(\d+)", out)
if not m:
continue
kb = int(m.group(1))
if kb < 51200:
continue
items.append({"name": os.path.basename(path.rstrip("/")) or path,
"path": path, "size_kb": kb, "size_h": human(kb)})
items.sort(key=lambda r: r["size_kb"], reverse=True)
if items:
groups[app_name] = items
return groups
def large_files_macos(min_mb=500, limit=20):
"""Find large files (>500MB) under home."""
results = []
try:
out = run(["find", HOME, "-maxdepth", "4", "-type", "f", "-size",
f"+{min_mb}M", "-not", "-path", "*/.git/*",
"-not", "-path", "*/node_modules/*",
"-not", "-path", "*/Library/Containers/*/Data/Downloads/*"],
timeout=300)
for line in out.strip().split("\n"):
if not line:
continue
try:
size_out = run(["du", "-sk", line], timeout=30)
m = re.match(r"\s*(\d+)", size_out)
if not m:
continue
kb = int(m.group(1))
results.append({"name": os.path.basename(line),
"path": line, "size_kb": kb, "size_h": human(kb)})
except Exception:
continue
except Exception:
pass
results.sort(key=lambda r: r["size_kb"], reverse=True)
return results[:limit]
def system_info_macos():
info = {}
info["os"] = "macOS " + run(["sw_vers", "-productVersion"]).strip()
info["build"] = run(["sw_vers", "-buildVersion"]).strip()
arch = run(["uname", "-m"]).strip()
brand = run(["sysctl", "-n", "machdep.cpu.brand_string"]).strip()
info["arch"] = (f"Apple Silicon (arm64){' / ' + brand if brand else ''}"
if arch == "arm64" else f"{arch}{' / ' + brand if brand else ''}")
info["user"] = os.environ.get("USER", "") or run(["whoami"]).strip()
info["home"] = HOME
total, used, free = "?", "?", "?"
try:
t, u, f = shutil.disk_usage("/")
total, used, free = human(t // 1024), human(u // 1024), human(f // 1024)
except Exception:
pass
info["disk_total"], info["disk_used"], info["disk_free"] = total, used, free
dinfo = run(["diskutil", "info", "/"])
fs = re.search(r"File System Personality:\s*(.+)", dinfo)
info["filesystem"] = fs.group(1).strip() if fs else "APFS"
pm = re.search(r"Purgeable Space:\s*([\d.,]+ \w+)", dinfo)
info["purgeable"] = pm.group(1).strip() if pm else ""
info["disk_name"] = "Macintosh HD"
info["disks"] = [{"name": "Macintosh HD", "total": total, "used": used, "free": free}]
return info
def scan_macos():
system = system_info_macos()
groups = {}
for key, path, floor in MAC_TARGETS:
groups[key] = dev_caches_macos() if key == "dev_caches" else du_children(path, min_kb=floor)
app_groups = app_scan_macos()
large = large_files_macos()
return system, groups, app_groups, large
# ======================================================================
# Windows
# ======================================================================
def dir_size_bytes(path):
"""Recursive size in bytes via os.scandir. Skips symlinks and unreadable."""
total = 0
try:
with os.scandir(path) as it:
for e in it:
try:
if e.is_symlink():
continue
if e.is_file(follow_symlinks=False):
total += e.stat(follow_symlinks=False).st_size
elif e.is_dir(follow_symlinks=False):
total += dir_size_bytes(e.path)
except (PermissionError, OSError):
continue
except (PermissionError, OSError):
pass
return total
def scandir_children(path, min_kb=51200, limit=40):
"""Size every immediate child of `path` via os.scandir. Windows."""
if not path or not os.path.isdir(path):
return []
results = []
try:
entries = sorted(os.listdir(path))
except PermissionError:
return [{"name": "(permission denied)", "path": path,
"size_kb": 0, "size_h": "?", "denied": True}]
for name in entries:
child = os.path.join(path, name)
if os.path.islink(child):
continue
try:
kb = (os.path.getsize(child) if os.path.isfile(child)
else dir_size_bytes(child)) // 1024
except (PermissionError, OSError):
continue
if kb < min_kb:
continue
results.append({"name": name, "path": child, "size_kb": kb, "size_h": human(kb)})
results.sort(key=lambda r: r["size_kb"], reverse=True)
return results[:limit]
def list_drives_windows():
drives = []
import string
for letter in string.ascii_uppercase:
root = f"{letter}:\\"
if os.path.exists(root):
try:
t, u, f = shutil.disk_usage(root)
drives.append({"name": root, "total": human(t // 1024),
"used": human(u // 1024), "free": human(f // 1024)})
except Exception:
continue
return drives
def system_info_windows():
import platform
info = {}
info["os"] = platform.system() + " " + platform.release()
info["build"] = platform.version()
info["arch"] = os.environ.get("PROCESSOR_ARCHITECTURE", platform.machine())
info["user"] = os.environ.get("USERNAME", "")
info["home"] = os.environ.get("USERPROFILE", HOME)
sysdrive = os.environ.get("SystemDrive", "C:") + "\\"
total, used, free = "?", "?", "?"
try:
t, u, f = shutil.disk_usage(sysdrive)
total, used, free = human(t // 1024), human(u // 1024), human(f // 1024)
except Exception:
pass
info["disk_total"], info["disk_used"], info["disk_free"] = total, used, free
info["filesystem"] = "NTFS"
info["purgeable"] = ""
info["disk_name"] = sysdrive
info["disks"] = list_drives_windows()
return info
WIN_APP_SCAN_TARGETS = {
"wechat": [
os.path.join(os.environ.get("USERPROFILE", HOME),
"Documents", "WeChat Files"),
],
"dingtalk": [
os.path.join(os.environ.get("APPDATA", ""), "DingTalk"),
],
"feishu": [
os.path.join(os.environ.get("APPDATA", ""), "bytedance"),
],
"discord": [
os.path.join(os.environ.get("APPDATA", ""), "discord"),
],
"teams": [
os.path.join(os.environ.get("APPDATA", ""), "Microsoft", "Teams"),
],
"chrome": [
os.path.join(os.environ.get("LOCALAPPDATA", ""),
"Google", "Chrome", "User Data"),
],
"edge": [
os.path.join(os.environ.get("LOCALAPPDATA", ""),
"Microsoft", "Edge", "User Data"),
],
"vscode": [
os.path.join(os.environ.get("APPDATA", ""), "Code"),
],
"steam": [
os.path.join(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)"),
"Steam"),
],
}
def app_scan_windows():
"""Scan application-specific paths for Windows."""
groups = {}
for app_name, paths in WIN_APP_SCAN_TARGETS.items():
items = []
for p in paths:
if not os.path.isdir(p):
continue
try:
kb = dir_size_bytes(p) // 1024
except (PermissionError, OSError):
continue
if kb < 51200:
continue
items.append({"name": os.path.basename(p.rstrip("\\")),
"path": p, "size_kb": kb, "size_h": human(kb)})
items.sort(key=lambda r: r["size_kb"], reverse=True)
if items:
groups[app_name] = items
return groups
def scan_windows():
profile = os.environ.get("USERPROFILE", HOME)
local = os.environ.get("LOCALAPPDATA", os.path.join(profile, "AppData", "Local"))
roaming = os.environ.get("APPDATA", os.path.join(profile, "AppData", "Roaming"))
targets = [
("user_profile", profile, 102400),
("appdata_local", local, 51200),
("appdata_roaming", roaming, 51200),
("temp", os.environ.get("TEMP", os.path.join(local, "Temp")), 51200),
("downloads", os.path.join(profile, "Downloads"), 51200),
("program_files", os.environ.get("ProgramFiles", r"C:\Program Files"), 102400),
("program_files_x86", os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"), 102400),
]
groups = {}
for key, path, floor in targets:
groups[key] = scandir_children(path, min_kb=floor)
dev_paths = [
os.path.join(profile, ".cache"), os.path.join(profile, ".npm"),
os.path.join(profile, ".gradle"), os.path.join(profile, ".m2"),
os.path.join(profile, ".nuget", "packages"), os.path.join(profile, ".cargo"),
os.path.join(local, "pip", "Cache"), os.path.join(local, "Yarn"),
os.path.join(local, "uv"), os.path.join(local, "ms-playwright"),
]
dev = []
for path in dev_paths:
if not os.path.isdir(path):
continue
try:
kb = dir_size_bytes(path) // 1024
except (PermissionError, OSError):
continue
if kb < 51200:
continue
dev.append({"name": os.path.basename(path.rstrip("\\/")) or path,
"path": path, "size_kb": kb, "size_h": human(kb)})
dev.sort(key=lambda r: r["size_kb"], reverse=True)
groups["dev_caches"] = dev
app_groups = app_scan_windows()
large = [] # Large file scan skipped on Windows (slow without find)
return system_info_windows(), groups, app_groups, large
# ======================================================================
def main():
started = time.time()
if sys.platform == "darwin":
system, groups, app_groups, large = scan_macos()
elif sys.platform.startswith("win"):
system, groups, app_groups, large = scan_windows()
else:
print(json.dumps({"error": "unsupported_platform", "platform": sys.platform,
"message": "scan.py supports macOS and Windows only."},
ensure_ascii=False))
return
data = {
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"system": system,
"groups": groups,
"app_groups": app_groups,
"large_files": large,
"scan_seconds": round(time.time() - started, 1),
}
print(json.dumps(data, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Serve the storage report with a guarded one-click delete API (macOS + Windows).
Starts on 127.0.0.1 + a random port + a random per-session token, serves the
interactive report, and exposes POST /action to move green-tier paths to Trash
or delete them outright. Stop with Ctrl+C.
Usage:
server.py <analysis.json>
SAFETY MODEL — read before changing:
- Allowlist: only paths listed in this report's green/yellow items' trash_paths
are accepted. Every request path is realpath-resolved and must be in the
allowlist AND under $HOME. Anything else is rejected.
- Bound to 127.0.0.1 only; every POST requires the session token; Host header
must be 127.0.0.1 (blocks DNS-rebinding from a malicious page).
- Two modes: "trash" (Finder -> Trash, reversible) and "rm" (immediate,
irreversible). The browser confirms each action before sending.
- Path validation (from Mole): reject empty, relative, traversal (..), control
chars, symlinks to system paths, and system-critical paths.
- Audit log: every action is recorded to ~/Library/Logs/disk-cleaner/operations.log
- Fail-closed: if Trash move fails, refuse permanent delete.
"""
import json
import os
import secrets
import shutil
import subprocess
import sys
import time
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
HERE = os.path.dirname(os.path.abspath(__file__))
TEMPLATE = os.path.join(HERE, "..", "assets", "report_template.html")
HOME = os.path.realpath(os.path.expanduser("~"))
TOKEN = secrets.token_urlsafe(24)
DATA = {}
TPL = ""
RM_ALLOW = set()
TRASH_ALLOW = set()
OPEN_ALLOW = set()
# Audit log path
AUDIT_LOG_DIR = os.path.join(HOME, "Library", "Logs", "disk-cleaner")
AUDIT_LOG = os.path.join(AUDIT_LOG_DIR, "operations.log")
# ======================================================================
# Path Validation (inspired by Mole's validate_path_for_deletion)
# ======================================================================
# System-critical paths that must never be deleted (macOS + Windows)
CRITICAL_PATHS = {
"/", "/System", "/bin", "/sbin", "/usr", "/etc", "/var", "/private",
"/Library/Apple", "/Library/Extensions", "/Library/Keychains",
"/Applications/Finder.app", "/Applications/Safari.app",
"/Users", "/Users/Shared", "/Users/Guest",
# Windows (harmless on macOS, needed for cross-platform)
"C:\\Windows", "C:\\Program Files", "C:\\Program Files (x86)",
}
CRITICAL_PREFIXES = (
"/System/", "/bin/", "/sbin/", "/usr/", "/etc/", "/private/var/",
"/Library/Apple/", "/Library/Extensions/", "/Library/Keychains/",
"C:\\Windows\\", "C:\\Program Files\\", "C:\\Program Files (x86)\\",
)
# Paths under protected roots that ARE allowed for cleanup
ALLOWLIST_EXCEPTIONS = (
"/private/tmp", "/private/var/tmp", "/private/var/log",
"/private/var/folders",
)
def validate_path(path):
"""Validate a path for deletion. Returns (ok, reason)."""
if not path:
return False, "empty path"
# Must be absolute
if not os.path.isabs(path):
return False, "relative path"
# No path traversal
parts = os.path.normpath(path).split(os.sep)
if ".." in parts:
return False, "path traversal"
# No control characters
if any(ord(c) < 32 for c in path):
return False, "control characters"
# Normalize
norm = os.path.normpath(path).rstrip(os.sep)
if not norm:
norm = os.sep
# Symlink check: resolve target and validate it too
if os.path.islink(path):
try:
real = os.path.realpath(path)
if _is_critical_path(real):
return False, f"symlink points to critical path: {real}"
except Exception:
return False, "cannot read symlink target"
# Check critical paths
if _is_critical_path(norm):
# Check exceptions
for exc in ALLOWLIST_EXCEPTIONS:
if norm == exc or norm.startswith(exc + os.sep):
return True, "ok"
return False, f"critical system path: {norm}"
return True, "ok"
def _is_critical_path(norm):
"""Check if normalized path is system-critical."""
if norm in CRITICAL_PATHS:
return True
for prefix in CRITICAL_PREFIXES:
if norm.startswith(prefix):
return True
return False
# ======================================================================
# Audit Logging
# ======================================================================
def audit_log(mode, status, path, detail=""):
"""Append an audit log entry."""
try:
os.makedirs(AUDIT_LOG_DIR, exist_ok=True)
ts = time.strftime("%Y-%m-%dT%H:%M:%S")
line = f"{ts}\t{mode}\t{status}\t{path}\t{detail}\n"
with open(AUDIT_LOG, "a", encoding="utf-8") as f:
f.write(line)
except Exception:
pass # Audit logging is best-effort
# ======================================================================
# Allowlist Loading
# ======================================================================
def expand(p):
return os.path.realpath(os.path.expanduser(p))
def load(src):
with open(src, encoding="utf-8") as f:
data = json.load(f)
with open(TEMPLATE, encoding="utf-8") as f:
tpl = f.read()
# Three allowlists, from strict to lenient:
# rm = only green trash_paths (pure caches safe to hard-delete)
# trash = green + yellow trash_paths (yellow only trash, never rm)
# open = trash set + yellow path + red app_paths (non-destructive)
rm_allow, trash_allow, open_allow = set(), set(), set()
for it in data.get("green", []):
for p in (it.get("trash_paths") or []):
rp = expand(p)
rm_allow.add(rp); trash_allow.add(rp); open_allow.add(rp)
for it in data.get("yellow", []):
for p in (it.get("trash_paths") or []):
rp = expand(p)
trash_allow.add(rp); open_allow.add(rp)
if it.get("path"):
rp = expand(it["path"])
if os.path.exists(rp):
open_allow.add(rp)
# Red: only allow "open" (app location for user to uninstall)
for it in data.get("red", []):
for p in (it.get("app_paths") or []):
rp = expand(p)
if os.path.exists(rp):
open_allow.add(rp)
return data, tpl, rm_allow, trash_allow, open_allow
# ======================================================================
# Trash / Delete Operations
# ======================================================================
def move_to_trash(path):
if sys.platform == "darwin":
_trash_macos(path)
elif sys.platform.startswith("win"):
_trash_windows(path)
else:
raise OSError("Trash only supported on macOS / Windows")
def _trash_macos(path):
# osascript Finder delete -> macOS Trash, recoverable.
script = 'tell application "Finder" to delete (POSIX file %s as alias)' % json.dumps(path)
r = subprocess.run(["osascript", "-e", script], capture_output=True, text=True)
if r.returncode != 0:
# Fallback: move to ~/.Trash
dest = os.path.join(HOME, ".Trash",
os.path.basename(path.rstrip("/")) + "." + time.strftime("%H%M%S"))
shutil.move(path, dest)
def _trash_windows(path):
import ctypes
from ctypes import wintypes
class SHFILEOPSTRUCTW(ctypes.Structure):
_fields_ = [
("hwnd", wintypes.HWND),
("wFunc", wintypes.UINT),
("pFrom", wintypes.LPCWSTR),
("pTo", wintypes.LPCWSTR),
("fFlags", ctypes.c_uint16),
("fAnyOperationsAborted", wintypes.BOOL),
("hNameMappings", ctypes.c_void_p),
("lpszProgressTitle", wintypes.LPCWSTR),
]
FO_DELETE = 3
FOF_ALLOWUNDO = 0x0040
FOF_NOCONFIRMATION = 0x0010
FOF_SILENT = 0x0004
op = SHFILEOPSTRUCTW()
op.wFunc = FO_DELETE
op.pFrom = os.path.abspath(path) + "\x00\x00"
op.fFlags = FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_SILENT
rc = ctypes.windll.shell32.SHFileOperationW(ctypes.byref(op))
if rc != 0:
raise OSError("SHFileOperation failed (code %d)" % rc)
def hard_delete(path):
if os.path.isdir(path) and not os.path.islink(path):
shutil.rmtree(path)
else:
os.remove(path)
def open_in_file_manager(path):
target = path if os.path.isdir(path) else os.path.dirname(path)
if sys.platform == "darwin":
if target.rstrip("/").endswith(".app"):
r = subprocess.run(["open", "-R", target], capture_output=True, text=True)
if r.returncode != 0:
raise OSError((r.stderr or "open -R failed").strip())
return
r = subprocess.run(["open", target], capture_output=True, text=True)
if r.returncode != 0:
r2 = subprocess.run(["open", "-R", target], capture_output=True, text=True)
if r2.returncode != 0:
raise OSError((r.stderr or r2.stderr or "open failed").strip())
elif sys.platform.startswith("win"):
subprocess.run(["explorer", target])
else:
raise OSError("Open only supported on macOS / Windows")
# ======================================================================
# HTTP Handler
# ======================================================================
class Handler(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def _send(self, code, body, ctype="application/json"):
b = body.encode("utf-8") if isinstance(body, str) else body
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(b)))
self.end_headers()
self.wfile.write(b)
def do_GET(self):
if self.path in ("/", "/index.html"):
blob = json.dumps(DATA, ensure_ascii=False)
cfg = json.dumps({"token": TOKEN, "endpoint": "/action"})
html = TPL.replace("__REPORT_DATA__", blob).replace("__DELETE_CONFIG__", cfg)
self._send(200, html, "text/html; charset=utf-8")
else:
self._send(404, "not found", "text/plain")
def do_POST(self):
if self.path != "/action":
self._send(404, json.dumps({"ok": False, "error": "not found"}))
return
# DNS-rebinding guard
host = (self.headers.get("Host") or "").split(":")[0]
if host not in ("127.0.0.1", "localhost"):
self._send(403, json.dumps({"ok": False, "error": "host not allowed"}))
return
n = int(self.headers.get("Content-Length", 0))
try:
req = json.loads(self.rfile.read(n) or b"{}")
except Exception:
self._send(400, json.dumps({"ok": False, "error": "invalid request"}))
return
if req.get("token") != TOKEN:
self._send(403, json.dumps({"ok": False, "error": "token mismatch"}))
return
mode = req.get("mode")
allow = {"rm": RM_ALLOW, "trash": TRASH_ALLOW, "open": OPEN_ALLOW}.get(mode)
if allow is None:
self._send(400, json.dumps({"ok": False, "error": "unknown mode"}))
return
done = []
for p in (req.get("paths") or []):
rp = expand(p)
# Allowlist check
if rp not in allow:
audit_log(mode, "rejected", rp, "not in allowlist")
self._send(403, json.dumps({"ok": False, "error": "path not allowed: %s" % p}))
return
# HOME boundary check (or /Applications for open mode)
valid_roots = (HOME, "/Applications")
if not any(rp == base or rp.startswith(base + os.sep) for base in valid_roots):
audit_log(mode, "rejected", rp, "outside home")
self._send(403, json.dumps({"ok": False, "error": "path outside home: %s" % p}))
return
# Path validation (Mole-style)
ok, reason = validate_path(p)
if not ok:
audit_log(mode, "rejected", rp, reason)
self._send(403, json.dumps({"ok": False, "error": "path unsafe: %s (%s)" % (p, reason)}))
return
try:
if mode == "open":
open_in_file_manager(rp)
audit_log("open", "ok", rp)
elif not os.path.exists(rp):
pass # already gone
elif mode == "trash":
move_to_trash(rp)
audit_log("trash", "ok", rp)
else:
hard_delete(rp)
audit_log("rm", "ok", rp)
done.append(p)
except Exception as e:
audit_log(mode, "error", rp, str(e))
self._send(500, json.dumps({"ok": False, "error": str(e)}))
return
self._send(200, json.dumps({"ok": True, "done": done}))
def main():
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)
global DATA, TPL, RM_ALLOW, TRASH_ALLOW, OPEN_ALLOW
DATA, TPL, RM_ALLOW, TRASH_ALLOW, OPEN_ALLOW = load(sys.argv[1])
srv = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
port = srv.server_address[1]
url = "http://127.0.0.1:%d/" % port
print("disk-cleaner 报告服务已启动:" + url)
print("绿灯可删 %d 项 | 橙灯可移废纸篓/打开 %d 项 | 安全模型: Mole + khazix" %
(len(RM_ALLOW), len(TRASH_ALLOW) - len(RM_ALLOW)))
print("用完按 Ctrl+C 停止服务")
webbrowser.open(url)
try:
srv.serve_forever()
except KeyboardInterrupt:
print("\n已停止服务。")
if __name__ == "__main__":
main()