Token导航 LogoToken导航

Agent Skill · 开发

macos-mac-cleaner(macOS Mac cleaner 开发)

macos-mac-cleaner 用于补充开发相关能力,适合在 OpenClaw 中需要让 Agent 承接开发相关任务时使用。可结合来源仓库、安装命令和原始 README 继续核验具体用法。安装前建议确认权限范围、维护状态,以及是否会触发联网、命令执行或文件读写。

macos-mac-cleaner 是开发类 Agent Skill,可用宿主为 OpenClaw,通过ClawHub获取,权限为接触敏感数据;许可证为 MIT-0,第三方扫描有提醒项,按安装量在开发类的 21,746 个 Skill 里排第 5,929。

总安装 5,381周安装 222GitHub Stars 公开资料未说明下载 1,758核验 2026-05-01

安装说明

本站只整理中文说明,不托管安装包,也不代用户安装。
GitHub
许可证
MIT-0
版本
1.0.3

安装方式

通过对话安装

复制提示词发给支持本地命令或 Skills 的 AI 助手,先确认命令和权限,再让它执行。

请帮我安装这个 Agent Skill:macos-mac-cleaner(macOS Mac cleaner 开发)
来源仓库:https://github.com/josephtandle/macos-mac-cleaner
安装命令:
openclaw skills install macos-mac-cleaner
安装前请先检查当前环境是否支持对应 CLI,并向我确认将要执行的命令、安装目录、联网范围和文件读写权限;确认后再执行。

命令行安装

复制命令到本机终端执行。该命令会通过 OpenClaw 从第三方来源获取 Skill;本站只展示命令,不托管安装包,也不自动执行。

ClawHubOpenClaw
openclaw skills install macos-mac-cleaner

这个 Skill 是做什么的

开发类 Skill 面向写代码、改代码和排查问题,一般会读写仓库文件,部分还会执行构建或测试命令。

替代 CleanMyMac 的免费磁盘清理代理程序。支持定期自动扫描和清理系统冗余文件以释放存储空间。可集成到定时任务中,实现无人值守的系统维护。macos-mac-cleaner 属于开发类 Skill,可作为该场景下的辅助能力补充。需授权访问用户目录及系统缓存路径,谨慎选择清理策略。

建议查看源码确认其数据来源与隐私合规性后再部署使用。

上手步骤

4 步
  1. 1

    确认自己的宿主工具

    公开资料标注支持 OpenClaw;先确认自己用的是其中之一,再按对应工具的 Skills 目录放置。

  2. 2

    拿到安装命令

    可以直接复制的命令是 openclaw skills install macos-mac-cleaner;命令里的占位符换成自己的路径或密钥再执行。

  3. 3

    安装后重启宿主

    命令执行完要完全退出宿主工具再打开,让新装的 Skill 被重新扫描到,否则列表里可能看不到。

  4. 4

    按最小权限试用

    它需要接触敏感数据,先在测试目录或测试账号里跑,确认没问题再放到正式项目。

常见问题

6 条

macos-mac-cleaner 怎么安装?

在终端执行 openclaw skills install macos-mac-cleaner。装完完全退出宿主工具再打开,让它重新扫描本地 Skill。

macos-mac-cleaner 支持哪些 AI 工具?

公开资料标注支持 OpenClaw。其他工具只要能读取本地 Skill 目录,一般也能手动放进去,但要自己确认触发方式是否一致。

macos-mac-cleaner 需要什么权限?

权限级别是接触敏感数据。该 Skill 可能接触密钥、Token、环境变量或敏感配置,应进入高风险复核队列,默认不自动发布。

macos-mac-cleaner 是免费的吗?

来源标注的许可证是 MIT-0。本站不代收任何费用,也不托管安装包;能否商用、要不要署名,以许可证原文为准。

macos-mac-cleaner 用的人多吗?

公开数据是累计安装 5,381 次,近一周安装 222 次,下载 1,758 次。这些数字来自第三方目录统计,只能当作热度参考,不代表质量或安全性。

macos-mac-cleaner 安全吗?

第三方扫描有提醒项,说明存在需要人工确认的行为。安装前请打开源码看清它读了什么、联网到哪里。

值得选的地方和要先想清楚的地方

值得选的地方

  • 专注 OpenClaw 一种宿主,触发方式和使用习惯不用来回切换。
  • 许可证明确为 MIT-0,商用和分发边界好判断。

先想清楚的地方

  • 需要接触敏感数据,装到常用环境前要先评估影响面。
  • 第三方扫描存在提醒项或未通过项,需要人工逐条确认。
  • 第三方目录整理,版本更新和行为细节以来源仓库为准。

适不适合你

更适合

  • 已经在用 OpenClaw,想直接扩展它能力的人。
  • 需要处理开发类任务、又不想每次都手写提示词的人。

不太适合

  • 环境里有大量密钥、凭证,不方便给第三方 Skill 读取的人。
  • 需要厂商级技术支持和 SLA 的生产系统。

开发类 Skill 对比

6 个同类
Skill总安装GitHub 星权限许可证扫描
skill-vetter

技能审查员 · OpenClaw

8,779,018986操作浏览器MIT-0通过
ontology

本体论 · OpenClaw

6,636,960576只读MIT-0通过
entra-app-registration

内部应用程序注册 · Codex、Claude

6,631,872764接触敏感数据未明确通过
entra-app-registration

内部应用程序注册 · Codex、Claude

3,757,680201接触敏感数据未明确通过
nano-banana-pro

纳米香蕉专业版 · OpenClaw

3,526,898364接触敏感数据MIT-0有提醒
macos-mac-cleaner

macOS Mac cleaner 开发 · 本页

5,381—接触敏感数据MIT-0有提醒

按安装量取同分类前几名;数字来自第三方目录统计,只作热度参考,不代表质量结论。

SKILL.md 原文

name
mac-cleaner
description
>

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

PropertyDetail
API keysNone
Network requestsNone
Runtime requiredNode.js v14+ (built-ins only -- no npm install needed)
Optional system toolsnpm (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 requiredNo -- runs as current user
LoggingEvery deletion logged to data/last-run.json with path, size, reason
Tools usedNode.js fs, child_process; brew cleanup (optional), npm cache clean --force (optional), df, du
Dry-run modePass --dry-run to preview what would be cleaned without deleting anything
Failure modeSkips inaccessible items gracefully -- missing tools (brew, npm) are detected and bypassed

What It Cleans

TargetBehaviorConfig Key
~/Library/Caches/*Removes all subdirectoriescacheMaxAgeDays (0 = all)
~/Library/LogsFiles older than thresholdlogMaxAgeDays (default 30)
~/.TrashItems older than thresholdtrashMaxAgeDays (default 30)
~/.npm/_cacacheCleans if exceeds thresholdnpmCacheThresholdMb (default 500)
Homebrew cachebrew cleanup --prune=NbrewPruneDays (default 30)
MC .next buildRemoves if older than N daysnextBuildMaxAgeDays (default 7)
Orphaned tmp foldersUnicode-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("\∕")) {
    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 < 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 < 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 = "\∕"; // 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 < 1) return "just now";
  if (minutes < 60) return `${minutes}m ago`;
  if (hours < 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<CleanupRun | null>(null);
  const [status, setStatus] = useState<Status | null>(null);
  const [history, setHistory] = useState<CleanupRun[]>([]);
  const [diskStats, setDiskStats] = useState<DiskStats | 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<Record<string, { 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 (
      <div className="flex items-center justify-center h-64">
        <Loader className="w-8 h-8 animate-spin text-cm-purple" />
      </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 (
    <div className="max-w-5xl mx-auto space-y-6">
      {/* Header */}
      <div className="gradient-cm-header rounded-xl p-6">
        <div className="flex items-center justify-between">
          <div className="flex items-center gap-3">
            <Sparkles className="w-8 h-8 text-cm-purple" />
            <div>
              <h1 className="text-2xl font-bold text-slate-900">MacCleaner</h1>
              <p className="text-sm text-slate-600">
                Weekly macOS disk cleanup agent
              </p>
            </div>
          </div>
          <button
            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 ? (
              <>
                <Loader className="w-4 h-4 animate-spin" />
                Cleaning...
              </>
            ) : (
              <>
                <RefreshCw className="w-4 h-4" />
                Run Now
              </>
            )}
          </button>
        </div>
      </div>

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

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

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

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

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

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

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

      {/* Run History Chart */}
      {chartRuns.length > 1 && (
        <div className="bg-white rounded-lg border border-cm-purple-light overflow-hidden">
          <div className="p-4 border-b border-cm-purple-light bg-gradient-to-r from-cm-purple-light/30 to-white">
            <h3 className="font-semibold text-slate-900 flex items-center gap-2">
              <BarChart3 className="w-4 h-4 text-cm-purple" />
              Run History (last {chartRuns.length} runs)
            </h3>
          </div>
          <div className="p-4">
            <div className="flex items-end gap-2 h-40">
              {chartRuns.map((run, idx) => {
                const heightPct = Math.max(
                  (run.bytes_freed / maxHistoryBytes) * 100,
                  3
                );
                return (
                  <div
                    key={idx}
                    className="flex-1 flex flex-col items-center gap-1"
                  >
                    <span className="text-[10px] text-slate-500">
                      {formatBytes(run.bytes_freed)}
                    </span>
                    <div className="w-full flex items-end justify-center" style={{ height: "120px" }}>
                      <div
                        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()}`}
                      />
                    </div>
                    <span className="text-[10px] text-slate-400">
                      {formatShortDate(run.timestamp)}
                    </span>
                  </div>
                );
              })}
            </div>
          </div>
        </div>
      )}

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

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

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

      {/* No data state */}
      {!lastRun && (
        <div className="bg-white rounded-lg border border-cm-purple-light p-12 text-center">
          <Sparkles className="w-12 h-12 text-cm-purple-mid mx-auto mb-4" />
          <h3 className="text-lg font-medium text-slate-900 mb-1">
            No cleanup data yet
          </h3>
          <p className="text-sm text-slate-500">
            Click &quot;Run Now&quot; to perform your first disk cleanup.
          </p>
        </div>
      )}
    </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<{ 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:

KeyDefaultDescription
cacheMaxAgeDays0Min age for cache dirs (0 = clean all)
logMaxAgeDays30Max age for log files before deletion
trashMaxAgeDays30Max age for trash items before deletion
npmCacheThresholdMb500npm cache size trigger in MB
brewPruneDays30Homebrew prune age in days
nextBuildMaxAgeDays7Max age for .next build artifacts
missionControlPath~/.openclaw/.../mission-control/.nextPath to .next build dir to clean
dataDir~/.openclaw/.../mac-cleaner/dataWhere 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.

平台分布

OpenClaw

70.89%
按下载量换算1,246

安全审计

VirusTotal

通过

ClawScan

通过

Static analysis

可疑

权限和风险

接触敏感数据

该 Skill 可能接触密钥、Token、环境变量或敏感配置,应进入高风险复核队列,默认不自动发布。

安装前确认

本站仅展示第三方公开信息,不托管安装包,不提供自动安装或运行环境。安装前应自行审查源码、依赖和命令行为。来源安全扫描存在提醒或未通过项,安装前请自行复核。目前只有一个公开来源,建议同时核对源仓库。

继续浏览同类 Skills