# 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
# The default root was REFUSED and still holds studio.db. Its own flag, not RemoveFailed:
# nothing failed, so "remove those paths by hand" is the wrong thing to say about it.
$script:StudioDbKept = $false
# ONE budget for the run: what _RemovePath waits out is wall clock and shared, so whatever
# the first blocked path waits, the next does not. Without it an undeletable root costs the
# full escalation at each of the 18 call sites. Classifying the error instead does not work:
# a delete-pending file and an antivirus hold both report access denied.
$script:RemoveWaitBudgetMs = 20000
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. throw, not exit, so an embedded run reports
# failure without killing 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 }
# Escalating: torch inductor holds unattributable DATA handles under
# TORCHINDUCTOR_CACHE_DIR for seconds after the server stops.
$delays = @(250, 500, 1000, 2000, 4000, 4000, 4000, 4000)
# 2100ms per path is free, exactly the flat 700ms x3 this replaced, so no path is retried
# less than before: a lock only this path hits is not the shared wait. $false means both
# are spent, so stop rather than retry with no pause in between.
$freeLeft = 2100
function _Wait {
param([int]$Ms, [ref]$FreeLeft)
$free = [Math]::Min($Ms, [Math]::Max(0, $FreeLeft.Value))
$paid = $Ms - $free
if ($paid -gt 0) {
if ($script:RemoveWaitBudgetMs -le 0) {
if ($free -le 0) { return $false }
$paid = 0
} else {
$paid = [Math]::Min($paid, $script:RemoveWaitBudgetMs)
$script:RemoveWaitBudgetMs -= $paid
}
}
$FreeLeft.Value -= $free
Start-Sleep -Milliseconds ($free + $paid)
return $true
}
for ($attempt = 0; $attempt -le $delays.Count; $attempt++) {
$lastTry = ($attempt -eq $delays.Count)
try {
Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop
} catch {
# Read before $wait runs, so nothing can shadow the error record.
$failure = $_.Exception.Message
if (-not $lastTry -and (_Wait $delays[$attempt] ([ref]$freeLeft))) { continue }
_Substep "could not remove: $Path ($failure)" "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 "removed" is never a lie.
if (-not (Test-Path -LiteralPath $Path)) {
_Substep "removed: $Path" "Green"
return
}
if (-not $lastTry -and (_Wait $delays[$attempt] ([ref]$freeLeft))) { continue }
_Substep "still present (files held open): $Path" "Yellow"
$script:RemoveFailed = $true
return
}
}
# 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
# the chat history (backend/storage/studio_db.py), not the provider API keys, which
# providers_db.py keeps in the browser's localStorage only; 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 passes the before-check through the link, but the delete unlinks only the reparse
# point. 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.
# A removal that got part way can take the sentinels and then fail on a locked child, leaving
# a root the next run's gate would refuse. Put the marker back so a retry recognises it.
function _RestoreOwnerMarker {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return }
if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return }
# Get-Item -Force, not Test-Path: the latter follows a dangling link and answers false,
# after which WriteAllText follows the link and writes outside the root.
$marker = Join-Path $Path ".unsloth-studio-owned"
if (Get-Item -LiteralPath $marker -Force -ErrorAction SilentlyContinue) { return }
try { [System.IO.File]::WriteAllText($marker, "") } catch { }
}
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.
# -LiteralPath takes no -Parent (other parameter set; it throws) and already returns it.
$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) $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
_RestoreOwnerMarker $Path
if ($hadDb) {
if (Test-Path -LiteralPath $dbPath -PathType Leaf) {
$script:RemoveFailed = $true
} else {
$script:StudioDbRemoved = $true
}
}
}
# Reclaim ONLY what install.ps1 puts under "<LocalAppData>\Unsloth Studio\temp": directories
# named ust-<pid>-<hex>, then the temp dir and its parent if left empty. Deliberately narrow:
# this runs against every LocalAppData spelling, including one that can name a different user's
# profile, so it must never recursively delete anything it did not create. Nothing here follows
# a link (Directory.Delete with $false unlinks a reparse point without touching its target).
function _RemoveStudioPrivateTempTrees {
param(
[string[]]$Paths,
# The spelling this uninstall is actually for. Any OTHER spelling can be
# a different user's profile, so it gets the stricter rule below.
[string]$PrimaryPath
)
# Every directory this sweep kept, returned so the callers keep it too: the wholesale
# data-directory removal runs over the same tree, and a live owner preserved here but
# deleted there is not preserved at all.
$preserved = @()
foreach ($temp in @($Paths)) {
if ([string]::IsNullOrWhiteSpace($temp)) { continue }
if (-not (Test-Path -LiteralPath $temp -PathType Container)) { continue }
$isPrimary = (-not [string]::IsNullOrWhiteSpace($PrimaryPath)) -and
[string]::Equals($temp.TrimEnd('\','/'), $PrimaryPath.TrimEnd('\','/'), [System.StringComparison]::OrdinalIgnoreCase)
# Never descend through a link. Get-ChildItem on a reparse point enumerates the
# TARGET, whose ordinary children carry no ReparsePoint attribute, so the recursive
# delete below would take somebody else's tree by way of a redirected temp dir.
#
# How far up to look differs by spelling. For the profile this uninstall is FOR, the
# temp directory and the "Unsloth Studio" directory above it are the two this script
# created and the two that decide where the enumeration lands; a redirected
# LocalAppData higher up is still that same user's own storage, and refusing there
# would leave the installer's own temp tree behind on every host that uses folder
# redirection. Any OTHER spelling may be another profile entirely, and a junction
# anywhere along it -- LocalAppData, Users, the drive root -- is enough to redirect an
# ordinary-looking "Unsloth Studio\temp", so there every ancestor has to be ordinary.
$ancestors = @()
if ($isPrimary) {
$ancestors = @($temp, [System.IO.Path]::GetDirectoryName($temp))
} else {
$walk = $temp
# Bounded: GetDirectoryName returns $null at the root, and the cap stops a spin.
for ($depth = 0; $depth -lt 64; $depth++) {
if ([string]::IsNullOrWhiteSpace($walk)) { break }
$ancestors += $walk
$next = [System.IO.Path]::GetDirectoryName($walk)
if ($next -eq $walk) { break }
$walk = $next
}
}
$linked = $false
foreach ($ancestor in $ancestors) {
try {
if ([string]::IsNullOrWhiteSpace($ancestor)) { continue }
$info = Get-Item -LiteralPath $ancestor -Force -ErrorAction Stop
if ($info.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { $linked = $true }
} catch {}
}
if ($linked) {
_Substep "skipped (reparse point): $temp" "Yellow"
# Not ours to delete here, nor in the data-directory pass either.
$preserved += $temp
continue
}
$entries = @()
try { $entries = @(Get-ChildItem -LiteralPath $temp -Force -ErrorAction Stop) } catch { continue }
foreach ($entry in $entries) {
# Shape, not prefix: "ust-legacy" and "ust-notapid-x" are not ours.
if ($entry.Name -notmatch '^ust-[0-9]+-[0-9a-f]{8}$') { continue }
if (-not ($entry.PSIsContainer)) { continue }
# A LIVE owner keeps its directory, exactly as install.ps1's own sweep does. An
# uninstall stops the Unsloth instances under the roots it knows about; one from
# another install root, or another user, is not among them and still needs %TEMP%.
$ownerPid = 0
try {
$ownerFile = Join-Path $entry.FullName "owner.pid"
if ([System.IO.File]::Exists($ownerFile)) {
$null = [int]::TryParse(([System.IO.File]::ReadAllText($ownerFile)).Trim(), [ref]$ownerPid)
}
} catch { $ownerPid = 0 }
if ($ownerPid -gt 0) {
$ownerLives = $true
try { $null = Get-Process -Id $ownerPid -ErrorAction Stop }
catch [Microsoft.PowerShell.Commands.ProcessCommandException] { $ownerLives = $false }
catch { $ownerLives = $true }
if ($ownerLives) {
_Substep "in use by pid ${ownerPid}, left alone: $($entry.FullName)" "Yellow"
$preserved += $entry.FullName
continue
}
} elseif (-not $isPrimary) {
# No recorded owner, and not the profile being uninstalled. install.ps1 reads
# that as UNKNOWN rather than abandoned, because an installer killed before
# writing owner.pid leaves a live Unsloth holding the directory, and another
# user's live %TEMP% is not this uninstall's business. Under our own profile
# the shape is enough, since that is what is being removed.
_Substep "no recorded owner in another profile, left alone: $($entry.FullName)" "Yellow"
$preserved += $entry.FullName
continue
}
try {
if ($entry.Attributes -band [System.IO.FileAttributes]::ReparsePoint) {
[System.IO.Directory]::Delete($entry.FullName, $false)
} else {
Remove-Item -LiteralPath $entry.FullName -Recurse -Force -ErrorAction Stop
}
_Substep "removed: $($entry.FullName)" "Green"
} catch {
_Substep "could not remove: $($entry.FullName) ($($_.Exception.Message))" "Yellow"
$script:RemoveFailed = $true
}
}
# Only when empty, so a temp dir holding anything else is left alone, and likewise the
# "Unsloth Studio" parent, which on the second spelling may not be ours to delete.
foreach ($dir in @($temp, [System.IO.Path]::GetDirectoryName($temp))) {
try {
if ((Test-Path -LiteralPath $dir -PathType Container) -and
-not @(Get-ChildItem -LiteralPath $dir -Force -ErrorAction Stop)) {
[System.IO.Directory]::Delete($dir, $false)
}
} catch {}
}
}
return $preserved
}
# Delete a tree except for the paths in -Keep and the directories above them, which have to
# survive to hold them. With an empty -Keep this is _RemovePath, which is the ordinary case.
function _RemoveTreeKeeping {
param(
[string]$Path,
[string[]]$Keep
)
if ([string]::IsNullOrWhiteSpace($Path)) { return }
if (-not (Test-Path -LiteralPath $Path)) { return }
$self = $Path.TrimEnd('\','/')
$holdsKept = $false
foreach ($k in @($Keep)) {
if ([string]::IsNullOrWhiteSpace($k)) { continue }
$kept = $k.TrimEnd('\','/')
# The kept path itself: stop here, with everything inside it.
if ([string]::Equals($kept, $self, [System.StringComparison]::OrdinalIgnoreCase)) { return }
if ($kept.StartsWith(($self + '\'), [System.StringComparison]::OrdinalIgnoreCase) -or
$kept.StartsWith(($self + '/'), [System.StringComparison]::OrdinalIgnoreCase)) {
$holdsKept = $true
}
}
if (-not $holdsKept) {
_RemovePath $Path
return
}
# Something below survives, so this directory does too; take the rest of its children.
Get-ChildItem -LiteralPath $Path -Force -ErrorAction SilentlyContinue | ForEach-Object {
_RemoveTreeKeeping -Path $_.FullName -Keep $Keep
}
}
# 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,
# Paths under the data dir a previous pass kept, typically a live Unsloth's %TEMP%.
[string[]]$Preserve = @()
)
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.
if ($null -eq $ShortcutDirs) {
# Guard $env:APPDATA: unset in service/CI contexts, where Join-Path errors noisily.
$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
}
}
$keep = @()
foreach ($p in @($Preserve)) { if (-not [string]::IsNullOrWhiteSpace($p)) { $keep += $p } }
if (@($wslShortcuts).Count -eq 0 -and $keep.Count -eq 0) {
_RemovePath $DataDir
return
}
if (@($wslShortcuts).Count -gt 0) {
# A WSL shortcut survives: keep its shared icon.
_Substep "keeping $(Join-Path $DataDir 'unsloth.ico') for the WSL shortcut" "Gray"
$keep += (Join-Path $DataDir "unsloth.ico")
}
_RemoveTreeKeeping -Path $DataDir -Keep $keep
}
# 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, and `unsloth.cmd` is a
# plausible wrapper for anyone shipping an unsloth-based tool, so 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. 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:
# <root>\share\studio.conf, <root>\unsloth_studio\.unsloth-studio-owned,
# <root>\bin\unsloth.exe, or a <root>\bin\unsloth.cmd this installer wrote.
# The .cmd is the launcher written where an Application Control policy denies the generated
# console script, so an install whose .exe that policy quarantined still owns its root.
# Plus the legacy venv shapes install.ps1 still migrates: share\studio.conf is never written
# on Windows, so every sentinel above postdates the bin\ shim dir.
# A sentinel this gate may trust. Deliberately a plain existence test, following links:
# refusing a reparse point here, or a marker inside one, buys nothing and costs a supported
# install. Nothing, because anyone who can plant a junction at that path can plant a plain
# file there instead, which this has always accepted. A supported install, because relocating
# a multi-gigabyte venv with a junction leaves a REAL marker behind one, and refusing it
# strands the install, which is the failure this gate exists to prevent. The link test belongs
# in install.ps1's claim, where following one would TRUNCATE the target rather than read it.
function _IsOwnerMarker {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return $false }
return (Test-Path -LiteralPath $Path -PathType Leaf)
}
# Is $Path a Python venv? What the gate reads out of one is evidence only if the directory
# really is one; a bare name at that path is somebody else's.
function _IsVenvDir {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return $false }
# No reparse check here either, and for the same reason: a relocated venv is still a
# venv. The leftover scan below rejects links on its own, where the name came from a
# wildcard rather than from us.
if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return $false }
if (Test-Path -LiteralPath (Join-Path $Path "pyvenv.cfg") -PathType Leaf) { return $true }
return (Test-Path -LiteralPath (Join-Path $Path "Scripts\python.exe") -PathType Leaf)
}
# The exact shape an installer gives a moved-aside venv. install.ps1 keeps every other
# spelling as the user's data (Test-StudioVenvRollbackMustBePreserved); do not be looser.
function _IsInstallerLeftoverName {
param([string]$Name)
# Two patterns: only the rollback name carries a collision counter (install.ps1:4171).
# No "time" alternative; that is install.sh's date(1) fallback and has no Windows twin.
# [0-9], not \d, which matches every Unicode decimal digit; install.ps1 writes ASCII.
if ($Name -match '^unsloth_studio\.rollback\.[0-9]{14}\.[0-9]+(\.[0-9]+)?$') { return $true }
return ($Name -match '^\.venv\.invalid\.[0-9]{14}\.[0-9]+$')
}
function _IsStudioRoot {
param([string]$Path, [switch]$ManagedDefaultRoot)
if ([string]::IsNullOrWhiteSpace($Path)) { return $false }
# install.ps1 writes the first when it creates the root, before the uv cache and long
# before the venv, so a partial install identifies itself instead of being guessed at.
if (_IsOwnerMarker (Join-Path $Path ".unsloth-studio-owned")) { return $true }
if (_IsOwnerMarker (Join-Path $Path "share\studio.conf")) { return $true }
if (_IsOwnerMarker (Join-Path $Path "unsloth_studio\.unsloth-studio-owned")) { return $true }
if (_IsOwnerMarker (Join-Path $Path ".venv\.unsloth-studio-owned")) { 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 }
# Below here is INSIDE a venv, where pip puts it for any install of the wheel: it names
# the wheel, not the owner. Proof only at the managed root, which install.ps1:4453 also
# makes the only root that can hold the layout, or a stale UNSLOTH_STUDIO_HOME deletes it.
if (-not $ManagedDefaultRoot) { return $false }
foreach ($venv in @("unsloth_studio", ".venv")) {
if (-not (_IsVenvDir (Join-Path $Path $venv))) { continue }
if (Test-Path -LiteralPath (Join-Path $Path "$venv\Scripts\unsloth.exe") -PathType Leaf) { return $true }
# Antivirus takes that .exe out of a venv that still runs; install.ps1:6412 repairs
# through it, so a pre-marker root has nothing else left.
foreach ($pkg in @("unsloth_cli", "unsloth")) {
if (Test-Path -LiteralPath (Join-Path $Path "$venv\Lib\site-packages\$pkg") -PathType Container) { return $true }
}
}
# An install that died between moving the old venv aside (install.ps1:4487, :4165) and
# writing the marker (install.ps1:4524) leaves only these, and only install.ps1 makes
# either name, always by renaming a venv.
foreach ($leftover in @("unsloth_studio.rollback.*", ".venv.invalid.*")) {
foreach ($dir in @(Get-ChildItem -LiteralPath $Path -Filter $leftover -Directory -Force -ErrorAction SilentlyContinue)) {
# Never a reparse point: install.ps1 refuses to prune a linked rollback either.
if (($dir.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { continue }
if (-not (_IsInstallerLeftoverName $dir.Name)) { continue }
if (_IsVenvDir $dir.FullName) { return $true }
}
}
return $false
}
# Hard deny list: never recursively delete a drive root, USERPROFILE, its parent or a system dir.
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
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='<path>' 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
$studio = Split-Path -LiteralPath $bin
$root = Split-Path -LiteralPath $studio
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 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 and STUDIO_HOME is ignored when both are set, or
# uninstalling install A would also delete install B from a stale STUDIO_HOME.
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, so an unrelated
# process listening on a stale Unsloth port is never killed.
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 <DataDir>\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 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)
# An EXPLICIT empty list means "no root qualifies", not "do not scope". @() is false in
# PowerShell, so `if ($KnownRoots)` swept the whole machine on a run with nothing to delete.
$scoped = $PSBoundParameters.ContainsKey('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 ($scoped) {
$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 Unsloth-managed subtrees underneath the reparse-point TARGET of each Unsloth home, for
# the stop scan only. A junction or symlinked home runs its native binaries out of the PHYSICAL
# path (the backend resolves the home before deriving <home>\stable-diffusion.cpp and launching
# sd-server there), while _CustomStudioRoots only normalizes the string: GetFullPath is lexical
# and leaves a reparse point untouched. The prefix scan below reads Win32_Process.ExecutablePath,
# the real image path, so without the target a 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, so anything
# under the target that is not ours is neither locking nor being removed and must not be
# force-stopped. Homes only, for the same reason: a component dir ($defaultNode, ...) can
# itself be a link onto a shared runtime, and resolving it would scope in every process there.
#
# Stop scan only, deliberately. The deletes still refuse to chase a link out of the expected
# location, which is what the deny list exists to prevent; ending our own process is not.
function _ManagedPathsUnderReparseTargets {
param([string[]]$Roots)
# Everything setup.ps1 / the prebuilt installers place inside an Unsloth 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) $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 holding a venv DLL (an open DLL handle blocks the dir delete), found by scanning
# each candidate's loaded modules as well as 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 }
$defaultDataRoot = _AppDataRoot $env:LOCALAPPDATA 'LocalApplicationData'
$defaultDataDir = if ($defaultDataRoot) { Join-Path $defaultDataRoot "Unsloth Studio" } else { $null }
# The SECOND LocalAppData spelling gets the private temp tree only, never the data directory.
# install.ps1 falls through from a set-but-unusable $env:LOCALAPPDATA to the known folder when
# it places "Unsloth Studio\temp", so that tree can outlive an uninstall; but the two spellings
# differ mainly when they name a DIFFERENT USER's profile, and the data-dir delete is
# recursive, sentinel-free and deny-list-free.
$knownLocalAppData = $null
try { $knownLocalAppData = [Environment]::GetFolderPath('LocalApplicationData') } catch { $knownLocalAppData = $null }
# The first non-blank spelling is the one _AppDataRoot resolved: the profile this run is for.
$primaryPrivateTemp = if ($defaultDataDir) { Join-Path $defaultDataDir "temp" } else { $null }
$privateTempDirs = @()
foreach ($root in @($env:LOCALAPPDATA, $knownLocalAppData)) {
if ([string]::IsNullOrWhiteSpace($root)) { continue }
$candidate = Join-Path $root "Unsloth Studio\temp"
if ($privateTempDirs -notcontains $candidate) { $privateTempDirs += $candidate }
}
# Default-mode ~/.unsloth holds a SHARED llama.cpp build + .cache that are siblings of studio,
# so deleting <studio> misses them. 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, 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 default-mode sibling of studio.
$defaultNode = if ($defaultUnslothHome) { Join-Path $defaultUnslothHome "node" } else { $null }
# llama.cpp atomic-install staging root (install_llama_prebuilt.py), a sibling of the install
# dir. Usually pruned after activate, but an interrupted build leaves a "<name>.staging-XXXX"
# tree behind and that blocks the empty-dir cleanup of ~/.unsloth below.
$defaultStaging = if ($defaultUnslothHome) { Join-Path $defaultUnslothHome ".staging" } else { $null }
# Managed whisper.cpp dictation engine (setup.ps1 installs it at $UnslothHome\whisper.cpp), a
# default-mode sibling of studio. No-op in env/custom mode 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
# The roots this run would actually delete. Everything that stops, deletes or edits on behalf
# of a root takes THIS list, never $knownRoots: all of it runs before the gates below, and a
# stale studio.conf can name a directory another application has taken over. _IsUnsafeRoot as
# well as _IsStudioRoot, because the removal loop refuses on either.
$ownedRoots = @()
if ($defaultStudioHome -and (_IsStudioRoot $defaultStudioHome -ManagedDefaultRoot) -and
-not (_IsUnsafeRoot $defaultStudioHome)) {
$ownedRoots += $defaultStudioHome
}
foreach ($r in $customRoots) {
if ((_IsStudioRoot $r) -and -not (_IsUnsafeRoot $r)) { $ownedRoots += $r }
}
# ── Stop running servers ──
_Step "Stopping any running Unsloth Studio servers..."
if ($defaultDataDir) {
_StopByPortFile -PortFile (Join-Path $defaultDataDir "studio.port") -KnownRoots $ownedRoots
}
# $ownedRoots, not $customRoots: _StopByPortFile deletes the port file on its way out.
foreach ($r in $ownedRoots) {
_StopByPortFile -PortFile (Join-Path $r "share\studio.port") -KnownRoots $ownedRoots
}
_StopStudioProcesses -KnownRoots $ownedRoots
# 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 Unsloth 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 "<bid>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, which the $knownRoots prefix match
# below already covers. Older builds put it BESIDE the root at <parent>\stable-diffusion.cpp,
# outside $knownRoots; we delete those marker-owned dirs below, so scan them too.
# Gated too: a stale root's PARENT can hold another install's marked sd.cpp.
$customSdCppToStop = @()
foreach ($r in $customRoots) {
if (-not (_IsStudioRoot $r)) { continue }
if (_IsUnsafeRoot $r) { continue }
$sdc = Join-Path (Split-Path -LiteralPath $r) "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 = @($ownedRoots) + @($defaultDataDir, $defaultLlamaCpp, $defaultCache, $defaultNode, $defaultWhisperCpp) + @($defaultSdCppToStop | Where-Object { $_ }) + @($customSdCppToStop)
# The reparse expansion turns one path into generic subdirectories of wherever it points
# (node, bin, unsloth_studio), so it is gated for the same reason.
_StopProcessesLockingRoots -Roots ($stopRoots + @(_ManagedPathsUnderReparseTargets $ownedRoots))
# ── 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 now installs UNDER the custom root, so the removal above already took
# it. Older builds put it BESIDE the root at <parent>\stable-diffusion.cpp, which removing
# the root alone would leave behind. <parent> is user-chosen and "stable-diffusion.cpp" is
# exactly what a git clone of the upstream project produces, so require our owner marker
# (install_sd_cpp_prebuilt) before rm and keep any unowned checkout. The derived parent
# path gets the deny-list check too.
$customSdCpp = Join-Path (Split-Path -LiteralPath $r) "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 Unsloth owner marker: $customSdCpp" "Yellow"
} else {
_RemovePath $customSdCpp
}
}
# Default install dir (always at %USERPROFILE%\.unsloth\studio when present). Same sentinels
# as a custom root: an ungated run takes a hand-made one, then ~/.unsloth with the prune below.
if ($defaultStudioHome -and (Test-Path -LiteralPath $defaultStudioHome) -and
-not (_IsStudioRoot $defaultStudioHome -ManagedDefaultRoot)) {
_Substep "refusing to remove non-Unsloth path: $defaultStudioHome" "Yellow"
# A refused CUSTOM root is somebody else's by definition. This is our own default path,
# where a damaged install can sit, so a studio.db here is chat history.
if (Test-Path -LiteralPath (Join-Path $defaultStudioHome "studio.db") -PathType Leaf) {
$script:StudioDbKept = $true
}
} elseif ($defaultStudioHome) {
_RemoveRootRecordingDb $defaultStudioHome
}
# Default data dir. The private temp sweep goes FIRST and hands back what it kept: the primary
# temp directory lives under this data dir, so a wholesale removal here would erase a live
# Unsloth's %TEMP% before the sweep ever looked at its owner.pid.
$preservedTemp = @(_RemoveStudioPrivateTempTrees -Paths $privateTempDirs -PrimaryPath $primaryPrivateTemp)
if ($defaultDataDir) { _RemoveDataDirKeepingWslIcon $defaultDataDir -Preserve $preservedTemp }
# Shared llama.cpp build + cache, siblings of studio under ~/.unsloth in default mode.
if ($defaultLlamaCpp) { _RemovePath $defaultLlamaCpp }
# "stable-diffusion.cpp" is exactly what a git clone of leejet/stable-diffusion.cpp produces
# and a user may keep their own checkout (or point UNSLOTH_SD_CPP_PATH) here, so require our
# owner marker before rm: an unowned checkout or a pre-marker Unsloth 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 Unsloth owner marker: $defaultSdCpp" "Yellow"
} elseif ($defaultSdCpp) {
_RemovePath $defaultSdCpp
}
if ($defaultCache) { _RemovePath $defaultCache }
# Isolated Node.js runtime, a sibling of studio under ~/.unsloth. Nested in env/custom mode.
if ($defaultNode) { _RemovePath $defaultNode }
if ($defaultStaging) { _RemovePath $defaultStaging }
# Managed whisper.cpp prebuilt, a sibling of studio under ~/.unsloth. Only present when one
# 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 <parent>\.<name>.install.lock
# (prebuilt_core.py), and a stray lock keeps ~/.unsloth from being pruned below.
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.<pid> before unlinking; a crash
# between the two strands the rename, so sweep any leftovers. -Force sees dotted names.
if (Test-Path -LiteralPath $defaultUnslothHome) {
# -like, not -Filter: the Win32 filter is unreliable for 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 instead
# of lingering stale (mirrors install.ps1). 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.)
$preservedTemp = @(_RemoveStudioPrivateTempTrees -Paths $privateTempDirs -PrimaryPath $primaryPrivateTemp)
if ($defaultDataDir -and (Test-Path -LiteralPath $defaultDataDir)) {
_RemoveDataDirKeepingWslIcon $defaultDataDir -Preserve $preservedTemp
}
# ── 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 inside an Unsloth root we actually own. A literal
# substring match on `unsloth_studio` would clobber unrelated user virtualenvs.
# A root this run refused keeps its PATH entry, because it keeps its files.
foreach ($e in $entries) {
if ([string]::IsNullOrWhiteSpace($e)) { continue }
$expanded = [Environment]::ExpandEnvironmentVariables($e).TrimEnd('\','/')
$isStudio = $false
foreach ($r in $ownedRoots) {
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."
if ($script:StudioDbKept) {
# Named, and with no advice to delete it: the gate kept it precisely because it
# does not look like ours.
Write-Host " $defaultStudioHome carries no Unsloth install marker, so it was"
Write-Host " left alone. The studio.db inside it is still there; look at that"
Write-Host " directory yourself before deciding what to do with it."
} else {
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 Unsloth as the desktop app, clear site data for the"
Write-Host " http://localhost:<port> 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