Files
platform/scripts/setup.sh
T
pastilhasandClaude Opus 4.8 92de996412 setup.sh: symlink claude into /usr/local/bin
The Claude sidecar execs /usr/local/bin/claude (claude-manager.ts), but the
Anthropic installer only puts the CLI in ~/.local/bin — so on a fresh host that
path doesn't exist and claude chat fails with
"ENOENT … posix_spawn '/usr/local/bin/claude'". Symlink ~/.local/bin/claude →
/usr/local/bin/claude after install (idempotent; tracks Claude's self-updates).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 17:22:26 +00:00

959 lines
31 KiB
Bash
Executable File

#!/bin/bash
# Officer — full host dependency setup
# Run once on a fresh Ubuntu/Debian host before launching the server.
# Usage: bash scripts/setup.sh
set -e
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
ok() { echo -e " ${GREEN}${NC} $1"; }
warn() { echo -e " ${YELLOW}!${NC} $1"; }
fail() { echo -e " ${RED}${NC} $1"; }
skip() { echo -e " - $1 (already installed)"; }
has() { command -v "$1" &>/dev/null; }
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
# Resolve the real user's home even when running under sudo
if [[ -n "${SUDO_USER:-}" ]]; then
REAL_HOME=$(getent passwd "$SUDO_USER" | cut -d: -f6)
else
REAL_HOME="$HOME"
fi
# ─── detect package manager ────────────────────────────────────────────────────
if has apt; then
PM=apt
elif has pacman; then
PM=pacman
elif has brew; then
PM=brew
else
fail "No supported package manager found (apt, pacman, brew)"
exit 1
fi
install_pkg() {
case $PM in
apt) sudo apt install -y "$@" ;;
pacman) sudo pacman -S --noconfirm "$@" ;;
brew) brew install "$@" ;;
esac
}
echo ""
echo "═══════════════════════════════════════════"
echo " Officer — dependency setup ($PM)"
echo "═══════════════════════════════════════════"
# ─── 1. core system packages ───────────────────────────────────────────────────
echo ""
echo "── Core system packages ──"
CORE_PKGS=()
# git
if has git; then skip "git"; else CORE_PKGS+=(git); fi
# zip / unzip
if has zip; then skip "zip"; else CORE_PKGS+=(zip); fi
if has unzip; then skip "unzip"; else CORE_PKGS+=(unzip); fi
# curl / wget
if has curl; then skip "curl"; else CORE_PKGS+=(curl); fi
if has wget; then skip "wget"; else CORE_PKGS+=(wget); fi
# zsh
if has zsh; then skip "zsh"; else CORE_PKGS+=(zsh); fi
# psmisc (fuser) and procps (pgrep)
if has fuser; then skip "fuser (psmisc)"; else
case $PM in
apt|pacman) CORE_PKGS+=(psmisc) ;;
brew) skip "fuser (not needed on macOS)" ;;
esac
fi
if has pgrep; then skip "pgrep (procps)"; else
case $PM in
apt) CORE_PKGS+=(procps) ;;
pacman) CORE_PKGS+=(procps-ng) ;;
brew) skip "pgrep (built-in on macOS)" ;;
esac
fi
# script (bsdutils on apt, util-linux on pacman, built-in on macOS)
if has script; then skip "script (bsdutils)"; else
case $PM in
apt) CORE_PKGS+=(bsdutils) ;;
pacman) CORE_PKGS+=(util-linux) ;;
brew) skip "script (built-in on macOS)" ;;
esac
fi
# build tools (make, gcc, g++) — needed for native npm modules like node-pty
if has make && has gcc; then skip "build tools (make, gcc, g++)"; else
case $PM in
apt) CORE_PKGS+=(build-essential) ;;
pacman) CORE_PKGS+=(base-devel) ;;
brew) warn "Install Xcode command line tools: xcode-select --install" ;;
esac
fi
# pkg-config — needed by cgo-based Go packages (e.g. ebitengine/oto for cliamp)
if has pkg-config; then skip "pkg-config"; else
case $PM in
apt) CORE_PKGS+=(pkg-config) ;;
pacman) CORE_PKGS+=(pkgconf) ;;
brew) CORE_PKGS+=(pkg-config) ;;
esac
fi
# python3 + pip + venv
if has python3; then skip "python3"; else
case $PM in
apt) CORE_PKGS+=(python3 python3-pip python3-venv) ;;
pacman) CORE_PKGS+=(python python-pip) ;;
brew) CORE_PKGS+=(python3) ;;
esac
fi
# ensure pip/venv even if python3 already exists (apt splits them)
if has python3 && [ "$PM" = "apt" ]; then
if ! dpkg -s python3-pip &>/dev/null 2>&1; then CORE_PKGS+=(python3-pip); fi
if ! dpkg -s python3-venv &>/dev/null 2>&1; then CORE_PKGS+=(python3-venv); fi
fi
# shell utilities
for tool in tree btop tmux jq htop lsof duf; do
if has "$tool"; then skip "$tool"; else CORE_PKGS+=("$tool"); fi
done
# sqlite3
if has sqlite3; then skip "sqlite3"; else
case $PM in
apt) CORE_PKGS+=(sqlite3) ;;
pacman) CORE_PKGS+=(sqlite) ;;
brew) CORE_PKGS+=(sqlite) ;;
esac
fi
# isync (provides mbsync for Gmail IMAP sync)
if has mbsync; then skip "isync (mbsync)"; else
case $PM in
apt) CORE_PKGS+=(isync) ;;
pacman) CORE_PKGS+=(isync) ;;
brew) CORE_PKGS+=(isync) ;;
esac
fi
# ripgrep
if has rg; then skip "ripgrep"; else
case $PM in
apt) CORE_PKGS+=(ripgrep) ;;
pacman) CORE_PKGS+=(ripgrep) ;;
brew) CORE_PKGS+=(ripgrep) ;;
esac
fi
# fd-find
if has fd || has fdfind; then skip "fd-find"; else
case $PM in
apt) CORE_PKGS+=(fd-find) ;;
pacman) CORE_PKGS+=(fd) ;;
brew) CORE_PKGS+=(fd) ;;
esac
fi
# net-tools, less, file, man-db
case $PM in
apt)
for pkg in net-tools less file man-db; do
if dpkg -s "$pkg" &>/dev/null 2>&1; then skip "$pkg"; else CORE_PKGS+=("$pkg"); fi
done
;;
pacman)
for pkg in net-tools less file man-db; do
if pacman -Qi "$pkg" &>/dev/null 2>&1; then skip "$pkg"; else CORE_PKGS+=("$pkg"); fi
done
;;
brew)
skip "net-tools, less, file, man (built-in on macOS)"
;;
esac
# locales
case $PM in
apt)
if dpkg -s locales &>/dev/null 2>&1; then skip "locales"; else CORE_PKGS+=(locales); fi
;;
esac
# ca-certificates
case $PM in
apt)
if dpkg -s ca-certificates &>/dev/null 2>&1; then skip "ca-certificates"; else CORE_PKGS+=(ca-certificates); fi
;;
esac
if [ ${#CORE_PKGS[@]} -gt 0 ]; then
install_pkg "${CORE_PKGS[@]}"
ok "Installed: ${CORE_PKGS[*]}"
fi
# locale generation (ensure en_US.UTF-8)
case $PM in
apt)
if ! locale -a 2>/dev/null | grep -q "en_US.utf8"; then
sudo sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen
sudo locale-gen
ok "Generated en_US.UTF-8 locale"
else
skip "en_US.UTF-8 locale"
fi
;;
esac
# symlink fdfind → fd (apt installs as fdfind)
if has fdfind && ! has fd; then
sudo ln -sf "$(command -v fdfind)" /usr/local/bin/fd
ok "Symlinked fdfind → fd"
fi
# ─── 2. archive extras (optional but useful) ──────────────────────────────────
echo ""
echo "── Archive utilities (optional) ──"
ARCHIVE_PKGS=()
# p7zip
if has 7z; then skip "7z (p7zip)"; else
case $PM in
apt) ARCHIVE_PKGS+=(p7zip-full) ;;
pacman) ARCHIVE_PKGS+=(p7zip) ;;
brew) ARCHIVE_PKGS+=(p7zip) ;;
esac
fi
# unrar
if has unrar; then skip "unrar"; else
case $PM in
apt) ARCHIVE_PKGS+=(unrar) ;;
pacman) ARCHIVE_PKGS+=(unrar) ;;
brew) ARCHIVE_PKGS+=(unrar) ;;
esac
fi
if [ ${#ARCHIVE_PKGS[@]} -gt 0 ]; then
install_pkg "${ARCHIVE_PKGS[@]}" || warn "Some archive packages may need non-free repos"
ok "Installed: ${ARCHIVE_PKGS[*]}"
fi
# ─── 3. ffmpeg ─────────────────────────────────────────────────────────────────
echo ""
echo "── FFmpeg ──"
if has ffmpeg; then
skip "ffmpeg ($(ffmpeg -version 2>&1 | head -1 | awk '{print $3}'))"
else
install_pkg ffmpeg
ok "ffmpeg installed"
fi
# ─── 4. sudoers for officer service user ──────────────────────────────────────
echo ""
echo "── Sudoers (Linux user isolation) ──"
SERVICE_USER="$(whoami)"
SUDOERS_FILE="/etc/sudoers.d/officer-service"
if [ -f "$SUDOERS_FILE" ] && grep -q "$SERVICE_USER" "$SUDOERS_FILE" 2>/dev/null; then
skip "sudoers entry for $SERVICE_USER"
else
case $PM in
apt|pacman)
echo "$SERVICE_USER ALL=(ALL) NOPASSWD: ALL" | sudo tee "$SUDOERS_FILE" > /dev/null
sudo chmod 0440 "$SUDOERS_FILE"
ok "Created sudoers entry for $SERVICE_USER at $SUDOERS_FILE"
;;
brew)
warn "Sudoers setup is Linux-only — skipping on macOS"
;;
esac
fi
# ─── 4b. Disable auto-suspend (server doesn't need to sleep) ──────────────────
echo ""
echo "── Auto-suspend (disable for server) ──"
# Disable system suspend/hibernate
if systemctl is-enabled sleep.target 2>/dev/null | grep -q "masked"; then
skip "sleep targets already masked"
else
sudo systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target 2>/dev/null
ok "Masked sleep/suspend targets"
fi
# Configure logind to ignore idle — patch individual keys, don't overwrite the file
LOGIND_CHANGED=0
set_logind_key() {
local key="$1" val="$2" file="/etc/systemd/logind.conf"
# Already set (uncommented) to the desired value → nothing to do.
if grep -qE "^${key}=${val}$" "$file" 2>/dev/null; then
return
fi
if grep -qE "^${key}=" "$file" 2>/dev/null; then
sudo sed -i "s|^${key}=.*|${key}=${val}|" "$file"
elif grep -qE "^#${key}=" "$file" 2>/dev/null; then
sudo sed -i "s|^#${key}=.*|${key}=${val}|" "$file"
else
echo "${key}=${val}" | sudo tee -a "$file" > /dev/null
fi
LOGIND_CHANGED=1
}
set_logind_key HandleLidSwitch ignore
set_logind_key HandleLidSwitchExternalPower ignore
set_logind_key HandlePowerKey ignore
set_logind_key IdleAction none
set_logind_key RuntimeDirectorySize 10%
# Only restart logind when something actually changed — a needless restart can disrupt live sessions.
if [ "$LOGIND_CHANGED" = "1" ]; then
sudo systemctl restart systemd-logind
ok "Configured logind to disable auto-suspend"
else
skip "logind auto-suspend settings"
fi
# ─── 5. Node.js 22 (system-wide) ─────────────────────────────────────────────
echo ""
echo "── Node.js 22 (system-wide) ──"
# Always check the canonical system path, not `which node` (which may resolve nvm).
SYSTEM_NODE="/usr/bin/node"
install_node22_apt() {
echo " Setting up NodeSource repository..."
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
echo " Installing nodejs..."
sudo apt-get install -y nodejs
}
case $PM in
apt)
NEED_INSTALL=0
if [ -f "$SYSTEM_NODE" ]; then
SYS_MAJOR=$("$SYSTEM_NODE" -v 2>/dev/null | cut -d. -f1 | sed 's/^v//')
if [ "$SYS_MAJOR" = "22" ]; then
skip "node v$("$SYSTEM_NODE" -v) (system-wide at $SYSTEM_NODE)"
else
warn "System node v$("$SYSTEM_NODE" -v) at $SYSTEM_NODE is not v22 — upgrading..."
NEED_INSTALL=1
fi
else
if has node; then
warn "node found at $(which node) (not system-wide, likely nvm) — installing Node 22 system-wide..."
else
echo " Node.js not found — installing v22..."
fi
NEED_INSTALL=1
fi
if [ "$NEED_INSTALL" = "1" ]; then
install_node22_apt
if [ -f "$SYSTEM_NODE" ] && [ "$("$SYSTEM_NODE" -v 2>/dev/null | cut -d. -f1 | sed 's/^v//')" = "22" ]; then
ok "node v$("$SYSTEM_NODE" -v) installed at $SYSTEM_NODE"
if has node && [ "$(command -v node)" != "$SYSTEM_NODE" ]; then
warn "Shell resolves 'node' to $(command -v node) — system node is at $SYSTEM_NODE"
warn "nvm may shadow it in interactive shells; systemd services will use $SYSTEM_NODE"
fi
else
fail "Node.js 22 install failed — $SYSTEM_NODE not found or wrong version"
exit 1
fi
fi
;;
pacman)
if has node && [ "$(node -v 2>/dev/null | cut -d. -f1 | sed 's/^v//')" = "22" ]; then
skip "node v$(node -v)"
else
install_pkg nodejs npm
if has node; then ok "node v$(node -v) installed"; else fail "node install failed"; exit 1; fi
fi
;;
brew)
if has node && [ "$(node -v 2>/dev/null | cut -d. -f1 | sed 's/^v//')" = "22" ]; then
skip "node v$(node -v)"
else
install_pkg node
if has node; then ok "node v$(node -v) installed"; else fail "node install failed"; exit 1; fi
fi
;;
esac
# ─── 6. Bun ───────────────────────────────────────────────────────────────────
echo ""
echo "── Bun ──"
export BUN_INSTALL="$HOME/.bun"
export PATH="$BUN_INSTALL/bin:$PATH"
if [ -f "$BUN_INSTALL/bin/bun" ]; then
skip "bun ($(bun --version 2>/dev/null))"
else
curl -fsSL https://bun.sh/install | bash
if [ ! -f "$BUN_INSTALL/bin/bun" ]; then fail "bun install failed"; exit 1; fi
ok "bun $(bun --version) installed"
fi
# Symlink to system-wide path so all users and systemd services can access it
if [ ! -L /usr/local/bin/bun ] || [ "$(readlink /usr/local/bin/bun)" != "$BUN_INSTALL/bin/bun" ]; then
sudo ln -sf "$BUN_INSTALL/bin/bun" /usr/local/bin/bun
ok "bun symlinked to /usr/local/bin/bun"
else
skip "bun symlink at /usr/local/bin/bun"
fi
# ─── 7. Go ─────────────────────────────────────────────────────────────────────
echo ""
echo "── Go ──"
echo " Fetching latest Go version..."
GOLANG_VERSION=$(curl -fsSL "https://go.dev/dl/?mode=json" | jq -r '.[0].version' | sed 's/^go//')
if [ -z "$GOLANG_VERSION" ]; then
warn "Could not fetch latest Go version — falling back to 1.23.6"
GOLANG_VERSION=1.23.6
fi
echo " Latest Go: $GOLANG_VERSION"
install_go() {
case $PM in
apt|pacman)
ARCH=$(uname -m)
case $ARCH in
x86_64) GO_ARCH=amd64 ;;
aarch64) GO_ARCH=arm64 ;;
*) GO_ARCH=amd64 ;;
esac
echo " Installing Go ${GOLANG_VERSION} from official tarball..."
curl -fsSL "https://go.dev/dl/go${GOLANG_VERSION}.linux-${GO_ARCH}.tar.gz" -o /tmp/go.tar.gz
mkdir -p "$HOME/.local"
# Only remove old installation after successful download
rm -rf "$HOME/.local/go"
tar -C "$HOME/.local" -xzf /tmp/go.tar.gz
rm /tmp/go.tar.gz
export GOPATH="$HOME/.local/go-path"
export PATH="$HOME/.local/go/bin:$GOPATH/bin:$PATH"
;;
brew)
brew install go
;;
esac
}
if has go; then
INSTALLED_GO=$(go version 2>/dev/null | awk '{print $3}' | sed 's/^go//')
if [ "$INSTALLED_GO" = "$GOLANG_VERSION" ]; then
skip "go $INSTALLED_GO"
else
warn "go $INSTALLED_GO installed but latest is $GOLANG_VERSION — upgrading..."
install_go
if has go; then ok "go $(go version | awk '{print $3}') installed"; else warn "go upgrade failed"; fi
fi
else
install_go
if has go; then ok "go $(go version | awk '{print $3}') installed"; else warn "go not found — install manually from https://go.dev/dl/"; fi
fi
# ─── 8. Rust ──────────────────────────────────────────────────────────────────
echo ""
echo "── Rust ──"
if has rustc && has cargo; then
skip "rust ($(rustc --version 2>/dev/null | awk '{print $2}'))"
else
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
export PATH="$HOME/.cargo/bin:$PATH"
if has rustc; then ok "rust installed"; else warn "rust install failed"; fi
fi
# ─── 9. PulseAudio (headless audio for cliamp) ────────────────────────────────
echo ""
echo "── PulseAudio (headless audio) ──"
PULSE_PKGS=()
if has pulseaudio; then skip "pulseaudio"; else
case $PM in
apt) PULSE_PKGS+=(pulseaudio) ;;
pacman) PULSE_PKGS+=(pulseaudio) ;;
brew) warn "PulseAudio: brew install pulseaudio (cliamp audio won't work without it)" ;;
esac
fi
# pulseaudio-utils provides parec and pactl
if has parec && has pactl; then skip "pulseaudio-utils (parec, pactl)"; else
case $PM in
apt) PULSE_PKGS+=(pulseaudio-utils) ;;
pacman) ;; # included in pulseaudio package
brew) ;; # included in pulseaudio formula
esac
fi
# ALSA dev headers (needed to compile cliamp's Go audio library)
case $PM in
apt)
if dpkg -s libasound2-dev &>/dev/null 2>&1; then skip "libasound2-dev"; else PULSE_PKGS+=(libasound2-dev); fi
;;
pacman)
if pacman -Qi alsa-lib &>/dev/null 2>&1; then skip "alsa-lib"; else PULSE_PKGS+=(alsa-lib); fi
;;
brew) ;; # not needed on macOS
esac
# Vorbis/OGG/FLAC dev headers (needed by cliamp's Go dependencies)
case $PM in
apt)
for pkg in libvorbis-dev libogg-dev libflac-dev; do
if dpkg -s "$pkg" &>/dev/null 2>&1; then skip "$pkg"; else PULSE_PKGS+=("$pkg"); fi
done
;;
pacman)
for pkg in libvorbis libogg flac; do
if pacman -Qi "$pkg" &>/dev/null 2>&1; then skip "$pkg"; else PULSE_PKGS+=("$pkg"); fi
done
;;
brew) ;; # not needed on macOS
esac
if [ ${#PULSE_PKGS[@]} -gt 0 ]; then
install_pkg "${PULSE_PKGS[@]}"
ok "Installed: ${PULSE_PKGS[*]}"
fi
# ─── 10. cliamp (music player) ────────────────────────────────────────────────
echo ""
echo "── cliamp ──"
# Export GOPATH (not just PATH) so `go install` lands in GOPATH_BIN — even when Go was already present
# this run and install_go (which sets GOPATH) never ran. Otherwise go uses its default ~/go/bin and the
# check below wrongly reports a build failure.
export GOPATH="${GOPATH:-$HOME/.local/go-path}"
GOPATH_BIN="$GOPATH/bin"
export PATH="$GOPATH_BIN:$PATH"
if has cliamp; then
skip "cliamp ($(command -v cliamp))"
else
if ! has go; then
warn "Go not installed — skipping cliamp build"
else
echo " Building cliamp from source..."
TMPDIR=$(mktemp -d)
git clone --depth=1 https://github.com/bjarneo/cliamp.git "$TMPDIR/cliamp"
(cd "$TMPDIR/cliamp" && go install .)
rm -rf "$TMPDIR"
if [ -f "$GOPATH_BIN/cliamp" ]; then
ok "cliamp installed at $GOPATH_BIN/cliamp"
else
warn "cliamp build failed"
fi
fi
fi
# ─── 11. Neovim ──────────────────────────────────────────────────────────────
echo ""
echo "── Neovim ──"
if has nvim; then
skip "neovim ($(nvim --version 2>/dev/null | head -1))"
else
case $PM in
apt)
echo " Installing Neovim from GitHub releases..."
ARCH=$(uname -m)
case $ARCH in
x86_64) NVIM_ARCH=x86_64 ;;
aarch64) NVIM_ARCH=aarch64 ;;
*) NVIM_ARCH=x86_64 ;;
esac
curl -fsSL "https://github.com/neovim/neovim/releases/latest/download/nvim-linux-${NVIM_ARCH}.tar.gz" -o /tmp/nvim.tar.gz
sudo tar -C /opt -xzf /tmp/nvim.tar.gz
sudo ln -sf "/opt/nvim-linux-${NVIM_ARCH}/bin/nvim" /usr/local/bin/nvim
rm /tmp/nvim.tar.gz
;;
pacman) install_pkg neovim ;;
brew) install_pkg neovim ;;
esac
if has nvim; then ok "neovim installed"; else warn "neovim install failed"; fi
fi
# LazyVim starter config
if [ -d "$HOME/.config/nvim" ]; then
skip "nvim config (already exists at ~/.config/nvim)"
else
echo " Installing LazyVim starter config..."
git clone --depth 1 https://github.com/LazyVim/starter "$HOME/.config/nvim"
rm -rf "$HOME/.config/nvim/.git"
ok "LazyVim starter installed at ~/.config/nvim"
fi
# ─── 12. Terminal tools ──────────────────────────────────────────────────────
echo ""
echo "── Terminal tools (starship, oh-my-zsh, eza, lazygit) ──"
# Starship prompt
if has starship; then
skip "starship"
else
curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b /usr/local/bin
if has starship; then ok "starship installed"; else warn "starship install failed"; fi
fi
# Deploy starship config
mkdir -p "$HOME/.config"
cp "$SCRIPT_DIR/starship.toml" "$HOME/.config/starship.toml"
ok "starship config deployed"
# Oh-My-Zsh
if [ -d "$HOME/.oh-my-zsh" ]; then
skip "oh-my-zsh (already at ~/.oh-my-zsh)"
else
git clone --depth 1 https://github.com/ohmyzsh/ohmyzsh.git "$HOME/.oh-my-zsh"
ok "oh-my-zsh installed at ~/.oh-my-zsh"
fi
# eza
if has eza; then
skip "eza"
else
case $PM in
apt)
echo " Fetching latest eza version..."
EZA_VERSION=$(curl -fsSL "https://api.github.com/repos/eza-community/eza/releases/latest" | jq -r '.tag_name' | sed 's/^v//')
if [ -z "$EZA_VERSION" ]; then warn "Could not fetch eza version — skipping"; else
ARCH=$(uname -m)
case $ARCH in
x86_64) EZA_ARCH=x86_64 ;;
aarch64) EZA_ARCH=aarch64 ;;
*) EZA_ARCH=x86_64 ;;
esac
curl -fsSL "https://github.com/eza-community/eza/releases/download/v${EZA_VERSION}/eza_${EZA_ARCH}-unknown-linux-gnu.tar.gz" -o /tmp/eza.tar.gz
tar -xzf /tmp/eza.tar.gz -C /tmp
sudo mv /tmp/eza /usr/local/bin/eza
sudo chmod +x /usr/local/bin/eza
rm -f /tmp/eza.tar.gz
fi
;;
pacman) install_pkg eza ;;
brew) install_pkg eza ;;
esac
if has eza; then ok "eza installed"; else warn "eza install failed"; fi
fi
# lazygit
if has lazygit; then
skip "lazygit"
else
case $PM in
apt)
echo " Fetching latest lazygit version..."
LAZYGIT_VERSION=$(curl -fsSL "https://api.github.com/repos/jesseduffield/lazygit/releases/latest" | jq -r '.tag_name' | sed 's/^v//')
if [ -z "$LAZYGIT_VERSION" ]; then warn "Could not fetch lazygit version — skipping"; else
ARCH=$(uname -m)
case $ARCH in
x86_64) LG_ARCH=x86_64 ;;
aarch64) LG_ARCH=arm64 ;;
*) LG_ARCH=x86_64 ;;
esac
curl -fsSL "https://github.com/jesseduffield/lazygit/releases/download/v${LAZYGIT_VERSION}/lazygit_${LAZYGIT_VERSION}_Linux_${LG_ARCH}.tar.gz" -o /tmp/lazygit.tar.gz
tar -xzf /tmp/lazygit.tar.gz -C /tmp
sudo mv /tmp/lazygit /usr/local/bin/lazygit
sudo chmod +x /usr/local/bin/lazygit
rm -f /tmp/lazygit.tar.gz /tmp/LICENSE /tmp/README.md
fi
;;
pacman) install_pkg lazygit ;;
brew) install_pkg lazygit ;;
esac
if has lazygit; then ok "lazygit installed"; else warn "lazygit install failed"; fi
fi
# ─── 13. yt-dlp (optional — video/audio download) ────────────────────────────
echo ""
echo "── yt-dlp (optional) ──"
# Always install/upgrade via pip to get the latest version (apt repos are outdated).
# Remove apt version first if present, then install via pip to /usr/local/bin.
if has pip3; then
# Remove outdated apt version if installed
case $PM in
apt)
if dpkg -s yt-dlp &>/dev/null 2>&1; then
echo " Removing outdated apt version..."
sudo apt remove -y yt-dlp > /dev/null 2>&1
fi
;;
esac
echo " Installing/upgrading yt-dlp via pip..."
sudo pip3 install --break-system-packages --upgrade yt-dlp 2>/dev/null
if has yt-dlp; then ok "yt-dlp $(yt-dlp --version) installed"; else warn "yt-dlp pip install failed"; fi
else
case $PM in
apt) install_pkg yt-dlp 2>/dev/null && ok "yt-dlp installed (apt — may be outdated)" || warn "yt-dlp not available" ;;
pacman) install_pkg yt-dlp && ok "yt-dlp installed" ;;
brew) install_pkg yt-dlp && ok "yt-dlp installed" ;;
esac
fi
# ─── 14. npm global packages (user-local) ───────────────────────────────────
echo ""
echo "── npm global packages (user-local) ──"
# Ensure ~/.local/bin is in PATH for this session
export PATH="$HOME/.local/bin:$PATH"
if ! has npm; then
warn "npm not found — skipping global package installs"
else
# Set npm prefix to user-local so no sudo is needed for installs/updates
echo " Configuring npm global prefix to ~/.local..."
npm config set prefix "$HOME/.local"
ok "npm prefix set to $HOME/.local"
# Claude Code (uses Anthropic's own installer for auto-update support)
if has claude; then
skip "claude (claude-code)"
else
echo " Installing claude-code via Anthropic installer..."
curl -fsSL https://claude.ai/install.sh | sh
if has claude; then ok "claude-code installed"; else warn "claude-code install failed"; fi
fi
# The platform's Claude sidecar execs /usr/local/bin/claude (a stable, sandbox-visible path). The
# installer only drops the CLI in ~/.local/bin, so symlink it there — pointing at the ~/.local/bin
# launcher so it keeps tracking Claude's self-updates. Without this, claude chat ENOENTs on a fresh host.
CLAUDE_LOCAL="$HOME/.local/bin/claude"
if [ -e "$CLAUDE_LOCAL" ]; then
if [ "$(readlink -f /usr/local/bin/claude 2>/dev/null)" != "$(readlink -f "$CLAUDE_LOCAL")" ]; then
sudo ln -sf "$CLAUDE_LOCAL" /usr/local/bin/claude
ok "claude symlinked to /usr/local/bin/claude"
else
skip "claude symlink at /usr/local/bin/claude"
fi
else
warn "claude not found at $CLAUDE_LOCAL — /usr/local/bin/claude symlink skipped"
fi
# pm2 (process manager)
if has pm2; then
skip "pm2"
else
echo " Installing pm2..."
npm install -g pm2
if has pm2; then ok "pm2 installed"; else warn "pm2 install failed"; fi
fi
fi
# ─── 15. bun install (project dependencies) ──────────────────────────────────
echo ""
echo "── Project dependencies ──"
if has bun && [ -f "$PROJECT_DIR/package.json" ]; then
echo " Running bun install..."
(cd "$PROJECT_DIR" && bun install)
ok "Project dependencies installed"
else
warn "Skipping bun install (bun not found or not in project dir)"
fi
# ─── 16. environment (.env) ──────────────────────────────────────────────────
echo ""
echo "── Environment (.env) ──"
GENERATE_ENV=true
if [ ! -t 0 ]; then
# Non-interactive shell: the prompts below would hit EOF and abort the whole script under `set -e`.
GENERATE_ENV=false
if [ -f "$PROJECT_DIR/.env" ]; then
skip ".env (kept existing — non-interactive shell)"
else
warn "No .env and not a terminal — re-run setup.sh interactively to generate it"
fi
elif [ -f "$PROJECT_DIR/.env" ]; then
echo -n " .env already exists. Regenerate? (y/n) [n]: "
read -r REGEN
if [[ "$REGEN" != "y" && "$REGEN" != "Y" ]]; then
GENERATE_ENV=false
skip ".env (kept existing)"
fi
fi
if [ "$GENERATE_ENV" = true ]; then
# Run setup-dockers.sh and capture its stdout output
echo " Setting up Docker Compose services..."
DOCKER_OUTPUT=$(bash "$SCRIPT_DIR/setup-dockers.sh")
# Parse output from setup-dockers.sh
COMPOSE_DIR=$(echo "$DOCKER_OUTPUT" | grep '^COMPOSE_DIR=' | cut -d= -f2-)
POSTGRES_URL=$(echo "$DOCKER_OUTPUT" | grep '^POSTGRES_URL=' | cut -d= -f2-)
DOCKER_MAIL_TRANSPORT=$(echo "$DOCKER_OUTPUT" | grep '^MAIL_TRANSPORT=' | cut -d= -f2-)
# Prompt for remaining values
echo ""
echo -n " PORT [9010]: "
read -r ENV_PORT
ENV_PORT="${ENV_PORT:-9010}"
echo -n " PUBLIC_URL (required): "
read -r ENV_PUBLIC_URL
while [ -z "$ENV_PUBLIC_URL" ]; do
warn "PUBLIC_URL is required"
echo -n " PUBLIC_URL: "
read -r ENV_PUBLIC_URL
done
echo -n " DATA_PATH [$REAL_HOME/.local/data]: "
read -r ENV_DATA_PATH
ENV_DATA_PATH="${ENV_DATA_PATH:-$REAL_HOME/.local/data}"
# The file-based item store (skills/tools/tasks/…). Defaults to a sibling of the repo; without it the
# server falls back to <repo>/officer-items and boots with an empty store.
ITEMS_DEFAULT="$(dirname "$PROJECT_DIR")/officer-items"
echo -n " OFFICER_ITEMS_DIR [$ITEMS_DEFAULT]: "
read -r ENV_OFFICER_ITEMS_DIR
ENV_OFFICER_ITEMS_DIR="${ENV_OFFICER_ITEMS_DIR:-$ITEMS_DEFAULT}"
MAIL_DEFAULT="${DOCKER_MAIL_TRANSPORT:-smtp://127.0.0.1:1025}"
echo -n " MAIL_TRANSPORT [$MAIL_DEFAULT]: "
read -r ENV_MAIL_TRANSPORT
ENV_MAIL_TRANSPORT="${ENV_MAIL_TRANSPORT:-$MAIL_DEFAULT}"
echo -n " DISCORD_BUG_REPORT_WEBHOOK []: "
read -r ENV_DISCORD_WEBHOOK
if [ -z "$POSTGRES_URL" ]; then
echo -n " POSTGRES_URL: "
read -r POSTGRES_URL
fi
# Auto-generate values
JWT_SECRET=$(openssl rand -base64 48 | tr -d '/+=' | head -c 48)
# Write .env
cat > "$PROJECT_DIR/.env" <<ENVFILE
PORT="$ENV_PORT"
JWT_SECRET="$JWT_SECRET"
MAIL_TRANSPORT="$ENV_MAIL_TRANSPORT"
PUBLIC_URL="$ENV_PUBLIC_URL"
PUBLIC_BUILD_ENV="production"
DATA_PATH="$ENV_DATA_PATH"
OFFICER_ITEMS_DIR="$ENV_OFFICER_ITEMS_DIR"
HOME_DIR="$HOME"
POSTGRES_URL="$POSTGRES_URL"
DISCORD_BUG_REPORT_WEBHOOK="$ENV_DISCORD_WEBHOOK"
ENVFILE
ok ".env written to $PROJECT_DIR/.env"
fi
# ─── 17. remote desktop (XFCE + VNC) ────────────────────────────────────────
echo ""
echo "── Remote Desktop (XFCE + VNC) ──"
if dpkg -s ubuntu-desktop &>/dev/null 2>&1; then
skip "remote desktop (ubuntu-desktop already installed)"
else
case $PM in
apt)
bash "$SCRIPT_DIR/setup-desktop.sh"
;;
*)
warn "Remote desktop setup is Ubuntu/Debian only — skipping"
;;
esac
fi
# ─── verification ─────────────────────────────────────────────────────────────
echo ""
echo "═══════════════════════════════════════════"
echo " Verification"
echo "═══════════════════════════════════════════"
echo ""
check() {
if has "$1"; then ok "$1"; else fail "$1 — NOT FOUND"; fi
}
echo "Required:"
check git
check node
check bun
check npm
check ffmpeg
check zip
check script
check python3
check make
check gcc
echo ""
echo "Dev tools:"
check go
check rustc
check cargo
check nvim
check zsh
check starship
check lazygit
check eza
check rg
check fd
check jq
check htop
check tmux
check tree
check btop
check sqlite3
echo ""
echo "Audio (cliamp):"
check pulseaudio
check parec
check pactl
check cliamp
echo ""
echo "AI agents:"
check claude
echo ""
echo "Process manager:"
check pm2
echo ""
echo "Optional:"
check unzip
check 7z
check unrar
check pgrep
check fuser
check yt-dlp
echo ""
echo "═══════════════════════════════════════════"
echo " Setup complete!"
echo "═══════════════════════════════════════════"
echo ""
echo "Notes:"
echo " • PulseAudio null sink starts automatically with the server"
echo " • Make sure ~/.local/go/bin and ~/.local/go-path/bin are in your PATH for Go tools"
echo " • Make sure ~/.cargo/bin is in your PATH for Rust tools"
echo " • sharp, whisper-cpp, mlx-audio can be installed from Settings > Applications"
echo ""