Repository navigation
Expand file tree
/
Copy pathgetLargeFiles.ts
More file actions
303 lines (270 loc) · 11.7 KB
/
Copy pathgetLargeFiles.ts
File metadata and controls
303 lines (270 loc) · 11.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
/**
* mcp/skills/getLargeFiles.ts — get_large_files skill
*
* Recursively walks a directory and returns files whose size exceeds a
* threshold, sorted largest first. Complements disk_scan by identifying
* specific files (not just folders) that are consuming space.
*
* Platform strategy
* -----------------
* Both Pure Node.js fs.readdir + fs.stat — cross-platform, no shell needed.
*
* Smoke test
* npx tsx -r dotenv/config mcp/skills/getLargeFiles.ts [/path] [minMB] [limit]
*/
import * as fs from "fs/promises";
import * as os from "os";
import * as nodePath from "path";
import { z } from "zod";
import { expandTilde } from "./_shared/expandTilde";
import { formatBytes } from "./_shared/formatBytes";
import { Semaphore } from "./_shared/semaphore";
const _statSem = process.platform === "win32" ? new Semaphore(32) : null;
// fs.realpath on Windows uses GetFinalPathNameByHandleW which can emit the
// \\?\ extended-length path prefix for paths involving reparse points or
// junction points. Strip it so scannedPath / files[].path are consistent
// with each other and compatible with downstream tools (delete_files etc.).
function stripWin32ExtendedPrefix(p: string): string {
return process.platform === "win32" && p.startsWith("\\\\?\\") ? p.slice(4) : p;
}
// On Windows, readdir calls are also dispatched to the libuv thread pool and
// compounded by Defender / OneDrive per-entry overhead. Without a cap the
// Promise.allSettled fan-out launches thousands of concurrent readdirs on a
// deep home tree — saturating the pool and causing 60 s+ timeouts. macOS is
// unaffected (fast VFS, no Defender) so the cap is Windows-only.
const _readdirSem = process.platform === "win32" ? new Semaphore(16) : null;
// -- Meta ---------------------------------------------------------------------
export const meta = {
name: "get_large_files",
description:
"Recursively scans a directory and returns files whose size exceeds the " +
"given threshold, sorted largest first. " +
"Use to identify specific files consuming disk space after disk_scan " +
"has narrowed down the target directory.",
riskLevel: "low",
destructive: false,
requiresConsent: false,
supportsDryRun: false,
affectedScope: ["user"],
auditRequired: false,
tccCategories: ["FullDiskAccess"],
// Full recursive walk + stat across the home directory. On Windows with
// Defender/OneDrive overhead this can take 40–60 s on a populated home;
// 180 s matches find_duplicate_files which does the same walk plus hashing.
timeoutMs: 180_000,
outputKeys: ["scannedPath","minSizeBytes","minSizeHuman","returned","files","totalFound","totalBytes","warning"],
schema: {
path: z
.string()
.nullable().optional()
.describe("Directory to scan recursively. Omit to scan home directory. Do NOT construct or guess a path."),
minSizeBytes: z
.number()
.int()
.positive()
.nullable().optional()
.describe("Only return files at least this large in bytes. Default: 104857600 (100 MB)."),
limit: z
.number()
.int()
.positive()
.nullable().optional()
.describe("Maximum number of files to return. Default: 20."),
},
} as const;
// -- Constants ----------------------------------------------------------------
const DEFAULT_MIN_BYTES = 100 * 1_000_000; // 100 MB (decimal/SI; matches formatBytes + Finder)
const DEFAULT_LIMIT = 20;
const MAX_DEPTH = 12; // prevent stack overflow on deep trees
// Directories unlikely to contain user-owned deletable files.
// Trash directories are excluded explicitly: get_trash_info handles them
// (disk-cleanup Step 8) and empty_trash is the destructive surface
// (disk-cleanup Step 11). Without this exclusion, large items in the
// Trash double-count under both the "large-files" and "trash" categories
// on the cleanup card, and a "large-files" deletion would race against
// the user's "trash" empty.
const SKIP_DIRS = new Set([
"node_modules", ".git", ".npm", ".yarn", ".cache",
"Library", "__pycache__", ".venv", "venv",
".Trash", ".Trashes", // macOS trash (user + per-volume)
"$Recycle.Bin", "System Volume Information", "Windows",
"Program Files", "Program Files (x86)",
// Windows-only: Store/UWP app sandboxes (134+ dirs, thousands of entries
// per app — Teams alone has ~3,800) and DirectX shader cache. Neither
// contains user-meaningful large files.
...(process.platform === "win32" ? ["Packages", "D3DSCache"] : []),
]);
// formatBytes is imported from _shared/formatBytes.
interface FileEntry {
path: string;
size: number;
sizeHuman: string;
modified: string; // ISO 8601
}
// -- Recursive walker ---------------------------------------------------------
interface WalkStats {
dirsVisited: number;
dirsPermissionDenied: number;
}
/** True if a Node fs error looks like a TCC / OS permission denial. */
function isPermissionError(err: unknown): boolean {
const code = (err as { code?: string })?.code;
return code === "EPERM" || code === "EACCES";
}
async function walk(
dir: string,
minSize: number,
acc: FileEntry[],
depth: number,
stats: WalkStats,
): Promise<void> {
if (depth > MAX_DEPTH) return;
stats.dirsVisited++;
if (_readdirSem) await _readdirSem.acquire();
let entries: import("fs").Dirent<string>[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch (err) {
// Track permission denials separately so the run() result can flag
// partial coverage. Non-permission errors (ENOENT, EBUSY, etc.) are
// ignored silently as before.
if (isPermissionError(err)) stats.dirsPermissionDenied++;
return;
} finally {
_readdirSem?.release();
}
await Promise.allSettled(
entries.map(async (e) => {
// Skip hidden entries at non-root depth (e.g. .git, .DS_Store)
if (e.name.startsWith(".") && depth > 0) return;
const full = nodePath.join(dir, e.name);
if (e.isDirectory()) {
if (SKIP_DIRS.has(e.name)) return;
await walk(full, minSize, acc, depth + 1, stats);
} else if (e.isFile()) {
if (_statSem) await _statSem.acquire();
try {
const stat = await fs.stat(full);
if (stat.size >= minSize) {
acc.push({
path: full,
size: stat.size,
sizeHuman: formatBytes(stat.size),
modified: stat.mtime.toISOString(),
});
}
} catch { /* unreadable file — skip */ }
finally { _statSem?.release(); }
}
}),
);
}
// -- Exported run function ----------------------------------------------------
export async function run({
path: inputPath = os.homedir(),
minSizeBytes = DEFAULT_MIN_BYTES,
limit,
}: {
path?: string;
minSizeBytes?: number;
limit?: number;
} = {}) {
// Caller-set vs default-applied distinction matters for the output shape:
// when the caller explicitly passes `limit`, they're asking for a bounded
// slice and the executor LLM downstream should NOT see aggregate fields
// (totalFound, totalBytes) that span the full match set — those mislead
// the cleanup-card substitution into showing "50 files" when only 5 are
// actionable. With no explicit limit, callers get the aggregates as
// before for back-compat with diagnostic-only consumers.
const limitWasSet = limit !== undefined;
const effectiveLimit = limit ?? DEFAULT_LIMIT;
// Expand ~ / ~/ — nodePath.resolve treats "~" as a literal segment
// relative to cwd, producing a non-existent path the executor LLM has
// to spot and retry around. See mcp/skills/_shared/expandTilde.ts.
const effectivePath = inputPath || os.homedir();
const scanPath = nodePath.resolve(expandTilde(effectivePath) ?? effectivePath);
// Security: restrict scanning to within the user home directory.
// Prevents Claude from being directed to scan /etc, /var, or other
// system paths that could leak sensitive file names to the LLM context.
//
// Symlink defence: resolve the real path BEFORE checking against home.
// Without this a symlink inside ~/ pointing to /etc would bypass the
// relative-path check — nodePath.relative would see it as a child of home
// but fs.readdir would walk the symlink target.
let realScanPath: string;
try {
realScanPath = stripWin32ExtendedPrefix(await fs.realpath(scanPath));
} catch {
throw new Error(`[get_large_files] Path not accessible: ${scanPath}`);
}
const home = os.homedir();
// Also resolve home so that macOS /var/folders symlinks are handled correctly
// (on macOS os.homedir() can return /Users/x while realpath gives the same).
const realHome = stripWin32ExtendedPrefix(await fs.realpath(home).catch(() => home));
const rel = nodePath.relative(realHome, realScanPath);
if (rel.startsWith("..") || nodePath.isAbsolute(rel)) {
const homeRel = nodePath.relative(realScanPath, realHome);
if (!homeRel.startsWith("..") && !nodePath.isAbsolute(homeRel)) {
realScanPath = realHome;
} else {
throw new Error(
`[get_large_files] Path must be within home directory`,
);
}
}
const results: FileEntry[] = [];
const stats: WalkStats = { dirsVisited: 0, dirsPermissionDenied: 0 };
await walk(realScanPath, minSizeBytes, results, 0, stats);
results.sort((a, b) => b.size - a.size);
const files = results.slice(0, effectiveLimit);
// ── Partial-result detection ────────────────────────────────────────────────
// If a meaningful share of directories couldn't be read because of OS
// permission errors, the file list is incomplete and the user shouldn't
// trust it for cleanup decisions. Almost always a TCC denial — the agent
// doesn't have Full Disk Access and can't traverse into protected
// subtrees (~/Library, etc.).
let warning: string | undefined;
if (
stats.dirsVisited > 0 &&
stats.dirsPermissionDenied / stats.dirsVisited > 0.2
) {
warning =
`Scan results are incomplete: ${stats.dirsPermissionDenied} of ` +
`${stats.dirsVisited} directories could not be read (likely missing ` +
`Full Disk Access). Open System Settings → Privacy & Security → ` +
`Full Disk Access, enable AI Support Agent, then quit and relaunch.`;
}
return {
scannedPath: scanPath,
minSizeBytes,
minSizeHuman: formatBytes(minSizeBytes),
returned: files.length,
files,
// Aggregates spanning the full match set (not the returned slice) are
// only included when the caller did NOT specify `limit`. With limit set,
// the caller already declared they want a bounded view; surfacing the
// wider counts misleads downstream substitution. See the run() header
// comment for the rationale.
...(limitWasSet ? {} : {
totalFound: results.length,
totalBytes: results.reduce((s, f) => s + f.size, 0),
}),
...(warning ? { warning } : {}),
};
}
// -- CLI smoke test -----------------------------------------------------------
if (require.main === module) {
const scanPath = process.argv[2] ?? os.homedir();
const minMB = parseInt(process.argv[3] ?? "100", 10);
const limit = parseInt(process.argv[4] ?? "20", 10);
const minSizeBytes = minMB * 1024 * 1024;
console.log(`\nScanning ${scanPath} for files >= ${minMB} MB (limit ${limit})...\n`);
run({ path: scanPath, minSizeBytes, limit })
.then((r) => {
console.log(`Returned ${r.returned} file(s) >= ${r.minSizeHuman}\n`);
r.files.forEach((f) =>
console.log(` ${f.sizeHuman.padStart(10)} ${f.path}`),
);
})
.catch((err: Error) => { console.error(err.message); process.exit(1); });
}