#!/usr/bin/env sh # One-time bootstrap for the covert-gpt MCP gateway on a user's laptop. # # curl -fsSL https://git.nic-oconnor.com/public/dotfiles/raw/branch/main/mcp-config/install.sh | sh # # It is interactive (prompts for the user's LiteLLM key) UNLESS LITELLM_KEY is # already set in the environment, e.g.: # curl -fsSL .../install.sh | LITELLM_KEY=sk-... sh # # What it installs (all under the user's home, no root): # ~/.config/mcp-sync/sync.sh the per-shell refresh hook (from Gitea) # ~/.config/mcp-sync/key the user's LiteLLM virtual key (chmod 600) # a line in the user's shell rc that sources sync.sh on each shell start # After this, every new shell refreshes the gateway config automatically. set -eu # Fallback source, used ONLY when this script is not run from a checkout. # A Gitea *API* endpoint, not the web /raw/ route: /raw/ answers an # unauthorized request with a 200 and an HTML login page, which `curl -f` # reports as success. The API returns an honest 401/403. Set GITEA_KEY to read # a private repo. API_BASE="${MCP_SYNC_API_BASE:-https://git.nic-oconnor.com/api/v1/repos/public/dotfiles}" REF="${MCP_SYNC_REF:-main}" SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd 2>/dev/null)" || SCRIPT_DIR="" SYNC_HOME="$HOME/.config/mcp-sync" SYNC_SH="$SYNC_HOME/sync.sh" KEYFILE="$SYNC_HOME/key" HOOK_MARKER="# >>> covert-gpt mcp-sync >>>" say() { printf '%s\n' "$*" >&2; } command -v curl >/dev/null 2>&1 || { say "error: curl is required"; exit 1; } mkdir -p "$SYNC_HOME" # --- 1. install the sync hook ---------------------------------------------- # Prefer the copy sitting next to this script: when bootstrap.sh runs the # module from a checkout there is no reason to go to the network, and it makes # the module work on a box that cannot reach Gitea at all. if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/sync.sh" ]; then say "Installing mcp-sync hook from $SCRIPT_DIR/sync.sh" cp "$SCRIPT_DIR/sync.sh" "$SYNC_SH" else SYNC_URL="$API_BASE/raw/mcp-config/sync.sh?ref=$REF" say "Fetching mcp-sync hook..." # Fetch to a temp file and CHECK IT before overwriting the installed hook. # The API endpoint gives an honest 4xx, but keep the shebang check as a # backstop: if anything ever writes an HTML login page over sync.sh, the shell # fails to parse it, the hook silently does nothing, OPENCODE_CONFIG is never # exported, and opencode comes up with no covert-gpt provider at all. _tmp="$(mktemp)" || { say "error: mktemp failed"; exit 1; } if [ -n "${GITEA_KEY:-}" ]; then set -- -H "Authorization: token $GITEA_KEY" else set -- fi if ! curl -fsSL -m 15 "$@" "$SYNC_URL" -o "$_tmp" || [ ! -s "$_tmp" ]; then rm -f "$_tmp" say "error: could not fetch $SYNC_URL" [ -n "${GITEA_KEY:-}" ] || say " The repo may be private — export GITEA_KEY and re-run." exit 1 fi case "$(head -c 2 "$_tmp")" in '#!') mv "$_tmp" "$SYNC_SH" ;; *) rm -f "$_tmp" say "error: $SYNC_URL did not return a script." exit 1 ;; esac fi chmod 0644 "$SYNC_SH" # --- 1b. record the checkout as the config source -------------------------- # THE POINT OF THIS FILE: bootstrap.sh already cloned this repo over # authenticated git, so the per-shell hook must never have to re-fetch the # gateway config anonymously over HTTP. Needing that was the only reason the # repo ever had to be world-readable. Record where we ran from and sync.sh # reads both the config and the skills straight off disk. if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/opencode.gateway.json" ]; then if REPO_DIR="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd 2>/dev/null)"; then printf '%s\n' "$REPO_DIR" > "$SYNC_HOME/source" say "Config source: $REPO_DIR (local checkout, no network needed)." fi fi # --- 2. capture the LiteLLM key ------------------------------------------- # An already-stored key is as good as one in the env: bootstrap.sh re-runs this # module on every pass, and prompting a box that is already configured would # make an otherwise unattended bootstrap hang on a TTY read. if [ "${LITELLM_KEY:-}" = "" ] && [ -f "$KEYFILE" ]; then LITELLM_KEY="$(cat "$KEYFILE" 2>/dev/null || true)" [ -n "$LITELLM_KEY" ] && say "Reusing the key already stored at $KEYFILE." fi if [ "${LITELLM_KEY:-}" = "" ]; then # Read from the controlling TTY since stdin is the piped script. if [ -r /dev/tty ]; then printf 'Paste your LiteLLM virtual key (sk-...): ' >&2 IFS= read -r LITELLM_KEY < /dev/tty else say "error: no LITELLM_KEY in env and no TTY to prompt." say "re-run as: curl ... | LITELLM_KEY=sk-... sh" exit 1 fi fi LITELLM_KEY="$(printf '%s' "$LITELLM_KEY" | tr -d ' \t\r\n')" case "$LITELLM_KEY" in sk-*) : ;; *) say "warning: key does not start with 'sk-' — continuing anyway." ;; esac printf '%s' "$LITELLM_KEY" > "$KEYFILE" chmod 0600 "$KEYFILE" say "Key stored at $KEYFILE (0600)." # --- 3. wire the shell rc -------------------------------------------------- # For zsh the hook goes in ~/.zshrc.local, NOT ~/.zshrc. The dotfiles repo # MANAGES ~/.zshrc and overwrites it wholesale on every bootstrap # (zsh/install.sh), so a hook written there survives only until the next # bootstrap run — and then models and skills silently vanish, because sync.sh # never runs and OPENCODE_CONFIG is never exported. ~/.zshrc.local is the # unmanaged file the managed zshrc sources last, so the hook persists. # # Do NOT pick the file from $SHELL alone. On a fresh box bootstrap runs this # from the user's ORIGINAL shell: zsh/ has just switched the login shell to zsh, # but $SHELL in this process still says bash. The hook then went into ~/.bashrc, # below zsh/'s auto-exec hook, so every new terminal exec'd into zsh before # reaching it, zsh never sourced sync.sh, and opencode came up unconfigured. # The dotfiles-managed ~/.zshrc (or zsh as the account's login shell) wins. _login_shell="$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f7)" if grep -qs 'Managed by dotfiles/zsh/install.sh' "$HOME/.zshrc"; then RC="$HOME/.zshrc.local" else case "${_login_shell:-${SHELL:-}}" in *zsh) RC="$HOME/.zshrc.local" ;; *bash) RC="$HOME/.bashrc" ;; *) [ -f "$HOME/.zshrc" ] && RC="$HOME/.zshrc.local" || RC="$HOME/.bashrc" ;; esac fi touch "$RC" # It sits alongside the user's secrets; keep it non-readable by others. [ "$RC" = "$HOME/.zshrc.local" ] && chmod 0600 "$RC" 2>/dev/null || true if grep -qF "$HOOK_MARKER" "$RC" 2>/dev/null; then say "Shell hook already present in $RC." else { printf '\n%s\n' "$HOOK_MARKER" printf '%s\n' '[ -f "$HOME/.config/mcp-sync/sync.sh" ] && . "$HOME/.config/mcp-sync/sync.sh"' printf '%s\n' "# <<< covert-gpt mcp-sync <<<" } >> "$RC" say "Added mcp-sync hook to $RC." fi # --- 4. run it once now so the config exists immediately ------------------- # shellcheck disable=SC1090 . "$SYNC_SH" || true # If this is the initial install (key just written now), restart Hermes. # The key file mtime changed, but we need to compare hashes to know if it's # actually new (not just a touch). Only restart if we're not in a re-run. if [ -f "$KEYFILE" ]; then KEY_HASH="$(sha256sum "$KEYFILE" 2>/dev/null | cut -d' ' -f1)" PREV_KEY_HASH="" if [ -f "$HOME/.config/mcp-sync/.key-hash" ]; then PREV_KEY_HASH="$(cat "$HOME/.config/mcp-sync/.key-hash" 2>/dev/null || true)" fi if [ "$KEY_HASH" != "$PREV_KEY_HASH" ]; then # Key changed - update .env and restart Hermes if running if [ -f "$HOME/.hermes/.env" ]; then say "hermes: Key changed during install — updating ~/.hermes/.env" if command -v sed >/dev/null 2>&1; then sed -i "s|^LITELLM_KEY=.*$|LITELLM_KEY=$LITELLM_KEY|" "$HOME/.hermes/.env" else awk -v key="$LITELLM_KEY" '/^LITELLM_KEY=/ {print "LITELLM_KEY=" key; next} {print}' "$HOME/.hermes/.env" >"$HOME/.hermes/.env.tmp" && mv "$HOME/.hermes/.env.tmp" "$HOME/.hermes/.env" fi chmod 0600 "$HOME/.hermes/.env" fi if pgrep -f 'hermes-agent/hermes' >/dev/null 2>&1; then HERMES_PID="$(pgrep -f 'hermes-agent/hermes')" say "hermes: Restarting to pick up new key (PID: $HERMES_PID)..." kill "$HERMES_PID" || true sleep 1 (cd "$HOME" && nohup hermes >/dev/null 2>&1 &) say "hermes: Hermes restarted." fi fi printf '%s\n' "$KEY_HASH" >"$HOME/.config/mcp-sync/.key-hash" fi say "" say "Done. Open a new terminal (or 'source $RC') and run opencode." say "The gateway config refreshes automatically on each new shell."