Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e60245c
Document generic residual cleanup design
BruceL017 Jul 14, 2026
cbcd25d
Plan generic residual cleanup implementation
BruceL017 Jul 14, 2026
527e463
Add residual identity capture
BruceL017 Jul 14, 2026
a0ec474
Add bounded residual discovery
BruceL017 Jul 14, 2026
62c563d
Require exact package residual matches
BruceL017 Jul 14, 2026
8afb05b
Prevent package identity fallback matches
BruceL017 Jul 14, 2026
17d87fc
Clarify short-name residual classification
BruceL017 Jul 14, 2026
b003221
Harden residual candidate classification
BruceL017 Jul 14, 2026
681b76d
Preserve residual scan safety metadata
BruceL017 Jul 14, 2026
69620b2
Add safe residual deletion
BruceL017 Jul 14, 2026
08dd190
Report residual verification truthfully
BruceL017 Jul 14, 2026
a7aee00
Fix residual report accuracy
BruceL017 Jul 14, 2026
8bce4a0
Wire three-stage residual cleanup
BruceL017 Jul 14, 2026
1b68a06
Document generic residual cleanup
BruceL017 Jul 14, 2026
a2b3ce2
Protect approved residual root aliases
BruceL017 Jul 14, 2026
1a32123
Require owned residual executable evidence
BruceL017 Jul 14, 2026
a634e1c
Harden residual CLI integration tests
BruceL017 Jul 14, 2026
d80d29a
Document final residual safety constraints
BruceL017 Jul 14, 2026
4e2b95f
Reject residual root container candidates
BruceL017 Jul 14, 2026
01d6e4b
Record residual confirmation chronology
BruceL017 Jul 14, 2026
fdbe474
Align residual collapse plan fixture
BruceL017 Jul 14, 2026
6ffbfa4
Make reporter assertions wrap-safe
BruceL017 Jul 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# CleanApp CLI

CleanApp 是一款面向 macOS 的安全、透明、规则驱动卸载工具。它扫描桌面应用与常见开发工具,在删除前展示将要停止的进程、调用的包管理器和清理的路径,并且只有在用户明确确认后才会执行。
CleanApp 是一款面向 macOS 的安全、透明、规则驱动卸载工具。它扫描桌面应用与常见开发工具,在删除前捕获应用、包和可执行文件身份,展示将要停止的进程、调用的包管理器和清理的路径,并且只有在用户明确确认后才会执行。

## 一条命令运行(推荐)

Expand Down Expand Up @@ -31,7 +31,7 @@ uvx --from git+https://github.com/clawdbot502/clean-cli cleanapp remove cursor -
| 包管理器 | Homebrew、npm、pnpm、pipx、uv、Cargo、Go 按本机实际安装情况自动检测 |
| 权限 | 普通用户可清理其有权访问的文件;部分系统级 Helper/Daemon 可能受 macOS 权限、ACL 或隐私控制限制 |

仓库包含运行源码、依赖声明、命令入口和全部内置 YAML 规则,不依赖作者电脑中的私有文件、密码或 API Key。这里的“完整流程”是指:扫描 → 选择 → 分析 → 预览 → 确认 → 停止相关进程 → 调用检测到的包管理器 → 清理规则覆盖的路径 → 检查残留 → 输出报告。它不承诺识别任意第三方应用未来新增的未知目录,也不会绕过 macOS 权限保护
仓库包含运行源码、依赖声明、命令入口和全部内置 YAML 规则,不依赖作者电脑中的私有文件、密码或 API Key。Applications、Homebrew formula/cask、npm、pnpm、pipx、uv tool、Cargo、Go 和 YAML 规则来源都进入同一套卸载与残留验证流程。最终的“已清理”结论只覆盖受支持且成功扫描的位置;无法访问的位置、人工复核项、原计划残留路径、进程和失败会分别报告

## 从 GitHub 安装

Expand Down Expand Up @@ -64,7 +64,7 @@ python3 -m venv .venv
.venv/bin/cleanapp remove cursor
```

也可以直接运行 `.venv/bin/cleanapp`,通过数字编号选择软件。真实删除前会展示预览并询问 `Confirm deletion? (Y/N)`;只有输入 `Y` 或 `y` 才会执行。建议任何软件第一次清理时都先使用 `--dry-run`。
也可以直接运行 `.venv/bin/cleanapp`,通过数字编号选择软件。真实删除前会展示主要卸载计划并询问 `Confirm deletion? (Y/N)`;只有输入 `Y` 或 `y` 才会执行。主要卸载尝试完成后,残留扫描 #2 会分别列出“可确认删除”“仅供复核”和“无法验证”项;仅当第二次 `y/N` 输入为 `y` 或 `Y` 时才删除“可确认删除”项。无论第二次是否确认、是否发现候选项或局部删除是否失败,都会执行新的扫描 #3 并报告结果。建议任何软件第一次清理时都先使用 `--dry-run`。

## 支持的命令

Expand All @@ -77,12 +77,15 @@ python3 -m venv .venv

## 安全与权限边界

- `--dry-run` 不停止进程、不调用包管理器、不删除文件。
- `--dry-run` 不停止进程、不调用包管理器、不删除文件;它只执行一次当前状态扫描并展示目前可检测到的额外候选项。由于没有做任何修改,dry-run 不会执行移除后的验证扫描
- 系统关键目录、过宽目录、危险通配符和指向系统目录的符号链接会被拒绝。
- 预览会标记当前进程可能无权删除的路径。
- 单项失败不会中断后续清理;权限错误与残留会出现在最终报告和日志中。
- 不建议为了绕过提示而直接以 root 身份运行未经审查的清理。应先检查 dry-run 和具体残留,再为终端授予必要且最小的 macOS 权限。
- 对没有内置规则的软件,CleanApp 可以移除扫描到的 `.app`、包管理器包或工具二进制文件,但不会猜测未知的配置和缓存路径。

真实卸载采用三阶段流程:先执行已预览的应用或包管理器卸载,再在受支持的用户、XDG 和 `/Library` 位置中扫描与目标身份精确关联的残留;如发现可确认归属的残留,会列出清单并再次询问 `y/N`,最后重新扫描并报告结果。

CleanApp 只枚举受支持目录的直接子项,不递归搜索整个用户目录,也不会进入 Chrome、Safari、Firefox、Edge、VS Code 等其他应用的数据目录。弱匹配仅列为人工复核项,不会因一次 `Y` 被删除;工具不会自动调用 `sudo`。

日志默认保存在 `~/Library/Logs/CleanApp/cleanapp.log`。完整设计、规则格式、构建方式和已知限制参见 [README.zh-CN.md](README.zh-CN.md)。

Expand Down
66 changes: 34 additions & 32 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# CleanApp CLI

CleanApp 是一款运行于 macOS 终端的交互式软件卸载工具。它可以扫描桌面应用和常见开发工具,识别软件的安装来源与运行状态,在删除前展示完整预览,并通过规则文件清理应用本体、配置、缓存、日志和后台组件
CleanApp 是一款运行于 macOS 终端的交互式软件卸载工具。它可以扫描桌面应用和常见开发工具,识别软件的安装来源与运行状态,在删除前捕获应用、包和可执行文件身份并展示完整预览,再清理应用本体和与目标身份精确关联的残留

CleanApp 的重点不是“尽可能快地删除”,而是让卸载过程安全、透明、可确认、可扩展。

Expand Down Expand Up @@ -31,12 +31,14 @@ uvx --from git+https://github.com/clawdbot502/clean-cli cleanapp remove cursor -
- 使用数字编号进行交互选择。
- 删除前显示软件名称、安装来源、运行状态、文件数、目录数和预计释放空间。
- 默认展示前 10 个待删除路径,可输入 `all` 查看完整列表。
- 只有明确输入 `Y` 或 `y` 才会执行删除
- 支持 `--dry-run`,完成扫描、分析和报告,但不修改系统。
- 主要卸载只有在明确输入 `Y` 或 `y` 后才会执行
- 支持 `--dry-run`,完成分析和一次当前状态残留扫描,但不修改系统,也不声称完成了移除后的验证
- 按安装来源调用 Homebrew、npm、pnpm、pipx、uv 或 Cargo 卸载命令。
- 清理应用、配置、缓存、日志、LaunchAgent、LaunchDaemon 和 Helper Tool。
- 单项删除失败不会中断后续步骤,失败原因会出现在报告和日志中。
- 清理完成后重新检查残留路径和相关进程。
- 主要卸载后执行受限的残留扫描,将结果分为“可确认删除”“仅供复核”和“无法验证”。
- 只有在第二次输入 `y/Y` 后才删除“可确认删除”项;人工复核项不会随之删除。
- 无论第二次是否确认,都重新扫描并分别报告残留路径、无法访问的位置、进程和失败。
- 通过 YAML 规则扩展软件支持。

## 环境要求
Expand Down Expand Up @@ -124,10 +126,10 @@ python3 -m venv .venv
1. 扫描所有可用来源。
2. 展示带数字编号的软件列表。
3. 要求输入软件编号。
4. 分析应用、配置、缓存、日志和相关进程。
5. 展示删除预览
6. 要求二次确认
7. 执行清理并输出报告
4. 在删除前捕获目标身份,并分析应用、配置、缓存、日志和相关进程。
5. 展示主要卸载预览并要求确认
6. 执行主要卸载,再展示残留扫描 #2 的分类结果和可选的第二次确认
7. 执行新的验证扫描 #3 并输出报告

### 安全预演

Expand All @@ -144,6 +146,8 @@ dry-run 会执行真实的扫描和分析,但不会:
- 删除文件或目录;
- 修改系统状态。

它还会执行一次当前状态残留扫描,排除已在主要卸载计划中的重复路径,并展示目前可检测到的额外候选项。因为 dry-run 没有执行主要卸载或任何删除,所以不会执行移除后的验证扫描 #3,也不能据此宣称卸载后已无残留。

### 实际卸载

检查 dry-run 结果无误后,可以执行:
Expand All @@ -158,7 +162,9 @@ dry-run 会执行真实的扫描和分析,但不会:
Confirm deletion? (Y/N)
```

只有输入 `Y` 或 `y` 才会继续。输入其他内容或直接按回车都会取消操作。
只有输入 `Y` 或 `y` 才会继续主要卸载。输入其他内容或直接按回车都会取消操作,不会执行残留扫描。

主要卸载尝试完成后,程序会展示残留扫描 #2。只有存在“可确认删除”项时,才会询问 `Delete ... confirmed residual paths? (y/N)`;第二次输入 `y` 或 `Y` 只删除该组,其他输入会保留全部残留。无论第二次是否确认、是否存在可确认候选项或局部删除是否失败,程序都会执行新的扫描 #3 并输出验证结果。

## 命令说明

Expand Down Expand Up @@ -224,34 +230,32 @@ Confirm deletion? (Y/N)

实际卸载会按照以下顺序进行:

1. 停止规则匹配到的相关进程。
2. 根据安装来源调用包管理器卸载命令。
3. 删除 macOS 应用本体。
4. 删除软件配置。
5. 删除缓存。
6. 删除日志。
7. 删除匹配到的 LaunchAgent 或 LaunchDaemon。
8. 删除匹配到的 Helper Tool。
9. 再次检查残留路径与运行进程。
10. 输出清理报告并写入日志。
1. 在删除前捕获应用、包和可执行文件身份。
2. 展示并确认主要卸载计划。
3. 停止相关进程、调用已检测到的包管理器并删除主要路径。
4. 扫描受支持位置中的残留并分为“可确认删除”“仅供复核”“无法验证”。
5. 仅在第二次输入 `y/Y` 后删除“可确认删除”项。
6. 无论第二次是否确认,都执行一次新的验证扫描。
7. 分别报告确认残留、人工复核项、无法访问的位置、原计划残留路径、进程和失败。

每一步都单独进行错误处理。某个文件没有权限或某个卸载命令失败时,程序会记录原因并继续处理后续项目。

### “完整清理”的准确边界

仓库包含扫描器、分析器、删除器、命令入口、依赖声明和全部内置规则。满足环境要求的用户克隆后,可以执行从扫描到残留报告的完整产品流程。不过,“完整”是指当前规则和安装来源覆盖范围内的完整流程,并不等于对所有软件做无法验证的 100% 残留保证:
仓库包含扫描器、分析器、删除器、命令入口、依赖声明和全部内置规则。Applications、Homebrew formula/cask、npm、pnpm、pipx、uv tool、Cargo、Go 和 YAML 规则来源都会进入同一套流程。不过,“完整”只指受支持且成功扫描的位置,并不等于对整块磁盘或任意第三方软件做无法验证的 100% 残留保证:

- 11 个内置规则会清理规则明确列出的应用、配置、缓存、日志、启动项和 Helper。
- 没有内置规则的软件仍可移除扫描到的 `.app`、包管理器包或工具二进制文件,但 CleanApp 不会猜测其未知数据目录。
- 第三方软件升级后可能改变路径,需要更新相应 YAML 规则。
- macOS ACL、隐私控制或系统级目录权限可能阻止部分删除;预览会给出权限提示,最终报告会列出失败与残留。
- 为避免把用户主目录解析成错误位置,不建议未经审查就以 root 身份运行整个工具。应先 dry-run,再按残留项授予终端最小必要权限。
- 内置规则继续提供已知应用路径、包标识符和进程信息;通用残留扫描会补充规则覆盖,而不是只清理 YAML 明确列出的路径。
- 扫描器只枚举受支持的用户 Library、XDG、`~/.local/bin`、用户主目录的直接隐藏子项,以及选定 `/Library` 根目录的直接子项。它不会递归搜索整个用户目录、Documents、项目目录或其他任意位置。
- Chrome、Safari、Firefox、Edge、VS Code 等其他应用的数据目录是隔离边界。例如扫描 `~/Library/Application Support` 时只检查其直接子项,不会进入不匹配的 `Google/Chrome/...`,因此不会把网站数据当作目标应用残留。
- 精确身份关联的普通候选项列为“可确认删除”;短名称、弱匹配和平台管理位置列为“仅供复核”,第二次输入 `Y` 也不会删除;无法读取的根目录列为“无法验证”。
- 系统级候选项会标为需要管理员范围,但 CleanApp 只使用当前进程已有权限,绝不会自动调用 `sudo`。macOS ACL、隐私控制或目录权限导致的失败会保留到最终报告。
- 只有确认残留、人工复核项、无法访问的位置、原计划残留路径、相关进程和清理失败都为空时,程序才会报告:在受支持且成功扫描的位置中没有确认残留。

## 安全设计

CleanApp 在真正删除文件前会执行多层检查:

- 默认需要二次确认
- 主要卸载前必须确认;只有残留扫描 #2 存在“可确认删除”项时,才会出现默认值为 `N` 的第二次确认
- 支持完整 dry-run。
- 拒绝删除 `/`、`/System`、`/usr`、`/Applications`、`/Library`、用户主目录等关键路径本身。
- 拒绝删除 `~/.config`、`~/.cache` 等范围过大的公共目录本身。
Expand All @@ -262,6 +266,7 @@ CleanApp 在真正删除文件前会执行多层检查:
- 进程匹配会排除 CleanApp 自身及其父进程。
- 短进程名称采用精确匹配,减少误判。
- 所有失败都会进入最终报告和日志。
- 不会自动调用 `sudo` 或静默提升权限。

即使有这些保护,卸载仍然属于可能不可逆的操作。执行实际删除前,应认真查看 dry-run 输出并备份重要数据。

Expand Down Expand Up @@ -381,11 +386,7 @@ packages:
.venv/bin/python -m pip check
```

当前项目回归结果:

```text
24 passed
```
测试数量会随功能迭代变化,以当前 `pytest` 输出的零失败结果为准。

测试使用临时隔离目录验证实际删除逻辑,不会删除用户的真实应用和配置。

Expand Down Expand Up @@ -421,6 +422,7 @@ clean-cli/
│ ├── providers.py # Applications 与包管理器 Provider
│ ├── rule_engine.py # YAML 规则加载和匹配
│ ├── analyzer.py # 路径、空间和进程分析
│ ├── residual_scanner.py # 删除前身份捕获与受限残留扫描
│ ├── safety.py # 删除路径安全检查
│ ├── remover.py # 进程、包和文件清理
│ ├── reporter.py # Rich 预览与清理报告
Expand All @@ -437,7 +439,7 @@ clean-cli/

- 当前版本面向 macOS,不支持 Windows 或 Linux 桌面应用卸载。
- 某些系统级 Helper Tool 或 LaunchDaemon 需要额外权限;权限不足时会记录失败并继续。
- 第三方软件可能在更新后改变数据目录,需要同步更新对应 YAML 规则
- 通用扫描不会递归搜索整个用户目录;不在受支持根目录直接子项中的第三方数据,需要通过明确规则或人工复核处理
- Go 没有统一的全局卸载命令,因此主要通过扫描到的安装目录清理二进制文件。
- 当前版本一次处理一个软件,尚未提供批量删除和完整 TUI。

Expand Down
64 changes: 61 additions & 3 deletions cleanapp/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,15 @@
from .analyzer import Analyzer
from .logging_config import configure_logging
from .models import Software, Source
from .reporter import show_preview, show_result, software_table
from .reporter import (
show_preview,
show_residual_scan,
show_result,
show_verification_result,
software_table,
)
from .remover import Remover
from .residual_scanner import ResidualScanner, capture_identity
from .rule_engine import RuleEngine, normalize
from .scanner import Scanner

Expand Down Expand Up @@ -58,18 +65,69 @@ def _select(items: list[Software]) -> Software:
def _confirm_and_remove(software: Software, dry_run: bool) -> None:
rules = RuleEngine().load()
rule = RuleEngine.match(software, rules)
identity = capture_identity(software, rule)
analysis = Analyzer().analyze(software, rule)
show_preview(console, analysis)
if len(analysis.paths) > 10 and typer.prompt("Type 'all' to view every path, or press Enter to continue", default="", show_default=False).casefold() == "all":
show_preview(console, analysis, limit=None)
if dry_run:
show_result(console, Remover().remove(analysis, dry_run=True))
primary_result = Remover().remove(analysis, dry_run=True)
current_scan = ResidualScanner().scan(identity)
primary_paths = {path.absolute() for path in analysis.paths}
current_scan.confirmed = [
item for item in current_scan.confirmed
if item.path.absolute() not in primary_paths
]
current_scan.review_only = [
item for item in current_scan.review_only
if item.path.absolute() not in primary_paths
]
show_result(console, primary_result)
show_residual_scan(
console,
current_scan,
"Currently detectable additional candidates",
)
console.print(
"Dry run only. Post-removal verification scan was not performed because no changes were made.",
soft_wrap=True,
)
return
answer = typer.prompt("Confirm deletion? (Y/N)", default="N", show_default=False)
if answer.casefold() != "y":
console.print("Cancelled. Nothing was changed.")
raise typer.Abort()
show_result(console, Remover().remove(analysis))
remover = Remover()
primary_result = remover.remove(analysis)
scanner = ResidualScanner()
scan_two = scanner.scan(identity)
show_residual_scan(console, scan_two, "Residual scan #2")

residual_result = None
if scan_two.confirmed:
answer = typer.prompt(
f"Delete {len(scan_two.confirmed)} confirmed residual paths? (y/N)",
default="N",
show_default=False,
)
if answer.casefold() == "y":
residual_result = remover.remove_residuals(
software.name,
scan_two.confirmed,
)

verification = scanner.scan(identity)
original_remaining = [
path for path in analysis.paths if path.exists() or path.is_symlink()
]
show_verification_result(
console,
software.name,
primary_result,
residual_result,
verification,
original_remaining,
)


@app.callback(invoke_without_command=True)
Expand Down
28 changes: 28 additions & 0 deletions cleanapp/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,34 @@ class Source(str, Enum):
RULE = "Rule"


@dataclass(frozen=True, slots=True)
class ResidualIdentity:
names: frozenset[str]
bundle_ids: frozenset[str]
package_ids: frozenset[str]
executables: frozenset[str]
sources: frozenset[Source]
owned_paths: frozenset[Path] = frozenset()


@dataclass(frozen=True, slots=True)
class ResidualCandidate:
path: Path
evidence: str
system_level: bool
device: int
inode: int
file_type: int


@dataclass(slots=True)
class ResidualScan:
confirmed: list[ResidualCandidate] = field(default_factory=list)
review_only: list[ResidualCandidate] = field(default_factory=list)
inaccessible_roots: list[Path] = field(default_factory=list)
running_pids: list[int] = field(default_factory=list)


@dataclass(slots=True)
class Software:
name: str
Expand Down
Loading
Loading