Code

/**
 * mcp/skills/getCollabAppCacheSize.ts — get_collab_app_cache_size
 *
 * Reports per-app cache directory sizes for Microsoft Teams / Slack /
 * Zoom / Cisco Webex without modifying anything.  Read-only counterpart
 * to `clear_collab_app_cache`.
 *
 * Smoke test
 *   npx tsx -r dotenv/config mcp/skills/getCollabAppCacheSize.ts
 *
 * NOTE: cache directory layout is duplicated from `clearCollabAppCache.ts`
 * — keep in sync if per-app cache subdirectory lists change.
 */
 
import * as os   from "os";
import * as path from "path";
import { promises as fs } from "fs";
import { z }     from "zod";
 
import type { CollabApp } from "./checkCollabAppStatus";
 
// -- Meta ---------------------------------------------------------------------
 
export const meta = {
  name: "get_collab_app_cache_size",
  description:
    "Reports per-app clearable-cache directory sizes for Teams / Slack / " +
    "Zoom / Webex without modifying anything. Read-only counterpart to " +
    "clear_collab_app_cache.",
  riskLevel:       "low",
  destructive:     false,
  requiresConsent: false,
  supportsDryRun:  false,
  affectedScope:   ["user"],
  auditRequired:   false,
  tccCategories:   ["FullDiskAccess"],
  schema: {
    apps: z
      .array(z.enum(["teams", "slack", "zoom", "webex"]))
      .optional()
      .describe(
        "Which collab apps to probe. Omit to scan all four. Wildcards rejected.",
      ),
  },
} as const;
 
// -- Types --------------------------------------------------------------------
 
export interface CollabAppCacheInfo {
  app:         CollabApp;
  installed:   boolean;
  basePath:    string;
  sizeBytes:   number;
  perPath:     Array<{ path: string; sizeBytes: number }>;
}
 
export interface GetCollabAppCacheSizeResult {
  platform:   NodeJS.Platform;
  apps:       CollabAppCacheInfo[];
  totalBytes: number;
  errors?:    Array<{ scope: string; message: string }>;
}
 
// -- Helpers ------------------------------------------------------------------
 
interface AppCacheLayout { baseDarwin: string; baseWin32: string; clearable: string[] }
 
function appCacheLayout(app: CollabApp): AppCacheLayout {
  const home    = os.homedir();
  const appData = process.env["APPDATA"] ?? path.join(home, "AppData", "Roaming");
 
  switch (app) {
    case "teams":
      return {
        baseDarwin: path.join(home,    "Library", "Application Support", "Microsoft", "Teams"),
        baseWin32:  path.join(appData, "Microsoft", "Teams"),
        clearable:  ["Cache", "Code Cache", "GPUCache", "Service Worker/CacheStorage", "Service Worker/ScriptCache", "tmp"],
      };
    case "slack":
      return {
        baseDarwin: path.join(home,    "Library", "Application Support", "Slack"),
        baseWin32:  path.join(appData, "Slack"),
        clearable:  ["Cache", "Code Cache", "GPUCache", "Service Worker/CacheStorage", "Service Worker/ScriptCache"],
      };
    case "zoom":
      return {
        baseDarwin: path.join(home,    "Library", "Application Support", "zoom.us"),
        baseWin32:  path.join(appData, "Zoom"),
        clearable:  ["data/Tcache", "data/Logs", "data/Avatar", "AutoUpdater/log"],
      };
    case "webex":
      return {
        baseDarwin: path.join(home,    "Library", "Application Support", "Cisco Spark"),
        baseWin32:  path.join(appData, "Cisco Spark"),
        clearable:  ["Cache", "Code Cache", "GPUCache", "Service Worker/CacheStorage"],
      };
  }
}
 
async function pathExists(p: string): Promise<boolean> {
  try {
    await fs.access(p);
    return true;
  } catch {
    return false;
  }
}
 
async function dirSizeBytes(p: string): Promise<number> {
  try {
    const stack: string[] = [p];
    let total = 0;
    while (stack.length > 0) {
      const cur = stack.pop()!;
      let entries;
      try {
        entries = await fs.readdir(cur, { withFileTypes: true });
      } catch { continue; }
      for (const e of entries) {
        const full = path.join(cur, e.name);
        if (e.isDirectory()) stack.push(full);
        else if (e.isFile()) {
          try {
            const s = await fs.stat(full);
            total += s.size;
          } catch { /* skip */ }
        }
      }
    }
    return total;
  } catch {
    return 0;
  }
}
 
async function probeApp(app: CollabApp, platform: NodeJS.Platform): Promise<CollabAppCacheInfo> {
  const layout   = appCacheLayout(app);
  const basePath = platform === "darwin" ? layout.baseDarwin : layout.baseWin32;
  if (!(await pathExists(basePath))) {
    return { app, installed: false, basePath, sizeBytes: 0, perPath: [] };
  }
  const perPath: CollabAppCacheInfo["perPath"] = [];
  let sizeBytes = 0;
  for (const sub of layout.clearable) {
    const target = path.join(basePath, sub);
    if (!(await pathExists(target))) continue;
    const bytes = await dirSizeBytes(target);
    perPath.push({ path: target, sizeBytes: bytes });
    sizeBytes += bytes;
  }
  return { app, installed: true, basePath, sizeBytes, perPath };
}
 
// -- Exported run -------------------------------------------------------------
 
const DEFAULT_APPS: CollabApp[] = ["teams", "slack", "zoom", "webex"];
 
export async function run({
  apps,
}: { apps?: CollabApp[] } = {}): Promise<GetCollabAppCacheSizeResult> {
  const platform = os.platform();
  if (platform !== "darwin" && platform !== "win32") {
    return {
      platform,
      apps: [],
      totalBytes: 0,
      errors: [{ scope: "platform", message: `unsupported platform: ${platform}` }],
    };
  }
 
  const selected = apps && apps.length > 0 ? apps : DEFAULT_APPS;
  const results  = await Promise.all(selected.map((a) => probeApp(a, platform)));
  results.sort((a, b) => b.sizeBytes - a.sizeBytes);
  const totalBytes = results.reduce((s, a) => s + a.sizeBytes, 0);
 
  return { platform, apps: results, totalBytes };
}
 
// -- 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); });
}