unsloth/install.sh
Daniel Han be2cd7087a
Add macOS and Linux desktop shortcuts to install.sh (#4568)
* Add macOS and Linux desktop shortcuts to install.sh

Adds create_studio_shortcuts() function that creates platform-native
shortcuts after `unsloth studio setup` completes, mirroring the Windows
shortcut behavior from PR #4558.

Linux: .desktop file in ~/.local/share/applications/ and ~/Desktop/
macOS: .app bundle in ~/Applications/ with Info.plist, exec stub, and
       optional .icns icon built from unsloth-gem.png via sips+iconutil

Both platforms share a Bash launcher script at
~/.local/share/unsloth/launch-studio.sh that provides:
- Health check with service fingerprint verification
- Port scanning (8888-8908) via ss/lsof
- PID-file single-instance guard (no flock dependency)
- Terminal spawning (macOS: Terminal.app; Linux: gnome-terminal etc.)
- Browser open after health poll with 60s timeout

WSL is skipped (no native desktop environment).

* Fix 6 issues found by 10 parallel reviewers

1. [10/10] Health check now supports wget as fallback to curl via
   _http_get() helper, matching the installer's own download() pattern.
   Previously wget-only systems would time out on every launch.

2. [9/10] Exe path substitution now escapes sed metacharacters (&, \, |)
   and shell single-quotes before injection, preventing launcher
   corruption for paths like /opt/R&D/bin/unsloth.

3. [4/10] Linux .desktop Exec= field now quotes the launcher path,
   fixing launches from home directories containing spaces.

4. [3/10] macOS AppleScript command now escapes backslashes and
   double-quotes before interpolation into do script "...", fixing
   Terminal.app launch failures.

5. [3/10] Single-instance guard now uses atomic mkdir instead of
   racy check-then-write PID file, preventing duplicate concurrent
   launches on rapid double-click.

6. [1/10] Launcher now scans for a free port via _find_launch_port()
   instead of always hardcoding -p 8888, so Studio starts correctly
   when another service already occupies port 8888.

Also fixed: `open` command on Linux (openvt) no longer incorrectly
triggers the macOS browser-open path -- now gated on uname=Darwin.

* Fix mktemp guard and exe path escaping from PR review comments

Two real issues identified from automated review comments:

1. Guard mktemp -d failure in macOS icns generation. If mktemp -d
   returned empty, dirname would resolve to / and rm -rf would attempt
   to delete the root directory. Now checks that the temp dir was
   actually created before proceeding.

2. Replace sed-based exe path substitution with a conf file approach.
   The previous sed escaping broke paths containing apostrophes
   (e.g. /home/O'Connor/) because the '\'' escape introduced
   backslashes that were then double-escaped by the metacharacter
   pass. Now writes UNSLOTH_EXE to a separate studio.conf file that
   the launcher sources at runtime, eliminating all sed metacharacter
   and shell quoting interaction issues.

   This also addresses the sed -i.bak portability concern (now moot
   since sed is no longer used on the launcher file).

* Fix unbound variable crash and per-user lock in launcher

- Use ${UNSLOTH_EXE:-} so set -u does not crash before the friendly
  error message when studio.conf is missing or empty.
- Append $(id -u) to the fallback lock path so each user gets their
  own lock directory when XDG_RUNTIME_DIR is unset.

* Mark desktop shortcut as trusted for GNOME/Nautilus

On modern GNOME desktops, chmod +x alone is not sufficient to make
a .desktop file launchable by double-click on ~/Desktop. Nautilus
requires the metadata::trusted attribute to be set via gio, otherwise
it shows a warning dialog instead of launching the application.
2026-03-25 03:37:37 -07:00

701 lines
23 KiB
Bash
Executable file

#!/bin/sh
# Unsloth Studio Installer
# Usage (curl): curl -fsSL https://raw.githubusercontent.com/unslothai/unsloth/main/install.sh | sh
# Usage (wget): wget -qO- https://raw.githubusercontent.com/unslothai/unsloth/main/install.sh | sh
set -e
VENV_NAME="unsloth_studio"
PYTHON_VERSION="3.13"
# ── Helper: download a URL to a file (supports curl and wget) ──
download() {
if command -v curl >/dev/null 2>&1; then
curl -LsSf "$1" -o "$2"
elif command -v wget >/dev/null 2>&1; then
wget -qO "$2" "$1"
else
echo "Error: neither curl nor wget found. Install one and re-run."
exit 1
fi
}
# ── Helper: check if a single package is available on the system ──
_is_pkg_installed() {
case "$1" in
build-essential) command -v gcc >/dev/null 2>&1 ;;
libcurl4-openssl-dev)
command -v dpkg >/dev/null 2>&1 && dpkg -s "$1" >/dev/null 2>&1 ;;
pciutils)
command -v lspci >/dev/null 2>&1 ;;
*) command -v "$1" >/dev/null 2>&1 ;;
esac
}
# ── Helper: install packages via apt, escalating to sudo only if needed ──
# Usage: _smart_apt_install pkg1 pkg2 pkg3 ...
_smart_apt_install() {
_PKGS="$*"
# Step 1: Try installing without sudo (works when already root)
apt-get update -y </dev/null >/dev/null 2>&1 || true
apt-get install -y $_PKGS </dev/null >/dev/null 2>&1 || true
# Step 2: Check which packages are still missing
_STILL_MISSING=""
for _pkg in $_PKGS; do
if ! _is_pkg_installed "$_pkg"; then
_STILL_MISSING="$_STILL_MISSING $_pkg"
fi
done
_STILL_MISSING=$(echo "$_STILL_MISSING" | sed 's/^ *//')
if [ -z "$_STILL_MISSING" ]; then
return 0
fi
# Step 3: Escalate -- need elevated permissions for remaining packages
if command -v sudo >/dev/null 2>&1; then
echo ""
echo " !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
echo " WARNING: We require sudo elevated permissions to install:"
echo " $_STILL_MISSING"
echo " If you accept, we'll run sudo now, and it'll prompt your password."
echo " !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
echo ""
printf " Accept? [Y/n] "
if [ -r /dev/tty ]; then
read -r REPLY </dev/tty || REPLY="y"
else
REPLY="y"
fi
case "$REPLY" in
[nN]*)
echo ""
echo " Please install these packages first, then re-run Unsloth Studio setup:"
echo " sudo apt-get update -y && sudo apt-get install -y $_STILL_MISSING"
exit 1
;;
*)
sudo apt-get update -y </dev/null
sudo apt-get install -y $_STILL_MISSING </dev/null
;;
esac
else
echo ""
echo " sudo is not available on this system."
echo " Please install these packages as root, then re-run Unsloth Studio setup:"
echo " apt-get update -y && apt-get install -y $_STILL_MISSING"
exit 1
fi
}
# ── Helper: create desktop shortcuts and launcher script ──
# Usage: create_studio_shortcuts <unsloth_exe> <os>
# Creates ~/.local/share/unsloth/launch-studio.sh (shared launcher),
# plus platform-specific shortcuts (Linux .desktop / macOS .app bundle).
# Skipped on WSL (no native desktop).
create_studio_shortcuts() {
_css_exe="$1"
_css_os="$2"
# Skip on WSL -- no native desktop environment
if [ "$_css_os" = "wsl" ]; then
return 0
fi
# Validate exe
if [ ! -x "$_css_exe" ]; then
echo "[WARN] Cannot create shortcuts: unsloth not found at $_css_exe"
return 0
fi
# Resolve absolute path
_css_exe_dir=$(cd "$(dirname "$_css_exe")" && pwd)
_css_exe="$_css_exe_dir/$(basename "$_css_exe")"
_css_data_dir="$HOME/.local/share/unsloth"
_css_launcher="$_css_data_dir/launch-studio.sh"
_css_icon_png="$_css_data_dir/unsloth-studio.png"
_css_gem_png="$_css_data_dir/unsloth-gem.png"
mkdir -p "$_css_data_dir"
# ── Write launcher script ──
# The launcher is Bash (not POSIX sh).
# We write it with a placeholder and substitute the exe path via sed.
cat > "$_css_launcher" << 'LAUNCHER_EOF'
#!/usr/bin/env bash
# Unsloth Studio Launcher
# Auto-generated by install.sh -- do not edit manually.
set -euo pipefail
DATA_DIR="$HOME/.local/share/unsloth"
# Read exe path from config written at install time.
# Sourcing is safe: the config file is written by install.sh, not user input.
if [ -f "$DATA_DIR/studio.conf" ]; then
. "$DATA_DIR/studio.conf"
fi
if [ -z "${UNSLOTH_EXE:-}" ] || [ ! -x "${UNSLOTH_EXE:-}" ]; then
echo "Error: UNSLOTH_EXE not set or not executable. Re-run the installer." >&2
exit 1
fi
BASE_PORT=8888
MAX_PORT_OFFSET=20
TIMEOUT_SEC=60
POLL_INTERVAL_SEC=1
LOG_FILE="$DATA_DIR/studio.log"
LOCK_DIR="${XDG_RUNTIME_DIR:-/tmp}/unsloth-studio-launcher-$(id -u).lock"
# ── HTTP GET helper (supports curl and wget) ──
_http_get() {
_url="$1"
if command -v curl >/dev/null 2>&1; then
curl -fsS --max-time 1 "$_url" 2>/dev/null
elif command -v wget >/dev/null 2>&1; then
wget -qO- --timeout=1 "$_url" 2>/dev/null
else
return 1
fi
}
# ── Health check ──
_check_health() {
_port=$1
_resp=$(_http_get "http://127.0.0.1:$_port/api/health") || return 1
case "$_resp" in
*'"status"'*'"healthy"'*'"service"'*'"Unsloth UI Backend"'*) return 0 ;;
*'"service"'*'"Unsloth UI Backend"'*'"status"'*'"healthy"'*) return 0 ;;
esac
return 1
}
# ── Port scanning ──
_candidate_ports() {
echo "$BASE_PORT"
_max_port=$((BASE_PORT + MAX_PORT_OFFSET))
if command -v ss >/dev/null 2>&1; then
ss -tlnH 2>/dev/null | awk '{print $4}' | grep -oE '[0-9]+$' | \
awk -v lo="$BASE_PORT" -v hi="$_max_port" '$1 >= lo && $1 <= hi && $1 != lo {print}' || true
elif command -v lsof >/dev/null 2>&1; then
lsof -iTCP -sTCP:LISTEN -nP 2>/dev/null | awk '{print $9}' | grep -oE '[0-9]+$' | \
awk -v lo="$BASE_PORT" -v hi="$_max_port" '$1 >= lo && $1 <= hi && $1 != lo {print}' || true
else
_offset=1
while [ "$_offset" -le "$MAX_PORT_OFFSET" ]; do
echo $((BASE_PORT + _offset))
_offset=$((_offset + 1))
done
fi
}
_find_healthy_port() {
for _p in $(_candidate_ports | sort -un); do
if _check_health "$_p"; then
echo "$_p"
return 0
fi
done
return 1
}
# ── Check if a port is busy ──
_is_port_busy() {
_port=$1
if command -v ss >/dev/null 2>&1; then
ss -tlnH 2>/dev/null | awk '{print $4}' | grep -qE "[.:]$_port$"
elif command -v lsof >/dev/null 2>&1; then
lsof -iTCP:"$_port" -sTCP:LISTEN -nP >/dev/null 2>&1
else
return 1
fi
}
# ── Find a free port in range ──
_find_launch_port() {
_offset=0
while [ "$_offset" -le "$MAX_PORT_OFFSET" ]; do
_candidate=$((BASE_PORT + _offset))
if ! _is_port_busy "$_candidate"; then
echo "$_candidate"
return 0
fi
_offset=$((_offset + 1))
done
return 1
}
# ── Open browser ──
_open_browser() {
_url="$1"
if [ "$(uname)" = "Darwin" ] && command -v open >/dev/null 2>&1; then
open "$_url"
elif command -v xdg-open >/dev/null 2>&1; then
xdg-open "$_url" >/dev/null 2>&1 &
else
echo "Open in your browser: $_url" >&2
fi
}
# ── Spawn terminal with studio command ──
_spawn_terminal() {
_cmd="$1"
_os=$(uname)
if [ "$_os" = "Darwin" ]; then
# Escape backslashes and double-quotes for AppleScript string
_cmd_escaped=$(printf '%s' "$_cmd" | sed 's/\\/\\\\/g; s/"/\\"/g')
osascript -e "tell application \"Terminal\" to do script \"$_cmd_escaped\"" >/dev/null 2>&1 && return 0
else
for _term in gnome-terminal konsole xfce4-terminal mate-terminal lxterminal xterm; do
if command -v "$_term" >/dev/null 2>&1; then
case "$_term" in
gnome-terminal) "$_term" -- sh -c "$_cmd" & return 0 ;;
konsole) "$_term" -e sh -c "$_cmd" & return 0 ;;
xterm) "$_term" -e sh -c "$_cmd" & return 0 ;;
*) "$_term" -e sh -c "$_cmd" & return 0 ;;
esac
fi
done
fi
# Fallback: background with log
echo "No terminal emulator found; running in background. Logs: $LOG_FILE" >&2
nohup sh -c "$_cmd" >> "$LOG_FILE" 2>&1 &
return 0
}
# ── Atomic directory-based single-instance guard ──
_acquire_lock() {
if mkdir "$LOCK_DIR" 2>/dev/null; then
echo "$$" > "$LOCK_DIR/pid"
return 0
fi
# Lock dir exists -- check if owner is still alive
_old_pid=$(cat "$LOCK_DIR/pid" 2>/dev/null || true)
if [ -n "$_old_pid" ] && kill -0 "$_old_pid" 2>/dev/null; then
# Another launcher is running; wait for it to bring Studio up
_deadline=$(($(date +%s) + TIMEOUT_SEC))
while [ "$(date +%s)" -lt "$_deadline" ]; do
_port=$(_find_healthy_port) && {
_open_browser "http://localhost:$_port"
exit 0
}
sleep "$POLL_INTERVAL_SEC"
done
echo "Timed out waiting for other launcher (PID $_old_pid)" >&2
exit 0
fi
# Stale lock -- reclaim
rm -rf "$LOCK_DIR"
mkdir "$LOCK_DIR" 2>/dev/null || return 1
echo "$$" > "$LOCK_DIR/pid"
}
_release_lock() {
rm -rf "$LOCK_DIR"
}
# ── Main ──
# Fast path: already healthy
_port=$(_find_healthy_port) && {
_open_browser "http://localhost:$_port"
exit 0
}
_acquire_lock
trap '_release_lock' EXIT INT TERM
# Post-lock re-check (handles race with another launcher)
_port=$(_find_healthy_port) && {
_open_browser "http://localhost:$_port"
exit 0
}
# Find a free port in range
_launch_port=$(_find_launch_port) || {
echo "No free port found in range ${BASE_PORT}-$((BASE_PORT + MAX_PORT_OFFSET))" >&2
exit 1
}
# Launch studio in a terminal
_launch_cmd=$(printf '%q ' "$UNSLOTH_EXE" studio -H 0.0.0.0 -p "$_launch_port")
_launch_cmd=${_launch_cmd% }
_spawn_terminal "$_launch_cmd"
# Poll for health
_deadline=$(($(date +%s) + TIMEOUT_SEC))
while [ "$(date +%s)" -lt "$_deadline" ]; do
_port=$(_find_healthy_port) && {
_open_browser "http://localhost:$_port"
exit 0
}
sleep "$POLL_INTERVAL_SEC"
done
echo "Unsloth Studio did not become healthy within ${TIMEOUT_SEC}s." >&2
echo "Check logs at: $LOG_FILE" >&2
exit 1
LAUNCHER_EOF
chmod +x "$_css_launcher"
# Write the exe path to a separate conf file sourced by the launcher.
# Using single-quote wrapping with the standard '\'' escape for any
# embedded apostrophes. This avoids all sed metacharacter issues.
_css_quoted_exe=$(printf '%s' "$_css_exe" | sed "s/'/'\\\\''/g")
printf '%s\n' "UNSLOTH_EXE='$_css_quoted_exe'" > "$_css_data_dir/studio.conf"
# ── Icon: try bundled, then download ──
# favicon.png (small, for Linux) and unsloth-gem.png (large, for macOS icns)
_css_script_dir=""
if [ -n "${0:-}" ] && [ -f "$0" ]; then
_css_script_dir=$(cd "$(dirname "$0")" 2>/dev/null && pwd) || true
fi
# Try to find favicon.png from installed package (site-packages) or local repo
_css_found_favicon=""
_css_found_gem=""
_css_venv_dir=$(dirname "$(dirname "$_css_exe")")
# Check site-packages
for _sp in "$_css_venv_dir"/lib/python*/site-packages/unsloth/studio/frontend/public; do
if [ -f "$_sp/favicon.png" ]; then
_css_found_favicon="$_sp/favicon.png"
fi
if [ -f "$_sp/unsloth-gem.png" ]; then
_css_found_gem="$_sp/unsloth-gem.png"
fi
done
# Check local repo (when running from clone)
if [ -z "$_css_found_favicon" ] && [ -n "$_css_script_dir" ] && [ -f "$_css_script_dir/studio/frontend/public/favicon.png" ]; then
_css_found_favicon="$_css_script_dir/studio/frontend/public/favicon.png"
fi
if [ -z "$_css_found_gem" ] && [ -n "$_css_script_dir" ] && [ -f "$_css_script_dir/studio/frontend/public/unsloth-gem.png" ]; then
_css_found_gem="$_css_script_dir/studio/frontend/public/unsloth-gem.png"
fi
# Copy or download favicon.png
if [ -n "$_css_found_favicon" ]; then
cp "$_css_found_favicon" "$_css_icon_png" 2>/dev/null || true
elif [ ! -f "$_css_icon_png" ]; then
download "https://raw.githubusercontent.com/unslothai/unsloth/main/studio/frontend/public/favicon.png" "$_css_icon_png" 2>/dev/null || true
fi
# Copy or download unsloth-gem.png (for macOS icns)
if [ -n "$_css_found_gem" ]; then
cp "$_css_found_gem" "$_css_gem_png" 2>/dev/null || true
elif [ ! -f "$_css_gem_png" ]; then
download "https://raw.githubusercontent.com/unslothai/unsloth/main/studio/frontend/public/unsloth-gem.png" "$_css_gem_png" 2>/dev/null || true
fi
# Validate PNG header (first 4 bytes: \x89PNG)
_css_validate_png() {
[ -f "$1" ] || return 1
_hdr=$(od -An -tx1 -N4 "$1" 2>/dev/null | tr -d ' ')
[ "$_hdr" = "89504e47" ]
}
if [ -f "$_css_icon_png" ] && ! _css_validate_png "$_css_icon_png"; then
rm -f "$_css_icon_png"
fi
if [ -f "$_css_gem_png" ] && ! _css_validate_png "$_css_gem_png"; then
rm -f "$_css_gem_png"
fi
# ── Platform-specific shortcuts ──
_css_created=0
if [ "$_css_os" = "linux" ]; then
# ── Linux: .desktop file ──
_css_app_dir="$HOME/.local/share/applications"
mkdir -p "$_css_app_dir"
_css_desktop="$_css_app_dir/unsloth-studio.desktop"
# Escape backslashes and double-quotes for .desktop Exec= field
_css_exec_escaped=$(printf '%s' "$_css_launcher" | sed 's/\\/\\\\/g; s/"/\\"/g')
_css_icon_escaped=$(printf '%s' "$_css_icon_png" | sed 's/\\/\\\\/g; s/"/\\"/g')
cat > "$_css_desktop" << DESKTOP_EOF
[Desktop Entry]
Version=1.0
Type=Application
Name=Unsloth Studio
Comment=Launch Unsloth Studio
Exec="$_css_exec_escaped"
Icon=$_css_icon_escaped
Terminal=false
StartupNotify=true
Categories=Development;Science;
DESKTOP_EOF
chmod +x "$_css_desktop"
# Copy to ~/Desktop if it exists
if [ -d "$HOME/Desktop" ]; then
cp "$_css_desktop" "$HOME/Desktop/unsloth-studio.desktop" 2>/dev/null || true
chmod +x "$HOME/Desktop/unsloth-studio.desktop" 2>/dev/null || true
# Mark as trusted so GNOME/Nautilus allows launching via double-click
if command -v gio >/dev/null 2>&1; then
gio set "$HOME/Desktop/unsloth-studio.desktop" metadata::trusted true 2>/dev/null || true
fi
fi
# Best-effort update database
update-desktop-database "$_css_app_dir" 2>/dev/null || true
_css_created=1
elif [ "$_css_os" = "macos" ]; then
# ── macOS: .app bundle ──
_css_app="$HOME/Applications/Unsloth Studio.app"
_css_contents="$_css_app/Contents"
_css_macos_dir="$_css_contents/MacOS"
_css_res_dir="$_css_contents/Resources"
mkdir -p "$_css_macos_dir" "$_css_res_dir"
# Info.plist
cat > "$_css_contents/Info.plist" << 'PLIST_EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>ai.unsloth.studio</string>
<key>CFBundleName</key>
<string>Unsloth Studio</string>
<key>CFBundleDisplayName</key>
<string>Unsloth Studio</string>
<key>CFBundleExecutable</key>
<string>launch-studio</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>LSMinimumSystemVersion</key>
<string>10.15</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
PLIST_EOF
# Executable stub
cat > "$_css_macos_dir/launch-studio" << STUB_EOF
#!/bin/sh
exec "$HOME/.local/share/unsloth/launch-studio.sh" "\$@"
STUB_EOF
chmod +x "$_css_macos_dir/launch-studio"
# Build AppIcon.icns from unsloth-gem.png (2240x2240)
if [ -f "$_css_gem_png" ] && command -v sips >/dev/null 2>&1 && command -v iconutil >/dev/null 2>&1; then
_css_tmpdir=$(mktemp -d 2>/dev/null)
if [ -d "$_css_tmpdir" ]; then
_css_iconset="$_css_tmpdir/AppIcon.iconset"
mkdir -p "$_css_iconset"
_css_icon_ok=true
for _sz in 16 32 128 256 512; do
_sz2=$((_sz * 2))
sips -z "$_sz" "$_sz" "$_css_gem_png" --out "$_css_iconset/icon_${_sz}x${_sz}.png" >/dev/null 2>&1 || _css_icon_ok=false
sips -z "$_sz2" "$_sz2" "$_css_gem_png" --out "$_css_iconset/icon_${_sz}x${_sz}@2x.png" >/dev/null 2>&1 || _css_icon_ok=false
done
if [ "$_css_icon_ok" = "true" ]; then
iconutil -c icns "$_css_iconset" -o "$_css_res_dir/AppIcon.icns" 2>/dev/null || true
fi
rm -rf "$_css_tmpdir"
fi
fi
# Fallback: copy PNG as icon
if [ ! -f "$_css_res_dir/AppIcon.icns" ] && [ -f "$_css_icon_png" ]; then
cp "$_css_icon_png" "$_css_res_dir/AppIcon.icns" 2>/dev/null || true
fi
# Touch so Finder indexes it
touch "$_css_app"
# Symlink on Desktop
if [ -d "$HOME/Desktop" ]; then
ln -sf "$_css_app" "$HOME/Desktop/Unsloth Studio" 2>/dev/null || true
fi
_css_created=1
fi
if [ "$_css_created" -eq 1 ]; then
echo "[OK] Created Unsloth Studio shortcut(s)"
fi
}
echo ""
echo "========================================="
echo " Unsloth Studio Installer"
echo "========================================="
echo ""
# ── Detect platform ──
OS="linux"
if [ "$(uname)" = "Darwin" ]; then
OS="macos"
elif grep -qi microsoft /proc/version 2>/dev/null; then
OS="wsl"
fi
echo "==> Platform: $OS"
# ── Check system dependencies ──
# cmake and git are needed by unsloth studio setup to build the GGUF inference
# engine (llama.cpp). build-essential and libcurl-dev are also needed on Linux.
MISSING=""
command -v cmake >/dev/null 2>&1 || MISSING="$MISSING cmake"
command -v git >/dev/null 2>&1 || MISSING="$MISSING git"
case "$OS" in
macos)
# Xcode Command Line Tools provide the C/C++ compiler
if ! xcode-select -p >/dev/null 2>&1; then
echo ""
echo "==> Xcode Command Line Tools are required."
echo " Installing (a system dialog will appear)..."
xcode-select --install </dev/null 2>/dev/null || true
echo " After the installation completes, please re-run this script."
exit 1
fi
;;
linux|wsl)
# curl or wget is needed for downloads; check both
if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
MISSING="$MISSING curl"
fi
command -v gcc >/dev/null 2>&1 || MISSING="$MISSING build-essential"
# libcurl dev headers for llama.cpp HTTPS support
if command -v dpkg >/dev/null 2>&1; then
dpkg -s libcurl4-openssl-dev >/dev/null 2>&1 || MISSING="$MISSING libcurl4-openssl-dev"
fi
;;
esac
MISSING=$(echo "$MISSING" | sed 's/^ *//')
if [ -n "$MISSING" ]; then
echo ""
echo "==> Unsloth Studio needs these packages: $MISSING"
echo " These are needed to build the GGUF inference engine."
case "$OS" in
macos)
if ! command -v brew >/dev/null 2>&1; then
echo ""
echo " Homebrew is required to install them."
echo " Install Homebrew from https://brew.sh then re-run this script."
exit 1
fi
brew install $MISSING </dev/null
;;
linux|wsl)
if command -v apt-get >/dev/null 2>&1; then
_smart_apt_install $MISSING
else
echo " apt-get is not available. Please install with your package manager:"
echo " $MISSING"
echo " Then re-run Unsloth Studio setup."
exit 1
fi
;;
esac
echo ""
else
echo "==> All system dependencies found."
fi
# ── Install uv ──
UV_MIN_VERSION="0.7.14"
version_ge() {
# returns 0 if $1 >= $2
_a=$1
_b=$2
while [ -n "$_a" ] || [ -n "$_b" ]; do
_a_part=${_a%%.*}
_b_part=${_b%%.*}
[ "$_a" = "$_a_part" ] && _a="" || _a=${_a#*.}
[ "$_b" = "$_b_part" ] && _b="" || _b=${_b#*.}
[ -z "$_a_part" ] && _a_part=0
[ -z "$_b_part" ] && _b_part=0
if [ "$_a_part" -gt "$_b_part" ]; then
return 0
fi
if [ "$_a_part" -lt "$_b_part" ]; then
return 1
fi
done
return 0
}
_uv_version_ok() {
_raw=$("$1" --version 2>/dev/null | awk '{print $2}') || return 1
[ -n "$_raw" ] || return 1
_ver=${_raw%%[-+]*}
case "$_ver" in
''|*[!0-9.]*) return 1 ;;
esac
version_ge "$_ver" "$UV_MIN_VERSION" || return 1
# Prerelease of the exact minimum (e.g. 0.7.14-rc1) is still below stable 0.7.14
[ "$_ver" = "$UV_MIN_VERSION" ] && [ "$_raw" != "$_ver" ] && return 1
return 0
}
if ! command -v uv >/dev/null 2>&1 || ! _uv_version_ok uv; then
echo "==> Installing uv package manager..."
_uv_tmp=$(mktemp)
download "https://astral.sh/uv/install.sh" "$_uv_tmp"
sh "$_uv_tmp" </dev/null
rm -f "$_uv_tmp"
if [ -f "$HOME/.local/bin/env" ]; then
. "$HOME/.local/bin/env"
fi
export PATH="$HOME/.local/bin:$PATH"
fi
# ── Create venv (skip if it already exists and has a valid interpreter) ──
if [ ! -x "$VENV_NAME/bin/python" ]; then
[ -e "$VENV_NAME" ] && rm -rf "$VENV_NAME"
echo "==> Creating Python ${PYTHON_VERSION} virtual environment (${VENV_NAME})..."
uv venv "$VENV_NAME" --python "$PYTHON_VERSION"
else
echo "==> Virtual environment ${VENV_NAME} already exists, skipping creation."
fi
# ── Install unsloth directly into the venv (no activation needed) ──
echo "==> Installing unsloth (this may take a few minutes)..."
uv pip install --python "$VENV_NAME/bin/python" "unsloth>=2026.3.11" --torch-backend=auto
# ── Run studio setup ──
# Ensure the venv's Python is on PATH for setup.sh's Python discovery.
# On macOS the system Python may be outside the 3.11-3.13 range that
# setup.sh requires, but uv already installed a compatible interpreter
# inside the venv.
VENV_ABS_BIN="$(cd "$VENV_NAME/bin" && pwd)"
if [ -n "$VENV_ABS_BIN" ]; then
export PATH="$VENV_ABS_BIN:$PATH"
fi
echo "==> Running unsloth studio setup..."
REQUESTED_PYTHON_VERSION="$(cd "$VENV_NAME/bin" && pwd)/python" \
"$VENV_NAME/bin/unsloth" studio setup </dev/null
create_studio_shortcuts "$VENV_ABS_BIN/unsloth" "$OS"
echo ""
echo "========================================="
echo " Unsloth Studio installed!"
echo "========================================="
echo ""
echo " To launch, run:"
echo ""
echo " source ${VENV_NAME}/bin/activate"
echo " unsloth studio -H 0.0.0.0 -p 8888"
echo ""