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