Code

/**
 * mcp/skills/getTrashInfo.ts — get_trash_info skill
 *
 * Reports macOS Trash / Windows Recycle Bin contents (item count + total
 * bytes) without modifying anything.  Read-only counterpart to
 * `empty_trash` — designed for synthesis-then-confirm skills (e.g.
 * disk-cleanup) that need a diagnostic size feed for the consolidated
 * present_preview card without invoking the destructive tool's dryRun
 * branch (which collides with G4's auto dry-run gate).
 *
 * Platform strategy
 * -----------------
 * darwin  Primary: AppleScript via Finder (TCC for ~/.Trash native to
 *         Finder; no Full Disk Access on the host required).
 *         Fallback: direct fs.readdir + stat on ~/.Trash (Full Disk
 *         Access required to succeed).  Errors surface as `errors[]`
 *         rather than throwing.
 * win32   Shell.Application COM via PowerShell — same query as
 *         empty_trash's dryRun path.
 *
 * Smoke test
 *   npx tsx -r dotenv/config mcp/skills/getTrashInfo.ts
 *
 * NOTE: measurement logic is intentionally duplicated from
 * `emptyTrash.ts` — keep in sync with that file if the underlying
 * Trash measurement strategy changes.
 */
 
import * as fs           from "fs/promises";
import * as os           from "os";
import * as nodePath     from "path";
import { execFile }      from "child_process";
import { promisify }     from "util";
 
import { loggedExec, isDarwin, isWin32 } from "./_shared/platform";
import { formatBytes } from "./_shared/formatBytes";
 
const execFileAsync = promisify(execFile);
 
// -- Meta ---------------------------------------------------------------------
 
export const meta = {
  name: "get_trash_info",
  description:
    "Reports the system Trash (macOS) or Recycle Bin (Windows) contents — " +
    "item count and total bytes — without modifying anything. Read-only " +
    "counterpart to empty_trash. Use this in diagnostic phases to feed a " +
    "present_preview card without invoking the destructive tool.",
  riskLevel:       "low",
  destructive:     false,
  requiresConsent: false,
  supportsDryRun:  false,
  affectedScope:   ["user"],
  auditRequired:   false,
  tccCategories:   ["FullDiskAccess", "Automation"],
  schema:          {},
} as const;
 
// -- Types --------------------------------------------------------------------
 
export interface GetTrashInfoResult {
  platform:    NodeJS.Platform;
  itemCount:   number;
  totalBytes:  number;
  totalHuman:  string;
  errors?:     Array<{ scope: string; message: string }>;
}
 
// -- Helpers ------------------------------------------------------------------
 
// formatBytes is imported from _shared/formatBytes.
 
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: `get_trash_info:${tag}`, maxBuffer: 4 * 1024 * 1024 },
  );
  return stdout.trim();
}
 
async function runOsa(script: string): Promise<string> {
  const { stdout } = await execFileAsync("osascript", ["-e", script], {
    maxBuffer: 4 * 1024 * 1024,
  });
  return stdout.trim();
}
 
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)") ||
    m.includes("(-1728)")
  );
}
 
interface TrashMeasurement { bytes: number; itemCount: number; error?: string }
 
// -- darwin -------------------------------------------------------------------
 
async function measureViaFinder(): Promise<TrashMeasurement> {
  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);
  const [countStr, bytesStr] = out.split(",");
  return {
    itemCount: parseInt(countStr ?? "0", 10) || 0,
    bytes:     parseInt(bytesStr ?? "0", 10) || 0,
  };
}
 
async function measureViaFs(trashPath: string): Promise<TrashMeasurement> {
  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 */ }
  }
  return { bytes, itemCount };
}
 
async function getTrashInfoDarwin(): Promise<GetTrashInfoResult> {
  const trashPath = nodePath.join(os.homedir(), ".Trash");
  const errors: GetTrashInfoResult["errors"] = [];
 
  let measured: TrashMeasurement | null = null;
  try {
    measured = await measureViaFinder();
  } catch (osaErr) {
    try {
      measured = await measureViaFs(trashPath);
    } catch (fsErr) {
      const tcc = isTccError(osaErr) || isTccError(fsErr);
      errors.push({
        scope:   "user-trash",
        message: tcc
          ? "Cannot read ~/.Trash — macOS TCC permission denied. Open System Settings → Privacy & Security → Automation, enable Finder for AI Support Agent, or grant Full Disk Access."
          : `Failed to measure trash: ${(osaErr as Error).message}`,
      });
    }
  }
 
  return {
    platform:   "darwin",
    itemCount:  measured?.itemCount ?? 0,
    totalBytes: measured?.bytes ?? 0,
    totalHuman: formatBytes(measured?.bytes ?? 0),
    ...(errors.length > 0 ? { errors } : {}),
  };
}
 
// -- win32 --------------------------------------------------------------------
 
async function getTrashInfoWin32(): Promise<GetTrashInfoResult> {
  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 -as [long] )
  items = [int]$items.Count
} | ConvertTo-Json -Compress`.trim();
 
  const errors: GetTrashInfoResult["errors"] = [];
  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 (err) {
    errors.push({ scope: "recycle-bin", message: (err as Error).message });
  }
 
  return {
    platform:   "win32",
    itemCount:  items,
    totalBytes: bytes,
    totalHuman: formatBytes(bytes),
    ...(errors.length > 0 ? { errors } : {}),
  };
}
 
// -- Exported run -------------------------------------------------------------
 
export async function run(_args: Record<string, never> = {}): Promise<GetTrashInfoResult> {
  if (isDarwin()) return getTrashInfoDarwin();
  if (isWin32())  return getTrashInfoWin32();
  return {
    platform:   os.platform(),
    itemCount:  0,
    totalBytes: 0,
    totalHuman: "0 B",
    errors:     [{ scope: "platform", message: `unsupported platform: ${os.platform()}` }],
  };
}
 
// -- CLI smoke test -----------------------------------------------------------
 
if (require.main === module) {
  run()
    .then((r) => console.log(JSON.stringify(r, null, 2)))
    .catch((err: Error) => { console.error(err.message); process.exit(1); });
}