Removes an application's preferences file (plist on macOS, registry on Windows) to force the app to use default settings. Use when an app behaves erratically or after a corrupt preferences file is suspected.
/** * mcp/skills/resetAppPreferences.ts — reset_app_preferences skill * * Removes an application's preferences file (plist on macOS, registry on * Windows) to force the app to use default settings. Defaults to dryRun=true * for safety. * * Platform strategy * ----------------- * darwin Scans ~/Library/Preferences for com.{appName}.* plist files * win32 PowerShell scans HKCU:\Software for matching registry keys * * Smoke test * npx tsx -r dotenv/config mcp/skills/resetAppPreferences.ts Mail */import * as fs from "fs/promises";import * as os from "os";import * as nodePath from "path";import { exec } from "child_process";import { promisify } from "util";import { z } from "zod";import { formatBytes } from "./_shared/formatBytes";const execAsync = promisify(exec);// -- Meta ---------------------------------------------------------------------export const meta = { name: "reset_app_preferences", description: "Removes an application's preferences file (plist on macOS, registry on " + "Windows) to force the app to use default settings. " + "Use when an app behaves erratically or after a corrupt preferences file is suspected.", riskLevel: "high", destructive: true, requiresConsent: true, supportsDryRun: true, affectedScope: ["user"], auditRequired: true, tccCategories: ["FullDiskAccess"], schema: { appName: z .string() .describe("Application name (e.g. 'Mail', 'Outlook', 'Slack')"), dryRun: z .boolean() .optional() .describe("If true, report what would be removed without removing. Default: true"), },} as const;// -- Types --------------------------------------------------------------------interface PrefEntry { path: string; sizeMb: number; /** Pre-formatted size (decimal/SI — matches Finder + Explorer). */ sizeHuman: string;}interface PrefsResult { platform: string; appName: string; dryRun: boolean; found: PrefEntry[]; /** Pre-formatted total across all found entries. Empty string when 0 bytes. */ totalSizeHuman: string; deleted: boolean; message: string;}// -- Helpers ------------------------------------------------------------------/** Prevent path traversal — ensure target stays within allowedRoot. */function isSafePath(target: string, allowedRoot: string): boolean { const rel = nodePath.relative(allowedRoot, target); return !rel.startsWith("..") && !nodePath.isAbsolute(rel);}async function getFileSizeBytes(filePath: string): Promise<number> { try { const stat = await fs.stat(filePath); return stat.size; } catch { return 0; }}/** Decimal/SI MB rounded to 3 decimals — matches Finder + Explorer. */function bytesToMb(bytes: number): number { return Math.round((bytes / 1_000_000) * 1000) / 1000;}// -- PowerShell helper --------------------------------------------------------async function runPS(script: string): Promise<string> { const encoded = Buffer.from(script, "utf16le").toString("base64"); const { stdout } = await execAsync( `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encoded}`, { maxBuffer: 10 * 1024 * 1024 }, ); return stdout.trim();}// -- darwin implementation ----------------------------------------------------async function resetAppPreferencesDarwin( appName: string, dryRun: boolean,): Promise<PrefsResult> { // Security: validate appName if (!/^[a-zA-Z0-9 _\-.']+$/.test(appName)) { throw new Error(`[reset_app_preferences] Invalid appName: ${appName}`); } const homeLib = nodePath.join(os.homedir(), "Library"); const prefsDir = nodePath.join(homeLib, "Preferences"); const lowerApp = appName.toLowerCase().replace(/\s+/g, ""); // Normalise away ALL non-alphanumerics on both sides before the substring // match. Reverse-DNS preference domains separate the vendor and app with a // dot (com.microsoft.Outlook.plist), so collapsing only spaces left // "Microsoft Outlook" -> "microsoftoutlook", which is NOT a substring of // "...microsoft.outlook..." -> the reset silently matched zero files. // Stripping the dots too ("commicrosoftoutlook") makes the match hold. const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, ""); const wantNorm = norm(appName); const matches = (fileName: string): boolean => { const lower = fileName.toLowerCase(); if (!lower.endsWith(".plist")) return false; const fileNorm = norm(fileName.replace(/\.plist$/i, "")); return lower.startsWith(`com.${lowerApp}`) || lower.startsWith(lowerApp) || fileNorm.includes(wantNorm); }; // Scan ~/Library/Preferences AND every sandbox container's preferences dir. // Sandboxed apps (e.g. Apple Mail) keep their REAL prefs under // ~/Library/Containers/<bundle-id>/Data/Library/Preferences — NOT in // ~/Library/Preferences — so a Preferences-only scan missed the actual file // and only ever hit collateral helper plists. const prefDirs: string[] = [prefsDir]; try { const containers = await fs.readdir(nodePath.join(homeLib, "Containers"), { withFileTypes: true }); for (const c of containers) { if (!c.isDirectory()) continue; // Only descend into containers whose bundle id relates to the target app. // Scanning EVERY container's prefs dir (hundreds on a real Mac) is far too // slow; filtering by the app token keeps it fast and cuts collateral. if (c.name.toLowerCase().startsWith(`com.${lowerApp}`) || norm(c.name).includes(wantNorm)) { prefDirs.push(nodePath.join(homeLib, "Containers", c.name, "Data", "Library", "Preferences")); } } } catch { // No Containers dir (or unreadable) — fall back to ~/Library/Preferences only. } const matchedPaths: string[] = []; let tccBlocked = 0; for (const dir of prefDirs) { let dirents: import("fs").Dirent[]; try { dirents = await fs.readdir(dir, { withFileTypes: true }); } catch (e) { // ENOENT = container has no prefs dir (normal — skip). EPERM/EACCES = macOS // TCC blocking a sandboxed location (e.g. Apple Mail's own com.apple.mail // container needs Full Disk Access). Never swallow that silently — count it // so the result can surface that some real prefs could not be reached. const code = (e as NodeJS.ErrnoException).code; if (code === "EPERM" || code === "EACCES") tccBlocked++; continue; } for (const d of dirents) { if (d.isFile() && matches(d.name)) matchedPaths.push(nodePath.join(dir, d.name)); } } const tccNote = tccBlocked > 0 ? ` Note: ${tccBlocked} sandboxed preference location(s) are blocked by macOS privacy protection — grant this app Full Disk Access to reset those (e.g. Apple Mail's own container).` : ""; // Build entries with a parallel byte total (sizeMb rounds to 3 decimals, // lossy for sub-kB plists; reduce on raw bytes for accuracy). const entries = await Promise.all( matchedPaths.map(async (full) => ({ full, bytes: await getFileSizeBytes(full) })), ); const found: PrefEntry[] = entries.map((e) => ({ path: e.full, sizeMb: bytesToMb(e.bytes), sizeHuman: formatBytes(e.bytes), })); const totalSizeHuman = formatBytes(entries.reduce((s, e) => s + e.bytes, 0)); if (found.length === 0) { return { platform: "darwin", appName, dryRun, found, totalSizeHuman, deleted: false, message: `No preference files found for '${appName}' (checked ~/Library/Preferences and sandbox containers).${tccNote}`, }; } if (dryRun) { return { platform: "darwin", appName, dryRun, found, totalSizeHuman, deleted: false, message: `Found ${found.length} preference file(s) (${totalSizeHuman}). Each will be backed up to a .bak alongside it (reversible) when you confirm.${tccNote}`, }; } // Back up (rename to .bak) rather than delete outright — a "high" risk reset // must be reversible. The app starts fresh because the original is gone from // its active location; renaming a .bak back restores the prior settings. let resetCount = 0; for (const entry of found) { if (!isSafePath(entry.path, homeLib)) continue; // stay within ~/Library const bak = `${entry.path}.bak`; try { await fs.rm(bak, { force: true }); // clear a stale backup from a prior run await fs.rename(entry.path, bak); resetCount++; } catch { // locked / unmovable — skip } } return { platform: "darwin", appName, dryRun, found, totalSizeHuman, deleted: resetCount > 0, message: `Reset ${resetCount} of ${found.length} preference file(s) for '${appName}' (${totalSizeHuman}). Originals backed up to <file>.bak — rename back to restore.${tccNote}`, };}// -- win32 implementation -----------------------------------------------------async function resetAppPreferencesWin32( appName: string, dryRun: boolean,): Promise<PrefsResult> { if (!/^[a-zA-Z0-9 _\-.']+$/.test(appName)) { throw new Error(`[reset_app_preferences] Invalid appName: ${appName}`); } const safeApp = appName.replace(/'/g, "''"); const findPs = `$ErrorActionPreference = 'SilentlyContinue'$base = 'HKCU:\\Software'$matches = Get-ChildItem -Path $base -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.PSChildName -like '*${safeApp}*' } | Select-Object -ExpandProperty PSPath$matches | ConvertTo-Json -Compress`.trim(); let registryKeys: string[] = []; try { const raw = await runPS(findPs); if (raw && raw !== "null") { const parsed = JSON.parse(raw) as string | string[]; registryKeys = Array.isArray(parsed) ? parsed : [parsed]; } } catch { // fallback: empty } // Registry keys have no size on Windows; sizeMb is always 0 and sizeHuman // reflects that. The totalSizeHuman at the result level is still emitted // for consistency with the darwin path. const found: PrefEntry[] = registryKeys.map((k) => ({ path: k, sizeMb: 0, sizeHuman: "0 B" })); const totalSizeHuman = "0 B"; if (found.length === 0) { return { platform: "win32", appName, dryRun, found, totalSizeHuman, deleted: false, message: `No registry keys found for '${appName}' under HKCU:\\Software`, }; } if (dryRun) { return { platform: "win32", appName, dryRun, found, totalSizeHuman, deleted: false, message: `Found ${found.length} registry key(s). Set dryRun=false to remove them.`, }; } // Delete registry keys const deleteOps = registryKeys.map((k) => `Remove-Item -LiteralPath '${k.replace(/'/g, "''")}' -Recurse -Force -ErrorAction SilentlyContinue`).join("\n"); const deletePs = `$ErrorActionPreference = 'SilentlyContinue'\n${deleteOps}\nWrite-Output 'done'`; try { await runPS(deletePs); return { platform: "win32", appName, dryRun, found, totalSizeHuman, deleted: true, message: `Removed ${found.length} registry key(s) for '${appName}'.`, }; } catch (err) { return { platform: "win32", appName, dryRun, found, totalSizeHuman, deleted: false, message: `Failed to remove registry keys: ${(err as Error).message}`, }; }}// -- Exported run function ----------------------------------------------------export async function run({ appName, dryRun = true,}: { appName: string; dryRun?: boolean;}) { const platform = os.platform(); return platform === "win32" ? resetAppPreferencesWin32(appName, dryRun) : resetAppPreferencesDarwin(appName, dryRun);}// -- CLI smoke test -----------------------------------------------------------if (false) { run({ appName: process.argv[2] ?? "Mail", dryRun: true }) .then((r) => console.log(JSON.stringify(r, null, 2))) .catch((err: Error) => { console.error(err.message); process.exit(1); });}