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
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
|
|
|