Code

/**
 * mcp/skills/clearXcodeDerivedData.ts — clear_xcode_derived_data skill
 *
 * Clears Xcode's DerivedData folder containing build artifacts that commonly
 * grow to 10-30 GB. Optionally clears Xcode Archives and device support files.
 * macOS only.
 *
 * Platform strategy
 * -----------------
 * darwin  du -sk to measure sizes, fs.rm to delete when not dryRun
 * win32   Not supported — returns unsupported message
 *
 * Smoke test
 *   npx tsx -r dotenv/config mcp/skills/clearXcodeDerivedData.ts
 */
 
import * as fs       from "fs/promises";
import * as os       from "os";
import * as nodePath from "path";
import { exec }      from "child_process";
import { promisify } from "util";
import { z }         from "zod";
 
const execAsync = promisify(exec);
 
// -- Meta ---------------------------------------------------------------------
 
export const meta = {
  name: "clear_xcode_derived_data",
  description:
    "Clears Xcode's DerivedData folder containing build artifacts that commonly grow " +
    "to 10-30GB. Optionally clears Xcode Archives and device support files. macOS only.",
  riskLevel:       "medium",
  destructive:     false,
  requiresConsent: false,
  supportsDryRun:  true,
  affectedScope:   ["user"],
  auditRequired:   true,
  schema: {
    what: z
      .array(z.enum(["derivedData", "archives", "deviceSupport", "all"]))
      .optional()
      .describe("What to clear. Default: ['derivedData']"),
    dryRun: z
      .boolean()
      .optional()
      .describe("If true, report sizes without deleting. Default: true"),
  },
} as const;
 
// -- Types --------------------------------------------------------------------
 
interface ClearTarget {
  name:     string;
  path:     string;
  sizeMb:   number;
  exists:   boolean;
  cleared:  boolean;
}
 
interface ClearResult {
  targets:      ClearTarget[];
  totalSizeMb:  number;
  freedMb:      number;
  dryRun:       boolean;
  supported?:   boolean;
  message?:     string;
}
 
// -- Size helper --------------------------------------------------------------
 
async function getDirSizeMb(dirPath: string): Promise<number> {
  try {
    const safePath = dirPath.replace(/'/g, `'\\''`);
    const { stdout } = await execAsync(
      `du -sk '${safePath}' 2>/dev/null`,
      { maxBuffer: 2 * 1024 * 1024, shell: "/bin/bash" },
    );
    const kb = parseInt(stdout.trim().split("\t")[0], 10);
    return isNaN(kb) ? 0 : Math.round((kb / 1024) * 10) / 10;
  } catch {
    return 0;
  }
}
 
// -- darwin: resolve target paths ---------------------------------------------
 
function resolveDarwinTargets(what: string[]): Array<{ name: string; path: string }> {
  const home     = os.homedir();
  const xcodeDir = nodePath.join(home, "Library", "Developer", "Xcode");
 
  const allTargets: Array<{ name: string; path: string }> = [
    {
      name: "derivedData",
      path: nodePath.join(xcodeDir, "DerivedData"),
    },
    {
      name: "archives",
      path: nodePath.join(xcodeDir, "Archives"),
    },
    {
      name: "iOS DeviceSupport",
      path: nodePath.join(xcodeDir, "iOS DeviceSupport"),
    },
    {
      name: "watchOS DeviceSupport",
      path: nodePath.join(xcodeDir, "watchOS DeviceSupport"),
    },
    {
      name: "tvOS DeviceSupport",
      path: nodePath.join(xcodeDir, "tvOS DeviceSupport"),
    },
    {
      name: "visionOS DeviceSupport",
      path: nodePath.join(xcodeDir, "visionOS DeviceSupport"),
    },
  ];
 
  const includeAll        = what.includes("all");
  const includeDevSupport = includeAll || what.includes("deviceSupport");
 
  return allTargets.filter((t) => {
    if (includeAll) return true;
    if (t.name === "derivedData" && what.includes("derivedData")) return true;
    if (t.name === "archives"    && what.includes("archives"))    return true;
    if (t.name.includes("DeviceSupport") && includeDevSupport)   return true;
    return false;
  });
}
 
// -- darwin implementation ----------------------------------------------------
 
async function clearXcodeDarwin(
  what:   string[],
  dryRun: boolean,
): Promise<ClearResult> {
  const targetDefs = resolveDarwinTargets(what);
  const targets:    ClearTarget[] = [];
 
  for (const def of targetDefs) {
    let exists  = false;
    let sizeMb  = 0;
    let cleared = false;
 
    try {
      await fs.access(def.path);
      exists = true;
      sizeMb = await getDirSizeMb(def.path);
    } catch {
      exists = false;
    }
 
    if (!dryRun && exists) {
      try {
        await fs.rm(def.path, { recursive: true, force: true });
        cleared = true;
      } catch {
        cleared = false;
      }
    }
 
    targets.push({ name: def.name, path: def.path, sizeMb, exists, cleared });
  }
 
  const totalSizeMb = targets.reduce((sum, t) => sum + t.sizeMb, 0);
  const freedMb     = dryRun ? 0 : targets.filter((t) => t.cleared).reduce((sum, t) => sum + t.sizeMb, 0);
 
  return {
    targets,
    totalSizeMb: Math.round(totalSizeMb * 10) / 10,
    freedMb:     Math.round(freedMb * 10) / 10,
    dryRun,
  };
}
 
// -- win32 implementation -----------------------------------------------------
 
function clearXcodeWin32(): ClearResult {
  return {
    targets:     [],
    totalSizeMb: 0,
    freedMb:     0,
    dryRun:      true,
    supported:   false,
    message:     "Xcode is macOS only. This skill has no effect on Windows.",
  };
}
 
// -- Exported run function ----------------------------------------------------
 
export async function run({
  what   = ["derivedData"],
  dryRun = true,
}: {
  what?:   Array<"derivedData" | "archives" | "deviceSupport" | "all">;
  dryRun?: boolean;
} = {}) {
  const platform = os.platform();
  if (platform === "win32") return clearXcodeWin32();
  return clearXcodeDarwin(what, dryRun);
}
 
// -- CLI smoke test -----------------------------------------------------------
 
if (false) {
  run({ what: ["derivedData"], dryRun: true })
    .then(r => console.log(JSON.stringify(r, null, 2)))
    .catch((err: Error) => { console.error(err.message); process.exit(1); });
}