chopsticks/install.sh

1062 lines
40 KiB
Bash
Executable file

#!/usr/bin/env bash
# install.sh - chopsticks vim configuration installer
# Usage: cd /path/to/chopsticks && ./install.sh [--yes] [--profile=engineer] [--help]
#
# --yes non-interactive: use default profile/component selections
# --profile=PROFILE choose minimal, engineer, or full without prompting
# --help show this help and exit
set -eo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
AUTO_YES=0
REQUESTED_PROFILE=""
for arg in "$@"; do
case "$arg" in
--yes) AUTO_YES=1 ;;
--profile=*) REQUESTED_PROFILE="${arg#*=}" ;;
--profile)
echo "Use --profile=minimal, --profile=engineer, or --profile=full" >&2
exit 1 ;;
--help|-h)
echo "Usage: ./install.sh [OPTIONS]"
echo ""
echo "Options:"
echo " --yes Non-interactive mode: use default profile/component selections"
echo " --profile=PROFILE"
echo " Select minimal, engineer, or full without prompting"
echo " --help Show this help and exit"
echo ""
echo "Supported platforms: macOS (brew), Debian/Ubuntu (apt), Arch (pacman), Fedora (dnf)"
exit 0 ;;
esac
done
case "$REQUESTED_PROFILE" in
""|minimal|engineer|full) ;;
*)
echo "Invalid profile: $REQUESTED_PROFILE" >&2
echo "Use: minimal, engineer, or full" >&2
exit 1 ;;
esac
# ── Colours (respect NO_COLOR and non-TTY) ───────────────────────────────────
if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BOLD='\033[1m'
CYAN='\033[0;36m'
DIM='\033[2m'
NC='\033[0m'
else
GREEN='' YELLOW='' RED='' BOLD='' CYAN='' DIM='' NC=''
fi
ok() { echo -e "${GREEN}[OK]${NC} $1"; }
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
skip() { echo -e "${CYAN}[--]${NC} $1"; }
fail() { echo -e "${RED}[ERR]${NC} $1"; }
die() { echo -e "${RED}[FATAL]${NC} $1" >&2
echo " Retry with: ./install.sh 2>&1 | tee /tmp/chopsticks-install.log" >&2
echo " Report issues: https://github.com/m1ngsama/chopsticks/issues" >&2
exit 1; }
step() { echo -e "\n${BOLD}==> $1${NC}"; }
info() { echo " $1"; }
INSTALLED=()
SKIPPED=()
FAILED=()
if [[ -n "${XDG_CONFIG_HOME:-}" && "$XDG_CONFIG_HOME" == /* ]]; then
CONFIG_HOME="$XDG_CONFIG_HOME"
else
CONFIG_HOME="$HOME/.config"
fi
LOCAL_CONFIG="$CONFIG_HOME/chopsticks.vim"
CONFIG_PROFILE="${REQUESTED_PROFILE:-engineer}"
# Ask yes/no; reads from /dev/tty so it works under: curl | bash
ask() {
[[ $AUTO_YES -eq 1 ]] && return 0
if [[ -t 0 ]]; then
read -r -p "$1 [y/N] " reply
elif { true </dev/tty; } 2>/dev/null; then
read -r -p "$1 [y/N] " reply </dev/tty
else
echo "$1 [y/N] N"
return 1
fi
[[ "$reply" =~ ^[Yy]$ ]]
}
profile_from_config() {
[[ -f "$LOCAL_CONFIG" ]] || return 0
sed -nE "s/^[[:space:]]*let[[:space:]]+g:chopsticks_profile[[:space:]]*=[[:space:]]*['\"](minimal|engineer|full)['\"].*/\1/p" \
"$LOCAL_CONFIG" | tail -n1
}
choose_profile() {
local default="${1:-engineer}" reply
while true; do
echo "Choose Vim profile:"
echo " 1) minimal core navigation/editing/git/markdown; no LSP/ALE/completion extras"
echo " 2) engineer default; LSP, ALE, completion, syntax extras"
echo " 3) full engineer plus heavier Markdown lint/spell/conceal/LSP feedback"
if [[ -t 0 ]]; then
read -r -p "Profile [$default]: " reply
elif { true </dev/tty; } 2>/dev/null; then
read -r -p "Profile [$default]: " reply </dev/tty
else
CONFIG_PROFILE="$default"
return
fi
reply="${reply:-$default}"
case "$reply" in
1|minimal) CONFIG_PROFILE="minimal"; return ;;
2|engineer) CONFIG_PROFILE="engineer"; return ;;
3|full) CONFIG_PROFILE="full"; return ;;
*) warn "Choose 1, 2, 3, minimal, engineer, or full." ;;
esac
done
}
write_profile_config() {
local profile="$1" config_dir tmp
config_dir="$(dirname "$LOCAL_CONFIG")"
mkdir -p "$config_dir"
if [[ -f "$LOCAL_CONFIG" ]] && \
grep -Eq '^[[:space:]]*let[[:space:]]+g:chopsticks_profile[[:space:]]*=' "$LOCAL_CONFIG"; then
tmp="$_TMPDIR/chopsticks-local-config"
awk -v profile="$profile" '
/^[[:space:]]*let[[:space:]]+g:chopsticks_profile[[:space:]]*=/ && !done {
printf "let g:chopsticks_profile = \047%s\047\n", profile
done = 1
next
}
{ print }
' "$LOCAL_CONFIG" > "$tmp"
mv "$tmp" "$LOCAL_CONFIG"
else
[[ -s "$LOCAL_CONFIG" ]] && printf '\n' >> "$LOCAL_CONFIG"
{
echo '" chopsticks local preferences'
echo "let g:chopsticks_profile = '$profile'"
} >> "$LOCAL_CONFIG"
fi
}
configure_profile() {
local existing
existing="$(profile_from_config)"
step "Configuring Vim profile"
if [[ -n "$REQUESTED_PROFILE" ]]; then
CONFIG_PROFILE="$REQUESTED_PROFILE"
write_profile_config "$CONFIG_PROFILE"
ok "Profile saved: $CONFIG_PROFILE ($LOCAL_CONFIG)"
return
fi
if [[ -n "$existing" ]]; then
CONFIG_PROFILE="$existing"
ok "Profile: $CONFIG_PROFILE ($LOCAL_CONFIG)"
if [[ $AUTO_YES -eq 1 ]] || ! { true </dev/tty; } 2>/dev/null; then
return
fi
if ask "Change profile now?"; then
choose_profile "$existing"
if [[ "$CONFIG_PROFILE" != "$existing" ]]; then
write_profile_config "$CONFIG_PROFILE"
ok "Profile saved: $CONFIG_PROFILE ($LOCAL_CONFIG)"
else
skip "profile unchanged"
fi
fi
else
if [[ $AUTO_YES -eq 1 ]] || ! { true </dev/tty; } 2>/dev/null; then
CONFIG_PROFILE="engineer"
info "Profile: engineer (default; create $LOCAL_CONFIG to change later)"
return
fi
choose_profile engineer
if [[ "$CONFIG_PROFILE" == "engineer" ]]; then
info "Profile: engineer (default; no local override written)"
else
write_profile_config "$CONFIG_PROFILE"
ok "Profile saved: $CONFIG_PROFILE ($LOCAL_CONFIG)"
fi
fi
}
# ── Error trap ────────────────────────────────────────────────────────────────
on_error() {
echo -e "\n${RED}[FATAL]${NC} Command '${BASH_COMMAND}' failed at line ${BASH_LINENO[0]}." >&2
echo " To get a full debug log:" >&2
echo " ./install.sh 2>&1 | tee /tmp/chopsticks-install.log" >&2
echo " Report issues: https://github.com/m1ngsama/chopsticks/issues" >&2
}
trap on_error ERR
_TMPDIR=$(mktemp -d "${TMPDIR:-/tmp}/chopsticks-XXXXXX")
trap 'rm -rf "$_TMPDIR" 2>/dev/null' EXIT
# ── Safe download helper ──────────────────────────────────────────────────────
safe_download() {
local url="$1" dest="$2"
curl -fsSL --connect-timeout 15 --retry 3 "$url" -o "$dest" 2>/dev/null || return 1
[[ -s "$dest" ]] || { rm -f "$dest"; return 1; }
if head -c 200 "$dest" 2>/dev/null | grep -qi "<!DOCTYPE\|<html"; then
rm -f "$dest"; return 1
fi
return 0
}
# ── Cross-platform package install helper ─────────────────────────────────────
pkg_install() {
local brew_pkg="${1:-}" apt_pkg="${2:-}" pac_pkg="${3:-}" dnf_pkg="${4:-}"
if [[ $OS == "macos" && $HAS_BREW -eq 1 && -n "$brew_pkg" ]]; then brew install "$brew_pkg" >/dev/null 2>&1
elif [[ $HAS_APT -eq 1 && -n "$apt_pkg" && $HAS_SUDO -eq 1 ]]; then sudo apt-get install -y "$apt_pkg" >/dev/null 2>&1
elif [[ $HAS_PACMAN -eq 1 && -n "$pac_pkg" && $HAS_SUDO -eq 1 ]]; then sudo pacman -S --noconfirm "$pac_pkg" >/dev/null 2>&1
elif [[ $HAS_DNF -eq 1 && -n "$dnf_pkg" && $HAS_SUDO -eq 1 ]]; then sudo dnf install -y "$dnf_pkg" >/dev/null 2>&1
elif [[ $HAS_BREW -eq 1 && -n "$brew_pkg" ]]; then brew install "$brew_pkg" >/dev/null 2>&1
else return 1
fi
}
# ── CPU architecture helpers ──────────────────────────────────────────────────
arch_github() {
case "$(uname -m)" in
x86_64) echo "x86_64" ;;
aarch64|arm64) echo "arm64" ;;
armv7l) echo "armv7" ;;
*) uname -m ;;
esac
}
arch_linux_x64() {
case "$(uname -m)" in
x86_64) echo "x64" ;;
aarch64|arm64) echo "arm64" ;;
*) uname -m ;;
esac
}
# ============================================================================
# Checkbox selection menu
# ============================================================================
#
# Usage:
# _menu_checkbox "Title" "label|description|default(0/1)" ...
#
# Result: global MENU_SEL array — MENU_SEL[i]=1 means item i was selected.
# Globals _MENU_LABELS / _MENU_DESCS remain populated after return.
#
# In --yes mode or non-TTY: uses defaults silently.
# Controls: ↑↓ navigate · Space toggle · a all · n none · Enter confirm
# ─────────────────────────────────────────────────────────────────────────────
_MENU_LABELS=()
_MENU_DESCS=()
_MENU_SELS=()
_MENU_N=0
_MENU_TITLE=""
_MENU_CUR=0
MENU_SEL=()
_menu_draw() {
local i mark
printf "\033[2K${BOLD}%s${NC}\n" "$_MENU_TITLE"
printf "\033[2K %b%b\n" "$DIM" "↑/↓ move Space toggle a all n none Enter confirm${NC}"
printf "\033[2K\n"
for ((i = 0; i < _MENU_N; i++)); do
if [[ ${_MENU_SELS[$i]} -eq 1 ]]; then
mark="${GREEN}${NC}"
else
mark=" "
fi
if [[ $i -eq $_MENU_CUR ]]; then
printf "\033[2K ${BOLD}▶ [%b] %s${NC}\n" "$mark" "${_MENU_LABELS[$i]}"
else
printf "\033[2K [%b] %s\n" "$mark" "${_MENU_LABELS[$i]}"
fi
printf "\033[2K ${CYAN}%s${NC}\n" "${_MENU_DESCS[$i]}"
done
}
_menu_checkbox() {
_MENU_TITLE="$1"; shift
_MENU_LABELS=(); _MENU_DESCS=(); _MENU_SELS=()
_MENU_N=0; _MENU_CUR=0
while [[ $# -gt 0 ]]; do
IFS='|' read -r \
"_MENU_LABELS[$_MENU_N]" \
"_MENU_DESCS[$_MENU_N]" \
"_MENU_SELS[$_MENU_N]" <<< "$1"
shift; : $(( _MENU_N++ ))
done
# Non-interactive or --yes: use defaults, no UI
if [[ $AUTO_YES -eq 1 ]] || ! { true </dev/tty; } 2>/dev/null; then
MENU_SEL=("${_MENU_SELS[@]}")
[[ $AUTO_YES -eq 1 ]] && info "(--yes mode: using all defaults)"
return
fi
# Lines printed per _menu_draw call: 3 header + 2 per item
local _lines=$(( 3 + 2 * _MENU_N ))
local _key _esc _i
tput civis 2>/dev/null # hide cursor
local _first=1
while true; do
if [[ $_first -eq 0 ]]; then
tput cuu "$_lines" 2>/dev/null # move back to top of menu
fi
_menu_draw
_first=0
IFS= read -r -s -n1 _key </dev/tty
if [[ $_key == $'\x1b' ]]; then
IFS= read -r -s -n2 _esc </dev/tty
case "$_esc" in
'[A') ((_MENU_CUR > 0)) && ((_MENU_CUR--)) ;;
'[B') ((_MENU_CUR < _MENU_N - 1)) && ((_MENU_CUR++)) ;;
esac
elif [[ $_key == ' ' ]]; then
_MENU_SELS[_MENU_CUR]=$(( 1 - _MENU_SELS[_MENU_CUR] ))
elif [[ $_key == 'a' || $_key == 'A' ]]; then
for ((_i = 0; _i < _MENU_N; _i++)); do _MENU_SELS[_i]=1; done
elif [[ $_key == 'n' || $_key == 'N' ]]; then
for ((_i = 0; _i < _MENU_N; _i++)); do _MENU_SELS[_i]=0; done
elif [[ -z $_key ]]; then # Enter
break
fi
done
tput cnorm 2>/dev/null # restore cursor
echo ""
MENU_SEL=("${_MENU_SELS[@]}")
}
# Helper: was menu item at index $1 selected?
_selected() { [[ ${MENU_SEL[${1:-999}]:-0} -eq 1 ]]; }
echo -e "${BOLD}chopsticks — Vim Configuration Installer${NC}"
echo "----------------------------------------"
# ============================================================================
# 1. OS + Package Manager Detection
# ============================================================================
step "Detecting environment"
OS="unknown"
if [[ "$OSTYPE" == darwin* ]]; then OS="macos"
elif [[ -f /etc/debian_version ]]; then OS="debian"
elif [[ -f /etc/fedora-release ]]; then OS="fedora"
elif [[ -f /etc/arch-release ]]; then OS="arch"
fi
ok "OS: $OS"
HAS_BREW=0; command -v brew >/dev/null 2>&1 && HAS_BREW=1
HAS_APT=0; command -v apt >/dev/null 2>&1 && HAS_APT=1
HAS_DNF=0; command -v dnf >/dev/null 2>&1 && HAS_DNF=1
HAS_PACMAN=0; command -v pacman >/dev/null 2>&1 && HAS_PACMAN=1
# sudo
HAS_SUDO=0
if [[ $OS == "macos" ]]; then
HAS_SUDO=1 # brew handles its own privilege escalation
elif sudo -n true 2>/dev/null; then
HAS_SUDO=1; ok "sudo: available (passwordless)"
elif [[ $AUTO_YES -eq 1 ]]; then
warn "sudo requires a password but running non-interactively (--yes)"
warn "System package installations will be skipped"
else
warn "Some steps require sudo. Authenticating now..."
if sudo true; then
HAS_SUDO=1; ok "sudo: authenticated"
else
warn "sudo not available — system package installations will be skipped"
fi
fi
# Network
if curl -fsSL --connect-timeout 5 https://github.com -o /dev/null 2>/dev/null; then
ok "Network: github.com reachable"
else
warn "Network: cannot reach github.com — plugin and binary downloads may fail"
fi
# Homebrew (macOS)
if [[ $OS == "macos" && $HAS_BREW -eq 0 ]]; then
warn "Homebrew not found — it is the recommended package manager for macOS"
if ask "Install Homebrew now? (strongly recommended — required for system tools)"; then
info "This may take a few minutes and will prompt for your password..."
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" || \
die "Homebrew installation failed. Install manually: https://brew.sh"
for brew_path in /opt/homebrew/bin/brew /usr/local/bin/brew; do
[[ -x "$brew_path" ]] && eval "$("$brew_path" shellenv)" && break
done
command -v brew >/dev/null 2>&1 && HAS_BREW=1 && ok "Homebrew installed"
else
warn "Homebrew skipped — system tools (ripgrep, fzf, etc.) will be unavailable"
fi
fi
# curl
if ! command -v curl >/dev/null 2>&1; then
warn "curl not found — required to download plugins and tools"
if pkg_install curl curl curl curl 2>/dev/null; then
ok "curl installed"
else
die "curl is required but could not be installed automatically.
Ubuntu/Debian: sudo apt install curl
Arch: sudo pacman -S curl
Fedora: sudo dnf install curl
macOS: brew install curl"
fi
fi
# git
if ! command -v git >/dev/null 2>&1; then
warn "git not found — required for vim-plug to install plugins"
if pkg_install git git git git 2>/dev/null; then
ok "git installed"
else
die "git is required but could not be installed automatically.
Ubuntu/Debian: sudo apt install git
Arch: sudo pacman -S git
Fedora: sudo dnf install git
macOS: brew install git (or: xcode-select --install)"
fi
fi
# vim
[ -f "$SCRIPT_DIR/.vimrc" ] || die ".vimrc not found in $SCRIPT_DIR — is this the chopsticks repo?"
if ! command -v vim >/dev/null 2>&1; then
warn "vim not found — attempting to install"
if pkg_install vim vim vim vim 2>/dev/null; then
ok "vim installed"
else
die "vim not found and could not be installed automatically.
Ubuntu/Debian: sudo apt install vim
Arch: sudo pacman -S vim
Fedora: sudo dnf install vim
macOS: brew install vim"
fi
fi
ok "Found: $(vim --version | head -n1)"
vim --version | grep -q 'Vi IMproved 8\|Vi IMproved 9' || \
warn "Vim 8.0+ recommended for full async/LSP support — some features may not work"
# Node.js (optional — vim-lsp needs no Node.js; only npm formatters do)
HAS_NODE=0; command -v node >/dev/null 2>&1 && HAS_NODE=1
if [[ $HAS_NODE -eq 1 ]]; then
ok "Node.js $(node --version) found"
else
warn "Node.js not found — npm formatters (prettier, eslint) will be unavailable"
warn "LSP still works without Node.js. To add formatters later: brew install node"
fi
# Python3 / pip3
HAS_PYTHON=0; command -v python3 >/dev/null 2>&1 && HAS_PYTHON=1
HAS_PIP=0; command -v pip3 >/dev/null 2>&1 && HAS_PIP=1
if [[ $HAS_PYTHON -eq 0 ]]; then
warn "python3 not found — Python formatters/linters will be unavailable"
fi
# Bootstrap pip3 when python3 exists but pip3 is absent (common on Ubuntu minimal)
if [[ $HAS_PYTHON -eq 1 && $HAS_PIP -eq 0 ]]; then
warn "python3 found but pip3 missing — attempting bootstrap"
if python3 -m ensurepip --upgrade >/dev/null 2>&1 || \
pkg_install python3-pip python3-pip python-pip python3-pip >/dev/null 2>&1; then
command -v pip3 >/dev/null 2>&1 && HAS_PIP=1 && ok "pip3 bootstrapped"
else
warn "pip3 bootstrap failed — Python tools will be skipped"
fi
fi
[[ $HAS_PIP -eq 1 ]] && ok "Python/pip3 found"
[[ $HAS_PYTHON -eq 1 && $HAS_PIP -eq 0 ]] && warn "pip3 not available — Python tools will be skipped"
# Go
HAS_GO=0; command -v go >/dev/null 2>&1 && HAS_GO=1
[[ $HAS_GO -eq 1 ]] && ok "Go $(go version | awk '{print $3}') found"
[[ $HAS_GO -eq 0 ]] && warn "Go not found — Go tools will be skipped (see https://go.dev/dl/)"
# ============================================================================
# 2. Symlinks
# ============================================================================
step "Setting up symlinks"
if [ -f "$HOME/.vimrc" ] && [ ! -L "$HOME/.vimrc" ]; then
TS=$(date +%Y%m%d_%H%M%S)
warn "Backing up existing ~/.vimrc → $HOME/.vimrc.backup.$TS"
mv "$HOME/.vimrc" "$HOME/.vimrc.backup.$TS"
fi
ln -sf "$SCRIPT_DIR/.vimrc" "$HOME/.vimrc"
if [[ -L "$HOME/.vimrc" ]]; then
ok "$HOME/.vimrc → $SCRIPT_DIR/.vimrc"
else
die "Failed to create ~/.vimrc symlink"
fi
mkdir -p "$HOME/.vim"
configure_profile
# ============================================================================
# 3. vim-plug + Plugins
# ============================================================================
step "Installing vim-plug"
VIM_PLUG="$HOME/.vim/autoload/plug.vim"
if [ ! -f "$VIM_PLUG" ]; then
mkdir -p "$HOME/.vim/autoload"
if safe_download \
"https://raw.githubusercontent.com/junegunn/vim-plug/master/plug.vim" \
"$VIM_PLUG"; then
ok "vim-plug downloaded"
else
warn "curl download failed — trying git clone fallback"
if git clone --depth=1 https://github.com/junegunn/vim-plug.git \
/tmp/vim-plug-src 2>/dev/null; then
cp /tmp/vim-plug-src/plug.vim "$VIM_PLUG"
rm -rf /tmp/vim-plug-src
ok "vim-plug installed (via git)"
else
die "vim-plug installation failed. Check your network connection and try again."
fi
fi
[[ -s "$VIM_PLUG" ]] || die "vim-plug file is empty after download — aborting"
else
ok "vim-plug already present"
fi
step "Installing Vim plugins"
_vim_run() {
if { true </dev/tty; } 2>/dev/null; then
# Interactive terminal: vim uses alternate screen; user sees progress
vim "$@" </dev/tty
else
# No TTY (SSH batch, CI): do NOT redirect stdin (causes "Error reading input" exit)
# or stdout (breaks async job callbacks — partial install).
# Redirect only stderr; escape sequences appear on stdout but installation succeeds.
vim --not-a-term "$@" 2>/dev/null
fi
}
verify_plugins() {
local required_file="$_TMPDIR/required-plugins.txt"
local verify_script="$_TMPDIR/required-plugins.vim"
local missing=() dir
cat > "$verify_script" <<'VIMEOF'
if !exists('g:plugs')
cquit
endif
let s:dirs = []
for s:plug in values(g:plugs)
let s:dir = get(s:plug, 'dir', '')
if !empty(s:dir)
call add(s:dirs, fnamemodify(s:dir, ':p'))
endif
endfor
call writefile(s:dirs, $CHOPSTICKS_REQUIRED_PLUGINS)
qa!
VIMEOF
CHOPSTICKS_REQUIRED_PLUGINS="$required_file" \
vim -u "$SCRIPT_DIR/.vimrc" -i NONE -es -N -S "$verify_script" >/dev/null 2>&1 || return 1
[[ -s "$required_file" ]] || return 1
while IFS= read -r dir; do
[[ -z "$dir" ]] && continue
[[ -d "$dir" ]] || missing+=("$dir")
done < "$required_file"
if [[ ${#missing[@]} -gt 0 ]]; then
fail "Plugin installation incomplete — missing:"
for dir in "${missing[@]}"; do echo " ! $dir"; done
return 1
fi
}
if [[ -d "$HOME/.vim/plugged" ]] && [[ -n "$(find "$HOME/.vim/plugged" -mindepth 1 -maxdepth 1 2>/dev/null)" ]]; then
warn "PlugClean: removing plugins not listed in .vimrc from ~/.vim/plugged"
fi
_vim_run +'PlugClean!' +qall || true # remove plugins no longer in vimrc; ignore exit code (none expected)
_vim_run +'PlugInstall --sync' +qall || true # fzf post-install hook may exit non-zero; harmless
verify_plugins || die "Plugin installation failed — retry with a stable network connection."
_plug_count=$(find "$HOME/.vim/plugged" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l | tr -d ' ')
if [[ $_plug_count -eq 0 ]]; then
die "Plugin installation failed — ~/.vim/plugged is empty. Check network and retry."
fi
ok "Plugins installed ($_plug_count)"
# ============================================================================
# 4. Module Selection
# ============================================================================
step "Select optional components"
info "Vim profile: $CONFIG_PROFILE"
_ITEMS=()
_idx=0
_PROFILE_TOOLING=1
[[ $CONFIG_PROFILE == "minimal" ]] && _PROFILE_TOOLING=0
_MARKSMAN_DEFAULT=0
[[ $CONFIG_PROFILE == "full" ]] && _MARKSMAN_DEFAULT=1
# Index map (-1 = not in menu / unavailable)
_I_RIPGREP=-1; _I_FZF=-1; _I_CTAGS=-1; _I_SHELLCHECK=-1
_I_HADOLINT=-1; _I_MARKSMAN=-1
_I_NPM=-1; _I_PYTHON=-1; _I_GO=-1; _I_TMUX=-1
# Is any package manager available?
HAS_PKG_MGR=0
if [[ $HAS_BREW -eq 1 ]] || \
{ [[ $HAS_APT -eq 1 || $HAS_PACMAN -eq 1 || $HAS_DNF -eq 1 ]] && [[ $HAS_SUDO -eq 1 ]]; }; then
HAS_PKG_MGR=1
fi
# ── System tools ─────────────────────────────────────────────────────────────
if [[ $HAS_PKG_MGR -eq 1 ]]; then
_I_RIPGREP=$_idx
_ITEMS+=("ripgrep|,rg / ,rG project-wide search · powers Ctrl+p preview|1")
: $(( _idx++ ))
_I_FZF=$_idx
_ITEMS+=("fzf|Ctrl+p fuzzy file search · ,b buffers · ,rt tag search|1")
: $(( _idx++ ))
_I_CTAGS=$_idx
_ITEMS+=("universal-ctags|Optional symbol index for ,rt tag jumps|0")
: $(( _idx++ ))
if [[ $_PROFILE_TOOLING -eq 1 ]]; then
_I_SHELLCHECK=$_idx
_ITEMS+=("shellcheck|Optional shell script static analysis via ALE|0")
: $(( _idx++ ))
_I_HADOLINT=$_idx
_ITEMS+=("hadolint|Optional Dockerfile linting via ALE|0")
: $(( _idx++ ))
_I_MARKSMAN=$_idx
_ITEMS+=("marksman|Markdown LSP for full profile or explicit Markdown LSP|$_MARKSMAN_DEFAULT")
: $(( _idx++ ))
else
skip "lint/LSP system tools hidden by minimal profile"
fi
else
warn "No package manager available — system tools skipped"
fi
# ── npm tools ────────────────────────────────────────────────────────────────
if [[ $HAS_NODE -eq 1 && $_PROFILE_TOOLING -eq 1 ]]; then
_I_NPM=$_idx
_ITEMS+=("npm formatter suite|Optional prettier / eslint / markdownlint / stylelint / tsc|0")
: $(( _idx++ ))
fi
# ── Python tools ─────────────────────────────────────────────────────────────
if [[ $HAS_PIP -eq 1 && $_PROFILE_TOOLING -eq 1 ]]; then
_I_PYTHON=$_idx
_ITEMS+=("Python tool suite|Optional black / isort / flake8 / pylint / yamllint / sqlfluff|0")
: $(( _idx++ ))
fi
# ── Go tools ─────────────────────────────────────────────────────────────────
if [[ $HAS_GO -eq 1 && $_PROFILE_TOOLING -eq 1 ]]; then
_I_GO=$_idx
_ITEMS+=("Go tool suite|Optional gopls / goimports / staticcheck|0")
: $(( _idx++ ))
fi
# ── tmux ─────────────────────────────────────────────────────────────────────
if command -v tmux >/dev/null 2>&1; then
if ! grep -q 'vim-tmux-navigator' "$HOME/.tmux.conf" 2>/dev/null; then
_I_TMUX=$_idx
_ITEMS+=("tmux integration|Optional Ctrl+h/j/k/l navigation between vim and tmux panes|0")
: $(( _idx++ ))
else
ok "tmux integration (vim-tmux-navigator already configured)"
fi
fi
if [[ ${#_ITEMS[@]} -gt 0 ]]; then
_menu_checkbox "Select components to install:" "${_ITEMS[@]}"
echo -e "${BOLD}Install plan:${NC}"
for ((_i = 0; _i < _MENU_N; _i++)); do
if [[ ${MENU_SEL[$_i]:-0} -eq 1 ]]; then
echo -e " ${GREEN}${NC} ${_MENU_LABELS[$_i]}"
else
echo -e " ${DIM}${NC} ${_MENU_LABELS[$_i]}"
fi
done
echo ""
else
warn "No optional components available for this environment"
MENU_SEL=()
fi
# ============================================================================
# 5. System Tools
# ============================================================================
step "System tools"
if [[ $HAS_PKG_MGR -eq 0 ]]; then
skip "system tools (no package manager available)"
SKIPPED+=("ripgrep" "fzf" "universal-ctags" "shellcheck" "hadolint" "marksman")
else
# _do_sys <name> <cmd_check> <idx> <brew_pkg> <apt_pkg> <pac_pkg> [dnf_pkg]
_do_sys() {
local name="$1" check="$2" idx="$3"
local brew_p="${4:-}" apt_p="${5:-}" pac_p="${6:-}" dnf_p="${7:-}"
if [[ $idx -lt 0 ]] || ! _selected "$idx"; then
skip "$name"; SKIPPED+=("$name"); return
fi
if command -v "$check" >/dev/null 2>&1; then
ok "$name (already installed)"; return
fi
if pkg_install "$brew_p" "$apt_p" "$pac_p" "$dnf_p"; then
ok "$name"; INSTALLED+=("$name")
else
fail "$name — could not install automatically (install manually)"
FAILED+=("$name")
fi
}
# _do_binary_apt: for tools with no apt/dnf package — download binary from GitHub
_do_binary_apt() {
local name="$1" check="$2" idx="$3" url="$4" tmp="$5"
if [[ $idx -lt 0 ]] || ! _selected "$idx"; then
skip "$name"; SKIPPED+=("$name"); return
fi
if command -v "$check" >/dev/null 2>&1; then
ok "$name (already installed)"; return
fi
if [[ $HAS_SUDO -ne 1 ]]; then
fail "$name — sudo not available, cannot install to /usr/local/bin"
FAILED+=("$name"); return
fi
if safe_download "$url" "$tmp"; then
chmod +x "$tmp" && sudo mv "$tmp" /usr/local/bin/"$check"
ok "$name"; INSTALLED+=("$name")
else
fail "$name — binary download failed (install manually)"
FAILED+=("$name")
fi
}
if [[ $OS == "macos" ]]; then
_do_sys "ripgrep" rg "$_I_RIPGREP" ripgrep "" "" ""
_do_sys "fzf" fzf "$_I_FZF" fzf "" "" ""
_do_sys "universal-ctags" ctags "$_I_CTAGS" universal-ctags "" "" ""
_do_sys "shellcheck" shellcheck "$_I_SHELLCHECK" shellcheck "" "" ""
_do_sys "hadolint" hadolint "$_I_HADOLINT" hadolint "" "" ""
_do_sys "marksman" marksman "$_I_MARKSMAN" marksman "" "" ""
elif [[ $HAS_APT -eq 1 ]]; then
[[ $HAS_SUDO -eq 1 ]] && sudo apt-get update -qq
_do_sys "ripgrep" rg "$_I_RIPGREP" "" ripgrep "" ""
_do_sys "fzf" fzf "$_I_FZF" "" fzf "" ""
_do_sys "universal-ctags" ctags "$_I_CTAGS" "" universal-ctags "" ""
_do_sys "shellcheck" shellcheck "$_I_SHELLCHECK" "" shellcheck "" ""
# hadolint: no apt package — binary from GitHub releases
if [[ $_I_HADOLINT -ge 0 ]] && _selected "$_I_HADOLINT"; then
HARCH=$(arch_github)
HVER=$(curl -fsSL https://api.github.com/repos/hadolint/hadolint/releases/latest \
| grep '"tag_name"' | cut -d'"' -f4 2>/dev/null) || HVER=""
if [[ -z "$HVER" ]]; then
fail "hadolint — could not determine latest release version"
FAILED+=("hadolint")
else
_do_binary_apt "hadolint" hadolint "$_I_HADOLINT" \
"https://github.com/hadolint/hadolint/releases/download/${HVER}/hadolint-Linux-${HARCH}" \
"$_TMPDIR/hadolint"
fi
else
skip "hadolint"; SKIPPED+=("hadolint")
fi
# marksman: no apt package — binary from GitHub releases
if [[ $_I_MARKSMAN -ge 0 ]] && _selected "$_I_MARKSMAN"; then
MARCH=$(arch_linux_x64)
MVER=$(curl -fsSL https://api.github.com/repos/artempyanykh/marksman/releases/latest \
| grep '"tag_name"' | cut -d'"' -f4 2>/dev/null) || MVER=""
if [[ -z "$MVER" ]]; then
fail "marksman — could not determine latest release version"
FAILED+=("marksman")
else
_do_binary_apt "marksman" marksman "$_I_MARKSMAN" \
"https://github.com/artempyanykh/marksman/releases/download/${MVER}/marksman-linux-${MARCH}" \
"$_TMPDIR/marksman"
fi
else
skip "marksman"; SKIPPED+=("marksman")
fi
elif [[ $HAS_PACMAN -eq 1 ]]; then
_do_sys "ripgrep" rg "$_I_RIPGREP" "" "" ripgrep ""
_do_sys "fzf" fzf "$_I_FZF" "" "" fzf ""
_do_sys "universal-ctags" ctags "$_I_CTAGS" "" "" ctags ""
_do_sys "shellcheck" shellcheck "$_I_SHELLCHECK" "" "" shellcheck ""
_do_sys "hadolint" hadolint "$_I_HADOLINT" "" "" hadolint ""
_do_sys "marksman" marksman "$_I_MARKSMAN" "" "" marksman ""
elif [[ $HAS_DNF -eq 1 ]]; then
_do_sys "ripgrep" rg "$_I_RIPGREP" "" "" "" ripgrep
_do_sys "fzf" fzf "$_I_FZF" "" "" "" fzf
_do_sys "shellcheck" shellcheck "$_I_SHELLCHECK" "" "" "" ShellCheck
if [[ $_I_CTAGS -ge 0 ]]; then
if _selected "$_I_CTAGS"; then
skip "universal-ctags — Fedora: install manually: sudo dnf install ctags"
fi
SKIPPED+=("universal-ctags")
fi
if [[ $_I_HADOLINT -ge 0 ]]; then
if _selected "$_I_HADOLINT"; then
skip "hadolint — Fedora: install manually: https://github.com/hadolint/hadolint/releases"
fi
SKIPPED+=("hadolint")
fi
if [[ $_I_MARKSMAN -ge 0 ]]; then
if _selected "$_I_MARKSMAN"; then
skip "marksman — Fedora: install manually: https://github.com/artempyanykh/marksman/releases"
fi
SKIPPED+=("marksman")
fi
else
warn "Unknown distro — skipping system tools (install manually)"
SKIPPED+=("ripgrep" "fzf" "universal-ctags" "shellcheck" "hadolint" "marksman")
fi
fi # end HAS_PKG_MGR
# ============================================================================
# 6. npm Tools
# ============================================================================
step "npm tools (formatters + linters)"
if [[ $HAS_NODE -eq 0 ]]; then
skip "npm tools (Node.js not installed)"
SKIPPED+=("prettier" "markdownlint-cli" "stylelint" "eslint" "typescript")
elif [[ $_PROFILE_TOOLING -eq 0 ]]; then
skip "npm tools (minimal profile)"
SKIPPED+=("prettier" "markdownlint-cli" "stylelint" "eslint" "typescript")
elif [[ $_I_NPM -lt 0 ]] || ! _selected "$_I_NPM"; then
skip "npm formatter suite (skipped by user)"
SKIPPED+=("prettier" "markdownlint-cli" "stylelint" "eslint" "typescript")
else
npm_install() {
local pkg="$1" check="${2:-$1}"
if command -v "$check" >/dev/null 2>&1; then
ok "$pkg (already installed)"; return
fi
if npm install -g "$pkg" >/dev/null 2>&1; then
ok "$pkg"; INSTALLED+=("$pkg")
else
fail "$pkg"; FAILED+=("$pkg")
fi
}
npm_install prettier
npm_install markdownlint-cli markdownlint
npm_install stylelint
npm_install stylelint-config-standard
npm_install eslint
npm_install typescript tsc
fi
# ============================================================================
# 7. Python Tools
# ============================================================================
step "Python tools (formatters + linters)"
if [[ $HAS_PIP -eq 0 ]]; then
skip "Python tools (pip3 not installed)"
SKIPPED+=("black" "isort" "flake8" "pylint" "yamllint" "sqlfluff")
elif [[ $_PROFILE_TOOLING -eq 0 ]]; then
skip "Python tools (minimal profile)"
SKIPPED+=("black" "isort" "flake8" "pylint" "yamllint" "sqlfluff")
elif [[ $_I_PYTHON -lt 0 ]] || ! _selected "$_I_PYTHON"; then
skip "Python tool suite (skipped by user)"
SKIPPED+=("black" "isort" "flake8" "pylint" "yamllint" "sqlfluff")
else
pip_install() {
local pkg="$1" check="${2:-$1}"
if command -v "$check" >/dev/null 2>&1; then
ok "$pkg (already installed)"; return
fi
if pip3 install --quiet "$pkg" 2>/dev/null; then
ok "$pkg"; INSTALLED+=("$pkg")
elif pip3 install --quiet --break-system-packages "$pkg" 2>/dev/null; then
warn "$pkg installed with --break-system-packages (consider using a virtualenv)"
ok "$pkg"; INSTALLED+=("$pkg")
else
fail "$pkg"; FAILED+=("$pkg")
fi
}
pip_install black
pip_install isort
pip_install flake8
pip_install pylint
pip_install yamllint
pip_install sqlfluff
fi
# ============================================================================
# 8. Go Tools
# ============================================================================
step "Go tools"
if [[ $HAS_GO -eq 0 ]]; then
skip "Go tools (go not installed — see https://go.dev/dl/)"
SKIPPED+=("gopls" "goimports" "staticcheck")
elif [[ $_PROFILE_TOOLING -eq 0 ]]; then
skip "Go tools (minimal profile)"
SKIPPED+=("gopls" "goimports" "staticcheck")
elif [[ $_I_GO -lt 0 ]] || ! _selected "$_I_GO"; then
skip "Go tool suite (skipped by user)"
SKIPPED+=("gopls" "goimports" "staticcheck")
else
GOBIN="$(go env GOPATH)/bin"
export PATH="$PATH:$GOBIN"
go_install() {
local name="$1" pkg="$2" check="$3"
if command -v "$check" >/dev/null 2>&1 || [[ -x "$GOBIN/$check" ]]; then
ok "$name (already installed)"; return
fi
if go install "$pkg" >/dev/null 2>&1; then
ok "$name"; INSTALLED+=("$name")
else
fail "$name"; FAILED+=("$name")
fi
}
go_install gopls "golang.org/x/tools/gopls@latest" gopls
go_install goimports "golang.org/x/tools/cmd/goimports@latest" goimports
go_install staticcheck "honnef.co/go/tools/cmd/staticcheck@latest" staticcheck
echo "$PATH" | grep -q "$GOBIN" || \
warn "Add Go binaries to PATH: export PATH=\"\$PATH:$GOBIN\""
fi
# ============================================================================
# 9. tmux: vim-tmux-navigator integration
# ============================================================================
step "tmux: vim-tmux-navigator integration"
if ! command -v tmux >/dev/null 2>&1; then
skip "tmux not found — skipping navigator config"
SKIPPED+=("tmux-navigator-config")
elif [[ $_I_TMUX -lt 0 ]]; then
: # already configured — noted earlier
elif ! _selected "$_I_TMUX"; then
skip "tmux navigator config (skipped by user)"
SKIPPED+=("tmux-navigator-config")
else
TMUX_CONF="$HOME/.tmux.conf"
TMUX_BEGIN="# >>> chopsticks vim-tmux-navigator >>>"
TMUX_END="# <<< chopsticks vim-tmux-navigator <<<"
if [[ -f "$TMUX_CONF" ]] && grep -Fq "$TMUX_BEGIN" "$TMUX_CONF"; then
tmp="$_TMPDIR/tmux.conf"
awk -v begin="$TMUX_BEGIN" -v end="$TMUX_END" '
$0 == begin { skip = 1; next }
$0 == end { skip = 0; next }
!skip { print }
' "$TMUX_CONF" > "$tmp"
mv "$tmp" "$TMUX_CONF"
fi
cat >> "$TMUX_CONF" << TMUXEOF
$TMUX_BEGIN
is_vim="ps -o state= -o comm= -t '#{pane_tty}' | grep -iqE '^[^TXZ ]+ +(\S+\/)?g?(view|n?vim?x?)(diff)?$'"
bind-key -n 'C-h' if-shell "\$is_vim" 'send-keys C-h' 'select-pane -L'
bind-key -n 'C-j' if-shell "\$is_vim" 'send-keys C-j' 'select-pane -D'
bind-key -n 'C-k' if-shell "\$is_vim" 'send-keys C-k' 'select-pane -U'
bind-key -n 'C-l' if-shell "\$is_vim" 'send-keys C-l' 'select-pane -R'
$TMUX_END
TMUXEOF
ok "vim-tmux-navigator bindings appended to ~/.tmux.conf"
warn "Reload tmux config now: tmux source-file ~/.tmux.conf"
warn "Note: C-l now navigates panes instead of clearing the screen."
warn " To restore clear: add 'bind C-l send-keys C-l' to ~/.tmux.conf"
INSTALLED+=("tmux-navigator-config")
fi
# ============================================================================
# 10. LSP language servers
# ============================================================================
step "LSP language servers"
info "vim-lsp installs language servers on demand — no action needed here."
info ""
info "To install a server: open a source file in Vim and run:"
info " :LspInstallServer"
info ""
info "Supported: Python, JS/TS, Go, Rust, C/C++, Shell, HTML, CSS, JSON, YAML, Markdown, SQL"
info ""
info "For Markdown LSP (marksman), the installer already handled it above."
# ============================================================================
# Summary
# ============================================================================
echo ""
echo -e "${BOLD}=======================================${NC}"
echo -e "${GREEN}Installation complete.${NC}"
echo -e "${BOLD}=======================================${NC}"
echo -e "\n${BOLD}Profile:${NC} $CONFIG_PROFILE"
if [[ -f "$LOCAL_CONFIG" ]]; then
echo " Local config: $LOCAL_CONFIG"
fi
if [[ ${#INSTALLED[@]} -gt 0 ]]; then
echo -e "\n${GREEN}Installed:${NC}"
for t in "${INSTALLED[@]}"; do echo " + $t"; done
fi
if [[ ${#SKIPPED[@]} -gt 0 ]]; then
echo -e "\n${CYAN}Skipped:${NC}"
for t in "${SKIPPED[@]}"; do echo " - $t"; done
fi
if [[ ${#FAILED[@]} -gt 0 ]]; then
echo -e "\n${RED}Failed (install manually):${NC}"
for t in "${FAILED[@]}"; do echo " ! $t"; done
echo ""
echo " To debug failures: ./install.sh 2>&1 | tee /tmp/chopsticks-install.log"
fi
echo ""
echo -e "${BOLD}---------------------------------------${NC}"
echo -e "${BOLD} You're ready. Open Vim with:${NC}"
echo -e "${BOLD}---------------------------------------${NC}"
echo -e " ${CYAN}vim${NC} Launch startup dashboard"
echo -e " ${CYAN}vim .${NC} Open dashboard in current directory"
echo -e " ${CYAN}vim myfile${NC} Edit a specific file"
echo ""
echo -e "${BOLD} First steps inside Vim${NC}"
echo -e " ${CYAN}Esc${NC} or ${CYAN}jk${NC} Exit insert mode → back to Normal"
echo -e " ${CYAN}:q!${NC} + Enter Emergency quit without saving"
echo -e " ${CYAN},x${NC} Save and quit"
echo -e " ${CYAN},?${NC} Open cheat sheet"
echo -e " ${CYAN}:LspInstallServer${NC} Install LSP for current filetype"
echo ""
echo -e "${YELLOW}[!]${NC} Ctrl+s is mapped to save in Vim."
echo " If it freezes your terminal, add this to ~/.bashrc or ~/.zshrc:"
echo -e " ${CYAN}stty -ixon${NC}"
echo ""