#!/bin/sh # Installs lug from a GitHub release, then offers to write a first init.lua: # curl -fsSL https://raw.githubusercontent.com/klaatu01/lug/main/install.sh | sh # Options go after `sh -s --`; run with --help to list them. LUG_VERSION and # LUG_INSTALL_DIR are the same as --version and --dir. LUG_BASE_URL is for the # workflow's own test. With no release to download (or --source), it can clone # LUG_GIT_URL (default: over SSH, so private access works) and build with cargo. set -eu usage() { cat <<'EOF' Usage: install.sh [options] --version TAG the release to install (default: the latest) --dir DIR where lug goes (default: ~/.local/bin) --model SPEC the model, as provider/model, or none to set one later --base-url URL where the model's provider is, for one of your own, such as a proxy; it needs --wire too --wire NAME how that provider talks: anthropic, openai, openai-chat, openrouter or ollama --mode MODE the prompt's first mode: insert or normal --manager NAME the plugin manager: haul or none --base NAME the stock runtime haul installs: basic.lug or none basic.lug's look: --style NAME slug (borders, fades, a launcher bar) or plain --theme NAME auto, slug, github-dark, github-light or ansi --launcher AT where the : line goes under slug: top or bottom --border NAME rounded, square, double, thick or none --fade PERCENT how far windows away from the focus fade; 0 is off --animate WHEN whether the slug crawls while lug works: yes or no --no-config leave init.lua alone --source build from source with git and cargo, instead of a release -y, --yes ask nothing; take the defaults and the options given Without a terminal to ask on, the installer takes the defaults. It writes init.lua only when there is none, so running it again only updates lug. EOF } fail() { printf ' %s✗ %s%s\n' "${red-}" "$*" "${off-}" >&2; exit 1; } need() { [ "$1" -ge 2 ] || fail "$2 needs a value; see --help"; } version=${LUG_VERSION:-} dir=${LUG_INSTALL_DIR:-$HOME/.local/bin} model='' url='' wire='' mode='' manager='' runtime='' config=yes ask=yes source=no style='' theme='' launcher='' border='' fade='' animate='' repo=${LUG_GIT_URL:-git@github.com:klaatu01/lug.git} while [ $# -gt 0 ]; do case $1 in --version) need $# "$1"; version=$2; shift ;; --dir) need $# "$1"; dir=$2; shift ;; --model) need $# "$1"; model=$2; shift ;; --base-url) need $# "$1"; url=$2; shift ;; --wire) need $# "$1"; wire=$2; shift ;; --mode) need $# "$1"; mode=$2; shift ;; --manager) need $# "$1"; manager=$2; shift ;; --base) need $# "$1"; runtime=$2; shift ;; --style) need $# "$1"; style=$2; shift ;; --theme) need $# "$1"; theme=$2; shift ;; --launcher) need $# "$1"; launcher=$2; shift ;; --border) need $# "$1"; border=$2; shift ;; --fade) need $# "$1"; fade=$2; shift ;; --animate) need $# "$1"; animate=$2; shift ;; --no-config) config=no ;; --source) source=yes ;; -y | --yes) ask=no ;; -h | --help) usage; exit 0 ;; *) fail "unknown option $1; see --help" ;; esac shift done case $model in '' | none | */*) ;; *) fail "--model is provider/model, like anthropic/claude-opus-5, or none" ;; esac case $wire in '' | anthropic | openai | openai-chat | openrouter | ollama) ;; *) fail "--wire is anthropic, openai, openai-chat, openrouter or ollama" ;; esac case $url in '' | http://* | https://*) ;; *) fail "--base-url is an http:// or https:// URL" ;; esac if [ -n "$url$wire" ]; then [ -n "$url" ] && [ -n "$wire" ] || fail "a provider of your own needs both --base-url and --wire" case $model in */*) ;; *) fail "--base-url and --wire name the provider in --model, so they need --model NAME/MODEL" ;; esac fi case $mode in '' | insert | normal) ;; *) fail "--mode is insert or normal" ;; esac case $manager in '' | haul | none) ;; *) fail "--manager is haul or none" ;; esac case $runtime in '' | basic.lug | none) ;; *) fail "--base is basic.lug or none" ;; esac case $style in '' | slug | plain) ;; *) fail "--style is slug or plain" ;; esac case $theme in '' | auto | slug | github-dark | github-light | ansi) ;; *) fail "--theme is auto, slug, github-dark, github-light or ansi" ;; esac case $launcher in '' | top | bottom) ;; *) fail "--launcher is top or bottom" ;; esac case $border in '' | rounded | square | double | thick | none) ;; *) fail "--border is rounded, square, double, thick or none" ;; esac case $fade in '' | [0-9] | [0-9][0-9] | 100) ;; *) fail "--fade is a percentage from 0 to 100" ;; esac case $animate in '' | yes | no) ;; *) fail "--animate is yes or no" ;; esac # basic.lug comes through haul, and its look only with basic.lug. if [ "$manager" = none ]; then [ "$runtime" != basic.lug ] || fail "basic.lug is installed by haul, so it needs --manager haul" runtime=none fi if [ "$runtime" = none ] && [ -n "$style$theme$launcher$border$fade$animate" ]; then fail "--style, --theme, --launcher, --border, --fade and --animate set basic.lug's options, so they need it" fi # Drawing needs a terminal on stdout; asking needs one we can open for reading, # since stdin is the script itself when piped from curl. term=no if [ -t 1 ] && [ "${TERM:-dumb}" != dumb ]; then term=yes; fi if [ $ask = yes ] && ! (: /dev/null; then ask=no; fi esc=$(printf '\033') clear='' if [ $term = yes ]; then clear="${esc}[K"; fi if [ $term = yes ] && [ -z "${NO_COLOR:-}" ]; then bold="${esc}[1m" dim="${esc}[2m" off="${esc}[0m" accent="${esc}[38;5;113m" green="${esc}[32m" red="${esc}[31m" yellow="${esc}[33m" body="${esc}[97m" slime="${esc}[38;5;113m" else bold='' dim='' off='' accent='' green='' red='' yellow='' body='' slime='' fi tmp=$(mktemp -d) saved='' pid='' if [ $ask = yes ]; then saved=$(stty -g /dev/null || true; fi if [ -n "$saved" ]; then stty "$saved" /dev/null | cut -d' ' -f2) || cols='' cols=${cols:-80} if [ "$cols" -gt 80 ]; then cols=80; fi fi # Draws rows $1 to 8 of the slug $2 with its head at column $3 and slime # behind it. Left of column $4 the logo has taken its place; -1 is none yet. draw() { awk -v first="$1" -v slug="$2" -v x="$3" -v wipe="$4" -v cols="$cols" -v logo="$logo" \ -v body="$body" -v slime="$slime" -v off="$off" -v clear="$clear" ' function pad(n, s) { s = ""; while (n-- > 0) s = s " "; return s } function paint(s) { gsub(/\.+/, slime "&" body, s); gsub(/\./, "·", s); gsub(/#/, "█", s) return body s off } BEGIN { split(slug, a, "|"); split(logo, b, "|") for (r = first; r <= 8; r++) { s = pad(x) a[r] if (r == 8) while (length(s) < cols) s = s "." s = substr(s pad(cols), 1, cols) if (wipe < 0) { line = paint(s) } else { l = substr(pad(2) b[r] pad(cols), 1, wipe) if (wipe >= cols) sub(/ +$/, "", l) gsub(/#/, "█", l) line = body l off if (wipe < cols) line = line body "▐" off paint(substr(s, wipe + 2)) } printf "%s%s\n", line, clear } }' } # Redraws the 8 rows just drawn, then waits a frame. redraw() { printf '%s[8A' "$esc" draw 1 "$1" "$2" "$3" sleep 0.03 } # Shows the logo. intro() { draw 1 "$slug0" 0 "$cols" # Off for now: the slug crawls in from the right and a wipe turns it into the logo. # printf '%s[?25l' "$esc" # x=$((2 + (cols + 2) / 4 * 4)) # draw 1 "$front" $x -1 # while [ $x -gt 2 ]; do # x=$((x - 2)) # redraw "$back" $x -1 # x=$((x - 2)) # redraw "$far" $x -1 # redraw "$slug2" $x -1 # redraw "$front" $x -1 # done # w=0 # while [ $w -lt "$cols" ]; do # redraw "$slug0" 2 $w # w=$((w + 3)) # done # redraw "$slug0" 2 "$cols" } mb() { awk -v b="$1" 'BEGIN { printf "%.1f MB", b / 1048576 }'; } home() { case $1 in "$HOME"/*) printf '~%s' "${1#"$HOME"}" ;; *) printf '%s' "$1" ;; esac; } # Runs the rest in the background with its output in $tmp/log, showing $1 # beside a small slug crawling left and then $2 beside a tick; on failure, # the log's end. $detail, when set, names a function that sets $extra, shown # after $1, and $at, how far the slug has got out of 100; without $at it # crawls to the end of the track, turns round and crawls back, over and over. detail='' dots() { printf "%${1}s" '' | sed 's/ /⣀/g'; } task() { doing=$1 done=$2 shift 2 "$@" >"$tmp/log" 2>&1 & pid=$! if [ $term = yes ]; then # Without wrapping, a line too long for the terminal can't push the # spinner onto a second row that \r would not clear. printf '%s[?25l%s[?7l' "$esc" "$esc" i=0 while kill -0 $pid 2>/dev/null; do at='' extra='' if [ -n "$detail" ]; then $detail; fi if [ -n "$at" ]; then n=$((at * 12 / 100)); else n=$((i / 2 % 24)); fi if [ "$n" -lt 12 ] || [ -n "$at" ]; then case $((i % 4)) in 0) tip=⣦ ;; 2) tip=⣄ ;; *) tip=⣤ ;; esac track="${dim}$(dots $((12 - n)))${off}${body}⣞⣞⣶${tip}${slime}$(dots "$n")${off}" else # Mirrored, heading right. n=$((n - 12)) case $((i % 4)) in 0) tip=⣴ ;; 2) tip=⣠ ;; *) tip=⣤ ;; esac track="${slime}$(dots "$n")${body}${tip}⣶⣳⣳${dim}$(dots $((12 - n)))${off}" fi printf '\r %s %s %s\033[K' "$track" "$doing" "$extra" i=$((i + 1)) sleep 0.08 done printf '\r\033[K%s[?7h%s[?25h' "$esc" "$esc" fi if wait $pid; then status=0; else status=$?; fi pid='' if [ $status = 0 ]; then printf ' %s✓%s %s\n' "$green" "$off" "$done" else printf ' %s✗%s %s\n' "$red" "$off" "$doing" tail -n 15 "$tmp/log" | sed "s/^/ $dim/; s/\$/$off/" fi return $status } # The download's progress, for task: $out is the file curl is writing, $total its size. progress() { got=0 if [ -f "$out" ]; then got=$(($(wc -c <"$out"))); fi if [ "$total" -gt 0 ]; then pct=$((got * 100 / total)) if [ $pct -gt 100 ]; then pct=100; fi at=$pct extra="$bold$pct%$off $dim$(mb $got) of $(mb "$total")$off" else extra="$dim$(mb $got)$off" fi } # Reads a line from the terminal into $answer, asking with $1 until it # matches the pattern $2 and not $4, and saying $3 when it does not. answer() { while :; do printf ' %s›%s %s: ' "$accent" "$off" "$1" read -r answer /dev/null /dev/null /dev/null & sums=$! fi intro printf '\n %sThere are many agent harnesses but this one has a slug%s\n\n' "$dim" "$off" printf ' %sInstalling lug%s %s%s for %s%s\n\n' "$bold" "$off" "$dim" "${version:-latest}" "$arch-$os" "$off" if [ $source = no ] && ! wait "$sums"; then if [ $ask = yes ]; then pick "No release was found. Build lug from source instead?" "yes ${dim}git clone $repo, then cargo build$off" "no" if [ "$picked" = 1 ]; then source=yes; fi fi [ $source = yes ] || fail "could not download $base/sha256sums.txt; check the version and your connection, or pass --source to build from $repo" fi if [ $source = yes ]; then command -v cargo >/dev/null || fail "building from source needs cargo; get it from https://rustup.rs" set -- --quiet --depth 1 if [ -n "$version" ]; then set -- "$@" --branch "$version"; fi task "Cloning $repo" "Cloned $repo" git clone "$@" "$repo" "$tmp/src" || fail "could not clone $repo; check that your git credentials can read it" # The last line cargo wrote, such as the crate it is compiling. compiling() { extra="$dim$(tail -n 1 "$tmp/log" | sed 's/^ *//' | cut -c1-40)$off"; } detail=compiling # From inside the clone, so rustup picks up its rust-toolchain.toml. build() { cd "$tmp/src" && cargo build --release --locked -p lug; } task "Building lug with cargo" "Built lug" build || fail "cargo could not build lug; nothing was installed" mv "$tmp/src/target/release/lug" "$tmp/lug" else tarball="$base/$file" out="$tmp/$file" total=0 if [ $term = yes ]; then total=$(curl -fsSLI "$tarball" 2>/dev/null | tr -d '\r' | awk 'tolower($1) == "content-length:" { n = $2 } END { print n + 0 }') fi detail=progress task "Downloading lug" "Downloaded $file" curl -fsSL -o "$out" "$tarball" || fail "could not download $tarball; check the version and your connection" want=$(awk -v f="$file" '$2 == f { print $1 }' "$tmp/sha256sums.txt") if command -v sha256sum >/dev/null; then got=$(sha256sum "$out"); else got=$(shasum -a 256 "$out"); fi [ -n "$want" ] && [ "$want" = "${got%% *}" ] || fail "$file does not match sha256sums.txt; nothing was installed" printf ' %s✓%s Checked it against sha256sums.txt\n' "$green" "$off" tar -xzf "$out" -C "$tmp" fi detail='' mkdir -p "$dir" mv "$tmp/lug" "$dir/lug" printf ' %s✓%s Installed %s in %s\n' "$green" "$off" "$("$dir/lug" --version)" "$(home "$dir")" case ":$PATH:" in *":$dir:"*) ;; *) printf ' %s!%s %s is not on your PATH; add it to run lug.\n' "$yellow" "$off" "$(home "$dir")" ;; esac init="${XDG_CONFIG_HOME:-$HOME/.config}/lug/init.lua" old="$HOME/Library/Application Support/lug/init.lua" if [ "$(uname -s)" = Darwin ] && [ ! -e "$init" ] && [ -e "$old" ]; then init=$old fi if [ $config = yes ] && [ -e "$init" ]; then printf ' %s✓%s Kept your %s\n' "$green" "$off" "$(home "$init")" config=kept fi if [ $config = yes ] && [ $ask = yes ]; then printf '\n' if [ -z "$model" ]; then pick "Which model?" "none ${dim}set one later$off" \ "anthropic/claude-opus-5 ${dim}Anthropic$off" "opencode-go/kimi-k3 ${dim}OpenCode Go$off" \ "another ${dim}type provider/model$off" "custom ${dim}a provider of your own, such as a proxy$off" model=$chosen case $model in another) answer 'provider/model' '*?/?*' 'Like openai/MODEL, openrouter/VENDOR/MODEL or ollama/MODEL.' model=$answer ;; custom) answer 'its name' '?*' 'Letters, digits, - and _, like corp.' '*[!A-Za-z0-9_-]*' model=$answer answer 'its base URL' 'http*://?*' 'Like https://llm.example.com/v1.' url=$answer pick "How does it talk?" "openai-chat ${dim}OpenAI's Chat Completions, which most proxies speak$off" \ "openai ${dim}OpenAI's Responses API$off" "anthropic ${dim}Anthropic's Messages API$off" \ "openrouter ${dim}OpenRouter's API$off" "ollama ${dim}Ollama's API$off" wire=$chosen answer 'the model, as it names it' '?*' 'Like gpt-5 or claude-opus-5.' model="$model/$answer" ;; esac fi if [ -z "$mode" ]; then pick "Where should the prompt start?" "insert ${dim}type straight away$off" "normal ${dim}vim-style, i to type$off" mode=$chosen fi if [ -z "$manager" ]; then pick "Which plugin manager?" "haul ${dim}installs plugins from git, Lug's own$off" "none ${dim}no plugins; lug alone$off" manager=$chosen fi if [ "$manager" = none ]; then runtime=none; fi if [ -z "$runtime" ]; then pick "Which base plugin?" "basic.lug ${dim}keys, commands, windows, statusline and themes$off" "none ${dim}build your own on lug.*$off" runtime=$chosen fi if [ "$runtime" = basic.lug ]; then if [ -z "$style" ]; then pick "Which style?" "slug ${dim}borders, fades, a launcher bar and a welcome box$off" "plain ${dim}bare windows$off" style=$chosen fi if [ -z "$theme" ]; then pick "Which theme?" "auto ${dim}slug, or github-light on a light terminal$off" "slug ${dim}black, white and green$off" \ github-dark github-light "ansi ${dim}the terminal's own colours$off" theme=$chosen fi # The rest only change the slug style. if [ "$style" = slug ]; then if [ -z "$launcher" ]; then pick "Where should the : line go?" "top ${dim}a bar across the top row$off" "bottom ${dim}a bar across the bottom row$off" launcher=$chosen fi if [ -z "$border" ]; then pick "Which border for the prompt?" "rounded ${dim}╭─╮$off" "square ${dim}┌─┐$off" "double ${dim}╔═╗$off" "thick ${dim}┏━┓$off" none border=$chosen fi if [ -z "$fade" ]; then pick "How far should windows away from the focus fade?" "20 ${dim}percent, a little$off" "40 ${dim}percent, more$off" "0 ${dim}not at all$off" fade=$chosen fi if [ -z "$animate" ]; then pick "Should the slug crawl along the prompt while lug works?" yes no animate=$chosen fi fi fi printf '\n' fi if [ $config = yes ]; then "$dir/lug" --init >/dev/null # The choices made, as settings. Those after the plugins change the stock # runtime's defaults, so they go straight after haul's setup. opt() { if [ -n "$2" ]; then printf 'lug.opt.%s = %s\n' "$1" "$2"; fi; } quote() { if [ -n "$1" ]; then printf '"%s"' "$1"; fi; } bool() { case $1 in yes) printf true ;; no) printf false ;; esac; } { if [ -n "$url" ]; then printf 'lug.opt.providers = { ["%s"] = { base_url = "%s", wire = "%s" } }\n' "${model%%/*}" "$url" "$wire" fi if [ "$model" != none ]; then opt model "$(quote "$model")"; fi opt start_mode "$(quote "$mode")" if [ "$runtime" != none ]; then opt style "$(quote "$style")" opt theme "$(quote "$theme")" opt launcher "$(quote "$launcher")" opt border "$(quote "$border")" opt dim_inactive "$fade" opt animate "$(bool "$animate")" fi } >"$tmp/chosen" # Without haul, the plugins go; without basic.lug, its line is left for # later, commented out. awk -v manager="$manager" -v runtime="$runtime" -v chosen="$tmp/chosen" ' # Prints the settings, with `before` and `after` around them. function settings(before, after, line) { if ((getline line < chosen) <= 0) return printf "%s", before print "-- Chosen when Lug was installed:" do print line; while ((getline line < chosen) > 0) printf "%s", after } /^-- Plugins, installed/ { plugins = 1 } plugins && manager == "none" { if ($0 == "})") { plugins = 0; drop = 1 } next } drop && $0 == "" { drop = 0; settings("", "\n"); next } plugins && runtime == "none" && /"klaatu01\/basic.lug"/ { sub(/"/, "-- \"") } { print } plugins && $0 == "})" { plugins = 0; settings("\n", "") }' "$init" >"$tmp/init.lua" cat "$tmp/init.lua" >"$init" printf ' %s✓%s Wrote %s\n' "$green" "$off" "$(home "$init")" fi # Where the key comes from, unless the model is in an init.lua we kept. case ${model%%/*} in anthropic) key=ANTHROPIC_API_KEY ;; openai) key=OPENAI_API_KEY ;; openrouter) key=OPENROUTER_API_KEY ;; opencode-go) key=OPENCODE_API_KEY ;; *) key='' ;; esac provider=${model%%/*} printf '\n %sNext:%s' "$bold" "$off" if [ $config != kept ]; then case $model in '' | none) printf ' set %slug.opt.model%s in %s.\n Then' "$accent" "$off" "$(home "$init")" ;; *) if [ -n "$key" ]; then printf ' export %s, or run %s:login %s KEY%s inside lug.\n Then' "$key" "$accent" "$provider" "$off" elif [ -n "$url" ]; then printf ' if %s needs a key, run %s:login %s KEY%s inside lug.\n Then' "$provider" "$accent" "$provider" "$off" fi ;; esac fi printf ' run %slug%s in a project.\n\n' "$accent" "$off"