#!/usr/bin/env bash # ============================================================ # kiln -- macOS / Linux front door. # # ./kiln.sh full-screen TUI # ./kiln.sh status status board # ./kiln.sh setup llama.cpp + cloudflared + models/ # ./kiln.sh hardware what this GPU can run # ./kiln.sh get [VARIANT|both] # ./kiln.sh start [VARIANT] [--no-mtp] # ./kiln.sh stop stop server and tunnel # ./kiln.sh key [show|rotate|set KEY] # ./kiln.sh cursor point Cursor at the model, and keep it there # ./kiln.sh opencode list the local models in OpenCode # ./kiln.sh pi list the local models in pi # ./kiln.sh self-update update kiln itself from GitHub # ./kiln.sh tui full-screen app, with a benchmark # ./kiln.sh bench | quality | clean | help # ./kiln.sh path [--remove] put kiln on PATH (~/.local/bin) # # Bare `kiln` opens the TUI. `kiln status` prints the board. # This dispatches to the per-platform scripts in scripts/unix/. # It deliberately holds no launch logic of its own -- those # scripts stay the single source of truth. # ============================================================ set -uo pipefail # A ~/.local/bin/kiln symlink has to resolve to this checkout, not # to ~/.local/bin. macOS readlink has no -f on older releases, so # walk the chain. Relative targets are joined to the link's directory. _kiln_src="${BASH_SOURCE[0]}" _kiln_n=0 while [ -L "$_kiln_src" ]; do _kiln_n=$((_kiln_n + 1)) if [ "$_kiln_n" -gt 20 ]; then echo "kiln: too many symlinks resolving ${_kiln_src}" >&2 exit 1 fi _kiln_dir="$(cd -P "$(dirname "$_kiln_src")" && pwd -P)" || exit 1 _kiln_next="$(readlink "$_kiln_src")" || exit 1 case "$_kiln_next" in /*) _kiln_src="$_kiln_next" ;; *) _kiln_src="${_kiln_dir}/${_kiln_next}" ;; esac done ROOT="$(cd -P "$(dirname "$_kiln_src")" && pwd -P)" || { echo "kiln: cannot find the repo from ${_kiln_src}" >&2 exit 1 } unset _kiln_src _kiln_dir _kiln_next _kiln_n UNIX="$ROOT/scripts/unix" cd "$ROOT" # ---- platform ---------------------------------------------- case "$(uname -s)" in Darwin) PLATFORM=mac ;; Linux) # Fedora/RHEL need dnf and a different CUDA package path, # which install_fedora.sh handles. if [ -r /etc/os-release ] && grep -qiE '^(ID|ID_LIKE)=.*(fedora|rhel|centos)' /etc/os-release; then PLATFORM=fedora else PLATFORM=linux fi ;; *) echo "kiln.sh supports macOS and Linux. On Windows use: kiln" >&2 exit 1 ;; esac INSTALL="$UNIX/install_${PLATFORM}.sh" START="$UNIX/start_${PLATFORM}.sh" have() { command -v "$1" >/dev/null 2>&1; } # The port the start script serves on. kiln's server and tunnel are # found by it, so the status board and kiln stop agree on which # processes are kiln's. server_port() { local port port="$(sed -n 's/^PORT=\([0-9][0-9]*\)$/\1/p' "$START" 2>/dev/null | head -1)" echo "${port:-8080}" } # hardware.py sizes every launch and holds the model registry. # Empty when Python is absent; every caller below degrades rather # than failing, so kiln still works on a machine that only has # weights already on disk. lib_python.sh rejects the Windows # Store stub, which is on PATH as "python3" but runs nothing. . "$UNIX/lib_python.sh" PY="$KILN_PY" # shellcheck source=scripts/unix/lib_server.sh . "$UNIX/lib_server.sh" # Every family the registry knows. Qwen3.8 is what kiln picks for # you; Gemma 4 runs only when named. VARIANTS="base, ablit, 9b, 4b, gemma-31b, gemma-26b, gemma-12b, gemma-e4b" # A family id hardware.py knows. The Qwen ids pass without Python # too, so lib_variants.sh can say what is missing. is_variant() { case "$1" in ""|-*) return 1 ;; base|ablit|9b|4b) return 0 ;; esac [ -n "$PY" ] && "$PY" "$ROOT/scripts/hardware.py" --variant "$1" >/dev/null 2>&1 } # ---- status board ------------------------------------------ status() { local bin="./llama-bin/llama-server" bin_help="" printf '\n' sed 's/^/ /' "$ROOT/assets/banner.txt" 2>/dev/null || true printf '\n' printf ' %s\n' '------------------------------------------------------------' # kiln itself: the version, and whether GitHub has a newer one. # selfupdate.py asks at most once a day and gives up after a few # seconds offline, so this is free on almost every render. local kv="" ku="" if [ -n "$PY" ]; then while IFS='=' read -r k v; do case "$k" in KILN_VERSION) kv="$v" ;; KILN_UPDATE) ku="$v" ;; esac done < <("$PY" "$ROOT/scripts/selfupdate.py" --notice 2>/dev/null \ | kiln_strip_cr || true) fi if [ -n "$ku" ]; then printf ' %-14s %s\n' "kiln" "${kv:-unknown} $ku available ./kiln.sh self-update" else printf ' %-14s %s\n' "kiln" "${kv:-unknown}" fi local build=0 if [ -x "$bin" ]; then if [ -n "$PY" ]; then build="$("$PY" "$ROOT/scripts/hardware.py" --build "$bin" 2>/dev/null | kiln_strip_cr | sed -n 's/^BUILD=//p' | head -1)" fi if [ "${build:-0}" -gt 0 ]; then printf ' %-14s %s\n' "llama.cpp" "installed build $build" else printf ' %-14s %s\n' "llama.cpp" "installed" fi # Read whole, not piped into grep -q, which quits at the first # match and fails the pipeline under pipefail. See lib_select.sh. bin_help="$("$bin" --help 2>&1 || true)" case "$bin_help" in *draft-mtp*) printf ' %-14s %s\n' "MTP support" "available" ;; *) printf ' %-14s %s\n' "MTP support" "unavailable build predates b9180" ;; esac else printf ' %-14s %s\n' "llama.cpp" "not installed ./kiln.sh setup" fi if have cloudflared; then printf ' %-14s %s\n' "cloudflared" "installed" else printf ' %-14s %s\n' "cloudflared" "not installed" fi if have nvidia-smi; then printf ' %-14s %s\n' "GPU" \ "$(nvidia-smi "--query-gpu=name,memory.total" --format=csv,noheader 2>/dev/null | head -1)" elif [ "$PLATFORM" = mac ]; then # Metal is Apple Silicon only. llama.cpp's Intel build runs on the CPU. if [ "$(sysctl -n hw.optional.arm64 2>/dev/null)" = "1" ]; then printf ' %-14s %s\n' "GPU" "$(sysctl -n machdep.cpu.brand_string 2>/dev/null) (Metal)" else printf ' %-14s %s\n' "GPU" "$(sysctl -n machdep.cpu.brand_string 2>/dev/null) (CPU only)" fi fi # What this card can actually run -- answers "will it run on my # GPU" on the board itself, rather than sending the reader to a # table to match their card by name. local tier="" rq="" rf="" rc=0 if [ -n "$PY" ]; then while IFS='=' read -r k v; do case "$k" in TIER_LABEL) tier="$v" ;; REC_FAMILY) rf="$v" ;; REC_QUANT) rq="$v" ;; REC_CTX) rc="$v" ;; esac done < <("$PY" "$ROOT/scripts/hardware.py" --recommend 2>/dev/null \ | kiln_strip_cr || true) fi if [ -n "$tier" ] && [ -n "$rq" ]; then printf ' %-14s %s\n' "profile" "$tier runs $rf $rq at $((rc / 1024))K ctx" elif [ -n "$tier" ]; then printf ' %-14s %s\n' "profile" "$tier nothing here fits" fi printf ' %s\n' '------------------------------------------------------------' local found=0 for f in ./models/Qwen3.8-27B-*.gguf; do [ -e "$f" ] || continue printf ' %-14s %s\n' "Qwen3.8-27B" "$(basename "$f")"; found=1 done for f in ./models/Huihui-Qwen3.8-27B-abliterated-*.gguf; do [ -e "$f" ] || continue printf ' %-14s %s\n' " abliterated" "$(basename "$f")"; found=1 done for f in ./models/Qwen3.8-9B-*.gguf; do [ -e "$f" ] || continue printf ' %-14s %s\n' " 9B distill" "$(basename "$f")"; found=1 done for f in ./models/Qwen3.8-4B-*.gguf; do [ -e "$f" ] || continue printf ' %-14s %s\n' " 4B distill" "$(basename "$f")"; found=1 done # Gemma 4 is opt-in, so it has rows only once something is on # disk. Its MTP heads and projectors are named mtp-gemma-4-* and # mmproj-gemma-4-*, so this glob only finds weights. local gemma=0 for f in ./models/gemma-4-*.gguf; do [ -e "$f" ] || continue printf ' %-14s %s\n' "Gemma 4" "$(basename "$f")"; found=1; gemma=1 done [ "$found" = 0 ] && printf ' %-14s %s\n' "weights" "none ./kiln.sh get" printf ' %s\n' '------------------------------------------------------------' local port running port="$(server_port)" running="$(kiln_server_pids "$port")" if [ -n "$running" ]; then printf ' %-14s %s\n' "server" "running" else printf ' %-14s %s\n' "server" "stopped" fi if [ -f ./api_key.txt ]; then local k; k="$(tr -d '\r\n' < ./api_key.txt)" printf ' %-14s %s...%s ./kiln.sh key show\n' "API key" \ "${k:0:6}" "${k: -4}" else printf ' %-14s %s\n' "API key" "not generated yet" fi printf ' %s\n\n' '------------------------------------------------------------' # The first unmet dependency, so following it repeatedly walks # an empty checkout all the way to a served model. local gmin=0 if [ "$gemma" = 1 ] && [ -n "$PY" ]; then gmin="$("$PY" "$ROOT/scripts/hardware.py" --variant gemma-12b 2>/dev/null | kiln_strip_cr | sed -n 's/^V_MIN_BUILD=//p' | head -1)" fi if [ ! -x "$bin" ]; then echo " Next: ./kiln.sh setup install llama.cpp" elif [ "$found" = 0 ]; then echo " Next: ./kiln.sh get download the weights" elif [ "${build:-0}" -gt 0 ] && [ "${build:-0}" -lt "${gmin:-0}" ]; then echo " Next: mv llama-bin llama-bin-backup && ./kiln.sh setup" echo " Gemma 4 needs llama.cpp b${gmin} or newer; this is b${build}." elif [ -z "$running" ]; then echo " Next: ./kiln.sh start serve a model to Cursor" elif [ -f .kiln/cursor.json ]; then echo " Server is up. Cursor follows its tunnel." else echo " Next: ./kiln.sh cursor connect Cursor to the server" fi printf '\n' } # No arguments open the TUI. A missing Python, or a non-terminal, # cannot run it: say why and print the board. stdin counts too -- # `kiln &2 status return 0 fi if [ -z "$PY" ]; then echo "kiln: no Python, so the TUI cannot start. Showing the status board." >&2 status return 0 fi exec "$PY" "$ROOT/scripts/tui.py" } usage() { cat <<'USAGE' Usage: kiln COMMAND [ARGUMENT] (./kiln.sh is the same command) status print the status board: what is installed, downloaded and running setup llama.cpp, cloudflared and models/ hardware what this GPU can run, and at what context get [VARIANT|both] download weights; no argument takes the build this GPU is sized for start [VARIANT] [--no-mtp] serve a model to Cursor; --no-mtp trades the MTP draft head for a larger context window stop stop llama-server and cloudflared key [show|rotate|set KEY] the API key Cursor needs; set KEY uses yours instead cursor point Cursor at the model and keep it there across restarts (--off to undo, --status, --print, --url URL for a tunnel of your own) opencode list the local models in OpenCode on this machine (--print to preview, --remove to undo, --url URL to pin the server address) pi list the local models in pi on this machine (the same --print, --remove and --url) self-update update kiln itself to the newest release (--main to follow every change on main, --check to only report what is available) tui full-screen app, with an interactive benchmark (the default: kiln with no command does this) bench speed benchmarks quality KL-divergence of each quant vs Q8_0 clean reclaim space from superseded GGUFs path [--remove] put kiln on PATH, so it runs from any directory (setup does this; --remove undoes it) help this text With no command, kiln opens the TUI. kiln status prints the status board. If the TUI cannot start (no Python, or not a terminal), kiln prints a short reason and then the status board. Variants: base ablit 9b 4b Qwen3.8: 27B, 27B abliterated, 9B and 4B distills. kiln picks from these for you. gemma-31b gemma-26b Gemma 4: 31B dense, 26B-A4B MoE, 12B and gemma-12b gemma-e4b E4B. Only when named. Needs llama.cpp b11059+. USAGE } CMD="${1:-}" ARG="${2:-}" case "$CMD" in "") open_tui ;; status) status ;; setup) exec "$INSTALL" ;; get) case "$ARG" in both) # Stock first: it brings down the MTP head and the # vision projector, which the abliterated run then # finds present and skips. VARIANT=base "$UNIX/download.sh" || exit $? VARIANT=ablit exec "$UNIX/download.sh" ;; "") exec "$UNIX/download.sh" ;; *) if is_variant "$ARG"; then exec env VARIANT="$ARG" "$UNIX/download.sh" fi echo "kiln: unknown variant '$ARG' (use $VARIANTS or both)" >&2 exit 1 ;; esac ;; start) # Either order, as start.bat takes them: start ablit --no-mtp. # The first variant wins, as it does there. shift WANT="" for a in "$@"; do if [ "$a" = --no-mtp ]; then export KILN_NO_MTP=1 elif is_variant "$a"; then [ -n "$WANT" ] || WANT="$a" else echo "kiln: unknown start option '$a' (use $VARIANTS or --no-mtp)" >&2 exit 1 fi done if [ -n "$WANT" ]; then exec env VARIANT="$WANT" "$START"; fi exec "$START" ;; stop) # Only kiln's own server and tunnel, on the port the start script # serves. Another llama-server, a named tunnel or T3 Code's remote # access are left running. port="$(server_port)" kiln_stop_server "$port" && echo " stopped llama-server" || echo " llama-server was not running" # shellcheck source=scripts/unix/lib_tunnel.sh . "$UNIX/lib_tunnel.sh" kiln_stop_tunnel "$port" && echo " stopped cloudflared" || true # With no tunnel, Cursor's OpenAI override points nowhere: off # until the next start, if `kiln cursor` set it up. KILN_PY="$PY" kiln_cursor_unfollow ;; key) case "$ARG" in set) # Your own key in place of the generated one, as kiln.bat does. # llama-server compares it verbatim, so any single line will do. # An empty value is no key at all, and a line break is cut off # when the file is read back, so neither is accepted. VALUE="${3:-}" case "$VALUE" in "") echo "usage: kiln key set KEY" >&2 exit 1 ;; *$'\n'*|*$'\r'*) echo "usage: kiln key set KEY (one line, no line breaks)" >&2 exit 1 ;; esac # Written to a new file and renamed over the old one: writing # into an existing file keeps its mode, which may be # world-readable, and the new key would show through until a # chmod caught up. if ! ( umask 077; printf '%s' "$VALUE" > ./api_key.txt.tmp ) || ! mv -f ./api_key.txt.tmp ./api_key.txt; then rm -f ./api_key.txt.tmp echo "kiln: could not write api_key.txt" >&2 exit 1 fi echo " key set" echo " Restart the server to load it." ;; ""|show|rotate) [ "$ARG" = rotate ] && rm -f ./api_key.txt # shellcheck source=scripts/unix/lib_api_key.sh . "$UNIX/lib_api_key.sh" echo echo " API key -- paste into Cursor's OpenAI API Key field" echo echo " $API_KEY" echo echo " Stored in api_key.txt, readable only by you." echo ;; *) echo "usage: kiln key [show|rotate|set KEY]" >&2 exit 1 ;; esac ;; hardware|hw) # Its own command rather than more rows on the board: this is # the answer to "will it run on my GPU", and it is meant to be # pasteable into a hardware report. if [ -z "$PY" ]; then echo "kiln: needs python3 to size a launch." >&2 exit 1 fi echo echo " This machine" echo " ------------------------------------------------------------" "$PY" "$ROOT/scripts/hardware.py" --detect | kiln_strip_cr | while IFS='=' read -r k v; do case "$k" in GPU_NAME) [ -n "$v" ] && printf ' %-11s %s\n' "GPU" "$v" ;; VRAM_MIB) printf ' %-11s %s MiB\n' "VRAM" "$v" ;; CORES) printf ' %-11s %s physical\n' "cores" "$v" ;; RAM_MIB) printf ' %-11s %s MiB\n' "RAM" "$v" ;; TIER_LABEL) printf ' %-11s %s\n' "profile" "$v" ;; esac done echo " ------------------------------------------------------------" echo echo " What it should run" echo " ------------------------------------------------------------" "$PY" "$ROOT/scripts/hardware.py" --recommend | kiln_strip_cr | while IFS='=' read -r k v; do case "$k" in REC_LABEL) printf ' %-11s %s\n' "model" "$v" ;; REC_QUANT) printf ' %-11s %s\n' "quant" "$v" ;; REC_CTX) printf ' %-11s %s tokens\n' "context" "$v" ;; REC_MTP) printf ' %-11s %s\n' "MTP head" "${v:-off}" ;; REC_MMPROJ) printf ' %-11s %s\n' "vision" "${v:-off}" ;; REC_TOTAL_GB) printf ' %-11s ~%s GB\n' "download" "$v" ;; REC_THIRD_PARTY) [ "$v" = 1 ] && printf ' %-11s %s\n' "note" \ "third-party distill, not a Qwen release" ;; REC_BELOW_MIN) [ "$v" = 1 ] && printf ' %-11s %s\n' "note" \ "below the context kiln aims for; it will still load" ;; REC_BELOW_QUALITY) [ "$v" = 1 ] && printf ' %-11s %s\n' "note" \ "a low-bit quant, below the quality floor; expect weaker output" ;; REC_REASON) printf ' %-11s %s\n' "no fit" "$v" ;; esac done echo " ------------------------------------------------------------" echo echo " ./kiln.sh get downloads exactly this" echo " Set KILN_MIN_CTX to trade context against quant quality." echo ;; cursor) if [ -z "$PY" ]; then echo "kiln: needs python3 to edit Cursor's settings." >&2 exit 1 fi # The key to paste into Cursor has to exist first. # shellcheck source=scripts/unix/lib_api_key.sh . "$UNIX/lib_api_key.sh" shift exec "$PY" "$ROOT/scripts/cursor.py" "$@" ;; opencode) if [ -z "$PY" ]; then echo "kiln: needs python3 to write the OpenCode config." >&2 exit 1 fi # The config references api_key.txt rather than copying it, # and OpenCode will not start if that file is missing. # shellcheck source=scripts/unix/lib_api_key.sh . "$UNIX/lib_api_key.sh" shift exec "$PY" "$ROOT/scripts/opencode.py" "$@" ;; pi) if [ -z "$PY" ]; then echo "kiln: needs python3 to write pi's models.json." >&2 exit 1 fi # pi reads api_key.txt on its first request to the server, so # make sure there is one to read. # shellcheck source=scripts/unix/lib_api_key.sh . "$UNIX/lib_api_key.sh" shift exec "$PY" "$ROOT/scripts/pi_agent.py" "$@" ;; self-update) if [ -z "$PY" ]; then echo "kiln: needs python3 to update itself. Or by hand: git pull" >&2 exit 1 fi # exec, so bash is not left reading a kiln.sh the update has # just replaced. shift exec "$PY" "$ROOT/scripts/selfupdate.py" "$@" ;; tui) if [ -z "$PY" ]; then echo "kiln: needs python3 for the TUI." >&2 exit 1 fi shift exec "$PY" "$ROOT/scripts/tui.py" "$@" ;; bench) exec "$UNIX/benchmark.sh" ;; quality) exec "$UNIX/quality.sh" ;; clean) exec "$UNIX/cleanup.sh" ;; path) shift; exec "$UNIX/kiln_path.sh" "$@" ;; help|-h|--help) usage ;; *) echo "kiln: unknown command '$CMD'" >&2; usage; exit 1 ;; esac