prune_docker

prune_docker

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.

Metadata

Name

prune_docker

Updated

last month

Source

GitHub source

Risk

Medium

Requires consent

true

Affected scope

user

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,
  footprint:       { kind: "external" },
  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); });
}