Add setup-macos.sh, a shell script that automates Claude Desktop MCP configuration on macOS. The script detects KiCad's bundled Python, resolves PYTHONPATH, generates the correct MCP server config, and safely merges it into the existing Claude Desktop configuration with backup support. Supports --verify, --dry-run, and --apply modes. Update README.md with documentation for the automated setup workflow, including usage examples, parameter reference, and post-setup steps. Extend docs/PLATFORM_GUIDE.md with macOS-specific sections covering installation, path handling, Python environment, troubleshooting, best practices, cross-platform migration, and support resources. Update docs/STATUS_SUMMARY.md to reflect the new macOS automation capabilities and bump the last-updated date.
317 lines
9.3 KiB
Bash
317 lines
9.3 KiB
Bash
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
MODE=""
|
|
ASSUME_YES=0
|
|
SERVER_NAME="kicad"
|
|
CLAUDE_CONFIG_PATH=""
|
|
|
|
SCRIPT_NAME="$(basename "$0")"
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
Usage:
|
|
$SCRIPT_NAME --verify [--name NAME] [--claude-config PATH]
|
|
$SCRIPT_NAME --dry-run [--name NAME] [--claude-config PATH]
|
|
$SCRIPT_NAME --apply [--name NAME] [--claude-config PATH] [--yes]
|
|
|
|
Options:
|
|
--verify Check prerequisites and print detected paths
|
|
--dry-run Show config and merged Claude Desktop config without writing
|
|
--apply Write/update Claude Desktop config
|
|
--yes Do not prompt before writing (only with --apply)
|
|
--name NAME MCP server name (default: kicad)
|
|
--claude-config PATH Path to Claude Desktop config file
|
|
(default: ~/.config/Claude/claude_desktop_config.json)
|
|
EOF
|
|
}
|
|
|
|
# --- Terminal formatting ---
|
|
if [[ -t 1 ]]; then
|
|
BOLD=$'\033[1m'
|
|
DIM=$'\033[2m'
|
|
RESET=$'\033[0m'
|
|
GREEN=$'\033[32m'
|
|
YELLOW=$'\033[33m'
|
|
RED=$'\033[31m'
|
|
CYAN=$'\033[36m'
|
|
else
|
|
BOLD="" DIM="" RESET="" GREEN="" YELLOW="" RED="" CYAN=""
|
|
fi
|
|
|
|
SYM_OK="${GREEN}✓${RESET}"
|
|
SYM_WARN="${YELLOW}⚠${RESET}"
|
|
SYM_FAIL="${RED}✗${RESET}"
|
|
|
|
fail() { echo "${SYM_FAIL} ${RED}Error:${RESET} $1" >&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 |