Code

/**
 * mcp/skills/getDockerDiskUsage.ts — get_docker_disk_usage skill
 *
 * Reports Docker disk-usage breakdown via `docker system df` without
 * pruning anything.  Read-only counterpart to `prune_docker`.
 *
 * Smoke test
 *   npx tsx -r dotenv/config mcp/skills/getDockerDiskUsage.ts
 *
 * NOTE: parsing logic is duplicated from `pruneDocker.ts` getDockerDf —
 * keep in sync if docker system df output format changes.
 */
 
import * as os from "os";
 
import { execAsync } from "./_shared/platform";
import { formatBytes } from "./_shared/formatBytes";
 
// -- Meta ---------------------------------------------------------------------
 
export const meta = {
  name: "get_docker_disk_usage",
  description:
    "Reports Docker disk usage (reclaimable space, container/image/volume counts) " +
    "via `docker system df` without modifying anything. Read-only counterpart to " +
    "prune_docker. Returns dockerInstalled:false when Docker is not available.",
  riskLevel:       "low",
  destructive:     false,
  requiresConsent: false,
  supportsDryRun:  false,
  affectedScope:   ["user"],
  auditRequired:   false,
  tccCategories:   [],
  schema:          {},
} as const;
 
// -- Types --------------------------------------------------------------------
 
export interface DockerBreakdownEntry {
  type:             string;
  totalCount:       number;
  activeCount:      number;
  size:             string;
  reclaimable:      string;
  reclaimableBytes: number;
}
 
export interface GetDockerDiskUsageResult {
  platform:               NodeJS.Platform;
  dockerInstalled:        boolean;
  dockerRunning:          boolean;
  totalReclaimableBytes:  number;
  /** Pre-formatted size of `totalReclaimableBytes` — see getAppCacheInfo.ts for rationale. */
  totalReclaimableHuman:  string;
  breakdown:              DockerBreakdownEntry[];
  errors?:                Array<{ scope: string; message: string }>;
}
 
// -- Helpers ------------------------------------------------------------------
 
function parseSizeToBytes(s: string): number {
  const m = s.match(/([\d.]+)\s*(B|KB|MB|GB|TB)/i);
  if (!m) return 0;
  const val  = parseFloat(m[1]);
  const unit = m[2].toUpperCase();
  if (unit === "TB") return Math.round(val * 1024 * 1024 * 1024 * 1024);
  if (unit === "GB") return Math.round(val * 1024 * 1024 * 1024);
  if (unit === "MB") return Math.round(val * 1024 * 1024);
  if (unit === "KB") return Math.round(val * 1024);
  return Math.round(val);
}
 
async function checkDockerInstalled(): Promise<boolean> {
  try {
    await execAsync("docker --version", { maxBuffer: 1 * 1024 * 1024, timeout: 5_000 });
    return true;
  } catch {
    return false;
  }
}
 
async function checkDockerRunning(): Promise<boolean> {
  // 5 s timeout: when Docker Desktop is installed but not running, `docker info`
  // can hang ~50 s before erroring out, eating most of the G4 ceiling. Treat a
  // hang as "daemon down" — same outcome as a non-zero exit.
  try {
    await execAsync("docker info 2>/dev/null", { maxBuffer: 2 * 1024 * 1024, timeout: 5_000 });
    return true;
  } catch {
    return false;
  }
}
 
// -- Exported run -------------------------------------------------------------
 
export async function run(_args: Record<string, never> = {}): Promise<GetDockerDiskUsageResult> {
  const platform = os.platform();
 
  const dockerInstalled = await checkDockerInstalled();
  if (!dockerInstalled) {
    return {
      platform,
      dockerInstalled: false,
      dockerRunning:   false,
      totalReclaimableBytes: 0,
      totalReclaimableHuman: formatBytes(0),
      breakdown: [],
    };
  }
 
  const dockerRunning = await checkDockerRunning();
  if (!dockerRunning) {
    return {
      platform,
      dockerInstalled: true,
      dockerRunning:   false,
      totalReclaimableBytes: 0,
      totalReclaimableHuman: formatBytes(0),
      breakdown: [],
      errors: [{ scope: "docker-daemon", message: "Docker is installed but the daemon is not running." }],
    };
  }
 
  // Use the columnar `docker system df` (not --format) so we get TOTAL/ACTIVE
  // counts in addition to size + reclaimable.  Output looks like:
  //   TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
  //   Images          12        3         2.5GB     1.8GB (72%)
  //   Containers      5         2         100MB     50MB (50%)
  //   Local Volumes   3         1         500MB     200MB (40%)
  //   Build Cache     0         0         0B        0B
  let stdout = "";
  try {
    ({ stdout } = await execAsync("docker system df", { maxBuffer: 4 * 1024 * 1024 }));
  } catch (err) {
    return {
      platform,
      dockerInstalled: true,
      dockerRunning:   true,
      totalReclaimableBytes: 0,
      totalReclaimableHuman: formatBytes(0),
      breakdown: [],
      errors: [{ scope: "docker-system-df", message: (err as Error).message }],
    };
  }
 
  const breakdown: DockerBreakdownEntry[] = [];
  let totalReclaimableBytes = 0;
  const lines = stdout.trim().split("\n");
 
  // Skip the header line; iterate data rows.
  for (let i = 1; i < lines.length; i++) {
    const row = lines[i].trim();
    if (!row) continue;
    // Tokenise on 2+ spaces to keep multi-word types ("Local Volumes",
    // "Build Cache") intact.
    const parts = row.split(/\s{2,}/);
    if (parts.length < 5) continue;
    const [type, totalStr, activeStr, sizeStr, reclaimableStr] = parts;
    const reclaimableBytes = parseSizeToBytes(reclaimableStr);
    totalReclaimableBytes += reclaimableBytes;
    breakdown.push({
      type,
      totalCount:       parseInt(totalStr, 10)  || 0,
      activeCount:      parseInt(activeStr, 10) || 0,
      size:             sizeStr,
      reclaimable:      reclaimableStr,
      reclaimableBytes,
    });
  }
 
  return {
    platform,
    dockerInstalled: true,
    dockerRunning:   true,
    totalReclaimableBytes,
    totalReclaimableHuman: formatBytes(totalReclaimableBytes),
    breakdown,
  };
}
 
// -- 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); });
}