get_large_files
get_large_files
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.
Metadata
Name
get_large_files
Updated
2 weeks ago
Source
Risk
Low
Requires consent
false
Affected scope
user
Code
/**
* 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 a " +
"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"],
timeoutMs: 180_000,
schema: {
path: z
.string()
.optional()
.describe(
"Absolute path of the directory to scan recursively. " +
"Defaults to the user home directory."
),
minSizeBytes: z
.number()
.int()
.positive()
.optional()
.describe("Only return files at least this large in bytes. Default: 104857600 (100 MB)."),
limit: z
.number()
.int()
.positive()
.optional()
.describe("Maximum number of files to return. Default: 20."),
},
} as const;
// -- Constants ----------------------------------------------------------------
const DEFAULT_MIN_BYTES = 100 * 1_000_000; // 100 MB
const DEFAULT_LIMIT = 20;
const MAX_DEPTH = 12;
// Directories unlikely to contain user-owned deletable files.
const SKIP_DIRS = new Set([
"node_modules", ".git", ".npm", ".yarn", ".cache",
"Library", "__pycache__", ".venv", "venv",
".Trash", ".Trashes",
"$Recycle.Bin", "System Volume Information", "Windows",
"Program Files", "Program Files (x86)",
...(process.platform === "win32" ? ["Packages", "D3DSCache"] : []),
]);
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) {
if (isPermissionError(err)) stats.dirsPermissionDenied++;
return;
} finally {
_readdirSem?.release();
}
await Promise.allSettled(
entries.map(async (e) => {
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;
} = {}) {
const limitWasSet = limit !== undefined;
const effectiveLimit = limit ?? DEFAULT_LIMIT;
const effectivePath = inputPath || os.homedir();
const scanPath = nodePath.resolve(expandTilde(effectivePath) ?? effectivePath);
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();
const realHome = stripWin32ExtendedPrefix(await fs.realpath(home).catch(() => home));
const rel = nodePath.relative(realHome, realScanPath);
if (rel.startsWith("..") || nodePath.isAbsolute(rel)) {
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);
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,
...(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); });
}