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

GitHub 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); });
}