1061 lines
34 KiB
Bash
Executable File
1061 lines
34 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}"
|
|
[[ "${PROJECT}" =~ ^[a-z0-9][a-z0-9_-]*$ ]] || fail "invalid CONTEXT_KIT_COMPOSE_PROJECT: ${PROJECT}"
|
|
HOST_UID="$(id -u)"
|
|
COMPOSE_FILE="${ROOT}/compose.yml"
|
|
DATA_DIR="${CONTEXT_KIT_DATA_DIR:-${DEFAULT_DATA_DIR}}"
|
|
NETWORK="${PROJECT}_default"
|
|
SEARXNG_PORT="${CONTEXT_KIT_SEARXNG_PORT:-8099}"
|
|
WEB_SEARCH_PORT="${CONTEXT_KIT_WEB_SEARCH_PORT:-8777}"
|
|
WEB_SEARCH_HTTP_URL="${CONTEXT_KIT_WEB_SEARCH_HTTP_URL:-http://127.0.0.1:${WEB_SEARCH_PORT}/mcp}"
|
|
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_SERVICE_NAME="web-search-mcp"
|
|
DOCS_SERVICE_NAME="docs-mcp"
|
|
SHARED_SERVICES=(searxng "${WEB_SEARCH_SERVICE_NAME}" "${DOCS_SERVICE_NAME}")
|
|
SNAPSHOT_IDS=()
|
|
SNAPSHOT_RUNNING=()
|
|
DOCS_SOURCES_FILE="${DATA_DIR}/docs-sources.txt"
|
|
DOCS_SOURCES_TRANSACTION_ACTIVE=0
|
|
DOCS_SOURCES_PRIOR_PRESENT=0
|
|
DOCS_SOURCES_BACKUP_DIR=''
|
|
DOCS_SOURCES_BACKUP_FILE=''
|
|
DOCS_SOURCES_RENDER_TMP=''
|
|
LIFECYCLE_STATE_ROLLBACK_ACTIVE=0
|
|
LIFECYCLE_STATE_ROLLBACK_REMOVE_NEW=false
|
|
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 the shared SearXNG and HTTP MCP services
|
|
context-kit stop Stop the shared services without removing them
|
|
context-kit restart Restart the shared services
|
|
context-kit build Build MCP images
|
|
context-kit status Show services, images, sources, and shared HTTP endpoints
|
|
context-kit doctor Check Docker, services, images, sources, and HTTP endpoints
|
|
context-kit redaction-check Scan this repo for local paths and secret patterns
|
|
context-kit docs-snapshot Build deterministic llms-full.txt local snapshots
|
|
context-kit docs-rebuild [URL...] Rebuild all or selected configured docs sources
|
|
|
|
MCP server commands:
|
|
context-kit web-search Stdio bridge to the shared web-search service
|
|
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_COMPOSE_PROJECT="${PROJECT}" \
|
|
CONTEXT_KIT_HOST_UID="${HOST_UID}" \
|
|
CONTEXT_KIT_SEARXNG_PORT="${SEARXNG_PORT}" \
|
|
CONTEXT_KIT_WEB_SEARCH_PORT="${WEB_SEARCH_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.${BASHPID}"
|
|
DOCS_SOURCES_RENDER_TMP="${tmp}"
|
|
if ! {
|
|
printf '# generated by context-kit lifecycle commands; edit your CONTEXT_KIT_DOCS_SOURCES file(s) instead\n'
|
|
resolved_sources
|
|
} > "${tmp}"; then
|
|
rm -f -- "${tmp}"
|
|
DOCS_SOURCES_RENDER_TMP=''
|
|
return 1
|
|
fi
|
|
if ! mv -fT -- "${tmp}" "${DOCS_SOURCES_FILE}"; then
|
|
rm -f -- "${tmp}"
|
|
DOCS_SOURCES_RENDER_TMP=''
|
|
return 1
|
|
fi
|
|
DOCS_SOURCES_RENDER_TMP=''
|
|
}
|
|
|
|
reset_docs_sources_transaction() {
|
|
DOCS_SOURCES_TRANSACTION_ACTIVE=0
|
|
DOCS_SOURCES_PRIOR_PRESENT=0
|
|
DOCS_SOURCES_BACKUP_DIR=''
|
|
DOCS_SOURCES_BACKUP_FILE=''
|
|
DOCS_SOURCES_RENDER_TMP=''
|
|
}
|
|
|
|
begin_docs_sources_transaction() {
|
|
[[ "${DOCS_SOURCES_TRANSACTION_ACTIVE}" -eq 0 ]] || return 1
|
|
mkdir -p "$(dirname "${DOCS_SOURCES_FILE}")"
|
|
|
|
local backup_dir
|
|
backup_dir="$(mktemp -d "${DOCS_SOURCES_FILE}.lifecycle-backup.XXXXXX")" || return 1
|
|
DOCS_SOURCES_BACKUP_DIR="${backup_dir}"
|
|
DOCS_SOURCES_BACKUP_FILE="${backup_dir}/docs-sources.txt"
|
|
DOCS_SOURCES_PRIOR_PRESENT=0
|
|
DOCS_SOURCES_TRANSACTION_ACTIVE=1
|
|
|
|
if [[ -e "${DOCS_SOURCES_FILE}" || -L "${DOCS_SOURCES_FILE}" ]]; then
|
|
DOCS_SOURCES_PRIOR_PRESENT=1
|
|
if ! mv -T -- "${DOCS_SOURCES_FILE}" "${DOCS_SOURCES_BACKUP_FILE}"; then
|
|
rmdir -- "${DOCS_SOURCES_BACKUP_DIR}" 2>/dev/null || true
|
|
reset_docs_sources_transaction
|
|
return 1
|
|
fi
|
|
fi
|
|
}
|
|
|
|
restore_docs_sources_transaction() {
|
|
[[ "${DOCS_SOURCES_TRANSACTION_ACTIVE}" -eq 1 ]] || return 0
|
|
local failed=0
|
|
|
|
if [[ -n "${DOCS_SOURCES_RENDER_TMP}" ]]; then
|
|
rm -f -- "${DOCS_SOURCES_RENDER_TMP}" || failed=1
|
|
DOCS_SOURCES_RENDER_TMP=''
|
|
fi
|
|
if [[ "${DOCS_SOURCES_PRIOR_PRESENT}" -eq 1 ]]; then
|
|
if [[ -e "${DOCS_SOURCES_BACKUP_FILE}" || -L "${DOCS_SOURCES_BACKUP_FILE}" ]]; then
|
|
if ! mv -fT -- "${DOCS_SOURCES_BACKUP_FILE}" "${DOCS_SOURCES_FILE}"; then
|
|
warn "failed to restore prior docs sources file: ${DOCS_SOURCES_FILE}"
|
|
failed=1
|
|
fi
|
|
else
|
|
warn "prior docs sources backup is missing: ${DOCS_SOURCES_BACKUP_FILE}"
|
|
failed=1
|
|
fi
|
|
elif ! rm -f -- "${DOCS_SOURCES_FILE}"; then
|
|
warn "failed to restore prior absence of docs sources file: ${DOCS_SOURCES_FILE}"
|
|
failed=1
|
|
fi
|
|
|
|
if [[ "${failed}" -eq 0 ]] && ! rmdir -- "${DOCS_SOURCES_BACKUP_DIR}"; then
|
|
warn "failed to remove docs sources backup directory: ${DOCS_SOURCES_BACKUP_DIR}"
|
|
failed=1
|
|
fi
|
|
if [[ "${failed}" -eq 0 ]]; then
|
|
reset_docs_sources_transaction
|
|
fi
|
|
return "${failed}"
|
|
}
|
|
|
|
discard_docs_sources_transaction() {
|
|
[[ "${DOCS_SOURCES_TRANSACTION_ACTIVE}" -eq 1 ]] || return 0
|
|
local failed=0
|
|
|
|
if [[ -n "${DOCS_SOURCES_RENDER_TMP}" ]]; then
|
|
rm -f -- "${DOCS_SOURCES_RENDER_TMP}" || failed=1
|
|
DOCS_SOURCES_RENDER_TMP=''
|
|
fi
|
|
if [[ "${DOCS_SOURCES_PRIOR_PRESENT}" -eq 1 ]]; then
|
|
rm -f -- "${DOCS_SOURCES_BACKUP_FILE}" || failed=1
|
|
fi
|
|
if [[ "${failed}" -eq 0 ]] && ! rmdir -- "${DOCS_SOURCES_BACKUP_DIR}"; then
|
|
failed=1
|
|
fi
|
|
if [[ "${failed}" -eq 0 ]]; then
|
|
reset_docs_sources_transaction
|
|
fi
|
|
return "${failed}"
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
print_relative_paths() {
|
|
local path
|
|
while IFS= read -r path; do
|
|
[[ -n "${path}" ]] || continue
|
|
if [[ "${path}" == "${ROOT}/"* ]]; then
|
|
path="${path#"${ROOT}/"}"
|
|
fi
|
|
printf '%s\n' "${path}"
|
|
done
|
|
}
|
|
|
|
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"
|
|
}
|
|
|
|
with_lifecycle_lock() {
|
|
command -v flock >/dev/null 2>&1 || fail "flock is required for shared service lifecycle operations"
|
|
command -v stat >/dev/null 2>&1 || fail "stat is required for shared service lifecycle operations"
|
|
local lock_dir="/tmp/context-kit-${PROJECT}.lock" owner mode
|
|
[[ -d /tmp && ! -L /tmp ]] || fail "canonical lifecycle lock parent /tmp is unsafe"
|
|
if mkdir -m 700 "${lock_dir}" 2>/dev/null; then
|
|
chmod 700 "${lock_dir}"
|
|
fi
|
|
[[ -d "${lock_dir}" && ! -L "${lock_dir}" ]] || fail "unsafe lifecycle lock path: ${lock_dir}"
|
|
owner="$(stat -c %u "${lock_dir}")"
|
|
mode="$(stat -c %a "${lock_dir}")"
|
|
[[ "${owner}" == "${HOST_UID}" && "${mode}" == "700" ]] \
|
|
|| fail "shared Compose project ${PROJECT} lock has uid ${owner} and mode ${mode}; expected uid ${HOST_UID} and mode 700"
|
|
(
|
|
flock -x 9
|
|
assert_project_owner
|
|
"$@"
|
|
) 9>"${lock_dir}/lifecycle"
|
|
}
|
|
|
|
service_container_id() {
|
|
local service="$1" ids
|
|
ids="$(compose ps --all --quiet "${service}")"
|
|
[[ "${ids}" != *$'\n'* ]] || fail "multiple containers found for shared service ${service}"
|
|
printf '%s' "${ids}"
|
|
}
|
|
|
|
container_running() {
|
|
docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null | grep -qx true
|
|
}
|
|
|
|
container_exists() {
|
|
docker inspect "$1" >/dev/null 2>&1
|
|
}
|
|
|
|
shared_service_running() {
|
|
local container_id
|
|
container_id="$(service_container_id "$1" 2>/dev/null || true)"
|
|
[[ -n "${container_id}" ]] && container_running "${container_id}"
|
|
}
|
|
|
|
container_matches_shared_service() {
|
|
local container_id="$1" service="$2" labels
|
|
labels="$(docker inspect -f '{{ index .Config.Labels "com.docker.compose.project" }}:{{ index .Config.Labels "com.docker.compose.service" }}' "${container_id}" 2>/dev/null || true)"
|
|
[[ "${labels}" == "${PROJECT}:${service}" ]]
|
|
}
|
|
|
|
assert_project_owner() {
|
|
local container_id configured_uid
|
|
container_id="$(service_container_id "${DOCS_SERVICE_NAME}" 2>/dev/null || true)"
|
|
if [[ -n "${container_id}" ]]; then
|
|
configured_uid="$(docker inspect -f '{{.Config.User}}' "${container_id}" 2>/dev/null || true)"
|
|
configured_uid="${configured_uid%%:*}"
|
|
[[ "${configured_uid}" == "${HOST_UID}" ]] \
|
|
|| fail "shared Compose project ${PROJECT} docs service belongs to uid ${configured_uid:-unknown}; cross-user ownership is unsupported"
|
|
fi
|
|
|
|
container_id="$(service_container_id "${WEB_SEARCH_SERVICE_NAME}" 2>/dev/null || true)"
|
|
if [[ -n "${container_id}" ]]; then
|
|
configured_uid="$(docker inspect -f '{{ index .Config.Labels "dev.context-kit.uid" }}' "${container_id}" 2>/dev/null || true)"
|
|
[[ "${configured_uid}" == "${HOST_UID}" ]] \
|
|
|| fail "shared Compose project ${PROJECT} web-search service belongs to uid ${configured_uid:-unknown}; cross-user ownership is unsupported"
|
|
fi
|
|
}
|
|
|
|
cleanup_owned_container() {
|
|
local container_id="$1" owner="$2" actual_owner
|
|
[[ -n "${container_id}" ]] || return 0
|
|
actual_owner="$(docker inspect -f '{{ index .Config.Labels "dev.context-kit.owner" }}' "${container_id}" 2>/dev/null || true)"
|
|
[[ "${actual_owner}" == "${owner}" ]] || return 0
|
|
docker rm -f "${container_id}" >/dev/null 2>&1 || true
|
|
}
|
|
|
|
cleanup_owned_stdio_container() {
|
|
local container_id="$1" owner="$2" attach_pid="$3"
|
|
cleanup_owned_container "${container_id}" "${owner}"
|
|
if [[ -n "${attach_pid}" ]]; then
|
|
kill "${attach_pid}" >/dev/null 2>&1 || true
|
|
wait "${attach_pid}" 2>/dev/null || true
|
|
fi
|
|
}
|
|
|
|
run_owned_stdio_container() {
|
|
local role="$1"
|
|
shift
|
|
local uid owner name container_id='' attach_pid='' status=0
|
|
uid="$(id -u)"
|
|
owner="${PROJECT}:${role}:${uid}:$$"
|
|
name="${PROJECT}-${role}-${uid}-$$"
|
|
|
|
trap 'cleanup_owned_stdio_container "${container_id}" "${owner}" "${attach_pid}"' EXIT
|
|
trap 'exit 129' HUP
|
|
trap 'exit 130' INT
|
|
trap 'exit 143' TERM
|
|
container_id="$(docker create -i --rm --init \
|
|
--name "${name}" \
|
|
--label dev.context-kit=true \
|
|
--label dev.context-kit.lifecycle=client \
|
|
--label "dev.context-kit.owner=${owner}" \
|
|
--label "dev.context-kit.role=${role}" \
|
|
"$@")" || status=$?
|
|
if [[ "${status}" -ne 0 ]]; then
|
|
cleanup_owned_container "${container_id}" "${owner}"
|
|
trap - EXIT HUP INT TERM
|
|
return "${status}"
|
|
fi
|
|
|
|
if [[ -n "${CONTEXT_KIT_DOCKER_CIDFILE:-}" ]]; then
|
|
printf '%s\n' "${container_id}" > "${CONTEXT_KIT_DOCKER_CIDFILE}"
|
|
fi
|
|
|
|
docker start -ai "${container_id}" <&0 &
|
|
attach_pid=$!
|
|
wait "${attach_pid}" || status=$?
|
|
attach_pid=''
|
|
cleanup_owned_container "${container_id}" "${owner}"
|
|
trap - EXIT HUP INT TERM
|
|
return "${status}"
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
wait_for_web_search_mcp() {
|
|
command -v curl >/dev/null 2>&1 || return 1
|
|
|
|
local attempt
|
|
for attempt in {1..60}; do
|
|
if probe_web_search_mcp; then
|
|
return 0
|
|
fi
|
|
sleep 1
|
|
done
|
|
|
|
warn "web-search-mcp did not become ready on 127.0.0.1:${WEB_SEARCH_PORT} after 60s (check: docker compose logs ${WEB_SEARCH_SERVICE_NAME})"
|
|
return 1
|
|
}
|
|
|
|
probe_web_search_mcp() {
|
|
local url="http://127.0.0.1:${WEB_SEARCH_PORT}/mcp" initialized tools
|
|
initialized="$(curl -fsS --max-time 10 \
|
|
-H 'Accept: application/json, text/event-stream' \
|
|
-H 'Content-Type: application/json' \
|
|
-H 'MCP-Protocol-Version: 2024-11-05' \
|
|
--data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"context-kit-doctor","version":"1"}}}' \
|
|
"${url}" 2>/dev/null)" || return 1
|
|
printf '%s' "${initialized}" | grep -Eq '"serverInfo"[[:space:]]*:' || return 1
|
|
|
|
tools="$(curl -fsS --max-time 10 \
|
|
-H 'Accept: application/json, text/event-stream' \
|
|
-H 'Content-Type: application/json' \
|
|
-H 'MCP-Protocol-Version: 2024-11-05' \
|
|
--data '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
|
|
"${url}" 2>/dev/null)" || return 1
|
|
printf '%s' "${tools}" | grep -Eq '"name"[[:space:]]*:[[:space:]]*"search_web"' || return 1
|
|
printf '%s' "${tools}" | grep -Eq '"name"[[:space:]]*:[[:space:]]*"fetch_url"'
|
|
}
|
|
|
|
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=1; attempt <= 180; attempt++)); 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 local_prefix relative full_path
|
|
local_prefix="http://127.0.0.1:${DOCS_LOCAL_SOURCES_PORT}/"
|
|
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
|
|
if [[ "${line}" == "${local_prefix}"*'/llms.txt' ]]; then
|
|
relative="${line#"${local_prefix}"}"
|
|
full_path="${DOCS_LOCAL_SOURCES_DIR}/${relative%llms.txt}llms-full.txt"
|
|
if [[ -f "${full_path}" ]] \
|
|
&& python3 "${ROOT}/scripts/docs_snapshot.py" --validate-output "${full_path}" >/dev/null 2>&1; then
|
|
line="${line%llms.txt}llms-full.txt"
|
|
fi
|
|
fi
|
|
printf '%s\n' "${line}"
|
|
done < "${file}"
|
|
done < <(source_files)
|
|
}
|
|
|
|
cmd_docs_snapshot() {
|
|
python3 "${ROOT}/scripts/docs_snapshot.py" \
|
|
--source-root "${DOCS_LOCAL_SOURCES_DIR}" \
|
|
--cache-dir "${DATA_DIR}/snapshot-cache" \
|
|
"$@"
|
|
}
|
|
|
|
cmd_docs_rebuild() {
|
|
require_docker
|
|
require_network
|
|
if ! shared_service_running "${DOCS_SERVICE_NAME}"; then
|
|
fail "long-lived docs-mcp not running; start it with: context-kit start"
|
|
fi
|
|
node "${ROOT}/scripts/docs-rebuild.mjs" "${DOCS_HTTP_URL}" "$@"
|
|
}
|
|
|
|
cmd_build() {
|
|
require_no_args "usage: context-kit build" "$@"
|
|
require_docker
|
|
compose build web-search-mcp docs-mcp
|
|
docker pull "${REPOMIX_IMAGE}"
|
|
}
|
|
|
|
snapshot_shared_services() {
|
|
local service container_id
|
|
SNAPSHOT_IDS=()
|
|
SNAPSHOT_RUNNING=()
|
|
for service in "${SHARED_SERVICES[@]}"; do
|
|
container_id="$(service_container_id "${service}")"
|
|
SNAPSHOT_IDS+=("${container_id}")
|
|
if [[ -n "${container_id}" ]] && container_running "${container_id}"; then
|
|
SNAPSHOT_RUNNING+=(1)
|
|
else
|
|
SNAPSHOT_RUNNING+=(0)
|
|
fi
|
|
done
|
|
}
|
|
|
|
restore_shared_service_states() {
|
|
local remove_new="$1" index service current_id prior_id failed=0
|
|
for ((index=0; index < ${#SHARED_SERVICES[@]}; index++)); do
|
|
service="${SHARED_SERVICES[index]}"
|
|
prior_id="${SNAPSHOT_IDS[index]}"
|
|
current_id="$(service_container_id "${service}" 2>/dev/null || true)"
|
|
|
|
if [[ -z "${prior_id}" && "${remove_new}" == true && -n "${current_id}" ]]; then
|
|
if container_matches_shared_service "${current_id}" "${service}"; then
|
|
if ! docker rm -f "${current_id}" >/dev/null 2>&1; then
|
|
warn "failed to remove newly-created ${service} container ${current_id} during rollback"
|
|
failed=1
|
|
fi
|
|
else
|
|
warn "new ${service} container ${current_id} lacks the expected Compose ownership; leaving it untouched"
|
|
failed=1
|
|
fi
|
|
continue
|
|
fi
|
|
[[ -n "${prior_id}" ]] || continue
|
|
|
|
if [[ -n "${current_id}" && "${current_id}" != "${prior_id}" ]]; then
|
|
warn "${service} unexpectedly changed from ${prior_id} to ${current_id}; leaving the replacement untouched"
|
|
failed=1
|
|
continue
|
|
fi
|
|
if ! container_exists "${prior_id}"; then
|
|
warn "cannot restore missing pre-start ${service} container ${prior_id}"
|
|
failed=1
|
|
continue
|
|
fi
|
|
if [[ "${SNAPSHOT_RUNNING[index]}" -eq 1 ]] && ! container_running "${prior_id}"; then
|
|
if ! docker start "${prior_id}" >/dev/null 2>&1; then
|
|
warn "failed to restart prior ${service} container ${prior_id} during rollback"
|
|
failed=1
|
|
fi
|
|
elif [[ "${SNAPSHOT_RUNNING[index]}" -eq 0 ]] && container_running "${prior_id}"; then
|
|
if ! docker stop "${prior_id}" >/dev/null 2>&1; then
|
|
warn "failed to stop prior ${service} container ${prior_id} during rollback"
|
|
failed=1
|
|
fi
|
|
fi
|
|
done
|
|
return "${failed}"
|
|
}
|
|
|
|
rollback_lifecycle_transaction() {
|
|
local failed=0
|
|
restore_docs_sources_transaction || failed=1
|
|
if [[ "${LIFECYCLE_STATE_ROLLBACK_ACTIVE}" -eq 1 ]]; then
|
|
restore_shared_service_states "${LIFECYCLE_STATE_ROLLBACK_REMOVE_NEW}" || failed=1
|
|
LIFECYCLE_STATE_ROLLBACK_ACTIVE=0
|
|
fi
|
|
return "${failed}"
|
|
}
|
|
|
|
lifecycle_transaction_exit() {
|
|
local status="$1"
|
|
trap - EXIT HUP INT TERM
|
|
rollback_lifecycle_transaction || true
|
|
exit "${status}"
|
|
}
|
|
|
|
arm_lifecycle_transaction_traps() {
|
|
trap 'lifecycle_transaction_exit "$?"' EXIT
|
|
trap 'exit 129' HUP
|
|
trap 'exit 130' INT
|
|
trap 'exit 143' TERM
|
|
}
|
|
|
|
disarm_lifecycle_transaction_traps() {
|
|
trap - EXIT HUP INT TERM
|
|
}
|
|
|
|
shared_services_ready() {
|
|
wait_for_searxng && wait_for_web_search_mcp && wait_for_docs_mcp
|
|
}
|
|
|
|
start_locked() {
|
|
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
|
|
local result=0 rollback_failed=0
|
|
arm_lifecycle_transaction_traps
|
|
if begin_docs_sources_transaction; then
|
|
:
|
|
else
|
|
result=$?
|
|
disarm_lifecycle_transaction_traps
|
|
return "${result}"
|
|
fi
|
|
if write_docs_sources_file; then
|
|
:
|
|
else
|
|
result=$?
|
|
rollback_lifecycle_transaction || rollback_failed=1
|
|
disarm_lifecycle_transaction_traps
|
|
[[ "${rollback_failed}" -eq 0 ]] || return 1
|
|
return "${result}"
|
|
fi
|
|
snapshot_shared_services
|
|
LIFECYCLE_STATE_ROLLBACK_ACTIVE=1
|
|
LIFECYCLE_STATE_ROLLBACK_REMOVE_NEW=true
|
|
compose up -d --no-recreate "${SHARED_SERVICES[@]}" || result=$?
|
|
if [[ "${result}" -eq 0 ]] && ! shared_services_ready; then
|
|
result=1
|
|
fi
|
|
if [[ "${result}" -eq 0 ]] && discard_docs_sources_transaction; then
|
|
LIFECYCLE_STATE_ROLLBACK_ACTIVE=0
|
|
disarm_lifecycle_transaction_traps
|
|
return 0
|
|
fi
|
|
[[ "${result}" -ne 0 ]] || result=1
|
|
|
|
warn "shared service startup failed; restoring prior docs sources and container states"
|
|
rollback_lifecycle_transaction || rollback_failed=1
|
|
disarm_lifecycle_transaction_traps
|
|
[[ "${rollback_failed}" -eq 0 ]] || return 1
|
|
return "${result}"
|
|
}
|
|
|
|
cmd_start() {
|
|
require_no_args "usage: context-kit start" "$@"
|
|
require_docker
|
|
with_lifecycle_lock start_locked
|
|
}
|
|
|
|
stop_locked() {
|
|
compose stop "${SHARED_SERVICES[@]}"
|
|
}
|
|
|
|
cmd_stop() {
|
|
require_no_args "usage: context-kit stop" "$@"
|
|
require_docker
|
|
with_lifecycle_lock stop_locked
|
|
}
|
|
|
|
cmd_restart() {
|
|
require_no_args "usage: context-kit restart" "$@"
|
|
require_docker
|
|
with_lifecycle_lock restart_locked
|
|
}
|
|
|
|
restart_locked() {
|
|
local result=0 rollback_failed=0
|
|
arm_lifecycle_transaction_traps
|
|
if begin_docs_sources_transaction; then
|
|
:
|
|
else
|
|
result=$?
|
|
disarm_lifecycle_transaction_traps
|
|
return "${result}"
|
|
fi
|
|
if write_docs_sources_file; then
|
|
:
|
|
else
|
|
result=$?
|
|
rollback_lifecycle_transaction || rollback_failed=1
|
|
disarm_lifecycle_transaction_traps
|
|
[[ "${rollback_failed}" -eq 0 ]] || return 1
|
|
return "${result}"
|
|
fi
|
|
snapshot_shared_services
|
|
LIFECYCLE_STATE_ROLLBACK_ACTIVE=1
|
|
LIFECYCLE_STATE_ROLLBACK_REMOVE_NEW=false
|
|
compose restart "${SHARED_SERVICES[@]}" || result=$?
|
|
if [[ "${result}" -eq 0 ]] && ! shared_services_ready; then
|
|
result=1
|
|
fi
|
|
if [[ "${result}" -eq 0 ]] && discard_docs_sources_transaction; then
|
|
LIFECYCLE_STATE_ROLLBACK_ACTIVE=0
|
|
disarm_lifecycle_transaction_traps
|
|
return 0
|
|
fi
|
|
[[ "${result}" -ne 0 ]] || result=1
|
|
|
|
warn "shared service restart failed; restoring prior docs sources and container states"
|
|
rollback_lifecycle_transaction || rollback_failed=1
|
|
disarm_lifecycle_transaction_traps
|
|
[[ "${rollback_failed}" -eq 0 ]] || return 1
|
|
return "${result}"
|
|
}
|
|
|
|
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 '\nClient-owned stdio MCP containers\n'
|
|
docker ps -a --filter label=dev.context-kit.lifecycle=client --format 'table {{.Names}}\t{{.Status}}\t{{.Label "dev.context-kit.role"}}\t{{.Label "dev.context-kit.owner"}}'
|
|
printf '\nLegacy unlabeled Context Kit containers (diagnostic only; never auto-removed)\n'
|
|
docker ps -a --filter label=dev.context-kit=true \
|
|
--format '{{.Names}}\t{{.Status}}\t{{.Label "dev.context-kit.lifecycle"}}\t{{.Label "com.docker.compose.service"}}' \
|
|
| awk -F '\t' 'BEGIN { print "NAMES\tSTATUS" } $3 == "" && $4 == "" { print $1 "\t" $2 }'
|
|
printf '\nShared MCP endpoints\n- %s (service: %s)\n- %s (service: %s)\n' \
|
|
"${WEB_SEARCH_HTTP_URL}" "${WEB_SEARCH_SERVICE_NAME}" \
|
|
"${DOCS_HTTP_URL}" "${DOCS_SERVICE_NAME}"
|
|
printf '\nShared Docker ownership\n- project: %s\n- uid: %s\n- network: %s\n- cache volume: %s_searxng-cache\n' \
|
|
"${PROJECT}" "${HOST_UID}" "${NETWORK}" "${PROJECT}"
|
|
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 && probe_web_search_mcp; then
|
|
printf 'pass web-search-mcp initialize and tools/list on 127.0.0.1:%s\n' "${WEB_SEARCH_PORT}"
|
|
else
|
|
printf 'fail web-search-mcp MCP protocol probe failed on 127.0.0.1:%s (run context-kit start)\n' "${WEB_SEARCH_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"
|
|
if ! shared_service_running "${WEB_SEARCH_SERVICE_NAME}"; then
|
|
fail "long-lived web-search-mcp not running; start it with: context-kit start"
|
|
fi
|
|
|
|
run_owned_stdio_container web-search-bridge \
|
|
--network "${NETWORK}" \
|
|
--entrypoint mcp-proxy \
|
|
"${WEB_SEARCH_IMAGE}" \
|
|
--transport streamablehttp \
|
|
"http://${WEB_SEARCH_SERVICE_NAME}:8000/mcp"
|
|
}
|
|
|
|
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 concurrent index writers, no host networking).
|
|
require_docker
|
|
require_network
|
|
require_image "${DOCS_IMAGE}" "context-kit build"
|
|
|
|
if ! shared_service_running "${DOCS_SERVICE_NAME}"; then
|
|
fail "long-lived docs-mcp not running; start it with: context-kit start"
|
|
fi
|
|
|
|
local bridge_url="http://${DOCS_SERVICE_NAME}:8000/mcp"
|
|
run_owned_stdio_container docs-bridge \
|
|
--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)"
|
|
run_owned_stdio_container repomix \
|
|
-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 docs_url web_search_url
|
|
bin="$(json_escape "$(snippet_command "${1:-}")")"
|
|
web_search_url="$(json_escape "${WEB_SEARCH_HTTP_URL}")"
|
|
docs_url="$(json_escape "${DOCS_HTTP_URL}")"
|
|
cat <<JSON
|
|
{
|
|
"\$schema": "https://opencode.ai/config.json",
|
|
"mcp": {
|
|
"context-web-search": {
|
|
"type": "remote",
|
|
"url": "${web_search_url}",
|
|
"enabled": true,
|
|
"timeout": 150000
|
|
},
|
|
"context-docs": {
|
|
"type": "remote",
|
|
"url": "${docs_url}",
|
|
"enabled": true,
|
|
"timeout": 150000
|
|
},
|
|
"context-repomix": {
|
|
"type": "local",
|
|
"command": ["${bin}", "repomix"],
|
|
"enabled": true,
|
|
"timeout": 120000
|
|
}
|
|
}
|
|
}
|
|
JSON
|
|
}
|
|
|
|
print_claude() {
|
|
local bin docs_url web_search_url
|
|
bin="$(json_escape "$(snippet_command "${1:-}")")"
|
|
web_search_url="$(json_escape "${WEB_SEARCH_HTTP_URL}")"
|
|
docs_url="$(json_escape "${DOCS_HTTP_URL}")"
|
|
cat <<JSON
|
|
{
|
|
"mcpServers": {
|
|
"context-web-search": {
|
|
"type": "http",
|
|
"url": "${web_search_url}"
|
|
},
|
|
"context-docs": {
|
|
"type": "http",
|
|
"url": "${docs_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
|
|
}
|
|
|
|
cmd_redaction_check() {
|
|
local bad=0
|
|
local scan_paths=("${ROOT}")
|
|
if [[ "$#" -gt 0 ]]; then
|
|
scan_paths=("$@")
|
|
fi
|
|
local local_path_terms='/(home|Users)/[^/[:space:]]+|/data/(projects|opencode-mcp)[^[:space:]]*|[A-Za-z]:\\Users\\[^\\[:space:]]+'
|
|
local secret_terms='AKIA[0-9A-Z]{16}|BEGIN (RSA |OPENSSH |EC |DSA )?PRIVATE KEY|xox[baprs]-|sk-[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,}|gitea_[A-Za-z0-9_-]{20,}'
|
|
|
|
# Scan only what would be published: skip .git plus everything .gitignore
|
|
# excludes by convention (local .env files, caches, logs).
|
|
local grep_opts=(
|
|
-RInE
|
|
--exclude-dir=.git
|
|
--exclude=.git
|
|
--exclude-dir=.cache
|
|
--exclude-dir=tmp
|
|
--exclude=.env
|
|
--exclude=.env.local
|
|
--exclude=*.log
|
|
)
|
|
|
|
local matches
|
|
matches="$(grep "${grep_opts[@]}" --files-with-matches "${local_path_terms}" "${scan_paths[@]}" 2>/dev/null || true)"
|
|
if [[ -n "${matches}" ]]; then
|
|
printf 'fail redaction-check found local path patterns in:\n' >&2
|
|
printf '%s\n' "${matches}" | print_relative_paths | sed 's/^/- /' >&2
|
|
bad=1
|
|
fi
|
|
|
|
matches="$(grep "${grep_opts[@]}" --files-with-matches "${secret_terms}" "${scan_paths[@]}" 2>/dev/null || true)"
|
|
if [[ -n "${matches}" ]]; then
|
|
printf 'fail redaction-check found secret-like patterns in:\n' >&2
|
|
printf '%s\n' "${matches}" | print_relative_paths | sed 's/^/- /' >&2
|
|
bad=1
|
|
fi
|
|
|
|
if [[ "${bad}" -eq 0 ]]; then
|
|
printf 'pass redaction-check found no local absolute paths or common secret patterns\n'
|
|
else
|
|
printf 'fail redaction-check found blocked content\n' >&2
|
|
fi
|
|
return "${bad}"
|
|
}
|
|
|
|
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 "$@" ;;
|
|
docs-snapshot) shift; cmd_docs_snapshot "$@" ;;
|
|
docs-rebuild) shift; cmd_docs_rebuild "$@" ;;
|
|
repomix) shift; cmd_repomix "$@" ;;
|
|
install) shift; cmd_install "$@" ;;
|
|
redaction-check) shift; cmd_redaction_check "$@" ;;
|
|
-h|--help|help|"") usage ;;
|
|
*) usage >&2; exit 64 ;;
|
|
esac
|