myspace-cli 1.2.0__py3-none-any.whl → 1.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: myspace-cli
3
- Version: 1.2.0
3
+ Version: 1.4.0
4
4
  Summary: A macOS disk space analysis CLI: health, index, app usage, big files.
5
5
  Author-email: Your Name <you@example.com>
6
6
  License: MIT
@@ -17,31 +17,54 @@ Description-Content-Type: text/markdown
17
17
  Provides-Extra: mcp
18
18
  Requires-Dist: mcp<2,>=1; extra == "mcp"
19
19
 
20
- # space-cli - Mac OS 磁盘空间分析工具
20
+ # space-cli - macOS 磁盘空间优化工具
21
21
 
22
22
  很多人的Mac电脑都会出现磁盘空间不够用,付费软件太贵或者难以使用。
23
23
 
24
- space-cli是一个开源的Mac OS命令行小工具,用于分析磁盘空间健康度并找出占用空间最大的目录。
24
+ space-cli是一个开源的macOS命令行小工具,用于分析磁盘空间健康度并找出占用空间大的目录,可选择针对单个应用进行一键清理。
25
25
 
26
- 本软件采用**最严安全原则**,所有操作采用只读模式,不会尝试改写和破坏用户电脑的任何数据,也不会上传任何数据到外网,严格保护用户的隐私。
26
+ 本软件采用**最严安全原则**,所有分析操作采用只读模式,未经允许不会尝试改写和破坏用户电脑的任何数据,也不会上传任何数据到外网,严格保护用户的隐私。
27
27
 
28
28
  ## 功能特性
29
29
 
30
30
  - 🔍 **磁盘健康度检测** - 评估磁盘空间使用情况,提供健康状态建议
31
- - 📊 **目录大小分析** - 递归分析目录大小,找出占用空间最大的文件夹
32
- - 💻 **系统信息显示** - 显示Mac系统版本和基本信息
31
+ - 📊 **交互式目录分析** - 递归分析目录大小,支持选择序号进行深度下探分析
32
+ - 💻 **详细系统信息** - 显示CPU、内存、GPU、硬盘等完整硬件信息
33
33
  - 📄 **报告导出** - 将分析结果导出为JSON格式报告
34
- - ⚡ **高性能** - 优化的算法,快速分析大型文件系统
34
+ - ⚡ **高性能优化** - 优先使用 `du -sk` 命令,失败时回退到 `os.scandir` 高效遍历
35
35
  - 🎯 **灵活配置** - 支持自定义分析路径和显示数量
36
- - 🗂️ **索引缓存** - 目录大小结果本地索引缓存(`~/.spacecli/index.json`),支持TTL与重建提示
36
+ - 🗂️ **智能索引缓存** - 目录大小结果本地索引缓存(`~/.spacecli/index.json`),支持TTL与重建提示
37
37
  - 🧩 **应用分析** - 汇总 `Applications`、`Library`、`Caches`、`Logs` 等路径估算应用占用,给出卸载建议
38
- - 🏠 **用户目录深度分析** - 针对 `~/Library`、`~/Downloads`、`~/Documents` 分别下探并展示Top N目录
39
- - 🗄️ **大文件分析** - 扫描并列出指定路径下最大的文件,支持数量和最小体积阈值
40
- - ⏱️ **支持MCP调用** - 支持你自己的AI Agent无缝调用磁盘空间信息
38
+ - 🗑️ **一键删除应用** - 在应用分析列表中输入序号即可一键删除所选应用及其缓存(含二次确认)
39
+ - 🏠 **用户目录深度分析** - 针对 `~/Library`、`~/Downloads`、`~/Documents` 分别下探并展示Top N目录
40
+ - 🗄️ **大文件分析** - 扫描并列出指定路径下最大的文件,支持数量和最小体积阈值
41
+ - 🔄 **返回上一级** - 在下探分析中支持选择0返回上一级目录
42
+ - ⏱️ **支持MCP调用** - 支持你自己的AI Agent无缝调用磁盘空间信息
41
43
 
42
44
  ## 安装
43
45
 
44
- ### 方法1:直接使用(推荐)
46
+ ### 方法1:通过 pip 安装(推荐)
47
+
48
+ ```bash
49
+ python3 -m pip install --upgrade myspace-cli
50
+
51
+ # 支持Pip安装
52
+ pip install myspace-cli
53
+
54
+ # 安装完成后直接使用
55
+ # 请注意命令行的启动文件名(space-cli)和pip包的名字(myspace-cli)不一样
56
+ # 建议直接运行,可以看到使用菜单
57
+ space-cli
58
+
59
+ # 或以模块方式
60
+ python3 -m space_cli
61
+
62
+ # 如果要使用高级的功能,请使用更复杂的命令行参数,可以运行help
63
+ space-cli --help
64
+
65
+ ```
66
+
67
+ ### 方法2:直接使用
45
68
 
46
69
  ```bash
47
70
  # 克隆或下载项目
@@ -55,7 +78,7 @@ chmod +x space_cli.py
55
78
  python3 space_cli.py
56
79
  ```
57
80
 
58
- ### 方法2:创建全局命令
81
+ ### 方法3:创建全局命令
59
82
 
60
83
  ```bash
61
84
  # 复制到系统路径
@@ -66,24 +89,14 @@ sudo chmod +x /usr/local/bin/space-cli
66
89
  space-cli
67
90
  ```
68
91
 
69
- ### 方法3:通过 pip 安装(发布到 PyPI 后)
70
-
71
- ```bash
72
- python3 -m pip install --upgrade spacecli
73
-
74
- # 直接使用命令
75
- space-cli --help
76
-
77
- # 或者作为模块调用
78
- python3 -m space_cli --help
79
- ```
92
+ 注:若你更倾向于使用 PyPI 包名 `spacecli`,也可执行 `python3 -m pip install --upgrade spacecli`,命令入口同为 `space-cli`。
80
93
 
81
94
  ## 使用方法
82
95
 
83
96
  ### 基本用法
84
97
 
85
98
  ```bash
86
- # 分析根目录(默认)
99
+ # 分析根目录(默认)- 支持交互式下探分析
87
100
  python3 space_cli.py
88
101
 
89
102
  # 分析指定路径
@@ -94,6 +107,9 @@ python3 space_cli.py -n 10
94
107
 
95
108
  # 快捷分析当前用户目录(含用户目录深度分析)
96
109
  python3 space_cli.py --home
110
+
111
+ # 交互式目录空间分析(支持选择序号下探,选择0返回上一级)
112
+ python3 space_cli.py --directories-only
97
113
  ```
98
114
 
99
115
  ### 高级用法
@@ -126,6 +142,8 @@ python3 space_cli.py --no-prompt
126
142
  # 分析应用目录占用并给出卸载建议(按应用归并)
127
143
  python3 space_cli.py --apps -n 20
128
144
 
145
+ # 在应用分析输出后,按提示输入序号一键删除应用(会二次确认)
146
+ # 例如:输入 3 即删除列表中的第3个应用及其相关缓存
129
147
  # 大文件分析(显示前20个,阈值2G)
130
148
  python3 space_cli.py --big-files --big-files-top 20 --big-files-min 2G
131
149
 
@@ -174,21 +192,30 @@ python3 space_cli.py --big-files --export report.json
174
192
  建议: 磁盘空间不足,建议清理一些文件
175
193
  ```
176
194
 
177
- ### 目录分析
195
+ ### 交互式目录分析
178
196
  ```
179
197
  ============================================================
180
198
  📊 占用空间最大的目录
181
199
  ============================================================
182
200
  显示前 20 个最大的目录:
183
201
 
184
- 1. /Applications
185
- 大小: 15.2 GB (3.04%)
202
+ 1. /Applications -- 大小: 15.2 GB (3.04%)
203
+ 2. /Users/username/Library -- 大小: 8.5 GB (1.70%)
204
+ 3. /System -- 大小: 6.8 GB (1.36%)
186
205
 
187
- 2. /Users/username/Library
188
- 大小: 8.5 GB (1.70%)
206
+ ============================================================
207
+ 🔍 下探分析选项
208
+ ============================================================
209
+ 选择序号进行深度分析,选择0返回上一级,直接回车退出:
210
+ 请输入选择 [回车=退出]: 1
189
211
 
190
- 3. /System
191
- 大小: 6.8 GB (1.36%)
212
+ 🔍 正在分析: /Applications (15.2 GB)
213
+ ============================================================
214
+ 📊 占用空间最大的目录
215
+ ============================================================
216
+ 1. /Applications/Xcode.app -- 大小: 8.2 GB (1.64%)
217
+ 2. /Applications/Docker.app -- 大小: 3.1 GB (0.62%)
218
+ 3. /Applications/Visual Studio Code.app -- 大小: 1.8 GB (0.36%)
192
219
  ```
193
220
 
194
221
  ### 大文件分析
@@ -200,6 +227,29 @@ python3 space_cli.py --big-files --export report.json
200
227
  2. /Users/username/Movies/clip.mov -- 大小: 3.1 GB (0.62%)
201
228
  ```
202
229
 
230
+ ### 应用分析与一键删除
231
+ ```
232
+ ============================================================
233
+ 🧩 应用目录空间分析与卸载建议
234
+ ============================================================
235
+ 1. Docker Desktop -- 占用: 9.1 GB (1.80%) — 建议卸载或清理缓存
236
+ 2. Xcode -- 占用: 6.2 GB (1.23%) — 建议卸载或清理缓存
237
+ 3. WeChat -- 占用: 2.4 GB (0.47%) — 可保留,定期清理缓存
238
+
239
+ 是否要一键删除某个应用?输入序号或回车跳过: 1
240
+ 确认删除应用及相关缓存: Docker Desktop (约 9.1 GB)?[y/N]: y
241
+ 将尝试删除以下路径:
242
+ - /Applications/Docker.app
243
+ - ~/Library/Application Support/Docker
244
+ - ~/Library/Caches/com.docker.docker
245
+ ...(略)
246
+ ✅ 删除完成,预计释放空间: 8.7 GB
247
+ ```
248
+
249
+ 说明:
250
+ - 删除动作包含二次确认,并会列出将删除的路径清单。
251
+ - 系统级目录可能因权限/SIP 受保护而无法完全删除,此时工具会尽量清理可删部分并给出失败项与原因。
252
+
203
253
 
204
254
  ## MCP Server(可选)
205
255
 
@@ -226,11 +276,13 @@ python3 mcp_server.py
226
276
 
227
277
  ## 性能优化
228
278
 
229
- - 使用递归算法高效计算目录大小
230
- - 跳过无法访问的系统文件和隐藏文件
231
- - 支持中断操作(Ctrl+C)
232
- - 内存优化的文件遍历
233
- - 单行滚动进度避免输出刷屏
279
+ - **优先使用 `du -sk` 命令**:在 macOS 上使用原生 `du` 命令快速获取目录大小
280
+ - **智能回退机制**:当 `du` 命令失败时,自动回退到基于 `os.scandir` 的高效遍历
281
+ - **跳过系统目录**:自动忽略 `/System`、`/Volumes`、`/private` 等系统目录
282
+ - **跳过无法访问的文件**:自动处理权限错误和符号链接
283
+ - **支持中断操作**:使用 Ctrl+C 随时中断分析
284
+ - **内存优化遍历**:使用栈式迭代替代递归,避免深度目录的栈溢出
285
+ - **单行滚动进度**:避免输出刷屏,使用 ANSI 清行(\r\033[K)避免长行残留
234
286
 
235
287
  ## 故障排除
236
288
 
@@ -244,6 +296,11 @@ sudo python3 space_cli.py
244
296
  python3 space_cli.py -p /Users/$(whoami)
245
297
  ```
246
298
 
299
+ 此外,针对“Operation not permitted”等提示:
300
+ - 退出相关应用后再试(例如删除 Docker 前先退出 Docker Desktop)。
301
+ - 在“系统设置 → 隐私与安全性”中为终端授予“完全磁盘访问权限”。
302
+ - 遇到容器元数据或受 SIP 保护的系统级文件(如 `~/Library/Containers/com.docker.docker/... .plist`),可能无法删除,建议仅清理用户级缓存目录。
303
+
247
304
  ### 性能问题
248
305
  对于大型文件系统,分析可能需要较长时间:
249
306
  - 使用 `--directories-only` 跳过健康检查
@@ -282,3 +339,16 @@ MIT License
282
339
  - 新增大文件分析 `--big-files`/`--big-files-top`/`--big-files-min`
283
340
  - 导出报告在启用大文件分析时包含 `largest_files`
284
341
  - 单行滚动进度显示
342
+
343
+ ### v1.2.0
344
+ - 应用分析支持"按序号一键删除应用",并显示将删除的路径清单与预计释放空间
345
+ - 删除过程增加权限修复与降级清理策略(chflags nouchg / chmod 0777 / 逐项清理)
346
+ - 针对 "Operation not permitted" 增加友好提示(SIP、完全磁盘访问、退出相关应用)
347
+ - 单行覆盖输出加入 ANSI 清行,避免长行残留
348
+
349
+ ### v1.3.0
350
+ - **性能大幅优化**:优先使用 `du -sk` 命令获取目录大小,失败时回退到 `os.scandir` 高效遍历
351
+ - **交互式下探分析**:支持选择序号进行深度目录分析,选择0返回上一级
352
+ - **增强系统信息**:显示 CPU、内存、GPU、硬盘等完整硬件信息
353
+ - **智能目录过滤**:自动忽略系统目录(`/System`、`/Volumes`、`/private`)
354
+ - **优化用户体验**:改进菜单选项,支持交互式目录空间分析
@@ -0,0 +1,6 @@
1
+ space_cli.py,sha256=qEEhU_3nuiG--fWGbUDdQytrL9mL1CZTKGTMFpCyBNk,48380
2
+ myspace_cli-1.4.0.dist-info/METADATA,sha256=DDp6iHPcaGr_nCmJg5GHqeVhfqY-2fpzuJQ-4izUlVg,12747
3
+ myspace_cli-1.4.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
4
+ myspace_cli-1.4.0.dist-info/entry_points.txt,sha256=sIVEmPf6W8aNa7KiqOwAI6JrsgHlQciO5xH2G29tKPQ,45
5
+ myspace_cli-1.4.0.dist-info/top_level.txt,sha256=lnUfbQX9h-9ZjecMVCOWucS2kx69FwTndlrb48S20Sc,10
6
+ myspace_cli-1.4.0.dist-info/RECORD,,
space_cli.py CHANGED
@@ -154,20 +154,48 @@ class SpaceAnalyzer:
154
154
  return f"{bytes_value:.1f} PB"
155
155
 
156
156
  def get_directory_size(self, path: str) -> int:
157
- """递归计算目录大小"""
158
- total_size = 0
157
+ """高性能计算目录大小。
158
+
159
+ 优先使用 macOS 的 du -sk(以 KiB 为单位,速度快,原生命令可处理边界情况),
160
+ 若 du 调用失败则回退到基于 os.scandir 的非递归遍历实现(避免 os.walk 的函数调用开销)。
161
+ """
162
+ # 优先尝试 du -sk(BSD du 在 macOS 可用)。
159
163
  try:
160
- for dirpath, dirnames, filenames in os.walk(path):
161
- for filename in filenames:
162
- filepath = os.path.join(dirpath, filename)
163
- try:
164
- total_size += os.path.getsize(filepath)
165
- except (OSError, FileNotFoundError):
166
- # 跳过无法访问的文件
167
- continue
168
- except (OSError, PermissionError):
169
- # 跳过无法访问的目录
164
+ # du 输出形如: "<kib>\t<path>\n"
165
+ result = subprocess.run([
166
+ 'du', '-sk', path
167
+ ], capture_output=True, text=True, check=True)
168
+ out = result.stdout.strip().split('\t', 1)[0].strip()
169
+ kib = int(out)
170
+ return kib * 1024
171
+ except Exception:
172
+ # du 不可用或失败时回退到 Python 实现
170
173
  pass
174
+
175
+ total_size = 0
176
+ # 基于栈的迭代遍历,避免递归栈与 os.walk 的额外开销
177
+ stack = [path]
178
+ while stack:
179
+ current = stack.pop()
180
+ try:
181
+ with os.scandir(current) as it:
182
+ for entry in it:
183
+ # 跳过符号链接,避免循环与跨文件系统问题
184
+ try:
185
+ if entry.is_symlink():
186
+ continue
187
+ if entry.is_file(follow_symlinks=False):
188
+ try:
189
+ total_size += entry.stat(follow_symlinks=False).st_size
190
+ except (OSError, FileNotFoundError, PermissionError):
191
+ continue
192
+ elif entry.is_dir(follow_symlinks=False):
193
+ stack.append(entry.path)
194
+ except (OSError, FileNotFoundError, PermissionError):
195
+ continue
196
+ except (OSError, FileNotFoundError, PermissionError):
197
+ # 无法进入该目录则跳过
198
+ continue
171
199
  return total_size
172
200
 
173
201
  def analyze_largest_files(self, root_path: str = "/", top_n: int = 50,
@@ -182,7 +210,7 @@ class SpaceAnalyzer:
182
210
  dirpath_display = dirpath[-80:] # 截取最后50个字符
183
211
  if dirpath_display == "":
184
212
  dirpath_display = dirpath
185
- sys.stdout.write(f"\r-> 正在读取: \033[36m{dirpath_display}\033[0m")
213
+ sys.stdout.write(f"\r\033[K-> 正在读取: \033[36m{dirpath_display}\033[0m")
186
214
  sys.stdout.flush()
187
215
  for filename in filenames:
188
216
  filepath = os.path.join(dirpath, filename)
@@ -203,7 +231,7 @@ class SpaceAnalyzer:
203
231
  if dirpath_display == "":
204
232
  dirpath_display = dirpath
205
233
  # 间隔性进度输出(单行覆盖)
206
- sys.stdout.write(f"\r-> 正在读取: \033[36m{dirpath_display}\033[0m 已扫描文件数: \033[32m{scanned}\033[0m")
234
+ sys.stdout.write(f"\r\033[K-> 正在读取: \033[36m{dirpath_display}\033[0m 已扫描文件数: \033[32m{scanned}\033[0m")
207
235
  sys.stdout.flush()
208
236
  except KeyboardInterrupt:
209
237
  print("\n用户中断扫描,返回当前结果...")
@@ -241,6 +269,14 @@ class SpaceAnalyzer:
241
269
  return [(e["path"], int(e["size"])) for e in cached["entries"]][:top_n]
242
270
 
243
271
  print("正在分析目录大小,这可能需要一些时间...")
272
+
273
+ # 忽略的目录列表, 这些目录时系统目录,不需要分析
274
+ ignore_dir_list = [
275
+ "/System", # 系统目录
276
+ "/Volumes", # 外部挂载卷
277
+ "/private", # 私有目录
278
+ ]
279
+
244
280
 
245
281
  directory_sizes = []
246
282
 
@@ -252,11 +288,14 @@ class SpaceAnalyzer:
252
288
  # 跳过隐藏文件和系统文件
253
289
  if item.startswith('.') and item not in ['.Trash', '.localized']:
254
290
  continue
291
+
292
+ if item_path in ignore_dir_list:
293
+ continue
255
294
 
256
295
  if os.path.isdir(item_path):
257
296
  try:
258
297
  # 进度提示:当前正在读取的目录(单行覆盖)
259
- sys.stdout.write(f"\r-> 正在读取: \033[36m{item_path}\033[0m")
298
+ sys.stdout.write(f"\r\033[K-> 正在读取: \033[36m{item_path}\033[0m")
260
299
  sys.stdout.flush()
261
300
  size = self.get_directory_size(item_path)
262
301
  directory_sizes.append((item_path, size))
@@ -284,19 +323,58 @@ class SpaceAnalyzer:
284
323
  return []
285
324
 
286
325
  def get_system_info(self) -> Dict:
287
- """获取系统信息"""
326
+ """获取系统信息(包括 CPU、内存、GPU、硬盘等硬件信息)"""
327
+ system_info = {}
328
+
288
329
  try:
289
- # 获取系统版本
330
+ # 获取系统版本信息
290
331
  result = subprocess.run(['sw_vers'], capture_output=True, text=True)
291
- system_info = {}
292
332
  for line in result.stdout.split('\n'):
293
333
  if ':' in line:
294
334
  key, value = line.split(':', 1)
295
335
  system_info[key.strip()] = value.strip()
336
+ except Exception:
337
+ system_info["ProductName"] = "macOS"
338
+ system_info["ProductVersion"] = "未知"
339
+
340
+ try:
341
+ # 获取 CPU 信息
342
+ cpu_result = subprocess.run(['sysctl', '-n', 'machdep.cpu.brand_string'],
343
+ capture_output=True, text=True)
344
+ if cpu_result.returncode == 0:
345
+ system_info["CPU"] = cpu_result.stdout.strip()
296
346
 
297
- return system_info
347
+ # 获取 CPU 核心数
348
+ cores_result = subprocess.run(['sysctl', '-n', 'hw.ncpu'],
349
+ capture_output=True, text=True)
350
+ if cores_result.returncode == 0:
351
+ system_info["CPU核心数"] = cores_result.stdout.strip()
352
+
353
+ except Exception:
354
+ system_info["CPU"] = "未知"
355
+ system_info["CPU核心数"] = "未知"
356
+
357
+ try:
358
+ # 获取内存信息
359
+ mem_result = subprocess.run(['sysctl', '-n', 'hw.memsize'],
360
+ capture_output=True, text=True)
361
+ if mem_result.returncode == 0:
362
+ mem_bytes = int(mem_result.stdout.strip())
363
+ system_info["内存"] = self.format_bytes(mem_bytes)
364
+ except Exception:
365
+ system_info["内存"] = "未知"
366
+
367
+
368
+ try:
369
+ # 获取启动时间
370
+ boot_result = subprocess.run(['uptime'], capture_output=True, text=True)
371
+ if boot_result.returncode == 0:
372
+ uptime_line = boot_result.stdout.strip()
373
+ system_info["运行时间"] = uptime_line
298
374
  except Exception:
299
- return {"ProductName": "macOS", "ProductVersion": "未知"}
375
+ system_info["运行时间"] = "未知"
376
+
377
+ return system_info
300
378
 
301
379
 
302
380
  class SpaceCli:
@@ -311,6 +389,200 @@ class SpaceCli:
311
389
  os.makedirs(app_cache_dir, exist_ok=True)
312
390
  self.app_index = IndexStore(index_file=os.path.join(app_cache_dir, "apps.json"))
313
391
 
392
+ # —— 应用删除相关 ——
393
+ def _candidate_app_paths(self, app_name: str) -> List[str]:
394
+ """根据应用名推导可能占用空间的相关目录/文件路径列表。"""
395
+ home = str(Path.home())
396
+ candidates: List[str] = []
397
+ possible_bases = [
398
+ ("/Applications", f"{app_name}.app"),
399
+ (os.path.join(home, "Applications"), f"{app_name}.app"),
400
+ ("/Library/Application Support", app_name),
401
+ (os.path.join(home, "Library", "Application Support"), app_name),
402
+ ("/Library/Caches", app_name),
403
+ (os.path.join(home, "Library", "Caches"), app_name),
404
+ ("/Library/Logs", app_name),
405
+ (os.path.join(home, "Library", "Logs"), app_name),
406
+ (os.path.join(home, "Library", "Containers"), app_name),
407
+ ]
408
+ # 直接拼接命中
409
+ for base, tail in possible_bases:
410
+ path = os.path.join(base, tail)
411
+ if os.path.exists(path):
412
+ candidates.append(path)
413
+ # 模糊扫描:包含应用名的目录
414
+ scan_dirs = [
415
+ "/Applications",
416
+ os.path.join(home, "Applications"),
417
+ "/Library/Application Support",
418
+ os.path.join(home, "Library", "Application Support"),
419
+ "/Library/Caches",
420
+ os.path.join(home, "Library", "Caches"),
421
+ "/Library/Logs",
422
+ os.path.join(home, "Library", "Logs"),
423
+ os.path.join(home, "Library", "Containers"),
424
+ ]
425
+ app_lower = app_name.lower()
426
+ for base in scan_dirs:
427
+ if not os.path.exists(base):
428
+ continue
429
+ try:
430
+ for item in os.listdir(base):
431
+ item_path = os.path.join(base, item)
432
+ # 只收集目录或 .app 包
433
+ if not os.path.isdir(item_path):
434
+ continue
435
+ name_lower = item.lower()
436
+ if app_lower in name_lower:
437
+ candidates.append(item_path)
438
+ except (PermissionError, OSError):
439
+ continue
440
+ # 去重并按路径长度降序(先删更深层,避免空目录残留)
441
+ uniq: List[str] = []
442
+ seen = set()
443
+ for p in sorted(set(candidates), key=lambda x: len(x), reverse=True):
444
+ if p not in seen:
445
+ uniq.append(p)
446
+ seen.add(p)
447
+ return uniq
448
+
449
+ def _delete_paths_and_sum(self, paths: List[str]) -> Tuple[int, List[Tuple[str, str]]]:
450
+ """删除给定路径列表,返回释放的总字节数与失败列表(路径, 原因)。"""
451
+ total_freed = 0
452
+ failures: List[Tuple[str, str]] = []
453
+
454
+ def _try_fix_permissions(path: str) -> None:
455
+ """尝试修复权限与不可变标记以便删除。"""
456
+ try:
457
+ # 去除不可变标记(普通用户能去除的场景)
458
+ subprocess.run(["chflags", "-R", "nouchg", path], capture_output=True)
459
+ except Exception:
460
+ pass
461
+ try:
462
+ os.chmod(path, 0o777)
463
+ except Exception:
464
+ pass
465
+
466
+ def _onerror(func, path, exc_info):
467
+ # 当 rmtree 无法删除时,尝试修复权限并重试一次
468
+ _try_fix_permissions(path)
469
+ try:
470
+ func(path)
471
+ except Exception:
472
+ # 让上层捕获
473
+ raise
474
+ for p in paths:
475
+ try:
476
+ size_before = 0
477
+ try:
478
+ if os.path.isdir(p):
479
+ size_before = self.analyzer.get_directory_size(p)
480
+ elif os.path.isfile(p):
481
+ size_before = os.path.getsize(p)
482
+ except Exception:
483
+ size_before = 0
484
+ if os.path.isdir(p) and not os.path.islink(p):
485
+ try:
486
+ shutil.rmtree(p, ignore_errors=False, onerror=_onerror)
487
+ except Exception:
488
+ # 目录删除失败,降级为逐项尝试删除(尽量清理可删部分)
489
+ for dirpath, dirnames, filenames in os.walk(p, topdown=False):
490
+ for name in filenames:
491
+ fpath = os.path.join(dirpath, name)
492
+ try:
493
+ _try_fix_permissions(fpath)
494
+ os.remove(fpath)
495
+ except Exception:
496
+ continue
497
+ for name in dirnames:
498
+ dpath = os.path.join(dirpath, name)
499
+ try:
500
+ _try_fix_permissions(dpath)
501
+ os.rmdir(dpath)
502
+ except Exception:
503
+ continue
504
+ # 最后尝试删除顶层目录
505
+ _try_fix_permissions(p)
506
+ os.rmdir(p)
507
+ else:
508
+ os.remove(p)
509
+ total_freed += size_before
510
+ except Exception as e:
511
+ failures.append((p, str(e)))
512
+ return total_freed, failures
513
+
514
+ def _offer_app_delete(self, apps: List[Tuple[str, int]]) -> None:
515
+ """在已打印的应用列表后,提供按序号一键删除功能。"""
516
+ if not sys.stdin.isatty() or getattr(self.args, 'no_prompt', False):
517
+ return
518
+ try:
519
+ ans = input("是否要一键删除某个应用?输入序号或回车跳过: ").strip()
520
+ except EOFError:
521
+ ans = ""
522
+ if not ans:
523
+ return
524
+ try:
525
+ idx = int(ans)
526
+ except ValueError:
527
+ print("❌ 无效的输入(应为数字序号)")
528
+ return
529
+ if idx < 1 or idx > len(apps):
530
+ print("❌ 序号超出范围")
531
+ return
532
+ app_name, app_size = apps[idx - 1]
533
+ size_str = self.analyzer.format_bytes(app_size)
534
+ try:
535
+ confirm = input(f"确认删除应用及相关缓存: {app_name} (约 {size_str})?[y/N]: ").strip().lower()
536
+ except EOFError:
537
+ confirm = ""
538
+ if confirm not in ("y", "yes"):
539
+ print("已取消删除")
540
+ return
541
+ related_paths = self._candidate_app_paths(app_name)
542
+ if not related_paths:
543
+ print("未找到可删除的相关目录/文件")
544
+ return
545
+ print("将尝试删除以下路径:")
546
+ for p in related_paths:
547
+ print(f" - {p}")
548
+ try:
549
+ confirm2 = input("再次确认删除以上路径?[y/N]: ").strip().lower()
550
+ except EOFError:
551
+ confirm2 = ""
552
+ if confirm2 not in ("y", "yes"):
553
+ print("已取消删除")
554
+ return
555
+ freed, failures = self._delete_paths_and_sum(related_paths)
556
+ print(f"✅ 删除完成,预计释放空间: {self.analyzer.format_bytes(freed)}")
557
+ if failures:
558
+ print("以下路径删除失败,可能需要手动或管理员权限:")
559
+ for p, reason in failures:
560
+ print(f" - {p} -> {reason}")
561
+ # 常见提示:Operation not permitted(SIP/容器元数据等)
562
+ if any("Operation not permitted" in r for _, r in failures):
563
+ print("提示:部分系统受保护或容器元数据文件无法删除。可尝试:")
564
+ print(" - 先退出相关应用(如 Docker)再重试")
565
+ print(" - 给予当前终端“完全磁盘访问权限”(系统设置 → 隐私与安全性)")
566
+ print(" - 仅删除用户目录下缓存,保留系统级容器元数据")
567
+
568
+ # 通用渲染:目录与应用(减少重复)
569
+ def _render_dirs(self, entries: List[Tuple[str, int]], total_bytes: int) -> None:
570
+ for i, (dir_path, size) in enumerate(entries, 1):
571
+ size_str = self.analyzer.format_bytes(size)
572
+ percentage = (size / total_bytes) * 100 if total_bytes else 0
573
+ # 1G 以上红色,否则绿色
574
+ color = "\033[31m" if size >= 1024**3 else "\033[32m"
575
+ print(f"{i:2d}. \033[36m{dir_path}\033[0m -- 大小: {color}{size_str}\033[0m (\033[33m{percentage:.2f}%\033[0m)")
576
+
577
+ def _render_apps(self, entries: List[Tuple[str, int]], disk_total: int) -> None:
578
+ for i, (app, size) in enumerate(entries, 1):
579
+ size_str = self.analyzer.format_bytes(size)
580
+ pct = (size / disk_total) * 100 if disk_total else 0
581
+ suggestion = "建议卸载或清理缓存" if size >= 5 * 1024**3 else "可保留,定期清理缓存"
582
+ # 3G 以上红色,否则绿色
583
+ color = "\033[31m" if size >= 3 * 1024**3 else "\033[32m"
584
+ print(f"{i:2d}. \033[36m{app}\033[0m -- 占用: {color}{size_str}\033[0m ({pct:.2f}%) — {suggestion}")
585
+
314
586
  def analyze_app_directories(self, top_n: int = 20,
315
587
  index: IndexStore = None,
316
588
  use_index: bool = True,
@@ -369,7 +641,8 @@ class SpaceCli:
369
641
  continue
370
642
  key = app_key_from_path(item_path)
371
643
  # 进度提示:当前应用相关目录(单行覆盖)
372
- sys.stdout.write(f"\r-> 正在读取: {item_path}")
644
+ item_path = item_path[:100]
645
+ sys.stdout.write(f"\r\033[K-> 正在读取: \033[36m{item_path}\033[0m")
373
646
  sys.stdout.flush()
374
647
  size = self.analyzer.get_directory_size(item_path)
375
648
  scanned_dirs.append(item_path)
@@ -419,37 +692,99 @@ class SpaceCli:
419
692
  print(f"建议: \033[36m{message}\033[0m")
420
693
  print()
421
694
 
422
- def print_largest_directories(self, path: str = "/", top_n: int = 20):
695
+ def print_largest_directories(self, path: str = "/Library", top_n: int = 20):
423
696
  """打印占用空间最大的目录"""
424
697
  print("=" * 60)
425
698
  print("📊 占用空间最大的目录")
426
699
  print("=" * 60)
427
700
 
701
+ # 若有缓存:直接显示缓存,然后再询问是否重新分析
702
+ if self.args.use_index:
703
+ cached = self.index.get(path)
704
+ if cached and cached.get("entries"):
705
+ cached_entries = [(e["path"], int(e["size"])) for e in cached["entries"]][:top_n]
706
+ total_info = self.analyzer.get_disk_usage(path)
707
+ total_bytes = total_info['total'] if total_info else 1
708
+ print(f"(来自索引) 显示前 {min(len(cached_entries), top_n)} 个最大的目录:\n")
709
+ self._render_dirs(cached_entries, total_bytes)
710
+ if sys.stdin.isatty() and not self.args.no_prompt:
711
+ try:
712
+ ans = input("是否重新分析以刷新索引?[y/N]: ").strip().lower()
713
+ except EOFError:
714
+ ans = ""
715
+ if ans not in ("y", "yes"):
716
+ # 提供下探分析选项
717
+ self._offer_drill_down_analysis(cached_entries, path)
718
+ return
719
+ else:
720
+ return
721
+
428
722
  directories = self.analyzer.analyze_largest_directories(
429
723
  path,
430
724
  top_n=top_n,
431
725
  index=self.index,
432
726
  use_index=self.args.use_index,
433
- reindex=self.args.reindex,
727
+ reindex=True, # 走到这里表示要刷新
434
728
  index_ttl_hours=self.args.index_ttl,
435
- prompt=not self.args.no_prompt,
729
+ prompt=False,
436
730
  )
437
-
438
731
  if not directories:
439
732
  print("❌ 无法分析目录大小")
440
733
  return
734
+ total_info = self.analyzer.get_disk_usage(path)
735
+ total_bytes = total_info['total'] if total_info else 1
736
+ print("\n已重新分析,最新结果:\n")
737
+ self._render_dirs(directories, total_bytes)
738
+
739
+ # 提供下探分析选项
740
+ self._offer_drill_down_analysis(directories, path)
741
+
742
+ def _offer_drill_down_analysis(self, directories: List[Tuple[str, int]], current_path: str) -> None:
743
+ """提供交互式下探分析选项"""
744
+ if not sys.stdin.isatty() or getattr(self.args, 'no_prompt', False):
745
+ return
441
746
 
442
- print(f"显示前 {min(len(directories), top_n)} 个最大的目录:\n")
747
+ print("\n" + "=" * 60)
748
+ print("🔍 下探分析选项")
749
+ print("=" * 60)
750
+ print("选择序号进行深度分析,选择0返回上一级,直接回车退出:")
443
751
 
444
- for i, (dir_path, size) in enumerate(directories, 1):
445
- size_str = self.analyzer.format_bytes(size)
446
- percentage = (size / self.analyzer.get_disk_usage(path)['total']) * 100 if self.analyzer.get_disk_usage(path) else 0
447
- # 目录大小大于1G采用红色显示
448
- color = "\033[31m" if size >= 1024**3 else "\033[32m"
449
- print(f"{i:2d}. \033[36m{dir_path}\033[0m -- 大小: {color}{size_str}\033[0m (\033[33m{percentage:.2f}%\033[0m)")
450
- ##print(f"{i:2d}. {dir_path}")
451
- ##print(f" 大小: {size_str} ({percentage:.2f}%)")
452
- ##print()
752
+ try:
753
+ choice = input("请输入选择 [回车=退出]: ").strip()
754
+ except EOFError:
755
+ return
756
+
757
+ if not choice:
758
+ return
759
+
760
+ try:
761
+ idx = int(choice)
762
+ except ValueError:
763
+ print("❌ 无效的输入(应为数字序号)")
764
+ return
765
+
766
+ if idx == 0:
767
+ # 返回上一级
768
+ parent_path = os.path.dirname(current_path.rstrip('/'))
769
+ if parent_path != current_path and parent_path != '/':
770
+ print(f"\n🔄 返回上一级: {parent_path}")
771
+ self.print_largest_directories(parent_path, self.args.top_n)
772
+ else:
773
+ print("❌ 已在根目录,无法返回上一级")
774
+ return
775
+
776
+ if idx < 1 or idx > len(directories):
777
+ print("❌ 序号超出范围")
778
+ return
779
+
780
+ selected_path, selected_size = directories[idx - 1]
781
+ size_str = self.analyzer.format_bytes(selected_size)
782
+
783
+ print(f"\n🔍 正在分析: {selected_path} ({size_str})")
784
+ print("=" * 60)
785
+
786
+ # 递归调用下探分析
787
+ self.print_largest_directories(selected_path, self.args.top_n)
453
788
 
454
789
  def print_app_analysis(self, top_n: int = 20):
455
790
  """打印应用目录占用分析,并给出卸载建议"""
@@ -457,29 +792,44 @@ class SpaceCli:
457
792
  print("🧩 应用目录空间分析与卸载建议")
458
793
  print("=" * 60)
459
794
 
795
+ # 先显示缓存,再决定是否刷新
796
+ if self.args.use_index:
797
+ cached = self.app_index.get_named("apps_aggregate")
798
+ if cached and cached.get("entries"):
799
+ cached_entries = [(e["name"], int(e["size"])) for e in cached["entries"]][:top_n]
800
+ total = self.analyzer.get_disk_usage("/")
801
+ disk_total = total['total'] if total else 1
802
+ print(f"(来自索引) 显示前 {min(len(cached_entries), top_n)} 个空间占用最高的应用:\n")
803
+ self._render_apps(cached_entries, disk_total)
804
+ # 提供一键删除
805
+ self._offer_app_delete(cached_entries)
806
+ if sys.stdin.isatty() and not self.args.no_prompt:
807
+ try:
808
+ ans = input("是否重新分析应用以刷新索引?[y/N]: ").strip().lower()
809
+ except EOFError:
810
+ ans = ""
811
+ if ans not in ("y", "yes"):
812
+ return
813
+ else:
814
+ return
815
+
460
816
  apps = self.analyze_app_directories(
461
817
  top_n=top_n,
462
818
  index=self.app_index,
463
819
  use_index=self.args.use_index,
464
- reindex=self.args.reindex,
820
+ reindex=True,
465
821
  index_ttl_hours=self.args.index_ttl,
466
- prompt=not self.args.no_prompt,
822
+ prompt=False,
467
823
  )
468
824
  if not apps:
469
825
  print("❌ 未发现可分析的应用目录")
470
826
  return
471
-
472
827
  total = self.analyzer.get_disk_usage("/")
473
828
  disk_total = total['total'] if total else 1
474
-
475
- print(f"显示前 {min(len(apps), top_n)} 个空间占用最高的应用:\n")
476
- for i, (app, size) in enumerate(apps, 1):
477
- size_str = self.analyzer.format_bytes(size)
478
- pct = (size / disk_total) * 100
479
- suggestion = "建议卸载或清理缓存" if size >= 5 * 1024**3 else "可保留,定期清理缓存"
480
- print(f"{i:2d}. \033[36m{app}\033[0m -- 占用: {size_str} ({pct:.2f}%) — {suggestion}")
481
- ##print(f" 占用: {size_str} ({pct:.2f}%) — {suggestion}")
482
- #print()
829
+ print("\n已重新分析,最新应用占用结果:\n")
830
+ self._render_apps(apps, disk_total)
831
+ # 提供一键删除
832
+ self._offer_app_delete(apps)
483
833
 
484
834
  def print_home_deep_analysis(self, top_n: int = 20):
485
835
  """对用户目录的 Library / Downloads / Documents 分别下探分析"""
@@ -545,7 +895,7 @@ class SpaceCli:
545
895
  system_info = self.analyzer.get_system_info()
546
896
 
547
897
  for key, value in system_info.items():
548
- print(f"{key}: {value}")
898
+ print(f"{key}: \033[36m{value}\033[0m")
549
899
  print()
550
900
 
551
901
  def export_report(self, output_file: str, path: str = "/"):
@@ -744,50 +1094,57 @@ def main():
744
1094
  print("🧭 SpaceCli 菜单(直接回车 = 执行全部项目)")
745
1095
  print("=" * 60)
746
1096
  home_path = str(Path.home())
747
- print("1) \033[36m执行全部项目(系统信息 + 健康 + 目录 + 应用)\033[0m")
1097
+ print("1) \033[36m执行主要项目(系统信息 + 健康 + 应用)\033[0m")
748
1098
  print(f"2) \033[36m当前用户目录分析(路径: {home_path})\033[0m")
749
1099
  print("3) \033[36m仅显示系统信息\033[0m")
750
1100
  print("4) \033[36m仅显示磁盘健康状态\033[0m")
751
- print("5) \033[36m仅显示最大目录列表\033[0m")
752
- print("6) \033[36m仅显示应用目录分析与建议\033[0m")
753
- print("7) \033[36m仅显示大文件分析\033[0m")
1101
+ print("5) \033[36m交互式目录空间分析\033[0m")
1102
+ print("6) \033[36m仅分析程序应用目录空间\033[0m")
1103
+ print("7) \033[36m仅进行大文件分析(很耗时,可随时终止)\033[0m")
754
1104
  print("0) \033[36m退出\033[0m")
755
1105
  try:
756
1106
  choice = input("请选择 [回车=1]: ").strip()
757
1107
  except EOFError:
758
1108
  choice = ""
759
1109
 
760
- if choice == "0":
1110
+ if choice == "0": # 退出
761
1111
  sys.exit(0)
762
- elif choice == "2":
1112
+ elif choice == "2": # 仅显示当前用户目录分析
763
1113
  args.path = home_path
764
1114
  args.apps = False
765
1115
  args.health_only = False
766
1116
  args.directories_only = False
767
- elif choice == "3":
1117
+ elif choice == "3": # 仅显示系统信息
1118
+ args.health_only = False
1119
+ args.directories_only = False
1120
+ args.apps = False
1121
+ args.big_files = False
1122
+ elif choice == "4": # 仅显示磁盘健康状态
768
1123
  args.health_only = True
769
1124
  args.directories_only = False
770
1125
  args.apps = False
771
- elif choice == "4":
1126
+ args.big_files = False
1127
+ elif choice == "5": # 仅显示最大目录列表
772
1128
  args.health_only = False
773
1129
  args.directories_only = True
774
1130
  args.apps = False
775
- elif choice == "5":
1131
+ args.big_files = False
1132
+ elif choice == "6": # 仅显示应用目录分析与建议
776
1133
  args.health_only = False
777
1134
  args.directories_only = False
778
- args.apps = False
779
- elif choice == "6":
780
- args.health_only = False
781
- args.directories_only = True
782
1135
  args.apps = True
783
- elif choice == "7":
1136
+ args.big_files = False
1137
+ elif choice == "7": # 仅显示大文件分析
784
1138
  args.health_only = False
785
- args.directories_only = True
1139
+ args.directories_only = False
786
1140
  args.apps = False
787
1141
  args.big_files = True
788
- else:
789
- # 默认执行全部(用户不选择,或者选择1)
790
- args.apps = True
1142
+ else: # 默认执行全部(用户不选择,或者选择1)
1143
+ args.health_only = True
1144
+ args.directories_only = False
1145
+ args.big_files = False
1146
+ args.apps = True
1147
+
791
1148
 
792
1149
  # --home 优先设置路径
793
1150
  if getattr(args, 'home', False):
@@ -805,15 +1162,14 @@ def main():
805
1162
 
806
1163
  try:
807
1164
  # 显示系统信息
808
- if not args.directories_only:
809
- space_cli.print_system_info()
1165
+ space_cli.print_system_info()
810
1166
 
811
1167
  # 显示磁盘健康状态
812
- if not args.directories_only:
1168
+ if args.health_only:
813
1169
  space_cli.print_disk_health(args.path)
814
1170
 
815
1171
  # 显示目录分析
816
- if not args.health_only:
1172
+ if args.directories_only or args.path !='/':
817
1173
  space_cli.print_largest_directories(args.path, args.top_n)
818
1174
  # 若分析路径为当前用户目录,做深度分析
819
1175
  if os.path.abspath(args.path) == os.path.abspath(str(Path.home())):
@@ -824,7 +1180,8 @@ def main():
824
1180
  space_cli.print_app_analysis(args.top_n)
825
1181
 
826
1182
  # 大文件分析
827
- if getattr(args, 'big_files', False):
1183
+ #if getattr(args, 'big_files', False):
1184
+ if args.big_files:
828
1185
  space_cli.print_big_files(args.path, top_n=args.big_files_top, min_size_bytes=args.big_files_min_bytes)
829
1186
 
830
1187
  # 导出报告
@@ -1,6 +0,0 @@
1
- space_cli.py,sha256=cQR64qsQyjGkkOskkm3EH59dx-T2idfv_YYuI6VPk9A,32260
2
- myspace_cli-1.2.0.dist-info/METADATA,sha256=AKVi6Os8zDw6h8z_St0DAgaYy3PpCT3HUOzhCe9cUHY,8284
3
- myspace_cli-1.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
4
- myspace_cli-1.2.0.dist-info/entry_points.txt,sha256=sIVEmPf6W8aNa7KiqOwAI6JrsgHlQciO5xH2G29tKPQ,45
5
- myspace_cli-1.2.0.dist-info/top_level.txt,sha256=lnUfbQX9h-9ZjecMVCOWucS2kx69FwTndlrb48S20Sc,10
6
- myspace_cli-1.2.0.dist-info/RECORD,,