Files
context-kit/bin/context-kit

555 lines
17 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
SCRIPT_PATH="${BASH_SOURCE[0]}"
while [[ -L "${SCRIPT_PATH}" ]]; do
SCRIPT_DIR="$(cd -P "$(dirname "${SCRIPT_PATH}")" && pwd)"
SCRIPT_TARGET="$(readlink "${SCRIPT_PATH}")"
if [[ "${SCRIPT_TARGET}" = /* ]]; then
SCRIPT_PATH="${SCRIPT_TARGET}"
else
SCRIPT_PATH="${SCRIPT_DIR}/${SCRIPT_TARGET}"
fi
done
ROOT="$(cd -P "$(dirname "${SCRIPT_PATH}")/.." && pwd)"
ENV_FILE="${ROOT}/.env"
load_env_file() {
[[ -f "${ENV_FILE}" ]] || return 0
local line key value
while IFS= read -r line || [[ -n "${line}" ]]; do
line="${line%$'\r'}"
[[ -z "${line}" || "${line}" =~ ^[[:space:]]*# ]] && continue
[[ "${line}" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]] || fail "unsupported .env line: ${line}"
key="${BASH_REMATCH[1]}"
value="${BASH_REMATCH[2]}"
[[ "${key}" == CONTEXT_KIT_* ]] || fail ".env may only set CONTEXT_KIT_* variables: ${key}"
[[ "${!key+x}" == "x" ]] && continue
if [[ "${value}" == \"*\" && "${value}" == *\" ]]; then
value="${value:1:${#value}-2}"
elif [[ "${value}" == \'*\' && "${value}" == *\' ]]; then
value="${value:1:${#value}-2}"
fi
export "${key}=${value}"
done < "${ENV_FILE}"
}
fail() {
printf 'context-kit: %s\n' "$*" >&2
exit 1
}
load_env_file
if [[ -z "${CONTEXT_KIT_DATA_DIR:-}" && -z "${HOME:-}" ]]; then
fail "HOME or CONTEXT_KIT_DATA_DIR must be set"
fi
DEFAULT_DATA_DIR="${HOME:-}/.local/share/context-kit"
PROJECT="${CONTEXT_KIT_COMPOSE_PROJECT:-context-kit}"
COMPOSE_FILE="${ROOT}/compose.yml"
DATA_DIR="${CONTEXT_KIT_DATA_DIR:-${DEFAULT_DATA_DIR}}"
NETWORK="${PROJECT}_default"
SEARXNG_PORT="${CONTEXT_KIT_SEARXNG_PORT:-8099}"
DOCS_PORT="${CONTEXT_KIT_DOCS_PORT:-8776}"
DOCS_HTTP_URL="${CONTEXT_KIT_DOCS_HTTP_URL:-http://127.0.0.1:${DOCS_PORT}/mcp}"
WEB_SEARCH_MAX_BYTES="${CONTEXT_KIT_WEB_SEARCH_MAX_BYTES:-52428800}"
WEB_SEARCH_PROVIDER="${CONTEXT_KIT_WEB_SEARCH_PROVIDER:-searxng}"
WEB_SEARCH_HTTP_TIMEOUT="${CONTEXT_KIT_WEB_SEARCH_HTTP_TIMEOUT:-15000}"
WEB_SEARCH_MAX_RESULTS="${CONTEXT_KIT_WEB_SEARCH_MAX_RESULTS:-10}"
WEB_SEARCH_CHROME_PATH="${CONTEXT_KIT_WEB_SEARCH_CHROME_PATH:-/usr/bin/chromium}"
WEB_SEARCH_BROWSER_USER_AGENT="${CONTEXT_KIT_WEB_SEARCH_BROWSER_USER_AGENT:-}"
WEB_SEARCH_MCP_COMPAT_MODE="${CONTEXT_KIT_WEB_SEARCH_MCP_COMPAT_MODE:-}"
DOCS_SERVICE_NAME="docs-mcp"
DOCS_SOURCES_FILE="${DATA_DIR}/docs-sources.txt"
DOCS_DATA_DIR="${DATA_DIR}/docs"
MODELS_DATA_DIR="${DATA_DIR}/models"
DOCS_LOCAL_SOURCES_DIR="${CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR:-${DATA_DIR}/local-sources}"
DOCS_LOCAL_SOURCES_PORT="${CONTEXT_KIT_DOCS_LOCAL_SOURCES_PORT:-8769}"
WEB_SEARCH_IMAGE="${CONTEXT_KIT_WEB_SEARCH_IMAGE:-context-kit/web-search-mcp:latest}"
DOCS_IMAGE="${CONTEXT_KIT_DOCS_IMAGE:-context-kit/docs-mcp:latest}"
REPOMIX_IMAGE="${CONTEXT_KIT_REPOMIX_IMAGE:-ghcr.io/yamadashy/repomix@sha256:62fb288a3f031f99bc332b73c22acb9ff1cf2a5d8ef2f0196185d5926d9edb2a}"
usage() {
cat <<'USAGE'
context-kit: local context tools for coding agents
Usage:
context-kit start Start SearXNG + the long-lived docs-mcp service
context-kit stop Stop SearXNG + docs-mcp
context-kit restart Restart SearXNG + docs-mcp
context-kit build Build MCP images
context-kit status Show services, images, sources, and the docs HTTP endpoint
context-kit doctor Check Docker, services, images, sources, and HTTP endpoints
MCP server commands:
context-kit web-search Per-call SearXNG-backed web-search MCP (stdio)
context-kit docs Stdio bridge to the long-lived docs-mcp service
(clients that speak HTTP MCP should connect
directly to the URL printed by `status`)
context-kit repomix Per-call Repomix MCP for the current project (stdio)
Assistant snippets:
context-kit install claude Print a project .mcp.json snippet using context-kit on PATH
context-kit install opencode Print an opencode.json MCP snippet using context-kit on PATH
Configuration is via .env or environment variables. See .env.example.
USAGE
}
compose() {
CONTEXT_KIT_DATA_DIR="${DATA_DIR}" \
CONTEXT_KIT_SEARXNG_PORT="${SEARXNG_PORT}" \
CONTEXT_KIT_DOCS_PORT="${DOCS_PORT}" \
CONTEXT_KIT_DOCS_UID="$(id -u)" \
CONTEXT_KIT_DOCS_GID="$(id -g)" \
CONTEXT_KIT_DOCS_TTL="${CONTEXT_KIT_DOCS_TTL:-24h}" \
CONTEXT_KIT_DOCS_MAX_GET_BYTES="${CONTEXT_KIT_DOCS_MAX_GET_BYTES:-75000}" \
CONTEXT_KIT_DOCS_EMBED_MODEL="${CONTEXT_KIT_DOCS_EMBED_MODEL:-BAAI/bge-small-en-v1.5}" \
CONTEXT_KIT_DOCS_PREINDEX="${CONTEXT_KIT_DOCS_PREINDEX:-0}" \
CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR="${DOCS_LOCAL_SOURCES_DIR}" \
CONTEXT_KIT_DOCS_LOCAL_SOURCES_PORT="${DOCS_LOCAL_SOURCES_PORT}" \
CONTEXT_KIT_WEB_SEARCH_IMAGE="${WEB_SEARCH_IMAGE}" \
CONTEXT_KIT_DOCS_IMAGE="${DOCS_IMAGE}" \
BUILDX_BUILDER="${CONTEXT_KIT_BUILDX_BUILDER:-default}" \
docker compose -p "${PROJECT}" -f "${COMPOSE_FILE}" "$@"
}
require_no_args() {
local usage_text="$1"
shift
[[ "$#" -eq 0 ]] || fail "${usage_text}"
}
write_docs_sources_file() {
mkdir -p "$(dirname "${DOCS_SOURCES_FILE}")"
local tmp="${DOCS_SOURCES_FILE}.tmp.$$"
{
printf '# generated by context-kit start; edit your CONTEXT_KIT_DOCS_SOURCES file(s) instead\n'
resolved_sources
} > "${tmp}"
mv "${tmp}" "${DOCS_SOURCES_FILE}"
}
ensure_writable_dir() {
local dir="$1"
mkdir -p "${dir}"
if [[ ! -w "${dir}" || ! -x "${dir}" ]]; then
fail "data directory is not writable by uid $(id -u): ${dir}; fix ownership or set CONTEXT_KIT_DATA_DIR"
fi
}
prepare_data_dirs() {
ensure_writable_dir "${DATA_DIR}"
ensure_writable_dir "${DOCS_DATA_DIR}"
ensure_writable_dir "${MODELS_DATA_DIR}"
ensure_writable_dir "${DOCS_LOCAL_SOURCES_DIR}"
}
check_data_dirs() {
local ok=0 dir
for dir in "${DATA_DIR}" "${DOCS_DATA_DIR}" "${MODELS_DATA_DIR}" "${DOCS_LOCAL_SOURCES_DIR}"; do
if [[ ! -d "${dir}" ]]; then
printf 'warn data directory missing: %s (run context-kit start)\n' "${dir}"
elif [[ -w "${dir}" && -x "${dir}" ]]; then
printf 'pass data directory writable: %s\n' "${dir}"
else
printf 'fail data directory not writable by uid %s: %s\n' "$(id -u)" "${dir}"
ok=1
fi
done
return "${ok}"
}
warn() {
printf 'warn: %s\n' "$*" >&2
}
json_escape() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
s="${s//$'\n'/\\n}"
s="${s//$'\r'/\\r}"
s="${s//$'\t'/\\t}"
printf '%s' "${s}"
}
require_docker() {
command -v docker >/dev/null 2>&1 || fail "Docker is required"
docker info >/dev/null 2>&1 || fail "Docker is not running or not reachable"
}
require_image() {
local image="$1"
local hint="$2"
docker image inspect "${image}" >/dev/null 2>&1 || fail "missing image ${image}; run: ${hint}"
}
require_network() {
docker network inspect "${NETWORK}" >/dev/null 2>&1 || fail "missing Docker network ${NETWORK}; run: context-kit start"
}
wait_for_searxng() {
command -v curl >/dev/null 2>&1 || return 0
local attempt
for attempt in {1..30}; do
if curl -fsS "http://127.0.0.1:${SEARXNG_PORT}/healthz" >/dev/null 2>&1; then
return 0
fi
sleep 1
done
warn "SearXNG did not become ready on 127.0.0.1:${SEARXNG_PORT} after 30s"
return 1
}
docs_service_running() {
local container_id
container_id="$(compose ps -q "${DOCS_SERVICE_NAME}" 2>/dev/null || true)"
[[ -n "${container_id}" ]] || return 1
docker inspect -f '{{.State.Running}}' "${container_id}" 2>/dev/null | grep -qx true
}
wait_for_docs_mcp() {
command -v curl >/dev/null 2>&1 || return 0
# First run can take a while: model download plus optional eager preindexing.
local attempt http_ready=0
for attempt in {1..180}; do
if curl -fsS -o /dev/null "http://127.0.0.1:${DOCS_PORT}/status" 2>/dev/null; then
http_ready=1
break
fi
sleep 1
done
if [[ "${http_ready}" -ne 1 ]]; then
warn "docs-mcp did not become ready on 127.0.0.1:${DOCS_PORT} after 180s (check: docker compose logs ${DOCS_SERVICE_NAME})"
return 1
fi
return 0
}
abs_dir() {
local path="$1"
mkdir -p "${path}"
(cd "${path}" && pwd -P)
}
project_dir() {
local dir="${CONTEXT_KIT_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-${PWD}}}"
(cd "${dir}" && pwd -P)
}
source_files() {
local configured="${CONTEXT_KIT_DOCS_SOURCES:-config/sources.default.txt}"
local file
for file in ${configured}; do
if [[ "${file}" = /* ]]; then
printf '%s\n' "${file}"
else
printf '%s\n' "${ROOT}/${file}"
fi
done
}
resolved_sources() {
local file line
while IFS= read -r file; do
[[ -f "${file}" ]] || fail "docs source file not found: ${file}"
while IFS= read -r line; do
line="${line%%#*}"
line="${line//[$'\t\r\n ']/}"
[[ -z "${line}" ]] && continue
printf '%s\n' "${line}"
done < "${file}"
done < <(source_files)
}
cmd_build() {
require_no_args "usage: context-kit build" "$@"
require_docker
# web-search-mcp is still profile-gated (built but not auto-started);
# docs-mcp is a regular long-lived service so it builds without a profile.
compose --profile mcp build web-search-mcp
compose build docs-mcp
docker pull "${REPOMIX_IMAGE}"
}
cmd_start() {
require_no_args "usage: context-kit start" "$@"
require_docker
prepare_data_dirs
if ! docker image inspect "${WEB_SEARCH_IMAGE}" >/dev/null 2>&1 || ! docker image inspect "${DOCS_IMAGE}" >/dev/null 2>&1; then
cmd_build
fi
write_docs_sources_file
compose up -d searxng docs-mcp
wait_for_searxng
wait_for_docs_mcp
}
cmd_stop() {
require_no_args "usage: context-kit stop" "$@"
require_docker
compose stop searxng docs-mcp
}
cmd_restart() {
require_no_args "usage: context-kit restart" "$@"
cmd_stop
cmd_start
}
cmd_status() {
require_no_args "usage: context-kit status" "$@"
require_docker
printf 'Services\n'
compose ps
printf '\nImages\n'
docker image ls --format '{{.Repository}}:{{.Tag}}\t{{.Size}}' \
| grep -E '^(context-kit/|ghcr.io/yamadashy/repomix:)' || true
printf '\nActive per-call MCP containers\n'
docker ps -a --filter label=dev.context-kit=true --format '{{.Names}}\t{{.Status}}\t{{.Image}}\t{{.Command}}\t{{.Label "com.docker.compose.service"}}' \
| awk -F '\t' 'BEGIN { print "NAMES\tSTATUS\tIMAGE\tCOMMAND" } $5 !~ /^(searxng|docs-mcp)$/ { print $1 "\t" $2 "\t" $3 "\t" $4 }'
printf '\nDocs MCP endpoint\n- %s (service: %s)\n' "${DOCS_HTTP_URL}" "${DOCS_SERVICE_NAME}"
printf '\nDocs sources\n'
resolved_sources | sed 's/^/- /'
printf '\nLocal docs source directory\n- %s (served inside docs-mcp at http://127.0.0.1:%s/)\n' "${DOCS_LOCAL_SOURCES_DIR}" "${DOCS_LOCAL_SOURCES_PORT}"
printf '\nData directory\n- %s\n' "${DATA_DIR}"
}
cmd_doctor() {
require_no_args "usage: context-kit doctor" "$@"
local ok=0
printf 'Context Kit doctor\n'
if command -v docker >/dev/null 2>&1; then
printf 'pass docker command found\n'
else
printf 'fail docker command not found\n'; ok=1
fi
if docker info >/dev/null 2>&1; then
printf 'pass docker daemon reachable\n'
else
printf 'fail docker daemon not reachable\n'; ok=1
fi
if docker compose version >/dev/null 2>&1; then
printf 'pass docker compose available\n'
else
printf 'fail docker compose unavailable\n'; ok=1
fi
if ! check_data_dirs; then
ok=1
fi
if docker network inspect "${NETWORK}" >/dev/null 2>&1; then
printf 'pass docker network exists: %s\n' "${NETWORK}"
else
printf 'warn docker network missing: %s (run context-kit start)\n' "${NETWORK}"
fi
for image in "${WEB_SEARCH_IMAGE}" "${DOCS_IMAGE}" "${REPOMIX_IMAGE}"; do
if docker image inspect "${image}" >/dev/null 2>&1; then
printf 'pass image exists: %s\n' "${image}"
else
printf 'warn image missing: %s\n' "${image}"
fi
done
if command -v curl >/dev/null 2>&1 && curl -fsS "http://127.0.0.1:${SEARXNG_PORT}/healthz" >/dev/null 2>&1; then
printf 'pass SearXNG responds on 127.0.0.1:%s\n' "${SEARXNG_PORT}"
else
printf 'fail SearXNG not responding on 127.0.0.1:%s\n' "${SEARXNG_PORT}"
ok=1
fi
if command -v curl >/dev/null 2>&1 && curl -fsS -o /dev/null "http://127.0.0.1:${DOCS_PORT}/status" 2>/dev/null; then
printf 'pass docs-mcp HTTP responds on 127.0.0.1:%s\n' "${DOCS_PORT}"
else
printf 'fail docs-mcp HTTP not responding on 127.0.0.1:%s (run context-kit start)\n' "${DOCS_PORT}"
ok=1
fi
if [[ "$(resolved_sources | wc -l | tr -d ' ')" -gt 0 ]]; then
printf 'pass docs sources resolve\n'
else
printf 'fail no docs sources configured\n'; ok=1
fi
return "${ok}"
}
cmd_web_search() {
require_no_args "usage: context-kit web-search" "$@"
require_docker
require_network
require_image "${WEB_SEARCH_IMAGE}" "context-kit build"
local cidfile_args=()
if [[ -n "${CONTEXT_KIT_DOCKER_CIDFILE:-}" ]]; then
cidfile_args=(--cidfile "${CONTEXT_KIT_DOCKER_CIDFILE}")
fi
exec docker run --rm -i \
--label dev.context-kit=true \
"${cidfile_args[@]}" \
--network "${NETWORK}" \
-e DEFAULT_SEARCH_PROVIDER="${WEB_SEARCH_PROVIDER}" \
-e SEARXNG_URL="http://searxng:8080" \
-e CHROME_PATH="${WEB_SEARCH_CHROME_PATH}" \
-e HTTP_TIMEOUT="${WEB_SEARCH_HTTP_TIMEOUT}" \
-e MAX_BYTES="${WEB_SEARCH_MAX_BYTES}" \
-e MAX_RESULTS="${WEB_SEARCH_MAX_RESULTS}" \
-e BROWSER_SEARCH_USER_AGENT="${WEB_SEARCH_BROWSER_USER_AGENT}" \
-e MCP_COMPAT_MODE="${WEB_SEARCH_MCP_COMPAT_MODE}" \
"${WEB_SEARCH_IMAGE}"
}
cmd_docs() {
require_no_args "usage: context-kit docs" "$@"
# Prefer the `type: remote` MCP config pointing at ${DOCS_HTTP_URL}.
# This stdio entrypoint is kept for clients that cannot speak HTTP MCP:
# it spawns a thin mcp-proxy bridge per call but all calls multiplex onto
# the single long-lived docs-mcp container over the Context Kit Docker
# network (no Chroma write contention, no host networking).
require_docker
require_network
require_image "${DOCS_IMAGE}" "context-kit build"
if ! docs_service_running; then
fail "long-lived docs-mcp not running; start it with: context-kit start"
fi
local bridge_url="http://${DOCS_SERVICE_NAME}:8000/mcp"
local cidfile_args=()
if [[ -n "${CONTEXT_KIT_DOCKER_CIDFILE:-}" ]]; then
cidfile_args=(--cidfile "${CONTEXT_KIT_DOCKER_CIDFILE}")
fi
exec docker run --rm -i \
--label dev.context-kit=true \
"${cidfile_args[@]}" \
--network "${NETWORK}" \
--entrypoint mcp-proxy \
"${DOCS_IMAGE}" \
--transport streamablehttp \
"${bridge_url}"
}
cmd_repomix() {
require_no_args "usage: context-kit repomix" "$@"
require_docker
require_image "${REPOMIX_IMAGE}" "docker pull ${REPOMIX_IMAGE}"
local dir mount_dir
dir="$(project_dir)"
mount_dir="${CONTEXT_KIT_REPOMIX_MOUNT_DIR:-${dir}}"
mount_dir="$(cd "${mount_dir}" && pwd -P)"
local cidfile_args=()
if [[ -n "${CONTEXT_KIT_DOCKER_CIDFILE:-}" ]]; then
cidfile_args=(--cidfile "${CONTEXT_KIT_DOCKER_CIDFILE}")
fi
exec docker run --rm -i \
--label dev.context-kit=true \
"${cidfile_args[@]}" \
-v "${mount_dir}:${mount_dir}:ro" \
--workdir "${dir}" \
"${REPOMIX_IMAGE}" --mcp
}
snippet_command() {
case "${1:-}" in
--absolute) printf '%s' "${ROOT}/bin/context-kit" ;;
"") printf '%s' "context-kit" ;;
*) fail "unknown install option: ${1}" ;;
esac
}
print_opencode() {
local bin url
bin="$(json_escape "$(snippet_command "${1:-}")")"
url="$(json_escape "${DOCS_HTTP_URL}")"
cat <<JSON
{
"\$schema": "https://opencode.ai/config.json",
"mcp": {
"context-web-search": {
"type": "local",
"command": ["${bin}", "web-search"],
"enabled": true,
"timeout": 150000
},
"context-docs": {
"type": "remote",
"url": "${url}",
"enabled": true,
"timeout": 150000
},
"context-repomix": {
"type": "local",
"command": ["${bin}", "repomix"],
"enabled": true,
"timeout": 120000
}
}
}
JSON
}
print_claude() {
local bin url
bin="$(json_escape "$(snippet_command "${1:-}")")"
url="$(json_escape "${DOCS_HTTP_URL}")"
cat <<JSON
{
"mcpServers": {
"context-web-search": {
"command": "${bin}",
"args": ["web-search"]
},
"context-docs": {
"type": "http",
"url": "${url}"
},
"context-repomix": {
"command": "${bin}",
"args": ["repomix"]
}
}
}
JSON
}
cmd_install() {
local target="${1:-}"
shift || true
local option="${1:-}"
shift || true
[[ "$#" -eq 0 ]] || fail "usage: context-kit install claude|opencode [--absolute]"
case "${target}" in
opencode) print_opencode "${option}" ;;
claude) print_claude "${option}" ;;
*) fail "usage: context-kit install claude|opencode [--absolute]" ;;
esac
}
case "${1:-}" in
start) shift; cmd_start "$@" ;;
stop) shift; cmd_stop "$@" ;;
restart) shift; cmd_restart "$@" ;;
build) shift; cmd_build "$@" ;;
status) shift; cmd_status "$@" ;;
doctor) shift; cmd_doctor "$@" ;;
web-search) shift; cmd_web_search "$@" ;;
docs) shift; cmd_docs "$@" ;;
repomix) shift; cmd_repomix "$@" ;;
install) shift; cmd_install "$@" ;;
-h|--help|help|"") usage ;;
*) usage >&2; exit 64 ;;
esac