get_trash_info
get_trash_info
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.
Metadata
Name
get_trash_info
Updated
3 weeks ago
Source
Risk
Low
Requires consent
false
Affected scope
user
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 = `
create script
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); });
}