Code

/**
 * mcp/skills/clearCollabAppCache.ts — clear_collab_app_cache skill
 *
 * Per-app cache clear for Microsoft Teams / Slack / Zoom / Cisco Webex.
 * The intent is to fix "stuck media", "search not finding messages",
 * "old meeting metadata" type problems WITHOUT signing the user out.
 *
 * Discipline
 * ----------
 *   - Cache directories cleared are an explicit per-app whitelist.  The
 *     tool refuses to take a wildcard or a user-supplied path — clearing
 *     is governed entirely by the app enum.
 *   - Auth artefacts are explicitly preserved: Cookies, Local Storage,
 *     IndexedDB, accounts.  These are NEVER on the clear list.
 *   - Dry-run path returns what WOULD be cleared (paths + total bytes)
 *     without touching disk.  G4 surfaces this to the user.
 *   - Errors per path are caught and reported in `errors[]` rather than
 *     aborting the whole operation — partial-clear is more useful than
 *     no-clear when one subdirectory has a stuck file lock (common
 *     while the app is running).
 */
 
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: "clear_collab_app_cache",
  description:
    "Clears the per-user cache directories for one collab app (Teams, Slack, " +
    "Zoom, Webex), preserving authentication state (Cookies, Local Storage, " +
    "IndexedDB, accounts) so the user does NOT have to sign back in. Targets " +
    "media cache, search index, GPU cache, and meeting cache. Use when a " +
    "specific collab app is misbehaving (stuck media, stale search results, " +
    "old meeting cache) but the user is still signed in.",
  riskLevel:       "medium",
  destructive:     true,
  requiresConsent: true,
  supportsDryRun:  true,
  affectedScope:   ["user"],
  auditRequired:   true,
  schema: {
    app: z
      .enum(["teams", "slack", "zoom", "webex"])
      .describe("Which collab app's cache to clear. Wildcards rejected."),
    dryRun: z
      .boolean()
      .optional()
      .describe("If true, report what would be cleared without deleting anything."),
  },
} as const;
 
// -- Per-app cache subdirectory whitelist -------------------------------------
 
interface AppCacheLayout {
  baseDarwin:       string;
  baseWin32:        string;
  /** Subdirectories under base that ARE safe to delete. */
  clearable:        string[];
  /** Subdirectories under base that MUST NOT be deleted (auth/identity). */
  preserved:        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",
        ],
        preserved:  ["Cookies", "Local Storage", "IndexedDB", "Session Storage"],
      };
    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",
        ],
        preserved:  ["Cookies", "Local Storage", "IndexedDB", "storage"],
      };
    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",
        ],
        preserved:  ["data/zoomus.db", "data/zoommeeting", "Preferences"],
      };
    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",
        ],
        preserved:  ["accounts", "databases", "Local Storage"],
      };
  }
}
 
// -- Filesystem helpers -------------------------------------------------------
 
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;   // unreadable subdir — skip silently
      }
      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 {
            // unreadable file — skip
          }
        }
      }
    }
    return total;
  } catch {
    return 0;
  }
}
 
// -- Types --------------------------------------------------------------------
 
export interface ClearedPathResult {
  path:        string;
  bytesFreed:  number;
}
 
export interface ClearCollabAppCacheResult {
  app:             CollabApp;
  platform:        NodeJS.Platform;
  dryRun:          boolean;
  basePath:        string;
  clearedPaths:    ClearedPathResult[];
  preservedPaths:  string[];
  sizeFreedBytes:  number;
  errors:          { path: string; message: string }[];
}
 
// -- Exported run function ----------------------------------------------------
 
export async function run({
  app,
  dryRun = false,
}: {
  app:     CollabApp;
  dryRun?: boolean;
}): Promise<ClearCollabAppCacheResult> {
  const platform = os.platform();
  if (platform !== "darwin" && platform !== "win32") {
    throw new Error(`clear_collab_app_cache: unsupported platform "${platform}"`);
  }
 
  const layout   = appCacheLayout(app);
  const basePath = platform === "darwin" ? layout.baseDarwin : layout.baseWin32;
 
  if (!(await pathExists(basePath))) {
    return {
      app,
      platform,
      dryRun,
      basePath,
      clearedPaths:   [],
      preservedPaths: layout.preserved.map((p) => path.join(basePath, p)),
      sizeFreedBytes: 0,
      errors:         [{ path: basePath, message: `${app} is not installed (base path missing)` }],
    };
  }
 
  const clearedPaths:   ClearedPathResult[] = [];
  const errors:         { path: string; message: string }[] = [];
  let   sizeFreedBytes  = 0;
 
  for (const sub of layout.clearable) {
    const target = path.join(basePath, sub);
    if (!(await pathExists(target))) continue;
 
    const bytes = await dirSizeBytes(target);
 
    if (dryRun) {
      clearedPaths.push({ path: target, bytesFreed: bytes });
      sizeFreedBytes += bytes;
      continue;
    }
 
    try {
      await fs.rm(target, { recursive: true, force: true });
      clearedPaths.push({ path: target, bytesFreed: bytes });
      sizeFreedBytes += bytes;
    } catch (err) {
      errors.push({ path: target, message: (err as Error).message });
    }
  }
 
  return {
    app,
    platform,
    dryRun,
    basePath,
    clearedPaths,
    preservedPaths: layout.preserved.map((p) => path.join(basePath, p)),
    sizeFreedBytes,
    errors,
  };
}
 
// -- Test helpers -------------------------------------------------------------
 
/** Exported for unit tests only — do not use from production code. */
export const __testing = {
  appCacheLayout,
  pathExists,
  dirSizeBytes,
};