Code

/**
 * mcp/skills/clearDevCache.ts — clear_dev_cache skill
 *
 * Clears developer tool caches to free disk space.  Supports npm, yarn,
 * pnpm, pip, gradle, maven.  Reports sizes before clearing.  Safe to
 * clear — tools rebuild caches as needed.
 *
 * Platform strategy
 * -----------------
 * darwin  npm/yarn/pnpm via their CLI; pip/gradle/maven via directory paths
 *         under ~/Library/Caches and ~/.gradle/.m2
 * win32   Same CLIs where available; paths from %APPDATA%/%LOCALAPPDATA%
 *
 * Smoke test
 *   npx tsx -r dotenv/config mcp/skills/clearDevCache.ts
 */
 
import * as os       from "os";
import * as nodePath from "path";
import * as fs       from "fs/promises";
import { exec }      from "child_process";
import { promisify } from "util";
import { z }         from "zod";
 
const execAsync = promisify(exec);
 
// -- Meta ---------------------------------------------------------------------
 
export const meta = {
  name: "clear_dev_cache",
  description:
    "Clears developer tool caches to free disk space. " +
    "Supports npm, yarn, pnpm, pip, gradle, maven. " +
    "Reports sizes before clearing. " +
    "Safe to clear — tools rebuild caches as needed.",
  riskLevel:       "medium",
  destructive:     false,
  requiresConsent: false,
  supportsDryRun:  true,
  affectedScope:   ["user"],
  auditRequired:   true,
  schema: {
    tools: z
      .array(z.enum(["npm", "yarn", "pnpm", "pip", "gradle", "maven", "all"]))
      .optional()
      .describe("Tools to clear. Default: all detected"),
    dryRun: z
      .boolean()
      .optional()
      .describe("If true, report sizes without clearing. Default: true"),
  },
} as const;
 
// -- Types --------------------------------------------------------------------
 
interface CacheEntry {
  tool:      string;
  path:      string | null;
  sizeMb:    number;
  available: boolean;
  cleared:   boolean;
}
 
interface ClearDevCacheResult {
  caches:       CacheEntry[];
  totalSizeMb:  number;
  freedMb:      number;
}
 
// -- Helpers ------------------------------------------------------------------
 
const ALL_TOOLS = ["npm", "yarn", "pnpm", "pip", "gradle", "maven"] as const;
type ToolName = typeof ALL_TOOLS[number];
 
async function getDirSizeMb(dirPath: string): Promise<number> {
  try {
    if (os.platform() === "win32") {
      // PowerShell for directory size
      const encoded = Buffer.from(
        `(Get-ChildItem -LiteralPath '${dirPath.replace(/'/g, "''")}' -Recurse -File -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum`,
        "utf16le",
      ).toString("base64");
      const { stdout } = await execAsync(
        `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encoded}`,
        { maxBuffer: 10 * 1024 * 1024 },
      );
      const bytes = parseInt(stdout.trim(), 10);
      return isNaN(bytes) ? 0 : Math.round((bytes / (1024 * 1024)) * 10) / 10;
    } else {
      const safePath = dirPath.replace(/'/g, "'\\''");
      const { stdout } = await execAsync(
        `du -sk '${safePath}' 2>/dev/null`,
        { maxBuffer: 10 * 1024 * 1024, shell: "/bin/bash" },
      );
      const kb = parseInt(stdout.split("\t")[0], 10);
      return isNaN(kb) ? 0 : Math.round((kb / 1024) * 10) / 10;
    }
  } catch {
    return 0;
  }
}
 
async function pathExists(p: string): Promise<boolean> {
  try { await fs.access(p); return true; } catch { return false; }
}
 
async function deleteDirContents(dirPath: string): Promise<void> {
  await fs.rm(dirPath, { recursive: true, force: true });
}
 
async function commandExists(cmd: string): Promise<boolean> {
  try {
    const check = os.platform() === "win32" ? `where ${cmd}` : `which ${cmd}`;
    await execAsync(check);
    return true;
  } catch {
    return false;
  }
}
 
// -- Per-tool handlers --------------------------------------------------------
 
async function handleNpm(dryRun: boolean): Promise<CacheEntry> {
  const tool = "npm";
  if (!(await commandExists("npm"))) {
    return { tool, path: null, sizeMb: 0, available: false, cleared: false };
  }
 
  // Get cache dir
  let cachePath: string | null = null;
  try {
    const { stdout } = await execAsync("npm config get cache");
    cachePath = stdout.trim();
  } catch { /* ignore */ }
 
  const sizeMb = cachePath ? await getDirSizeMb(cachePath) : 0;
 
  let cleared = false;
  if (!dryRun) {
    try {
      await execAsync("npm cache clean --force");
      cleared = true;
    } catch { cleared = false; }
  }
 
  return { tool, path: cachePath, sizeMb, available: true, cleared };
}
 
async function handleYarn(dryRun: boolean): Promise<CacheEntry> {
  const tool = "yarn";
  if (!(await commandExists("yarn"))) {
    return { tool, path: null, sizeMb: 0, available: false, cleared: false };
  }
 
  let cachePath: string | null = null;
  try {
    const { stdout } = await execAsync("yarn cache dir");
    cachePath = stdout.trim();
  } catch { /* ignore */ }
 
  const sizeMb = cachePath ? await getDirSizeMb(cachePath) : 0;
 
  let cleared = false;
  if (!dryRun && cachePath) {
    try {
      await deleteDirContents(cachePath);
      cleared = true;
    } catch { cleared = false; }
  }
 
  return { tool, path: cachePath, sizeMb, available: true, cleared };
}
 
async function handlePnpm(dryRun: boolean): Promise<CacheEntry> {
  const tool = "pnpm";
  if (!(await commandExists("pnpm"))) {
    return { tool, path: null, sizeMb: 0, available: false, cleared: false };
  }
 
  let cachePath: string | null = null;
  try {
    const { stdout } = await execAsync("pnpm store path");
    cachePath = stdout.trim();
  } catch { /* ignore */ }
 
  const sizeMb = cachePath ? await getDirSizeMb(cachePath) : 0;
 
  let cleared = false;
  if (!dryRun) {
    try {
      await execAsync("pnpm store prune");
      cleared = true;
    } catch { cleared = false; }
  }
 
  return { tool, path: cachePath, sizeMb, available: true, cleared };
}
 
async function handlePip(dryRun: boolean): Promise<CacheEntry> {
  const tool = "pip";
  const home  = os.homedir();
  const cachePath = os.platform() === "win32"
    ? nodePath.join(process.env.LOCALAPPDATA ?? nodePath.join(home, "AppData", "Local"), "pip", "Cache")
    : nodePath.join(home, "Library", "Caches", "pip");
 
  const available = await pathExists(cachePath);
  const sizeMb    = available ? await getDirSizeMb(cachePath) : 0;
 
  let cleared = false;
  if (!dryRun && available) {
    try {
      // Try pip cache purge first (pip 20.1+)
      try {
        const pipCmd = (await commandExists("pip3")) ? "pip3" : "pip";
        await execAsync(`${pipCmd} cache purge`);
      } catch {
        await deleteDirContents(cachePath);
      }
      cleared = true;
    } catch { cleared = false; }
  }
 
  return { tool, path: cachePath, sizeMb, available, cleared };
}
 
async function handleGradle(dryRun: boolean): Promise<CacheEntry> {
  const tool       = "gradle";
  const home       = os.homedir();
  const cachePath  = os.platform() === "win32"
    ? nodePath.join(process.env.USERPROFILE ?? home, ".gradle", "caches")
    : nodePath.join(home, ".gradle", "caches");
 
  const available = await pathExists(cachePath);
  const sizeMb    = available ? await getDirSizeMb(cachePath) : 0;
 
  let cleared = false;
  if (!dryRun && available) {
    try {
      await deleteDirContents(cachePath);
      cleared = true;
    } catch { cleared = false; }
  }
 
  return { tool, path: cachePath, sizeMb, available, cleared };
}
 
async function handleMaven(dryRun: boolean): Promise<CacheEntry> {
  const tool      = "maven";
  const home      = os.homedir();
  const cachePath = os.platform() === "win32"
    ? nodePath.join(process.env.USERPROFILE ?? home, ".m2", "repository")
    : nodePath.join(home, ".m2", "repository");
 
  const available = await pathExists(cachePath);
  const sizeMb    = available ? await getDirSizeMb(cachePath) : 0;
 
  let cleared = false;
  if (!dryRun && available) {
    try {
      await deleteDirContents(cachePath);
      cleared = true;
    } catch { cleared = false; }
  }
 
  return { tool, path: cachePath, sizeMb, available, cleared };
}
 
// -- Exported run function ----------------------------------------------------
 
export async function run({
  tools  = ["all"],
  dryRun = true,
}: {
  tools?:  Array<"npm" | "yarn" | "pnpm" | "pip" | "gradle" | "maven" | "all">;
  dryRun?: boolean;
} = {}) {
  const selected: ToolName[] = tools.includes("all")
    ? [...ALL_TOOLS]
    : (tools.filter((t) => t !== "all") as ToolName[]);
 
  const handlers: Record<ToolName, (dryRun: boolean) => Promise<CacheEntry>> = {
    npm:    handleNpm,
    yarn:   handleYarn,
    pnpm:   handlePnpm,
    pip:    handlePip,
    gradle: handleGradle,
    maven:  handleMaven,
  };
 
  const caches = await Promise.all(
    selected.map((t) => handlers[t](dryRun)),
  );
 
  const totalSizeMb = Math.round(caches.reduce((acc, c) => acc + c.sizeMb, 0) * 10) / 10;
  const freedMb     = dryRun
    ? 0
    : Math.round(caches.filter((c) => c.cleared).reduce((acc, c) => acc + c.sizeMb, 0) * 10) / 10;
 
  return { caches, totalSizeMb, freedMb };
}
 
// -- Smoke test ---------------------------------------------------------------
 
if (false) {
  run({})
    .then(r => console.log(JSON.stringify(r, null, 2)))
    .catch((err: Error) => { console.error(err.message); process.exit(1); });
}