/**
* mcp/skills/uninstallApp.ts — uninstall_app skill
*
* Removes an application bundle and associated support files (preferences,
* caches, app support data, logs). More thorough than Trash. Use before
* reinstalling for a clean state.
*
* Platform strategy
* -----------------
* darwin Find .app in /Applications and ~/Applications, then remove support
* files from ~/Library when deep=true
* win32 PowerShell Get-Package / Uninstall-Package or registry uninstaller
*
* Smoke test
* npx tsx -r dotenv/config mcp/skills/uninstallApp.ts
*/
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" ;
const execAsync = promisify (exec);
// -- Meta ---------------------------------------------------------------------
export const meta = {
name: "uninstall_app" ,
description:
"Removes an application bundle and associated support files (preferences, caches, " +
"app support data, logs). More thorough than Trash. Use before reinstalling for a clean state." ,
riskLevel: "high" ,
destructive: true ,
requiresConsent: true ,
supportsDryRun: true ,
affectedScope: [ "system" ],
auditRequired: true ,
schema: {
appName: z
. string ()
. describe ( "Application name (e.g. 'Zoom', 'Slack')" ),
deep: z
. boolean ()
. optional ()
. describe ( "Also remove support files in ~/Library. Default: false" ),
dryRun: z
. boolean ()
. optional ()
. describe ( "If true, list files that would be removed. Default: true" ),
},
} as const ;
// -- Types --------------------------------------------------------------------
interface UninstallResult {
appBundle : string | null ;
supportFiles : string [];
totalSizeMb : number ;
removed : boolean ;
dryRun : boolean ;
message : string ;
}
// -- 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: 20 * 1024 * 1024 },
);
return stdout. trim ();
}
// -- darwin: calculate size with du -sk ---------------------------------------
async function getDiskUsageKb ( targetPath : string ) : Promise < number > {
try {
const safePath = targetPath. replace ( / ' / g , `' \\ ''` );
const { stdout } = await execAsync (
`du -sk '${ safePath }' 2>/dev/null` ,
{ maxBuffer: 2 * 1024 * 1024 , shell: "/bin/bash" },
);
const kb = parseInt (stdout. trim (). split ( " \t " )[ 0 ], 10 );
return isNaN (kb) ? 0 : kb;
} catch {
return 0 ;
}
}
// -- darwin: glob support files in ~/Library ----------------------------------
async function findSupportFiles ( appName : string ) : Promise < string []> {
const home = os. homedir ();
const libBase = nodePath. join (home, "Library" );
const searchDirs = [
nodePath. join (libBase, "Application Support" ),
nodePath. join (libBase, "Caches" ),
nodePath. join (libBase, "Preferences" ),
nodePath. join (libBase, "Logs" ),
];
const found : string [] = [];
const lowerName = appName. toLowerCase ();
for ( const dir of searchDirs) {
try {
const entries = await fs. readdir (dir);
for ( const entry of entries) {
if (entry. toLowerCase (). includes (lowerName)) {
found. push (nodePath. join (dir, entry));
}
}
} catch {
// Directory may not exist or be inaccessible
}
}
return found;
}
// -- darwin implementation ----------------------------------------------------
async function uninstallAppDarwin (
appName : string ,
deep : boolean ,
dryRun : boolean ,
) : Promise < UninstallResult > {
// Locate .app bundle
let appBundle : string | null = null ;
const searchLocations = [
`/Applications/${ appName }.app` ,
nodePath. join (os. homedir (), "Applications" , `${ appName }.app` ),
];
for ( const loc of searchLocations) {
try {
await fs. access (loc);
appBundle = loc;
break ;
} catch {
// Try next location
}
}
// Also try find if exact match not found
if ( ! appBundle) {
try {
const safeName = appName. replace ( / ' / g , `' \\ ''` );
const { stdout } = await execAsync (
`find /Applications ~/Applications -maxdepth 2 -name '${ safeName }.app' 2>/dev/null` ,
{ maxBuffer: 1024 * 1024 , shell: "/bin/bash" },
);
appBundle = stdout. trim (). split ( " \n " )[ 0 ] || null ;
} catch {
appBundle = null ;
}
}
// Gather support files
const supportFiles : string [] = deep ? await findSupportFiles (appName) : [];
// Calculate total size
const allPaths = [ ... (appBundle ? [appBundle] : []), ... supportFiles];
let totalSizeKb = 0 ;
for ( const p of allPaths) {
totalSizeKb += await getDiskUsageKb (p);
}
const totalSizeMb = Math. round ((totalSizeKb / 1024 ) * 10 ) / 10 ;
if (dryRun) {
return {
appBundle,
supportFiles,
totalSizeMb,
removed: false ,
dryRun: true ,
message: appBundle
? `Dry run: would remove app bundle and ${ supportFiles . length } support file(s). Total: ~${ totalSizeMb } MB`
: `App bundle for "${ appName }" not found. ${ supportFiles . length } support file(s) found.` ,
};
}
// Perform removal
let removed = false ;
const errors : string [] = [];
for ( const p of allPaths) {
try {
await fs. rm (p, { recursive: true , force: true });
removed = true ;
} catch (err) {
errors. push ( `Failed to remove ${ p }: ${ ( err as Error ). message }` );
}
}
return {
appBundle,
supportFiles,
totalSizeMb,
removed,
dryRun: false ,
message: errors. length === 0
? `Removed app bundle and ${ supportFiles . length } support file(s). ~${ totalSizeMb } MB freed.`
: `Partial removal. Errors: ${ errors . join ( "; " ) }` ,
};
}
// -- win32 implementation -----------------------------------------------------
async function uninstallAppWin32 (
appName : string ,
_deep : boolean ,
dryRun : boolean ,
) : Promise < UninstallResult > {
const safeAppName = appName. replace ( / ' / g , "''" );
if (dryRun) {
const ps = `
$ErrorActionPreference = 'SilentlyContinue'
$pkg = Get-Package -Name '*${ safeAppName }*' -ErrorAction SilentlyContinue | Select-Object Name,Version,Source
if ($pkg) { @($pkg) | ConvertTo-Json -Compress } else { '[]' }` . trim ();
let found = false ;
let info = "" ;
try {
const raw = await runPS (ps);
const parsed = JSON . parse (raw ?? "[]" ) as Array <{ Name : string ; Version : string }>;
if (parsed. length > 0 ) {
found = true ;
info = parsed. map (( p ) => `${ p . Name } v${ p . Version }` ). join ( ", " );
}
} catch {
info = "Could not query installed packages" ;
}
return {
appBundle: found ? info : null ,
supportFiles: [],
totalSizeMb: 0 ,
removed: false ,
dryRun: true ,
message: found
? `Dry run: found "${ info }". Run with dryRun=false to uninstall.`
: `Package matching "${ appName }" not found via Get-Package.` ,
};
}
const ps = `
$ErrorActionPreference = 'SilentlyContinue'
$pkg = Get-Package -Name '*${ safeAppName }*' -ErrorAction SilentlyContinue | Select-Object -First 1
if ($pkg) {
Uninstall-Package -Name $pkg.Name -Force -ErrorAction SilentlyContinue
"uninstalled:$($pkg.Name)"
} else {
# Try registry uninstaller fallback
$reg = Get-ChildItem 'HKLM: \\ SOFTWARE \\ Microsoft \\ Windows \\ CurrentVersion \\ Uninstall' -ErrorAction SilentlyContinue |
Get-ItemProperty -ErrorAction SilentlyContinue |
Where-Object { $_.DisplayName -match '${ safeAppName }' } |
Select-Object -First 1
if ($reg -and $reg.UninstallString) {
$uninstCmd = $reg.UninstallString + ' /quiet /norestart'
Start-Process cmd -ArgumentList ("/c " + $uninstCmd) -Wait
"uninstalled_via_registry:$($reg.DisplayName)"
} else {
"not_found"
}
}` . trim ();
let removed = false ;
let message = "" ;
try {
const raw = await runPS (ps);
if (raw. startsWith ( "uninstalled" )) {
removed = true ;
message = `Uninstalled: ${ raw . split ( ":" )[ 1 ] ?? appName }` ;
} else {
message = `Package "${ appName }" not found or could not be uninstalled via standard methods.` ;
}
} catch (err) {
message = `Uninstall error: ${ ( err as Error ). message }` ;
}
return {
appBundle: appName,
supportFiles: [],
totalSizeMb: 0 ,
removed,
dryRun: false ,
message,
};
}
// -- Exported run function ----------------------------------------------------
export async function run ({
appName ,
deep = false ,
dryRun = true ,
} : {
appName : string ;
deep ?: boolean ;
dryRun ?: boolean ;
}) {
const platform = os. platform ();
return platform === "win32"
? uninstallAppWin32 (appName, deep, dryRun)
: uninstallAppDarwin (appName, deep, dryRun);
}
// -- CLI smoke test -----------------------------------------------------------
if ( false ) {
run ({ appName: "Zoom" , deep: false , dryRun: true })
. then ( r => console. log ( JSON . stringify (r, null , 2 )))
. catch (( err : Error ) => { console. error (err.message); process. exit ( 1 ); });
}