#!/usr/bin/env bash set -euo pipefail MODE="" ASSUME_YES=0 SERVER_NAME="kicad" CLAUDE_CONFIG_PATH="" SCRIPT_NAME="$(basename "$0")" usage() { cat <&2; exit 1; } info() { echo "${SYM_OK} $1"; } warn() { echo "${SYM_WARN} ${YELLOW}$1${RESET}"; } section() { echo echo "${DIM}────────────────────────────────────────────────────${RESET}" echo "${BOLD}${CYAN}$1${RESET}" echo "${DIM}────────────────────────────────────────────────────${RESET}" } while [[ $# -gt 0 ]]; do case "$1" in --verify) MODE="verify" shift ;; --dry-run) MODE="dry-run" shift ;; --apply) MODE="apply" shift ;; --yes) ASSUME_YES=1 shift ;; --name) [[ $# -ge 2 ]] || fail "--name requires a value" SERVER_NAME="$2" shift 2 ;; --claude-config) [[ $# -ge 2 ]] || fail "--claude-config requires a value" CLAUDE_CONFIG_PATH="$2" shift 2 ;; -h|--help) usage exit 0 ;; *) fail "Unknown argument: $1" ;; esac done [[ -n "$MODE" ]] || { usage; exit 1; } [[ -n "$SERVER_NAME" ]] || fail "Server name must not be empty" if [[ -z "$CLAUDE_CONFIG_PATH" ]]; then CLAUDE_CONFIG_PATH="$HOME/.config/Claude/claude_desktop_config.json" fi case "$CLAUDE_CONFIG_PATH" in "~/"*) CLAUDE_CONFIG_PATH="$HOME/${CLAUDE_CONFIG_PATH#~/}" ;; esac CLAUDE_CONFIG_DIR="$(dirname "$CLAUDE_CONFIG_PATH")" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" if [[ -f "$SCRIPT_DIR/package.json" ]]; then REPO_ROOT="$SCRIPT_DIR" else REPO_ROOT="$(pwd)" fi DIST_JS="$REPO_ROOT/dist/index.js" DEFAULT_KICAD_PYTHON="/Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/Current/bin/python3" KICAD_PYTHON="${KICAD_PYTHON:-$DEFAULT_KICAD_PYTHON}" command -v python3 >/dev/null 2>&1 || fail "python3 not found" NODE_PATH="$(command -v node || true)" [[ -n "$NODE_PATH" ]] || fail "node not found in PATH" [[ -f "$DIST_JS" ]] || fail "Missing build artifact: $DIST_JS. Run 'npm install && npm run build' first." [[ -x "$KICAD_PYTHON" ]] || fail "KiCad Python not found or not executable: $KICAD_PYTHON" DETECT_JSON="$("$KICAD_PYTHON" - <<'PY' import json, sys, sysconfig result = { "python_executable": sys.executable, "python_version": sys.version.split()[0], "purelib": sysconfig.get_paths().get("purelib"), "pcbnew_ok": False, "pcbnew_version": None, "pcbnew_error": None, } try: import pcbnew result["pcbnew_ok"] = True if hasattr(pcbnew, "GetBuildVersion"): result["pcbnew_version"] = pcbnew.GetBuildVersion() except Exception as e: result["pcbnew_error"] = repr(e) print(json.dumps(result)) PY )" PYTHON_EXE="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["python_executable"])' "$DETECT_JSON")" PYTHON_VERSION="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["python_version"])' "$DETECT_JSON")" PYTHONPATH_VALUE="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["purelib"])' "$DETECT_JSON")" PCBNEW_OK="$(python3 -c 'import json,sys; print("true" if json.loads(sys.argv[1])["pcbnew_ok"] else "false")' "$DETECT_JSON")" PCBNEW_VERSION="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["pcbnew_version"] or "")' "$DETECT_JSON")" PCBNEW_ERROR="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["pcbnew_error"] or "")' "$DETECT_JSON")" if [[ "$PCBNEW_OK" != "true" ]]; then fail "KiCad Python could not import pcbnew. Details: $PCBNEW_ERROR" fi CONFIG_FRAGMENT_JSON="$(python3 - "$NODE_PATH" "$DIST_JS" "$KICAD_PYTHON" "$PYTHONPATH_VALUE" <<'PY' import json, sys fragment = { "command": sys.argv[1], "args": [sys.argv[2]], "env": { "KICAD_PYTHON": sys.argv[3], "PYTHONPATH": sys.argv[4], "LOG_LEVEL": "info" } } print(json.dumps(fragment, indent=2)) PY )" show_detected() { section "Prerequisites" echo " ${SYM_OK} python3 $(command -v python3)" echo " ${SYM_OK} node $NODE_PATH" echo " ${SYM_OK} build artifact $DIST_JS" echo " ${SYM_OK} KiCad Python $KICAD_PYTHON" echo " ${SYM_OK} pcbnew import $PCBNEW_VERSION" section "Configuration" echo " Server name: ${BOLD}$SERVER_NAME${RESET}" echo " Repo root: $REPO_ROOT" echo " Python executable: $PYTHON_EXE" echo " Python version: $PYTHON_VERSION" echo " PYTHONPATH: $PYTHONPATH_VALUE" echo " Claude config: $CLAUDE_CONFIG_PATH" } merge_config() { python3 - "$CLAUDE_CONFIG_PATH" "$CONFIG_FRAGMENT_JSON" "$SERVER_NAME" <<'PY' import json, os, sys config_path = sys.argv[1] fragment = json.loads(sys.argv[2]) server_name = sys.argv[3] existing = {} status = { "config_exists": False, "config_valid": True, "had_mcpServers": False, "had_entry": False, } if os.path.exists(config_path): status["config_exists"] = True try: with open(config_path, "r", encoding="utf-8") as f: text = f.read().strip() existing = json.loads(text) if text else {} except Exception: print(json.dumps({"error": "Existing Claude config is not valid JSON", "status": status})) sys.exit(2) if not isinstance(existing, dict): print(json.dumps({"error": "Existing Claude config root is not a JSON object", "status": status})) sys.exit(2) if "mcpServers" in existing: status["had_mcpServers"] = True if not isinstance(existing["mcpServers"], dict): print(json.dumps({"error": "'mcpServers' exists but is not an object", "status": status})) sys.exit(2) else: existing["mcpServers"] = {} if server_name in existing["mcpServers"]: status["had_entry"] = True existing["mcpServers"][server_name] = fragment print(json.dumps({"status": status, "merged": existing}, indent=2)) PY } if [[ "$MODE" == "verify" ]]; then show_detected section "Proposed Claude Desktop entry ('$SERVER_NAME')" echo "$CONFIG_FRAGMENT_JSON" exit 0 fi MERGE_RESULT="$(merge_config)" || { echo "$MERGE_RESULT" exit 1 } MERGED_JSON="$(python3 -c 'import json,sys; print(json.dumps(json.loads(sys.stdin.read())["merged"], indent=2))' <<<"$MERGE_RESULT")" CONFIG_EXISTS="$(python3 -c 'import json,sys; print("true" if json.loads(sys.stdin.read())["status"]["config_exists"] else "false")' <<<"$MERGE_RESULT")" HAD_ENTRY="$(python3 -c 'import json,sys; print("true" if json.loads(sys.stdin.read())["status"]["had_entry"] else "false")' <<<"$MERGE_RESULT")" show_detected section "Proposed MCP entry ('$SERVER_NAME')" echo "$CONFIG_FRAGMENT_JSON" section "Claude Desktop config" if [[ "$CONFIG_EXISTS" == "true" ]]; then if [[ "$HAD_ENTRY" == "true" ]]; then warn "Existing config already has mcpServers.$SERVER_NAME — it will be replaced." else info "Existing config will be preserved; mcpServers.$SERVER_NAME will be added." fi else info "Config does not exist yet. A new file will be created." fi echo echo "${DIM}Merged config preview:${RESET}" echo "$MERGED_JSON" if [[ "$MODE" == "dry-run" ]]; then exit 0 fi mkdir -p "$CLAUDE_CONFIG_DIR" if [[ $ASSUME_YES -ne 1 ]]; then echo read -r -p "Write this configuration to $CLAUDE_CONFIG_PATH ? [y/N] " REPLY case "$REPLY" in y|Y|yes|YES) ;; *) echo "Aborted." exit 0 ;; esac fi ORIG_MODE="" if [[ -f "$CLAUDE_CONFIG_PATH" ]]; then ORIG_MODE="$(stat -f '%Lp' "$CLAUDE_CONFIG_PATH")" BACKUP_PATH="${CLAUDE_CONFIG_PATH}.bak.$(date +%Y%m%d-%H%M%S)" cp "$CLAUDE_CONFIG_PATH" "$BACKUP_PATH" info "Backup written to ${DIM}$BACKUP_PATH${RESET}" fi TMP_PATH="${CLAUDE_CONFIG_PATH}.tmp.$$" (umask 077 && printf '%s\n' "$MERGED_JSON" > "$TMP_PATH") if [[ -n "$ORIG_MODE" ]]; then chmod "$ORIG_MODE" "$TMP_PATH" fi mv "$TMP_PATH" "$CLAUDE_CONFIG_PATH" section "Done" info "Claude Desktop configuration updated successfully." echo echo "${BOLD}Next steps:${RESET}" echo " 1. Fully quit Claude Desktop" echo " 2. Reopen Claude Desktop" echo " 3. In a new chat, check: + → Connectors" echo " 4. Verify with:" echo " Use the ${BOLD}$SERVER_NAME${RESET} MCP server to run ${BOLD}check_kicad_ui${RESET}." echo