clear_app_cache
clear_app_cache
Clears application cache files from the system cache directory. Can target a specific app or clear all caches. Use to free disk space or resolve app performance issues caused by corrupt cache.
Metadata
Name
clear_app_cache
Updated
3 weeks ago
Source
Risk
Medium
Requires consent
false
Affected scope
user
Code
/**
* mcp/skills/clearAppCache.ts — clear_app_cache skill
*
* Clears application cache files from the system cache directory. Can target
* a specific app or list all caches. Defaults to dryRun=true for safety.
*
* Platform strategy
* -----------------
* darwin Scans ~/Library/Caches — subdirectories per application
* win32 Scans %LOCALAPPDATA% and %TEMP% for app cache folders
*
* Smoke test
* npx tsx -r dotenv/config mcp/skills/clearAppCache.ts [appName]
*/
import * as fs from "fs/promises";
import * as os from "os";
import * as nodePath from "path";
import { z } from "zod";
import {
DARWIN_BROWSER_CACHE_DIR_NAMES,
WIN32_BROWSER_VENDOR_DIR_NAMES,
isWin32BrowserVendorDir,
} from "./_shared/browserCaches";
import { DEV_CACHE_DIR_NAMES, isDevCacheDir } from "./_shared/devCaches";
import { getDirSizeBytes } from "./_shared/dirSize";
import type { Footprint } from "./_shared/footprint";
// -- Meta ---------------------------------------------------------------------
// Footprint excludes are derived from the SAME ownership sets the runtime
// filter uses, so the declared footprint can never drift from actual behavior.
const APP_CACHE_FOOTPRINT: Footprint = {
kind: "sweep",
darwin: {
roots: ["~/Library/Caches"],
excludes: [
...[...DARWIN_BROWSER_CACHE_DIR_NAMES].map((n) => `~/Library/Caches/${n}`),
...[...DEV_CACHE_DIR_NAMES].map((n) => `~/Library/Caches/${n}`),
],
},
win32: {
roots: ["%LOCALAPPDATA%", "%TEMP%"],
excludes: [
...[...WIN32_BROWSER_VENDOR_DIR_NAMES].map((n) => `%LOCALAPPDATA%/${n}`),
...[...DEV_CACHE_DIR_NAMES].map((n) => `%LOCALAPPDATA%/${n}`),
],
},
};
export const meta = {
name: "clear_app_cache",
description:
"Clears application cache files from the system cache directory. " +
"Can target a specific app or clear all caches. " +
"Use to free disk space or resolve app performance issues caused by corrupt cache.",
riskLevel: "medium",
destructive: false,
requiresConsent: false,
supportsDryRun: true,
affectedScope: ["user"],
auditRequired: true,
tccCategories: ["FullDiskAccess"],
footprint: APP_CACHE_FOOTPRINT,
schema: {
appName: z
.string()
.optional()
.describe("App name to target (e.g. 'Slack', 'Chrome'). Omit + dryRun:false to clear every app cache (used by the disk-cleanup present_preview flow)."),
dryRun: z
.boolean()
.optional()
.describe("If true, report what would be deleted without deleting. Default: true"),
},
} as const;
// -- Types --------------------------------------------------------------------
interface CacheEntry {
name: string;
path: string;
sizeMb: number;
}
// -- Helpers ------------------------------------------------------------------
async function getDirSizeMb(dirPath: string): Promise<number> {
try {
const entries = await fs.readdir(dirPath, { recursive: true, withFileTypes: true });
let totalBytes = 0;
await Promise.all(
entries
.filter((e) => !e.isDirectory())
.map(async (e) => {
try {
const fullPath = nodePath.join(e.parentPath ?? (e as unknown as { path: string }).path ?? dirPath, e.name);
const stat = await fs.stat(fullPath);
totalBytes += stat.size;
} catch {
// skip inaccessible files
}
}),
);
// SI/decimal MB to match _shared/formatBytes.ts + Finder.
return Math.round((totalBytes / 1_000_000) * 100) / 100;
} catch {
return 0;
}
}
/** Prevent path traversal — ensure path stays within allowedRoot. */
function isSafePath(target: string, allowedRoot: string): boolean {
const rel = nodePath.relative(allowedRoot, target);
return !rel.startsWith("..") && !nodePath.isAbsolute(rel);
}
// -- darwin implementation ----------------------------------------------------
async function clearAppCacheDarwin(
appName: string | undefined,
dryRun: boolean,
): Promise<{ caches: CacheEntry[]; totalSizeMb: number; deleted: boolean; freedMb: number }> {
const cacheRoot = nodePath.join(os.homedir(), "Library", "Caches");
let dirents: import("fs").Dirent[];
try {
dirents = await fs.readdir(cacheRoot, { withFileTypes: true });
} catch {
return { caches: [], totalSizeMb: 0, deleted: false, freedMb: 0 };
}
const subdirs = dirents.filter((d) => d.isDirectory());
// If appName provided, filter to matching subdirs (case-insensitive).
// In the bulk (no appName) disk-cleanup flow, exclude browser + dev cache
// dirs — those categories own them; clearing them here deletes Chrome / pip /
// Yarn before clear_browser_cache / clear_dev_cache run (and clears dev caches
// even when only "App caches" was selected). An explicit appName target still
// wins. See _shared/browserCaches.ts and _shared/devCaches.ts.
const matched = appName
? subdirs.filter((d) => d.name.toLowerCase().includes(appName.toLowerCase()))
: subdirs.filter((d) => !DARWIN_BROWSER_CACHE_DIR_NAMES.has(d.name) && !isDevCacheDir(d.name));
const caches: CacheEntry[] = await Promise.all(
matched.map(async (d) => {
const full = nodePath.join(cacheRoot, d.name);
const sizeMb = await getDirSizeMb(full);
return { name: d.name, path: full, sizeMb };
}),
);
caches.sort((a, b) => b.sizeMb - a.sizeMb);
const totalSizeMb = Math.round(caches.reduce((s, c) => s + c.sizeMb, 0) * 100) / 100;
if (dryRun || caches.length === 0) {
return { caches, totalSizeMb, deleted: false, freedMb: 0 };
}
// dryRun:false → delete every cache in `caches`. When appName is set,
// `caches` is already filtered to the matching subset; when appName is
// omitted it spans every subdir under ~/Library/Caches (used by the
// disk-cleanup present_preview flow, where the user already confirmed
// the bulk-clear via the consolidated category card).
let freedMb = 0;
for (const cache of caches) {
if (!isSafePath(cache.path, cacheRoot)) continue;
try {
await fs.rm(cache.path, { recursive: true, force: true });
freedMb += cache.sizeMb;
} catch {
// skip items we can't remove
}
}
return {
caches,
totalSizeMb,
deleted: true,
freedMb: Math.round(freedMb * 100) / 100,
};
}
// -- win32 implementation -----------------------------------------------------
async function clearAppCacheWin32(
appName: string | undefined,
dryRun: boolean,
deadlineMs: number,
): Promise<{ caches: CacheEntry[]; totalSizeMb: number; deleted: boolean; freedMb: number }> {
const localAppData = process.env["LOCALAPPDATA"] ?? nodePath.join(os.homedir(), "AppData", "Local");
const tempDir = process.env["TEMP"] ?? nodePath.join(os.homedir(), "AppData", "Local", "Temp");
const roots = [localAppData, tempDir];
const seen = new Set<string>();
const all: { name: string; fullPath: string; root: string }[] = [];
for (const root of roots) {
let dirents: import("fs").Dirent[];
try {
dirents = await fs.readdir(root, { withFileTypes: true });
} catch {
continue;
}
for (const d of dirents) {
if (!d.isDirectory() || seen.has(d.name.toLowerCase())) continue;
if (appName && !d.name.toLowerCase().includes(appName.toLowerCase())) continue;
// In the bulk (no appName) flow, NEVER recurse-delete a browser vendor
// dir (holds the full browser profile, not just cache) or a dev cache dir
// (pip/Yarn) — those are owned by clear_browser_cache / clear_dev_cache.
// An explicit appName target still wins.
// See _shared/browserCaches.ts and _shared/devCaches.ts.
if (!appName && (isWin32BrowserVendorDir(d.name) || isDevCacheDir(d.name))) continue;
seen.add(d.name.toLowerCase());
all.push({ name: d.name, fullPath: nodePath.join(root, d.name), root });
}
}
const caches: CacheEntry[] = await Promise.all(
all.map(async ({ name, fullPath }) => {
const { sizeBytes } = await getDirSizeBytes(fullPath, deadlineMs);
return { name, path: fullPath, sizeMb: Math.round((sizeBytes / 1_000_000) * 100) / 100 };
}),
);
caches.sort((a, b) => b.sizeMb - a.sizeMb);
const totalSizeMb = Math.round(caches.reduce((s, c) => s + c.sizeMb, 0) * 100) / 100;
if (dryRun || caches.length === 0) {
return { caches, totalSizeMb, deleted: false, freedMb: 0 };
}
// dryRun:false → delete every cache in `caches`. Same semantics as the
// darwin branch: appName-filtered when set, every cache subdir when omitted.
let freedMb = 0;
for (const cache of caches) {
const root = roots.find((r) => cache.path.startsWith(r));
if (!root || !isSafePath(cache.path, root)) continue;
try {
await fs.rm(cache.path, { recursive: true, force: true });
freedMb += cache.sizeMb;
} catch {
// skip items we can't remove
}
}
return {
caches,
totalSizeMb,
deleted: true,
freedMb: Math.round(freedMb * 100) / 100,
};
}
// -- Exported run function ----------------------------------------------------
interface RunCtx { deadlineMs?: number }
export async function run(
{
appName,
dryRun = true,
}: {
appName?: string;
dryRun?: boolean;
} = {},
ctx?: RunCtx,
) {
const ceilingMs = ctx?.deadlineMs ?? (Date.now() + 60_000);
const remainingMs = Math.max(0, ceilingMs - Date.now());
const internalDeadlineMs = Date.now() + Math.floor(remainingMs * 0.9);
const platform = os.platform();
const result = platform === "win32"
? await clearAppCacheWin32(appName, dryRun, internalDeadlineMs)
: await clearAppCacheDarwin(appName, dryRun);
return {
platform,
appName: appName ?? null,
dryRun,
...result,
};
}
// -- CLI smoke test -----------------------------------------------------------
if (false) {
run({ appName: process.argv[2], dryRun: true })
.then((r) => console.log(JSON.stringify(r, null, 2)))
.catch((err: Error) => { console.error(err.message); process.exit(1); });
}