#!/usr/bin/env bash # # LeadMagic CLI — Installer (macOS + Linux) # # Uses the system Bun runtime — requires Bun installed on the host. # Install Bun: curl -fsSL https://bun.sh/install | bash # # Does NOT install, modify, or interfere with any existing Bun installation. # # Install: curl -fsSL https://releases.leadmagic.io/install.sh | bash # Uninstall: curl -fsSL https://releases.leadmagic.io/install.sh | bash -s -- --uninstall # # Environment variables: # VERSION=vX.Y.Z Pin a specific release tag/version # LEADMAGIC_INSTALL_DIR Override install directory (default: ~/.leadmagic) # SKIP_CHECKSUM=1 Skip SHA-256 verification # REMOVE_DATA=1 Also remove config/database on uninstall # LEADMAGIC_AUTO_SETUP=0 Skip automatic db/health/login bootstrap # LEADMAGIC_PATH_ALL_SHELLS=1 Add PATH to every detected shell rc (default: auto from $SHELL + peer configs) # LEADMAGIC_INSTALL_COMPLETIONS=1 Run lm completions install after install (default: off) # # PATH management: # The installer only adds lines tagged with "# LeadMagic CLI — PATH". # It never rewrites, reformats, or removes any other content in shell rc files. # Uninstall removes ONLY tagged lines — your dotfiles are never touched otherwise. # # When piping (curl | bash), use args for install dir: ... | bash -s -- --install-dir /path # set -euo pipefail # ── Constants ───────────────────────────────────────────────────────────────── RELEASES="https://releases.leadmagic.io" INSTALL_DIR="${LEADMAGIC_INSTALL_DIR:-${HOME}/.leadmagic}" BIN_DIR="${INSTALL_DIR}/bin" VERSION="${VERSION:-latest}" SKIP_CHECKSUM="${SKIP_CHECKSUM:-0}" LEADMAGIC_AUTO_SETUP="${LEADMAGIC_AUTO_SETUP:-1}" LEADMAGIC_PATH_ALL_SHELLS="${LEADMAGIC_PATH_ALL_SHELLS:-0}" LEADMAGIC_INSTALL_COMPLETIONS="${LEADMAGIC_INSTALL_COMPLETIONS:-0}" # The marker we use to tag every line we add to shell rc files. # Uninstall only removes lines containing this marker — nothing else. PATH_MARKER="# LeadMagic CLI — PATH" # Expected file permissions PERM_CONFIG=0600 # config.json — only owner can read/write PERM_BIN=0755 # lm, leadmagic — executable by all, writable by owner PERM_DIR=0755 # directories # ── Colors ──────────────────────────────────────────────────────────────────── R='\033[0;31m'; G='\033[0;32m'; Y='\033[1;33m'; B='\033[0;34m'; C='\033[0;36m' W='\033[1;37m'; N='\033[0m'; D='\033[2m'; BOLD='\033[1m' STEP=0 TOTAL=7 step() { STEP=$((STEP + 1)); echo -e " ${D}[${STEP}/${TOTAL}]${N} $1"; } ok() { echo -e " ${D}[${STEP}/${TOTAL}]${N} ${G}✓${N} $1"; } info() { echo -e " ${D}[${STEP}/${TOTAL}]${N} ${B}ℹ${N} $1"; } warn() { echo -e " ${D}[${STEP}/${TOTAL}]${N} ${Y}⚠${N} $1"; } err() { echo -e " ${D}[${STEP}/${TOTAL}]${N} ${R}✗${N} $1"; exit 1; } note() { echo -e " ${D} ${N} $1"; } # ── Cleanup trap ────────────────────────────────────────────────────────────── DOWNLOAD_PID="" TMP="" cleanup() { [ -n "${DOWNLOAD_PID:-}" ] && kill "$DOWNLOAD_PID" 2>/dev/null || true [ -n "${TMP:-}" ] && [ -d "$TMP" ] && rm -rf "$TMP" true } trap cleanup EXIT # ── Helpers ─────────────────────────────────────────────────────────────────── human_bytes() { local bytes="$1" if [ "$bytes" -ge 1073741824 ] 2>/dev/null; then echo "$((bytes / 1073741824))G" elif [ "$bytes" -ge 1048576 ] 2>/dev/null; then echo "$((bytes / 1048576))M" elif [ "$bytes" -ge 1024 ] 2>/dev/null; then echo "$((bytes / 1024))K" else echo "${bytes}B"; fi } # Remove only lines containing our PATH_MARKER from a shell rc file. # Uses a temp file + mv for atomicity. Never touches other lines. scrub_path_lines() { local rc="$1" [ -f "$rc" ] || return 0 grep -vF "$PATH_MARKER" "$rc" > "${rc}.lm_tmp" 2>/dev/null || true mv "${rc}.lm_tmp" "$rc" 2>/dev/null || true } # Add our PATH export to a shell rc file (only if not already present). add_path_line() { local rc="$1" local shell_type="$2" # Already has our PATH marker? Skip. [ -f "$rc" ] && grep -qF "$PATH_MARKER" "$rc" 2>/dev/null && return 0 # Create parent dir if needed (e.g. fish config) mkdir -p "$(dirname "$rc")" # Add a blank line + our marker + PATH export echo "" >> "$rc" echo "$PATH_MARKER" >> "$rc" case "$shell_type" in fish) echo "set -gx PATH ${BIN_DIR} \$PATH" >> "$rc" ;; *) echo "export PATH=\"${BIN_DIR}:\$PATH\"" >> "$rc" ;; esac } # ── Uninstall ───────────────────────────────────────────────────────────────── uninstall() { echo "" echo -e " ${W}${BOLD}LeadMagic CLI — Uninstaller${N}" echo "" local removed=0 # 1. Remove binaries if [ -d "$BIN_DIR" ]; then rm -rf "$BIN_DIR" echo -e " ${G}✓${N} Removed ${BIN_DIR}" removed=1 fi # 2. Remove old backups for bak in "${INSTALL_DIR}"/bin.bak*; do [ -d "$bak" ] || continue rm -rf "$bak" echo -e " ${G}✓${N} Removed backup ${bak}" removed=1 done # 3. Handle data directory if [ -d "$INSTALL_DIR" ]; then local has_data=0 for item in config.json data.db sessions export logs; do [ -e "${INSTALL_DIR}/${item}" ] && has_data=1 && break done if [ "$has_data" = "1" ]; then echo "" echo -e " ${Y}⚠${N} Data directory exists: ${INSTALL_DIR}" echo -e " ${D}Contains: config, database, chat sessions, exports${N}" echo "" if [ "${REMOVE_DATA:-0}" = "1" ]; then rm -rf "$INSTALL_DIR" echo -e " ${G}✓${N} Removed all data: ${INSTALL_DIR}" removed=1 else echo -e " To also remove data: ${W}REMOVE_DATA=1${N} curl -fsSL https://releases.leadmagic.io/install.sh | bash -s -- --uninstall" echo -e " Or manually: ${D}rm -rf ${INSTALL_DIR}${N}" echo "" fi else rm -rf "$INSTALL_DIR" echo -e " ${G}✓${N} Removed ${INSTALL_DIR}" removed=1 fi fi # 4. Scrub PATH lines from shell rc files — ONLY our tagged lines for rc in "${HOME}/.zshrc" "${HOME}/.bashrc" "${HOME}/.bash_profile" "${HOME}/.profile" "${HOME}/.config/fish/config.fish"; do if [ -f "$rc" ] && grep -qF "$PATH_MARKER" "$rc" 2>/dev/null; then scrub_path_lines "$rc" echo -e " ${G}✓${N} Cleaned LeadMagic PATH from ${rc}" removed=1 fi done # 5. Result if [ "$removed" = "0" ]; then echo -e " ${B}ℹ${N} Nothing to remove — LeadMagic CLI is not installed" else echo "" echo -e " ${G}✓${N} LeadMagic CLI uninstalled" echo "" echo -e " ${D}Restart your terminal to update PATH${N}" echo "" fi exit 0 } # ── Parse args ──────────────────────────────────────────────────────────────── INSTALL_DIR_ARG="" ARGS=("$@") skip_idx=-1 for i in "${!ARGS[@]}"; do [ "$i" -eq "$skip_idx" ] && continue arg="${ARGS[$i]}" case "$arg" in --uninstall|uninstall) uninstall ;; --install-dir) next_i=$((i + 1)) [ "$next_i" -lt "${#ARGS[@]}" ] && INSTALL_DIR_ARG="${ARGS[$next_i]}" && skip_idx=$next_i ;; --help|-h) echo "Usage: curl -fsSL https://releases.leadmagic.io/install.sh | bash" echo " curl -fsSL ... | bash -s -- --install-dir /path" echo " VERSION=vX.Y.Z curl -fsSL ... | bash" echo "" echo "Uninstall: curl -fsSL https://releases.leadmagic.io/install.sh | bash -s -- --uninstall" echo " REMOVE_DATA=1 ... --uninstall (also removes config/database)" echo "" echo "Optional env:" echo " LEADMAGIC_PATH_ALL_SHELLS=1 Add PATH to every detected shell rc (default: login + peer bash/zsh if dotfiles exist)" echo " LEADMAGIC_INSTALL_COMPLETIONS=1 Run lm completions install (default: off)" exit 0 ;; esac done [ -n "$INSTALL_DIR_ARG" ] && INSTALL_DIR="$INSTALL_DIR_ARG" && BIN_DIR="${INSTALL_DIR}/bin" # ── Header ──────────────────────────────────────────────────────────────────── echo "" echo -e " ${C}◆${N} ${W}${BOLD}INSTALL${N} ${D}LeadMagic CLI${N}" echo "" # ── Step 1: Detect platform ─────────────────────────────────────────────────── STEP=1 KERNEL="$(uname -s)" MACHINE="$(uname -m)" case "$KERNEL" in Darwin) PLATFORM="darwin" ;; Linux) PLATFORM="linux" ;; MINGW*|MSYS*|CYGWIN*) err "Windows detected. Use PowerShell: irm https://releases.leadmagic.io/install.ps1 | iex" ;; FreeBSD|OpenBSD|NetBSD) err "BSD is not supported. Supported: macOS, Linux" ;; *) err "Unsupported OS: ${KERNEL}" ;; esac case "$MACHINE" in arm64|aarch64) ARCH="arm64" ;; x86_64|amd64) ARCH="x64" ;; i386|i486|i586|i686) err "32-bit x86 is not supported" ;; armv7l|armv6l) err "32-bit ARM is not supported" ;; s390x|ppc64le|ppc64|mips*) err "Architecture ${MACHINE} is not supported" ;; *) err "Unsupported architecture: ${MACHINE}" ;; esac PLATFORM_DETAIL="${PLATFORM}-${ARCH}" if [ "$PLATFORM" = "linux" ]; then if [ -f /etc/alpine-release ] || (ldd --version 2>&1 | grep -qi musl); then err "Alpine Linux (musl) is not supported — this build requires glibc" fi GLIBC_VER="" if command -v ldd &>/dev/null; then GLIBC_VER=$(ldd --version 2>&1 | head -1 | grep -oE '[0-9]+\.[0-9]+$' || true) fi if [ -n "$GLIBC_VER" ]; then GLIBC_MAJOR=$(echo "$GLIBC_VER" | cut -d. -f1) GLIBC_MINOR=$(echo "$GLIBC_VER" | cut -d. -f2) if [ "$GLIBC_MAJOR" -lt 2 ] || { [ "$GLIBC_MAJOR" -eq 2 ] && [ "$GLIBC_MINOR" -lt 17 ]; }; then err "glibc ${GLIBC_VER} — requires >= 2.17" fi PLATFORM_DETAIL="${PLATFORM}-${ARCH} (glibc ${GLIBC_VER})" fi fi ok "Detected ${PLATFORM_DETAIL}" # ── Step 2: Fetch version ───────────────────────────────────────────────────── STEP=2 META="" if [ "$VERSION" = "latest" ]; then VERSION="" META_URLS="${RELEASES}/latest/lm-${PLATFORM}-${ARCH}.metadata.json ${RELEASES}/latest/lm-metadata.json" for _try in 1 2 3; do for _url in $META_URLS; do META=$(curl -sL --connect-timeout 10 --max-time 15 "$_url" 2>/dev/null) || true VERSION=$(echo "$META" | grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/') || true [ -n "$VERSION" ] && break 2 done sleep 1 done [ -z "$VERSION" ] && err "Could not fetch version. Try: VERSION=vX.Y.Z curl -fsSL ... | bash" fi VERSION="${VERSION#v}" ok "Version v${VERSION}" # ── Step 3: Download ────────────────────────────────────────────────────────── STEP=3 TARBALL="lm-v${VERSION}-${PLATFORM}-${ARCH}.tar.gz" URL="${RELEASES}/v${VERSION}/${TARBALL}" TMP="$(mktemp -d)" FILE="${TMP}/${TARBALL}" # Get expected size from metadata EXPECTED_SIZE="" if [ -n "$META" ]; then META_PLATFORM=$(echo "$META" | grep -o '"platform"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/') || true META_ARCH=$(echo "$META" | grep -o '"arch"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/') || true if [ "$META_PLATFORM" = "$PLATFORM" ] && [ "$META_ARCH" = "$ARCH" ]; then EXPECTED_SIZE=$(echo "$META" | grep -o '"size_bytes"[[:space:]]*:[[:space:]]*[0-9]*' | grep -o '[0-9]*$') || true fi fi if [ -z "$EXPECTED_SIZE" ] || [ "$EXPECTED_SIZE" = "0" ]; then EXPECTED_SIZE=$(curl -sI --connect-timeout 10 --max-time 10 -L "$URL" 2>/dev/null \ | grep -i '^content-length' | tail -1 | awk '{print $2}' | tr -d '\r') || true fi EXPECTED_HUMAN="" [ -n "$EXPECTED_SIZE" ] && [ "$EXPECTED_SIZE" -gt 0 ] 2>/dev/null && \ EXPECTED_HUMAN=" (~$(human_bytes "$EXPECTED_SIZE"))" info "Downloading ${TARBALL}${EXPECTED_HUMAN}..." MAX_RETRIES=3 RETRY=0 DOWNLOAD_OK=0 DL_START=$(date +%s) while [ "$RETRY" -lt "$MAX_RETRIES" ]; do RETRY=$((RETRY + 1)) [ "$RETRY" -gt 1 ] && warn "Retry ${RETRY}/${MAX_RETRIES}..." && sleep 2 rm -f "$FILE" 2>/dev/null || true http_code=$(curl -SL \ --connect-timeout 15 \ --max-time 600 \ --retry 2 \ --retry-delay 3 \ --retry-connrefused \ -w '%{http_code}' \ -o "$FILE" \ "$URL" 2>/dev/null) || http_code="000" if [ "$http_code" = "200" ] && [ -s "$FILE" ]; then ACTUAL_SIZE=$(wc -c < "$FILE" | tr -d ' ') if [ -n "$EXPECTED_SIZE" ] && [ "$EXPECTED_SIZE" -gt 0 ] 2>/dev/null; then MIN_SIZE=$((EXPECTED_SIZE * 85 / 100)) if [ "$ACTUAL_SIZE" -lt "$MIN_SIZE" ]; then warn "Download truncated (got $(human_bytes "$ACTUAL_SIZE"), expected $(human_bytes "$EXPECTED_SIZE"))" rm -f "$FILE" 2>/dev/null || true continue fi fi DOWNLOAD_OK=1 break fi rm -f "$FILE" 2>/dev/null || true done if [ "$DOWNLOAD_OK" != "1" ] || [ ! -s "$FILE" ]; then err "Download failed (HTTP ${http_code}). Try: curl -SL -o /tmp/${TARBALL} ${URL}" fi DL_END=$(date +%s) DL_SECS=$((DL_END - DL_START)) FILE_SIZE=$(human_bytes "$(wc -c < "$FILE" | tr -d ' ')") ok "Downloaded ${TARBALL} (${FILE_SIZE} in ${DL_SECS}s)" # ── Step 4: Checksum verification ────────────────────────────────────────────── STEP=4 if [ "$SKIP_CHECKSUM" != "1" ]; then SUMS=$(curl -sL --connect-timeout 10 --max-time 15 "${RELEASES}/v${VERSION}/checksums.txt" 2>/dev/null) || true EXPECTED_HASH=$(echo "$SUMS" | grep -F " ${TARBALL}" | awk '{print $1}') [ -z "$EXPECTED_HASH" ] && err "Checksum for ${TARBALL} not found. Refusing to install. Set SKIP_CHECKSUM=1 to override." if command -v sha256sum &>/dev/null; then ACTUAL_HASH=$(sha256sum "$FILE" | awk '{print $1}') elif command -v shasum &>/dev/null; then ACTUAL_HASH=$(shasum -a 256 "$FILE" | awk '{print $1}') else rm -f "$FILE" err "No SHA-256 tool found. Install sha256sum/shasum or set SKIP_CHECKSUM=1 to override." fi if [ "$EXPECTED_HASH" != "$ACTUAL_HASH" ]; then rm -f "$FILE" err "Checksum mismatch — download corrupted. Try again." fi ok "Integrity verified (SHA-256)" else ok "Checksum ${D}skipped${N}" fi # ── Step 5: Extract & install ───────────────────────────────────────────────── STEP=5 EXT="${TMP}/extract" mkdir -p "$EXT" if ! tar xzf "$FILE" -C "$EXT" 2>/dev/null; then err "Archive corrupted. Try again." fi [ ! -f "${EXT}/lm.js" ] && err "Archive invalid (lm.js missing)" if [ -f "${EXT}/.runtime/bun" ]; then HAS_BUNDLED_BUN=1 else HAS_BUNDLED_BUN=0 fi # Create install directory with correct permissions mkdir -p "$BIN_DIR" chmod "$PERM_DIR" "$INSTALL_DIR" 2>/dev/null || true chmod "$PERM_DIR" "$BIN_DIR" 2>/dev/null || true # Back up existing install if upgrading if [ -f "${BIN_DIR}/lm.js" ]; then for bak in "${INSTALL_DIR}"/bin.bak*; do [ -d "$bak" ] || continue rm -rf "$bak" 2>/dev/null || true done mv "$BIN_DIR" "${INSTALL_DIR}/bin.bak" mkdir -p "$BIN_DIR" chmod "$PERM_DIR" "$BIN_DIR" fi cp -a "${EXT}"/. "$BIN_DIR/" # Write the launcher wrapper cat > "${BIN_DIR}/lm" << 'WRAP' #!/usr/bin/env bash set -euo pipefail d="$(cd "$(dirname "$0")" && pwd)" # Warp terminal intercepts terminal I/O for its Blocks UI. # The escape sequences and stty calls below cause Warp to echo garbage. # OpenTUI handles its own terminal setup/teardown, so skip wrapper-level # terminal manipulation entirely when running inside Warp. IS_WARP=0 [ "${TERM_PROGRAM:-}" = "WarpTerminal" ] && IS_WARP=1 # Reset terminal modes and drain ALL pending capability responses. # iTerm2/kitty send DCS/CSI responses 200-800ms after exit. # Strategy: disable modes, then keep reading until idle for 0.5s (max 2s total). restore_tty() { [ "$IS_WARP" = "1" ] && return 0 [ -t 0 ] || [ -t 1 ] || return 0 stty sane < /dev/tty 2>/dev/null || true # Disable mouse, focus, bracketed paste, kitty keyboard, synced output; show cursor; reset attrs printf '\033[?2004l\033[?1000l\033[?1002l\033[?1003l\033[?1004l\033[?1005l\033[?1006l\033[?1015l\033[?1016l\033[?2026l\033[?2027l\033[?25h\033[0m\033[>0u' > /dev/tty 2>/dev/null || true # Drain: read with 0.5s idle timeout, hard cap at 2s total local max_iter=40 # 40 * 0.05s = 2s max local idle=0 local i=0 while [ $i -lt $max_iter ]; do if IFS= read -r -t 0.05 -n 1 _junk < /dev/tty 2>/dev/null; then # Got a byte — keep draining, reset idle counter while IFS= read -r -t 0.01 -n 1 _junk < /dev/tty 2>/dev/null; do :; done idle=0 else idle=$((idle + 1)) # 10 consecutive idle reads at 0.05s = 0.5s of silence → done [ $idle -ge 10 ] && break fi i=$((i + 1)) done stty sane < /dev/tty 2>/dev/null || true } run_lm() { # Helper: validate Bun binary runs with --version (3s timeout) # Uses background process + kill instead of `timeout` (not available on macOS) validate_bun() { local bun_path="$1" "$bun_path" --version >/dev/null 2>&1 & local pid=$! local i=0 while [ $i -lt 30 ]; do if ! kill -0 "$pid" 2>/dev/null; then wait "$pid" 2>/dev/null return $? fi sleep 0.1 i=$((i + 1)) done kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null return 1 } # solid-js exports "node" → server.js (SSR, no reactivity). OpenTUI needs # the client build. Match OpenCode: --conditions=browser selects dist/solid.js # via package exports. The preload is defense-in-depth for Bun 1.3+ cache paths # that still resolve the node condition. Absolute --preload works from any CWD. local conditions="--conditions=browser" local preload="--preload=${d}/solid-preload.ts" # System Bun first — avoid bundled runtime segfaults for p in "${HOME}/.bun/bin/bun" /usr/local/bin/bun; do if [ -x "$p" ] && validate_bun "$p"; then "$p" "$conditions" "$preload" "${d}/lm.js" "$@" return $? fi done if command -v bun &>/dev/null; then if validate_bun bun; then bun "$conditions" "$preload" "${d}/lm.js" "$@" return $? fi fi # Last resort: bundled runtime (if present) if [ -x "${d}/.runtime/bun" ]; then if validate_bun "${d}/.runtime/bun"; then "${d}/.runtime/bun" "$conditions" "$preload" "${d}/lm.js" "$@" return $? fi fi echo "Error: Bun runtime not found. Install Bun: curl -fsSL https://bun.sh/install | bash" >&2 echo "Or reinstall: curl -fsSL https://releases.leadmagic.io/install.sh | bash" >&2 return 1 } # Quick pre-drain (0.1s) so stale bytes from install/previous cmd don't leak into lm chat quick_drain() { [ "$IS_WARP" = "1" ] && return 0 [ -t 0 ] || [ -t 1 ] || return 0 for _ in $(seq 1 5); do while IFS= read -r -t 0.02 -n 1 _junk < /dev/tty 2>/dev/null; do :; done sleep 0.02 done } quick_drain code=0 # Trap ensures cleanup on unexpected exits (SIGINT, SIGTERM, etc.) trap restore_tty EXIT run_lm "$@" || code=$? restore_tty exit "$code" WRAP chmod "$PERM_BIN" "${BIN_DIR}/lm" cp "${BIN_DIR}/lm" "${BIN_DIR}/leadmagic" chmod "$PERM_BIN" "${BIN_DIR}/leadmagic" RUNTIME_LABEL="system Bun" [ "$HAS_BUNDLED_BUN" = "1" ] && RUNTIME_LABEL="system Bun (bundled fallback available)" ok "Installed to ${BIN_DIR} ${D}(${RUNTIME_LABEL})${N}" note "${D}Permissions: bin ${PERM_BIN}, config ${PERM_CONFIG}, data dir ${PERM_DIR}${N}" # ── Step 6: Configure PATH ──────────────────────────────────────────────────── STEP=6 # Resolve rc path for a shell name (zsh | bash | fish) get_rc_file() { local shell="$1" case "$shell" in zsh) echo "${HOME}/.zshrc" ;; bash) if [ -f "${HOME}/.bashrc" ]; then echo "${HOME}/.bashrc" elif [ -f "${HOME}/.bash_profile" ]; then echo "${HOME}/.bash_profile" else echo "${HOME}/.profile" fi ;; fish) echo "${HOME}/.config/fish/config.fish" ;; *) echo "${HOME}/.profile" ;; esac } # Normalize $SHELL to zsh | bash | fish (handles /bin/zsh, /usr/local/bin/bash, etc.) case "$(basename "${SHELL:-/bin/bash}")" in zsh|zsh-*|-zsh) login_shell=zsh ;; bash|bash-*|-bash) login_shell=bash ;; fish|fish-*|-fish) login_shell=fish ;; *) login_shell=bash ;; esac SHELLS_TO_CONFIGURE=() SHELLS_TO_CONFIGURE+=("$login_shell") [ -n "${ZSH_VERSION:-}" ] && SHELLS_TO_CONFIGURE+=("zsh") if [ -r "/proc/$PPID/comm" ]; then ppid_name="$(tr -d '\0' < "/proc/$PPID/comm" 2>/dev/null || true)" case "$ppid_name" in zsh|bash|fish) SHELLS_TO_CONFIGURE+=("$ppid_name") ;; esac fi if [ -r "/proc/$PPID/status" ]; then grandppid=$(awk '/^PPid:/ {print $2}' /proc/$PPID/status 2>/dev/null || true) if [ -n "$grandppid" ] && [ -r "/proc/$grandppid/comm" ]; then gp_name="$(tr -d '\0' < "/proc/$grandppid/comm" 2>/dev/null || true)" case "$gp_name" in zsh|bash|fish) SHELLS_TO_CONFIGURE+=("$gp_name") ;; esac fi fi # Non-empty zshrc suggests zsh is used (ignore empty placeholder files) [ -s "${HOME}/.zshrc" ] && SHELLS_TO_CONFIGURE+=("zsh") RC_FILES=() _seen_rcs="" for _sh in "${SHELLS_TO_CONFIGURE[@]}"; do _rc="$(get_rc_file "$_sh")" case "$_seen_rcs" in *"|${_rc}|"*) continue ;; esac _seen_rcs="${_seen_rcs}|${_rc}|" RC_FILES+=("$_rc") done # PATH targets: all discovered rcs, or smart auto (login + peer shell when dotfiles exist) PATH_RC_FILES=() append_unique_path_rc() { local candidate="$1" local p for p in ${PATH_RC_FILES[@]+"${PATH_RC_FILES[@]}"}; do [ "$p" = "$candidate" ] && return 0 done PATH_RC_FILES+=("$candidate") } if [ "$LEADMAGIC_PATH_ALL_SHELLS" = "1" ]; then PATH_RC_FILES=(${RC_FILES[@]+"${RC_FILES[@]}"}) else append_unique_path_rc "$(get_rc_file "$login_shell")" # Peer shell: keep PATH consistent when both bash and zsh configs are in active use if [ "$login_shell" = "zsh" ]; then if [ -s "${HOME}/.bashrc" ] || [ -s "${HOME}/.bash_profile" ]; then append_unique_path_rc "$(get_rc_file bash)" fi elif [ "$login_shell" = "bash" ]; then if [ -s "${HOME}/.zshrc" ]; then append_unique_path_rc "$(get_rc_file zsh)" fi fi fi # One-line context for the user (non-interactive logs too) _path_rc_list="" for _rc in ${PATH_RC_FILES[@]+"${PATH_RC_FILES[@]}"}; do [ -n "$_path_rc_list" ] && _path_rc_list="${_path_rc_list}, " _path_rc_list="${_path_rc_list}$(basename "$_rc")" done note "${D}Shell:${N} login ${login_shell} ${D}(\$SHELL)${N} ${D}→${N} PATH blocks: ${_path_rc_list}" # Check if PATH is already configured in all target rc files ADD_TO_PATH=0 ALREADY_CONFIGURED=1 for _rc in ${PATH_RC_FILES[@]+"${PATH_RC_FILES[@]}"}; do if [ -f "$_rc" ] && grep -qF "$PATH_MARKER" "$_rc" 2>/dev/null; then continue fi ALREADY_CONFIGURED=0 break done if echo ":$PATH:" | grep -q ":${BIN_DIR}:" && [ "$ALREADY_CONFIGURED" = "1" ]; then true elif [ "$ALREADY_CONFIGURED" = "1" ]; then true elif [ -t 0 ] && [ -t 1 ]; then # Interactive: ask the user RC_DISPLAY=$(printf '%s' ${PATH_RC_FILES[*]+"${PATH_RC_FILES[*]}"} | tr ' ' ', ') echo "" echo -e " ${W}Add lm to your PATH?${N}" echo -e " ${D}File(s): ${RC_DISPLAY}${N}" echo -e " ${D}Only tagged lines will be added — your dotfiles are never modified otherwise${N}" if [ "$LEADMAGIC_PATH_ALL_SHELLS" = "1" ]; then echo -e " ${D}(all detected shell rc files — LEADMAGIC_PATH_ALL_SHELLS=1)${N}" elif [ "${#PATH_RC_FILES[@]}" -gt 1 ]; then echo -e " ${D}(login ${login_shell} + peer shell rc — both dotfiles in use)${N}" else echo -e " ${D}(login shell: ${login_shell})${N}" fi echo "" printf " Add to shell config? [Y/n] " read -r REPLY < /dev/tty 2>/dev/null || REPLY="" case "$REPLY" in [Nn]*) true ;; *) ADD_TO_PATH=1 ;; esac else # Non-interactive: auto-add ADD_TO_PATH=1 fi PATH_DETAIL="" if [ "$ADD_TO_PATH" = "1" ]; then for _rc in ${PATH_RC_FILES[@]+"${PATH_RC_FILES[@]}"}; do [ -f "$_rc" ] && grep -qF "$PATH_MARKER" "$_rc" 2>/dev/null && continue _rc_shell="$(basename "$_rc" | sed 's/^\.//')" case "$_rc_shell" in config.fish) add_path_line "$_rc" "fish" ;; *) add_path_line "$_rc" "sh" ;; esac [ -n "$PATH_DETAIL" ] && PATH_DETAIL="${PATH_DETAIL}, " PATH_DETAIL="${PATH_DETAIL}$(basename "$_rc")" done fi export PATH="${BIN_DIR}:$PATH" if [ -n "$PATH_DETAIL" ]; then ok "Added to PATH in ${PATH_DETAIL}" elif [ "$ALREADY_CONFIGURED" = "1" ]; then ok "PATH ${D}already configured${N}" else ok "PATH ${D}skipped${N}" fi # Detect duplicate lm launchers that can shadow hardened wrapper behavior LM_PATHS=() if type -aP lm >/dev/null 2>&1; then while IFS= read -r p; do [ -n "$p" ] || continue case " ${LM_PATHS[*]+"${LM_PATHS[*]}"} " in *" $p "*) ;; *) LM_PATHS+=("$p") ;; esac done < <(type -aP lm 2>/dev/null || true) fi if [ "${#LM_PATHS[@]}" -gt 1 ]; then warn "Multiple lm launchers detected on PATH" for p in "${LM_PATHS[@]}"; do echo -e " ${D}- ${p}${N}" done echo -e " ${D}Tip: keep ${BIN_DIR}/lm first on PATH${N}" fi if [ "${#LM_PATHS[@]}" -gt 0 ]; then if ! grep -q "restore_tty" "${LM_PATHS[0]}" 2>/dev/null; then warn "Primary lm launcher appears legacy (no terminal cleanup)" echo -e " ${D}Reinstall to refresh wrapper: curl -fsSL https://releases.leadmagic.io/install.sh | bash${N}" fi fi # ── Step 7: Verify ──────────────────────────────────────────────────────────── STEP=7 # Verify through the wrapper (uses system Bun first, bundled fallback) if [ -x "${BIN_DIR}/lm" ]; then LM_VER=$("${BIN_DIR}/lm" --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || true) else LM_VER="" fi if [ -n "$LM_VER" ]; then ok "Verified: lm v${LM_VER}" else warn "Could not verify — restart terminal and run: lm --version" fi # ── Step 7b: Install shell completions (opt-in) ─────────────────────────────── if [ -n "$LM_VER" ] && [ "$LEADMAGIC_INSTALL_COMPLETIONS" = "1" ]; then if "${BIN_DIR}/lm" completions install --shell "$login_shell" 2>/dev/null | grep -q "✓"; then note "${G}✓${N} Shell completions installed ${D}(${login_shell})${N}" else note "${B}ℹ${N} Shell completions ${D}skipped (install manually: lm completions install)${N}" fi fi # ── Cleanup old versions ────────────────────────────────────────────────────── if [ -n "$LM_VER" ]; then for bak in "${INSTALL_DIR}"/bin.bak*; do [ -d "$bak" ] || continue rm -rf "$bak" 2>/dev/null || true done fi # ── Auto bootstrap ──────────────────────────────────────────────────────────── if [ "$LEADMAGIC_AUTO_SETUP" = "1" ]; then if [ -x "${BIN_DIR}/lm" ]; then mkdir -p "${INSTALL_DIR}/sessions" "${INSTALL_DIR}/export" "${INSTALL_DIR}/logs" chmod "$PERM_DIR" "${INSTALL_DIR}/sessions" "${INSTALL_DIR}/export" "${INSTALL_DIR}/logs" 2>/dev/null || true if [ ! -f "${INSTALL_DIR}/config.json" ]; then echo '{}' > "${INSTALL_DIR}/config.json" chmod "$PERM_CONFIG" "${INSTALL_DIR}/config.json" fi note "${G}✓${N} Initialized directories and config" note "${D}Config permissions: ${PERM_CONFIG} (owner read/write only)${N}" # Post-install welcome screen (next steps, version, tips). # Skipped when output is piped (curl | bash) or LM_SKIP_WELCOME=1. if [ "${LM_SKIP_WELCOME:-0}" != "1" ] && [ -t 1 ]; then "${BIN_DIR}/lm" welcome >/dev/null 2>&1 || true fi fi fi # ── Get started ─────────────────────────────────────────────────────────────── echo "" echo -e " ${G}${BOLD}✓ LeadMagic CLI v${VERSION} installed${N}" echo "" echo -e " ${W}Next:${N}" case "$login_shell" in fish) echo -e " ${C}source ~/.config/fish/config.fish${N} ${D}reload shell${N}" ;; *) echo -e " ${C}exec \$SHELL -l${N} ${D}reload shell${N}" ;; esac echo -e " ${C}lm login${N} ${D}sign in and sync credentials${N}" echo -e " ${C}lm chat${N} ${D}open the workspace and load a CSV${N}" echo -e " ${C}lm completions install${N} ${D}optional: tab completion${N}" echo "" echo -e " ${D}Browser sign-in may mention localhost because the CLI uses a local callback.${N}" echo -e " ${D}Need help? lm help${N}" echo -e " ${D}Uninstall: curl -fsSL https://releases.leadmagic.io/install.sh | bash -s -- --uninstall${N}" echo ""