Code

/**
 * mcp/skills/getAppCacheInfo.ts — get_app_cache_info skill
 *
 * Reports application cache directory sizes (per app) without modifying
 * anything.  Read-only counterpart to `clear_app_cache` — designed for
 * synthesis-then-confirm skills (disk-cleanup) that need a diagnostic
 * size feed for the consolidated present_preview card.
 *
 * 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/getAppCacheInfo.ts
 *
 * NOTE: enumeration logic is duplicated from `clearAppCache.ts` — keep
 * in sync if cache-directory roots or sizing approach 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";
import { DARWIN_BROWSER_CACHE_DIR_NAMES, isWin32BrowserVendorDir } from "./_shared/browserCaches";
import { isDevCacheDir } from "./_shared/devCaches";
 
// -- Meta ---------------------------------------------------------------------
 
export const meta = {
  name: "get_app_cache_info",
  description:
    "Reports per-application cache directory sizes on macOS (~/Library/Caches) " +
    "or Windows (%LOCALAPPDATA% / %TEMP%) without deleting anything. Read-only " +
    "counterpart to clear_app_cache.",
  riskLevel:       "low",
  destructive:     false,
  requiresConsent: false,
  supportsDryRun:  false,
  affectedScope:   ["user"],
  auditRequired:   false,
  tccCategories:   ["FullDiskAccess"],
  // Walks every subdir of ~/Library/Caches; on a populated home this exceeds
  // the default 60 s ceiling. Tool honours ctx.deadlineMs internally and
  // returns partial results before this hard timeout fires.
  timeoutMs:       180_000,
  schema:          {},
} as const;
 
// -- Types --------------------------------------------------------------------
 
export interface AppCacheEntry {
  name:      string;
  path:      string;
  sizeBytes: number;
}
 
export interface GetAppCacheInfoResult {
  platform:   NodeJS.Platform;
  caches:     AppCacheEntry[];
  totalBytes: number;
  /**
   * Pre-formatted human-readable size of `totalBytes`, computed via the
   * shared formatBytes helper (decimal/SI units — matches Finder + Explorer).
   * disk-cleanup SKILL.md substitutes this verbatim into the cleanup card's
   * `{size}` placeholder for the app-cache row, so the LLM doesn't have to
   * do byte math itself (and pick binary-vs-decimal inconsistently).
   */
  totalHuman: string;
  errors?:    Array<{ scope: string; message: string }>;
}
 
// -- Helpers ------------------------------------------------------------------
 
 
// -- darwin -------------------------------------------------------------------
 
async function getAppCacheInfoDarwin(deadlineMs: number): Promise<GetAppCacheInfoResult> {
  const cacheRoot = nodePath.join(os.homedir(), "Library", "Caches");
  const errors: GetAppCacheInfoResult["errors"] = [];
 
  let dirents: import("fs").Dirent[];
  try {
    dirents = await fs.readdir(cacheRoot, { withFileTypes: true });
  } catch (err) {
    errors.push({ scope: "cache-root", message: (err as Error).message });
    return { platform: "darwin", caches: [], totalBytes: 0, totalHuman: formatBytes(0), errors };
  }
 
  // Exclude browser + dev cache dirs — the browser-cache / dev-cache tools own
  // them. Without this, app-cache double-counts them in the cleanup card and
  // clear_app_cache deletes them before clear_browser_cache / clear_dev_cache
  // run. See _shared/browserCaches.ts and _shared/devCaches.ts.
  const subdirs = dirents.filter(
    (d) => d.isDirectory()
      && !DARWIN_BROWSER_CACHE_DIR_NAMES.has(d.name)
      && !isDevCacheDir(d.name),
  );
  let anyPartial = false;
  const caches: AppCacheEntry[] = await Promise.all(
    subdirs.map(async (d) => {
      const full = nodePath.join(cacheRoot, d.name);
      const { sizeBytes, partial } = await getDirSizeBytesShared(full, deadlineMs);
      if (partial) anyPartial = true;
      return { name: d.name, path: full, sizeBytes };
    }),
  );
 
  caches.sort((a, b) => b.sizeBytes - a.sizeBytes);
  const totalBytes = caches.reduce((s, c) => s + c.sizeBytes, 0);
 
  if (anyPartial) {
    errors.push({
      scope:   "deadline",
      message: "Cache scan exceeded the per-tool deadline; sizes are partial.",
    });
  }
 
  return {
    platform: "darwin",
    caches,
    totalBytes,
    totalHuman: formatBytes(totalBytes),
    ...(errors.length > 0 ? { errors } : {}),
  };
}
 
// -- win32 --------------------------------------------------------------------
 
async function getAppCacheInfoWin32(deadlineMs: number): Promise<GetAppCacheInfoResult> {
  const localAppData = process.env["LOCALAPPDATA"] ?? nodePath.join(os.homedir(), "AppData", "Local");
  const tempDir      = process.env["TEMP"]         ?? nodePath.join(localAppData, "Temp");
 
  const roots = [localAppData, tempDir];
  const seen  = new Set<string>();
  const errors: GetAppCacheInfoResult["errors"] = [];
  const all:  { name: string; fullPath: string }[] = [];
 
  for (const root of roots) {
    let dirents: import("fs").Dirent[];
    try {
      dirents = await fs.readdir(root, { withFileTypes: true });
    } catch (err) {
      errors.push({ scope: root, message: (err as Error).message });
      continue;
    }
    for (const d of dirents) {
      if (!d.isDirectory() || seen.has(d.name.toLowerCase())) continue;
      if (isWin32BrowserVendorDir(d.name) || isDevCacheDir(d.name)) continue;
      seen.add(d.name.toLowerCase());
      all.push({ name: d.name, fullPath: nodePath.join(root, d.name) });
    }
  }
 
  let anyPartial = false;
  const caches: AppCacheEntry[] = await Promise.all(
    all.map(async ({ name, fullPath }) => {
      const { sizeBytes, partial } = await getDirSizeBytesShared(fullPath, deadlineMs);
      if (partial) anyPartial = true;
      return { name, path: fullPath, sizeBytes };
    }),
  );
 
  caches.sort((a, b) => b.sizeBytes - a.sizeBytes);
  const totalBytes = caches.reduce((s, c) => s + c.sizeBytes, 0);
 
  if (anyPartial) {
    errors.push({
      scope:   "deadline",
      message: "Cache scan exceeded the per-tool deadline; sizes are partial.",
    });
  }
 
  return {
    platform: "win32",
    caches,
    totalBytes,
    totalHuman: formatBytes(totalBytes),
    ...(errors.length > 0 ? { errors } : {}),
  };
}
 
// -- Exported run -------------------------------------------------------------
 
interface RunCtx { deadlineMs?: number }
 
export async function run(
  _args: Record<string, never> = {},
  ctx?:  RunCtx,
): Promise<GetAppCacheInfoResult> {
  // Internal budget = 90% of the G4 deadline, leaving 10% headroom to
  // serialize the partial result and return through G4 before its
  // Promise.race rejects.
  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 getAppCacheInfoDarwin(internalDeadlineMs);
  if (isWin32())  return getAppCacheInfoWin32(internalDeadlineMs);
  return {
    platform: os.platform(),
    caches:   [],
    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); });
}