# SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 # # Unsloth Studio uninstaller for Windows PowerShell. Run -Help for details. # Custom roots (UNSLOTH_STUDIO_HOME / STUDIO_HOME) come from share\studio.conf. # # Usage: run -Help. The web one-liner is in that help text and is not repeated here, since # AMSI scans this file in full before any of it runs and nothing reads the header. function Uninstall-UnslothStudio { $ErrorActionPreference = "Continue" # Reset at entry: a piped web run defines this function in the caller's session, so a # second run in the same window would otherwise inherit the first run's flags. $script:RemoveFailed = $false $script:StudioDbRemoved = $false function _Usage { Write-Host @' Unsloth Studio uninstaller (Windows PowerShell). Usage: irm https://raw.githubusercontent.com/unslothai/unsloth/main/scripts/uninstall.ps1 | iex Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass; .\scripts\uninstall.ps1 Stops running Unsloth Studio servers, then removes the install dir, launcher data, CLI shim, desktop and Start Menu shortcuts, the user PATH entry and the PathBackup registry key. In a default-mode install it also removes the shared prebuilts that sit beside the install dir: %USERPROFILE%\.unsloth\{llama.cpp,node,whisper.cpp,.cache}. The Hugging Face cache is left in place, as is anything else you keep under %USERPROFILE%\.unsloth. Options: -Help, -h, --help, -?, /? Print this message and exit without removing anything. Run with no arguments to uninstall. Unrecognized arguments never trigger removal. Environment: UNSLOTH_STUDIO_HOME Also remove this custom install root. Set it to the value used at install time. STUDIO_HOME Alias for the above, ignored when both are set. '@ } # Reject unknown arguments before destructive work. Use throw so embedded # invocations report failure without exiting the caller's PowerShell session. foreach ($arg in $args) { if ($arg -in @('-h', '-help', '--help', '-?', '/?')) { _Usage; return } Write-Host "uninstall.ps1: unrecognized argument: $arg" -ForegroundColor Red Write-Host "Nothing was removed. Re-run with no arguments to uninstall, or -Help." throw "uninstall.ps1: unrecognized argument: $arg" } function _Step { param([string]$Msg) Write-Host $Msg } function _Substep { param([string]$Msg, [string]$Color = "Gray") Write-Host " $Msg" -ForegroundColor $Color } # Remove a file/dir/symlink if present. Idempotent; retries since a just-killed # process can briefly hold a handle (Windows refuses the delete until released). function _RemovePath { param([string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { return } if (-not (Test-Path -LiteralPath $Path)) { return } for ($attempt = 1; $attempt -le 4; $attempt++) { try { Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop } catch { if ($attempt -lt 4) { Start-Sleep -Milliseconds 700; continue } _Substep "could not remove: $Path ($($_.Exception.Message))" "Yellow" # The closing summary must not promise the data is gone. $script:RemoveFailed = $true return } # Remove-Item -Recurse can report success yet leave a transiently-locked # child (e.g. unsloth.ico in Explorer's icon cache); verify + retry so we # never falsely claim "removed" or orphan the dir. if (-not (Test-Path -LiteralPath $Path)) { _Substep "removed: $Path" "Green" return } if ($attempt -lt 4) { Start-Sleep -Milliseconds 700; continue } _Substep "still present (files held open): $Path" "Yellow" $script:RemoveFailed = $true } } # LOCALAPPDATA / APPDATA are dropped in service and CI contexts, so fall back to the known # folder; $null only if that fails too, which callers treat as incomplete cleanup. function _AppDataRoot { param([string]$Var, [string]$Folder) if (-not [string]::IsNullOrWhiteSpace($Var)) { return $Var } $p = try { [Environment]::GetFolderPath($Folder) } catch { $null } if ([string]::IsNullOrWhiteSpace($p)) { return $null } return $p } # Remove an install root and record whether its studio.db really went with it. That file # holds chat_threads/chat_messages (backend/storage/studio_db.py), not the provider API # keys, which providers_db.py keeps in the browser's localStorage only. It sits under the # install root, so an env-mode install keeps it in a custom root a bare run cannot find. # The check runs on the RESOLVED target: a relocated install (junction or symlink to # another disk) passes the before-check through the link, but the delete unlinks only the # reparse point, and afterwards the path stops resolving and reads as absent either way. # Verifying rather than chasing the link is deliberate: following a reparse point out of # the expected location to delete its target is what the deny list exists to stop. function _RemoveRootRecordingDb { param([string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { return } # Anchor a relative reparse-point target to the link's own parent, or Join-Path # resolves it from the uninstaller's working directory and the db test reads false. $resolveTarget = { param($Item, $Fallback) if (-not $Item -or -not $Item.Target) { return $Fallback } $t = @($Item.Target)[0] if ([string]::IsNullOrWhiteSpace($t)) { return $Fallback } if (-not [System.IO.Path]::IsPathRooted($t)) { $t = Join-Path (Split-Path -LiteralPath $Item.FullName -Parent) $t } return $t } $real = $Path try { $item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue $real = & $resolveTarget $item $Path } catch { } # The db itself can be a reparse point out of the tree: the target survives the delete. $dbPath = Join-Path $real "studio.db" $hadDb = Test-Path -LiteralPath $dbPath -PathType Leaf if ($hadDb) { try { $dbItem = Get-Item -LiteralPath $dbPath -Force -ErrorAction SilentlyContinue $dbPath = & $resolveTarget $dbItem $dbPath } catch { } } _RemovePath $Path if ($hadDb) { if (Test-Path -LiteralPath $dbPath -PathType Leaf) { $script:RemoveFailed = $true } else { $script:StudioDbRemoved = $true } } } # Remove the shared data dir, but keep unsloth.ico if a WSL shortcut still points # at it (else that shortcut blanks); uninstall.sh drops it when WSL is removed. function _RemoveDataDirKeepingWslIcon { param( [string]$DataDir, # WSL-shortcut search dirs; default Start Menu + Desktop, overridable for tests. [string[]]$ShortcutDirs = $null ) if ([string]::IsNullOrWhiteSpace($DataDir)) { return } if (-not (Test-Path -LiteralPath $DataDir)) { return } # $null = not passed (use defaults); test $null not truthiness so an explicit # @() is honored (-not @() is $true). if ($null -eq $ShortcutDirs) { # Guard $env:APPDATA: it can be unset in service/CI Windows contexts, where # an unguarded Join-Path emits a noisy parameter-binding error. $ShortcutDirs = @() if (-not [string]::IsNullOrWhiteSpace($env:APPDATA)) { $ShortcutDirs += Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs" } try { $desktop = [Environment]::GetFolderPath("Desktop") if (-not [string]::IsNullOrWhiteSpace($desktop)) { $ShortcutDirs += $desktop } } catch {} } $wslShortcuts = @() foreach ($d in $ShortcutDirs) { if ($d -and (Test-Path -LiteralPath $d)) { $wslShortcuts += Get-ChildItem -LiteralPath $d -Filter "Unsloth Studio (WSL*.lnk" -ErrorAction SilentlyContinue } } if (@($wslShortcuts).Count -eq 0) { _RemovePath $DataDir return } # A WSL shortcut survives: drop everything except its shared icon. _Substep "keeping $(Join-Path $DataDir 'unsloth.ico') for the WSL shortcut" "Gray" Get-ChildItem -LiteralPath $DataDir -Force -ErrorAction SilentlyContinue | ForEach-Object { if ($_.Name -ne "unsloth.ico") { _RemovePath $_.FullName } } } # Is this bin\unsloth.cmd the launcher install.ps1 wrote, or just a file with that # name? The distinction decides whether a directory gets deleted recursively, so a # name alone is not enough -- `unsloth.cmd` is a plausible wrapper for anyone who # ships an unsloth-based tool, and pointing UNSLOTH_STUDIO_HOME at such a project # must not hand its whole tree to _RemovePath. # # The trampoline is the marker: install.ps1 bakes that exact expression into the # shim, no other file has a reason to carry it, and it survives every layout the # shim has (relative %~dp0 or an absolute cross-volume path, unsloth_studio or the # legacy .venv). Bounded read: the real shim is a few hundred bytes. function _IsUnslothCmdShim { param([string]$Path) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $false } try { $item = Get-Item -LiteralPath $Path -ErrorAction Stop if ($item.Length -gt 8192) { return $false } $text = [System.IO.File]::ReadAllText($Path) } catch { # Unreadable proves nothing, and "proves nothing" must not mean "delete it". return $false } return ($text -like "*unsloth-studio-managed-launcher*" -and $text -like "*from unsloth_cli import app*") } # A path is an Unsloth-owned root iff one of install.ps1's sentinels exists: # \share\studio.conf, \unsloth_studio\.unsloth-studio-owned, # \bin\unsloth.exe, or a \bin\unsloth.cmd this installer wrote. # The .cmd is the interpreter-based launcher install.ps1 writes beside the .exe for # machines whose Application Control policy denies the generated console script. An # install whose .exe was removed by that policy's quarantine still owns its root. function _IsStudioRoot { param([string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { return $false } if (Test-Path -LiteralPath (Join-Path $Path "share\studio.conf") -PathType Leaf) { return $true } if (Test-Path -LiteralPath (Join-Path $Path "unsloth_studio\.unsloth-studio-owned") -PathType Leaf) { return $true } if (Test-Path -LiteralPath (Join-Path $Path "bin\unsloth.exe") -PathType Leaf) { return $true } if (_IsUnslothCmdShim (Join-Path $Path "bin\unsloth.cmd")) { return $true } return $false } # Hard deny list. Refuse to recursively delete drive roots, USERPROFILE # itself, parent of USERPROFILE, or system directories. function _IsUnsafeRoot { param([string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { return $true } $norm = $null try { $norm = [System.IO.Path]::GetFullPath($Path).TrimEnd('\','/') } catch { return $true } if ([string]::IsNullOrWhiteSpace($norm)) { return $true } # Drive root, e.g. C:\ if ($norm -match '^[A-Za-z]:[\\/]?$') { return $true } $userProfile = $env:USERPROFILE if ($userProfile) { $userProfile = $userProfile.TrimEnd('\','/') if ($norm -ieq $userProfile) { return $true } try { $parent = Split-Path -LiteralPath $userProfile -Parent if ($parent -and ($norm -ieq $parent.TrimEnd('\','/'))) { return $true } } catch { } } $systemRoots = @( $env:SystemRoot, $env:windir, $env:ProgramFiles, ${env:ProgramFiles(x86)}, $env:ProgramData, $env:APPDATA, $env:LOCALAPPDATA ) foreach ($s in $systemRoots) { if (-not [string]::IsNullOrWhiteSpace($s)) { $s2 = $s.TrimEnd('\','/') if ($norm -ieq $s2) { return $true } } } return $false } # Parse UNSLOTH_EXE='' out of a share\studio.conf and return the # implied install root (three dirnames up from the venv exe). function _RootFromConf { param([string]$ConfFile) if (-not (Test-Path -LiteralPath $ConfFile -PathType Leaf)) { return $null } $line = Get-Content -LiteralPath $ConfFile -ErrorAction SilentlyContinue | Where-Object { $_ -match "^UNSLOTH_EXE\s*=" } | Select-Object -First 1 if (-not $line) { return $null } # Tolerate ' value ' single-quoted with '' -> ' apostrophe escape. if ($line -match "^UNSLOTH_EXE\s*=\s*'(.*)'\s*$") { $exe = $Matches[1] -replace "''", "'" try { $bin = Split-Path -LiteralPath $exe -Parent $studio = Split-Path -LiteralPath $bin -Parent $root = Split-Path -LiteralPath $studio -Parent if ($root) { return $root } } catch { } } return $null } # Expand a leading ~ or ~/ ~\ to $env:USERPROFILE so env-mode roots # written with the tilde shape install.ps1 supports (lines 152-154) are # found here too. function _ExpandTilde { param([string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { return $Path } $p = $Path.Trim() if ($p -eq '~') { return $env:USERPROFILE } if ($p.StartsWith('~/') -or $p.StartsWith('~\')) { if ($env:USERPROFILE) { return (Join-Path $env:USERPROFILE $p.Substring(2).TrimStart('/','\')) } } return $p } # Discover non-default Unsloth roots from env vars + studio.conf files. # Mirrors install.ps1's precedence: UNSLOTH_STUDIO_HOME wins, STUDIO_HOME # is ignored when both are set, so uninstalling install A doesn't also # delete install B if the user has a stale STUDIO_HOME pointing at B. function _CustomStudioRoots { $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $defaultRoot = $null if ($env:USERPROFILE) { $defaultRoot = (Join-Path $env:USERPROFILE ".unsloth\studio") } $emit = { param($Path) if ([string]::IsNullOrWhiteSpace($Path)) { return } $expanded = _ExpandTilde $Path $norm = $null try { $norm = [System.IO.Path]::GetFullPath($expanded).TrimEnd('\','/') } catch { return } if (-not $norm) { return } if ($defaultRoot -and ($norm -ieq $defaultRoot.TrimEnd('\','/'))) { return } if ($seen.Add($norm)) { Write-Output $norm } } $envRoot = $null if ($env:UNSLOTH_STUDIO_HOME) { $envRoot = $env:UNSLOTH_STUDIO_HOME } elseif ($env:STUDIO_HOME) { $envRoot = $env:STUDIO_HOME } if ($envRoot) { $expandedEnv = _ExpandTilde $envRoot & $emit $expandedEnv $confRoot = _RootFromConf (Join-Path $expandedEnv "share\studio.conf") if ($confRoot) { & $emit $confRoot } } # Default-mode conf at LOCALAPPDATA\Unsloth Studio. if ($env:LOCALAPPDATA) { $confRoot = _RootFromConf (Join-Path $env:LOCALAPPDATA "Unsloth Studio\studio.conf") if ($confRoot) { & $emit $confRoot } } } # Return $true iff the PID's image path lives under one of $KnownRoots. # Prevents killing an unrelated process that happens to listen on a stale # Unsloth port. function _PidUnderKnownRoot { param([int]$Pid_, [string[]]$KnownRoots) if (-not $KnownRoots -or $KnownRoots.Count -eq 0) { return $false } try { $proc = Get-CimInstance Win32_Process -Filter "ProcessId=$Pid_" -ErrorAction SilentlyContinue if (-not $proc) { return $false } $exe = $proc.ExecutablePath if (-not $exe) { return $false } foreach ($r in $KnownRoots) { if ($r -and ($exe -ilike "$r\*")) { return $true } } } catch { } return $false } # Stop an Unsloth backend whose port is recorded in \studio.port. # Only kills if the listening PID's exe path is under a known Unsloth root. function _StopByPortFile { param([string]$PortFile, [string[]]$KnownRoots) if (-not (Test-Path -LiteralPath $PortFile -PathType Leaf)) { return } $port = Get-Content -LiteralPath $PortFile -ErrorAction SilentlyContinue | Select-Object -First 1 if ($port) { $port = $port.Trim() } if (-not ($port -match '^[0-9]+$')) { Remove-Item -LiteralPath $PortFile -Force -ErrorAction SilentlyContinue return } try { $conns = Get-NetTCPConnection -State Listen -LocalPort ([int]$port) -ErrorAction SilentlyContinue foreach ($c in $conns) { if (-not (_PidUnderKnownRoot -Pid_ ([int]$c.OwningProcess) -KnownRoots $KnownRoots)) { continue } try { Stop-Process -Id $c.OwningProcess -Force -ErrorAction SilentlyContinue } catch { } } } catch { # netstat fallback for older PowerShell. Require LISTENING state so # we never kill a process whose remote endpoint just happens to be # the cached port (browser -> :443 etc.). try { $lines = & netstat.exe -ano 2>$null | Select-String -Pattern "LISTENING" | Select-String -Pattern ":$port\s" foreach ($l in $lines) { $parts = ($l.ToString() -split '\s+') | Where-Object { $_ } $pid_ = $parts[-1] if ($pid_ -match '^\d+$') { if (-not (_PidUnderKnownRoot -Pid_ ([int]$pid_) -KnownRoots $KnownRoots)) { continue } try { Stop-Process -Id ([int]$pid_) -Force -ErrorAction SilentlyContinue } catch { } } } } catch { } } Remove-Item -LiteralPath $PortFile -Force -ErrorAction SilentlyContinue } # Stop processes whose ExecutablePath lives under an unsloth_studio venv. # Anchoring on the venv path avoids matching unrelated python.exe / studio.exe. function _StopStudioProcesses { param([string[]]$KnownRoots) try { $procs = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { $_.ExecutablePath -and ($_.ExecutablePath -match '\\unsloth_studio\\.*\\(unsloth|python|studio)\.exe$') -and $_.CommandLine -and ($_.CommandLine -match 'studio') } foreach ($p in $procs) { # Optional scope: only kill if the exe is under a known root. if ($KnownRoots) { $match = $false foreach ($r in $KnownRoots) { if ($p.ExecutablePath -and ($p.ExecutablePath -ilike "$r\*")) { $match = $true; break } } if (-not $match) { continue } } try { Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue } catch { } } } catch { } } # The Studio-managed subtrees underneath the reparse-point TARGET of each Studio home, for the # stop scan only. # # A junction or directory symlink Studio home runs its native binaries out of the PHYSICAL # path: the backend resolves the home (Path.resolve) before deriving \stable-diffusion.cpp # and launching sd-server there, while _CustomStudioRoots only normalizes the string -- # System.IO.Path.GetFullPath is lexical and never touches the filesystem, so it leaves a # reparse point untouched. The prefix scan below reads Win32_Process.ExecutablePath, the real # image path, so without the target the running server never matches and survives an uninstall # that took its tree. # # The SUBTREES, never the bare target. The delete unlinks only the reparse point and leaves the # target standing, so anything there that is not ours is neither locking nor being removed -- # a home relocated onto a directory that holds other software must not have those force-stopped. # Homes only, for the same reason: the component dirs ($defaultNode, $defaultLlamaCpp, ...) can # themselves be links onto a shared runtime, and resolving those would put every process out of # it in scope. # # Stop scan only, deliberately. _RemoveRootRecordingDb and the deletes still refuse to chase a # link out of the expected location -- following one to delete its target is exactly what the # deny list exists to prevent. Ending our own process under the target is not destructive. function _ManagedPathsUnderReparseTargets { param([string[]]$Roots) # Everything setup.ps1 / the prebuilt installers place inside a Studio home. $managed = @( "unsloth_studio", "share", "bin", "llama.cpp", "whisper.cpp", "node", "stable-diffusion.cpp", ".cache", ".venv_t5_510", ".venv_t5_530", ".venv_t5_550" ) $out = @() foreach ($r in @($Roots | Where-Object { $_ })) { try { $item = Get-Item -LiteralPath $r -Force -ErrorAction SilentlyContinue if (-not $item -or -not $item.Target) { continue } $t = @($item.Target)[0] if ([string]::IsNullOrWhiteSpace($t)) { continue } # A symlink target may be relative; a junction's never is. Anchor it on the link's # own parent, or GetFullPath would read it from the uninstaller's working directory. if (-not [System.IO.Path]::IsPathRooted($t)) { $t = Join-Path (Split-Path -LiteralPath $item.FullName -Parent) $t } $t = [System.IO.Path]::GetFullPath($t).TrimEnd('\', '/') if (-not $t) { continue } foreach ($sub in $managed) { $p = (Join-Path $t $sub).TrimEnd('\', '/') if ($out -notcontains $p) { $out += $p } } } catch { } } return $out } # Stop processes that would block deleting the paths we remove. Unlike # _StopStudioProcesses (venv exe only), this also catches llama-server/llama-cli, # the unsloth.exe shim, and orphaned mp workers under SYSTEM python holding a # venv DLL (an open DLL handle blocks the dir delete) -- found by scanning each # candidate's loaded modules, not just its image path. function _StopProcessesLockingRoots { param([string[]]$Roots) $clean = @($Roots | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\','/') }) if ($clean.Count -eq 0) { return } $underRoot = { param($p) if (-not $p) { return $false } foreach ($r in $clean) { if ($p -ieq $r -or $p -ilike "$r\*") { return $true } } return $false } # 1. Image path under a target root (venv python, shim, llama-server). try { foreach ($proc in (Get-CimInstance Win32_Process -ErrorAction SilentlyContinue)) { if ((& $underRoot $proc.ExecutablePath)) { try { Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue } catch { } } } } catch { } # 2. A loaded module under a target root (orphaned mp-fork python holding a # venv DLL). Scoped to names that load our DLLs to keep the scan fast. try { $cands = Get-Process -Name python, pythonw, unsloth, llama-server, llama-cli, sd-cli, sd-server -ErrorAction SilentlyContinue foreach ($proc in $cands) { $hit = $false try { foreach ($m in $proc.Modules) { if ((& $underRoot $m.FileName)) { $hit = $true; break } } } catch { } # access denied enumerating modules -> skip if ($hit) { try { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue } catch { } } } } catch { } } # Default install root + default data dir. $defaultStudioHome = if ($env:USERPROFILE) { Join-Path $env:USERPROFILE ".unsloth\studio" } else { $null } $defaultDataDir = if ($env:LOCALAPPDATA) { Join-Path $env:LOCALAPPDATA "Unsloth Studio" } else { $null } # Default-mode ~/.unsloth holds a SHARED llama.cpp build + .cache that are # siblings of studio (not under it), so deleting misses them -- handle # explicitly. No-op in env/custom mode (nested under the custom root, removed # with it). A user-set UNSLOTH_LLAMA_CPP_PATH is left alone. $defaultUnslothHome = if ($env:USERPROFILE) { Join-Path $env:USERPROFILE ".unsloth" } else { $null } $defaultLlamaCpp = if ($defaultUnslothHome) { Join-Path $defaultUnslothHome "llama.cpp" } else { $null } # Default-mode native diffusion build (install_sd_cpp_prebuilt.default_install_dir()), # a sibling of studio like llama.cpp. No-op in env/custom mode and when absent. A # user-set UNSLOTH_SD_CPP_PATH is left alone. $defaultSdCpp = if ($defaultUnslothHome) { Join-Path $defaultUnslothHome "stable-diffusion.cpp" } else { $null } $defaultCache = if ($defaultUnslothHome) { Join-Path $defaultUnslothHome ".cache" } else { $null } # Isolated Node.js runtime (install_node_prebuilt.py), a sibling of studio in # default mode. No-op in env/custom mode (nested under the custom root) and absent. $defaultNode = if ($defaultUnslothHome) { Join-Path $defaultUnslothHome "node" } else { $null } # llama.cpp atomic-install staging root (install_llama_prebuilt.py .staging, # sibling of the install dir). Usually pruned after activate, but an interrupted # build can leave a ".staging-XXXX" tree; removing it lets the empty-dir # cleanup of ~/.unsloth below succeed. No-op in env/custom mode and when absent. $defaultStaging = if ($defaultUnslothHome) { Join-Path $defaultUnslothHome ".staging" } else { $null } # Managed whisper.cpp dictation engine (setup.ps1 PHASE 3.4 installs it at # $UnslothHome\whisper.cpp), a sibling of studio in default mode. No-op in # env/custom mode (nested under the custom root) and when absent. $defaultWhisperCpp = if ($defaultUnslothHome) { Join-Path $defaultUnslothHome "whisper.cpp" } else { $null } # Build known-root list FIRST so the port-file kill can verify ownership. $customRoots = @(_CustomStudioRoots) $knownRoots = @() if ($defaultStudioHome) { $knownRoots += $defaultStudioHome } $knownRoots += $customRoots # ── Stop running servers ── _Step "Stopping any running Unsloth Studio servers..." if ($defaultDataDir) { _StopByPortFile -PortFile (Join-Path $defaultDataDir "studio.port") -KnownRoots $knownRoots } foreach ($r in $customRoots) { _StopByPortFile -PortFile (Join-Path $r "share\studio.port") -KnownRoots $knownRoots } _StopStudioProcesses -KnownRoots $knownRoots # The app and the WebView2 helpers holding its profile open must both exit before the # EBWebView delete below. Same resolver as that removal, or the sweep misses the profile # we then try to delete and the helpers keep holding locks. $localAppRoot = _AppDataRoot $env:LOCALAPPDATA 'LocalApplicationData' $webviewProfile = if ($localAppRoot) { Join-Path $localAppRoot "ai.unsloth.studio" } else { $null } # Account-scoped, like every other kill here. installMode is currentUser, so the profile is # this account's and shared by all its sessions: a second console or RDS session must die # too or it re-creates the profile mid-delete, while another user's Studio must not be # touched. SIDs, so domain and locale do not matter; an unreadable owner is skipped. $meSid = try { [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value } catch { $null } $studioPids = @() if ($meSid) { try { # Unsloth.exe too: older releases used the product name as MAINBINARYNAME, and this # script is always fetched fresh from main, so it meets those installs. A missed # process re-creates the profile. The owner-SID filter keeps the broader name safe. $filter = "Name = 'unsloth-studio.exe' OR Name = 'Unsloth.exe'" foreach ($sp in (Get-CimInstance Win32_Process -Filter $filter -ErrorAction SilentlyContinue)) { $spSid = try { (Invoke-CimMethod -InputObject $sp -MethodName GetOwnerSid -ErrorAction SilentlyContinue).Sid } catch { $null } if ($spSid -eq $meSid) { $studioPids += [int]$sp.ProcessId } } } catch { } } if ($webviewProfile) { # Escape "[", a wildcard class, or we miss our profile and match others. Trailing \ # spares "2\EBWebView". $pattern = "*" + [System.Management.Automation.WildcardPattern]::Escape($webviewProfile) + "\*" try { $wvProcs = @(Get-CimInstance Win32_Process -Filter "Name = 'msedgewebview2.exe'" -ErrorAction SilentlyContinue) # Parent lookup for the ancestor walk; WebView2 procs are all the chain passes through. $parentOf = @{} foreach ($p in $wvProcs) { $parentOf[[int]$p.ProcessId] = [int]$p.ParentProcessId } # Chromium process model: the renderer, GPU and utility helpers are children of the # browser process, not of unsloth-studio.exe, so an immediate-parent check misses # them all and they keep EBWebView locked (Stop-Process is not recursive). # Depth-capped so a PID-reuse cycle cannot spin here. $isOurs = { param($StartPid) $cur = $StartPid for ($hop = 0; $hop -lt 12; $hop++) { if (-not $parentOf.ContainsKey($cur)) { return $false } $parent = $parentOf[$cur] if ($studioPids -contains $parent) { return $true } if ($parent -eq $cur -or $parent -le 0) { return $false } $cur = $parent } return $false } foreach ($proc in $wvProcs) { # CommandLine is null across an elevation boundary: fall back to the parent chain. $mine = if ($proc.CommandLine) { $proc.CommandLine -ilike $pattern } else { & $isOurs ([int]$proc.ProcessId) } if ($mine) { try { Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue } catch { } } } } catch { } } if ($studioPids) { try { Stop-Process -Id $studioPids -Force -ErrorAction SilentlyContinue } catch { } # WebView2 releases the profile only once its browser processes have exited. Wait-Process -Id $studioPids -Timeout 10 -ErrorAction SilentlyContinue } # Only stop the default sd.cpp dir when it carries our owner marker, so stop matches the # marker-gated delete and a user's own sd-server at this default path is left running. $defaultSdCppToStop = $null if ($defaultSdCpp -and (Test-Path -LiteralPath $defaultSdCpp) -and (Test-Path -LiteralPath (Join-Path $defaultSdCpp ".unsloth-studio-owned") -PathType Leaf)) { $defaultSdCppToStop = $defaultSdCpp } # A custom/env-mode sd.cpp build now sits UNDER its root at \stable-diffusion.cpp, which # the $knownRoots prefix match below already covers. Older builds put it BESIDE the root at # \stable-diffusion.cpp, outside $knownRoots. We delete those marker-owned dirs below, # so add them to the handle scan too, gated on the same owner marker. $customSdCppToStop = @() foreach ($r in $customRoots) { $sdc = Join-Path (Split-Path -LiteralPath $r -Parent) "stable-diffusion.cpp" if ((Test-Path -LiteralPath $sdc) -and (Test-Path -LiteralPath (Join-Path $sdc ".unsloth-studio-owned") -PathType Leaf)) { $customSdCppToStop += $sdc } } # Also stop anything holding a handle on the exact paths we delete (llama-server, # the CLI shim, an mp-fork python with a venv DLL) so the dir delete isn't refused. $stopRoots = @($knownRoots) + @($defaultDataDir, $defaultLlamaCpp, $defaultCache, $defaultNode, $defaultWhisperCpp) + @($defaultSdCppToStop | Where-Object { $_ }) + @($customSdCppToStop) _StopProcessesLockingRoots -Roots ($stopRoots + @(_ManagedPathsUnderReparseTargets $knownRoots)) # ── Remove custom-root install trees ── _Step "Removing data and install directories..." foreach ($r in $customRoots) { if (_IsUnsafeRoot $r) { _Substep "refusing to remove unsafe path: $r" "Yellow" # install.ps1 accepts any writable root, so a real install can sit under a deny-listed # path. Nothing is deleted and it holds studio.db, so say so. Mirrors uninstall.sh. if (Test-Path -LiteralPath $r) { $script:RemoveFailed = $true } continue } if (-not (_IsStudioRoot $r)) { _Substep "refusing to remove non-Unsloth path: $r" "Yellow" continue } _RemoveRootRecordingDb $r # Native diffusion (stable-diffusion.cpp) now installs UNDER the custom root, at # \stable-diffusion.cpp, so the removal above already took it. Older builds put it # BESIDE the root at \stable-diffusion.cpp (find_sd_cpp_binary derived it from # UNSLOTH_STUDIO_HOME.parent), and removing only the root would leave that build behind. # Only remove a sibling Studio installed: is a user-chosen dir and # "stable-diffusion.cpp" is exactly what a git clone of the upstream project produces, so # require our owner marker (written by install_sd_cpp_prebuilt) before rm, and keep any # unowned checkout. Guard the derived parent path the same way. $customSdCpp = Join-Path (Split-Path -LiteralPath $r -Parent) "stable-diffusion.cpp" if (_IsUnsafeRoot $customSdCpp) { _Substep "refusing to remove unsafe path: $customSdCpp" "Yellow" } elseif ((Test-Path -LiteralPath $customSdCpp) -and -not (Test-Path -LiteralPath (Join-Path $customSdCpp ".unsloth-studio-owned") -PathType Leaf)) { _Substep "keeping sd.cpp without Studio owner marker: $customSdCpp" "Yellow" } else { _RemovePath $customSdCpp } } # Default install dir (always at %USERPROFILE%\.unsloth\studio when present). if ($defaultStudioHome) { _RemoveRootRecordingDb $defaultStudioHome } # Default data dir. if ($defaultDataDir) { _RemoveDataDirKeepingWslIcon $defaultDataDir } # Default-mode shared llama.cpp build + cache (siblings of studio under # ~/.unsloth). No-op in env/custom mode and when absent. if ($defaultLlamaCpp) { _RemovePath $defaultLlamaCpp } # "stable-diffusion.cpp" is exactly what a git clone of leejet/stable-diffusion.cpp produces, # so a user may keep their own checkout (or point UNSLOTH_SD_CPP_PATH) at this default path; # require our owner marker (written by install_sd_cpp_prebuilt) before rm, mirroring the # custom-root guard above, so a user's own checkout or a pre-marker Studio build is kept. if ($defaultSdCpp -and (Test-Path -LiteralPath $defaultSdCpp) -and -not (Test-Path -LiteralPath (Join-Path $defaultSdCpp ".unsloth-studio-owned") -PathType Leaf)) { _Substep "keeping sd.cpp without Studio owner marker: $defaultSdCpp" "Yellow" } elseif ($defaultSdCpp) { _RemovePath $defaultSdCpp } if ($defaultCache) { _RemovePath $defaultCache } # Isolated Node.js runtime (sibling of studio under ~/.unsloth). No-op in env/ # custom mode (nested under the custom root, removed with it) and when absent. if ($defaultNode) { _RemovePath $defaultNode } if ($defaultStaging) { _RemovePath $defaultStaging } # Managed whisper.cpp prebuilt (sibling of studio under ~/.unsloth). Only # present when a whisper prebuilt matching the pinned llama.cpp build existed # at install time, so many installs lack it. if ($defaultWhisperCpp) { _RemovePath $defaultWhisperCpp } # Prebuilt install locks. Every prebuilt serializes on # \..install.lock (prebuilt_core.py install_lock_path), so # llama.cpp, node and whisper.cpp each leave one; a stray lock keeps # ~/.unsloth from being pruned below. No-op in env/custom mode and when absent. if ($defaultUnslothHome) { foreach ($lockName in @(".llama.cpp.install.lock", ".node.install.lock", ".whisper.cpp.install.lock")) { _RemovePath (Join-Path $defaultUnslothHome $lockName) } # Taking over an abandoned lock renames it to .stale. before unlinking # (install_node_prebuilt.py); a crash between the two steps strands the # rename, so sweep any leftovers. -Force to see the dot-prefixed names. if (Test-Path -LiteralPath $defaultUnslothHome) { # -like, not -Filter: the provider's Win32 filter is unreliable for # dot-leading names with several dots. foreach ($stale in @(Get-ChildItem -LiteralPath $defaultUnslothHome -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name -like "*.install.lock.stale.*" })) { _RemovePath $stale.FullName } } } # Drop ~/.unsloth itself, but ONLY if now empty -- never nuke unrelated content. if ($defaultUnslothHome -and (Test-Path -LiteralPath $defaultUnslothHome) -and -not (Get-ChildItem -LiteralPath $defaultUnslothHome -Force -ErrorAction SilentlyContinue)) { _RemovePath $defaultUnslothHome } # Runtime data, created at first launch rather than by install.ps1: LOCALAPPDATA holds the # EBWebView profile (a leftover copy serves a stale frontend), APPDATA the app config dir. _Step "Removing WebView caches and app data (ai.unsloth.studio)..." # Count an unresolvable root as incomplete cleanup: skipping it silently would leave the # profile on disk while the summary reports the session as gone. foreach ($known in @( @{ Var = $env:LOCALAPPDATA; Folder = 'LocalApplicationData' }, @{ Var = $env:APPDATA; Folder = 'ApplicationData' } )) { $base = _AppDataRoot $known.Var $known.Folder if ([string]::IsNullOrWhiteSpace($base)) { _Substep "could not resolve $($known.Folder); WebView data may remain" "Yellow" $script:RemoveFailed = $true continue } _RemovePath (Join-Path $base "ai.unsloth.studio") } # ── Remove desktop and Start Menu shortcuts ── _Step "Removing desktop and Start Menu shortcuts..." try { $desktop = [Environment]::GetFolderPath("Desktop") if ($desktop) { _RemovePath (Join-Path $desktop "Unsloth Studio.lnk") } } catch { } if ($env:APPDATA) { _RemovePath (Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Unsloth Studio.lnk") } # Invalidate the Win11 Start Menu tile cache so the removed shortcut's tile # disappears promptly instead of lingering stale (mirrors install.ps1's # New-StudioShortcuts). Preserves start2.bin (the pin layout). try { $smehTemp = Join-Path $env:LOCALAPPDATA "Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\TempState" if (Test-Path -LiteralPath $smehTemp) { Get-ChildItem -LiteralPath $smehTemp -Filter "TileCache_*" -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath (Join-Path $smehTemp "StartUnifiedTileModelCache.dat") -Force -ErrorAction SilentlyContinue Stop-Process -Name StartMenuExperienceHost -Force -ErrorAction SilentlyContinue } } catch { } # Re-sweep: the first pass may have left unsloth.ico locked by Explorer/SMEH for # the native shortcut; that handle is now freed. (A surviving WSL shortcut still # keeps the icon -- see the helper.) if ($defaultDataDir -and (Test-Path -LiteralPath $defaultDataDir)) { _RemoveDataDirKeepingWslIcon $defaultDataDir } # ── Clean user PATH and registry backup ── _Step "Cleaning user PATH and registry..." try { $regKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true) if ($regKey) { try { $rawPath = $regKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) if ($rawPath) { $entries = $rawPath -split ';' $kept = New-Object System.Collections.ArrayList $removedAny = $false # Only remove PATH entries that live inside an Unsloth root we # actually own (default or env-mode). A literal substring # match on `unsloth_studio` would clobber unrelated user # virtualenvs that happen to share the name. foreach ($e in $entries) { if ([string]::IsNullOrWhiteSpace($e)) { continue } $expanded = [Environment]::ExpandEnvironmentVariables($e).TrimEnd('\','/') $isStudio = $false foreach ($r in $knownRoots) { if (-not $r) { continue } $rNorm = $r.TrimEnd('\','/') if ($expanded -ieq $rNorm -or $expanded -ilike "$rNorm\*") { $isStudio = $true; break } } if ($isStudio) { _Substep "removed PATH entry: $e" "Green" $removedAny = $true continue } [void]$kept.Add($e) } if ($removedAny) { $newPath = ($kept -join ';') $regKey.SetValue('Path', $newPath, [Microsoft.Win32.RegistryValueKind]::ExpandString) try { $d = "UnslothPathRefresh_" + ([guid]::NewGuid().ToString('N').Substring(0, 8)) [Environment]::SetEnvironmentVariable($d, '1', 'User') [Environment]::SetEnvironmentVariable($d, [NullString]::Value, 'User') } catch { } } } } finally { $regKey.Close() } } } catch { _Substep "could not update user PATH: $($_.Exception.Message)" "Yellow" } # Remove HKCU\Software\Unsloth (PathBackup lives here; install.ps1 owns it). try { Remove-Item -LiteralPath 'HKCU:\Software\Unsloth' -Recurse -Force -ErrorAction SilentlyContinue } catch { } Write-Host "" Write-Host "Unsloth Studio uninstalled." if ($script:RemoveFailed) { Write-Host "Note: some paths could not be removed (see 'could not remove:' above), so the" Write-Host " signed-in session and local chat history may still be on disk. Remove" Write-Host " those paths by hand to clear them." } elseif ($script:StudioDbRemoved) { # Scoped to what was removed: a bare run never discovers a coexisting env-mode root. Write-Host "Note: this also removed the app's WebView data and the studio.db it found, so" Write-Host " the desktop app's session and the chat history in the install(s) removed" Write-Host " above are gone." } else { # No studio.db was deleted, so only the WebView-local data is accounted for: an env-mode # install this run never discovered still has its keys and history. Write-Host "Note: this also removed the app's WebView data, so the desktop app's session" Write-Host " is gone. A browser session is not affected: its tokens live in the same" Write-Host " localStorage as the API keys below." Write-Host " No studio.db was found, so any chat history in an install root this run" Write-Host " did not see is still on disk." } Write-Host "Note: provider API keys are kept in the browser's localStorage, not in studio.db." Write-Host " Unless you ran Studio as the desktop app, clear site data for the" Write-Host " http://localhost: origin you used to remove them." Write-Host "Note: Hugging Face model cache at %USERPROFILE%\.cache\huggingface was left in place." Write-Host "Remove it manually with 'Remove-Item -Recurse -Force `"$env:USERPROFILE\.cache\huggingface\hub`"' if desired." if (-not $env:UNSLOTH_STUDIO_HOME -and -not $env:STUDIO_HOME) { Write-Host "" Write-Host "If you installed Unsloth Studio with UNSLOTH_STUDIO_HOME or STUDIO_HOME" Write-Host "pointing at a custom directory, re-run this script with the same variable" Write-Host "set to also remove that install tree, e.g.:" Write-Host " `$env:UNSLOTH_STUDIO_HOME = 'C:\your\path'; irm https://raw.githubusercontent.com/unslothai/unsloth/main/scripts/uninstall.ps1 | iex" } } Uninstall-UnslothStudio @args