#!/bin/sh
# Copyright (c) 2026 Jiaxing Hu <gahing@gahingwoo.com>
# SPDX-License-Identifier: GPL-2.0
#
# charsiu-get: get a model charsiu can actually run, from Hugging Face or a URL.
#
#   charsiu-get                       the zoo: models verified to load
#   charsiu-get llama3.2-1b-q4        fetch one of them
#   charsiu-get bartowski/Foo-GGUF    list a repo's gguf files and their sizes
#   charsiu-get bartowski/Foo-GGUF F  fetch that exact file
#   charsiu-get https://host/m.gguf   fetch a URL
#   charsiu-get --check FILE          just gate a local file
#
# WHY THIS IS NOT "curl the file and be done".
#
# charsiu reads six ggml types (f32 f16 q4_0 q4_1 q8_0 q6_K) and seven
# architectures (llama qwen2 qwen3 gemma3 gemma4 phi3 smollm3). Hugging Face is
# full of files whose NAMES pass a substring test and whose CONTENTS do not:
#
#   ...-Q4_0_4_4.gguf   measured: type 31 in 210 tensors. A Q4_0 REPACKED for
#                       ARM dotprod: same block size, interleaved weights.
#   ...-Q4_K_M.gguf     measured: type 6 (q5_0) in 166 tensors, among others.
#   gemma-2-*.gguf      measured: architecture gemma2, and it declares
#                       attn_logit_softcapping 50.0 and a 4096 sliding window,
#                       which are behaviours and not weights.
#
# So every download is verified by charsiu_check, which uses the RUNTIME's own
# gguf parser, so the gate cannot drift from what will actually load. A file that
# fails is DELETED, never left where the runtime would find it.
#
# And a name that looks unsupported may be fine: ...-Q6_K_L.gguf MEASURED as
# runnable, because every type in that particular file is one charsiu reads.
# Only the contents decide. That is the whole point.
#
# Env: CHARSIU_MODELS_DIR (default /opt/charsiu/models), CHARSIU_CHECK (path to
#      charsiu_check), HF_ENDPOINT (default https://huggingface.co).
set -eu
CHARSIU_HERE=$(dirname "$0")
for _l in ${CHARSIU_LIB:+"$CHARSIU_LIB/charsiu-lib.sh"} \
	  "$CHARSIU_HERE/charsiu-lib.sh" \
	  "$CHARSIU_HERE/../scripts/charsiu-lib.sh" \
	  "$CHARSIU_HERE/../../opt/charsiu/charsiu-lib.sh" \
	  /opt/charsiu/charsiu-lib.sh; do
	[ -r "$_l" ] && { . "$_l"; break; }
done
command -v ini_get >/dev/null 2>&1 \
	|| { echo "$(basename "$0"): charsiu-lib.sh not found" >&2; exit 1; }

MODELS_DIR="${CHARSIU_MODELS_DIR:-/opt/charsiu/models}"
HF="${HF_ENDPOINT:-https://huggingface.co}"

if [ -t 1 ]; then
	C_C=$(printf '\033[1;36m'); C_R=$(printf '\033[1;31m')
	C_Y=$(printf '\033[33m');   C_G=$(printf '\033[1;32m')
	C_D=$(printf '\033[2m');    C_0=$(printf '\033[0m')
else C_C=; C_R=; C_Y=; C_G=; C_D=; C_0=; fi
# The TUI layer has to be findable from every layout this ships in: a source
# tree, a real install under /opt/charsiu, and a staged --prefix install where
# /opt is not at the root. CHARSIU_LIB names it outright; the rest are guesses
# in the order they are likely to be right.
for t in ${CHARSIU_LIB:+"$CHARSIU_LIB/charsiu-tui.sh"} \
	 "$(dirname "$0")/charsiu-tui.sh" \
	 "$(dirname "$0")/../scripts/charsiu-tui.sh" \
	 "$(dirname "$0")/../opt/charsiu/charsiu-tui.sh" \
	 /opt/charsiu/charsiu-tui.sh; do
	[ -r "$t" ] && { . "$t"; break; }
done

say()  { printf '%s[charsiu-get]%s %s\n' "$C_C" "$C_0" "$*"; }
warn() { printf '%s[charsiu-get] WARN:%s %s\n' "$C_Y" "$C_0" "$*" >&2; }
die()  { printf '%s[charsiu-get] ERROR:%s %s\n' "$C_R" "$C_0" "$*" >&2; exit 1; }

# ---------------------------------------------------------------------------
# THE ZOO. Every row was verified against the live repo on 2026-08-25: the size
# comes from the HF tree API and general.architecture was read out of the file's
# own header over a range request. Nothing here is guessed.
#
#   name | repo | file | MB | note | mmproj file | mmproj MB
#
# THE LAST TWO FIELDS ARE THE VISION HALF, and a row that has them is a model
# that SEES. A multimodal download is two files and one without the other is
# not a smaller version of it, it is a model that cannot do the thing it was
# chosen for -- so pulling one pulls both, and the size shown is the total.
#
# The q8_0 mmproj, not the f16: 104 MB against 190, and verified to give the
# same answer on the same picture ("Llama." for llama.cpp's logo, 2026-08-28).
# ---------------------------------------------------------------------------
zoo() {
cat <<'ZOO'
smollm2-135m-q4|bartowski/SmolLM2-135M-Instruct-GGUF|SmolLM2-135M-Instruct-Q4_0.gguf|92|smallest thing that talks; good for a first run
smollm2-135m-q8|bartowski/SmolLM2-135M-Instruct-GGUF|SmolLM2-135M-Instruct-Q8_0.gguf|145|same, at eight bits
smollm2-360m-q4|bartowski/SmolLM2-360M-Instruct-GGUF|SmolLM2-360M-Instruct-Q4_0.gguf|230|
smollm2-1.7b-q4|bartowski/SmolLM2-1.7B-Instruct-GGUF|SmolLM2-1.7B-Instruct-Q4_0.gguf|994|
tinyllama-1.1b-q4|TheBloke/TinyLlama-1.1B-Chat-v1.0-GGUF|tinyllama-1.1b-chat-v1.0.Q4_0.gguf|638|
llama3.2-1b-q4|bartowski/Llama-3.2-1B-Instruct-GGUF|Llama-3.2-1B-Instruct-Q4_0.gguf|773|
llama3.2-1b-q8|bartowski/Llama-3.2-1B-Instruct-GGUF|Llama-3.2-1B-Instruct-Q8_0.gguf|1321|THE ONE EVERY BOARD ROUND USES
llama3.2-1b-q4_1|unsloth/Llama-3.2-1B-Instruct-GGUF|Llama-3.2-1B-Instruct-Q4_1.gguf|832|
llama3.2-3b-q4|bartowski/Llama-3.2-3B-Instruct-GGUF|Llama-3.2-3B-Instruct-Q4_0.gguf|1922|check the card has room
qwen2.5-1.5b-q4|bartowski/Qwen2.5-1.5B-Instruct-GGUF|Qwen2.5-1.5B-Instruct-Q4_0.gguf|894|answers better than the 1B llamas
qwen2.5-coder-1.5b-q4|bartowski/Qwen2.5-Coder-1.5B-Instruct-GGUF|Qwen2.5-Coder-1.5B-Instruct-Q4_0.gguf|894|the same size, tuned for code
phi3.5-mini-q4|bartowski/Phi-3.5-mini-instruct-GGUF|Phi-3.5-mini-instruct-Q4_0.gguf|2114|3.8B; check the card has room
smollm3-3b-q4|unsloth/SmolLM3-3B-GGUF|SmolLM3-3B-Q4_0.gguf|1727|3B, reasoning, 65K context
qwen3-0.6b-q4|ggml-org/Qwen3-0.6B-GGUF|Qwen3-0.6B-Q4_0.gguf|409|0.6B that reasons in <think>; smallest of the new ones
qwen3-1.7b-q4|unsloth/Qwen3-1.7B-GGUF|Qwen3-1.7B-Q4_0.gguf|1008|the sweet spot on this board
qwen3-4b-q4|unsloth/Qwen3-4B-Instruct-2507-GGUF|Qwen3-4B-Instruct-2507-Q4_0.gguf|2266|4B, answers straight out with no <think>; check the card has room
gemma3-270m-q8|unsloth/gemma-3-270m-it-GGUF|gemma-3-270m-it-Q8_0.gguf|278|tiny, and it still answers
gemma3-1b-q4|unsloth/gemma-3-1b-it-GGUF|gemma-3-1b-it-Q4_0.gguf|689|32K context on a 512 sliding window
gemma3-4b-q4|unsloth/gemma-3-4b-it-GGUF|gemma-3-4b-it-Q4_0.gguf|2260|check the card has room; the text half only
gemma4-e2b-q4|ggml-org/gemma-4-E2B-it-GGUF|gemma-4-E2B-it-Q4_0.gguf|2710|per-layer embeddings; 2B of a 4.6B model is active
smolvlm-256m|ggml-org/SmolVLM-256M-Instruct-GGUF|SmolVLM-256M-Instruct-Q8_0.gguf|175|SEES PICTURES: pass --image FILE. Smallest one that does|mmproj-SmolVLM-256M-Instruct-Q8_0.gguf|104
smolvlm-500m|ggml-org/SmolVLM-500M-Instruct-GGUF|SmolVLM-500M-Instruct-Q8_0.gguf|437|SEES PICTURES, and describes them better than the 256M|mmproj-SmolVLM-500M-Instruct-Q8_0.gguf|109
clip-b32|mys/ggml_clip-vit-base-patch32|clip-vit-base-patch32_ggml-model-q4_0.gguf|90|MATCHES pictures to words -- charsiu_clip, not a chat model
whisper-tiny.en|ggerganov/whisper.cpp|ggml-tiny.en.bin|78|HEARS: charsiu_whisper --transcribe. English only
whisper-base.en|ggerganov/whisper.cpp|ggml-base.en.bin|148|HEARS, and gets more of it right than tiny
ZOO
}

# Families that are NOT runnable, listed on purpose so the answer is instant
# instead of arriving after a gigabyte. Architecture read from the file header.
known_bad() {
cat <<'BAD'
gemma2|gemma2|measured: gemma-2-2b-it softcaps the attention scores, not just the logits
gemma4-moe|gemma4|the 26B-A4B is a mixture of experts; E2B and E4B are dense and do run
BAD
}

find_check() {
	if [ -n "${CHARSIU_CHECK:-}" ]; then echo "$CHARSIU_CHECK"; return 0; fi
	find_bin charsiu_check
}

fetcher() {
	command -v curl >/dev/null 2>&1 && { echo curl; return; }
	command -v wget >/dev/null 2>&1 && { echo wget; return; }
	echo ""
}

# GET url outfile. Resumable, because a gigabyte over a board's link is not
# a single uninterrupted event.
get_url() {
	# a progress bar on a terminal, silence in a log. curl's default meter
	# redraws with \r and a captured log turns into one unreadable line.
	if [ -t 2 ]; then P="--progress-bar"; else P="-sS"; fi
	case "$(fetcher)" in
	curl) curl -fL $P --retry 3 --connect-timeout 15 -C - -o "$2" "$1" ;;
	wget) if [ -t 2 ]; then wget -c -O "$2" "$1"; else wget -q -c -O "$2" "$1"; fi ;;
	*)    die "neither curl nor wget is installed" ;;
	esac
}

api() {
	case "$(fetcher)" in
	curl) curl -fsL --connect-timeout 15 "$1" ;;
	wget) wget -qO- "$1" ;;
	*)    die "neither curl nor wget is installed" ;;
	esac
}

free_mb() { df -Pm "$1" 2>/dev/null | awk 'NR==2 {print $4}'; }

list_zoo() {
	printf '\n%sModels verified to load on charsiu%s   (into %s)\n\n' \
		"$C_G" "$C_0" "$MODELS_DIR"
	printf '  %-22s %7s  %s\n' NAME SIZE WHERE
	zoo | while IFS='|' read -r n repo file mb note mmp mmb; do
		[ -n "$mmp" ] && mb=$((mb + mmb))
		have=""
		[ -f "$MODELS_DIR/$file" ] && have=" ${C_G}[have]${C_0}"
		printf '  %-22s %6sM  %s%s\n' "$n" "$mb" "$repo" "$have"
		[ -n "$note" ] && printf '  %-19s %7s  %s%s%s\n' "" "" "$C_D" "$note" "$C_0"
	done
	printf '\n%sNot runnable, whatever the file says%s\n' "$C_Y" "$C_0"
	known_bad | while IFS='|' read -r fam arch why; do
		printf '  %-19s %7s  %s\n' "$fam" "" "$why"
	done
	printf '\n  charsiu-get <name>              fetch one\n'
	printf '  charsiu-get <user/repo>         list any repo instead\n'
	printf '  charsiu-get --check FILE        gate a file you already have\n\n'
}

# list the gguf files in an arbitrary repo
list_repo() {
	say "listing $1"
	api "$HF/api/models/$1/tree/main" | tr ',' '\n' | grep -o '"path":"[^"]*\.gguf"' \
		| sed 's/.*:"//;s/"$//' | sort | while read -r f; do
		printf '  %s\n' "$f"
	done
	printf '\n  %sSizes and types are not shown because only the file decides.%s\n' "$C_D" "$C_0"
	printf '  charsiu-get %s <file>   downloads, gates, and keeps it only if it runs.\n\n' "$1"
}

# fetch URL into MODELS_DIR under BASENAME, gate it, keep it only if it passes
# DOWNLOADING A MODEL AND NOT SAYING SO IN THE CONFIG LEAVES A WORKING
# INSTALL THAT CANNOT RUN. The shipped config names a 1B Llama as a placeholder;
# a first run that fetched the 135M model still died on the Llama path, because
# nothing connected the two. Point the config at what was fetched when what it
# names is not here. An existing, present choice is left alone: someone who set
# a model meant it, and a second download should not silently switch them.
claim_config() {
	got="$1"
	cur=$(current_model)
	if [ -n "$cur" ] && [ -f "$cur" ]; then
		return 0
	fi
	conf=$(charsiu_conf_w)
	[ -n "$conf" ] || { say "no config to record ${got##*/} in"; return 0; }
	if [ ! -f "$conf" ]; then
		printf '[model]\npath = %s\n' "$got" > "$conf" 2>/dev/null \
			&& say "config now runs ${got##*/}" \
			|| say "could not write $conf; charsiu will still find ${got##*/}"
		return 0
	fi

	t="$conf.tmp.$$"
	# Writing to /etc needs root, and nobody should need root to download a
	# file. Failing here is not fatal: the runner falls back to the only
	# model on disk, so say what happened and carry on.
	if awk -v v="$got" '
		/^[ \t]*\[.*\][ \t]*$/ { h=$0; sub(/^[ \t]*\[/,"",h); sub(/\][ \t]*$/,"",h); t=(h=="model"); print; next }
		t && !done && match($0,/^[ \t]*path[ \t]*=/) { print "path = " v; done=1; next }
		{ print }
	' "$conf" > "$t" 2>/dev/null && mv "$t" "$conf" 2>/dev/null; then
		say "config now runs ${got##*/}"
	else
		rm -f "$t"
		say "could not write $conf; charsiu will still find ${got##*/}"
	fi
}

fetch_and_gate() {
	url="$1"; base="$2"; want_mb="${3:-0}"
	CK=$(find_check)
	[ -z "$CK" ] && die "charsiu_check not found; build it (make charsiu_check) or set CHARSIU_CHECK"

	mkdir -p "$MODELS_DIR" || die "cannot create $MODELS_DIR"
	out="$MODELS_DIR/$base"
	part="$out.part"

	# EXIT 3 IS "RUNNABLE, BUT NOT A CHAT MODEL", and both ways of not
	# handling it are bad and opposite. A whisper model is not a gguf at
	# all, so the gate called a finished 78 MB download unreadable and
	# DELETED it. A vision tower IS a gguf, so it passed -- and then
	# claim_config pointed the front door at a file it cannot talk to.
	# Accept both, claim the config for neither.
	#
	# THIS PATH HAS TO CLAIM THE CONFIG TOO. Asking for a model you
	# already have is the most ordinary way to say "run this one", and
	# returning early left the config pointing at the placeholder.
	if [ -f "$out" ]; then
		say "already here: $out"
		"$CK" -q "$out" >/dev/null 2>&1 || _rc=$?
		_rc=${_rc:-0}
		if [ "$_rc" = 0 ]; then
			claim_config "$out"
			return 0
		fi
		if [ "$_rc" = 3 ]; then
			return 0
		fi
		unset _rc
		warn "...but it does not pass the gate; re-fetching"
		rm -f "$out"
	fi

	if [ "$want_mb" -gt 0 ]; then
		fm=$(free_mb "$MODELS_DIR")
		if [ -n "$fm" ] && [ "$fm" -lt $((want_mb + 128)) ]; then
			die "$MODELS_DIR has ${fm}M free and this needs about ${want_mb}M"
		fi
	fi

	# A 1.3 GB DOWNLOAD WITH NO PROGRESS LOOKS LIKE A HANG. curl draws its
	# own bar on a terminal, but a whiptail install has just cleared the
	# screen around it. When the size is known, drive a real gauge off the
	# bytes on disk, which also survives a resume.
	_dlfail=0
	if [ "$want_mb" -gt 0 ] && [ "${CTUI:-plain}" = whiptail ]; then
		get_url "$url" "$part" >/dev/null 2>&1 &
		_gpid=$!
		{
			while kill -0 "$_gpid" 2>/dev/null; do
				# file_bytes, NOT wc -c, and the reason is worse than
				# tidiness: busybox wc READS THE WHOLE FILE, so this loop
				# re-read a growing multi-gigabyte download once a second
				# to draw its own progress bar. It also handles a missing
				# file quietly -- `wc -c < missing 2>/dev/null` does not,
				# because the redirection is the shell's and it reports the
				# failure on its own stderr before wc ever runs, which
				# printed an error straight over the gauge for the first
				# seconds of every download.
				_sz=$(file_bytes "$part")
				_p=$(( _sz / 1048576 * 100 / want_mb ))
				[ "$_p" -gt 99 ] && _p=99
				echo "$_p"
				sleep 1
			done
			echo 100
		} | ui_progress "fetching $base
about ${want_mb} MB"
		wait "$_gpid" || _dlfail=1
	else
		say "fetching $base"
		get_url "$url" "$part" || _dlfail=1
	fi
	if [ "$_dlfail" = 1 ]; then
		got=$(file_bytes "$part")
		die "the download stopped after $((got / 1048576)) MB of about ${want_mb} MB.

  IT DOES NOT START OVER. The part that arrived is kept in
    $part
  and running the SAME command again picks up where it stopped
  (curl -C - / wget -c). On a board's link that is normal, not a fault."
	fi

	say "checking what actually came down"
	"$CK" "$part" || _grc=$?
	_grc=${_grc:-0}
	if [ "$_grc" = 0 ]; then
		mv "$part" "$out"
		say "${C_G}kept${C_0} $out"
		claim_config "$out"
		unset _grc
		return 0
	fi
	if [ "$_grc" = 3 ]; then
		mv "$part" "$out"
		say "${C_G}kept${C_0} $out"
		say "  ...and the config still points at the chat model: this is"
		say "  not one, and charsiu_whisper or charsiu_clip runs it."
		unset _grc
		return 0
	fi
	unset _grc
	rm -f "$part"
	die "charsiu cannot run that file, so it was deleted rather than left in $MODELS_DIR"
}

# The first-run picker. A menu of the zoo with sizes, so the first thing a new
# board asks for is a choice and not a filename.
#
# It offers to SKIP. A 1.3 GB download over a board's link is not something to
# start by accident, and an install with no model is still a working install.
wizard() {
	command -v ui_menu >/dev/null 2>&1 || { list_zoo; return 0; }
	CTUI_TITLE="charsiu: a model"
	set --
	while IFS='|' read -r n repo file mb note mmp mmb; do
		[ -n "$n" ] || continue
		[ -n "$mmp" ] && mb=$((mb + mmb))
		if [ -f "$MODELS_DIR/$file" ]; then d="${mb}M  (already here)"
		else d="${mb}M  ${note}"; fi
		set -- "$@" "$n" "$d"
	done <<ZOOEOF
$(zoo)
ZOOEOF
	set -- "$@" "skip" "decide later; charsiu-get lists them all"
	sel=$(ui_menu "Which model?

charsiu re-quantises whatever it loads, so a smaller file is a faster
first run rather than a worse one. The 135M model talks in 92 MB.
The 1B one is what every board measurement uses." "$@") || return 0
	[ "$sel" = skip ] || [ -z "$sel" ] && return 0
	row=$(zoo | grep "^$sel|")
	pull_row "$row" || return 1
	return 0
}

# A ROW IS PULLED WHOLE. A model that sees is two files, and fetching the
# language half alone leaves somebody with a model that answers every question
# about a picture by inventing one -- fluently. So the vision half comes with
# it, and the message says which is which.
pull_row() {
	_row=$1
	_repo=$(echo "$_row" | cut -d'|' -f2)
	_file=$(echo "$_row" | cut -d'|' -f3)
	_mb=$(echo "$_row"   | cut -d'|' -f4)
	_mmp=$(echo "$_row"  | cut -d'|' -f6)
	_mmb=$(echo "$_row"  | cut -d'|' -f7)

	fetch_and_gate "$HF/$_repo/resolve/main/$_file" "$_file" "$_mb" || return 1
	if [ -z "$_mmp" ]; then
		say_how "$_file"
		return 0
	fi
	echo
	echo "  and its vision tower, which is the other half of this model:"
	fetch_and_gate "$HF/$_repo/resolve/main/$_mmp" "$_mmp" "$_mmb" || return 1
	echo
	echo "  Both halves are here. To ask it about a picture:"
	echo "    charsiu --image photo.jpg \"what is in this picture?\""
	return 0
}

# A MODEL THAT IS NOT A CHAT MODEL NEEDS A SENTENCE SAYING WHAT RUNS IT.
# `charsiu` cannot hold a conversation with a whisper model or a CLIP file, so
# without this the download finishes and the tool goes quiet about the one thing
# the person now needs to know.
say_how() {
	case "$1" in
	*whisper*|*ggml-*.bin)
		echo "  To transcribe a clip:"
		echo "    charsiu_whisper $MODELS_DIR/$1 --transcribe --audio clip.wav" ;;
	*clip*)
		echo "  To match a picture against words:"
		echo "    charsiu_clip $MODELS_DIR/$1 --image photo.jpg \\"
		echo "        --text \"a cat\" \"a dog\" \"a diagram\"" ;;
	esac
}

# ---------------------------------------------------------------------------
case "${1:-}" in
""|--list|list) list_zoo; exit 0 ;;
--wizard)       wizard; exit $? ;;
-h|--help)      sed -n '4,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
--check)
	[ $# -ge 2 ] || die "--check needs a file"
	CK=$(find_check); [ -z "$CK" ] && die "charsiu_check not found"
	exec "$CK" "$2" ;;
esac

ARG="$1"

# a zoo name?
row=$(zoo | grep "^$ARG|" || true)
if [ -n "$row" ]; then
	pull_row "$row"
	exit $?
fi

# a known-bad family, answered without a download
if known_bad | grep -qi "^$ARG|"; then
	why=$(known_bad | grep -i "^$ARG|" | cut -d'|' -f3)
	die "$why. charsiu only builds a llama graph, so there is nothing to download"
fi

case "$ARG" in
http://*|https://*)
	fetch_and_gate "$ARG" "$(basename "$ARG")" 0 ;;
*/*)
	if [ $# -ge 2 ]; then
		fetch_and_gate "$HF/$ARG/resolve/main/$2" "$2" 0
	else
		list_repo "$ARG"
	fi ;;
*)
	die "no zoo entry called '$ARG'. Run charsiu-get with no arguments for the list." ;;
esac
