myspace-cli 1.1.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.
@@ -0,0 +1,284 @@
1
+ Metadata-Version: 2.4
2
+ Name: myspace-cli
3
+ Version: 1.1.0
4
+ Summary: A macOS disk space analysis CLI: health, index, app usage, big files.
5
+ Author-email: Your Name <you@example.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/kennyz/space-cli
8
+ Project-URL: Issues, https://github.com/kennyz/space-cli/issues
9
+ Keywords: disk,space,cleanup,macos,cli
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: MacOS
13
+ Classifier: Environment :: Console
14
+ Classifier: Topic :: System :: Filesystems
15
+ Requires-Python: >=3.8
16
+ Description-Content-Type: text/markdown
17
+ Provides-Extra: mcp
18
+ Requires-Dist: mcp<2,>=1; extra == "mcp"
19
+
20
+ # space-cli - Mac OS 磁盘空间分析工具
21
+
22
+ 很多人的Mac电脑都会出现磁盘空间不够用,付费软件太贵或者难以使用。
23
+
24
+ space-cli是一个开源的Mac OS命令行小工具,用于分析磁盘空间健康度并找出占用空间最大的目录。
25
+
26
+ 本软件采用**最严安全原则**,所有操作采用只读模式,不会尝试改写和破坏用户电脑的任何数据,也不会上传任何数据到外网,严格保护用户的隐私。
27
+
28
+ ## 功能特性
29
+
30
+ - 🔍 **磁盘健康度检测** - 评估磁盘空间使用情况,提供健康状态建议
31
+ - 📊 **目录大小分析** - 递归分析目录大小,找出占用空间最大的文件夹
32
+ - 💻 **系统信息显示** - 显示Mac系统版本和基本信息
33
+ - 📄 **报告导出** - 将分析结果导出为JSON格式报告
34
+ - ⚡ **高性能** - 优化的算法,快速分析大型文件系统
35
+ - 🎯 **灵活配置** - 支持自定义分析路径和显示数量
36
+ - 🗂️ **索引缓存** - 目录大小结果本地索引缓存(`~/.spacecli/index.json`),支持TTL与重建提示
37
+ - 🧩 **应用分析** - 汇总 `Applications`、`Library`、`Caches`、`Logs` 等路径估算应用占用,给出卸载建议
38
+ - 🏠 **用户目录深度分析** - 针对 `~/Library`、`~/Downloads`、`~/Documents` 分别下探并展示Top N目录
39
+ - 🗄️ **大文件分析** - 扫描并列出指定路径下最大的文件,支持数量和最小体积阈值
40
+ - ⏱️ **支持MCP调用** - 支持你自己的AI Agent无缝调用磁盘空间信息
41
+
42
+ ## 安装
43
+
44
+ ### 方法1:直接使用(推荐)
45
+
46
+ ```bash
47
+ # 克隆或下载项目
48
+ git clone https://github.com/kennyz/space-cli
49
+ cd MacDiskSpace
50
+
51
+ # 给脚本添加执行权限
52
+ chmod +x space_cli.py
53
+
54
+ # 运行
55
+ python3 space_cli.py
56
+ ```
57
+
58
+ ### 方法2:创建全局命令
59
+
60
+ ```bash
61
+ # 复制到系统路径
62
+ sudo cp space_cli.py /usr/local/bin/space-cli
63
+ sudo chmod +x /usr/local/bin/space-cli
64
+
65
+ # 现在可以在任何地方使用
66
+ space-cli
67
+ ```
68
+
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
+ ```
80
+
81
+ ## 使用方法
82
+
83
+ ### 基本用法
84
+
85
+ ```bash
86
+ # 分析根目录(默认)
87
+ python3 space_cli.py
88
+
89
+ # 分析指定路径
90
+ python3 space_cli.py -p /Users/username
91
+
92
+ # 显示前10个最大的目录
93
+ python3 space_cli.py -n 10
94
+
95
+ # 快捷分析当前用户目录(含用户目录深度分析)
96
+ python3 space_cli.py --home
97
+ ```
98
+
99
+ ### 高级用法
100
+
101
+ ```bash
102
+ # 只显示磁盘健康状态
103
+ python3 space_cli.py --health-only
104
+
105
+ # 只显示目录分析
106
+ python3 space_cli.py --directories-only
107
+
108
+ # 导出分析报告
109
+ python3 space_cli.py --export disk_report.json
110
+
111
+ # 分析用户目录并导出报告
112
+ python3 space_cli.py -p /Users -n 15 --export user_analysis.json
113
+
114
+ # 使用索引缓存(默认开启)
115
+ python3 space_cli.py --use-index
116
+
117
+ # 强制重建索引
118
+ python3 space_cli.py --reindex
119
+
120
+ # 设置索引缓存有效期为 6 小时
121
+ python3 space_cli.py --index-ttl 6
122
+
123
+ # 非交互,不提示使用缓存
124
+ python3 space_cli.py --no-prompt
125
+
126
+ # 分析应用目录占用并给出卸载建议(按应用归并)
127
+ python3 space_cli.py --apps -n 20
128
+
129
+ # 大文件分析(显示前20个,阈值2G)
130
+ python3 space_cli.py --big-files --big-files-top 20 --big-files-min 2G
131
+
132
+ # 将含大文件分析的结果写入导出报告
133
+ python3 space_cli.py --big-files --export report.json
134
+
135
+
136
+ ```
137
+
138
+
139
+ ### 命令行参数
140
+
141
+ | 参数 | 说明 | 默认值 |
142
+ |------|------|--------|
143
+ | `-p, --path` | 要分析的路径 | `/` |
144
+ | `-n, --top-n` | 显示前N个最大的目录 | `20` |
145
+ | `--health-only` | 只显示磁盘健康状态 | - |
146
+ | `--directories-only` | 只显示目录分析 | - |
147
+ | `--export FILE` | 导出报告到JSON文件 | - |
148
+ | `--use-index` | 使用索引缓存(默认) | - |
149
+ | `--no-index` | 禁用索引缓存 | - |
150
+ | `--reindex` | 强制重建索引 | - |
151
+ | `--index-ttl` | 索引缓存有效期(小时) | `24` |
152
+ | `--no-prompt` | 非交互模式,不提示使用缓存 | - |
153
+ | `--apps` | 分析应用目录空间与卸载建议 | - |
154
+ | `--home` | 将分析路径设置为当前用户目录 | - |
155
+ | `--big-files` | 启用大文件分析 | - |
156
+ | `--big-files-top` | 大文件列表数量 | `20` |
157
+ | `--big-files-min` | 大文件最小阈值(K/M/G/T) | `0` |
158
+ | `--version` | 显示版本信息 | - |
159
+ | `-h, --help` | 显示帮助信息 | - |
160
+
161
+ ## 输出示例
162
+
163
+ ### 磁盘健康状态
164
+ ```
165
+ ============================================================
166
+ 🔍 磁盘空间健康度分析
167
+ ============================================================
168
+ 磁盘路径: /
169
+ 总容量: 500.0 GB
170
+ 已使用: 400.0 GB
171
+ 可用空间: 100.0 GB
172
+ 使用率: 80.0%
173
+ 健康状态: ⚠️ 警告
174
+ 建议: 磁盘空间不足,建议清理一些文件
175
+ ```
176
+
177
+ ### 目录分析
178
+ ```
179
+ ============================================================
180
+ 📊 占用空间最大的目录
181
+ ============================================================
182
+ 显示前 20 个最大的目录:
183
+
184
+ 1. /Applications
185
+ 大小: 15.2 GB (3.04%)
186
+
187
+ 2. /Users/username/Library
188
+ 大小: 8.5 GB (1.70%)
189
+
190
+ 3. /System
191
+ 大小: 6.8 GB (1.36%)
192
+ ```
193
+
194
+ ### 大文件分析
195
+ ```
196
+ ============================================================
197
+ 🗄️ 大文件分析
198
+ ============================================================
199
+ 1. /Users/username/Downloads/big.iso -- 大小: 7.2 GB (1.44%)
200
+ 2. /Users/username/Movies/clip.mov -- 大小: 3.1 GB (0.62%)
201
+ ```
202
+
203
+
204
+ ## MCP Server(可选)
205
+
206
+ 本项目提供 MCP Server,方便在支持 MCP 的客户端中以“工具”的形式调用:
207
+
208
+ ### 安装依赖
209
+ ```bash
210
+ python3 -m pip install mcp
211
+ ```
212
+
213
+ ### 启动MCP服务
214
+ ```bash
215
+ python3 mcp_server.py
216
+ ```
217
+
218
+ ### MCP暴露的工具
219
+ - `disk_health(path="/")`
220
+ - `largest_directories(path="/", top_n=20, use_index=True, reindex=False, index_ttl=24)`
221
+ - `app_analysis(top_n=20, use_index=True, reindex=False, index_ttl=24)`
222
+ - `big_files(path="/", top_n=20, min_size="0")`
223
+
224
+ 以上工具与 CLI 输出保持一致的逻辑(索引缓存、阈值等),适合与 IDE/Agent 集成。
225
+
226
+
227
+ ## 性能优化
228
+
229
+ - 使用递归算法高效计算目录大小
230
+ - 跳过无法访问的系统文件和隐藏文件
231
+ - 支持中断操作(Ctrl+C)
232
+ - 内存优化的文件遍历
233
+ - 单行滚动进度避免输出刷屏
234
+
235
+ ## 故障排除
236
+
237
+ ### 权限问题
238
+ 如果遇到权限错误,可以尝试:
239
+ ```bash
240
+ # 使用sudo运行(谨慎使用)
241
+ sudo python3 space_cli.py
242
+
243
+ # 或者分析用户目录
244
+ python3 space_cli.py -p /Users/$(whoami)
245
+ ```
246
+
247
+ ### 性能问题
248
+ 对于大型文件系统,分析可能需要较长时间:
249
+ - 使用 `--directories-only` 跳过健康检查
250
+ - 减少 `-n` 参数值
251
+ - 分析特定子目录而不是根目录
252
+ - 使用 `--big-files-min` 提高阈值可减少扫描文件数量
253
+ - 使用 `--use-index`/`--reindex`/`--index-ttl` 控制索引的使用与刷新
254
+
255
+ ## 系统要求
256
+
257
+ - macOS 10.12 或更高版本
258
+ - Python 3.6 或更高版本
259
+ - 足够的磁盘空间用于临时文件
260
+
261
+ ## 许可证
262
+
263
+ MIT License
264
+
265
+ ## 贡献
266
+
267
+ 欢迎提交Issue和Pull Request来改进这个工具!
268
+
269
+ ## 更新日志
270
+
271
+ ### v1.0.0
272
+ - 初始版本发布
273
+ - 基本的磁盘健康度检测
274
+ - 目录大小分析功能
275
+ - JSON报告导出
276
+ - 命令行参数支持
277
+
278
+ ### v1.1.0
279
+ - 新增交互式菜单(无参数时出现),默认执行全部项目
280
+ - 新增 `--home` 用户目录快速分析与用户目录深度分析
281
+ - 新增应用分析缓存(`~/.cache/spacecli/apps.json`)
282
+ - 新增大文件分析 `--big-files`/`--big-files-top`/`--big-files-min`
283
+ - 导出报告在启用大文件分析时包含 `largest_files`
284
+ - 单行滚动进度显示
@@ -0,0 +1,6 @@
1
+ space_cli.py,sha256=cQR64qsQyjGkkOskkm3EH59dx-T2idfv_YYuI6VPk9A,32260
2
+ myspace_cli-1.1.0.dist-info/METADATA,sha256=FvdivopGYqHsG8WMMA-7KANDUkM1QRpxU6ZozoA92_k,8284
3
+ myspace_cli-1.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
4
+ myspace_cli-1.1.0.dist-info/entry_points.txt,sha256=sIVEmPf6W8aNa7KiqOwAI6JrsgHlQciO5xH2G29tKPQ,45
5
+ myspace_cli-1.1.0.dist-info/top_level.txt,sha256=lnUfbQX9h-9ZjecMVCOWucS2kx69FwTndlrb48S20Sc,10
6
+ myspace_cli-1.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ space-cli = space_cli:main
@@ -0,0 +1 @@
1
+ space_cli
space_cli.py ADDED
@@ -0,0 +1,847 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ SpaceCli - Mac OS 磁盘空间分析工具
5
+ 用于检测磁盘空间健康度并列出占用空间最大的目录
6
+ """
7
+
8
+ import os
9
+ import sys
10
+ import argparse
11
+ import subprocess
12
+ import shutil
13
+ from pathlib import Path
14
+ from typing import List, Tuple, Dict
15
+ import json
16
+ import time
17
+ from datetime import datetime, timedelta
18
+ import heapq
19
+
20
+
21
+ class IndexStore:
22
+ """简单的目录大小索引缓存管理器"""
23
+
24
+ def __init__(self, index_file: str = None):
25
+ home = str(Path.home())
26
+ cache_dir = os.path.join(home, ".spacecli")
27
+ os.makedirs(cache_dir, exist_ok=True)
28
+ self.index_file = index_file or os.path.join(cache_dir, "index.json")
29
+ self._data: Dict = {}
30
+ self._loaded = False
31
+
32
+ def load(self) -> None:
33
+ if self._loaded:
34
+ return
35
+ if os.path.exists(self.index_file):
36
+ try:
37
+ with open(self.index_file, "r", encoding="utf-8") as f:
38
+ self._data = json.load(f)
39
+ except Exception:
40
+ self._data = {}
41
+ self._loaded = True
42
+
43
+ def save(self) -> None:
44
+ try:
45
+ with open(self.index_file, "w", encoding="utf-8") as f:
46
+ json.dump(self._data, f, ensure_ascii=False, indent=2)
47
+ except Exception:
48
+ pass
49
+
50
+ def _key(self, root_path: str) -> str:
51
+ return os.path.abspath(root_path)
52
+
53
+ def get(self, root_path: str) -> Dict:
54
+ self.load()
55
+ return self._data.get(self._key(root_path))
56
+
57
+ def set(self, root_path: str, entries: List[Tuple[str, int]]) -> None:
58
+ self.load()
59
+ now_iso = datetime.utcnow().isoformat()
60
+ self._data[self._key(root_path)] = {
61
+ "updated_at": now_iso,
62
+ "entries": [{"path": p, "size": s} for p, s in entries],
63
+ }
64
+ self.save()
65
+
66
+ def is_fresh(self, root_path: str, ttl_hours: int) -> bool:
67
+ self.load()
68
+ rec = self._data.get(self._key(root_path))
69
+ if not rec:
70
+ return False
71
+ try:
72
+ updated_at = datetime.fromisoformat(rec.get("updated_at"))
73
+ return datetime.utcnow() - updated_at <= timedelta(hours=ttl_hours)
74
+ except Exception:
75
+ return False
76
+
77
+ # 命名缓存(非路径键),适合应用分析等聚合结果
78
+ def get_named(self, name: str) -> Dict:
79
+ self.load()
80
+ return self._data.get(name)
81
+
82
+ def set_named(self, name: str, entries: List[Tuple[str, int]]) -> None:
83
+ self.load()
84
+ now_iso = datetime.utcnow().isoformat()
85
+ self._data[name] = {
86
+ "updated_at": now_iso,
87
+ "entries": [{"name": p, "size": s} for p, s in entries],
88
+ }
89
+ self.save()
90
+
91
+ def is_fresh_named(self, name: str, ttl_hours: int) -> bool:
92
+ self.load()
93
+ rec = self._data.get(name)
94
+ if not rec:
95
+ return False
96
+ try:
97
+ updated_at = datetime.fromisoformat(rec.get("updated_at"))
98
+ return datetime.utcnow() - updated_at <= timedelta(hours=ttl_hours)
99
+ except Exception:
100
+ return False
101
+
102
+
103
+ class SpaceAnalyzer:
104
+ """磁盘空间分析器"""
105
+
106
+ def __init__(self):
107
+ self.warning_threshold = 80 # 警告阈值百分比
108
+ self.critical_threshold = 90 # 严重阈值百分比
109
+
110
+ def get_disk_usage(self, path: str = "/") -> Dict:
111
+ """获取磁盘使用情况"""
112
+ try:
113
+ statvfs = os.statvfs(path)
114
+
115
+ # 计算磁盘空间信息
116
+ total_bytes = statvfs.f_frsize * statvfs.f_blocks
117
+ free_bytes = statvfs.f_frsize * statvfs.f_bavail
118
+ used_bytes = total_bytes - free_bytes
119
+
120
+ # 计算百分比
121
+ usage_percent = (used_bytes / total_bytes) * 100
122
+
123
+ return {
124
+ 'total': total_bytes,
125
+ 'used': used_bytes,
126
+ 'free': free_bytes,
127
+ 'usage_percent': usage_percent,
128
+ 'path': path
129
+ }
130
+ except Exception as e:
131
+ print(f"错误:无法获取磁盘使用情况 - {e}")
132
+ return None
133
+
134
+ def get_disk_health_status(self, usage_info: Dict) -> Tuple[str, str]:
135
+ """评估磁盘健康状态"""
136
+ if not usage_info:
137
+ return "未知", "无法获取磁盘信息"
138
+
139
+ usage_percent = usage_info['usage_percent']
140
+
141
+ if usage_percent >= self.critical_threshold:
142
+ return "严重", "磁盘空间严重不足!请立即清理磁盘空间"
143
+ elif usage_percent >= self.warning_threshold:
144
+ return "警告", "磁盘空间不足,建议清理一些文件"
145
+ else:
146
+ return "良好", "磁盘空间充足"
147
+
148
+ def format_bytes(self, bytes_value: int) -> str:
149
+ """格式化字节数为人类可读格式"""
150
+ for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
151
+ if bytes_value < 1024.0:
152
+ return f"{bytes_value:.1f} {unit}"
153
+ bytes_value /= 1024.0
154
+ return f"{bytes_value:.1f} PB"
155
+
156
+ def get_directory_size(self, path: str) -> int:
157
+ """递归计算目录大小"""
158
+ total_size = 0
159
+ 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
+ # 跳过无法访问的目录
170
+ pass
171
+ return total_size
172
+
173
+ def analyze_largest_files(self, root_path: str = "/", top_n: int = 50,
174
+ min_size_bytes: int = 0) -> List[Tuple[str, int]]:
175
+ """扫描并返回体积最大的文件列表"""
176
+ print("正在扫描大文件,这可能需要一些时间...")
177
+ heap: List[Tuple[int, str]] = [] # 最小堆 (size, path)
178
+ scanned = 0
179
+ try:
180
+ for dirpath, dirnames, filenames in os.walk(root_path):
181
+ # 进度提示:单行覆盖当前目录
182
+ dirpath_display = dirpath[-80:] # 截取最后50个字符
183
+ if dirpath_display == "":
184
+ dirpath_display = dirpath
185
+ sys.stdout.write(f"\r-> 正在读取: \033[36m{dirpath_display}\033[0m")
186
+ sys.stdout.flush()
187
+ for filename in filenames:
188
+ filepath = os.path.join(dirpath, filename)
189
+ try:
190
+ size = os.path.getsize(filepath)
191
+ except (OSError, FileNotFoundError, PermissionError):
192
+ continue
193
+ if size < min_size_bytes:
194
+ continue
195
+ if len(heap) < top_n:
196
+ heapq.heappush(heap, (size, filepath))
197
+ else:
198
+ if size > heap[0][0]:
199
+ heapq.heapreplace(heap, (size, filepath))
200
+ scanned += 1
201
+ if scanned % 500 == 0:
202
+ dirpath_display = dirpath[-80:] # 截取最后50个字符
203
+ if dirpath_display == "":
204
+ dirpath_display = dirpath
205
+ # 间隔性进度输出(单行覆盖)
206
+ sys.stdout.write(f"\r-> 正在读取: \033[36m{dirpath_display}\033[0m 已扫描文件数: \033[32m{scanned}\033[0m")
207
+ sys.stdout.flush()
208
+ except KeyboardInterrupt:
209
+ print("\n用户中断扫描,返回当前结果...")
210
+ except Exception as e:
211
+ print(f"扫描时出错: {e}")
212
+ finally:
213
+ sys.stdout.write("\n")
214
+ sys.stdout.flush()
215
+ # 转换为按体积降序列表
216
+ result = sorted([(p, s) for s, p in heap], key=lambda x: x[1], reverse=False)
217
+ result.sort(key=lambda x: x[1])
218
+ result = sorted([(p, s) for s, p in heap], key=lambda x: x[1])
219
+ # 正确:按 size 降序
220
+ result = sorted([(p, s) for s, p in heap], key=lambda x: x[1], reverse=True)
221
+ # 以上为了避免编辑器误合并,最终以最后一行排序为准
222
+ return result
223
+
224
+ def analyze_largest_directories(self, root_path: str = "/", max_depth: int = 2, top_n: int = 20,
225
+ index: IndexStore = None, use_index: bool = True,
226
+ reindex: bool = False, index_ttl_hours: int = 24,
227
+ prompt: bool = True) -> List[Tuple[str, int]]:
228
+ """分析占用空间最大的目录(支持索引缓存)"""
229
+ # 索引命中
230
+ if use_index and index and not reindex and index.is_fresh(root_path, index_ttl_hours):
231
+ cached = index.get(root_path)
232
+ if cached and cached.get("entries"):
233
+ if prompt and sys.stdin.isatty():
234
+ try:
235
+ ans = input("检测到最近索引,是否使用缓存结果而不重新索引?[Y/n]: ").strip().lower()
236
+ if ans in ("", "y", "yes"):
237
+ return [(e["path"], int(e["size"])) for e in cached["entries"]][:top_n]
238
+ except EOFError:
239
+ pass
240
+ else:
241
+ return [(e["path"], int(e["size"])) for e in cached["entries"]][:top_n]
242
+
243
+ print("正在分析目录大小,这可能需要一些时间...")
244
+
245
+ directory_sizes = []
246
+
247
+ try:
248
+ # 获取根目录下的直接子目录
249
+ for item in os.listdir(root_path):
250
+ item_path = os.path.join(root_path, item)
251
+
252
+ # 跳过隐藏文件和系统文件
253
+ if item.startswith('.') and item not in ['.Trash', '.localized']:
254
+ continue
255
+
256
+ if os.path.isdir(item_path):
257
+ try:
258
+ # 进度提示:当前正在读取的目录(单行覆盖)
259
+ sys.stdout.write(f"\r-> 正在读取: \033[36m{item_path}\033[0m")
260
+ sys.stdout.flush()
261
+ size = self.get_directory_size(item_path)
262
+ directory_sizes.append((item_path, size))
263
+ #print(f"已分析: {item_path} ({self.format_bytes(size)})")
264
+ print(f" ({self.format_bytes(size)})\033[0m")
265
+ except (OSError, PermissionError):
266
+ print(f"跳过无法访问的目录: {item_path}")
267
+ continue
268
+ # 结束时换行,避免后续输出粘连在同一行
269
+ sys.stdout.write("\n")
270
+ sys.stdout.flush()
271
+
272
+ # 按大小排序
273
+ directory_sizes.sort(key=lambda x: x[1], reverse=True)
274
+ # 写入索引
275
+ if index:
276
+ try:
277
+ index.set(root_path, directory_sizes)
278
+ except Exception:
279
+ pass
280
+ return directory_sizes[:top_n]
281
+
282
+ except Exception as e:
283
+ print(f"分析目录时出错: {e}")
284
+ return []
285
+
286
+ def get_system_info(self) -> Dict:
287
+ """获取系统信息"""
288
+ try:
289
+ # 获取系统版本
290
+ result = subprocess.run(['sw_vers'], capture_output=True, text=True)
291
+ system_info = {}
292
+ for line in result.stdout.split('\n'):
293
+ if ':' in line:
294
+ key, value = line.split(':', 1)
295
+ system_info[key.strip()] = value.strip()
296
+
297
+ return system_info
298
+ except Exception:
299
+ return {"ProductName": "macOS", "ProductVersion": "未知"}
300
+
301
+
302
+ class SpaceCli:
303
+ """SpaceCli 主类"""
304
+
305
+ def __init__(self):
306
+ self.analyzer = SpaceAnalyzer()
307
+ self.index = IndexStore()
308
+ # 应用分析缓存存放于 ~/.cache/spacecli/apps.json
309
+ home = str(Path.home())
310
+ app_cache_dir = os.path.join(home, ".cache", "spacecli")
311
+ os.makedirs(app_cache_dir, exist_ok=True)
312
+ self.app_index = IndexStore(index_file=os.path.join(app_cache_dir, "apps.json"))
313
+
314
+ def analyze_app_directories(self, top_n: int = 20,
315
+ index: IndexStore = None,
316
+ use_index: bool = True,
317
+ reindex: bool = False,
318
+ index_ttl_hours: int = 24,
319
+ prompt: bool = True) -> List[Tuple[str, int]]:
320
+ """分析应用安装与数据目录占用,按应用归并估算大小(支持缓存)"""
321
+
322
+ # 命中命名缓存
323
+ cache_name = "apps_aggregate"
324
+ if use_index and index and not reindex and index.is_fresh_named(cache_name, index_ttl_hours):
325
+ cached = index.get_named(cache_name)
326
+ if cached and cached.get("entries"):
327
+ if prompt and sys.stdin.isatty():
328
+ try:
329
+ ans = input("检测到最近应用分析索引,是否使用缓存结果?[Y/n]: ").strip().lower()
330
+ if ans in ("", "y", "yes"):
331
+ return [(e["name"], int(e["size"])) for e in cached["entries"]][:top_n]
332
+ except EOFError:
333
+ pass
334
+ else:
335
+ return [(e["name"], int(e["size"])) for e in cached["entries"]][:top_n]
336
+ # 关注目录
337
+ home = str(Path.home())
338
+ target_dirs = [
339
+ "/Applications",
340
+ os.path.join(home, "Applications"),
341
+ "/Library/Application Support",
342
+ "/Library/Caches",
343
+ "/Library/Logs",
344
+ os.path.join(home, "Library", "Application Support"),
345
+ os.path.join(home, "Library", "Caches"),
346
+ os.path.join(home, "Library", "Logs"),
347
+ os.path.join(home, "Library", "Containers"),
348
+ ]
349
+
350
+ def app_key_from_path(p: str) -> str:
351
+ # 优先用.app 名称,其次用顶级目录名
352
+ parts = Path(p).parts
353
+ for i in range(len(parts)-1, -1, -1):
354
+ if parts[i].endswith('.app'):
355
+ return parts[i].replace('.app', '')
356
+ # 否则返回倒数第二级或最后一级作为应用键
357
+ return parts[-1] if parts else p
358
+
359
+ app_size_map: Dict[str, int] = {}
360
+ scanned_dirs: List[str] = []
361
+
362
+ for base in target_dirs:
363
+ if not os.path.exists(base):
364
+ continue
365
+ try:
366
+ for item in os.listdir(base):
367
+ item_path = os.path.join(base, item)
368
+ if not os.path.isdir(item_path):
369
+ continue
370
+ key = app_key_from_path(item_path)
371
+ # 进度提示:当前应用相关目录(单行覆盖)
372
+ sys.stdout.write(f"\r-> 正在读取: {item_path}")
373
+ sys.stdout.flush()
374
+ size = self.analyzer.get_directory_size(item_path)
375
+ scanned_dirs.append(item_path)
376
+ app_size_map[key] = app_size_map.get(key, 0) + size
377
+ except (PermissionError, OSError):
378
+ continue
379
+ # 结束时换行
380
+ sys.stdout.write("\n")
381
+ sys.stdout.flush()
382
+
383
+ # 转为排序列表
384
+ result = sorted(app_size_map.items(), key=lambda x: x[1], reverse=True)
385
+ # 写入命名缓存
386
+ if index:
387
+ try:
388
+ index.set_named(cache_name, result)
389
+ except Exception:
390
+ pass
391
+ return result[:top_n]
392
+
393
+ def print_disk_health(self, path: str = "/"):
394
+ """打印磁盘健康状态"""
395
+ print("=" * 60)
396
+ print("🔍 磁盘空间健康度分析")
397
+ print("=" * 60)
398
+
399
+ usage_info = self.analyzer.get_disk_usage(path)
400
+ if not usage_info:
401
+ print("❌ 无法获取磁盘使用情况")
402
+ return
403
+
404
+ status, message = self.analyzer.get_disk_health_status(usage_info)
405
+
406
+ # 状态图标
407
+ status_icon = {
408
+ "良好": "✅",
409
+ "警告": "⚠️",
410
+ "严重": "🚨"
411
+ }.get(status, "❓")
412
+
413
+ print(f"磁盘路径: \033[36m{usage_info['path']}\033[0m")
414
+ print(f"总容量: \033[36m{self.analyzer.format_bytes(usage_info['total'])}\033[0m")
415
+ print(f"已使用: \033[36m{self.analyzer.format_bytes(usage_info['used'])}\033[0m")
416
+ print(f"可用空间: \033[36m{self.analyzer.format_bytes(usage_info['free'])}\033[0m")
417
+ print(f"使用率: \033[36m{usage_info['usage_percent']:.1f}%\033[0m")
418
+ print(f"健康状态: {status_icon} \033[36m{status}\033[0m")
419
+ print(f"建议: \033[36m{message}\033[0m")
420
+ print()
421
+
422
+ def print_largest_directories(self, path: str = "/", top_n: int = 20):
423
+ """打印占用空间最大的目录"""
424
+ print("=" * 60)
425
+ print("📊 占用空间最大的目录")
426
+ print("=" * 60)
427
+
428
+ directories = self.analyzer.analyze_largest_directories(
429
+ path,
430
+ top_n=top_n,
431
+ index=self.index,
432
+ use_index=self.args.use_index,
433
+ reindex=self.args.reindex,
434
+ index_ttl_hours=self.args.index_ttl,
435
+ prompt=not self.args.no_prompt,
436
+ )
437
+
438
+ if not directories:
439
+ print("❌ 无法分析目录大小")
440
+ return
441
+
442
+ print(f"显示前 {min(len(directories), top_n)} 个最大的目录:\n")
443
+
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()
453
+
454
+ def print_app_analysis(self, top_n: int = 20):
455
+ """打印应用目录占用分析,并给出卸载建议"""
456
+ print("=" * 60)
457
+ print("🧩 应用目录空间分析与卸载建议")
458
+ print("=" * 60)
459
+
460
+ apps = self.analyze_app_directories(
461
+ top_n=top_n,
462
+ index=self.app_index,
463
+ use_index=self.args.use_index,
464
+ reindex=self.args.reindex,
465
+ index_ttl_hours=self.args.index_ttl,
466
+ prompt=not self.args.no_prompt,
467
+ )
468
+ if not apps:
469
+ print("❌ 未发现可分析的应用目录")
470
+ return
471
+
472
+ total = self.analyzer.get_disk_usage("/")
473
+ 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()
483
+
484
+ def print_home_deep_analysis(self, top_n: int = 20):
485
+ """对用户目录的 Library / Downloads / Documents 分别下探分析"""
486
+ home = str(Path.home())
487
+ targets = [
488
+ ("Library", os.path.join(home, "Library")),
489
+ ("Downloads", os.path.join(home, "Downloads")),
490
+ ("Documents", os.path.join(home, "Documents")),
491
+ ]
492
+
493
+ for label, target in targets:
494
+ if not os.path.exists(target):
495
+ continue
496
+ print("=" * 60)
497
+ print(f"🏠 用户目录下探 - {label}")
498
+ print("=" * 60)
499
+ directories = self.analyzer.analyze_largest_directories(
500
+ target,
501
+ top_n=top_n,
502
+ index=self.index,
503
+ use_index=self.args.use_index,
504
+ reindex=self.args.reindex,
505
+ index_ttl_hours=self.args.index_ttl,
506
+ prompt=not self.args.no_prompt,
507
+ )
508
+ if not directories:
509
+ print("❌ 无法分析目录大小")
510
+ continue
511
+ total_info = self.analyzer.get_disk_usage("/")
512
+ total_bytes = total_info['total'] if total_info else 1
513
+ print(f"显示前 {min(len(directories), top_n)} 个最大的目录:\n")
514
+ for i, (dir_path, size) in enumerate(directories, 1):
515
+ size_str = self.analyzer.format_bytes(size)
516
+ percentage = (size / total_bytes) * 100
517
+ color = "\033[31m" if size >= 1024**3 else "\033[32m"
518
+ print(f"{i:2d}. \033[36m{dir_path}\033[0m -- 大小: {color}{size_str}\033[0m (\033[33m{percentage:.2f}%\033[0m)")
519
+ #print()
520
+
521
+ def print_big_files(self, path: str, top_n: int = 50, min_size_bytes: int = 0):
522
+ """打印大文件列表"""
523
+ print("=" * 60)
524
+ print("🗄️ 大文件分析")
525
+ print("=" * 60)
526
+ files = self.analyzer.analyze_largest_files(path, top_n=top_n, min_size_bytes=min_size_bytes)
527
+ if not files:
528
+ print("❌ 未找到符合条件的大文件")
529
+ return
530
+ total = self.analyzer.get_disk_usage("/")
531
+ disk_total = total['total'] if total else 1
532
+ for i, (file_path, size) in enumerate(files, 1):
533
+ size_str = self.analyzer.format_bytes(size)
534
+ pct = (size / disk_total) * 100
535
+ color = "\033[31m" if size >= 5 * 1024**3 else ("\033[33m" if size >= 1024**3 else "\033[32m")
536
+ print(f"{i:2d}. \033[36m{file_path}\033[0m -- 大小: {color}{size_str}\033[0m (\033[33m{pct:.2f}%\033[0m)")
537
+ print()
538
+
539
+ def print_system_info(self):
540
+ """打印系统信息"""
541
+ print("=" * 60)
542
+ print("💻 系统信息")
543
+ print("=" * 60)
544
+
545
+ system_info = self.analyzer.get_system_info()
546
+
547
+ for key, value in system_info.items():
548
+ print(f"{key}: {value}")
549
+ print()
550
+
551
+ def export_report(self, output_file: str, path: str = "/"):
552
+ """导出分析报告到JSON文件"""
553
+ print(f"正在生成报告并保存到: {output_file}")
554
+
555
+ usage_info = self.analyzer.get_disk_usage(path)
556
+ status, message = self.analyzer.get_disk_health_status(usage_info)
557
+ directories = self.analyzer.analyze_largest_directories(path)
558
+ system_info = self.analyzer.get_system_info()
559
+ # 可选:大文件分析
560
+ largest_files = []
561
+ try:
562
+ if getattr(self, 'args', None) and getattr(self.args, 'big_files', False):
563
+ files = self.analyzer.analyze_largest_files(
564
+ path,
565
+ top_n=getattr(self.args, 'big_files_top', 20),
566
+ min_size_bytes=getattr(self.args, 'big_files_min_bytes', 0),
567
+ )
568
+ largest_files = [
569
+ {
570
+ "path": file_path,
571
+ "size_bytes": size,
572
+ "size_formatted": self.analyzer.format_bytes(size),
573
+ }
574
+ for file_path, size in files
575
+ ]
576
+ except Exception:
577
+ largest_files = []
578
+
579
+ report = {
580
+ "timestamp": subprocess.run(['date'], capture_output=True, text=True).stdout.strip(),
581
+ "system_info": system_info,
582
+ "disk_usage": usage_info,
583
+ "health_status": {
584
+ "status": status,
585
+ "message": message
586
+ },
587
+ "largest_directories": [
588
+ {
589
+ "path": dir_path,
590
+ "size_bytes": size,
591
+ "size_formatted": self.analyzer.format_bytes(size)
592
+ }
593
+ for dir_path, size in directories
594
+ ],
595
+ "largest_files": largest_files
596
+ }
597
+
598
+ try:
599
+ with open(output_file, 'w', encoding='utf-8') as f:
600
+ json.dump(report, f, ensure_ascii=False, indent=2)
601
+ print(f"✅ 报告已保存到: {output_file}")
602
+ except Exception as e:
603
+ print(f"❌ 保存报告失败: {e}")
604
+
605
+
606
+ def main():
607
+ """主函数"""
608
+ parser = argparse.ArgumentParser(
609
+ description="SpaceCli - Mac OS 磁盘空间分析工具",
610
+ formatter_class=argparse.RawDescriptionHelpFormatter,
611
+ epilog="""
612
+ 示例用法:
613
+ python space_cli.py # 分析根目录
614
+ python space_cli.py -p /Users # 分析用户目录
615
+ python space_cli.py -n 10 # 显示前10个最大目录
616
+ python space_cli.py --export report.json # 导出报告
617
+ python space_cli.py --health-only # 只显示健康状态
618
+ """
619
+ )
620
+
621
+ parser.add_argument(
622
+ '-p', '--path',
623
+ default='/',
624
+ help='要分析的路径 (默认: /)'
625
+ )
626
+
627
+ # 快捷:分析当前用户目录
628
+ parser.add_argument(
629
+ '--home',
630
+ action='store_true',
631
+ help='将分析路径设置为当前用户目录($HOME)'
632
+ )
633
+
634
+ parser.add_argument(
635
+ '-n', '--top-n',
636
+ type=int,
637
+ default=20,
638
+ help='显示前N个最大的目录 (默认: 20)'
639
+ )
640
+
641
+ parser.add_argument(
642
+ '--health-only',
643
+ action='store_true',
644
+ help='只显示磁盘健康状态'
645
+ )
646
+
647
+ parser.add_argument(
648
+ '--directories-only',
649
+ action='store_true',
650
+ help='只显示目录分析'
651
+ )
652
+
653
+ # 索引相关
654
+ parser.add_argument(
655
+ '--use-index',
656
+ dest='use_index',
657
+ action='store_true',
658
+ help='使用已存在的索引缓存(若存在)'
659
+ )
660
+ parser.add_argument(
661
+ '--no-index',
662
+ dest='use_index',
663
+ action='store_false',
664
+ help='不使用索引缓存'
665
+ )
666
+ parser.set_defaults(use_index=True)
667
+ parser.add_argument(
668
+ '--reindex',
669
+ action='store_true',
670
+ help='强制重建索引'
671
+ )
672
+ parser.add_argument(
673
+ '--index-ttl',
674
+ type=int,
675
+ default=24,
676
+ help='索引缓存有效期(小时),默认24小时'
677
+ )
678
+ parser.add_argument(
679
+ '--no-prompt',
680
+ action='store_true',
681
+ help='非交互模式:不提示是否使用缓存'
682
+ )
683
+
684
+ # 应用分析
685
+ parser.add_argument(
686
+ '--apps',
687
+ action='store_true',
688
+ help='显示应用目录空间分析与卸载建议'
689
+ )
690
+
691
+ # 大文件分析
692
+ parser.add_argument(
693
+ '--big-files',
694
+ action='store_true',
695
+ help='显示大文件分析结果'
696
+ )
697
+ parser.add_argument(
698
+ '--big-files-top',
699
+ type=int,
700
+ default=20,
701
+ help='大文件分析显示前N个(默认20)'
702
+ )
703
+ parser.add_argument(
704
+ '--big-files-min',
705
+ type=str,
706
+ default='0',
707
+ help='只显示大于该阈值的文件,支持K/M/G/T,如 500M、2G,默认0'
708
+ )
709
+
710
+ parser.add_argument(
711
+ '--export',
712
+ metavar='FILE',
713
+ help='导出分析报告到JSON文件'
714
+ )
715
+
716
+ parser.add_argument(
717
+ '--version',
718
+ action='version',
719
+ version='SpaceCli 1.0.0'
720
+ )
721
+
722
+ args = parser.parse_args()
723
+
724
+ # 解析 --big-files-min 阈值字符串到字节
725
+ def parse_size(s: str) -> int:
726
+ s = (s or '0').strip().upper()
727
+ if s.endswith('K'):
728
+ return int(float(s[:-1]) * 1024)
729
+ if s.endswith('M'):
730
+ return int(float(s[:-1]) * 1024**2)
731
+ if s.endswith('G'):
732
+ return int(float(s[:-1]) * 1024**3)
733
+ if s.endswith('T'):
734
+ return int(float(s[:-1]) * 1024**4)
735
+ try:
736
+ return int(float(s))
737
+ except ValueError:
738
+ return 0
739
+ args.big_files_min_bytes = parse_size(getattr(args, 'big_files_min', '0'))
740
+
741
+ # 交互式菜单:当未传入任何参数时触发(默认执行全部分析)
742
+ if len(sys.argv) == 1:
743
+ print("=" * 60)
744
+ print("🧭 SpaceCli 菜单(直接回车 = 执行全部项目)")
745
+ print("=" * 60)
746
+ home_path = str(Path.home())
747
+ print("1) \033[36m执行全部项目(系统信息 + 健康 + 目录 + 应用)\033[0m")
748
+ print(f"2) \033[36m当前用户目录分析(路径: {home_path})\033[0m")
749
+ print("3) \033[36m仅显示系统信息\033[0m")
750
+ 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")
754
+ print("0) \033[36m退出\033[0m")
755
+ try:
756
+ choice = input("请选择 [回车=1]: ").strip()
757
+ except EOFError:
758
+ choice = ""
759
+
760
+ if choice == "0":
761
+ sys.exit(0)
762
+ elif choice == "2":
763
+ args.path = home_path
764
+ args.apps = False
765
+ args.health_only = False
766
+ args.directories_only = False
767
+ elif choice == "3":
768
+ args.health_only = True
769
+ args.directories_only = False
770
+ args.apps = False
771
+ elif choice == "4":
772
+ args.health_only = False
773
+ args.directories_only = True
774
+ args.apps = False
775
+ elif choice == "5":
776
+ args.health_only = False
777
+ args.directories_only = False
778
+ args.apps = False
779
+ elif choice == "6":
780
+ args.health_only = False
781
+ args.directories_only = True
782
+ args.apps = True
783
+ elif choice == "7":
784
+ args.health_only = False
785
+ args.directories_only = True
786
+ args.apps = False
787
+ args.big_files = True
788
+ else:
789
+ # 默认执行全部(用户不选择,或者选择1)
790
+ args.apps = True
791
+
792
+ # --home 优先设置路径
793
+ if getattr(args, 'home', False):
794
+ args.path = str(Path.home())
795
+
796
+ # 检查路径是否存在
797
+ if not os.path.exists(args.path):
798
+ print(f"❌ 错误: 路径 '{args.path}' 不存在")
799
+ sys.exit(1)
800
+
801
+ # 创建SpaceCli实例
802
+ space_cli = SpaceCli()
803
+ # 让 SpaceCli 实例可访问参数(用于索引与提示控制)
804
+ space_cli.args = args
805
+
806
+ try:
807
+ # 显示系统信息
808
+ if not args.directories_only:
809
+ space_cli.print_system_info()
810
+
811
+ # 显示磁盘健康状态
812
+ if not args.directories_only:
813
+ space_cli.print_disk_health(args.path)
814
+
815
+ # 显示目录分析
816
+ if not args.health_only:
817
+ space_cli.print_largest_directories(args.path, args.top_n)
818
+ # 若分析路径为当前用户目录,做深度分析
819
+ if os.path.abspath(args.path) == os.path.abspath(str(Path.home())):
820
+ space_cli.print_home_deep_analysis(args.top_n)
821
+
822
+ # 应用目录分析
823
+ if args.apps:
824
+ space_cli.print_app_analysis(args.top_n)
825
+
826
+ # 大文件分析
827
+ if getattr(args, 'big_files', False):
828
+ space_cli.print_big_files(args.path, top_n=args.big_files_top, min_size_bytes=args.big_files_min_bytes)
829
+
830
+ # 导出报告
831
+ if args.export:
832
+ space_cli.export_report(args.export, args.path)
833
+
834
+ print("=" * 60)
835
+ print("✅ 分析完成!")
836
+ print("=" * 60)
837
+
838
+ except KeyboardInterrupt:
839
+ print("\n\n❌ 用户中断操作")
840
+ sys.exit(1)
841
+ except Exception as e:
842
+ print(f"\n❌ 发生错误: {e}")
843
+ sys.exit(1)
844
+
845
+
846
+ if __name__ == "__main__":
847
+ main()