#!/usr/bin/env bash # bootstrap.sh — enroll a new client into the homelab context system. # # Usage: # curl -fsSL https://git.hubris.network/dtoro/Homelab-Docs/raw/main/bootstrap.sh \ # | sudo bash # curl ... | sudo bash -s -- --with-mcp # also wire Claude's .mcp.json # curl ... | sudo bash -s -- --with-hermes # also install Goose + Hermes wrapper # curl ... | sudo bash -s -- --dry-run # show what would happen # curl ... | sudo bash -s -- --no-secrets # skip age-key issuance # # Prerequisites the script verifies: # - running as root # - OS is Linux or macOS # - git, age, sops are installed # - at least one mesh (netbird OR tailscale) is connected # - this host has an `hosts/.yaml` entry in the repo (or refuses) set -euo pipefail # -------- defaults -------- REPO_HTTPS="${HOMELAB_REPO_URL:-https://git.hubris.network/dtoro/Homelab-Docs.git}" CLONE_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab-context}" ISSUANCE_URL_NETBIRD="${HOMELAB_ISSUANCE_NETBIRD:-https://secrets.hubris.network/issue}" ISSUANCE_URL_TAILSCALE="${HOMELAB_ISSUANCE_TAILSCALE:-https://secrets.hubris.network/issue}" MCP_URL="${HOMELAB_MCP_URL:-https://mcp.hubris.network/mcp}" HERMES_MCP_URI="${HOMELAB_HERMES_MCP_URI:-https://mcp.hubris.network/mcp}" HERMES_MODEL="${HOMELAB_HERMES_MODEL:-nousresearch/hermes-4-405b}" WITH_MCP=0 WITH_HERMES=0 DRY_RUN=0 NO_SECRETS=0 GITEA_TOKEN="${HOMELAB_GITEA_TOKEN:-}" GITEA_USER="${HOMELAB_GITEA_USER:-dtoro}" # -------- flag parsing -------- while [ $# -gt 0 ]; do case "$1" in --with-mcp) WITH_MCP=1; shift ;; --with-hermes) WITH_HERMES=1; shift ;; --dry-run) DRY_RUN=1; shift ;; --no-secrets) NO_SECRETS=1; shift ;; --gitea-token) GITEA_TOKEN="$2"; shift 2 ;; --gitea-user) GITEA_USER="$2"; shift 2 ;; --help|-h) sed -n '2,11p' "$0" | sed 's/^# *//' exit 0 ;; *) echo "unknown flag: $1" >&2; exit 2 ;; esac done # If a gitea token is provided, write it to the standard credential store # BEFORE the clone happens. The HTTPS REPO_HTTPS will then pick it up via # git's credential helper. configure_gitea_creds() { if [ -z "$GITEA_TOKEN" ]; then return 0; fi local creds_dir=/etc/homelab-context local creds_file=$creds_dir/git-credentials mkdir -p "$creds_dir" chmod 700 "$creds_dir" # Format the credential URL: ://user:token@host (scheme must match # the actual REPO_HTTPS — git's credential helper does exact prefix match). local proto host proto=$(echo "$REPO_HTTPS" | sed -E 's|^(https?)://.*|\1|') host=$(echo "$REPO_HTTPS" | sed -E 's|^https?://([^/]+).*|\1|') printf '%s://%s:%s@%s\n' "$proto" "$GITEA_USER" "$GITEA_TOKEN" "$host" > "$creds_file" chmod 600 "$creds_file" # Point git at this store system-wide (/etc/gitconfig) so the systemd # sync timer's git — which runs as root without HOME set — finds it. # --global writes to /root/.gitconfig which the timer doesn't read. git config --system credential.helper "store --file=$creds_file" echo "[bootstrap] wrote gitea credentials to $creds_file" } run() { if [ "$DRY_RUN" -eq 1 ]; then printf '+ %s\n' "$*" else eval "$*" fi } # Run a command as the enrolling human user when one exists (i.e. this # script was invoked via `sudo bash bootstrap.sh` from a real login), and # directly otherwise. Minimal Linux images (bare Proxmox/Debian installs # reached via `ssh root@host`) often don't even have a `sudo` binary # installed — calling `sudo -u root ...` on those unconditionally fails # with "sudo: command not found" even though we're already root and don't # need to switch users at all. run_as() { if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then sudo -u "$SUDO_USER" -- "$@" else "$@" fi } # -------- preflight -------- if [ "$(id -u)" -ne 0 ]; then echo "bootstrap.sh must run as root (use sudo)." >&2 exit 1 fi OS="$(uname -s)" case "$OS" in Linux|Darwin) ;; *) echo "unsupported OS: $OS" >&2; exit 1 ;; esac # Resolve hostname; on macOS prefer LocalHostName if set. if [ "$OS" = "Darwin" ]; then HNAME="$(scutil --get LocalHostName 2>/dev/null || hostname -s)" HNAME_ALT="$(hostname -s)" if [ "$HNAME" != "$HNAME_ALT" ]; then echo "note: scutil LocalHostName=$HNAME differs from hostname=$HNAME_ALT" echo " using LocalHostName for inventory lookup." fi else HNAME="$(hostname -s)" fi echo "[bootstrap] hostname: $HNAME" # Check dependencies. missing=() for cmd in git python3; do command -v "$cmd" >/dev/null || missing+=("$cmd"); done # The homelab CLI needs PyYAML. if ! python3 -c "import yaml" >/dev/null 2>&1; then missing+=("python3-yaml") fi if [ "$NO_SECRETS" -eq 0 ]; then for cmd in age sops; do command -v "$cmd" >/dev/null || missing+=("$cmd"); done fi # sops isn't a real Debian/Fedora package (there is no apt/dnf "sops"), so it # always needs the direct-binary-download path, on both distros. Only Darwin # (brew) can install it via a package manager. install_sops_binary() { local sops_version=v3.9.4 local arch arch="$(uname -m)" case "$arch" in x86_64|amd64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; *) echo "[bootstrap] unsupported arch for sops binary download: $arch" >&2; return 1 ;; esac curl -fsSL "https://github.com/getsops/sops/releases/download/${sops_version}/sops-${sops_version}.linux.${arch}" \ -o /usr/local/bin/sops && chmod +x /usr/local/bin/sops } if [ "${#missing[@]}" -gt 0 ]; then if [ "$DRY_RUN" -eq 1 ]; then echo "+ would install missing tools: ${missing[*]}" else echo "[bootstrap] installing missing tools: ${missing[*]}" if [ "$OS" = "Darwin" ]; then brew_list=() for m in "${missing[@]}"; do case "$m" in python3-yaml) python3 -m pip install --break-system-packages pyyaml >/dev/null 2>&1 \ || python3 -m pip install pyyaml ;; *) brew_list+=("$m") ;; esac done [ "${#brew_list[@]}" -gt 0 ] && brew install "${brew_list[@]}" elif command -v dnf >/dev/null 2>&1; then dnf_list=() for m in "${missing[@]}"; do case "$m" in python3-yaml) dnf_list+=("python3-pyyaml") ;; sops) install_sops_binary ;; *) dnf_list+=("$m") ;; esac done [ "${#dnf_list[@]}" -gt 0 ] && dnf install -y "${dnf_list[@]}" elif command -v apt-get >/dev/null 2>&1; then apt_list=() for m in "${missing[@]}"; do case "$m" in sops) install_sops_binary ;; *) apt_list+=("$m") ;; esac done if [ "${#apt_list[@]}" -gt 0 ]; then DEBIAN_FRONTEND=noninteractive apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y "${apt_list[@]}" fi else echo "[bootstrap] no supported package manager for: ${missing[*]}" >&2 echo "[bootstrap] install with your package manager + re-run" >&2 exit 1 fi # Re-verify (especially python yaml — the rename is the most common gotcha). for cmd in git python3; do command -v "$cmd" >/dev/null || { echo "[bootstrap] post-install $cmd still missing" >&2; exit 1; } done python3 -c "import yaml" 2>/dev/null \ || { echo "[bootstrap] post-install python3-yaml/pyyaml still missing" >&2; exit 1; } if [ "$NO_SECRETS" -eq 0 ]; then for cmd in age sops; do command -v "$cmd" >/dev/null || { echo "[bootstrap] post-install $cmd still missing" >&2; exit 1; } done fi fi fi # -------- ensure netbird is installed + connected (workstation/VM hosts) -------- # Skipped on --no-secrets (LXCs that route via the LAN already) and --dry-run. # Installs netbird if missing, then drives `netbird up` against the homelab # management server. The operator clicks the printed device-code URL once. if [ "$NO_SECRETS" -eq 0 ] && [ "$DRY_RUN" -eq 0 ]; then if ! command -v netbird >/dev/null 2>&1 && ! command -v tailscale >/dev/null 2>&1; then echo "[bootstrap] no mesh CLI found; installing netbird..." if [ "$OS" = "Darwin" ]; then brew install --cask netbird || { echo "[bootstrap] brew install netbird failed" >&2; exit 1; } elif command -v dnf >/dev/null 2>&1; then cat > /etc/yum.repos.d/netbird.repo <<'NBREPO' [netbird] name=NetBird stable baseurl=https://pkgs.netbird.io/yum/$basearch enabled=1 gpgcheck=0 NBREPO dnf install -y netbird netbird-ui || { echo "[bootstrap] dnf install netbird failed" >&2; exit 1; } elif command -v apt-get >/dev/null 2>&1; then install -d -m 0755 /usr/share/keyrings curl -fsSL https://pkgs.netbird.io/debian/public.key \ | gpg --dearmor -o /usr/share/keyrings/netbird-archive-keyring.gpg echo "deb [signed-by=/usr/share/keyrings/netbird-archive-keyring.gpg] https://pkgs.netbird.io/debian stable main" \ > /etc/apt/sources.list.d/netbird.list apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y netbird \ || { echo "[bootstrap] apt install netbird failed" >&2; exit 1; } else echo "[bootstrap] can't auto-install netbird on this OS; install manually + re-run" >&2 exit 1 fi fi # Bring netbird up if not already connected. if command -v netbird >/dev/null && ! netbird status 2>/dev/null | grep -q "Management: Connected"; then cat <&2; exit 1; } # `netbird up` returns once the device-code SSO completes; give the # mgmt connection ~30s to settle before continuing. for _ in $(seq 1 10); do netbird status 2>/dev/null | grep -q "Management: Connected" && break sleep 3 done if ! netbird status 2>/dev/null | grep -q "Management: Connected"; then echo "[bootstrap] netbird daemon not reporting Management: Connected after 30s" >&2 echo "[bootstrap] try: 'netbird status -d' and 'sudo journalctl -u netbird -n 30'" >&2 exit 1 fi echo "[bootstrap] netbird connected." fi fi # Mesh check — accept Netbird, Tailscale, or LAN reachability of the issuance # endpoint. LAN is fine for LXCs that don't run a mesh CLI but sit in the # trusted subnet (192.168.8.0/24) included in MESH_SUBNETS. MESH_CONNECTED="" if command -v netbird >/dev/null && netbird status 2>/dev/null | grep -q "Management: Connected"; then MESH_CONNECTED="netbird" elif command -v tailscale >/dev/null && tailscale status >/dev/null 2>&1; then MESH_CONNECTED="tailscale" elif curl -sf -o /dev/null --max-time 3 "${ISSUANCE_URL_NETBIRD%/issue}/health" 2>/dev/null; then MESH_CONNECTED="lan" fi if [ -z "$MESH_CONNECTED" ] && [ "$NO_SECRETS" -eq 0 ]; then echo "no reachable issuance endpoint (no netbird/tailscale connected and" >&2 echo "$ISSUANCE_URL_NETBIRD did not respond to /health)." >&2 echo "either bring up the mesh first, fix DNS for *.hubris.network, or pass --no-secrets." >&2 exit 1 fi echo "[bootstrap] mesh: ${MESH_CONNECTED:-none (skipped, --no-secrets)}" # -------- gitea creds (if provided) -------- configure_gitea_creds # -------- clone -------- if [ -d "$CLONE_DIR/.git" ]; then existing_remote="$(git -C "$CLONE_DIR" remote get-url origin 2>/dev/null || true)" if [ -n "$existing_remote" ] && [ "$existing_remote" != "$REPO_HTTPS" ]; then echo "$CLONE_DIR already exists with a different remote ($existing_remote);" >&2 echo "refusing to overwrite. Move it aside or set HOMELAB_REPO_URL." >&2 exit 1 fi echo "[bootstrap] clone exists; pulling" run "git -C '$CLONE_DIR' pull --ff-only --quiet" else echo "[bootstrap] cloning to $CLONE_DIR" run "git clone --quiet '$REPO_HTTPS' '$CLONE_DIR'" fi # -------- identity check -------- HOST_YAML="$CLONE_DIR/hosts/$HNAME.yaml" if [ ! -f "$HOST_YAML" ]; then cat >&2 <' on Linux, or System Preferences → Sharing on macOS.) EOF exit 1 fi # -------- secrets issuance -------- if [ "$NO_SECRETS" -eq 0 ]; then if [ "$MESH_CONNECTED" = "netbird" ]; then URL="$ISSUANCE_URL_NETBIRD" else URL="$ISSUANCE_URL_TAILSCALE" fi KEY_FILE=/etc/age/key.txt run "mkdir -p /etc/age && chmod 0700 /etc/age" if [ -f "$KEY_FILE" ]; then echo "[bootstrap] age key already exists at $KEY_FILE — verifying with issuance" fi echo "[bootstrap] requesting age key from $URL" if [ "$DRY_RUN" -eq 0 ]; then # The issuance endpoint identifies us by source mesh IP. No body needed. HTTP_CODE=$(curl -sS -o /tmp/homelab-age-key -w '%{http_code}' \ -X POST -H "Content-Type: application/json" \ -d "{\"hostname\":\"$HNAME\"}" \ "$URL") || HTTP_CODE=000 case "$HTTP_CODE" in 200) mv /tmp/homelab-age-key "$KEY_FILE" chmod 0600 "$KEY_FILE" echo "[bootstrap] age key installed at $KEY_FILE" # Capture the PR snippet (if returned in a sidecar header) so the # operator knows the pubkey to add to inventory.yaml. The server # includes it in the JSON response when generating a new key. if grep -q '"pubkey"' "$KEY_FILE" 2>/dev/null; then # Shouldn't happen — server should return raw key, not JSON. echo "[bootstrap] unexpected: key file contains JSON, please inspect" >&2 fi ;; 403) echo "[bootstrap] issuance returned 403 — caller not recognized" >&2 echo "is this peer in the Netbird/Tailscale console? is the hostname" >&2 echo "above ('$HNAME') matching the inventory entry?" >&2 exit 1 ;; *) echo "[bootstrap] issuance failed (HTTP $HTTP_CODE)" >&2 cat /tmp/homelab-age-key >&2 || true exit 1 ;; esac fi fi # -------- install sync timer / launchd plist -------- echo "[bootstrap] installing sync mechanism for $OS" run "bash '$CLONE_DIR/scripts/sync/install.sh'" # -------- install homelab CLI -------- # Symlink rather than copy so the 5-min sync auto-updates the CLI. echo "[bootstrap] linking homelab CLI to /usr/local/bin/homelab" run "ln -sfn '$CLONE_DIR/bin/homelab' /usr/local/bin/homelab" # -------- AGENTS.md symlink -------- case "$OS" in Linux) AGENTS_LINK=/root/AGENTS.md ;; Darwin) AGENTS_LINK=/etc/AGENTS.md ;; esac run "ln -sfn '$CLONE_DIR/AGENTS.md' '$AGENTS_LINK'" echo "[bootstrap] linked AGENTS.md → $AGENTS_LINK" # -------- auto-upgrade to write-scoped Gitea PAT -------- # After enrollment, if this client is already a recipient on # secrets/gitea-pat.yaml (i.e. the operator has run # `homelab client add --finalize-pubkey` from another client), swap the # read-only bootstrap PAT for the write-scoped one. Best-effort: fails # silently if the client isn't yet a recipient — the operator just re-runs # bootstrap or `homelab refresh-creds` later. if [ "$NO_SECRETS" -eq 0 ] && [ "$DRY_RUN" -eq 0 ] \ && command -v sops >/dev/null 2>&1 \ && [ -f "$CLONE_DIR/secrets/gitea-pat.yaml" ]; then if /usr/local/bin/homelab refresh-creds >/tmp/homelab-refresh-creds.log 2>&1; then echo "[bootstrap] refresh-creds: write-scoped Gitea PAT installed" else echo "[bootstrap] refresh-creds: skipped (this client isn't yet a recipient" echo " on secrets/gitea-pat.yaml — run 'homelab client add" echo " $HNAME --finalize-pubkey ' from an existing client," echo " then re-run bootstrap or 'homelab refresh-creds')" fi fi # -------- MCP wiring -------- if [ "$WITH_MCP" -eq 1 ]; then # Pick the right user's home — when invoked via sudo, SUDO_USER is set. if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then USER_HOME=$(eval echo "~$SUDO_USER") else USER_HOME="$HOME" fi MCP_CONFIG="$USER_HOME/.claude/.mcp.json" run "mkdir -p '$USER_HOME/.claude'" # Merge endpoint into existing config (or create new). Use python for the merge # because shell JSON juggling is error-prone. PY_MERGE=$(cat </dev/null 2>&1; then echo "[bootstrap] installing Goose CLI" if [ "$DRY_RUN" -eq 1 ]; then echo "+ would run upstream goose installer and symlink to /usr/local/bin/goose" else # Upstream installer drops the binary at ~/.local/bin/goose for the # invoking user. We run it as $H_USER (via run_as) then symlink # system-wide. run_as env CONFIGURE=false \ bash -c 'curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | bash' if [ -x "$H_HOME/.local/bin/goose" ]; then ln -sfn "$H_HOME/.local/bin/goose" /usr/local/bin/goose else echo "[bootstrap] WARNING: goose binary not found at $H_HOME/.local/bin/goose after install" >&2 fi fi else echo "[bootstrap] goose already installed: $(command -v goose)" fi # 2. Symlink hermes wrapper. echo "[bootstrap] linking hermes CLI to /usr/local/bin/hermes" run "ln -sfn '$CLONE_DIR/bin/hermes' /usr/local/bin/hermes" # 3. Symlink HERMES.md persona. The hermes wrapper does not need it — the # Goose config below references the canonical clone path — but operators # frequently `cat /root/HERMES.md` to inspect the persona, mirroring the # AGENTS.md convention above. case "$OS" in Linux) HERMES_LINK=/root/HERMES.md ;; Darwin) HERMES_LINK=/etc/HERMES.md ;; esac run "ln -sfn '$CLONE_DIR/HERMES.md' '$HERMES_LINK'" echo "[bootstrap] linked HERMES.md → $HERMES_LINK" # 4. Drop the Goose config. Idempotent YAML merge — preserves any keys the # operator added by hand, overwrites only the keys we manage. GOOSE_DIR="$H_HOME/.config/goose" GOOSE_CONFIG="$GOOSE_DIR/config.yaml" GOOSEHINTS="$GOOSE_DIR/.goosehints" run "mkdir -p '$GOOSE_DIR'" PY_GOOSE_MERGE=$(cat </dev/null || true fi fi # -------- netbird tuning (skip per-session SSO for ssh into mesh peers) -------- # Apply the SSH JWT cache TTL so `ssh ... .netbird.selfhosted` doesn't trigger # device-code SSO on every connection. Flag added in netbird 0.71.x # (netbirdio/netbird#4015). It belongs on `netbird up` — putting it on the # daemon's ExecStart crashes the daemon with "unknown flag". After this runs the # FIRST ssh still prompts SSO once; subsequent sessions within 24h skip it. if [ "$MESH_CONNECTED" = "netbird" ]; then if [ "$DRY_RUN" -eq 1 ]; then echo "+ would: netbird down && netbird up --ssh-jwt-cache-ttl=86400" elif netbird up --help 2>&1 | grep -q -- "--ssh-jwt-cache-ttl"; then echo "[bootstrap] netbird: enabling ssh-jwt-cache-ttl=86400 (one SSO per 24h)" # `netbird up` short-circuits with "Already connected" — need down first. netbird down >/dev/null 2>&1 || true if ! netbird up --ssh-jwt-cache-ttl=86400; then echo "[bootstrap] WARNING: netbird up with --ssh-jwt-cache-ttl failed; rerun manually:" echo "[bootstrap] netbird down && netbird up --ssh-jwt-cache-ttl=86400" fi else echo "[bootstrap] netbird: --ssh-jwt-cache-ttl flag not supported (need >=0.71.x); skipping" fi fi # -------- ssh ControlMaster for netbird peers (workstations) -------- # Drop a Host block into the enrolling user's ~/.ssh/config so that ssh to # `*.netbird.selfhosted` multiplexes over a single connection. After one SSO # device-code completion, subsequent ssh / scp / `pct exec` invocations # (within ControlPersist=2h) reuse the master socket with no re-auth — the # real workaround for netbird's flaky SSH JWT cache. Skip on LXCs (no # outbound ssh expected from them). HKIND="$(python3 -c "import yaml; print(yaml.safe_load(open('$HOST_YAML')).get('kind',''))" 2>/dev/null || true)" if [ "$MESH_CONNECTED" = "netbird" ] && [ "$HKIND" != "lxc" ]; then if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then SSH_USER_HOME=$(eval echo "~$SUDO_USER") SSH_OWNER="$SUDO_USER" else SSH_USER_HOME="$HOME" SSH_OWNER="" fi SSH_CFG="$SSH_USER_HOME/.ssh/config" SSH_CM_DIR="$SSH_USER_HOME/.ssh/cm" SENTINEL="# homelab-bootstrap: ssh ControlMaster for netbird mesh" if [ "$DRY_RUN" -eq 1 ]; then echo "+ would write Host *.netbird.selfhosted ControlMaster block into $SSH_CFG" elif [ -f "$SSH_CFG" ] && grep -qF "$SENTINEL" "$SSH_CFG"; then echo "[bootstrap] ssh ControlMaster block already present in $SSH_CFG (skip)" else mkdir -p "$SSH_USER_HOME/.ssh" "$SSH_CM_DIR" chmod 700 "$SSH_USER_HOME/.ssh" "$SSH_CM_DIR" cat >> "$SSH_CFG" <<'SSHEOF' # homelab-bootstrap: ssh ControlMaster for netbird mesh # One SSO covers many back-to-back ssh/scp/pct ops within ControlPersist. Host *.netbird.selfhosted ControlMaster auto ControlPath ~/.ssh/cm/%C ControlPersist 2h SSHEOF chmod 600 "$SSH_CFG" if [ -n "$SSH_OWNER" ]; then chown -R "$SSH_OWNER":"$SSH_OWNER" "$SSH_USER_HOME/.ssh" fi echo "[bootstrap] ssh: installed ControlMaster block into $SSH_CFG" fi fi # -------- mcp CLI install (workstations) -------- # `homelab mcp ` shells out to the `mcp` python CLI. Install it via # pipx for the enrolling user. Skip on LXCs / VMs. if [ "$HKIND" != "lxc" ] && [ "$HKIND" != "vm" ]; then if [ "$DRY_RUN" -eq 1 ]; then echo "+ would install 'mcp[cli]' via pipx for the enrolling user" elif command -v mcp >/dev/null 2>&1; then echo "[bootstrap] mcp CLI already on PATH (skip)" else # Make sure pipx is available; OS-specific install. if ! command -v pipx >/dev/null 2>&1; then if [ "$OS" = "Darwin" ] && command -v brew >/dev/null 2>&1; then run_as brew install pipx 2>&1 | tail -2 || true elif command -v dnf >/dev/null 2>&1; then dnf install -y pipx 2>&1 | tail -2 || true elif command -v apt-get >/dev/null 2>&1; then DEBIAN_FRONTEND=noninteractive apt-get install -y pipx 2>&1 | tail -2 || true fi fi if command -v pipx >/dev/null 2>&1; then INVOKING_USER="${SUDO_USER:-root}" run_as bash -lc "pipx install 'mcp[cli]'" 2>&1 | tail -3 || true run_as bash -lc "pipx ensurepath" >/dev/null 2>&1 || true echo "[bootstrap] mcp CLI installed for $INVOKING_USER via pipx" else echo "[bootstrap] WARNING: pipx unavailable; install manually: pipx install 'mcp[cli]'" >&2 fi fi fi # -------- done -------- cat <.yaml (key at $KEY_FILE)" fi if [ "$WITH_MCP" -eq 1 ]; then echo "MCP: merged into $MCP_CONFIG" fi