#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" source "$ROOT_DIR/tools/lib/runtime_home.sh" COMPOSE_FILE="$ROOT_DIR/deploy/cad/docker-compose.yml" DOCKER_CLIENT_HOME="${HOME:-}" VERBOSE=0 DOCTOR=0 RUNTIME="${KICAD_MCP_RUNTIME:-auto}" FORCE_BUILD=0 REQUESTED_RUNTIME="$RUNTIME" usage() { cat <<'EOF' Usage: tools/hw/run_kicad_mcp.sh [--doctor] [--verbose] [--debug] [--rebuild] [-- ] Launch the supported KiCad MCP server from the companion `mascarade` repo. Options: --doctor Print resolved paths and exit --verbose Print resolved command before exec --debug Enable verbose launcher + server logs --rebuild Force a rebuild of the container image before launch -h, --help Show this help Environment: MASCARADE_DIR Override the companion repo path (default: ../mascarade) KICAD_MCP_ENTRYPOINT Override the Node entrypoint KICAD_MCP_HOME Override the MCP runtime home (default: .cad-home/kicad-mcp) NODE_BIN Override the Node executable (default: node) KICAD_MCP_DATA_DIR Override the writable data directory used by the server KICAD_MCP_RUNTIME host | container | auto (default: auto) KICAD_MCP_LOG_LEVEL error | warn | info | debug (default: warn) EOF } log() { printf '[kill_life:mcp] %s\n' "$*" >&2 } debug() { if [ "$VERBOSE" -eq 1 ]; then log "$*" fi } cleanup_container() { if [ -n "${ACTIVE_CONTAINER_NAME:-}" ]; then docker rm -f "$ACTIVE_CONTAINER_NAME" >/dev/null 2>&1 || true fi } die() { printf '[kill_life:mcp][err] %s\n' "$*" >&2 exit 1 } compose() { if HOME="$DOCKER_CLIENT_HOME" docker compose version >/dev/null 2>&1; then HOME="$DOCKER_CLIENT_HOME" docker compose -f "$COMPOSE_FILE" "$@" return fi if command -v docker-compose >/dev/null 2>&1; then HOME="$DOCKER_CLIENT_HOME" docker-compose -f "$COMPOSE_FILE" "$@" return fi die "docker compose support not available" } build_pythonpath() { local -a candidates=() local value="" if [ -n "${KICAD_PYTHONPATH:-}" ]; then value="${KICAD_PYTHONPATH}" elif [ -n "${PYTHONPATH:-}" ]; then value="${PYTHONPATH}" else candidates=( /Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/3.9/lib/python3.9/site-packages /usr/lib/kicad/lib/python3/dist-packages /usr/local/lib/kicad/lib/python3/dist-packages /usr/lib/python3/dist-packages /usr/local/lib/python3/dist-packages /usr/lib/python3.12/dist-packages /usr/local/lib/python3.12/dist-packages ) for candidate in "${candidates[@]}"; do if [ -d "$candidate" ]; then if [ -n "$value" ]; then value="${value}:$candidate" else value="$candidate" fi fi done fi printf '%s' "$value" } resolve_probe_python() { if [ -n "${KICAD_PYTHON:-}" ]; then printf '%s' "$KICAD_PYTHON" return 0 fi if [ -x /Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/3.9/bin/python3.9 ]; then printf '%s' /Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/3.9/bin/python3.9 return 0 fi if [ -x "$SERVER_DIR/venv/bin/python" ]; then printf '%s' "$SERVER_DIR/venv/bin/python" return 0 fi command -v python3 || true } probe_pcbnew() { local probe_python="$1" local pythonpath="$2" [ -n "$probe_python" ] || return 1 if [ -n "$pythonpath" ]; then env PYTHONPATH="$pythonpath" "$probe_python" -c 'import pcbnew' >/dev/null 2>&1 return $? fi "$probe_python" -c 'import pcbnew' >/dev/null 2>&1 } while [ "$#" -gt 0 ]; do case "$1" in --doctor) DOCTOR=1 shift ;; --runtime) [ "$#" -ge 2 ] || die "--runtime requires a value" RUNTIME="$2" REQUESTED_RUNTIME="$RUNTIME" shift 2 ;; --runtime=*) RUNTIME="${1#*=}" REQUESTED_RUNTIME="$RUNTIME" shift ;; --container) RUNTIME="container" REQUESTED_RUNTIME="$RUNTIME" shift ;; --host) RUNTIME="host" REQUESTED_RUNTIME="$RUNTIME" shift ;; --verbose) VERBOSE=1 shift ;; --debug) VERBOSE=1 export KICAD_MCP_LOG_LEVEL="${KICAD_MCP_LOG_LEVEL:-debug}" export KICAD_PYTHON_STDERR_LOG_LEVEL="${KICAD_PYTHON_STDERR_LOG_LEVEL:-DEBUG}" export KICAD_PYTHON_FILE_LOG_LEVEL="${KICAD_PYTHON_FILE_LOG_LEVEL:-DEBUG}" shift ;; --profile|--profile=*) die "KiCad MCP now exposes a single stable runtime; remove --profile" ;; --rebuild) FORCE_BUILD=1 shift ;; -h|--help) usage exit 0 ;; --) shift break ;; *) break ;; esac done REPO_PARENT="$(cd "$ROOT_DIR/.." && pwd)" export CAD_HOST_ROOT="${CAD_HOST_ROOT:-$REPO_PARENT}" export CAD_WORKSPACE_DIR="${CAD_WORKSPACE_DIR:-$ROOT_DIR}" export KICAD_MCP_IMAGE="${KICAD_MCP_IMAGE:-kill_life_cad-kicad-mcp:latest}" export KICAD_MCP_LOG_LEVEL="${KICAD_MCP_LOG_LEVEL:-warn}" export KICAD_PYTHON_STDERR_LOG_LEVEL="${KICAD_PYTHON_STDERR_LOG_LEVEL:-WARNING}" export KICAD_PYTHON_FILE_LOG_LEVEL="${KICAD_PYTHON_FILE_LOG_LEVEL:-INFO}" MASCARADE_DIR="$( kill_life_resolve_mascarade_dir \ "$ROOT_DIR" \ "finetune" )" SERVER_DIR="$MASCARADE_DIR/finetune/kicad_mcp_server" ENTRYPOINT="${KICAD_MCP_ENTRYPOINT:-$SERVER_DIR/dist/index.js}" NODE_BIN="${NODE_BIN:-node}" if [ -n "${KICAD_MCP_HOME:-}" ] && [ -z "${KILL_LIFE_RUNTIME_HOME:-}" ]; then export KILL_LIFE_RUNTIME_HOME="$KICAD_MCP_HOME" fi kill_life_runtime_home_init "$ROOT_DIR" "kicad-mcp" "$ROOT_DIR/.cad-home" MCP_HOME="$HOME" CONFIG_HOME="$XDG_CONFIG_HOME" CACHE_HOME="$XDG_CACHE_HOME" DATA_DIR="${KICAD_MCP_DATA_DIR:-$MCP_HOME/data}" PYTHONPATH_VALUE="$(build_pythonpath)" PROBE_PYTHON="$(resolve_probe_python)" HOST_PCBNEW_STATUS="missing" HOST_ENTRYPOINT_STATE="missing" CONTAINER_STATUS="unknown" if probe_pcbnew "$PROBE_PYTHON" "$PYTHONPATH_VALUE"; then HOST_PCBNEW_STATUS="ok" fi if [ -f "$ENTRYPOINT" ]; then HOST_ENTRYPOINT_STATE="present" fi if command -v docker >/dev/null 2>&1 && [ -f "$COMPOSE_FILE" ]; then CONTAINER_STATUS="available" else CONTAINER_STATUS="missing" fi case "$RUNTIME" in auto) if [ "$HOST_PCBNEW_STATUS" = "ok" ] && [ "$HOST_ENTRYPOINT_STATE" = "present" ]; then RUNTIME="host" else RUNTIME="container" fi ;; host|container) ;; *) die "invalid runtime: $RUNTIME" ;; esac if [ "$DOCTOR" -eq 1 ]; then cat </dev/null 2>&1 || die "node executable not found: $NODE_BIN" [ -f "$ENTRYPOINT" ] || die "MCP entrypoint missing: $ENTRYPOINT (run npm build in mascarade/finetune/kicad_mcp_server)" probe_pcbnew "$PROBE_PYTHON" "${KICAD_PYTHONPATH:-}" || die "pcbnew is not importable from $PROBE_PYTHON. Install KiCad with Python bindings, set KICAD_PYTHON/KICAD_PYTHONPATH, or use --container." debug "exec host $NODE_BIN $ENTRYPOINT $*" exec "$NODE_BIN" "$ENTRYPOINT" "$@" fi command -v docker >/dev/null 2>&1 || die "docker is required for container runtime" [ -f "$COMPOSE_FILE" ] || die "compose file not found: $COMPOSE_FILE" if [ "$FORCE_BUILD" -eq 1 ] || ! docker image inspect "$KICAD_MCP_IMAGE" >/dev/null 2>&1; then log "Building kicad-mcp container (KiCad v10 via kicad/kicad:nightly-full)" compose build kicad-mcp >&2 fi ACTIVE_CONTAINER_NAME="${KICAD_MCP_CONTAINER_NAME:-kill-life-kicad-mcp-$(id -u)-$$}" trap cleanup_container EXIT INT TERM debug "exec container kicad-mcp $*" docker run --rm -i --init \ --name "$ACTIVE_CONTAINER_NAME" \ --user "$(id -u):$(id -g)" \ --workdir "$CAD_WORKSPACE_DIR" \ -v "$CAD_HOST_ROOT:$CAD_HOST_ROOT" \ -e HOME="$MCP_HOME" \ -e XDG_CONFIG_HOME="$CONFIG_HOME" \ -e XDG_CACHE_HOME="$CACHE_HOME" \ -e KICAD_MCP_DATA_DIR="$DATA_DIR" \ -e KICAD_MCP_LOG_LEVEL="$KICAD_MCP_LOG_LEVEL" \ -e KICAD_PYTHON_STDERR_LOG_LEVEL="$KICAD_PYTHON_STDERR_LOG_LEVEL" \ -e KICAD_PYTHON_FILE_LOG_LEVEL="$KICAD_PYTHON_FILE_LOG_LEVEL" \ "$KICAD_MCP_IMAGE" \ sh -lc 'set -e; mkdir -p "$HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$KICAD_MCP_DATA_DIR"; exec node /opt/kicad-mcp/server/dist/index.js "$@"' sh "$@" status=$? trap - EXIT INT TERM cleanup_container exit $status