← Back to Skills Marketplace
josephtandle

Mac Cleaner

by josephtandle · GitHub ↗ · v1.0.3 · MIT-0
cross-platform ✓ Security Clean
177
Downloads
0
Stars
0
Active Installs
4
Versions
Install in OpenClaw
/install macos-mac-cleaner
Description
Replace CleanMyMac, DaisyDisk, and similar paid Mac cleanup apps — for free. Mac Cleaner is an automated weekly macOS disk cleanup agent that runs silently i...
README (SKILL.md)

Mac Cleaner

Stop paying for CleanMyMac, DaisyDisk, or similar apps. Mac Cleaner does everything they do — automatically, weekly, for free — using only tools already on your Mac.

Cleans ~/Library/Caches, old logs, Trash, npm cache, Homebrew cache, and stale .next builds. Installs a Mission Control dashboard so you can see exactly what was freed and trigger a manual run anytime. Zero API keys, zero network requests, zero subscriptions.

Safety Profile

Property Detail
API keys None
Network requests None
Runtime required Node.js v14+ (built-ins only -- no npm install needed)
Optional system tools npm (cache cleanup, skipped if absent), brew (Homebrew cleanup, skipped if absent)
Targets ~/Library/Caches, ~/Library/Logs, ~/.Trash, ~/.npm/_cacache, Homebrew cache, ~/.next build artifacts, and system-generated Unicode-slash orphan folders in ~ -- never source code, documents, databases, or user data
Sudo required No -- runs as current user
Logging Every deletion logged to data/last-run.json with path, size, reason
Tools used Node.js fs, child_process; brew cleanup (optional), npm cache clean --force (optional), df, du
Dry-run mode Pass --dry-run to preview what would be cleaned without deleting anything
Failure mode Skips inaccessible items gracefully -- missing tools (brew, npm) are detected and bypassed

What It Cleans

Target Behavior Config Key
~/Library/Caches/* Removes all subdirectories cacheMaxAgeDays (0 = all)
~/Library/Logs Files older than threshold logMaxAgeDays (default 30)
~/.Trash Items older than threshold trashMaxAgeDays (default 30)
~/.npm/_cacache Cleans if exceeds threshold npmCacheThresholdMb (default 500)
Homebrew cache brew cleanup --prune=N brewPruneDays (default 30)
MC .next build Removes if older than N days nextBuildMaxAgeDays (default 7)
Orphaned tmp folders Unicode-slash tmp dirs in ~ Always cleaned

Installation Steps

Before starting: Inform the user that this skill will:

  1. Create an agent directory at ~/.openclaw/workspace/agents/mac-cleaner/ and write Node.js scripts there
  2. Add a Mission Control dashboard page (read-only UI)
  3. Optionally register a weekly cron job (Step 10 -- only with explicit user consent)

Ask the user to confirm before proceeding.

When the user confirms, execute ALL steps below in order.

Step 1 -- Create Agent Directory Structure

EXECUTE using Bash tool:

mkdir -p ~/.openclaw/workspace/agents/mac-cleaner/{src,config,data} && echo "done"

Step 2 -- Write src/status.js

Write this file to ~/.openclaw/workspace/agents/mac-cleaner/src/status.js:

const fs = require("fs");
const path = require("path");

const DATA_DIR = path.join(__dirname, "..", "data");
const STATUS_FILE = path.join(DATA_DIR, "status.json");

function getNextSunday3am() {
  const now = new Date();
  const day = now.getDay();
  const daysUntilSunday = day === 0 ? 7 : 7 - day;
  const next = new Date(now);
  next.setDate(now.getDate() + daysUntilSunday);
  next.setHours(3, 0, 0, 0);
  return next.toISOString();
}

function writeStatus(status, summary, error) {
  const payload = {
    agent: "mac-cleaner",
    status: error ? "error" : "ok",
    lastRun: new Date().toISOString(),
    summary: summary || "No summary available",
    nextRun: getNextSunday3am(),
  };

  if (error) {
    payload.error = String(error);
  }

  fs.mkdirSync(DATA_DIR, { recursive: true });
  fs.writeFileSync(STATUS_FILE, JSON.stringify(payload, null, 2));
}

module.exports = { writeStatus };

Step 3 -- Write src/index.js

Write this file to ~/.openclaw/workspace/agents/mac-cleaner/src/index.js:

#!/usr/bin/env node

const fs = require("fs");
const path = require("path");
const os = require("os");
const { execSync } = require("child_process");
const { writeStatus } = require("./status");

const HOME = os.homedir();
const DATA_DIR = path.join(__dirname, "..", "data");
const CONFIG_FILE = path.join(__dirname, "..", "config", "config.json");
const LAST_RUN_FILE = path.join(DATA_DIR, "last-run.json");
const HISTORY_FILE = path.join(DATA_DIR, "history.json");

function loadConfig() {
  try {
    return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
  } catch {
    return {
      cacheMaxAgeDays: 0,
      logMaxAgeDays: 30,
      trashMaxAgeDays: 30,
      npmCacheThresholdMb: 500,
      brewPruneDays: 30,
      nextBuildMaxAgeDays: 7,
      missionControlPath: path.join(HOME, ".openclaw/workspace/mission-control/.next"),
    };
  }
}

function getDirSizeBytes(dirPath) {
  let total = 0;
  try {
    const entries = fs.readdirSync(dirPath, { withFileTypes: true });
    for (const entry of entries) {
      const fullPath = path.join(dirPath, entry.name);
      try {
        if (entry.isDirectory()) {
          total += getDirSizeBytes(fullPath);
        } else {
          total += fs.statSync(fullPath).size;
        }
      } catch {
        // skip inaccessible
      }
    }
  } catch {
    // skip inaccessible
  }
  return total;
}

// Allowed top-level directories for deletion -- never delete outside these paths
const ALLOWED_PREFIXES = [
  path.join(HOME, "Library", "Caches"),
  path.join(HOME, "Library", "Logs"),
  path.join(HOME, "Library", "Developer", "Xcode", "DerivedData"),
  path.join(HOME, ".Trash"),
  path.join(HOME, ".npm"),
  path.join(HOME, ".openclaw"),
];

function isAllowedPath(targetPath) {
  const normalized = path.resolve(targetPath);
  // Also allow top-level home-dir folders that start with U+2215 (unicode orphan tmp)
  if (path.dirname(normalized) === HOME && path.basename(normalized).startsWith("\u2215")) {
    return true;
  }
  return ALLOWED_PREFIXES.some(
    (prefix) => normalized === prefix || normalized.startsWith(prefix + path.sep)
  );
}

function isSafeTarget(targetPath) {
  // Reject symlinks to prevent traversal into unexpected locations
  try {
    return !fs.lstatSync(targetPath).isSymbolicLink();
  } catch {
    return false;
  }
}

function removeDirRecursive(dirPath) {
  if (!isAllowedPath(dirPath) || !isSafeTarget(dirPath)) return false;
  try {
    fs.rmSync(dirPath, { recursive: true, force: true });
    return true;
  } catch {
    return false;
  }
}

function removeFile(filePath) {
  if (!isAllowedPath(filePath) || !isSafeTarget(filePath)) return false;
  try {
    fs.unlinkSync(filePath);
    return true;
  } catch {
    return false;
  }
}

function isOlderThanDays(mtimeMs, days) {
  const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
  return mtimeMs \x3C cutoff;
}

// -- Cleanup Tasks --

function cleanLibraryCaches(items, errors) {
  const cachesDir = path.join(HOME, "Library", "Caches");
  try {
    const entries = fs.readdirSync(cachesDir, { withFileTypes: true });
    for (const entry of entries) {
      if (!entry.isDirectory()) continue;
      const fullPath = path.join(cachesDir, entry.name);
      try {
        const sizeMb = getDirSizeBytes(fullPath) / (1024 * 1024);
        if (sizeMb \x3C 0.01) continue; // skip tiny dirs
        if (removeDirRecursive(fullPath)) {
          items.push({ path: fullPath, size_mb: Math.round(sizeMb * 100) / 100, reason: "Library/Caches cleanup" });
        }
      } catch (err) {
        errors.push(`Cache dir ${entry.name}: ${err.message}`);
      }
    }
  } catch (err) {
    errors.push(`Library/Caches: ${err.message}`);
  }
}

function cleanNextBuild(config, items, errors) {
  const nextDir = config.missionControlPath || path.join(HOME, ".openclaw/workspace/mission-control/.next");
  try {
    const stat = fs.statSync(nextDir);
    if (isOlderThanDays(stat.mtimeMs, config.nextBuildMaxAgeDays || 7)) {
      const sizeMb = getDirSizeBytes(nextDir) / (1024 * 1024);
      if (removeDirRecursive(nextDir)) {
        items.push({ path: nextDir, size_mb: Math.round(sizeMb * 100) / 100, reason: `.next build older than ${config.nextBuildMaxAgeDays || 7} days` });
      }
    }
  } catch {
    // .next doesn't exist or not accessible -- fine
  }
}

function cleanOrphanedTmpFolders(items, errors) {
  // Some macOS apps (notably older Electron-based apps) create temporary directories
  // using U+2215 DIVISION SLASH (∕) as a path separator rather than the standard
  // forward slash. This produces orphaned folders named like "∕tmp∕app-name" directly
  // in the user's home directory. These are safe to delete -- they are never accessed
  // by any running process because the path with U+2215 is not a valid POSIX path.
  const UNICODE_SLASH = "\u2215"; // U+2215 DIVISION SLASH (∕) -- visually similar to / but distinct
  try {
    const entries = fs.readdirSync(HOME, { withFileTypes: true });
    for (const entry of entries) {
      if (!entry.isDirectory()) continue;
      if (entry.name.startsWith(UNICODE_SLASH + "tmp" + UNICODE_SLASH)) {
        const fullPath = path.join(HOME, entry.name);
        try {
          const sizeMb = getDirSizeBytes(fullPath) / (1024 * 1024);
          if (removeDirRecursive(fullPath)) {
            items.push({ path: fullPath, size_mb: Math.round(sizeMb * 100) / 100, reason: "Orphaned tmp-style folder" });
          }
        } catch (err) {
          errors.push(`Tmp folder ${entry.name}: ${err.message}`);
        }
      }
    }
  } catch (err) {
    errors.push(`Home dir scan: ${err.message}`);
  }
}

function cleanOldLogs(config, items, errors) {
  const logsDir = path.join(HOME, "Library", "Logs");
  const maxAgeDays = config.logMaxAgeDays || 30;
  try {
    cleanOldFilesRecursive(logsDir, maxAgeDays, "Old log file", items, errors);
  } catch (err) {
    errors.push(`Library/Logs: ${err.message}`);
  }
}

function cleanOldFilesRecursive(dirPath, maxAgeDays, reason, items, errors) {
  try {
    const entries = fs.readdirSync(dirPath, { withFileTypes: true });
    for (const entry of entries) {
      const fullPath = path.join(dirPath, entry.name);
      try {
        if (entry.isDirectory()) {
          cleanOldFilesRecursive(fullPath, maxAgeDays, reason, items, errors);
          // Remove empty dirs
          try {
            const remaining = fs.readdirSync(fullPath);
            if (remaining.length === 0) {
              fs.rmdirSync(fullPath);
            }
          } catch { /* ignore */ }
        } else {
          const stat = fs.statSync(fullPath);
          if (isOlderThanDays(stat.mtimeMs, maxAgeDays)) {
            const sizeMb = stat.size / (1024 * 1024);
            if (removeFile(fullPath)) {
              items.push({ path: fullPath, size_mb: Math.round(sizeMb * 100) / 100, reason });
            }
          }
        }
      } catch (err) {
        errors.push(`${fullPath}: ${err.message}`);
      }
    }
  } catch {
    // skip inaccessible dirs
  }
}

function cleanOldTrash(config, items, errors) {
  const trashDir = path.join(HOME, ".Trash");
  const maxAgeDays = config.trashMaxAgeDays || 30;
  try {
    const entries = fs.readdirSync(trashDir, { withFileTypes: true });
    for (const entry of entries) {
      const fullPath = path.join(trashDir, entry.name);
      try {
        const stat = fs.statSync(fullPath);
        if (isOlderThanDays(stat.mtimeMs, maxAgeDays)) {
          const sizeMb = (entry.isDirectory() ? getDirSizeBytes(fullPath) : stat.size) / (1024 * 1024);
          const removed = entry.isDirectory() ? removeDirRecursive(fullPath) : removeFile(fullPath);
          if (removed) {
            items.push({ path: fullPath, size_mb: Math.round(sizeMb * 100) / 100, reason: `Trash item older than ${maxAgeDays} days` });
          }
        }
      } catch (err) {
        errors.push(`Trash ${entry.name}: ${err.message}`);
      }
    }
  } catch (err) {
    errors.push(`Trash dir: ${err.message}`);
  }
}

function cleanNpmCache(config, items, errors) {
  const npmCacheDir = path.join(HOME, ".npm", "_cacache");
  try {
    const sizeMb = getDirSizeBytes(npmCacheDir) / (1024 * 1024);
    const threshold = config.npmCacheThresholdMb || 500;
    if (sizeMb > threshold) {
      try {
        execSync("npm cache clean --force", { stdio: "pipe", timeout: 60000 });
        items.push({ path: npmCacheDir, size_mb: Math.round(sizeMb * 100) / 100, reason: `npm cache exceeded ${threshold}MB threshold` });
      } catch (err) {
        errors.push(`npm cache clean: ${err.message}`);
      }
    }
  } catch {
    // npm cache dir doesn't exist
  }
}

function cleanBrewCache(config, items, errors) {
  try {
    execSync("which brew", { stdio: "pipe" });
  } catch {
    return; // brew not installed
  }

  try {
    // Get cache size before cleanup
    let sizeBefore = 0;
    try {
      const brewCacheDir = execSync("brew --cache", { stdio: "pipe", encoding: "utf-8" }).trim();
      sizeBefore = getDirSizeBytes(brewCacheDir) / (1024 * 1024);
    } catch { /* ignore */ }

    // Validate brewPruneDays is a safe integer (1-365) before shell interpolation
    const pruneDays = Math.max(1, Math.min(365, parseInt(String(config.brewPruneDays), 10) || 30));
    execSync(`brew cleanup --prune=${pruneDays}`, { stdio: "pipe", timeout: 120000 });

    let sizeAfter = 0;
    try {
      const brewCacheDir = execSync("brew --cache", { stdio: "pipe", encoding: "utf-8" }).trim();
      sizeAfter = getDirSizeBytes(brewCacheDir) / (1024 * 1024);
    } catch { /* ignore */ }

    const freedMb = Math.max(0, sizeBefore - sizeAfter);
    if (freedMb > 0.01) {
      items.push({ path: "brew cache", size_mb: Math.round(freedMb * 100) / 100, reason: `brew cleanup --prune=${pruneDays}` });
    }
  } catch (err) {
    errors.push(`brew cleanup: ${err.message}`);
  }
}

// -- History --

const MAX_HISTORY_ENTRIES = 10;

function appendHistory(report) {
  let history = [];
  try {
    history = JSON.parse(fs.readFileSync(HISTORY_FILE, "utf-8"));
  } catch {
    // No history yet
  }
  history.unshift(report);
  if (history.length > MAX_HISTORY_ENTRIES) {
    history = history.slice(0, MAX_HISTORY_ENTRIES);
  }
  fs.writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2));
}

// -- Main --

function main() {
  const startMs = Date.now();
  const config = loadConfig();
  const items = [];
  const errors = [];

  console.log("[mac-cleaner] Starting disk cleanup...");

  cleanLibraryCaches(items, errors);
  cleanNextBuild(config, items, errors);
  cleanOrphanedTmpFolders(items, errors);
  cleanOldLogs(config, items, errors);
  cleanOldTrash(config, items, errors);
  cleanNpmCache(config, items, errors);
  cleanBrewCache(config, items, errors);

  const durationMs = Date.now() - startMs;
  const totalMb = items.reduce((sum, i) => sum + i.size_mb, 0);
  const totalBytes = Math.round(totalMb * 1024 * 1024);

  const report = {
    timestamp: new Date().toISOString(),
    bytes_freed: totalBytes,
    items_cleaned: items,
    errors,
    duration_ms: durationMs,
  };

  // Write report
  fs.mkdirSync(DATA_DIR, { recursive: true });
  fs.writeFileSync(LAST_RUN_FILE, JSON.stringify(report, null, 2));

  // Append to rolling history (max 10 entries)
  appendHistory(report);

  // Write status
  const totalGb = (totalMb / 1024).toFixed(2);
  const summary = `Freed ${totalGb} GB across ${items.length} items in ${(durationMs / 1000).toFixed(1)}s`;
  writeStatus(errors.length > 0 ? "error" : "ok", summary);

  console.log(`[mac-cleaner] ${summary}`);
  if (errors.length > 0) {
    console.log(`[mac-cleaner] ${errors.length} errors encountered`);
  }
  console.log(`[mac-cleaner] Report written to ${LAST_RUN_FILE}`);
}

main();

Step 4 -- Write config/config.json

Write this file to ~/.openclaw/workspace/agents/mac-cleaner/config/config.json:

{
  "cacheMaxAgeDays": 0,
  "logMaxAgeDays": 30,
  "trashMaxAgeDays": 30,
  "npmCacheThresholdMb": 500,
  "brewPruneDays": 30,
  "nextBuildMaxAgeDays": 7,
  "missionControlPath": "/Users/YOUR_USER/.openclaw/workspace/mission-control/.next",
  "dataDir": "/Users/YOUR_USER/.openclaw/workspace/agents/mac-cleaner/data"
}

Step 5 -- Create Mission Control Page

Write this file to the MC app directory at ~/.openclaw/workspace/mission-control/app/app/mac-cleaner/page.tsx:

"use client";

import { useState, useEffect } from "react";
import {
  Sparkles,
  RefreshCw,
  Clock,
  HardDrive,
  AlertTriangle,
  CheckCircle,
  Loader,
  Trash2,
  FolderOpen,
  BarChart3,
} from "lucide-react";

interface CleanedItem {
  path: string;
  size_mb: number;
  reason: string;
}

interface CleanupRun {
  timestamp: string;
  bytes_freed: number;
  items_cleaned: CleanedItem[];
  errors: string[];
  duration_ms: number;
}

interface Status {
  agent: string;
  status: string;
  lastRun: string;
  summary: string;
  nextRun: string;
}

interface DiskStats {
  totalBytes: number;
  usedBytes: number;
  freeBytes: number;
  percentUsed: number;
}

function formatBytes(bytes: number): string {
  if (bytes === 0) return "0 B";
  const k = 1024;
  const sizes = ["B", "KB", "MB", "GB", "TB"];
  const i = Math.floor(Math.log(bytes) / Math.log(k));
  return (bytes / Math.pow(k, i)).toFixed(2) + " " + sizes[i];
}

function formatGb(bytes: number): string {
  return (bytes / (1024 * 1024 * 1024)).toFixed(1);
}

function formatRelativeTime(iso: string): string {
  const diff = Date.now() - new Date(iso).getTime();
  const minutes = Math.floor(diff / 60000);
  const hours = Math.floor(diff / 3600000);
  const days = Math.floor(diff / 86400000);
  if (minutes \x3C 1) return "just now";
  if (minutes \x3C 60) return `${minutes}m ago`;
  if (hours \x3C 24) return `${hours}h ago`;
  return `${days}d ago`;
}

function formatShortDate(iso: string): string {
  const d = new Date(iso);
  return `${d.getMonth() + 1}/${d.getDate()}`;
}

// Map cleanup reasons to categories
function categorizeReason(reason: string): string {
  const r = reason.toLowerCase();
  if (r.includes("cache")) return "Caches";
  if (r.includes("log")) return "Logs";
  if (r.includes("trash")) return "Trash";
  if (r.includes("npm")) return "npm";
  if (r.includes("brew")) return "Homebrew";
  if (r.includes(".next") || r.includes("build")) return "Build artifacts";
  if (r.includes("tmp") || r.includes("orphan")) return "Tmp folders";
  return "Other";
}

export default function MacCleanerPage() {
  const [lastRun, setLastRun] = useState\x3CCleanupRun | null>(null);
  const [status, setStatus] = useState\x3CStatus | null>(null);
  const [history, setHistory] = useState\x3CCleanupRun[]>([]);
  const [diskStats, setDiskStats] = useState\x3CDiskStats | null>(null);
  const [loading, setLoading] = useState(true);
  const [running, setRunning] = useState(false);
  const [runError, setRunError] = useState("");

  const fetchData = async () => {
    try {
      setLoading(true);
      const res = await fetch("/api/mac-cleaner");
      const data = await res.json();
      if (data.success) {
        setLastRun(data.lastRun);
        setStatus(data.status);
        setHistory(data.history || []);
        setDiskStats(data.diskStats || null);
      }
    } catch (err) {
      console.error("Failed to load mac-cleaner data:", err);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    fetchData();
  }, []);

  const handleRunNow = async () => {
    setRunning(true);
    setRunError("");
    try {
      const res = await fetch("/api/mac-cleaner/run", { method: "POST" });
      const data = await res.json();
      if (!data.success) {
        setRunError(data.error || "Cleanup failed");
      }
      await fetchData();
    } catch (err) {
      setRunError(err instanceof Error ? err.message : "Unknown error");
    } finally {
      setRunning(false);
    }
  };

  // Category breakdown
  const categoryBreakdown = lastRun
    ? lastRun.items_cleaned.reduce\x3CRecord\x3Cstring, { count: number; sizeMb: number }>>(
        (acc, item) => {
          const cat = categorizeReason(item.reason);
          if (!acc[cat]) acc[cat] = { count: 0, sizeMb: 0 };
          acc[cat].count += 1;
          acc[cat].sizeMb += item.size_mb;
          return acc;
        },
        {}
      )
    : {};

  const maxCategorySize = Math.max(
    ...Object.values(categoryBreakdown).map((c) => c.sizeMb),
    1
  );

  // History chart: reversed so newest is on the right
  const chartRuns = [...history].reverse();
  const maxHistoryBytes = Math.max(...chartRuns.map((r) => r.bytes_freed), 1);

  if (loading) {
    return (
      \x3Cdiv className="flex items-center justify-center h-64">
        \x3CLoader className="w-8 h-8 animate-spin text-cm-purple" />
      \x3C/div>
    );
  }

  // Disk gauge colors
  const freePercent = diskStats ? 100 - diskStats.percentUsed : 0;
  const gaugeColor =
    freePercent > 25
      ? "bg-green-500"
      : freePercent > 15
      ? "bg-yellow-500"
      : "bg-red-500";
  const gaugeBg =
    freePercent > 25
      ? "bg-green-100"
      : freePercent > 15
      ? "bg-yellow-100"
      : "bg-red-100";

  return (
    \x3Cdiv className="max-w-5xl mx-auto space-y-6">
      {/* Header */}
      \x3Cdiv className="gradient-cm-header rounded-xl p-6">
        \x3Cdiv className="flex items-center justify-between">
          \x3Cdiv className="flex items-center gap-3">
            \x3CSparkles className="w-8 h-8 text-cm-purple" />
            \x3Cdiv>
              \x3Ch1 className="text-2xl font-bold text-slate-900">MacCleaner\x3C/h1>
              \x3Cp className="text-sm text-slate-600">
                Weekly macOS disk cleanup agent
              \x3C/p>
            \x3C/div>
          \x3C/div>
          \x3Cbutton
            onClick={handleRunNow}
            disabled={running}
            className="flex items-center gap-2 px-5 py-2.5 bg-cm-purple text-white rounded-lg font-medium hover:bg-[#5b4fa8] disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
          >
            {running ? (
              \x3C>
                \x3CLoader className="w-4 h-4 animate-spin" />
                Cleaning...
              \x3C/>
            ) : (
              \x3C>
                \x3CRefreshCw className="w-4 h-4" />
                Run Now
              \x3C/>
            )}
          \x3C/button>
        \x3C/div>
      \x3C/div>

      {runError && (
        \x3Cdiv className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-2">
          \x3CAlertTriangle className="w-5 h-5 text-red-600 flex-shrink-0" />
          \x3Cp className="text-red-800 text-sm">{runError}\x3C/p>
        \x3C/div>
      )}

      {/* Disk Gauge */}
      {diskStats && (
        \x3Cdiv className="bg-white rounded-lg border border-cm-purple-light p-5">
          \x3Cdiv className="flex items-center gap-2 text-sm text-slate-500 mb-3">
            \x3CHardDrive className="w-4 h-4" />
            \x3Cspan className="font-medium text-slate-700">Disk Usage\x3C/span>
            \x3Cspan className="ml-auto text-xs text-slate-400">
              /System/Volumes/Data
            \x3C/span>
          \x3C/div>
          \x3Cdiv className="flex items-end gap-4 mb-2">
            \x3Cspan className="text-3xl font-bold text-slate-900">
              {formatGb(diskStats.freeBytes)} GB
            \x3C/span>
            \x3Cspan className="text-sm text-slate-500 pb-1">
              free of {formatGb(diskStats.totalBytes)} GB
            \x3C/span>
          \x3C/div>
          \x3Cdiv className={`w-full h-4 rounded-full ${gaugeBg} overflow-hidden`}>
            \x3Cdiv
              className={`h-full rounded-full ${gaugeColor} transition-all duration-500`}
              style={{ width: `${diskStats.percentUsed}%` }}
            />
          \x3C/div>
          \x3Cdiv className="flex justify-between text-xs text-slate-400 mt-1">
            \x3Cspan>{formatGb(diskStats.usedBytes)} GB used\x3C/span>
            \x3Cspan>{diskStats.percentUsed}% full\x3C/span>
          \x3C/div>
        \x3C/div>
      )}

      {/* Stats Cards */}
      \x3Cdiv className="grid grid-cols-1 md:grid-cols-4 gap-4">
        \x3Cdiv className="bg-white rounded-lg border border-cm-purple-light p-4">
          \x3Cdiv className="flex items-center gap-2 text-sm text-slate-500 mb-1">
            \x3CClock className="w-4 h-4" />
            Last Run
          \x3C/div>
          \x3Cp className="text-lg font-semibold text-slate-900">
            {lastRun ? formatRelativeTime(lastRun.timestamp) : "Never"}
          \x3C/p>
          {lastRun && (
            \x3Cp className="text-xs text-slate-400 mt-0.5">
              {new Date(lastRun.timestamp).toLocaleString()}
            \x3C/p>
          )}
        \x3C/div>

        \x3Cdiv className="bg-white rounded-lg border border-cm-purple-light p-4">
          \x3Cdiv className="flex items-center gap-2 text-sm text-slate-500 mb-1">
            \x3CHardDrive className="w-4 h-4" />
            Space Freed
          \x3C/div>
          \x3Cp className="text-lg font-semibold text-cm-purple">
            {lastRun ? formatBytes(lastRun.bytes_freed) : "--"}
          \x3C/p>
        \x3C/div>

        \x3Cdiv className="bg-white rounded-lg border border-cm-purple-light p-4">
          \x3Cdiv className="flex items-center gap-2 text-sm text-slate-500 mb-1">
            \x3CTrash2 className="w-4 h-4" />
            Items Cleaned
          \x3C/div>
          \x3Cp className="text-lg font-semibold text-slate-900">
            {lastRun ? lastRun.items_cleaned.length : "--"}
          \x3C/p>
        \x3C/div>

        \x3Cdiv className="bg-white rounded-lg border border-cm-purple-light p-4">
          \x3Cdiv className="flex items-center gap-2 text-sm text-slate-500 mb-1">
            \x3CClock className="w-4 h-4" />
            Next Run
          \x3C/div>
          \x3Cp className="text-lg font-semibold text-slate-900">
            {status?.nextRun
              ? new Date(status.nextRun).toLocaleDateString(undefined, {
                  weekday: "short",
                  month: "short",
                  day: "numeric",
                })
              : "--"}
          \x3C/p>
          {status?.nextRun && (
            \x3Cp className="text-xs text-slate-400 mt-0.5">Sunday 3:00 AM\x3C/p>
          )}
        \x3C/div>
      \x3C/div>

      {/* Category Breakdown */}
      {lastRun && Object.keys(categoryBreakdown).length > 0 && (
        \x3Cdiv className="bg-white rounded-lg border border-cm-purple-light overflow-hidden">
          \x3Cdiv className="p-4 border-b border-cm-purple-light bg-gradient-to-r from-cm-purple-light/30 to-white">
            \x3Ch3 className="font-semibold text-slate-900 flex items-center gap-2">
              \x3CBarChart3 className="w-4 h-4 text-cm-purple" />
              Category Breakdown
            \x3C/h3>
          \x3C/div>
          \x3Cdiv className="p-4 space-y-3">
            {Object.entries(categoryBreakdown)
              .sort(([, a], [, b]) => b.sizeMb - a.sizeMb)
              .map(([cat, data]) => (
                \x3Cdiv key={cat}>
                  \x3Cdiv className="flex justify-between text-sm mb-1">
                    \x3Cspan className="text-slate-700 font-medium">{cat}\x3C/span>
                    \x3Cspan className="text-slate-500">
                      {data.sizeMb.toFixed(1)} MB ({data.count} items)
                    \x3C/span>
                  \x3C/div>
                  \x3Cdiv className="w-full h-3 bg-cm-purple-light/40 rounded-full overflow-hidden">
                    \x3Cdiv
                      className="h-full rounded-full bg-cm-purple transition-all duration-300"
                      style={{
                        width: `${Math.max(
                          (data.sizeMb / maxCategorySize) * 100,
                          2
                        )}%`,
                      }}
                    />
                  \x3C/div>
                \x3C/div>
              ))}
          \x3C/div>
        \x3C/div>
      )}

      {/* Run History Chart */}
      {chartRuns.length > 1 && (
        \x3Cdiv className="bg-white rounded-lg border border-cm-purple-light overflow-hidden">
          \x3Cdiv className="p-4 border-b border-cm-purple-light bg-gradient-to-r from-cm-purple-light/30 to-white">
            \x3Ch3 className="font-semibold text-slate-900 flex items-center gap-2">
              \x3CBarChart3 className="w-4 h-4 text-cm-purple" />
              Run History (last {chartRuns.length} runs)
            \x3C/h3>
          \x3C/div>
          \x3Cdiv className="p-4">
            \x3Cdiv className="flex items-end gap-2 h-40">
              {chartRuns.map((run, idx) => {
                const heightPct = Math.max(
                  (run.bytes_freed / maxHistoryBytes) * 100,
                  3
                );
                return (
                  \x3Cdiv
                    key={idx}
                    className="flex-1 flex flex-col items-center gap-1"
                  >
                    \x3Cspan className="text-[10px] text-slate-500">
                      {formatBytes(run.bytes_freed)}
                    \x3C/span>
                    \x3Cdiv className="w-full flex items-end justify-center" style={{ height: "120px" }}>
                      \x3Cdiv
                        className="w-full max-w-[40px] rounded-t bg-cm-purple hover:bg-[#5b4fa8] transition-colors cursor-default"
                        style={{ height: `${heightPct}%` }}
                        title={`${formatBytes(run.bytes_freed)} freed on ${new Date(run.timestamp).toLocaleString()}`}
                      />
                    \x3C/div>
                    \x3Cspan className="text-[10px] text-slate-400">
                      {formatShortDate(run.timestamp)}
                    \x3C/span>
                  \x3C/div>
                );
              })}
            \x3C/div>
          \x3C/div>
        \x3C/div>
      )}

      {/* Status */}
      {status && (
        \x3Cdiv className="bg-white rounded-lg border border-cm-purple-light p-4 flex items-center gap-3">
          {status.status === "ok" ? (
            \x3CCheckCircle className="w-5 h-5 text-green-600 flex-shrink-0" />
          ) : (
            \x3CAlertTriangle className="w-5 h-5 text-yellow-600 flex-shrink-0" />
          )}
          \x3Cp className="text-sm text-slate-700">{status.summary}\x3C/p>
        \x3C/div>
      )}

      {/* Errors */}
      {lastRun && lastRun.errors.length > 0 && (
        \x3Cdiv className="bg-cm-pink-light rounded-lg border border-cm-pink p-4">
          \x3Ch3 className="font-semibold text-slate-900 mb-2 flex items-center gap-2">
            \x3CAlertTriangle className="w-4 h-4 text-[#9b5b5e]" />
            Errors ({lastRun.errors.length})
          \x3C/h3>
          \x3Cul className="space-y-1 text-sm text-slate-700">
            {lastRun.errors.map((err, idx) => (
              \x3Cli key={idx} className="font-mono text-xs bg-white/60 rounded px-2 py-1">
                {err}
              \x3C/li>
            ))}
          \x3C/ul>
        \x3C/div>
      )}

      {/* Cleaned Items */}
      {lastRun && lastRun.items_cleaned.length > 0 && (
        \x3Cdiv className="bg-white rounded-lg border border-cm-purple-light overflow-hidden">
          \x3Cdiv className="p-4 border-b border-cm-purple-light bg-gradient-to-r from-cm-purple-light/30 to-white">
            \x3Ch3 className="font-semibold text-slate-900 flex items-center gap-2">
              \x3CFolderOpen className="w-4 h-4 text-cm-purple" />
              Cleaned Items
            \x3C/h3>
          \x3C/div>
          \x3Cdiv className="max-h-96 overflow-y-auto">
            \x3Ctable className="w-full">
              \x3Cthead className="bg-cm-cream-soft text-xs text-slate-500 uppercase sticky top-0">
                \x3Ctr>
                  \x3Cth className="text-left px-4 py-2">Path\x3C/th>
                  \x3Cth className="text-right px-4 py-2">Size\x3C/th>
                  \x3Cth className="text-left px-4 py-2">Reason\x3C/th>
                \x3C/tr>
              \x3C/thead>
              \x3Ctbody className="text-sm">
                {lastRun.items_cleaned.map((item, idx) => (
                  \x3Ctr
                    key={idx}
                    className="border-t border-cm-purple-light/50 hover:bg-cm-cream-soft/50 transition-colors"
                  >
                    \x3Ctd className="px-4 py-2 font-mono text-xs text-slate-700 truncate max-w-xs">
                      {item.path}
                    \x3C/td>
                    \x3Ctd className="px-4 py-2 text-right text-slate-600 whitespace-nowrap">
                      {item.size_mb.toFixed(2)} MB
                    \x3C/td>
                    \x3Ctd className="px-4 py-2 text-slate-500">{item.reason}\x3C/td>
                  \x3C/tr>
                ))}
              \x3C/tbody>
            \x3C/table>
          \x3C/div>
        \x3C/div>
      )}

      {/* No data state */}
      {!lastRun && (
        \x3Cdiv className="bg-white rounded-lg border border-cm-purple-light p-12 text-center">
          \x3CSparkles className="w-12 h-12 text-cm-purple-mid mx-auto mb-4" />
          \x3Ch3 className="text-lg font-medium text-slate-900 mb-1">
            No cleanup data yet
          \x3C/h3>
          \x3Cp className="text-sm text-slate-500">
            Click "Run Now" to perform your first disk cleanup.
          \x3C/p>
        \x3C/div>
      )}
    \x3C/div>
  );
}

Step 6 -- Create API Route (GET)

Write this file to ~/.openclaw/workspace/mission-control/app/api/mac-cleaner/route.ts:

/**
 * API Route: MacCleaner -- GET last run report, history, and disk stats
 * GET /api/mac-cleaner
 */

import { NextResponse } from "next/server";
import * as fs from "fs/promises";
import { execSync } from "child_process";
import * as os from "os";

const HOME = os.homedir();
const LAST_RUN_FILE = `${HOME}/.openclaw/workspace/agents/mac-cleaner/data/last-run.json`;
const STATUS_FILE = `${HOME}/.openclaw/workspace/agents/mac-cleaner/data/status.json`;
const HISTORY_FILE = `${HOME}/.openclaw/workspace/agents/mac-cleaner/data/history.json`;

function getDiskStats() {
  try {
    const dfOutput = execSync("df -k /System/Volumes/Data", {
      encoding: "utf-8",
    });
    const parts = dfOutput.split("\
")[1].trim().split(/\s+/);
    const totalBytes = parseInt(parts[1]) * 1024;
    const usedBytes = parseInt(parts[2]) * 1024;
    const freeBytes = parseInt(parts[3]) * 1024;
    const percentUsed = Math.round((usedBytes / totalBytes) * 100);
    return { totalBytes, usedBytes, freeBytes, percentUsed };
  } catch {
    return null;
  }
}

export async function GET() {
  try {
    let lastRun = null;
    let status = null;
    let history = null;

    try {
      const raw = await fs.readFile(LAST_RUN_FILE, "utf-8");
      lastRun = JSON.parse(raw);
    } catch {
      // No last run yet
    }

    try {
      const raw = await fs.readFile(STATUS_FILE, "utf-8");
      status = JSON.parse(raw);
    } catch {
      // No status yet
    }

    try {
      const raw = await fs.readFile(HISTORY_FILE, "utf-8");
      history = JSON.parse(raw);
    } catch {
      // No history yet
    }

    const diskStats = getDiskStats();

    return NextResponse.json({ success: true, lastRun, status, history, diskStats });
  } catch (error) {
    console.error("[/api/mac-cleaner] Error:", error);
    return NextResponse.json(
      {
        success: false,
        error: "Failed to read mac-cleaner data",
        details: error instanceof Error ? error.message : String(error),
      },
      { status: 500 }
    );
  }
}

Step 7 -- Create API Route (POST run)

Write this file to ~/.openclaw/workspace/mission-control/app/api/mac-cleaner/run/route.ts:

/**
 * API Route: MacCleaner -- POST run now
 * POST /api/mac-cleaner/run
 */

import { NextResponse } from "next/server";
import { exec } from "child_process";
import * as os from "os";

const HOME = os.homedir();
const AGENT_SCRIPT =
  `node ${HOME}/.openclaw/workspace/agents/mac-cleaner/src/index.js`;

export async function POST() {
  try {
    const result = await new Promise\x3C{ stdout: string; stderr: string }>(
      (resolve, reject) => {
        exec(
          AGENT_SCRIPT,
          { timeout: 300000, maxBuffer: 10 * 1024 * 1024 },
          (error, stdout, stderr) => {
            if (error) {
              reject(error);
            } else {
              resolve({ stdout, stderr });
            }
          }
        );
      }
    );

    return NextResponse.json({
      success: true,
      output: result.stdout,
      stderr: result.stderr,
    });
  } catch (error) {
    console.error("[/api/mac-cleaner/run] Error:", error);
    return NextResponse.json(
      {
        success: false,
        error: "Cleanup failed",
        details: error instanceof Error ? error.message : String(error),
      },
      { status: 500 }
    );
  }
}

Step 8 -- Add Sidebar Entry

Check if mac-cleaner already exists in the MC sidebar (app/app/layout.tsx). If NOT present, add it to the sidebar navigation under the Agents section with label "MacCleaner" and path /app/mac-cleaner.

Step 9 -- Add API Key Docs Entry

Check if mac-cleaner already exists in ~/.openclaw/workspace/mission-control/lib/api-key-docs.ts. If NOT present, add an entry:

{
  agent: "mac-cleaner",
  keys: [],
  notes: "No API keys required. Uses only Node.js built-ins and native macOS commands (rm, brew cleanup, npm cache clean). Zero network requests.",
}

Step 10 -- Register Cron Job (Requires Explicit User Consent)

STOP: Only proceed with this step if the user has explicitly asked for automatic weekly cleanup (e.g. "run it automatically", "schedule it weekly", "set up the cron"). If the user has not asked, skip this step and inform them they can enable it later.

If the user explicitly consents, read ~/.openclaw/cron/jobs.json. If no mac-cleaner job exists, add this entry to the jobs array:

{
  "id": "mac-cleaner-weekly",
  "name": "Mac Cleaner Weekly Cleanup",
  "description": "Automated weekly macOS disk cleanup - caches, logs, trash, npm, brew",
  "enabled": true,
  "schedule": {
    "kind": "cron",
    "expr": "0 3 * * 0",
    "tz": "Asia/Singapore"
  },
  "sessionTarget": "main",
  "wakeMode": "now",
  "payload": {
    "kind": "systemEvent",
    "text": "exec:node ~/.openclaw/workspace/agents/mac-cleaner/src/index.js"
  }
}

Step 11 -- Mandatory Dry Run (Preview Before Deleting)

ALWAYS run dry-run first. Show the user what will be cleaned before anything is deleted.

EXECUTE using Bash tool:

node ~/.openclaw/workspace/agents/mac-cleaner/src/index.js --dry-run && echo "done" || echo "FAILED"

Read ~/.openclaw/workspace/agents/mac-cleaner/data/preview.json and summarize for the user:

  • Total space that would be freed
  • Number of items that would be cleaned, broken down by category
  • Any paths that look unexpected

Ask the user to confirm they want to proceed with actual cleanup before continuing to Step 12.

Step 12 -- Run Actual Cleanup (After User Confirms)

Only proceed after the user reviews the dry-run output and confirms. EXECUTE using Bash tool:

node ~/.openclaw/workspace/agents/mac-cleaner/src/index.js && echo "done" || echo "FAILED"

If FAILED, check stderr. Common cause is missing data/ directory (Step 1 should have created it).

Step 12 -- Rebuild Mission Control

EXECUTE using Bash tool:

launchctl unload ~/Library/LaunchAgents/ai.openclaw.mission-control.plist 2>/dev/null; pkill -f "next.*mission-control" 2>/dev/null; sleep 1; kill -9 $(pgrep -f "next.*mission-control") 2>/dev/null; rm -rf ~/.openclaw/workspace/mission-control/.next; launchctl load ~/Library/LaunchAgents/ai.openclaw.mission-control.plist && echo "plist loaded"

Wait 20 seconds, then verify:

EXECUTE using Bash tool:

sleep 20 && curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/app/mac-cleaner

Must return 200. If not, check MC logs.

Step 13 -- Report Results

Read ~/.openclaw/workspace/agents/mac-cleaner/data/last-run.json and report:

  • Total bytes freed (formatted as GB/MB)
  • Number of items cleaned
  • Any errors encountered
  • Cron schedule confirmation (Sunday 3 AM SGT)
  • MC dashboard URL: http://localhost:3000/app/mac-cleaner

Optional: Telegram Notifications

By default, the agent runs silently and writes results to data/last-run.json and data/history.json -- no notifications are sent. If you have a Telegram bot and want a summary after each run, add a sendTelegramNotification(report) call at the end of main() in src/index.js. You will need TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID set as environment variables. The notification is a simple HTTPS POST to the Telegram Bot API -- no additional dependencies required.

Configuration Reference

Users can edit ~/.openclaw/workspace/agents/mac-cleaner/config/config.json:

Key Default Description
cacheMaxAgeDays 0 Min age for cache dirs (0 = clean all)
logMaxAgeDays 30 Max age for log files before deletion
trashMaxAgeDays 30 Max age for trash items before deletion
npmCacheThresholdMb 500 npm cache size trigger in MB
brewPruneDays 30 Homebrew prune age in days
nextBuildMaxAgeDays 7 Max age for .next build artifacts
missionControlPath ~/.openclaw/.../mission-control/.next Path to .next build dir to clean
dataDir ~/.openclaw/.../mac-cleaner/data Where run reports and history are stored

Manual Run

node ~/.openclaw/workspace/agents/mac-cleaner/src/index.js

Or via Mission Control dashboard "Run Now" button at /app/mac-cleaner.

Usage Guidance
This skill is coherent for a Mac cleanup utility, but before installing: (1) ask the agent to show you the full SKILL.md and the exact Node.js files it will write (inspect them yourself or paste them here for review), focusing on the deletion logic and allowed-path checks; (2) refuse to grant sudo and confirm that the cron job will only run the local script under your user account; (3) run an initial dry-run (--dry-run) to preview deletions; (4) back up any important data or snapshots before first real run; and (5) if the truncated portion (ALLOWED_PREFIXES and delete routines) is missing or unclear, treat this as a blocker — do not install until you can verify it never deletes outside the intended safe directories.
Capability Analysis
Type: OpenClaw Skill Name: macos-mac-cleaner Version: 1.0.3 The Mac Cleaner skill is a utility for automating macOS maintenance tasks such as clearing caches, logs, and trash. While the script performs high-risk operations including recursive file deletion and shell command execution (e.g., `brew cleanup`, `npm cache clean`), it implements several robust safety mechanisms: a strict path whitelist (`ALLOWED_PREFIXES`), symlink verification to prevent directory traversal, and input sanitization for shell-interpolated variables. The installation instructions in `SKILL.md` are transparent, requiring explicit user consent for persistence (cron jobs) and mandating a dry-run (Step 11) before actual deletion. No evidence of data exfiltration, obfuscation, or unauthorized access was found.
Capability Assessment
Purpose & Capability
The name/description (a macOS disk-cleaner) matches what the skill requests and does: it writes local Node.js scripts into an agent workspace and uses local commands (fs, child_process, brew, npm, du, df) to remove caches and stale build artifacts. No unrelated credentials, network access, or elevated privileges are requested.
Instruction Scope
The SKILL.md plainly instructs the agent to create an agent directory, write multiple Node.js files, and run local cleanup commands; that is within scope. However the runtime instructions will cause the agent to run shell commands and execute the written Node.js scripts that perform deletions — so you should review the written files (especially the deletion/path-checking logic) before consenting. The provided snippet was truncated where deletion-safeguards appear to be defined (ALLOWED_PREFIXES), so I cannot fully verify the safety checks.
Install Mechanism
Instruction-only skill with no installer or external downloads. It relies on Node.js built-ins and existing system tools; nothing is fetched from external URLs. This is the lower-risk category for install mechanics.
Credentials
No environment variables, credentials, or system config paths are requested. The targets it intends to clean (user caches, logs, trash, npm/Homebrew caches, .next builds) are consistent with a cleanup tool and with the declared scope.
Persistence & Privilege
The skill will persist by writing scripts under ~/.openclaw/workspace/agents/mac-cleaner/ and (optionally, with explicit consent) registering a weekly cron job. It does not request always:true or any elevated/sudo permissions. Persisting in the agent workspace and adding a cron job are reasonable for a weekly cleaner but increase blast radius — ensure you explicitly consent to the cron registration and review what the cron will run.
How to Use
  1. Make sure OpenClaw is installed (local or Docker)
  2. Run the install command in chat: /install macos-mac-cleaner
  3. After installation, invoke the skill by name or use /macos-mac-cleaner
  4. Provide required inputs per the skill's parameter spec and get structured output
Version History
v1.0.3
v1.0.3: add explicit path allowlist (ALLOWED_PREFIXES) so deletions are restricted to known safe directories; add symlink check (isSafeTarget) to prevent traversal attacks; mandatory dry-run step before any real cleanup with user confirmation required; removal guards applied to all delete operations.
v1.0.2
v1.0.2: clarify exact target paths in Safety Profile (never source code/documents/user data); require explicit user confirmation before installation begins; require explicit user consent before registering cron job (Step 10); fix inconsistency between conservative scope claim and actual cleanup targets.
v1.0.1
v1.0.1: fix command injection vulnerability in cleanBrewCache (validate brewPruneDays as integer before shell interpolation); add clear documentation for Unicode U+2215 temp folder detection; update Safety Profile to declare Node.js runtime and optional brew/npm system tools.
v1.0.0
mac-cleaner 1.0.0 - Initial release: free, automated weekly Mac disk cleanup agent. - Cleans caches, logs, Trash, npm/Homebrew caches, and stale `.next` build artifacts. - Installs a Mission Control dashboard for manual runs and status. - No API keys, network requests, or external dependencies required. - Runs safe cleanup routines as your current user, skipping protected files.
Metadata
Slug macos-mac-cleaner
Version 1.0.3
License MIT-0
All-time Installs 0
Active Installs 0
Total Versions 4
Frequently Asked Questions

What is Mac Cleaner?

Replace CleanMyMac, DaisyDisk, and similar paid Mac cleanup apps — for free. Mac Cleaner is an automated weekly macOS disk cleanup agent that runs silently i... It is an AI Agent Skill for Claude Code / OpenClaw, with 177 downloads so far.

How do I install Mac Cleaner?

Run "/install macos-mac-cleaner" in the OpenClaw or Claude Code chat to install it in one step — no extra setup required.

Is Mac Cleaner free?

Yes, Mac Cleaner is completely free, licensed under MIT-0. You can download, install and use it at no cost.

Which platforms does Mac Cleaner support?

Mac Cleaner is cross-platform and runs anywhere OpenClaw / Claude Code is available (cross-platform).

Who created Mac Cleaner?

It is built and maintained by josephtandle (@josephtandle); the current version is v1.0.3.

💬 Comments