#!/usr/bin/env bash # # Register (or remove) a project repository with the local MCP codebase-index # gateway managed by this repo's docker-compose stack. # # ./add-project.sh [slug] # ./add-project.sh --remove # # What "register" does: # 1. records the project in projects.env (slug=path) - the list of indexed repos # 2. regenerates supergateway.projects.jsonc - one MCP endpoint per project: # http://localhost:8001//mcp # 3. recreates the mcp-codebase-index container with the updated config # 4. builds the initial code index (keyword + call graph, works offline) # 5. writes AI-client config snippets INTO the project repo so every tool # finds its index automatically when opened in that directory: # opencode.json (opencode project config) # .crush.json (crush project config) # .roo/mcp.json (Roo Code / Cline / Kilo Code workspace config) # and adds ".opencode/" to the project's .gitignore (index data lives there) # set -euo pipefail REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$REPO_DIR" PROJECTS_ENV="$REPO_DIR/projects.env" GENERATED_CONFIG="$REPO_DIR/supergateway.projects.jsonc" GATEWAY_PORT=8001 INDEX_CONFIG=/etc/codebase-index/config.json SERVICE=mcp-codebase-index die() { echo "ERROR: $*" >&2; exit 1; } note() { echo; echo "== $*"; } http_get() { if command -v curl >/dev/null 2>&1; then curl -fsS -m 3 "$1" 2>/dev/null || true elif command -v wget >/dev/null 2>&1; then wget -qO- -T 3 "$1" 2>/dev/null || true fi } slugify() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/^-+//; s/-+$//' } json_escape() { sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' <<<"$1"; } # --------------------------------------------------------------------------- # projects.env helpers # --------------------------------------------------------------------------- env_get() { # env_get -> slug (or empty) local p line key val p="$1" [[ -f "$PROJECTS_ENV" ]] || return 0 while IFS= read -r line || [[ -n "$line" ]]; do [[ -z "$line" || "$line" == \#* ]] && continue key="${line%%=*}"; val="${line#*=}" [[ "$val" == "$p" ]] && { echo "$key"; return 0; } done < "$PROJECTS_ENV" return 0 } env_set() { # env_set (idempotent, keeps order) local slug="$1" path="$2" tmp line key val tmp="$(mktemp)" [[ -f "$PROJECTS_ENV" ]] || touch "$PROJECTS_ENV" while IFS= read -r line || [[ -n "$line" ]]; do if [[ -z "$line" || "$line" == \#* ]]; then printf '%s\n' "$line" >> "$tmp"; continue; fi key="${line%%=*}"; val="${line#*=}" # drop lines that match either the same path or the same slug [[ "$val" == "$path" || "$key" == "$slug" ]] && continue printf '%s\n' "$line" >> "$tmp" done < "$PROJECTS_ENV" printf '%s=%s\n' "$slug" "$path" >> "$tmp" mv "$tmp" "$PROJECTS_ENV" } env_del() { # env_del local target="$1" tmp line key val tmp="$(mktemp)" while IFS= read -r line || [[ -n "$line" ]]; do if [[ -z "$line" || "$line" == \#* ]]; then printf '%s\n' "$line" >> "$tmp"; continue; fi key="${line%%=*}"; val="${line#*=}" [[ "$val" == "$target" || "$key" == "$target" ]] && continue printf '%s\n' "$line" >> "$tmp" done < "$PROJECTS_ENV" mv "$tmp" "$PROJECTS_ENV" } regenerate_config() { local line slug path first=1 { echo "// GENERATED by add-project.sh from projects.env - do not edit by hand." echo "// Each project is served at http://localhost:${GATEWAY_PORT}//mcp" echo "// Re-run ./add-project.sh after editing projects.env." echo "{" echo " \"outputTransport\": \"streamableHttp\"," echo " \"stateful\": true," echo " \"sessionTimeout\": 3600000," echo " \"mcpServers\": {" while IFS= read -r line || [[ -n "$line" ]]; do [[ -z "$line" || "$line" == \#* ]] && continue slug="${line%%=*}"; path="${line#*=}" [[ -z "$slug" || -z "$path" ]] && continue [[ $first -eq 0 ]] && echo "," first=0 printf ' "%s": { "stdio": "open-codebase-index-mcp --config %s --project %s" }' \ "$(json_escape "$slug")" "$INDEX_CONFIG" "$(json_escape "$path")" done < "$PROJECTS_ENV" echo "" echo " }" echo "}" } > "$GENERATED_CONFIG" } # --------------------------------------------------------------------------- # .env bootstrap # --------------------------------------------------------------------------- ensure_env() { if [[ ! -f "$REPO_DIR/.env" ]]; then cp "$REPO_DIR/.env.example" "$REPO_DIR/.env" 2>/dev/null || touch "$REPO_DIR/.env" echo "Created .env (remember to set BRAVE_API_KEY there)." fi if ! grep -qE '^PROJECTS_ROOT=' "$REPO_DIR/.env"; then printf 'PROJECTS_ROOT=%s\n' "${PROJECTS_ROOT:-$HOME/src}" >> "$REPO_DIR/.env" echo "Set PROJECTS_ROOT=${PROJECTS_ROOT:-$HOME/src} in .env" fi PROJECTS_ROOT="$(grep -E '^PROJECTS_ROOT=' "$REPO_DIR/.env" | tail -1 | cut -d= -f2-)" [[ -d "$PROJECTS_ROOT" ]] || die "PROJECTS_ROOT=$PROJECTS_ROOT does not exist (edit .env)." } # --------------------------------------------------------------------------- # client config snippets written into the target project # --------------------------------------------------------------------------- write_client_config() { # write_client_config local file="$1" fresh="$2" merge="$3" url="$4" mkdir -p "$(dirname "$file")" if [[ -f "$file" ]]; then if command -v jq >/dev/null 2>&1; then if jq --arg u "$url" "$merge" "$file" > "$file.tmp" 2>/dev/null; then mv "$file.tmp" "$file" echo " updated $file" else rm -f "$file.tmp" echo " NOTE: could not merge into $file (invalid JSON?) - add manually:" echo " codebase-index -> $url" fi else echo " NOTE: $file exists and jq is not installed - merge manually:" echo " codebase-index -> $url" fi else printf '%s\n' "$fresh" > "$file" echo " created $file" fi } write_project_configs() { # write_project_configs local dir="$1" slug="$2" local url="http://localhost:${GATEWAY_PORT}/${slug}/mcp" write_client_config "$dir/opencode.json" \ "{ \"\$schema\": \"https://opencode.ai/config.json\", \"mcp\": { \"codebase-index\": { \"type\": \"remote\", \"url\": \"$url\", \"enabled\": true, \"timeout\": 600000 } } }" \ '.mcp = ((.mcp // {}) | ."codebase-index" = {type:"remote", url:$u, enabled:true, timeout:600000})' \ "$url" write_client_config "$dir/.crush.json" \ "{ \"\$schema\": \"https://charm.land/crush.json\", \"mcp\": { \"codebase-index\": { \"type\": \"http\", \"url\": \"$url\", \"timeout\": 600000 } } }" \ '.mcp = ((.mcp // {}) | ."codebase-index" = {type:"http", url:$u, timeout:600000})' \ "$url" write_client_config "$dir/.roo/mcp.json" \ "{ \"mcpServers\": { \"codebase-index\": { \"type\": \"streamableHttp\", \"url\": \"$url\", \"disabled\": false } } }" \ '.mcpServers = ((.mcpServers // {}) | ."codebase-index" = {type:"streamableHttp", url:$u, disabled:false})' \ "$url" if [[ -f "$dir/.gitignore" ]] && ! grep -qE '^\.opencode/?$' "$dir/.gitignore"; then printf '\n# codebase index data (dockerized open-codebase-index)\n.opencode/\n' >> "$dir/.gitignore" echo " updated .gitignore (.opencode/)" elif [[ ! -f "$dir/.gitignore" ]]; then printf '.opencode/\n' > "$dir/.gitignore" echo " created .gitignore (.opencode/)" fi } # --------------------------------------------------------------------------- # commands # --------------------------------------------------------------------------- cmd_remove() { local target="${1:-}" [[ -n "$target" ]] || die "usage: $0 --remove " if [[ -d "$target" ]]; then target="$(realpath -e "$target")"; fi [[ -f "$PROJECTS_ENV" ]] || die "no projects registered yet" env_del "$target" regenerate_config note "Recreating the index gateway" docker compose up -d --build >/dev/null echo "Removed '$target' from the index gateway." echo "Left in place (safe to delete by hand): client config snippets in the" echo "project (opencode.json / .crush.json / .roo/mcp.json) and its .opencode/ index data." } cmd_add() { local target="${1:-}" slug="${2:-}" [[ -n "$target" ]] || die "usage: $0 [slug]" target="$(realpath -e "$target")" || die "path not found: $target" [[ -d "$target/.git" ]] || die "not a git repository: $target (the index is branch-aware and needs git)" ensure_env [[ "$target" == "$PROJECTS_ROOT" || "$target" == "$PROJECTS_ROOT"/* ]] || { die "$target is not under PROJECTS_ROOT=$PROJECTS_ROOT. Either move the project under that directory, or expose its parent too: create a docker-compose.override.yml next to this compose file containing services: mcp-codebase-index: volumes: - /extra/root:/extra/root then add 'PROJECTS_ROOT' style entries by hand in projects.env and re-run this script." } # slug: explicit, or from directory name, deduped against existing entries if [[ -z "$slug" ]]; then slug="$(slugify "$(basename "$target")")" [[ -n "$slug" ]] || slug="project" if [[ -n "$(env_get_other_slug "$slug" "$target")" ]]; then slug="$(slugify "$(basename "$(dirname "$target")")")-$slug" fi else slug="$(slugify "$slug")" [[ -n "$slug" ]] || die "invalid slug" fi local existing; existing="$(env_get "$target")" if [[ -n "$existing" && "$existing" != "$slug" ]]; then echo "Project already registered as '$existing' - renaming to '$slug'." elif [[ -n "$existing" ]]; then echo "Project already registered as '$existing' - refreshing." fi env_set "$slug" "$target" regenerate_config note "Starting the stack (first build can take a few minutes)" local log; log="$(mktemp)" if ! docker compose up -d --build >"$log" 2>&1; then tail -n 40 "$log" die "docker compose up failed" fi rm -f "$log" echo "Waiting for the index gateway..." local i ok="" for i in $(seq 1 30); do [[ "$(http_get "http://localhost:${GATEWAY_PORT}/healthz")" == "ok" ]] && { ok=1; break; } sleep 1 done [[ -n "$ok" ]] || die "index gateway did not become healthy (see: docker compose logs $SERVICE)" note "Building the code index for $target (keyword + call graph, offline)" docker compose exec -T "$SERVICE" ocbi index --project "$target" --config "$INDEX_CONFIG" note "Writing AI-client config snippets into $target" write_project_configs "$target" "$slug" note "Done" echo " MCP endpoint : http://localhost:${GATEWAY_PORT}/${slug}/mcp" echo echo "Open $target with your AI tool and it picks the config up automatically:" echo " opencode / crush : (re)start in that directory, or run 'opencode reload'" echo " VS Code (Cline/Roo/Kilo): reopen the workspace" } env_get_other_slug() { # env_get_other_slug -> prints slug if taken by another path local slug="$1" path="$2" line key val [[ -f "$PROJECTS_ENV" ]] || return 0 while IFS= read -r line || [[ -n "$line" ]]; do [[ -z "$line" || "$line" == \#* ]] && continue key="${line%%=*}"; val="${line#*=}" [[ "$key" == "$slug" && "$val" != "$path" ]] && { echo "$key"; return 0; } done < "$PROJECTS_ENV" return 0 } case "${1:-}" in --remove|-r) shift; cmd_remove "$@" ;; -h|--help) sed -n '2,20p' "$0" ;; *) cmd_add "$@" ;; esac