ci / check (push) Waiting to run
The first public release of LLeMbas CLI: a terminal coding agent and project manager for any LLM API, with permission modes, git snapshots, memory and skills, knowledge bases, MCP, voice, and a link to a LLeMbas instance whose web UI can work its sessions too. Signed Linux binaries for x64 and arm64. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
341 lines
16 KiB
Bash
Executable File
341 lines
16 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Install LLeMbas CLI (the `lembas` command) for the current user.
|
|
#
|
|
# ./install.sh [--binary PATH] [--aliases | --no-aliases] [--no-config] [--yes]
|
|
# ./install.sh --uninstall [--purge] [--yes]
|
|
#
|
|
# - From a checkout, builds dist/lembas first, every time (a binary left from an older
|
|
# checkout is never installed). Bun builds it: when Bun is missing, offers to fetch it into
|
|
# ~/.bun (the release LLeMbas CLI is tested with, checked against its published SHA-256).
|
|
# - Copies the binary to ~/.local/bin/lembas.
|
|
# - Writes starter ~/.config/lembas/config.yaml and connections.yaml (0600), commented, where
|
|
# there are none (--no-config: none at all, for a deploy that writes its own). Existing files
|
|
# are never touched. The JSON Schemas editors check them with (~/.config/lembas/schema/)
|
|
# are written every time, so they match the binary.
|
|
# - For bash (~/.bashrc) and zsh (${ZDOTDIR:-~}/.zshrc) — whichever exist, or the one for $SHELL
|
|
# if neither does — writes one marked block that puts ~/.local/bin on PATH and, if wanted,
|
|
# the alias `agent`. Re-running replaces the block; nothing is appended twice.
|
|
# - --uninstall says what it will remove and asks: the service (stopped first), the binary (and
|
|
# the one an update kept), the editor schemas and the rc block.
|
|
# Settings, sessions and memory stay, for a later reinstall; --purge removes them too
|
|
# (~/.config, ~/.local/share and ~/.local/state /lembas). A project's own .agent/ is never
|
|
# touched. `lembas uninstall` does the same.
|
|
set -euo pipefail
|
|
|
|
BIN_DIR="$HOME/.local/bin"
|
|
TARGET="$BIN_DIR/lembas"
|
|
BEGIN="# >>> lembas >>>"
|
|
END="# <<< lembas <<<"
|
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
# A relative XDG_* is ignored, as the XDG spec says (it would mean wherever this was started).
|
|
xdg() { case "${1:-}" in /*) printf '%s' "$1" ;; *) printf '%s' "$2" ;; esac; }
|
|
CONFIG="$(xdg "${XDG_CONFIG_HOME:-}" "$HOME/.config")/lembas"
|
|
# The Bun release LLeMbas CLI is built and tested with.
|
|
BUN_VERSION=1.4.2
|
|
BUN_SHA256_X64=36368faef7527875d5ffa52e53cd48021741f2a83eb6208a8dd64068d422a913
|
|
BUN_SHA256_X64_BASELINE=c678040f14fe0440eb839d37cbd0ce4c051a32da72806ac97de6a6aab6bf728f
|
|
BUN_SHA256_AARCH64=54328bbc2d9c8e0c9f892c544d66c57a83b84139e34909e5ee81758f1ac8fda7
|
|
|
|
binary="" aliases="ask" yes=0 uninstall=0 starter=1 purge=0
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--binary) binary="$2"; shift 2 ;;
|
|
--aliases) aliases=yes; shift ;;
|
|
--no-aliases) aliases=no; shift ;;
|
|
--no-config) starter=0; shift ;;
|
|
--yes|-y) yes=1; shift ;;
|
|
--uninstall) uninstall=1; shift ;;
|
|
--purge) purge=1; shift ;;
|
|
-h|--help) sed -n '2,22p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
*) echo "install.sh: unknown option $1" >&2; exit 2 ;;
|
|
esac
|
|
done
|
|
|
|
say() { printf '%s\n' "$*"; }
|
|
|
|
rc_files() {
|
|
local files=() zrc="${ZDOTDIR:-$HOME}/.zshrc"
|
|
[ -f "$HOME/.bashrc" ] && files+=("$HOME/.bashrc")
|
|
[ -f "$zrc" ] && files+=("$zrc")
|
|
if [ ${#files[@]} -eq 0 ]; then
|
|
case "${SHELL##*/}" in
|
|
zsh) files+=("$zrc") ;;
|
|
*) files+=("$HOME/.bashrc") ;;
|
|
esac
|
|
fi
|
|
printf '%s\n' "${files[@]}"
|
|
}
|
|
|
|
# Remove our block from a file, leaving everything else byte-for-byte.
|
|
strip_block() {
|
|
local f="$1"
|
|
[ -f "$f" ] || return 0
|
|
grep -qF "$BEGIN" "$f" || return 0
|
|
# Only a whole block: with its end marker gone, everything after the start would go with it.
|
|
if ! awk -v b="$BEGIN" -v e="$END" '$0==b{open=1;next} $0==e&&open{open=0;ok=1} END{exit !(ok && !open)}' "$f"; then
|
|
echo "install.sh: $f has \"$BEGIN\" without its \"$END\" — left as it is; remove the block by hand" >&2
|
|
return 1
|
|
fi
|
|
local tmp
|
|
tmp="$(mktemp)"
|
|
awk -v b="$BEGIN" -v e="$END" '$0==b{skip=1;next} $0==e{skip=0;next} !skip' "$f" >"$tmp"
|
|
cat "$tmp" >"$f"
|
|
rm -f "$tmp"
|
|
}
|
|
|
|
confirm() {
|
|
[ "$yes" -eq 1 ] && return 0
|
|
[ -t 0 ] || return 1
|
|
local answer
|
|
read -r -p " $1 [Y/n] " answer
|
|
[[ ! "$answer" =~ ^[Nn] ]]
|
|
}
|
|
|
|
if [ "$uninstall" -eq 1 ]; then
|
|
DATA="$(xdg "${XDG_DATA_HOME:-}" "$HOME/.local/share")/lembas"
|
|
STATE="$(xdg "${XDG_STATE_HOME:-}" "$HOME/.local/state")/lembas"
|
|
# `lembas service` as a systemd user unit: left running with its binary gone,
|
|
# it restart-loops on 203/EXEC every 10 s, so it is stopped, disabled and removed first.
|
|
UNITS="$(xdg "${XDG_CONFIG_HOME:-}" "$HOME/.config")/systemd/user"
|
|
gone=() kept=() units=()
|
|
u=lembas.service
|
|
if [ -e "$UNITS/$u" ]; then units+=("$u"); gone+=("$UNITS/$u (stopped and disabled first)"); fi
|
|
for f in "$TARGET" "$TARGET.prev" "$CONFIG/schema"; do [ -e "$f" ] || [ -L "$f" ] && gone+=("$f"); done
|
|
while IFS= read -r f; do
|
|
[ -f "$f" ] && grep -qF "$BEGIN" "$f" && gone+=("the LLeMbas CLI block in $f")
|
|
done < <(rc_files)
|
|
for d in "$CONFIG" "$DATA" "$STATE"; do
|
|
[ -e "$d" ] || continue
|
|
if [ "$purge" -eq 1 ]; then gone+=("$d"); else kept+=("$d"); fi
|
|
done
|
|
if [ ${#gone[@]} -eq 0 ]; then
|
|
say "Nothing of LLeMbas CLI's to remove here."
|
|
exit 0
|
|
fi
|
|
say "This removes:"
|
|
printf ' %s\n' "${gone[@]}"
|
|
if [ ${#kept[@]} -gt 0 ]; then
|
|
say "and keeps your settings, sessions and memory (--purge removes them too):"
|
|
printf ' %s\n' "${kept[@]}"
|
|
fi
|
|
[ "$purge" -eq 1 ] && say "(--purge: settings, connections, memory, skills, every session and snapshot.)"
|
|
confirm "Uninstall?" || { say "Nothing removed."; exit 1; }
|
|
if [ ${#units[@]} -gt 0 ]; then
|
|
for u in "${units[@]}"; do
|
|
systemctl --user disable --now "$u" >/dev/null 2>&1 || true
|
|
rm -f "$UNITS/$u"
|
|
done
|
|
systemctl --user daemon-reload >/dev/null 2>&1 || true
|
|
fi
|
|
while IFS= read -r f; do strip_block "$f"; done < <(rc_files)
|
|
rm -rf "$TARGET" "$TARGET.prev" "$CONFIG/schema"
|
|
[ "$purge" -eq 1 ] && rm -rf "$CONFIG" "$DATA" "$STATE"
|
|
say "LLeMbas CLI is uninstalled. A project's own .agent/ directory is left as it is."
|
|
exit 0
|
|
fi
|
|
|
|
# Bun, user-local, without its install script (which edits rc files): the release zip, checked
|
|
# against the release's SHASUMS256.txt.
|
|
fetch_bun() {
|
|
local arch asset tmp base="https://github.com/oven-sh/bun/releases/download/bun-v$BUN_VERSION"
|
|
case "$(uname -m)" in
|
|
x86_64) arch=x64; grep -qw avx2 /proc/cpuinfo 2>/dev/null || arch=x64-baseline ;;
|
|
aarch64 | arm64) arch=aarch64 ;;
|
|
*) echo "install.sh: no Bun build for $(uname -m)" >&2; return 1 ;;
|
|
esac
|
|
asset="bun-linux-$arch"
|
|
for tool in curl sha256sum unzip; do
|
|
command -v "$tool" >/dev/null 2>&1 || { echo "install.sh: fetching Bun needs $tool (sudo apt install $tool)" >&2; return 1; }
|
|
done
|
|
tmp="$(mktemp -d)"
|
|
say " fetching Bun $BUN_VERSION ($asset)…"
|
|
if ! curl -fsSL -o "$tmp/$asset.zip" "$base/$asset.zip" || ! curl -fsSL -o "$tmp/SHASUMS256.txt" "$base/SHASUMS256.txt"; then
|
|
rm -rf "$tmp"
|
|
echo "install.sh: could not download Bun" >&2
|
|
return 1
|
|
fi
|
|
# Checked against the SHA-256 written here for this version, not only against the list that came
|
|
# with the download (the same place could have changed both).
|
|
local want
|
|
case "$asset" in
|
|
bun-linux-x64) want="$BUN_SHA256_X64" ;;
|
|
bun-linux-x64-baseline) want="$BUN_SHA256_X64_BASELINE" ;;
|
|
bun-linux-aarch64) want="$BUN_SHA256_AARCH64" ;;
|
|
esac
|
|
{ (cd "$tmp" && grep " $asset.zip\$" SHASUMS256.txt | sha256sum -c --quiet -) && [ "$(sha256sum "$tmp/$asset.zip" | cut -d' ' -f1)" = "$want" ]; } || { rm -rf "$tmp"; echo "install.sh: Bun's checksum did not match — not installed" >&2; return 1; }
|
|
unzip -q "$tmp/$asset.zip" -d "$tmp"
|
|
mkdir -p "$HOME/.bun/bin"
|
|
install -m 0755 "$tmp/$asset/bun" "$HOME/.bun/bin/bun"
|
|
rm -rf "$tmp"
|
|
say " installed $HOME/.bun/bin/bun ($("$HOME/.bun/bin/bun" --version))"
|
|
}
|
|
|
|
# ── the binary ─────────────────────────────────────────────────────────────────────────────
|
|
if [ -z "$binary" ]; then
|
|
binary="$HERE/dist/lembas"
|
|
if [ -f "$HERE/package.json" ]; then
|
|
bun="$(command -v bun || true)"
|
|
[ -n "$bun" ] || { [ -x "$HOME/.bun/bin/bun" ] && bun="$HOME/.bun/bin/bun"; } || true
|
|
if [ -z "$bun" ]; then
|
|
say "LLeMbas CLI is built with Bun, which is not installed here."
|
|
if confirm "Fetch Bun $BUN_VERSION into ~/.bun (checked against its published SHA-256)?"; then
|
|
fetch_bun || exit 1
|
|
bun="$HOME/.bun/bin/bun"
|
|
else
|
|
echo "install.sh: no Bun, so nothing to build. Install Bun (https://bun.sh) and run this again, or pass --binary PATH." >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
say "Building $binary…"
|
|
log="$(mktemp)"
|
|
if ! (cd "$HERE" && "$bun" install --frozen-lockfile && "$bun" run scripts/build.ts) >"$log" 2>&1; then
|
|
cat "$log" >&2
|
|
rm -f "$log"
|
|
echo "install.sh: the build failed (output above)" >&2
|
|
exit 1
|
|
fi
|
|
rm -f "$log"
|
|
fi
|
|
fi
|
|
[ -x "$binary" ] || { echo "install.sh: no binary at $binary (build it with: bun run build)" >&2; exit 1; }
|
|
"$binary" --version >/dev/null 2>&1 || { echo "install.sh: $binary does not run" >&2; exit 1; }
|
|
mkdir -p "$BIN_DIR"
|
|
install -m 0755 "$binary" "$TARGET.new" && mv -f "$TARGET.new" "$TARGET"
|
|
say " installed $TARGET ($("$TARGET" --version))"
|
|
|
|
# ── where the config is ────────────────────────────────────────────────────────────────────
|
|
# Asked of the binary before anything is written there: where it says is where the starter files
|
|
# go. A binary that does not know the question (a stand-in) leaves the default.
|
|
if where="$("$TARGET" __paths 2>/dev/null)"; then
|
|
said="$(printf '%s\n' "$where" | sed -n 's/^config=//p' | head -n1)"
|
|
case "$said" in /*) CONFIG="$said" ;; esac
|
|
fi
|
|
|
|
# ── starter config ─────────────────────────────────────────────────────────────────────────
|
|
# Only where there is none: an existing file is the user's, whatever is in it.
|
|
[ "$starter" -eq 1 ] && mkdir -p "$CONFIG"
|
|
if [ "$starter" -eq 1 ] && [ ! -e "$CONFIG/config.yaml" ]; then
|
|
cat >"$CONFIG/config.yaml" <<'YAML'
|
|
# yaml-language-server: $schema=schema/config.schema.json
|
|
# LLeMbas CLI settings. Every key is optional; `lembas config check` validates this file.
|
|
# The full list: https://llembas.eu/docs/. An editor with a YAML language server
|
|
# completes and checks keys as you type (the first line points it at the schema).
|
|
|
|
# The model to start with, as connection/model from connections.yaml.
|
|
# model: local/qwen3
|
|
|
|
# manual (asks before changes) | edit (changes files in the project freely) | plan
|
|
mode: manual
|
|
|
|
# personality: concise # concise | pragmatic | optimistic | funny | formal | socratic | custom
|
|
# instructions: "Answer in Slovak; I know Python well." # how you want to be helped
|
|
# icons: plain # one-column symbols, for a terminal font without emoji
|
|
# mouse: false # leave the mouse to the terminal (its own selection and copy)
|
|
YAML
|
|
say " wrote $CONFIG/config.yaml"
|
|
fi
|
|
if [ "$starter" -eq 1 ] && [ ! -e "$CONFIG/connections.yaml" ]; then
|
|
(umask 077 && cat >"$CONFIG/connections.yaml" <<'YAML'
|
|
# yaml-language-server: $schema=schema/connections.schema.json
|
|
# Where the models are. Global only: a project can never point LLeMbas CLI elsewhere.
|
|
# Keys are never written here in plain text: {env:VAR}, {file:~/path}, or key_cmd.
|
|
# Then set `model: <connection>/<model>` in config.yaml, and check with `lembas models`.
|
|
connections: {}
|
|
#
|
|
# connections:
|
|
# local: # llama.cpp / llama-swap / vLLM / LM Studio — OpenAI-compatible
|
|
# dialect: openai-chat
|
|
# base_url: http://127.0.0.1:8080/v1
|
|
# models:
|
|
# qwen3: { context: 131072, max_output: 65536 }
|
|
# anthropic:
|
|
# dialect: anthropic
|
|
# base_url: https://api.anthropic.com
|
|
# api_key: "{env:ANTHROPIC_API_KEY}"
|
|
# models:
|
|
# claude-sonnet-5: { context: 200000 }
|
|
YAML
|
|
)
|
|
chmod 600 "$CONFIG/connections.yaml"
|
|
say " wrote $CONFIG/connections.yaml (0600) — add a connection there"
|
|
fi
|
|
# The schemas for editors follow the binary, so they are rewritten on every install.
|
|
"$TARGET" config schema >/dev/null && say " wrote $CONFIG/schema/ (JSON Schemas for editors)"
|
|
|
|
# ── aliases ────────────────────────────────────────────────────────────────────────────────
|
|
# A name is free when no command, function or alias of that name exists already — checked in
|
|
# this shell and in the rc files, since an alias defined there is invisible to this script.
|
|
taken() {
|
|
local name="$1" f
|
|
command -v "$name" >/dev/null 2>&1 && return 0
|
|
while IFS= read -r f; do
|
|
[ -f "$f" ] || continue
|
|
# Our block is left out: the alias there is the one this replaces.
|
|
awk -v b="$BEGIN" -v e="$END" '$0==b{skip=1;next} $0==e{skip=0;next} !skip' "$f" |
|
|
grep -Eq "^[[:space:]]*(alias[[:space:]]+${name}=|${name}[[:space:]]*\(\)|function[[:space:]]+${name}\b)" && return 0
|
|
done < <(rc_files)
|
|
return 1
|
|
}
|
|
|
|
want_alias() {
|
|
local name="$1"
|
|
if taken "$name"; then
|
|
say " '$name' is already a command or alias here — not adding it"
|
|
return 1
|
|
fi
|
|
case "$aliases" in
|
|
yes) return 0 ;;
|
|
no) return 1 ;;
|
|
esac
|
|
[ "$yes" -eq 1 ] && return 1
|
|
[ -t 0 ] || return 1
|
|
local answer
|
|
read -r -p " Add the alias '$name' for lembas? [y/N] " answer
|
|
[[ "$answer" =~ ^[Yy] ]]
|
|
}
|
|
|
|
extra=""
|
|
# One alias, `agent`.
|
|
if want_alias agent; then extra+="alias agent='lembas'"$'\n'; fi
|
|
|
|
# ── rc files ───────────────────────────────────────────────────────────────────────────────
|
|
block="$BEGIN
|
|
# Added by LLeMbas CLI's install.sh; re-run it to change, or ./install.sh --uninstall.
|
|
case \":\$PATH:\" in *\":\$HOME/.local/bin:\"*) ;; *) export PATH=\"\$HOME/.local/bin:\$PATH\" ;; esac
|
|
${extra}$END"
|
|
|
|
while IFS= read -r f; do
|
|
strip_block "$f"
|
|
[ -f "$f" ] || : >"$f"
|
|
# Keep a blank line between the user's content and ours.
|
|
[ -s "$f" ] && [ -n "$(tail -c 1 "$f")" ] && printf '\n' >>"$f"
|
|
printf '%s\n' "$block" >>"$f"
|
|
say " updated $f"
|
|
done < <(rc_files)
|
|
|
|
# ── another `lembas` first on PATH ─────────────────────────────────────────────────────────
|
|
# A LLeMbas server installed with pip may bring a console script called `lembas` too (the server
|
|
# itself runs as `lembas-server`). Whichever comes first on PATH is what `lembas` runs, so a server
|
|
# installed into ~/.local or a venv on PATH would shadow this one. The PATH
|
|
# a new shell gets: ours first when the rc block adds it, as it is when it is already there.
|
|
eff="$PATH"
|
|
case ":$PATH:" in *":$BIN_DIR:"*) ;; *) eff="$BIN_DIR:$PATH" ;; esac
|
|
first="$(PATH="$eff"; hash -r; command -v lembas 2>/dev/null || true)"
|
|
if [ -n "$first" ] && [ "$(readlink -f "$first")" != "$(readlink -f "$TARGET")" ]; then
|
|
what="another program"
|
|
if head -c 300 "$first" 2>/dev/null | head -n 1 | grep -q '^#!.*python'; then
|
|
what="a Python console script, most likely the LLeMbas server's own \`lembas\`"
|
|
fi
|
|
say ""
|
|
say "⚠ $first comes before $TARGET on PATH. It is $what,"
|
|
say " so \`lembas\` would run it, not LLeMbas CLI. Fix: put $BIN_DIR before $(dirname "$first") in PATH,"
|
|
say " or remove that one (the server itself runs as lembas-server). lembas config check says it too."
|
|
fi
|
|
|
|
say ""
|
|
say "Done. Open a new shell (or: source your rc file), then run: lembas"
|
|
[ -n "$extra" ] && say "Aliases: $(printf '%s' "$extra" | sed -n "s/^alias \([a-z]*\)=.*/\1/p" | paste -sd' ')"
|
|
if [ "$starter" -eq 1 ] && [ -z "$(sed -n 's/^model: *//p' "$CONFIG/config.yaml")" ]; then
|
|
say "Next: add a connection to $CONFIG/connections.yaml and set model: in config.yaml."
|
|
fi
|