Code

/**
 * mcp/skills/emptyTrash.ts — empty_trash skill
 *
 * Empties the system Trash (macOS) or Recycle Bin (Windows) and reports the
 * space freed.  A dryRun mode lets Claude show the user the impact first.
 *
 * IMPORTANT: Always confirm with the user before calling without dryRun.
 *
 * Platform strategy
 * -----------------
 * darwin  Primary path is AppleScript via Finder — Finder holds the TCC
 *         permission for ~/.Trash natively, so it works without granting
 *         Full Disk Access to the host app. Falls back to fs.rm if
 *         AppleScript is unavailable, and surfaces any errors clearly
 *         (EPERM / TCC failures are no longer silently swallowed).
 * win32   Shell.Application COM object for size; Clear-RecycleBin to empty.
 *
 * Smoke test
 *   npx tsx -r dotenv/config mcp/skills/emptyTrash.ts --dry-run
 */
 
import * as fs       from "fs/promises";
import * as os       from "os";
import * as nodePath from "path";
import { z }         from "zod";
 
import { loggedExec } from "./_shared/platform";
 
// -- Meta ---------------------------------------------------------------------
 
export const meta = {
  name: "empty_trash",
  description:
    "Empties the system Trash (macOS) or Recycle Bin (Windows) and reports " +
    "the space freed. Always confirm with the user before calling without dryRun.",
  riskLevel:       "high",
  destructive:     true,
  requiresConsent: true,
  supportsDryRun:  true,
  affectedScope:   ["user"],
  auditRequired:   true,
  schema: {
    dryRun: z
      .boolean()
      .optional()
      .describe(
        "When true, reports how much would be freed without actually emptying. " +
        "Default: false.",
      ),
  },
} as const;
 
// -- Helpers ------------------------------------------------------------------
 
function formatBytes(bytes: number): string {
  if (bytes === 0) return "0 B";
  const units = ["B", "KB", "MB", "GB", "TB"];
  const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
  return `${(bytes / 1024 ** i).toFixed(1)} ${units[i]}`;
}
 
async function runPS(script: string, tag: string): Promise<string> {
  const encoded = Buffer.from(script, "utf16le").toString("base64");
  const { stdout } = await loggedExec(
    `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encoded}`,
    { tag: `empty_trash:${tag}`, maxBuffer: 4 * 1024 * 1024 },
  );
  return stdout.trim();
}
 
/** Run an AppleScript snippet via osascript; returns stdout (trimmed). */
async function runOsa(script: string, tag: string): Promise<string> {
  const { stdout } = await loggedExec(
    `osascript -e ${JSON.stringify(script)}`,
    { tag: `empty_trash:${tag}`, maxBuffer: 4 * 1024 * 1024 },
  );
  return stdout.trim();
}
 
/** True if an Error looks like a TCC / permission denial from macOS. */
function isTccError(err: unknown): boolean {
  if (!(err instanceof Error)) return false;
  const m = err.message.toLowerCase();
  return (
    m.includes("operation not permitted") ||
    m.includes("not authorized") ||
    m.includes("eperm") ||
    m.includes("eacces") ||
    m.includes("errauthorizationcanceled") ||
    m.includes("not allowed assistive access") ||
    m.includes("(-1743)") || // automation permission denied
    m.includes("(-1728)")    // object not found (often a TCC symptom)
  );
}
 
// -- darwin implementation ----------------------------------------------------
 
interface TrashInfo { bytes: number; itemCount: number; error?: string }
 
/**
 * Measure trash via AppleScript (Finder). Finder has the TCC permission to
 * read ~/.Trash even when the host Electron app does not.
 */
async function measureTrashDarwinOsa(): Promise<TrashInfo> {
  // Use the POSIX trash path via Finder. `size of` on each item avoids needing
  // Full Disk Access on the host process. `every item` excludes .DS_Store by
  // default (Finder hides it).
  const script = `
tell application "Finder"
  set trashItems to items of trash
  set itemCount to count of trashItems
  set totalBytes to 0
  repeat with anItem in trashItems
    try
      set totalBytes to totalBytes + (size of anItem)
    end try
  end repeat
end tell
return (itemCount as text) & "," & (totalBytes as text)`.trim();
  const out = await runOsa(script, "osa-measure");
  const [countStr, bytesStr] = out.split(",");
  return {
    itemCount: parseInt(countStr ?? "0", 10) || 0,
    bytes:     parseInt(bytesStr ?? "0", 10) || 0,
  };
}
 
/** Fallback direct-fs measurement (only works with Full Disk Access). */
async function measureTrashDarwinFs(trashPath: string): Promise<TrashInfo> {
  let bytes     = 0;
  let itemCount = 0;
  const entries = await fs.readdir(trashPath, { withFileTypes: true });
  for (const e of entries) {
    if (e.name === ".DS_Store") continue;
    itemCount++;
    try {
      const stat = await fs.stat(nodePath.join(trashPath, e.name));
      bytes += stat.size;
    } catch { /* skip unreadable entries */ }
  }
  return { bytes, itemCount };
}
 
async function measureTrashDarwin(trashPath: string): Promise<TrashInfo> {
  // Try AppleScript first (works without Full Disk Access).
  try {
    return await measureTrashDarwinOsa();
  } catch (osaErr) {
    // Fall back to direct fs — may work if app has Full Disk Access.
    try {
      return await measureTrashDarwinFs(trashPath);
    } catch (fsErr) {
      const tcc = isTccError(osaErr) || isTccError(fsErr);
      return {
        bytes:     0,
        itemCount: 0,
        error: tcc
          ? "Cannot read ~/.Trash — macOS TCC permission denied. To fix: open System Settings → Privacy & Security → Automation → find 'AI Support Agent' (or 'Electron' in dev) → enable the checkbox for Finder. Alternatively, grant Full Disk Access to the app in Privacy & Security → Full Disk Access. After granting, fully quit and relaunch the app."
          : `Failed to measure trash: ${(osaErr as Error).message}`,
      };
    }
  }
}
 
async function emptyTrashDarwin(dryRun: boolean) {
  const trashPath = nodePath.join(os.homedir(), ".Trash");
  const measured  = await measureTrashDarwin(trashPath);
 
  if (dryRun) {
    return {
      dryRun:       true,
      itemsInTrash: measured.itemCount,
      freedBytes:   measured.bytes,
      freedHuman:   formatBytes(measured.bytes),
      ...(measured.error ? { warning: measured.error } : {}),
    };
  }
 
  // Primary path — ask Finder to empty the trash. Finder holds the TCC
  // permission natively; this also handles locked items and trash folders
  // on mounted external volumes.
  let method: "finder" | "fs" = "finder";
  let emptyError: string | undefined;
  try {
    await runOsa(`tell application "Finder" to empty trash`, "osa-empty");
  } catch (osaErr) {
    // Fall back to direct fs.rm — only works with Full Disk Access.
    method = "fs";
    try {
      const entries = await fs.readdir(trashPath);
      const results = await Promise.allSettled(
        entries.map((e) =>
          fs.rm(nodePath.join(trashPath, e), { recursive: true, force: true }),
        ),
      );
      const failures = results.filter((r) => r.status === "rejected");
      if (failures.length > 0) {
        const firstErr = (failures[0] as PromiseRejectedResult).reason as Error;
        emptyError = isTccError(firstErr) || isTccError(osaErr)
          ? `Could not empty trash — macOS TCC permission denied. To fix: open System Settings → Privacy & Security → Automation → find 'AI Support Agent' (or 'Electron' in dev) → enable the checkbox for Finder. Alternatively, grant Full Disk Access. After granting, fully quit and relaunch the app, then try again. (${failures.length}/${results.length} items failed)`
          : `Failed to remove ${failures.length}/${results.length} items: ${firstErr.message}`;
      }
    } catch (fsErr) {
      emptyError = isTccError(osaErr) || isTccError(fsErr)
        ? "Could not empty trash — macOS TCC permission denied. To fix: open System Settings → Privacy & Security → Automation → find 'AI Support Agent' (or 'Electron' in dev) → enable the checkbox for Finder. Alternatively, grant Full Disk Access. After granting, fully quit and relaunch the app, then try again."
        : `Failed to empty trash: ${(fsErr as Error).message}`;
    }
  }
 
  // Verify by re-measuring. This tells us what *actually* got freed.
  const after        = await measureTrashDarwin(trashPath);
  const freedBytes   = Math.max(0, measured.bytes     - after.bytes);
  const itemsRemoved = Math.max(0, measured.itemCount - after.itemCount);
 
  // ── Silent-failure detection ────────────────────────────────────────────────
  // If the empty operation reported success (no thrown error → emptyError
  // unset) but the before-measurement showed items and zero were actually
  // removed, Finder accepted the AppleScript but did not perform the operation.
  // This is the defining symptom of macOS TCC silently denying Apple Events
  // to Finder — osascript exits 0, nothing happens. Surface a clear, actionable
  // error so the user knows what to fix; without this, the agent reports fake
  // success and the trash stays full.
  if (
    emptyError === undefined &&
    measured.itemCount > 0 &&
    itemsRemoved === 0
  ) {
    emptyError =
      "Trash was not actually emptied (Finder reported success but the items remain). " +
      "This usually means AI Support Agent does not have permission to automate Finder. " +
      "Open System Settings → Privacy & Security → Automation, find AI Support Agent, " +
      "and enable the Finder checkbox. Then quit and relaunch AI Support Agent.";
  }
 
  return {
    dryRun:          false,
    method,
    itemsRemoved,
    itemsRemaining:  after.itemCount,
    freedBytes,
    freedHuman:      formatBytes(freedBytes),
    ...(emptyError ? { error: emptyError } : {}),
  };
}
 
// -- win32 implementation -----------------------------------------------------
 
async function emptyTrashWin32(dryRun: boolean) {
  // Query Recycle Bin size via Shell.Application COM object (works on all Windows versions).
  const sizeScript = `
$ErrorActionPreference = 'SilentlyContinue'
$shell = New-Object -ComObject Shell.Application
$bin   = $shell.Namespace(0xA)
$items = $bin.Items()
[PSCustomObject]@{
  bytes = [long](($items | ForEach-Object { $_.Size } | Measure-Object -Sum).Sum ?? 0)
  items = [int]$items.Count
} | ConvertTo-Json -Compress`.trim();
 
  let bytes = 0;
  let items = 0;
  try {
    const out    = await runPS(sizeScript, "ps-measure");
    const parsed = JSON.parse(out) as { bytes: number; items: number };
    bytes = parsed.bytes ?? 0;
    items = parsed.items ?? 0;
  } catch { /* best-effort — proceed even if size query fails */ }
 
  if (dryRun) {
    return { dryRun: true, itemsInTrash: items, freedBytes: bytes, freedHuman: formatBytes(bytes) };
  }
 
  const clearScript = `Clear-RecycleBin -Force -ErrorAction SilentlyContinue; exit 0`;
  await runPS(clearScript, "ps-clear").catch(() => { /* ignore — Clear-RecycleBin is best-effort */ });
 
  return { dryRun: false, itemsRemoved: items, freedBytes: bytes, freedHuman: formatBytes(bytes) };
}
 
// -- Exported run function ----------------------------------------------------
 
export async function run({ dryRun = false }: { dryRun?: boolean } = {}) {
  const platform = os.platform();
 
  if (platform === "win32") return emptyTrashWin32(dryRun);
  if (platform === "darwin") return emptyTrashDarwin(dryRun);
 
  throw new Error(
    `[empty_trash] Unsupported platform: ${platform}. ` +
    "Supported: darwin, win32.",
  );
}
 
// -- CLI smoke test -----------------------------------------------------------
 
if (require.main === module) {
  const dryRun = process.argv.includes("--dry-run");
  console.log(`empty_trash smoke test — dryRun=${dryRun}`);
  run({ dryRun })
    .then((r) => console.log(JSON.stringify(r, null, 2)))
    .catch((err: Error) => { console.error(err.message); process.exit(1); });
}