You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

325 lines
11 KiB

#!/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 <path-to-repo> [slug]
# ./add-project.sh --remove <path-or-slug>
#
# 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/<slug>/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 <path> -> 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 <slug> <path> (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 <slug-or-path>
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}/<slug>/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 <file> <fresh-content> <jq-merge-expr> <url>
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 <project-dir> <slug>
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 <path-or-slug>"
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 <path-to-repo> [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 <slug> <path> -> 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