The backend container is the sidecar's only client and reaches it through host.docker.internal (docker's host-gateway = docker0). install.sh now binds there (SIDECAR_BIND overrides), keeping the run API off the LAN and the tailnet; StartLimitIntervalSec=0 keeps retrying until docker made the bridge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
110 lines
4.8 KiB
Bash
Executable File
110 lines
4.8 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# install.sh — set up the claude-sidecar as a systemd *user* service.
|
|
#
|
|
# The sidecar must run natively on the host (not in Docker) as the repo owner so
|
|
# the `claude -p` sessions it spawns use the real ~/.claude auth, hooks and
|
|
# skills. This installs a venv, writes a systemd user unit, and starts it.
|
|
#
|
|
# Reboot survival needs lingering (`sudo loginctl enable-linger $USER`); we try
|
|
# it but it's fine if it fails — the service still runs for the current login.
|
|
set -euo pipefail
|
|
|
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
PROJECT_ROOT="$(cd "$HERE/.." && pwd)" # sidecar/ -> repo root
|
|
VENV="$HERE/.venv"
|
|
PORT="${SIDECAR_PORT:-8790}"
|
|
UNIT_DIR="$HOME/.config/systemd/user"
|
|
UNIT="$UNIT_DIR/claude-sidecar.service"
|
|
|
|
# The .env holding SIDECAR_TOKEN (+ optional SIDECAR_PORT), single source of
|
|
# truth shared with the docker-compose deployment. Override with
|
|
# SIDECAR_ENV_FILE; defaults to this repo's .env, then the homelab checkout's
|
|
# (where the compose stack that proxies to this sidecar reads it from).
|
|
ENV_FILE="${SIDECAR_ENV_FILE:-}"
|
|
if [[ -z "$ENV_FILE" ]]; then
|
|
# The first one that actually holds the token: this repo's .env can exist
|
|
# for other settings, and grepping a token-less file dies under pipefail.
|
|
for f in "$PROJECT_ROOT/.env" "$HOME/homelab/.env"; do
|
|
[[ -f "$f" ]] && grep -q '^SIDECAR_TOKEN=' "$f" && { ENV_FILE="$f"; break; }
|
|
done
|
|
fi
|
|
if [[ -n "$ENV_FILE" && -f "$ENV_FILE" ]]; then
|
|
TOKEN="$(grep -E '^SIDECAR_TOKEN=' "$ENV_FILE" | head -1 | cut -d= -f2-)"
|
|
PORT="$(grep -E '^SIDECAR_PORT=' "$ENV_FILE" | head -1 | cut -d= -f2- || true)"
|
|
PORT="${PORT:-8790}"
|
|
ACCOUNTS_ENV="$(grep -E '^SIDECAR_ACCOUNTS=' "$ENV_FILE" | head -1 | cut -d= -f2- || true)"
|
|
fi
|
|
[[ -n "${TOKEN:-}" ]] || { echo "error: SIDECAR_TOKEN not found (looked in ${ENV_FILE:-\$PROJECT_ROOT/.env, ~/homelab/.env})" >&2; exit 1; }
|
|
|
|
# Where spawned `claude -p` sessions start. Defaults to the dir the .env came
|
|
# from (the homelab checkout there), overridable with SIDECAR_CWD.
|
|
SIDECAR_CWD="${SIDECAR_CWD:-$(dirname "$ENV_FILE")}"
|
|
|
|
# Claude accounts a run can launch on besides the default (~/.claude): comma-
|
|
# separated id=config-dir pairs, exported per run as CLAUDE_CONFIG_DIR. From the
|
|
# environment, else the .env's SIDECAR_ACCOUNTS, else a work seat at
|
|
# ~/.claude-work when that dir exists (scripts/setup-claude-work.sh makes it).
|
|
SIDECAR_ACCOUNTS="${SIDECAR_ACCOUNTS:-${ACCOUNTS_ENV:-}}"
|
|
if [[ -z "$SIDECAR_ACCOUNTS" && -d "$HOME/.claude-work" ]]; then
|
|
SIDECAR_ACCOUNTS="work=$HOME/.claude-work"
|
|
fi
|
|
|
|
# The interface uvicorn listens on. Its only client is the backend container,
|
|
# which reaches the host through `host.docker.internal` = docker's host-gateway,
|
|
# i.e. the default bridge's gateway (docker0). Binding there instead of 0.0.0.0
|
|
# keeps the run API off the LAN and the tailnet. SIDECAR_BIND overrides (e.g.
|
|
# 0.0.0.0 on a box where the container reaches the host some other way).
|
|
if [[ -z "${SIDECAR_BIND:-}" ]]; then
|
|
SIDECAR_BIND="$(ip -4 -o addr show docker0 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)"
|
|
SIDECAR_BIND="${SIDECAR_BIND:-127.0.0.1}"
|
|
fi
|
|
|
|
echo "==> venv + deps ($VENV)"
|
|
command -v uv >/dev/null || { echo "error: 'uv' not found on PATH" >&2; exit 1; }
|
|
[[ -d "$VENV" ]] || uv venv "$VENV"
|
|
uv pip install --quiet --python "$VENV" -r "$HERE/requirements.txt"
|
|
|
|
echo "==> writing $UNIT"
|
|
mkdir -p "$UNIT_DIR"
|
|
cat > "$UNIT" <<EOF
|
|
[Unit]
|
|
Description=claude-sidecar — spawns \`claude -p\` sessions for the ai-agent viewer
|
|
After=network-online.target
|
|
# Bound to the docker bridge (SIDECAR_BIND): until docker has created it the
|
|
# bind fails, so keep retrying instead of hitting systemd's start limit.
|
|
StartLimitIntervalSec=0
|
|
|
|
[Service]
|
|
Type=simple
|
|
WorkingDirectory=$HERE
|
|
Environment=SIDECAR_TOKEN=$TOKEN
|
|
Environment=SIDECAR_CWD=$SIDECAR_CWD
|
|
Environment=SIDECAR_ACCOUNTS=$SIDECAR_ACCOUNTS
|
|
Environment=PATH=$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin
|
|
ExecStart=$VENV/bin/uvicorn sidecar:app --host $SIDECAR_BIND --port $PORT
|
|
Restart=on-failure
|
|
RestartSec=3
|
|
# Spawned \`claude -p\` runs are detached daemons (start_new_session). KillMode=
|
|
# process makes systemd signal only uvicorn on stop/restart, so redeploying the
|
|
# sidecar (e.g. while Claude is editing it) leaves those in-flight runs alive;
|
|
# the restarted sidecar re-discovers them from logs/*.pid.
|
|
KillMode=process
|
|
|
|
[Install]
|
|
WantedBy=default.target
|
|
EOF
|
|
|
|
echo "==> enabling + starting"
|
|
systemctl --user daemon-reload
|
|
systemctl --user enable --now claude-sidecar.service
|
|
sleep 1
|
|
systemctl --user --no-pager status claude-sidecar.service | head -8 || true
|
|
|
|
# Survive logout/reboot (needs privileges; ignore failure).
|
|
loginctl enable-linger "$USER" 2>/dev/null \
|
|
&& echo "==> lingering enabled (survives logout/reboot)" \
|
|
|| echo "note: run 'sudo loginctl enable-linger $USER' for reboot survival"
|
|
|
|
echo "==> done. health: curl -s $SIDECAR_BIND:$PORT/health"
|