disk_scan
disk_scan
Scans a directory and returns the size of each immediate child entry (files and sub-folders), sorted largest first. Use when the user wants to find what is consuming disk space.
Metadata
Name
disk_scan
Updated
2 weeks ago
Source
Risk
Low
Requires consent
false
Affected scope
user
Code
/**
* mcp/skills/diskScan.ts — disk_scan skill
*
* Scans a directory and returns the size of every immediate child entry,
* sorted largest first. Helps Claude answer "what is using my disk space?"
*
* Platform strategy
* -----------------
* darwin `du -sk <dir>/*` — fast, OS-native, gives recursive dir sizes
* win32 PowerShell via -EncodedCommand — recursive Measure-Object per child
*
* Smoke test
* npx tsx -r dotenv/config mcp/skills/diskScan.ts [/optional/path]
*/
import * as fs from "fs/promises";
import * as os from "os";
import * as nodePath from "path";
import { z } from "zod";
import { loggedExec } from "./_shared/platform";
import { expandTilde } from "./_shared/expandTilde";
import { formatBytes } from "./_shared/formatBytes";
import { getDirSizeBytes } from "./_shared/dirSize";
// Re-export so existing test imports (`import { formatBytes } from "./diskScan"`)
// continue to resolve. No production consumer imports formatBytes from this
// module — kept purely for test back-compat.
export { formatBytes };
// -- Meta ---------------------------------------------------------------------
export const meta = {
name: "disk_scan",
description:
"Scans a directory and returns the size of each immediate child entry " +
"(files and sub-folders), sorted largest first. " +
"Use when the user wants to find what is consuming disk space.",
riskLevel: "low",
destructive: false,
requiresConsent: false,
supportsDryRun: false,
affectedScope: ["user"],
auditRequired: false,
tccCategories: ["FullDiskAccess"],
schema: {
path: z
.string()
.optional()
.describe(
"Absolute path of the directory to scan. " +
"Defaults to the user home directory.",
),
},
} as const;
interface Entry {
name: string;
path: string;
size: number;
sizeHuman: string;
type: "file" | "directory";
}
// -- PowerShell helper --------------------------------------------------------
export async function runPS(script: string, tag = "ps"): Promise<string> {
const encoded = Buffer.from(script, "utf16le").toString("base64");
const { stdout } = await loggedExec(
`powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encoded}`,
{ tag: `disk_scan:${tag}`, maxBuffer: 20 * 1024 * 1024, timeoutMs: 60_000 },
);
return stdout.trim();
}
// -- darwin implementation ----------------------------------------------------
/** Fallback: stat immediate children (used when du output is empty). */
async function statChildren(scanPath: string): Promise<Entry[]> {
const dirents = await fs.readdir(scanPath, { withFileTypes: true });
const settled = await Promise.allSettled(
dirents.map(async (e) => {
const full = nodePath.join(scanPath, e.name);
const stat = await fs.stat(full);
return {
name: e.name,
path: full,
size: stat.size,
sizeHuman: formatBytes(stat.size),
type: (e.isDirectory() ? "directory" : "file") as Entry["type"],
};
}),
);
return settled
.filter((r): r is PromiseFulfilledResult<Entry> => r.status === "fulfilled")
.map((r) => r.value)
.sort((a, b) => b.size - a.size);
}
async function scanDarwin(scanPath: string): Promise<Entry[]> {
let stdout = "";
try {
const safePath = scanPath.replace(/'/g, `'\''`);
({ stdout } = await loggedExec(
`du -sk '${safePath}'/*`,
{ tag: "disk_scan:du", maxBuffer: 20 * 1024 * 1024, timeoutMs: 60_000 },
));
} catch (err) {
const e = err as { stdout?: string; killed?: boolean; signal?: string };
if (e.killed || e.signal) {
throw new Error(
`[disk_scan] du timed out after 60s scanning ${scanPath} — ` +
`partial results suppressed to avoid misleading the cleanup planner.`,
);
}
stdout = e.stdout ?? "";
}
if (!stdout.trim()) return statChildren(scanPath);
return stdout
.trim()
.split("\n")
.filter(Boolean)
.map((line) => {
const tab = line.indexOf("\t");
const kb = parseInt(line.slice(0, tab), 10);
const full = line.slice(tab + 1).trim();
const size = kb * 1024; // du -k reports 1024-byte blocks
return {
name: nodePath.basename(full),
path: full,
size,
sizeHuman: formatBytes(size),
type: "directory" as Entry["type"],
};
})
.sort((a, b) => b.size - a.size);
}
// -- win32 implementation -----------------------------------------------------
async function scanWin32(scanPath: string, deadlineMs: number): Promise<Entry[]> {
const dirents = await fs.readdir(scanPath, { withFileTypes: true });
const results = await Promise.all(
dirents.map(async (e): Promise<Entry> => {
const full = nodePath.join(scanPath, e.name);
if (e.isDirectory()) {
const { sizeBytes } = await getDirSizeBytes(full, deadlineMs);
return {
name: e.name, path: full, size: sizeBytes,
sizeHuman: formatBytes(sizeBytes), type: "directory",
};
}
const stat = await fs.stat(full).catch(() => null);
const size = stat?.size ?? 0;
return {
name: e.name, path: full, size,
sizeHuman: formatBytes(size), type: "file",
};
}),
);
return results.sort((a, b) => b.size - a.size);
}
// -- Exported run function ----------------------------------------------------
interface RunCtx { deadlineMs?: number }
export async function run(
{ path: inputPath = os.homedir() }: { path?: string },
ctx?: RunCtx,
) {
const ceilingMs = ctx?.deadlineMs ?? (Date.now() + 60_000);
const remainingMs = Math.max(0, ceilingMs - Date.now());
const deadlineMs = Date.now() + Math.floor(remainingMs * 0.9);
const effectivePath = inputPath || os.homedir();
const scanPath = nodePath.resolve(expandTilde(effectivePath) ?? effectivePath);
const home = os.homedir();
const rel = nodePath.relative(home, scanPath);
if (rel.startsWith("..") || nodePath.isAbsolute(rel)) {
throw new Error(
`[disk_scan] Path must be within home directory (${home}): ${scanPath}`,
);
}
try {
await fs.access(scanPath);
} catch {
throw new Error(`[disk_scan] Path not accessible: ${scanPath}`);
}
const platform = os.platform();
const entries = platform === "win32"
? await scanWin32(scanPath, deadlineMs)
: await scanDarwin(scanPath);
let warning: string | undefined;
try {
const expected = (await fs.readdir(scanPath))
.filter((n) => n !== ".DS_Store" && !n.startsWith("."));
if (expected.length > 0) {
const skipped = Math.max(0, expected.length - entries.length);
const ratio = skipped / expected.length;
if (ratio > 0.2) {
warning =
`Scan results are incomplete: ${skipped} of ${expected.length} children ` +
`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.`;
}
}
} catch {
// readdir itself failed — main scan likely already failed too; let the
// empty entries result speak for itself.
}
return {
scannedPath: scanPath,
platform,
entryCount: entries.length,
entries,
...(warning ? { warning } : {}),
};
}
// -- CLI smoke test -----------------------------------------------------------
if (require.main === module) {
run({ path: process.argv[2] })
.then((r) => {
console.log(`\nScanned: ${r.scannedPath} (${r.entryCount} entries)\n`);
r.entries.slice(0, 10).forEach((e) =>
console.log(
` ${e.sizeHuman.padStart(10)} ${e.type === "directory" ? "[DIR]" : "[FILE]"} ${e.name}`,
),
);
})
.catch((err: Error) => { console.error(err.message); process.exit(1); });
}
``