scripts/uninstall.sh
Rama main · réplica de lectura del repositorio original.
#!/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). Run --help for details.
# Usage: curl -fsSL https://raw.githubusercontent.com/unslothai/unsloth/main/scripts/uninstall.sh | sh
set -e
_usage() {
cat <<'EOF'
Unsloth Studio uninstaller (macOS / Linux / WSL).
Usage:
curl -fsSL https://raw.githubusercontent.com/unslothai/unsloth/main/scripts/uninstall.sh | sh
sh scripts/uninstall.sh
To read this help from the piped form, sh needs -s so the arguments reach the
script instead of the shell. Spelled out, with the same URL as above:
curl -fsSL https://raw.githubusercontent.com/unslothai/unsloth/main/scripts/uninstall.sh | sh -s -- --help
Never pipe to `sh -h` expecting help. Neither form prints this message: where
-h is accepted (bash, zsh, macOS /bin/sh) it is the shell's own hashall option,
so the shell consumes it and the script uninstalls with no arguments; where it
is not (dash, busybox sh) the shell exits with "Illegal option -h".
Stops running Unsloth Studio servers, then removes the install dir, launcher
data dir, CLI shim, desktop shortcut, macOS .app bundle and Launch Services
entry. In a default-mode install it also removes the shared prebuilts that sit
beside the install dir: ~/.unsloth/{llama.cpp,node,whisper.cpp,audio.cpp,.cache}.
The Hugging Face cache at ~/.cache/huggingface is left in place (only audio.cpp's
unsloth-audiocpp-links beside it goes), as is anything else you keep under
~/.unsloth. A shared uv package cache (`uv cache dir`) is also left when install
reused one.
On WSL it also removes this distro's Windows-side shortcuts under /mnt/*/Users,
strips the Unsloth block from ~/.bashrc, and uses sudo to delete
/etc/profile.d/unsloth-rocm-wsl.sh.
Options:
-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. Pass the value
used at install time.
STUDIO_HOME Alias for the above, ignored when both are set.
UNSLOTH_UNINSTALL_ROCM=1 Also remove system ROCm (WSL only). Off by default
because ROCm is a shared prerequisite.
EOF
}
_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
_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'
}
# The nested <root>/stable-diffusion.cpp is not marker-gated since this run deletes that root;
# the default and legacy sibling paths survive when unowned, so they are.
_owned_sd_cpp_roots() {
_default_sd="$HOME/.unsloth/stable-diffusion.cpp"
[ -f "$_default_sd/.unsloth-studio-owned" ] && printf '%s\n' "$_default_sd"
_custom_studio_roots 2>/dev/null | while IFS= read -r _root; do
[ -n "$_root" ] || continue
_sd_root="$_root/stable-diffusion.cpp"
if [ -f "$_sd_root/.unsloth-studio-owned" ] || _is_studio_root "$_root"; then
[ -d "$_sd_root" ] && printf '%s\n' "$_sd_root"
fi
done
_sd_cpp_sibling_bases 2>/dev/null | while IFS= read -r _root; do
[ -n "$_root" ] || continue
_sd_root="$(dirname "$_root")/stable-diffusion.cpp"
[ -f "$_sd_root/.unsloth-studio-owned" ] && printf '%s\n' "$_sd_root"
done
}
# Lexical roots differ from canonical ones only when the Unsloth home is a symlink;
# old builds used plain dirname.
_sd_cpp_sibling_bases() {
{
_custom_studio_roots 2>/dev/null
_custom_studio_roots lexical 2>/dev/null
} | awk '!seen[$0]++'
}
# A live sd-server keeps running after its binary is unlinked, so stop it first.
_stop_owned_sd_cpp_processes() {
_signal="$1"
command -v pkill >/dev/null 2>&1 || return 0
_owned_sd_cpp_roots | while IFS= read -r _root; do
[ -n "$_root" ] || continue
[ -d "$_root" ] || continue
_re=$(_pkill_escape "$_root")
pkill "-$_signal" -f "^${_re}/([^ ]*/)?sd-(server|cli)( |\$)" 2>/dev/null || true
done
}
# Owner of $HOME, not the caller: macOS sudo keeps HOME, so euid can be 0 here.
_home_uid() {
# -L: stat lstats by default, so a root-owned link to a user home would read as uid 0.
_hu=$(stat -L -c %u "$HOME" 2>/dev/null || stat -L -f %u "$HOME" 2>/dev/null || true)
case "$_hu" in ''|*[!0-9]*) _hu=$(id -u 2>/dev/null || true) ;; esac
case "$_hu" in *[!0-9]*) _hu= ;; esac
printf '%s\n' "$_hu"
}
# Run as the $HOME owner when root, so per-user daemons see the right domain.
_run_as_home_owner() {
_ro=$(_home_uid)
if [ "$(id -u 2>/dev/null || echo 0)" = "0" ] && [ -n "$_ro" ] && [ "$_ro" != "0" ] &&
command -v launchctl >/dev/null 2>&1 && command -v sudo >/dev/null 2>&1; then
launchctl asuser "$_ro" sudo -u "#$_ro" "$@"
else
"$@"
fi
}
# Only reached without pkill. Scoped to the $HOME owner, matching pkill -u below.
_studio_app_running() {
[ -d /proc ] || return 1
_sar_uid=$(_home_uid)
for _sar_p in /proc/[0-9]*; do
[ -r "$_sar_p/comm" ] || continue
_sar_comm=$(cat "$_sar_p/comm" 2>/dev/null || true)
[ "$_sar_comm" = "unsloth-studio" ] || continue
if [ -n "$_sar_uid" ]; then
_sar_owner=$(stat -c %u "$_sar_p" 2>/dev/null || stat -f %u "$_sar_p" 2>/dev/null || true)
[ "$_sar_owner" = "$_sar_uid" ] || continue
fi
return 0
done
return 1
}
_pkill_studio() {
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
if ! command -v pkill >/dev/null 2>&1; then
# No procps: a live app re-creates the profile after the delete, so do not claim a clean removal.
if _studio_app_running; then
echo " pkill not found and Unsloth Studio is running; close it and re-run" >&2
_set_marker "$_REMOVE_FAILED_FLAG"
fi
return 0
fi
# Scope patterns to the roots being removed so a different install 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")
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
_stop_owned_sd_cpp_processes TERM
sleep 0.5
_stop_owned_sd_cpp_processes KILL
# WebView helpers re-create caches, so kill the app. -x spares the CLI shim; -u is the $HOME owner
# (macOS sudo keeps HOME). Numeric uid and signal first for BSD pkill.
_studio_uid=$(_home_uid)
if [ -n "$_studio_uid" ]; then
pkill -TERM -x -u "$_studio_uid" unsloth-studio 2>/dev/null || true
sleep 0.5
pkill -KILL -x -u "$_studio_uid" unsloth-studio 2>/dev/null || true
fi
}
# Markers in files, not variables: custom roots are removed in a pipeline subshell.
# db-kept is separate from remove-failed: the refused default root failed nothing.
# mktemp -d: private (0700) and unpredictable, so nothing else can pre-create the markers.
_MARKER_DIR=$(mktemp -d 2>/dev/null || true)
_REMOVE_FAILED_FLAG=""
_DB_REMOVED_FLAG=""
_DB_KEPT_FLAG=""
_UV_ROOTS_FILE=""
_UV_LEFTOVER_FILE=""
_UV_SAW_MARKER_FLAG=""
if [ -n "$_MARKER_DIR" ] && [ -d "$_MARKER_DIR" ]; then
_REMOVE_FAILED_FLAG="$_MARKER_DIR/remove-failed"
_DB_REMOVED_FLAG="$_MARKER_DIR/db-removed"
_DB_KEPT_FLAG="$_MARKER_DIR/db-kept"
_UV_ROOTS_FILE="$_MARKER_DIR/uv-roots"
_UV_LEFTOVER_FILE="$_MARKER_DIR/uv-leftovers"
_UV_SAW_MARKER_FLAG="$_MARKER_DIR/uv-saw-marker"
fi
# printf, not `: >`: a redirect error on a special builtin kills dash/ash despite || true.
_set_marker() {
[ -n "$1" ] || return 0
printf '' > "$1" 2>/dev/null || true
return 0
}
_marker_set() { [ -n "$1" ] && [ -f "$1" ]; }
# Re-checked each time: the dir can vanish mid-run and _set_marker would silently drop failures.
_markers_unavailable() {
[ -n "$_MARKER_DIR" ] || return 0
[ -d "$_MARKER_DIR" ] || return 0
[ -w "$_MARKER_DIR" ] || return 0
return 1
}
# Read before any root is deleted; a uv cache outside removed roots is shared and stays.
_uv_cache_under_any_root() {
while IFS= read -r _uv_r; do
[ -L "$_uv_r" ] && continue
case "$1" in "$_uv_r"|"$_uv_r"/*) return 0 ;; esac
done < "$_UV_ROOTS_FILE"
return 1
}
_uv_collect_from_install_roots() {
[ -n "$_UV_ROOTS_FILE" ] || return 0
{
printf '%s\n' "$HOME/.unsloth/studio"
_custom_studio_roots | while IFS= read -r _uv_root; do
[ -n "$_uv_root" ] || continue
if ! _is_unsafe_root "$_uv_root" && _is_studio_root "$_uv_root"; then
printf '%s\n' "$_uv_root"
fi
done
} > "$_UV_ROOTS_FILE" 2>/dev/null || return 0
while IFS= read -r _uv_root; do
[ -f "$_uv_root/cache/uv-cache-dir" ] || continue
_set_marker "$_UV_SAW_MARKER_FLAG"
_uv_rec=$(sed -n '1p' "$_uv_root/cache/uv-cache-dir" 2>/dev/null | tr -d '\r') || _uv_rec=""
[ -n "$_uv_rec" ] || continue
_uv_cache_under_any_root "$_uv_rec" || printf '%s\n' "$_uv_rec" >> "$_UV_LEFTOVER_FILE" 2>/dev/null || true
done < "$_UV_ROOTS_FILE"
}
_uv_print_leftover_notes() {
if [ -s "$_UV_LEFTOVER_FILE" ]; then
awk '!seen[$0]++' "$_UV_LEFTOVER_FILE" 2>/dev/null | while IFS= read -r _uv_path; do
[ -n "$_uv_path" ] || continue
# Explicit --cache-dir: bare `uv cache clean` cleans whatever cache uv resolves now.
_uv_q=$(printf '%s' "$_uv_path" | sed "s/'/'\\\\''/g")
echo "Note: the uv package cache at $_uv_path was left in place (it may be shared with other tools)."
echo " Free it with: uv cache clean --cache-dir '$_uv_q'"
done
elif ! _marker_set "$_UV_SAW_MARKER_FLAG"; then
echo "Note: if install reused a shared uv cache (\`uv cache dir\`), it was left in place."
echo " Free it with 'uv cache clean'."
fi
}
# EXIT trap: --help, a bad argument and set -e all skip an end-of-main cleanup.
_cleanup_markers() {
if [ -n "$_MARKER_DIR" ]; then
rm -rf "$_MARKER_DIR" 2>/dev/null || true
fi
}
trap _cleanup_markers EXIT
# A partial removal can drop the sentinels; restore the marker so a retry's gate accepts the root.
_restore_owner_marker() {
[ -d "$1" ] || return 0
if [ -e "$1/.unsloth-studio-owned" ] || [ -L "$1/.unsloth-studio-owned" ]; then return 0; fi
printf '' > "$1/.unsloth-studio-owned" 2>/dev/null || true
return 0
}
# The db check uses the resolved path: rm -rf only unlinks a symlinked root.
# Never follow the link to delete its target; that is what the deny lists prevent.
_remove_root_recording_db() {
_rrd_root="$1"
# shellcheck disable=SC1007
_rrd_real=$(CDPATH= cd -P -- "$_rrd_root" 2>/dev/null && pwd -P) || _rrd_real=""
[ -n "$_rrd_real" ] || _rrd_real="$_rrd_root"
_rrd_had_db=0
_rrd_db="$_rrd_real/studio.db"
if [ -f "$_rrd_db" ]; then
_rrd_had_db=1
# A symlinked db survives rm of the link. No readlink -f (BSD lacks it before macOS 12.3).
if [ -L "$_rrd_db" ]; then
_rrd_link=$(readlink "$_rrd_db" 2>/dev/null || true)
if [ -n "$_rrd_link" ]; then
case "$_rrd_link" in
/*) ;;
*) _rrd_link="$(dirname "$_rrd_db")/$_rrd_link" ;;
esac
# shellcheck disable=SC1007
_rrd_ldir=$(CDPATH= cd -P -- "$(dirname "$_rrd_link")" 2>/dev/null && pwd -P) \
|| _rrd_ldir=""
[ -n "$_rrd_ldir" ] && _rrd_db="$_rrd_ldir/$(basename "$_rrd_link")"
fi
fi
fi
_remove_path "$_rrd_root"
_restore_owner_marker "$_rrd_root"
if [ "$_rrd_had_db" = 1 ]; then
if [ -f "$_rrd_db" ]; then
_set_marker "$_REMOVE_FAILED_FLAG"
else
_set_marker "$_DB_REMOVED_FLAG"
fi
fi
return 0
}
_remove_path() {
_p="$1"
if [ -e "$_p" ] || [ -L "$_p" ]; then
if rm -rf "$_p" 2>/dev/null; then
echo " removed: $_p"
else
echo " could not remove: $_p" >&2
_set_marker "$_REMOVE_FAILED_FLAG"
fi
fi
}
# Locks are always regular files (O_CREAT|O_EXCL); a dir or link here is the user's.
# Must agree with uninstall.ps1's _RemoveLockFile.
_remove_lock_file() {
_rlf="$1"
if [ -L "$_rlf" ]; then
echo " keeping link at an install-lock path: $_rlf" >&2
elif [ -f "$_rlf" ]; then
_remove_path "$_rlf"
elif [ -e "$_rlf" ]; then
echo " keeping non-file at an install-lock path: $_rlf" >&2
fi
}
# A relative XDG override is invalid and ignored by Tauri, so honouring it would rm under cwd.
_xdg_dir() {
case "$1" in /*) printf '%s\n' "$1" ;; *) printf '%s\n' "$2" ;; esac
}
# Root accepted only with an install-time owner marker (matches install.sh's env-mode guard).
# Plain -f, following links: a relocated venv keeps a real marker behind a link.
_is_owner_marker() { [ -f "$1" ]; }
# No -L: a relocated venv is still a venv.
_is_venv_dir() {
[ -d "$1" ] || return 1
[ -f "$1/pyvenv.cfg" ] && return 0
[ -f "$1/bin/python" ] && return 0
return 1
}
# Exact installer name <prefix>.<stamp>.<pid>[.<n>]; must not be looser than install.sh.
_is_installer_leftover_name() {
_l=${1##*/}
# Only the rollback name carries a collision counter; .venv.invalid takes no suffix.
_l_suffix_ok=false
case "$_l" in
unsloth_studio.rollback.*) _l=${_l#unsloth_studio.rollback.}; _l_suffix_ok=true ;;
.venv.invalid.*) _l=${_l#.venv.invalid.} ;;
*) return 1 ;;
esac
_l_rest=${_l#*.}
[ "$_l_rest" != "$_l" ] || return 1
_l_stamp=${_l%%.*}
case "$_l_stamp" in
time) ;;
''|*[!0-9]*) return 1 ;;
*) [ "${#_l_stamp}" -eq 14 ] || return 1 ;;
esac
_l_pid=${_l_rest%%.*}
case "$_l_pid" in ''|*[!0-9]*) return 1 ;; esac
_l_suffix=${_l_rest#*.}
if [ "$_l_suffix" != "$_l_rest" ]; then
[ "$_l_suffix_ok" = true ] || return 1
case "$_l_suffix" in ''|*[!0-9]*) return 1 ;; esac
fi
return 0
}
_is_studio_root() {
_r="$1"
_managed="${2:-}"
[ -n "$_r" ] || return 1
# install.sh writes the marker before the venv, so partial installs identify themselves.
_is_owner_marker "$_r/.unsloth-studio-owned" && return 0
_is_owner_marker "$_r/share/studio.conf" && return 0
_is_owner_marker "$_r/unsloth_studio/.unsloth-studio-owned" && return 0
_is_owner_marker "$_r/.venv/.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
# bin/unsloth alone is weak proof (any venv with the wheel has it), so only trust it at the managed root.
[ "$_managed" = managed ] || return 1
for _v in unsloth_studio .venv; do
_is_venv_dir "$_r/$_v" || continue
[ -f "$_r/$_v/bin/unsloth" ] && return 0
done
# An install that died between moving the venv aside and writing the marker leaves only these.
for _p in "$_r"/unsloth_studio.rollback.* "$_r"/.venv.invalid.*; do
[ -L "$_p" ] && continue
_is_installer_leftover_name "$_p" || continue
_is_venv_dir "$_p" && return 0
done
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
}
_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
}
# The master root UNSLOTH_HOME names, or empty. Trimmed and tilde-expanded like
# storage_roots.unsloth_home().
_master_root() {
_mr_from_note=
_mr=$(printf '%s' "${UNSLOTH_HOME:-}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
# Fall back to the note setup.sh leaves in the Studio tree, only for the root this run targets,
# in studio_root() precedence, or two installs could cross-delete runtimes.
if [ -z "$_mr" ]; then
# Trimmed as setup.sh trims it, or a padded value never finds the note.
_mr_ush=$(printf '%s' "${UNSLOTH_STUDIO_HOME:-}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
_mr_sh=$(printf '%s' "${STUDIO_HOME:-}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
if [ -n "$_mr_ush" ]; then
_mr_roots="$_mr_ush"
elif [ -n "$_mr_sh" ]; then
_mr_roots="$_mr_sh"
else
# Not via _custom_studio_roots, which calls back into this function. Root is three dirnames
# up from UNSLOTH_EXE; apostrophes arrive escaped as '\''.
_mr_roots=""
_mr_dconf="$HOME/.local/share/unsloth/studio.conf"
if [ -f "$_mr_dconf" ]; then
_mr_exe=$(sed -n "s/^UNSLOTH_EXE='\(.*\)'\$/\1/p" "$_mr_dconf" | head -n1)
_mr_exe=$(printf '%s' "$_mr_exe" | sed "s/'\\\\''/'/g")
[ -n "$_mr_exe" ] && _mr_roots=$(dirname "$(dirname "$(dirname "$_mr_exe")")")
fi
_mr_roots="$_mr_roots
$HOME/.unsloth/studio"
fi
_mr_saved_ifs=$IFS
IFS='
'
for _mr_studio in $_mr_roots; do
IFS=$_mr_saved_ifs
[ -n "$_mr_studio" ] || continue
_mr_conf="${_mr_studio}/share/.unsloth-master-root"
[ -f "$_mr_conf" ] || continue
# A multi-line note is refused: storage_roots.py and the CLI reject it too, so it
# must not license a delete.
if [ -n "$(sed -n '2,$p' "$_mr_conf" 2>/dev/null | tr -d '[:space:]')" ]; then
continue
fi
_mr=$(head -n 1 "$_mr_conf" 2>/dev/null | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') \
|| _mr=""
if [ -n "$_mr" ]; then
_mr_from_note=1
break
fi
done
IFS=$_mr_saved_ifs
fi
[ -n "$_mr" ] || return 0
# shellcheck disable=SC2088
case "$_mr" in
"~") _mr="$HOME" ;;
"~/"*) _mr="$HOME/${_mr#'~/'}" ;;
esac
# shellcheck disable=SC1007
# || fallback: dash/POSIX sh apply set -e to a failed substitution in an assignment.
_mr_canon=$(CDPATH= cd -P -- "$_mr" 2>/dev/null && pwd -P) || _mr_canon=""
[ -n "$_mr_canon" ] && _mr="$_mr_canon"
# A note must describe the tree it was found in, or a copied tree would delete the original's runtimes.
# An explicit UNSLOTH_HOME skips this check.
if [ "${_mr_from_note:-}" = 1 ]; then
_mr_here=$(CDPATH= cd -P -- "$_mr_studio" 2>/dev/null && pwd -P) || _mr_here=""
# A note inside ~/.unsloth/studio is declined outright, matching
# storage_roots._is_legacy_studio_tree.
_mr_legacy_studio=$(CDPATH= cd -P -- "$HOME/.unsloth/studio" 2>/dev/null && pwd -P) \
|| _mr_legacy_studio="$HOME/.unsloth/studio"
[ "$_mr_here" != "$_mr_legacy_studio" ] || return 0
case "$_mr_here" in
"$_mr") : ;;
"$_mr"/*) : ;;
*) return 0 ;;
esac
fi
case "$_mr" in "$HOME/.unsloth"|/|"") return 0 ;; esac
printf '%s\n' "$_mr"
}
_custom_studio_roots() {
# $1 = "lexical" skips canonicalization; reset on every call.
_studio_roots_lexical="${1:-}"
_seen=""
_emit() {
_r="$1"
[ -z "$_r" ] && return 0
# Tilde expansion, matching install.sh's _resolve_studio_destinations.
# shellcheck disable=SC2088
case "$_r" in
"~") _r="$HOME" ;;
"~/"*) _r="$HOME/${_r#'~/'}" ;;
esac
# Canonicalize so variants hit the _is_unsafe_root deny list; the lexical pass
# rebuilds old dirname paths.
if [ "${_studio_roots_lexical:-}" != "lexical" ]; then
# shellcheck disable=SC1007
_canon=$(CDPATH= cd -P -- "$_r" 2>/dev/null && pwd -P)
[ -n "$_canon" ] && _r="$_canon"
fi
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
_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: UNSLOTH_STUDIO_HOME wins over STUDIO_HOME. Trimmed like storage_roots.studio_root().
_ush=$(printf '%s' "${UNSLOTH_STUDIO_HOME:-}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
_sh=$(printf '%s' "${STUDIO_HOME:-}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
if [ -n "$_ush" ]; then
_emit "$_ush"
_from_conf "$_ush/share/studio.conf"
elif [ -n "$_sh" ]; then
_emit "$_sh"
_from_conf "$_sh/share/studio.conf"
elif [ -n "$(_master_root)" ]; then
# Last, as in storage_roots.studio_root().
_emit "$(_master_root)/studio"
_from_conf "$(_master_root)/studio/share/studio.conf"
fi
# Default-mode conf.
_from_conf "$HOME/.local/share/unsloth/studio.conf"
}
# Remove the CLI shim only if it is install.sh's symlink; a pip-installed unsloth is a regular file.
_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
}
# PlistBuddy reads binary plists; awk fallback handles XML only.
_plist_string() {
[ -f "$1" ] || return 1
if [ -x /usr/libexec/PlistBuddy ]; then
/usr/libexec/PlistBuddy -c "Print :$2" "$1" 2>/dev/null && return 0
fi
awk -v k="<key>$2</key>" 'index($0, k) { f = 1; next }
f && /<string>/ { sub(/.*<string>/, ""); sub(/<\/string>.*/, ""); print; exit }' "$1"
}
# install.sh's shell launcher shares the bundle id, so exclude it by executable.
_owns_bundle_id() {
[ -d "$1" ] || return 1
[ "$(_plist_string "$1/Contents/Info.plist" CFBundleIdentifier)" = "$2" ] || return 1
[ "$(_plist_string "$1/Contents/Info.plist" CFBundleExecutable)" != "launch-studio" ]
}
# Match on bundle id: renamed and nested bundles are supported.
_bundle_id_owner() {
# Overridable so tests can point the scan at a fixture dir.
_bio_apps="${UNSLOTH_APPLICATIONS_DIR:-/Applications}"
# Skipped for fixture scans so a real install does not change test results.
if [ -z "${UNSLOTH_APPLICATIONS_DIR:-}" ] && command -v mdfind >/dev/null 2>&1; then
_bio_hit=$(mdfind "kMDItemCFBundleIdentifier == '$1'" 2>/dev/null |
while IFS= read -r _bio_app; do
_owns_bundle_id "$_bio_app" "$1" && { printf '%s\n' "$_bio_app"; break; }
done | head -n 1)
if [ -n "$_bio_hit" ]; then
printf '%s\n' "$_bio_hit"
return 0
fi
fi
# Spotlight can be off or indexing. -prune stops at each bundle, so no depth cap is needed.
find "$_bio_apps" "$HOME/Applications" -name '*.app' -type d -prune -print 2>/dev/null |
while IFS= read -r _bio_app; do
if _owns_bundle_id "$_bio_app" "$1"; then
printf '%s\n' "$_bio_app"
break
fi
done | head -n 1
}
_unsloth_uninstall_main() {
for _arg in "$@"; do
case "$_arg" in
-h|--help) _usage; return 0 ;;
*)
echo "uninstall.sh: unrecognized argument: $_arg" >&2
echo "Nothing was removed. Re-run with no arguments to uninstall, or --help." >&2
return 2
;;
esac
done
_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
# Before the kill sweep, or Restart=on-failure brings the server back mid-removal.
_remove_systemd_user_service() {
_sd_dir="$(_xdg_dir "${XDG_CONFIG_HOME:-}" "$HOME/.config")/systemd/user"
_sd_unit="$_sd_dir/unsloth-studio.service"
if [ ! -f "$_sd_unit" ] && command -v systemctl >/dev/null 2>&1; then
_sd_frag=$(systemctl --user show -p FragmentPath --value unsloth-studio.service 2>/dev/null || true)
case "$_sd_frag" in */unsloth-studio.service) _sd_unit="$_sd_frag"; _sd_dir="${_sd_frag%/*}" ;; esac
fi
if [ ! -f "$_sd_unit" ] && command -v getent >/dev/null 2>&1; then
_sd_pw=$(getent passwd "$(id -un 2>/dev/null)" 2>/dev/null | cut -d: -f6)
case "$_sd_pw" in /*) _sd_dir="$_sd_pw/.config/systemd/user"; _sd_unit="$_sd_dir/unsloth-studio.service" ;; esac
fi
[ -f "$_sd_unit" ] || return 0
[ "$(head -n 1 "$_sd_unit" 2>/dev/null)" = "# unsloth-studio-managed-systemd" ] || return 0
_sd_stopped=0
if command -v systemctl >/dev/null 2>&1 && systemctl --user show-environment >/dev/null 2>&1; then
systemctl --user disable --now unsloth-studio.service 2>/dev/null && _sd_stopped=1
fi
_remove_path "$_sd_dir/default.target.wants/unsloth-studio.service"
_remove_path "$_sd_unit"
[ "$_sd_stopped" = 1 ] && { systemctl --user daemon-reload 2>/dev/null || true; }
echo "Removed systemd user service (unsloth-studio.service)."
[ "$_sd_stopped" = 1 ] || echo " could not reach the systemd user manager to stop it; it will not start again after the next login or reboot" >&2
}
_remove_systemd_user_service
echo "Stopping any running Unsloth Studio servers..."
_pkill_studio
echo "Removing data and install directories..."
_uv_collect_from_install_roots
# Resolved once before deletion: the note it may read lives in a tree the loop removes.
_MASTER_ROOT_SAVED="$(_master_root)"
_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
# A real install can sit under a deny-listed path; it still holds studio.db, so flag it.
[ -d "$_custom_root" ] && _set_marker "$_REMOVE_FAILED_FLAG"
continue
fi
if ! _is_studio_root "$_custom_root"; then
echo " refusing to remove non-Unsloth path: $_custom_root" >&2
continue
fi
# Flat layout (both variables name one dir): do not take the user-chosen master root with it.
_crf_canon=$(CDPATH= cd -P -- "$_custom_root" 2>/dev/null && pwd -P) || _crf_canon=""
[ -n "$_crf_canon" ] || _crf_canon="$_custom_root"
_crf_master="$_MASTER_ROOT_SAVED"
if [ -n "$_crf_master" ] && [ "$_crf_canon" = "$_crf_master" ]; then
echo " keeping $_custom_root: UNSLOTH_HOME and the Unsloth Studio root name the same" >&2
echo " directory, so removing it would take whatever else you keep there." >&2
echo " Delete it by hand once you have checked what is in it." >&2
_set_marker "$_REMOVE_FAILED_FLAG"
unset _crf_canon _crf_master
continue
fi
unset _crf_canon _crf_master
_remove_root_recording_db "$_custom_root"
# Older builds put sd.cpp beside the root; require our owner marker, since a git
# clone has the same name.
_custom_sd_cpp="$(dirname "$_custom_root")/stable-diffusion.cpp"
if _is_unsafe_root "$_custom_sd_cpp"; then
echo " refusing to remove unsafe path: $_custom_sd_cpp" >&2
elif [ -e "$_custom_sd_cpp" ] && [ ! -f "$_custom_sd_cpp/.unsloth-studio-owned" ]; then
echo " keeping sd.cpp without Unsloth owner marker: $_custom_sd_cpp" >&2
else
_remove_path "$_custom_sd_cpp"
fi
done
# The lexical parent too: a symlinked home has its old sd.cpp tree beside the link.
_custom_studio_roots lexical 2>/dev/null | while IFS= read -r _lex_root; do
[ -n "$_lex_root" ] || continue
# Same ownership check, or a mistyped UNSLOTH_STUDIO_HOME could take someone's marked sd.cpp.
_is_studio_root "$_lex_root" || continue
_lex_sd_cpp="$(dirname "$_lex_root")/stable-diffusion.cpp"
[ -f "$_lex_sd_cpp/.unsloth-studio-owned" ] || continue
# The deny list is string-based, so check the resolved path; the rm stays lexical.
# shellcheck disable=SC1007
_lex_sd_canon=$(CDPATH= cd -P -- "$_lex_sd_cpp" 2>/dev/null && pwd -P)
[ -n "$_lex_sd_canon" ] || _lex_sd_canon="$_lex_sd_cpp"
if _is_unsafe_root "$_lex_sd_cpp" || _is_unsafe_root "$_lex_sd_canon"; then
echo " refusing to remove unsafe path: $_lex_sd_cpp" >&2
else
_remove_path "$_lex_sd_cpp"
fi
done
# The master root's own children are marker-gated and deny-listed: <master> is user-chosen.
_mr_root="$_MASTER_ROOT_SAVED"
if [ -n "$_mr_root" ]; then
if _is_unsafe_root "$_mr_root"; then
echo " refusing to remove unsafe path: $_mr_root" >&2
else
for _mr_child in llama.cpp node whisper.cpp audio.cpp stable-diffusion.cpp; do
_mr_path="$_mr_root/$_mr_child"
if _is_unsafe_root "$_mr_path"; then
echo " refusing to remove unsafe path: $_mr_path" >&2
# -L too: -e is false for a dangling link, which in a user-chosen root is theirs.
elif { [ -e "$_mr_path" ] || [ -L "$_mr_path" ]; } \
&& [ ! -f "$_mr_path/.unsloth-studio-owned" ]; then
echo " keeping $_mr_child without Unsloth owner marker: $_mr_path" >&2
else
_remove_path "$_mr_path"
fi
done
for _mr_lock in .llama.cpp.install.lock .node.install.lock \
.whisper.cpp.install.lock .audio.cpp.install.lock .sd.cpp.install.lock; do
_remove_lock_file "$_mr_root/$_mr_lock"
done
# Shared .staging is pruned only when empty; rmdir, not a recursive delete.
rmdir "$_mr_root/.staging" 2>/dev/null || true
# The exact shape prebuilt_core.py leaves: a component lock name, ".stale.", and the
# pid. A bare .*.install.lock.stale.* also matched .backup.install.lock.stale.copy.
for _mr_lock in .llama.cpp.install.lock .node.install.lock \
.whisper.cpp.install.lock .audio.cpp.install.lock .sd.cpp.install.lock; do
for _mr_stale in "$_mr_root/$_mr_lock".stale.*; do
case "${_mr_stale##*.stale.}" in
''|*[!0-9]*) continue ;;
esac
{ [ -e "$_mr_stale" ] || [ -L "$_mr_stale" ]; } && _remove_lock_file "$_mr_stale"
done
done
rmdir "$_mr_root" 2>/dev/null || true
fi
fi
# end master-root children
# -e OR -L: _remove_path also unlinks dangling links, so the gate must see them.
if { [ -e "$HOME/.unsloth/studio" ] || [ -L "$HOME/.unsloth/studio" ]; } \
&& ! _is_studio_root "$HOME/.unsloth/studio" managed; then
echo " refusing to remove non-Unsloth path: $HOME/.unsloth/studio" >&2
# Unlike a refused custom root, a studio.db in our default path is chat history.
if [ -f "$HOME/.unsloth/studio/studio.db" ]; then
_set_marker "$_DB_KEPT_FLAG"
fi
else
_remove_root_recording_db "$HOME/.unsloth/studio"
fi
# Shared llama.cpp build + cache, siblings of studio in default mode (deleting studio misses
# them). No-op in env/custom mode and when absent. A user-set UNSLOTH_LLAMA_CPP_PATH is kept.
_remove_path "$HOME/.unsloth/llama.cpp"
# A user's own stable-diffusion.cpp checkout may sit here, so require our owner marker.
_default_sd_cpp="$HOME/.unsloth/stable-diffusion.cpp"
if [ -e "$_default_sd_cpp" ] && [ ! -f "$_default_sd_cpp/.unsloth-studio-owned" ]; then
echo " keeping sd.cpp without Unsloth owner marker: $_default_sd_cpp" >&2
else
_remove_path "$_default_sd_cpp"
fi
_remove_path "$HOME/.unsloth/.cache"
_remove_path "$HOME/.unsloth/node"
# An interrupted llama.cpp install leaves .staging, which blocks the rmdir below.
_remove_path "$HOME/.unsloth/.staging"
_remove_path "$HOME/.unsloth/whisper.cpp"
# The audio.cpp installer always marks its tree, so an unmarked one is the user's build.
_default_audio_cpp="$HOME/.unsloth/audio.cpp"
if { [ -e "$_default_audio_cpp" ] || [ -L "$_default_audio_cpp" ]; } \
&& [ ! -f "$_default_audio_cpp/.unsloth-studio-owned" ]; then
echo " keeping audio.cpp without Unsloth owner marker: $_default_audio_cpp" >&2
else
_remove_path "$_default_audio_cpp"
fi
# A stray install lock keeps ~/.unsloth from being pruned below.
_remove_lock_file "$HOME/.unsloth/.llama.cpp.install.lock"
_remove_lock_file "$HOME/.unsloth/.node.install.lock"
_remove_lock_file "$HOME/.unsloth/.whisper.cpp.install.lock"
_remove_lock_file "$HOME/.unsloth/.audio.cpp.install.lock"
# audio.cpp model links live beside the HF hub cache; the cache itself stays.
_hf_hub="${HF_HUB_CACHE:-${HUGGINGFACE_HUB_CACHE:-${HF_HOME:-${XDG_CACHE_HOME:-$HOME/.cache}/huggingface}/hub}}"
_remove_path "$(dirname "$_hf_hub")/unsloth-audiocpp-links"
# Per-uid audio.cpp scratch home; only a real directory this user owns.
_audiocpp_home="${TMPDIR:-/tmp}"
_audiocpp_home="${_audiocpp_home%/}/unsloth-audiocpp-home-$(id -u 2>/dev/null)"
if [ -d "$_audiocpp_home" ] && [ ! -L "$_audiocpp_home" ] && [ -O "$_audiocpp_home" ]; then
_remove_path "$_audiocpp_home"
fi
# Taking over an abandoned lock renames it to .stale.<pid> before unlinking
# (install_node_prebuilt.py); a crash between the two strands the rename, and a stranded one
# blocks the rmdir below. Unmatched globs stay literal, hence the existence test.
# Same shape restriction as the master-root sweep above: the help text promises that
# anything else kept under ~/.unsloth is left in place, and .backup.install.lock.stale.copy
# is dotted too.
for _lock in .llama.cpp.install.lock .node.install.lock \
.whisper.cpp.install.lock .audio.cpp.install.lock .sd.cpp.install.lock; do
for _stale in "$HOME/.unsloth/$_lock".stale.*; do
case "${_stale##*.stale.}" in
''|*[!0-9]*) continue ;;
esac
{ [ -e "$_stale" ] || [ -L "$_stale" ]; } && _remove_lock_file "$_stale"
done
done
_remove_path "$HOME/.unsloth/librocdxg"
_remove_path "$HOME/.unsloth/rocm-smoketest"
rmdir "$HOME/.unsloth" 2>/dev/null || true
_remove_path "$HOME/.local/share/unsloth"
_remove_cli_shim
echo "Removing desktop shortcut and launcher lock..."
_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"
_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
# While the packaged app is installed, do not reset its WebView data.
_bid="ai.unsloth.studio"
_bid_owner=$(_bundle_id_owner "$_bid")
if [ -n "$_bid_owner" ]; then
echo "Keeping app data ($_bid): it belongs to $_bid_owner"
else
echo "Removing WebView caches and app data ($_bid)..."
_remove_path "$HOME/Library/Caches/$_bid"
_remove_path "$HOME/Library/WebKit/$_bid"
_remove_path "$HOME/Library/Application Support/$_bid"
_remove_path "$HOME/Library/HTTPStorages/$_bid"
_remove_path "$HOME/Library/HTTPStorages/$_bid.binarycookies"
_remove_path "$HOME/Library/Cookies/$_bid.binarycookies"
_remove_path "$HOME/Library/Saved Application State/$_bid.savedState"
# defaults, not rm: cfprefsd rewrites the plist from memory.
if command -v defaults >/dev/null 2>&1; then
_run_as_home_owner defaults delete "$_bid" >/dev/null 2>&1 || true
_run_as_home_owner defaults -currentHost delete "$_bid" >/dev/null 2>&1 || true
fi
_remove_path "$HOME/Library/Preferences/$_bid.plist"
fi
;;
Linux)
if [ "$_is_wsl" = "1" ]; then
echo "Removing WSL Windows-side shortcuts..."
# Remove only THIS distro's WSL shortcuts. Test powershell.exe can execute:
# `command -v` succeeds even with interop off.
_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
# A -Command string does not receive trailing tokens as $args, so inject the distro.
# 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 "<distro>" 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
# Keep the shared icon while any shortcut uses it.
# Reciprocal of uninstall.ps1's _RemoveDataDirKeepingWslIcon.
_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
}
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
_lnk="$_scdir/Unsloth Studio (WSL - ${_wsl_distro}).lnk"
[ -e "$_lnk" ] && rm -f "$_lnk" 2>/dev/null && echo " removed: $_lnk" || true
else
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_shared_icon_if_unused "$_udir"
done
done
fi
# ── ROCm-on-WSL config (install_rocm_wsl_strixhalo.sh) ──
# System ROCm is kept unless UNSLOTH_UNINSTALL_ROCM=1.
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
_bid="ai.unsloth.studio"
echo "Removing WebView caches and app data ($_bid)..."
_remove_path "$(_xdg_dir "${XDG_DATA_HOME:-}" "$HOME/.local/share")/$_bid"
_remove_path "$(_xdg_dir "${XDG_CACHE_HOME:-}" "$HOME/.cache")/$_bid"
_remove_path "$(_xdg_dir "${XDG_CONFIG_HOME:-}" "$HOME/.config")/$_bid"
_remove_path "$(_xdg_dir "${XDG_STATE_HOME:-}" "$HOME/.local/state")/$_bid"
echo "Removing Linux .desktop entry..."
_remove_path "$HOME/.local/share/applications/unsloth-studio.desktop"
# tauri-plugin-deep-link rewrites this on every launch and honours XDG_DATA_HOME.
_un_appdir="$(_xdg_dir "${XDG_DATA_HOME:-}" "$HOME/.local/share")/applications"
_remove_path "$_un_appdir/unsloth-studio-handler.desktop"
if [ "$_un_appdir" != "$HOME/.local/share/applications" ]; then
_remove_path "$HOME/.local/share/applications/unsloth-studio-handler.desktop"
fi
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
if [ "$_un_appdir" != "$HOME/.local/share/applications" ]; then
update-desktop-database "$_un_appdir" 2>/dev/null || true
fi
fi
;;
esac
echo ""
echo "Unsloth Studio uninstalled."
if _markers_unavailable || _marker_set "$_REMOVE_FAILED_FLAG"; then
echo "Note: some paths could not be removed (see 'could not remove:' above), so the"
echo " signed-in session and local chat history may still be on disk. Remove"
echo " those paths by hand to clear them."
elif _marker_set "$_DB_REMOVED_FLAG"; then
# A bare run never discovers an env-mode root, so do not claim its database is gone.
echo "Note: this also removed the app's WebView data and the studio.db it found, so"
echo " the desktop app's session and the chat history in the install(s) removed"
echo " above are gone."
else
echo "Note: this also removed the app's WebView data, so the desktop app's session is"
echo " gone. A browser session is not affected: its tokens live in the same"
echo " localStorage as the API keys below."
if _marker_set "$_DB_KEPT_FLAG"; then
echo " $HOME/.unsloth/studio carries no Unsloth install marker, so it was left"
echo " alone. The studio.db inside it is still there; look at that directory"
echo " yourself before deciding what to do with it."
else
echo " No studio.db was found, so any chat history in an install root this run"
echo " did not see is still on disk."
fi
fi
echo "Note: provider API keys are kept in the browser's localStorage, not in studio.db."
echo " Unless you ran Unsloth as the desktop app, clear site data for the"
echo " http://localhost:<port> origin you used to remove them."
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."
_uv_print_leftover_notes
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
}
# Parse the whole script first so a truncated download is inert.
{
_unsloth_uninstall_main "$@"
}