/**
* 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 { exec } from "child_process" ;
import { promisify } from "util" ;
import { z } from "zod" ;
const execAsync = promisify (exec);
// -- Meta ---------------------------------------------------------------------
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 ,
schema: {
appName: z
. string ()
. optional ()
. describe ( "App name to target (e.g. 'Slack', 'Chrome'). Omit to list available caches without deleting" ),
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
}
}),
);
return Math. round ((totalBytes / ( 1024 * 1024 )) * 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)
const matched = appName
? subdirs. filter (( d ) => d.name. toLowerCase (). includes (appName. toLowerCase ()))
: subdirs;
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 ( ! appName || dryRun || caches. length === 0 ) {
return { caches, totalSizeMb, deleted: false , freedMb: 0 };
}
// Only delete when appName is specified and dryRun is false
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 getDirSizeMbWin32 ( dirPath : string ) : Promise < number > {
try {
const encoded = Buffer. from (
`(Get-ChildItem -LiteralPath '${ dirPath . replace ( / ' / g , "''" ) }' -Recurse -File -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum` ,
"utf16le" ,
). toString ( "base64" );
const { stdout } = await execAsync (
`powershell.exe -NoProfile -NonInteractive -EncodedCommand ${ encoded }` ,
{ maxBuffer: 5 * 1024 * 1024 },
);
const bytes = parseFloat (stdout. trim ());
return isNaN (bytes) ? 0 : Math. round ((bytes / ( 1024 * 1024 )) * 100 ) / 100 ;
} catch {
return 0 ;
}
}
async function clearAppCacheWin32 (
appName : string | undefined ,
dryRun : boolean ,
) : 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 ;
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 }) => ({
name,
path: fullPath,
sizeMb: await getDirSizeMbWin32 (fullPath),
})),
);
caches. sort (( a , b ) => b.sizeMb - a.sizeMb);
const totalSizeMb = Math. round (caches. reduce (( s , c ) => s + c.sizeMb, 0 ) * 100 ) / 100 ;
if ( ! appName || dryRun || caches. length === 0 ) {
return { caches, totalSizeMb, deleted: false , freedMb: 0 };
}
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 ----------------------------------------------------
export async function run ({
appName ,
dryRun = true ,
} : {
appName ?: string ;
dryRun ?: boolean ;
} = {}) {
const platform = os. platform ();
const result = platform === "win32"
? await clearAppCacheWin32 (appName, dryRun)
: 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 ); });
}