Code

/**
 * mcp/skills/getBrowserCacheInfo.ts — get_browser_cache_info skill
 *
 * Reports per-browser cache directory sizes without modifying anything.
 * Read-only counterpart to `clear_browser_cache` — designed for
 * synthesis-then-confirm skills that need a diagnostic size feed for
 * the consolidated present_preview card.
 *
 * 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/getBrowserCacheInfo.ts
 *
 * NOTE: cache root resolution is duplicated from `clearBrowserCache.ts`
 * — keep in sync if browser cache paths change.
 */
 
import * as fs       from "fs/promises";
import * as os       from "os";
import * as nodePath from "path";
 
import { isDarwin, isWin32 } from "./_shared/platform";
import { getDirSizeBytes as getDirSizeBytesShared } from "./_shared/dirSize";
import { formatBytes } from "./_shared/formatBytes";
 
// -- Meta ---------------------------------------------------------------------
 
export const meta = {
  name: "get_browser_cache_info",
  description:
    "Reports per-browser cache directory sizes (Chrome, Safari, Firefox, Edge) " +
    "without clearing anything. Read-only counterpart to clear_browser_cache.",
  riskLevel:       "low",
  destructive:     false,
  requiresConsent: false,
  supportsDryRun:  false,
  affectedScope:   ["user"],
  auditRequired:   false,
  tccCategories:   ["FullDiskAccess"],
  // Browser caches can be many GB and millions of small files; the default
  // 60 s ceiling isn't enough on populated home directories. Tool honours
  // ctx.deadlineMs internally and returns partial results before this
  // hard timeout fires.
  timeoutMs:       180_000,
  schema:          {},
} as const;
 
// -- Types --------------------------------------------------------------------
 
export interface BrowserCacheEntry {
  browser:   string;
  profile:   string;
  path:      string;
  sizeBytes: number;
}
 
export interface GetBrowserCacheInfoResult {
  platform:   NodeJS.Platform;
  browsers:   BrowserCacheEntry[];
  totalBytes: number;
  /** Pre-formatted size of `totalBytes` — see getAppCacheInfo.ts for rationale. */
  totalHuman: string;
  errors?:    Array<{ scope: string; message: string }>;
}
 
// -- Helpers ------------------------------------------------------------------
 
async function measureIfExists(
  browser:    string,
  profile:    string,
  path:       string,
  deadlineMs: number,
): Promise<{ entry: BrowserCacheEntry; partial: boolean } | null> {
  try {
    await fs.access(path);
  } catch {
    return null;
  }
  const { sizeBytes, partial } = await getDirSizeBytesShared(path, deadlineMs);
  return { entry: { browser, profile, path, sizeBytes }, partial };
}
 
// -- darwin -------------------------------------------------------------------
 
async function getBrowserCacheInfoDarwin(deadlineMs: number): Promise<GetBrowserCacheInfoResult> {
  const cacheRoot = nodePath.join(os.homedir(), "Library", "Caches");
  const defs: Array<{ browser: string; path: string }> = [
    { browser: "Chrome",  path: nodePath.join(cacheRoot, "Google", "Chrome") },
    { browser: "Safari",  path: nodePath.join(cacheRoot, "com.apple.Safari") },
    { browser: "Firefox", path: nodePath.join(cacheRoot, "Firefox") },
    { browser: "Edge",    path: nodePath.join(cacheRoot, "Microsoft Edge") },
  ];
 
  const measured = await Promise.all(
    defs.map((d) => measureIfExists(d.browser, "default", d.path, deadlineMs)),
  );
  const present  = measured.filter((m): m is { entry: BrowserCacheEntry; partial: boolean } => m !== null);
  const browsers = present.map((m) => m.entry);
  browsers.sort((a, b) => b.sizeBytes - a.sizeBytes);
  const totalBytes = browsers.reduce((s, b) => s + b.sizeBytes, 0);
  const anyPartial = present.some((m) => m.partial);
 
  return {
    platform: "darwin",
    browsers,
    totalBytes,
    totalHuman: formatBytes(totalBytes),
    ...(anyPartial
      ? { errors: [{ scope: "deadline", message: "Browser cache scan exceeded the per-tool deadline; sizes are partial." }] }
      : {}),
  };
}
 
// -- win32 --------------------------------------------------------------------
 
async function getBrowserCacheInfoWin32(deadlineMs: number): Promise<GetBrowserCacheInfoResult> {
  const localAppData = process.env["LOCALAPPDATA"]
    ?? nodePath.join(os.homedir(), "AppData", "Local");
 
  const defs: Array<{ browser: string; path: string }> = [
    { browser: "Chrome",  path: nodePath.join(localAppData, "Google", "Chrome", "User Data", "Default", "Cache") },
    { browser: "Firefox", path: nodePath.join(localAppData, "Mozilla", "Firefox", "Profiles") },
    { browser: "Edge",    path: nodePath.join(localAppData, "Microsoft", "Edge", "User Data", "Default", "Cache") },
  ];
 
  const measured = await Promise.all(
    defs.map((d) => measureIfExists(d.browser, "default", d.path, deadlineMs)),
  );
  const present  = measured.filter((m): m is { entry: BrowserCacheEntry; partial: boolean } => m !== null);
  const browsers = present.map((m) => m.entry);
  browsers.sort((a, b) => b.sizeBytes - a.sizeBytes);
  const totalBytes = browsers.reduce((s, b) => s + b.sizeBytes, 0);
  const anyPartial = present.some((m) => m.partial);
 
  return {
    platform: "win32",
    browsers,
    totalBytes,
    totalHuman: formatBytes(totalBytes),
    ...(anyPartial
      ? { errors: [{ scope: "deadline", message: "Browser cache scan exceeded the per-tool deadline; sizes are partial." }] }
      : {}),
  };
}
 
// -- Exported run -------------------------------------------------------------
 
interface RunCtx { deadlineMs?: number }
 
export async function run(
  _args: Record<string, never> = {},
  ctx?:  RunCtx,
): Promise<GetBrowserCacheInfoResult> {
  // Internal budget = 90% of the G4 deadline.
  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);
 
  if (isDarwin()) return getBrowserCacheInfoDarwin(internalDeadlineMs);
  if (isWin32())  return getBrowserCacheInfoWin32(internalDeadlineMs);
  return {
    platform:   os.platform(),
    browsers:   [],
    totalBytes: 0,
    totalHuman: formatBytes(0),
    errors:     [{ scope: "platform", message: `unsupported platform: ${os.platform()}` }],
  };
}
 
// -- CLI smoke test -----------------------------------------------------------
 
if (require.main === module) {
  run()
    .then((r) => console.log(JSON.stringify(r, null, 2)))
    .catch((err: Error) => { console.error(err.message); process.exit(1); });
}