#!/usr/bin/env sh # 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 (macOS / Linux / WSL). # Stops running servers and removes install dir, launcher data, # CLI shim, desktop shortcut, .app bundle, and Launch Services entry. # Honors custom roots set via UNSLOTH_STUDIO_HOME / STUDIO_HOME at # install time (read back from studio.conf). # # Usage: curl -fsSL https://raw.githubusercontent.com/unslothai/unsloth/main/scripts/uninstall.sh | sh set -e # Stop a Studio server via its PID file (written by install.sh's _spawn_terminal). _kill_pid_file() { _pid_file="$1" [ -f "$_pid_file" ] || return 0 _pid=$(sed -n '1s/[^0-9].*//p' "$_pid_file" 2>/dev/null || true) if [ -n "$_pid" ] && kill -0 "$_pid" 2>/dev/null; then kill -TERM "$_pid" 2>/dev/null || true # Wait up to 10s for graceful shutdown. _i=0 while kill -0 "$_pid" 2>/dev/null && [ "$_i" -lt 20 ]; do sleep 0.5 _i=$((_i + 1)) done kill -0 "$_pid" 2>/dev/null && kill -KILL "$_pid" 2>/dev/null || true fi rm -f "$_pid_file" 2>/dev/null || true } # BRE-escape a path so it can be embedded in a pkill -f regex. _pkill_escape() { printf '%s' "$1" | sed -e 's:[][\\.^$*+?{|}()/]:\\&:g' } _pkill_studio() { # Prefer PID files written by _spawn_terminal so we only touch our own installs. for _data_dir in "$HOME/.local/share/unsloth" $(_custom_studio_data_dirs); do [ -d "$_data_dir" ] || continue for _pf in "$_data_dir"/studio-*.pid; do [ -f "$_pf" ] && _kill_pid_file "$_pf" done done command -v pkill >/dev/null 2>&1 || return 0 # Scope fallback patterns to the install roots we are removing so a # different Studio install (different UNSLOTH_STUDIO_HOME) is not touched. _kill_roots="$HOME/.unsloth/studio" _roots_from_conf=$(_custom_studio_roots 2>/dev/null || true) [ -n "$_roots_from_conf" ] && _kill_roots="$_kill_roots $_roots_from_conf" printf '%s\n' "$_kill_roots" | while IFS= read -r _root; do [ -n "$_root" ] || continue [ -d "$_root" ] || continue _re=$(_pkill_escape "$_root") # `unsloth studio` (default port) + `-p N` + `--port N` forms, all # anchored on the install root's venv path. for _pat in \ "${_re}/unsloth_studio/bin/[^ ]* studio( |\$|.*-p[ =][0-9])" \ "${_re}/unsloth_studio/bin/[^ ]* studio.*--port[ =][0-9]" \ "${_re}/.*studio/backend/run\.py" do pkill -TERM -f "$_pat" 2>/dev/null || true done done sleep 0.5 printf '%s\n' "$_kill_roots" | while IFS= read -r _root; do [ -n "$_root" ] || continue [ -d "$_root" ] || continue _re=$(_pkill_escape "$_root") for _pat in \ "${_re}/unsloth_studio/bin/[^ ]* studio( |\$|.*-p[ =][0-9])" \ "${_re}/unsloth_studio/bin/[^ ]* studio.*--port[ =][0-9]" \ "${_re}/.*studio/backend/run\.py" do pkill -KILL -f "$_pat" 2>/dev/null || true done done } _remove_path() { _p="$1" if [ -e "$_p" ] || [ -L "$_p" ]; then rm -rf "$_p" 2>/dev/null && echo " removed: $_p" || echo " could not remove: $_p" >&2 fi } # Accept as Studio root only if Studio sentinels exist (matches install.sh's # env-mode ownership guard at install.sh:1358-1361). A bare unsloth_studio/ # directory is NOT enough -- require the install-time owner marker so a user # directory that happens to contain a folder named "unsloth_studio" is safe. _is_studio_root() { _r="$1" [ -n "$_r" ] || return 1 [ -f "$_r/share/studio.conf" ] && return 0 [ -f "$_r/unsloth_studio/.unsloth-studio-owned" ] && return 0 if [ -L "$_r/bin/unsloth" ]; then _t=$(readlink "$_r/bin/unsloth" 2>/dev/null || true) case "$_t" in *unsloth_studio/bin/unsloth) return 0 ;; esac fi return 1 } # Hard deny list: never delete /, $HOME, $HOME's parent, or system paths. _is_unsafe_root() { _r="$1" [ -z "$_r" ] && return 0 case "$_r" in /|""|"$HOME"|"$HOME/") return 0 ;; esac case "$_r" in /bin|/sbin|/etc|/usr|/usr/*|/var|/var/*|/opt|/opt/*|/Library|/Library/*|/System|/System/*|/Applications|/Applications/*) return 0 ;; esac _parent=$(dirname "$HOME" 2>/dev/null || echo "") [ -n "$_parent" ] && [ "$_r" = "$_parent" ] && return 0 return 1 } # Print share/ dirs of known custom roots (where PID files live). _custom_studio_data_dirs() { _custom_studio_roots 2>/dev/null | while IFS= read -r _r; do [ -d "$_r/share" ] && printf '%s\n' "$_r/share" done } # Resolve a custom install root from any of: # 1. UNSLOTH_STUDIO_HOME / STUDIO_HOME env vars at uninstall time # 2. Default-mode studio.conf at $HOME/.local/share/unsloth/studio.conf # 3. Env-mode studio.conf at $/share/studio.conf (discovered via 1) # install.sh writes UNSLOTH_EXE='/unsloth_studio/bin/unsloth', so # the install root is three dirnames up. Prints each discovered non-default # root on its own line; the caller iterates and de-duplicates. _custom_studio_roots() { _seen="" _emit() { _r="$1" [ -z "$_r" ] && return 0 # Tilde expansion (env vars are not subject to it on quoted assignment), # matches install.sh's _resolve_studio_destinations. The literal "~/" # pattern is intentional; SC2088 is a false positive here. # shellcheck disable=SC2088 case "$_r" in "~") _r="$HOME" ;; "~/"*) _r="$HOME/${_r#'~/'}" ;; esac # Canonicalize so syntactic variants ($HOME/../$USER, trailing slash) # resolve to the same path and hit the _is_unsafe_root deny list. # shellcheck disable=SC1007 _canon=$(CDPATH= cd -P -- "$_r" 2>/dev/null && pwd -P) [ -n "$_canon" ] && _r="$_canon" case "$_r" in "$HOME/.unsloth/studio"|/|"") return 0 ;; esac case ":$_seen:" in *":$_r:"*) return 0 ;; esac _seen="$_seen:$_r" printf '%s\n' "$_r" } _from_conf() { [ -f "$1" ] || return 0 # Tolerate paths containing apostrophes (install.sh emits '\'' for them). _exe=$(sed -n "s/^UNSLOTH_EXE='\(.*\)'\$/\1/p" "$1" | head -n1) _exe=$(printf '%s' "$_exe" | sed "s/'\\\\''/'/g") [ -n "$_exe" ] || return 0 _emit "$(dirname "$(dirname "$(dirname "$_exe")")")" } # Mirror install.sh's precedence: UNSLOTH_STUDIO_HOME wins, STUDIO_HOME is # ignored when both are set. Otherwise uninstalling install A could also # delete install B if the user has STUDIO_HOME left over from B. if [ -n "${UNSLOTH_STUDIO_HOME:-}" ]; then _emit "$UNSLOTH_STUDIO_HOME" _from_conf "$UNSLOTH_STUDIO_HOME/share/studio.conf" elif [ -n "${STUDIO_HOME:-}" ]; then _emit "$STUDIO_HOME" _from_conf "$STUDIO_HOME/share/studio.conf" fi # Default-mode conf. _from_conf "$HOME/.local/share/unsloth/studio.conf" } # Remove $HOME/.local/bin/unsloth only if it's a Studio-managed symlink. # Studio's install.sh writes this as a symlink into the studio venv # (install.sh: `ln -sfn "$VENV_DIR/bin/unsloth" "$_shim_path"`). A # pip-installed `unsloth` CLI is a regular file — leave it alone to avoid # wiping an unrelated install. _remove_cli_shim() { _shim="$HOME/.local/bin/unsloth" [ -L "$_shim" ] || return 0 _target=$(readlink "$_shim" 2>/dev/null || true) case "$_target" in */unsloth_studio/bin/unsloth) _remove_path "$_shim" ;; *) ;; esac } _uid=$(id -u 2>/dev/null || echo 0) _os=$(uname 2>/dev/null || echo unknown) _is_wsl=0 [ "$_os" = "Linux" ] && grep -qi microsoft /proc/version 2>/dev/null && _is_wsl=1 echo "Stopping any running Unsloth Studio servers..." _pkill_studio echo "Removing data and install directories..." _custom_studio_roots | while IFS= read -r _custom_root; do [ -n "$_custom_root" ] || continue if _is_unsafe_root "$_custom_root"; then echo " refusing to remove unsafe path: $_custom_root" >&2 continue fi if ! _is_studio_root "$_custom_root"; then echo " refusing to remove non-Studio path: $_custom_root" >&2 continue fi _remove_path "$_custom_root" done _remove_path "$HOME/.unsloth/studio" # Default-mode shared llama.cpp build + cache are siblings of studio (not removed # by deleting it). No-op in env/custom mode (they nest under the custom root) and # when absent. A user-set UNSLOTH_LLAMA_CPP_PATH is intentionally kept. _remove_path "$HOME/.unsloth/llama.cpp" _remove_path "$HOME/.unsloth/.cache" # 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. _remove_path "$HOME/.unsloth/node" # llama.cpp atomic-install staging root (install_llama_prebuilt.py .staging). # Normally pruned after activate, but an interrupted build can leave it behind; # removing it lets the rmdir below succeed. No-op in env/custom mode and absent. _remove_path "$HOME/.unsloth/.staging" # llama.cpp install lock (serializes the shared build); a stray one keeps ~/.unsloth # from being pruned below. No-op in env/custom mode and when absent. _remove_path "$HOME/.unsloth/.llama.cpp.install.lock" # ROCm-on-WSL helper artifacts (librocdxg build clone + smoke-test venv). No-op # where they don't exist; removing them lets the rmdir below succeed. _remove_path "$HOME/.unsloth/librocdxg" _remove_path "$HOME/.unsloth/rocm-smoketest" # Drop ~/.unsloth only if now empty (rmdir refuses non-empty, so user content is kept). rmdir "$HOME/.unsloth" 2>/dev/null || true _remove_path "$HOME/.local/share/unsloth" # CLI shim: only the symlink Studio created, never a pip-installed file. _remove_cli_shim echo "Removing desktop shortcut and launcher lock..." # install.sh creates Desktop/Unsloth Studio as a symlink. If the user has an # unrelated regular directory by that name, leave it alone. _desktop_link="$HOME/Desktop/Unsloth Studio" if [ -L "$_desktop_link" ] || [ ! -e "$_desktop_link" ]; then _remove_path "$_desktop_link" else echo " refusing to remove non-symlink Desktop path: $_desktop_link" >&2 fi _remove_path "$HOME/Desktop/unsloth-studio.desktop" # Locks are namespaced per-uid; env-mode adds an extra suffix. _lock_glob="${XDG_RUNTIME_DIR:-/tmp}/unsloth-studio-launcher-${_uid}" for _lock in "$_lock_glob".lock "$_lock_glob"-*.lock; do [ -e "$_lock" ] && _remove_path "$_lock" done case "$_os" in Darwin) echo "Removing macOS .app bundle and Launch Services entry..." _remove_path "$HOME/Applications/Unsloth Studio.app" _lsr="/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister" if [ -x "$_lsr" ]; then "$_lsr" -u "$HOME/Applications/Unsloth Studio.app" 2>/dev/null || true fi ;; Linux) if [ "$_is_wsl" = "1" ]; then echo "Removing WSL Windows-side shortcuts..." # install.sh creates per-distro 'Unsloth Studio (WSL - ).lnk' # on the Windows Desktop + Start Menu via powershell.exe. Scope removal # to THIS distro (passed as $args[0]) so a multi-distro install keeps the # other distros' launchers; the TARGET=wsl.exe check still spares a # native install's "Unsloth Studio.lnk". Prefer powershell.exe; test it # can EXECUTE (`command -v` succeeds even with interop OFF -- .exe then # fails "Exec format error", common on systemd-enabled distros). _wsl_distro="${WSL_DISTRO_NAME:-}" _ps_ran=0 if command -v powershell.exe >/dev/null 2>&1 && \ powershell.exe -NoProfile -Command "exit 0" >/dev/null 2>&1; then _ps_ran=1 # Inject the distro into the command: a -Command string does not # receive trailing tokens as $args. WSL distro names are safe to # embed (no quotes/$/backtick). # shellcheck disable=SC2016 powershell.exe -NoProfile -Command '$distro = "'"$_wsl_distro"'"; $dirs = @( [Environment]::GetFolderPath("Desktop"), (Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs") ); $ws = New-Object -ComObject WScript.Shell; foreach ($d in $dirs) { if (-not $d -or -not (Test-Path -LiteralPath $d)) { continue } Get-ChildItem -LiteralPath $d -Filter "Unsloth Studio*.lnk" -ErrorAction SilentlyContinue | ForEach-Object { try { $sc = $ws.CreateShortcut($_.FullName); if ("$($sc.TargetPath) $($sc.Arguments)" -notmatch "wsl\.exe") { return } # When the distro is known, require the per-distro # name for this distro or its -d "" argument # so launchers for other distros are not removed. if ($distro) { $nameMatch = ($_.Name -eq "Unsloth Studio (WSL - $distro).lnk"); $argMatch = ($sc.Arguments -match ("-d\s+`"?" + [regex]::Escape($distro) + "`"?")); if (-not ($nameMatch -or $argMatch)) { return } } Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue } catch { } } } # Keep the shared icon while any Unsloth shortcut still uses it (native # install or another WSL distro); drop it only with the last one. $iconInUse = $false; foreach ($d in $dirs) { if (-not $d -or -not (Test-Path -LiteralPath $d)) { continue } if (Get-ChildItem -LiteralPath $d -Filter "Unsloth Studio*.lnk" -ErrorAction SilentlyContinue) { $iconInUse = $true; break } } # Guard LOCALAPPDATA: empty on a service/SYSTEM account makes # Join-Path throw, aborting the icon cleanup (mirror uninstall.ps1). if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { $iconDir = Join-Path $env:LOCALAPPDATA "Unsloth Studio"; $ico = Join-Path $iconDir "unsloth.ico"; if ((-not $iconInUse) -and (Test-Path -LiteralPath $ico)) { Remove-Item -LiteralPath $ico -Force -ErrorAction SilentlyContinue } if ((Test-Path -LiteralPath $iconDir) -and -not (Get-ChildItem -LiteralPath $iconDir -Force -ErrorAction SilentlyContinue)) { Remove-Item -LiteralPath $iconDir -Recurse -Force -ErrorAction SilentlyContinue } }' >/dev/null 2>&1 || true fi # Remove $1's shared unsloth.ico only if no Unsloth shortcut (native install # or another WSL distro) still uses it, then drop the dir if empty. Reciprocal # of uninstall.ps1's _RemoveDataDirKeepingWslIcon (keeps the icon for a # surviving WSL shortcut when the native side is removed). _drop_shared_icon_if_unused() { _du="$1" _icodir="$_du/AppData/Local/Unsloth Studio" _icon_in_use=0 for _sd in \ "$_du/Desktop" \ "$_du/OneDrive/Desktop" \ "$_du"/OneDrive*/Desktop \ "$_du/AppData/Roaming/Microsoft/Windows/Start Menu/Programs"; do [ -d "$_sd" ] || continue for _any in "$_sd"/"Unsloth Studio"*.lnk; do [ -e "$_any" ] && { _icon_in_use=1; break; } done [ "$_icon_in_use" = "1" ] && break done if [ "$_icon_in_use" = "0" ]; then [ -f "$_icodir/unsloth.ico" ] && rm -f "$_icodir/unsloth.ico" 2>/dev/null || true fi [ -d "$_icodir" ] && rmdir "$_icodir" 2>/dev/null || true } # Fallback when powershell.exe can't run (interop disabled): remove WSL .lnk # files via drvfs. The "Unsloth Studio (WSL..." name is WSL-specific, so a # native install's "Unsloth Studio.lnk" never matches. if [ "$_ps_ran" = "0" ]; then for _drive in /mnt/c /mnt/d /mnt/e; do [ -d "$_drive/Users" ] || continue for _udir in "$_drive"/Users/*; do [ -d "$_udir" ] || continue for _scdir in \ "$_udir/Desktop" \ "$_udir/OneDrive/Desktop" \ "$_udir"/OneDrive*/Desktop \ "$_udir/AppData/Roaming/Microsoft/Windows/Start Menu/Programs"; do [ -d "$_scdir" ] || continue if [ -n "$_wsl_distro" ]; then # Exact per-distro name (no glob) so other distros survive. _lnk="$_scdir/Unsloth Studio (WSL - ${_wsl_distro}).lnk" [ -e "$_lnk" ] && rm -f "$_lnk" 2>/dev/null && echo " removed: $_lnk" || true else # Distro unknown: fall back to the broad WSL prefix. for _lnk in "$_scdir"/"Unsloth Studio (WSL"*.lnk; do [ -e "$_lnk" ] && rm -f "$_lnk" 2>/dev/null && echo " removed: $_lnk" || true done fi done # Drop the shared icon only when no shortcut still needs it. _drop_shared_icon_if_unused "$_udir" done done fi # ── ROCm-on-WSL config (install_rocm_wsl_strixhalo.sh) ── # Remove Unsloth's own ROCDXG config (the env it persisted). The system # ROCm userspace is a shared prereq (like CUDA) and is LEFT IN PLACE by # default; set UNSLOTH_UNINSTALL_ROCM=1 to remove it too. echo "Removing ROCm-on-WSL config..." _sudo="" if [ "$_uid" != "0" ] && command -v sudo >/dev/null 2>&1; then _sudo="sudo"; fi $_sudo rm -f /etc/profile.d/unsloth-rocm-wsl.sh 2>/dev/null || true if [ -f "$HOME/.bashrc" ] && grep -q "Unsloth ROCm-on-WSL" "$HOME/.bashrc" 2>/dev/null; then _bk=$(mktemp 2>/dev/null || echo "$HOME/.bashrc.unsloth.tmp") if sed '/# >>> Unsloth ROCm-on-WSL/,/# <<< Unsloth ROCm-on-WSL/d' "$HOME/.bashrc" > "$_bk" 2>/dev/null; then cat "$_bk" > "$HOME/.bashrc" 2>/dev/null || true echo " cleaned ROCm-on-WSL block from ~/.bashrc" fi rm -f "$_bk" 2>/dev/null || true fi if [ "${UNSLOTH_UNINSTALL_ROCM:-0}" = "1" ]; then echo " removing system ROCm (UNSLOTH_UNINSTALL_ROCM=1)..." $_sudo rm -f /etc/apt/sources.list.d/rocm.list /etc/apt/preferences.d/rocm-pin-600 \ /etc/apt/keyrings/rocm.gpg /etc/ld.so.conf.d/rocm.conf 2>/dev/null || true $_sudo sh -c 'rm -rf /opt/rocm /opt/rocm-*' 2>/dev/null || true if command -v ldconfig >/dev/null 2>&1; then $_sudo ldconfig 2>/dev/null || true; fi elif [ -d /opt/rocm ]; then echo " Note: ROCm userspace (/opt/rocm*) left in place (shared prereq)." echo " Remove it by re-running with UNSLOTH_UNINSTALL_ROCM=1, or manually:" echo " sudo rm -rf /opt/rocm /opt/rocm-* && sudo ldconfig" fi fi echo "Removing Linux .desktop entry..." _remove_path "$HOME/.local/share/applications/unsloth-studio.desktop" if command -v update-desktop-database >/dev/null 2>&1; then update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true fi ;; esac echo "" echo "Unsloth Studio uninstalled." echo "Note: Hugging Face model cache at ~/.cache/huggingface was left in place." echo "Remove it manually with 'rm -rf ~/.cache/huggingface/hub' if desired." # Env-mode installs leave no breadcrumb in $HOME, so a custom root can # only be located if the user re-exports the variable. Print a hint when # neither var is set so the bare `curl | sh` flow doesn't silently miss. if [ -z "${UNSLOTH_STUDIO_HOME:-}" ] && [ -z "${STUDIO_HOME:-}" ]; then echo "" echo "If you installed Unsloth Studio with UNSLOTH_STUDIO_HOME or STUDIO_HOME" echo "pointing at a custom directory, re-run this script with the same variable" echo "set to also remove that install tree, e.g.:" echo " UNSLOTH_STUDIO_HOME=/your/path sh -c \"\$(curl -fsSL https://raw.githubusercontent.com/unslothai/unsloth/main/scripts/uninstall.sh)\"" fi