Code

/**
 * mcp/skills/clearBrowserCache.ts — clear_browser_cache skill
 *
 * Clears cache files for installed browsers (Chrome, Safari, Firefox, Edge).
 * Can target a specific browser or all browsers. Use to free disk space or
 * resolve browser performance issues.
 *
 * Platform strategy
 * -----------------
 * darwin  ~/Library/Caches/{Google/Chrome,com.apple.Safari,Firefox,Microsoft Edge}
 * win32   %LOCALAPPDATA%\{Google\Chrome,Mozilla\Firefox,Microsoft\Edge}\...\Cache
 *
 * Smoke test
 *   npx tsx -r dotenv/config mcp/skills/clearBrowserCache.ts
 */
 
import * as fsp      from "fs/promises";
import * as os       from "os";
import * as nodePath from "path";
import { z }         from "zod";
 
// -- Meta ---------------------------------------------------------------------
 
export const meta = {
  name: "clear_browser_cache",
  description:
    "Clears cache files for installed browsers (Chrome, Safari, Firefox, Edge). " +
    "Can target a specific browser or all browsers. " +
    "Use to free disk space or resolve browser performance issues.",
  riskLevel:       "medium",
  destructive:     false,
  requiresConsent: false,
  supportsDryRun:  true,
  affectedScope:   ["user"],
  auditRequired:   true,
  schema: {
    browser: z
      .enum(["chrome", "safari", "firefox", "edge", "all"])
      .optional()
      .describe("Target browser. Default: all"),
    dryRun: z
      .boolean()
      .optional()
      .describe("If true, report sizes without deleting. Default: true"),
  },
} as const;
 
// -- Types --------------------------------------------------------------------
 
interface BrowserCacheResult {
  name:      string;
  cachePath: string;
  sizeMb:    number;
  cleared:   boolean;
  /**
   * Set when the clear operation reported success but post-clear size
   * confirms the data is still there — typically a TCC denial on a
   * protected cache directory. Includes actionable remediation text.
   */
  error?:    string;
}
 
// -- Helpers ------------------------------------------------------------------
 
async function getDirSizeMb(dirPath: string): Promise<number> {
  try {
    const entries = await fsp.readdir(dirPath, { recursive: true, withFileTypes: true });
    let totalBytes = 0;
    await Promise.allSettled(
      entries
        .filter((e) => !e.isDirectory())
        .map(async (e) => {
          try {
            const parentPath = (e as unknown as { parentPath?: string; path?: string }).parentPath
              ?? (e as unknown as { path?: string }).path
              ?? dirPath;
            const fullPath = nodePath.join(parentPath, e.name);
            const stat     = await fsp.stat(fullPath);
            totalBytes    += stat.size;
          } catch {
            // skip
          }
        }),
    );
    return Math.round((totalBytes / (1024 * 1024)) * 100) / 100;
  } catch {
    return 0;
  }
}
 
function isSafePath(target: string, allowedRoot: string): boolean {
  const rel = nodePath.relative(allowedRoot, target);
  return !rel.startsWith("..") && !nodePath.isAbsolute(rel);
}
 
async function processCachePath(
  name:      string,
  cachePath: string,
  dryRun:    boolean,
  safeRoot:  string,
): Promise<BrowserCacheResult> {
  // Check if path exists
  try {
    await fsp.access(cachePath);
  } catch {
    return { name, cachePath, sizeMb: 0, cleared: false };
  }
 
  const sizeMb = await getDirSizeMb(cachePath);
 
  if (dryRun || !isSafePath(cachePath, safeRoot)) {
    return { name, cachePath, sizeMb, cleared: false };
  }
 
  try {
    const entries = await fsp.readdir(cachePath);
    await Promise.allSettled(
      entries.map((entry) =>
        fsp.rm(nodePath.join(cachePath, entry), { recursive: true, force: true }),
      ),
    );
 
    // ── Silent-failure detection ──────────────────────────────────────────────
    // The rm loop reports success (Promise.allSettled never throws), but
    // individual removes may have been denied silently. Re-measure after
    // the clear: if size barely changed, the cache wasn't actually cleared
    // — almost always a TCC denial on a protected browser cache directory.
    const sizeAfterMb = await getDirSizeMb(cachePath);
    if (sizeMb > 0 && sizeAfterMb >= sizeMb * 0.9) {
      return {
        name, cachePath, sizeMb,
        cleared: false,
        error:
          `Could not clear ${name} cache (${sizeMb} MB still present after ` +
          `the operation). Full Disk Access is required to remove protected ` +
          `browser cache files. Open System Settings → Privacy & Security → ` +
          `Full Disk Access, enable AI Support Agent, then quit and relaunch.`,
      };
    }
 
    return { name, cachePath, sizeMb, cleared: true };
  } catch (err) {
    const code = (err as { code?: string }).code;
    if (code === "EPERM" || code === "EACCES") {
      return {
        name, cachePath, sizeMb,
        cleared: false,
        error:
          `Could not clear ${name} cache — macOS denied access. ` +
          `Open System Settings → Privacy & Security → Full Disk Access, ` +
          `enable AI Support Agent, then quit and relaunch.`,
      };
    }
    return { name, cachePath, sizeMb, cleared: false };
  }
}
 
// -- darwin implementation ----------------------------------------------------
 
async function clearBrowserCacheDarwin(
  browser: string,
  dryRun:  boolean,
): Promise<BrowserCacheResult[]> {
  const home      = os.homedir();
  const cacheRoot = nodePath.join(home, "Library", "Caches");
 
  const browserDefs: { key: string; name: string; path: string }[] = [
    {
      key:  "chrome",
      name: "Chrome",
      path: nodePath.join(cacheRoot, "Google", "Chrome"),
    },
    {
      key:  "safari",
      name: "Safari",
      path: nodePath.join(cacheRoot, "com.apple.Safari"),
    },
    {
      key:  "firefox",
      name: "Firefox",
      path: nodePath.join(cacheRoot, "Firefox"),
    },
    {
      key:  "edge",
      name: "Edge",
      path: nodePath.join(cacheRoot, "Microsoft Edge"),
    },
  ];
 
  const targets = browser === "all"
    ? browserDefs
    : browserDefs.filter((b) => b.key === browser);
 
  return Promise.all(
    targets.map((b) => processCachePath(b.name, b.path, dryRun, cacheRoot)),
  );
}
 
// -- win32 implementation -----------------------------------------------------
 
async function clearBrowserCacheWin32(
  browser: string,
  dryRun:  boolean,
): Promise<BrowserCacheResult[]> {
  const localAppData = process.env["LOCALAPPDATA"]
    ?? nodePath.join(os.homedir(), "AppData", "Local");
 
  const browserDefs: { key: string; name: string; path: string }[] = [
    {
      key:  "chrome",
      name: "Chrome",
      path: nodePath.join(localAppData, "Google", "Chrome", "User Data", "Default", "Cache"),
    },
    {
      key:  "firefox",
      name: "Firefox",
      path: nodePath.join(localAppData, "Mozilla", "Firefox", "Profiles"),
    },
    {
      key:  "edge",
      name: "Edge",
      path: nodePath.join(localAppData, "Microsoft", "Edge", "User Data", "Default", "Cache"),
    },
    // Safari is not available on Windows
  ];
 
  const targets = browser === "all" || browser === "safari"
    ? browserDefs.filter((b) => browser === "all" || b.key === browser)
    : browserDefs.filter((b) => b.key === browser);
 
  if (browser === "safari") {
    return [{ name: "Safari", cachePath: "N/A", sizeMb: 0, cleared: false }];
  }
 
  return Promise.all(
    targets.map((b) => processCachePath(b.name, b.path, dryRun, localAppData)),
  );
}
 
// -- Exported run function ----------------------------------------------------
 
export async function run({
  browser = "all",
  dryRun  = true,
}: {
  browser?: "chrome" | "safari" | "firefox" | "edge" | "all";
  dryRun?:  boolean;
} = {}) {
  const platform = os.platform();
 
  const browsers = platform === "win32"
    ? await clearBrowserCacheWin32(browser, dryRun)
    : await clearBrowserCacheDarwin(browser, dryRun);
 
  const totalSizeMb = Math.round(browsers.reduce((s, b) => s + b.sizeMb, 0) * 100) / 100;
  const freedMb     = Math.round(
    browsers.filter((b) => b.cleared).reduce((s, b) => s + b.sizeMb, 0) * 100,
  ) / 100;
 
  // Aggregate any per-browser TCC / silent-failure errors into a top-level
  // error string so the soft-error pipeline (execution.ts → log.warn,
  // summarizer → result bubble) surfaces them. Without this, only the
  // per-browser entries carry the message and the user-facing summary
  // typically loses the detail.
  const failedBrowsers = browsers.filter((b) => b.error);
  const error = failedBrowsers.length > 0
    ? failedBrowsers.map((b) => b.error).join(" ")
    : undefined;
 
  return {
    platform, dryRun, browsers, totalSizeMb, freedMb,
    ...(error ? { error } : {}),
  };
}
 
// -- CLI smoke test -----------------------------------------------------------
 
if (false) {
  run({})
    .then(r => console.log(JSON.stringify(r, null, 2)))
    .catch((err: Error) => { console.error(err.message); process.exit(1); });
}