Code

/**
 * mcp/skills/pruneDocker.ts — prune_docker skill
 *
 * Removes unused Docker resources (stopped containers, dangling images, unused
 * volumes, unused networks). Use to free significant disk space on developer
 * machines. Checks if Docker is installed first.
 *
 * Platform strategy
 * -----------------
 * Both   `docker system df` for dry run info, `docker container prune -f`,
 *        `docker image prune -f`, `docker volume prune -f`,
 *        `docker network prune -f`, or `docker system prune -f`
 *
 * Smoke test
 *   npx tsx -r dotenv/config mcp/skills/pruneDocker.ts
 */
 
import * as os       from "os";
import { exec }      from "child_process";
import { promisify } from "util";
import { z }         from "zod";
 
const execAsync = promisify(exec);
 
// -- Meta ---------------------------------------------------------------------
 
export const meta = {
  name: "prune_docker",
  description:
    "Removes unused Docker resources (stopped containers, dangling images, " +
    "unused volumes, unused networks). Use to free significant disk space on " +
    "developer machines. Checks if Docker is installed first.",
  riskLevel:       "medium",
  destructive:     false,
  requiresConsent: true,
  supportsDryRun:  true,
  affectedScope:   ["user"],
  auditRequired:   true,
  schema: {
    what: z
      .array(z.enum(["containers", "images", "volumes", "networks", "all"]))
      .optional()
      .describe("Resources to prune. Default: all"),
    dryRun: z
      .boolean()
      .optional()
      .describe("If true, show what would be removed. Default: true"),
  },
} as const;
 
// -- Types --------------------------------------------------------------------
 
interface PruneStats {
  containers: number;
  images:     number;
  volumes:    number;
  networks:   number;
}
 
interface DockerDfOutput {
  reclaimableMb: number;
  containers:    number;
  images:        number;
  volumes:       number;
}
 
// -- Helpers ------------------------------------------------------------------
 
async function isDockerInstalled(): Promise<boolean> {
  try {
    await execAsync("docker --version", { maxBuffer: 1 * 1024 * 1024 });
    return true;
  } catch {
    return false;
  }
}
 
async function isDockerRunning(): Promise<boolean> {
  try {
    await execAsync("docker info 2>/dev/null", { maxBuffer: 2 * 1024 * 1024 });
    return true;
  } catch {
    return false;
  }
}
 
/** Parse `docker system df` for a rough reclaimable estimate. */
async function getDockerDf(): Promise<DockerDfOutput> {
  try {
    const { stdout } = await execAsync(
      "docker system df --format '{{.Type}}\\t{{.Reclaimable}}' 2>/dev/null",
      { maxBuffer: 2 * 1024 * 1024 },
    );
    let reclaimableMb = 0;
    let containers    = 0;
    let images        = 0;
    let volumes       = 0;
 
    for (const line of stdout.trim().split("\n")) {
      const [type, reclaimStr] = line.split("\t");
      if (!reclaimStr) continue;
      // reclaimStr looks like "1.23GB (50%)" or "500MB"
      const sizeMatch = reclaimStr.match(/([\d.]+)\s*(B|KB|MB|GB|TB)/i);
      let mb = 0;
      if (sizeMatch) {
        const val  = parseFloat(sizeMatch[1]);
        const unit = sizeMatch[2].toUpperCase();
        if      (unit === "TB") mb = val * 1024 * 1024;
        else if (unit === "GB") mb = val * 1024;
        else if (unit === "MB") mb = val;
        else if (unit === "KB") mb = val / 1024;
        else                    mb = val / (1024 * 1024);
      }
      reclaimableMb += mb;
 
      if (type?.toLowerCase().includes("container")) containers++;
      if (type?.toLowerCase().includes("image"))     images++;
      if (type?.toLowerCase().includes("volume"))    volumes++;
    }
 
    return {
      reclaimableMb: Math.round(reclaimableMb * 100) / 100,
      containers,
      images,
      volumes,
    };
  } catch {
    return { reclaimableMb: 0, containers: 0, images: 0, volumes: 0 };
  }
}
 
/** Parse reclaimed bytes from docker prune stdout like "Total reclaimed space: 1.23GB" */
function parseReclaimedMb(stdout: string): number {
  const match = stdout.match(/Total reclaimed space:\s*([\d.]+)\s*(B|kB|MB|GB|TB)/i);
  if (!match) return 0;
  const val  = parseFloat(match[1]);
  const unit = match[2].toUpperCase();
  if      (unit === "TB") return val * 1024 * 1024;
  else if (unit === "GB") return val * 1024;
  else if (unit === "MB") return val;
  else if (unit === "KB") return val / 1024;
  return val / (1024 * 1024);
}
 
// -- Exported run function ----------------------------------------------------
 
export async function run({
  what   = ["all"],
  dryRun = true,
}: {
  what?:   Array<"containers" | "images" | "volumes" | "networks" | "all">;
  dryRun?: boolean;
} = {}) {
  const platform        = os.platform();
  const dockerInstalled = await isDockerInstalled();
 
  if (!dockerInstalled) {
    return {
      platform,
      dockerInstalled:    false,
      dryRun,
      reclaimedMb:        0,
      prunedContainers:   0,
      prunedImages:       0,
      prunedVolumes:      0,
      prunedNetworks:     0,
      message:            "Docker is not installed or not in PATH.",
    };
  }
 
  const dockerRunning = await isDockerRunning();
  if (!dockerRunning) {
    return {
      platform,
      dockerInstalled:    true,
      dryRun,
      reclaimedMb:        0,
      prunedContainers:   0,
      prunedImages:       0,
      prunedVolumes:      0,
      prunedNetworks:     0,
      message:            "Docker daemon is not running. Start Docker Desktop and try again.",
    };
  }
 
  const pruneAll  = what.includes("all");
  const doContainers = pruneAll || what.includes("containers");
  const doImages     = pruneAll || what.includes("images");
  const doVolumes    = pruneAll || what.includes("volumes");
  const doNetworks   = pruneAll || what.includes("networks");
 
  if (dryRun) {
    const df = await getDockerDf();
    return {
      platform,
      dockerInstalled:    true,
      dryRun:             true,
      reclaimedMb:        df.reclaimableMb,
      prunedContainers:   0,
      prunedImages:       0,
      prunedVolumes:      0,
      prunedNetworks:     0,
      message:
        `Dry run: approximately ${df.reclaimableMb} MB could be reclaimed. ` +
        "Run with dryRun=false to apply.",
    };
  }
 
  // Perform pruning
  let totalReclaimedMb = 0;
  const pruned: PruneStats = { containers: 0, images: 0, volumes: 0, networks: 0 };
 
  if (doContainers) {
    try {
      const { stdout } = await execAsync(
        "docker container prune -f 2>/dev/null",
        { maxBuffer: 5 * 1024 * 1024 },
      );
      const count = (stdout.match(/Deleted Containers:/g) ?? []).length;
      pruned.containers   = count;
      totalReclaimedMb   += parseReclaimedMb(stdout);
    } catch { /* ignore */ }
  }
 
  if (doImages) {
    try {
      const { stdout } = await execAsync(
        "docker image prune -f 2>/dev/null",
        { maxBuffer: 5 * 1024 * 1024 },
      );
      const count = (stdout.match(/sha256:/g) ?? []).length;
      pruned.images      = count;
      totalReclaimedMb  += parseReclaimedMb(stdout);
    } catch { /* ignore */ }
  }
 
  if (doVolumes) {
    try {
      const { stdout } = await execAsync(
        "docker volume prune -f 2>/dev/null",
        { maxBuffer: 5 * 1024 * 1024 },
      );
      pruned.volumes     = (stdout.match(/\n/g) ?? []).length;
      totalReclaimedMb  += parseReclaimedMb(stdout);
    } catch { /* ignore */ }
  }
 
  if (doNetworks) {
    try {
      await execAsync("docker network prune -f 2>/dev/null", { maxBuffer: 2 * 1024 * 1024 });
      pruned.networks = 1; // no count in output, just mark as done
    } catch { /* ignore */ }
  }
 
  return {
    platform,
    dockerInstalled:  true,
    dryRun:           false,
    reclaimedMb:      Math.round(totalReclaimedMb * 100) / 100,
    prunedContainers: pruned.containers,
    prunedImages:     pruned.images,
    prunedVolumes:    pruned.volumes,
    prunedNetworks:   pruned.networks,
    message:          "Docker resources pruned successfully.",
  };
}
 
// -- CLI smoke test -----------------------------------------------------------
 
if (false) {
  run({})
    .then(r => console.log(JSON.stringify(r, null, 2)))
    .catch((err: Error) => { console.error(err.message); process.exit(1); });
}