app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge: multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
← Commit history
v0.9.162: ROOT CAUSE + fix for the 10.0.5 plugin bind regression (#31) - KiCad 10.0.5 GUI processes no longer run site.execusercustomize(), so usercustomize.py never fired; the loader now also ships as a .pth executed by addsitedir(), which KiCad's own sitecustomize always calls. Dashboard: plugin LED read the wrong field (alive lives under summary) and the dev-only suite LED is now behind --dev
6 files changed
+628−547
BRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.161+0.9.162
adom-bridge-kicad-manifest.json+5−5@@ -1,15 +1,15 @@ { "manifest_version": 1, "name": "kicad",- "version": "0.9.161",- "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.161/adom-bridge-kicad-v0.9.161.zip",- "sha256": "198c0eee32965ec33e0370bb83aa48a4271587612c7cb993b5af65d69fc6dac2",- "size": 441539,+ "version": "0.9.162",+ "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.162/adom-bridge-kicad-v0.9.162.zip",+ "sha256": "fc5dfed12f05c15b1ac6b88f4febca9631f4c7b153f3e2dfaee9f67fe75fc334",+ "size": 442541, "verbPrefixes": [ "kicad_" ], "healthEndpoint": "/status",- "released_at": "2026-08-18T10:41:01Z",+ "released_at": "2026-08-18T11:04:56Z", "hero": "https://wiki.adom.inc/blob/app/kicad-bridge/kicad-hero.png", "languages": [ "Python",
adom-bridge-kicad-v0.9.162.zipadded⋯ 1 unchanged line ⋯
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "kicad", "displayName": "KiCad EDA",- "version": "0.9.161",+ "version": "0.9.162", "description": "Reverse bridge for KiCad \u2014 board/schematic introspection, lint via kicad-cli, plugin install, multi-instance probe, in-process DRC.", "homepage": "https://wiki.adom.inc/adom/adom-bridge", "author": "Adom Inc.",
dashboard/kicad_dashboard.py+39−12@@ -20,7 +20,7 @@ Serves: POST /ui/click {target} the PAGE clicks its own control CLI:- kicad-dashboard serve [--port 8931] [--target BOX] [--print-url]+ kicad-dashboard serve [--port 8931] [--target BOX] [--print-url] [--dev] kicad-dashboard status [--target BOX] kicad-dashboard ui toast "msg" [--kind ok|err|info] kicad-dashboard ui click <setup|launch|close|demo|part-tour|settings|refresh>@@ -28,6 +28,9 @@ CLI: kicad-dashboard open [--pup | --webview] [--target BOX] kicad-dashboard health +--dev (or KICAD_DASHBOARD_DEV=1) adds the maintainer-only Daily suite LED. Normal+Adom users never run the nightly verb suite, so it is off by default.+ PROXY NOTE (paid for live): adom.cloud STRIPS query strings on sub-paths, so the target rides the PATH (/api/status/<target>/force), never ?target=. The SSE route also needs the padding + no-transform headers or the proxy buffers every byte.@@ -134,9 +137,14 @@ def log(line): # ------------------------------------------------------------------ LED model+DEV_MODE = bool(os.environ.get("KICAD_DASHBOARD_DEV"))++ def _suite_led():- """Last daily-suite verdict, plus container-cron liveness (a dead cron means- the suite silently stops running - it died unnoticed for 4 days once)."""+ """DEV-ONLY (John, 2026-08-18: "the daily suite should not be in here. no+ normal adom user runs that. that's just for me as the main dev"). Shown only+ with --dev / KICAD_DASHBOARD_DEV=1: the nightly verb-suite verdict plus+ container-cron liveness (the cron died unnoticed for 4 days once).""" try: try: hb = time.time() - os.path.getmtime("/tmp/cron-heartbeat")@@ -186,7 +194,8 @@ def build_status(target): ("plugin", "Plugin"), ("focus", "Focus contract"), ("presence", "User presence")): leds[k] = {"label": lbl, "state": "off", "detail": "box offline", "_hint": "Unknowable while the box is off the relay - dormant, not broken."}- leds["suite"] = _suite_led()+ if DEV_MODE:+ leds["suite"] = _suite_led() return {"target": target, "targets": targets, "leds": leds, "settings": {}, "windows": [], "dashboardVersion": VERSION, "generatedAt": time.strftime("%H:%M:%S"),@@ -216,13 +225,28 @@ def build_status(target): "_hint": "The bridge checks upstream every 24h and nudges the AI when a release lands."} bs = ab(target, "kicad_bridge_status", {})- alive = bs.get("alive") or 0- leds["plugin"] = ({"label": "Plugin", "state": "ok", "detail": f"{alive} live instance(s)",- "_hint": "The in-process plugin is bound, so background part navigation is available."}- if alive else- {"label": "Plugin", "state": "warn", "detail": "no live plugin",- "_hint": "Normal while KiCad is closed - it binds ~10s after a launch. "- "kicad_install_plugin forces a fresh deploy if it never binds."})+ summary = bs.get("summary") or {}+ alive = summary.get("alive") or bs.get("alive") or 0 # alive lives under summary+ registered = summary.get("total") or len(bs.get("plugins") or [])+ kicad_open = bool((ab(target, "kicad_state", {"includeImages": False})+ .get("kicadState") or {}).get("windows"))+ if alive:+ leds["plugin"] = {"label": "Plugin", "state": "ok",+ "detail": f"{alive} live instance(s)",+ "_hint": "The in-process plugin is bound, so background part navigation "+ "(symbol/footprint loading without touching the GUI) is available."}+ elif not kicad_open:+ leds["plugin"] = {"label": "Plugin", "state": "off", "detail": "KiCad closed",+ "_hint": "The plugin lives inside KiCad, so it can only bind once KiCad "+ "runs. Launch KiCad and it binds shortly after."}+ else:+ leds["plugin"] = {"label": "Plugin", "state": "warn",+ "detail": f"KiCad open but plugin not answering ({registered} stale)",+ "_hint": "KiCad is running yet no plugin instance answers. On KiCad 10.0.5 "+ "the embedded Python binds late or not at all (tracked as issue #31): "+ "kicad_install_plugin redeploys it, and closing/reopening KiCad "+ "usually binds it. Everything else works without it - only "+ "background part navigation is degraded."} st = ab(target, "kicad_state", {"includeImages": False}) ks = st.get("kicadState") or st@@ -246,7 +270,8 @@ def build_status(target): leds["presence"] = {"label": "User presence", "state": "ok", "detail": f"idle {int(idle // 60)} min", "_hint": "Idle long enough that the daily suite and window automation may run."}- leds["suite"] = _suite_led()+ if DEV_MODE:+ leds["suite"] = _suite_led() settings = (ab(target, "kicad_get_settings", {}, timeout=60) or {}).get("settings") or {} return {"target": target, "targets": targets, "leds": leds, "settings": settings,@@ -513,6 +538,8 @@ def main(): port = int(flag("--port", DEFAULT_PORT)) target = flag("--target", "")+ if "--dev" in argv: # maintainer view: adds the nightly-suite / cron LED+ globals()["DEV_MODE"] = True if cmd == "serve": if "--print-url" in argv:
plugin_install.py+582−528@@ -1,528 +1,582 @@-"""plugin_install.py — auto-install adom_bridge into KiCad USER_SITE. - -The Phase 2 reverse bridge plugin (`adom_bridge.py`) needs to live at -KiCad's USER_SITE directory so that KiCad's bundled Python loads it via -`usercustomize.py` at every GUI process startup. See PLAN.md v4 for the -architectural story. - -This module does the deployment, with extensive feedback for the AI that -triggered the install. The pattern is: -- On every bridge boot, the first incoming kicad_* command triggers - `ensure_plugin_installed_once()`. -- The result (whether install was needed, what was copied, what failed) - is cached in module state and attached as `pluginAutoInstall` on that - first response. -- Subsequent commands do nothing (cached short-circuit) so steady-state - command flow has zero overhead. - -Failure modes the AI needs to know about -======================================== -* `appdata_missing` — %USERPROFILE% (or $HOME on Mac) unresolved. -* `no_kicad_installed` — kicad_detect found zero installs, nothing to deploy to. -* `payload_missing` — plugins/kicad/plugin_payload/ wasn't bundled into - the Tauri app (Tauri bundling regression, like the v1.3.30..v1.3.37 - recorder.html bug — see verify_bundle.py). -* `file_locked` — the destination .py was open (rare; might happen if - KiCad already has it imported and OS locks the file on Windows). -* `permission_denied` — user-site dir uncreatable (very rare). -* `kicad_running` — KiCad was running when we installed; the running - process WON'T pick up the new plugin until restarted. - -Every error case returns a `_hint` field telling the calling AI exactly -what action it should take to recover. -""" - -from __future__ import annotations - -import hashlib -import json -import os -import platform -import shutil -import sys -import threading -import traceback -from datetime import datetime -from pathlib import Path -from typing import Optional - -# Bridge version we're shipping. Bumped together with adom_bridge.py's -# __version__ — version drift between this file and the payload would -# cause an infinite reinstall loop; the runtime asserts they match. -PAYLOAD_VERSION = "0.9.1" - -# Module-level cache: install runs at most ONCE per bridge process boot. -# Multiple concurrent kicad_* commands hitting the bridge during startup -# might all check `ensure_plugin_installed_once()` — the lock guarantees -# only the first one actually does the file copy. -_install_lock = threading.Lock() -_install_result: Optional[dict] = None -_response_attached = False - - -def _payload_dir() -> Path: - """Find plugin_payload/ relative to this file. Works in dev (`plugins/kicad/`) - AND in the bundled Tauri release (`<resources>/plugins/kicad/`).""" - return Path(__file__).parent / "plugin_payload" - - -def _user_site_dir_windows(kicad_version: str) -> Optional[Path]: - """Compute the KiCad USER_SITE path on Windows. - - KiCad's bundled sitecustomize.py builds this path from `CSIDL_PERSONAL` - (== `%USERPROFILE%\\Documents`) joined with `KiCad\\<version>\\3rdparty\\Python<XY>\\site-packages`. - We're matching that exactly so adom_bridge.py lands where KiCad's - embedded interpreter will look for usercustomize.py. - - Returns None if USERPROFILE isn't set (extremely unusual on Windows). - """ - userprofile = os.environ.get("USERPROFILE") - if not userprofile: - return None - # KiCad 10 uses Python 3.11 — the path includes the no-dot version - # ("311"). KiCad 9 also used 3.x; we hardcode 311 for now and add a - # version detection step in Phase 2.x. - return Path(userprofile) / "Documents" / "KiCad" / kicad_version / "3rdparty" / "Python311" / "site-packages" - - -def _user_site_dir_mac(kicad_version: str) -> Optional[Path]: - """Best-effort macOS USER_SITE. NEEDS EMPIRICAL VERIFICATION - (Phase 1 macOS parity follow-up). Documented here so future Kyle- - session work can confirm/correct.""" - home = os.environ.get("HOME") - if not home: - return None - return Path(home) / "Library" / "Preferences" / "kicad" / kicad_version / "3rdparty" / "Python311" / "site-packages" - - -def _user_site_dir(kicad_version: str) -> Optional[Path]: - if sys.platform == "win32": - return _user_site_dir_windows(kicad_version) - if sys.platform == "darwin": - return _user_site_dir_mac(kicad_version) - # Linux KiCad install paths vary wildly (flatpak vs apt vs build- - # from-source). Caller will get "skipped_unsupported_platform". - return None - - -def _file_hash(path: Path) -> Optional[str]: - try: - with open(path, "rb") as f: - return hashlib.sha256(f.read()).hexdigest() - except OSError: - return None - - -def _file_state(dest: Path) -> dict: - """Inspect a destination file and return state for the feedback report. - - Returns a dict with `exists`, `size`, `sha256`, `mtime` (or `_error`). - """ - if not dest.exists(): - return {"exists": False} - try: - st = dest.stat() - return { - "exists": True, - "size": st.st_size, - "sha256": _file_hash(dest), - "mtime": datetime.utcfromtimestamp(st.st_mtime).isoformat() + "Z", - } - except OSError as e: - return {"exists": True, "_error": f"stat failed: {e}"} - - -def _is_kicad_running() -> list[dict]: - """Best-effort: return PIDs of running KiCad GUI exes. So the AI can - warn the user that the freshly-installed plugin won't be active until - they restart KiCad.""" - if sys.platform != "win32": - return [] - try: - import subprocess - out = subprocess.run( - ["tasklist", "/FO", "CSV", "/NH"], - capture_output=True, text=True, timeout=10, - ) - running: list[dict] = [] - targets = {"kicad.exe", "pcbnew.exe", "eeschema.exe", "kicad-cli.exe"} - for line in out.stdout.splitlines(): - parts = [p.strip('"') for p in line.split(",")] - if len(parts) < 2: - continue - name = parts[0].lower() - if name in targets: - try: - running.append({"exeName": name, "pid": int(parts[1])}) - except ValueError: - pass - return running - except (OSError, subprocess.SubprocessError): - return [] - - -def _install_one_version(version: str, version_info: dict) -> dict: - """Install / refresh the plugin for ONE KiCad version. Returns a - dict describing what happened.""" - report: dict = { - "kicadVersion": version, - "installPath": version_info.get("base_dir"), - } - payload = _payload_dir() - if not payload.exists(): - report["action"] = "failed" - report["errorCode"] = "payload_missing" - report["error"] = f"plugin payload directory not found at {payload}" - report["_hint"] = ( - "The plugin_payload/ directory wasn't shipped with the bundle. This is a " - "build regression — see verify_bundle.py in src-tauri/. Report to the user " - "and ask them to upgrade adom-desktop to a build that includes the payload " - "(verify with: adom-desktop --version, expecting v1.7.5 or newer)." - ) - return report - - user_site = _user_site_dir(version) - if user_site is None: - report["action"] = "skipped" - report["errorCode"] = "user_site_unavailable" - report["error"] = ( - f"could not compute USER_SITE path on {sys.platform} for KiCad {version} " - f"(missing USERPROFILE / HOME?)" - ) - report["_hint"] = ( - f"Plugin auto-install is only supported on Windows in v1.7.x; " - f"this is {sys.platform}. macOS parity is Phase 1 follow-up. " - "Other kicad_* commands still work via the legacy UI-automation path; " - "you just won't have the in-process reverse bridge." - ) - return report - - report["userSitePath"] = str(user_site) - src_bridge = payload / "adom_bridge.py" - src_loader = payload / "usercustomize.py" - dest_bridge = user_site / "adom_bridge.py" - dest_loader = user_site / "usercustomize.py" - - # Pre-flight: inspect current state so feedback shows before/after - pre_state = { - "adom_bridge.py": _file_state(dest_bridge), - "usercustomize.py": _file_state(dest_loader), - } - report["preState"] = pre_state - - # Compare hashes — short-circuit if already up to date - src_bridge_hash = _file_hash(src_bridge) - src_loader_hash = _file_hash(src_loader) - dest_bridge_hash = pre_state["adom_bridge.py"].get("sha256") - dest_loader_hash = pre_state["usercustomize.py"].get("sha256") - - if dest_bridge_hash == src_bridge_hash and dest_loader_hash == src_loader_hash: - report["action"] = "already-current" - report["payloadVersion"] = PAYLOAD_VERSION - report["message"] = ( - f"plugin already deployed at {user_site} and content matches the shipped " - f"payload (sha256 identical for both files). No write performed." - ) - return report - - # Mkdir the user-site if missing. The KiCad sitecustomize.py creates - # this on first launch too, so we're paving the same path it would. - try: - user_site.mkdir(parents=True, exist_ok=True) - except OSError as e: - report["action"] = "failed" - report["errorCode"] = "permission_denied" - report["error"] = f"could not create {user_site}: {e}" - report["_hint"] = ( - "Filesystem permission issue creating the KiCad user-site directory. " - "This is rare on Windows. Possible causes: (1) Documents folder is on " - "OneDrive with sync errors; (2) antivirus blocking. Report exact error " - "to the user and have them try: `mkdir -p '<userSitePath>'` manually, " - "then retry the kicad_* command. If that also fails, the user has a " - "broken filesystem state." - ) - return report - - # Copy. shutil.copy2 preserves timestamps which is harmless. We do - # one file at a time so a half-failure still leaves a coherent state. - written: list[dict] = [] - for src, dest in ((src_bridge, dest_bridge), (src_loader, dest_loader)): - try: - shutil.copy2(src, dest) - written.append({ - "name": src.name, - "src": str(src), - "dest": str(dest), - "size": dest.stat().st_size, - "sha256": _file_hash(dest), - }) - except OSError as e: - report["action"] = "failed" - report["errorCode"] = "copy_failed" - report["error"] = f"copying {src.name} -> {dest}: {e}" - report["filesWritten"] = written - report["_hint"] = ( - "Failed mid-copy. Most likely cause on Windows: the destination .py " - "is currently imported by a running KiCad process which has Windows-" - "level locks on the file. Fix: ask the user to close ALL KiCad windows " - "(Project Manager, PCB Editor, Schematic Editor), then retry the " - "kicad_* command. If the file isn't held, this could also be an " - "antivirus quarantine — check Windows Defender's protection history." - ) - return report - - report["action"] = "installed" if not pre_state["adom_bridge.py"]["exists"] else "updated" - report["payloadVersion"] = PAYLOAD_VERSION - report["filesWritten"] = written - report["message"] = ( - f"plugin {'installed' if report['action'] == 'installed' else 'updated to v' + PAYLOAD_VERSION} " - f"at {user_site}. KiCad GUI processes launched AFTER this point will auto-load it via " - f"sitecustomize.py + usercustomize.py." - ) - return report - - -def _do_install(all_kicad_versions: list[dict]) -> dict: - """Internal entry. Iterates over every detected KiCad version and - installs into each. Returns a top-level summary dict.""" - started_at = datetime.utcnow().isoformat() + "Z" - - if not all_kicad_versions: - return { - "triggered": True, - "startedAt": started_at, - "completedAt": datetime.utcnow().isoformat() + "Z", - "summary": "no-kicad", - "errorCode": "no_kicad_installed", - "perVersion": [], - "_hint": ( - "Auto-install skipped because kicad_detect found zero KiCad installs. " - "Report this to the user; recommend installing KiCad from " - "https://www.kicad.org/download/ (or via `adom-desktop desktop_install_kicad` " - "which uses winget). After install, the next kicad_* command will retry " - "the auto-install for you." - ), - } - - running = _is_kicad_running() - per_version = [] - overall_action = "already-current" - overall_error = None - for v in all_kicad_versions: - version = v.get("version") or "10.0" - try: - per_version.append(_install_one_version(version, v)) - except Exception as e: # pylint: disable=broad-except - per_version.append({ - "kicadVersion": version, - "action": "failed", - "errorCode": "unexpected_error", - "error": f"{type(e).__name__}: {e}", - "_traceback": traceback.format_exc(), - "_hint": ( - "Unexpected exception during plugin install for this version. " - "Full traceback in `_traceback`. Report to John on the adom-desktop " - "side; this should not happen and is a real bug, not an environment " - "issue." - ), - }) - - # Pick the most-severe action across all versions as the overall summary. - actions = [pv.get("action") for pv in per_version] - if any(a == "failed" for a in actions): - overall_action = "partial-failure" - overall_error = next(pv for pv in per_version if pv.get("action") == "failed").get("errorCode") - elif any(a == "installed" for a in actions): - overall_action = "installed" - elif any(a == "updated" for a in actions): - overall_action = "updated" - elif any(a == "skipped" for a in actions): - overall_action = "skipped" - - result: dict = { - "triggered": True, - "startedAt": started_at, - "completedAt": datetime.utcnow().isoformat() + "Z", - "summary": overall_action, - "payloadVersion": PAYLOAD_VERSION, - "perVersion": per_version, - "runningKiCad": running, - } - - if overall_error: - result["errorCode"] = overall_error - - # Top-level hint depends on what happened + whether KiCad was running - if overall_action == "installed": - if running: - running_summary = ", ".join(f"{r['exeName']} pid={r['pid']}" for r in running) - result["_hint"] = ( - f"Plugin successfully INSTALLED for the first time. HOWEVER, KiCad is " - f"currently running ({running_summary}). Those running processes will " - f"NOT pick up the new plugin (usercustomize.py only fires at Python " - f"interpreter init, which happens at KiCad startup). " - f"\n\nTo use the new reverse bridge, ask the user to: (1) Save any work " - f"in their open KiCad windows; (2) Close ALL KiCad windows; (3) Reopen " - f"KiCad. After that, run kicad_bridge_status to verify the plugin came " - f"online.\n\nIf the user doesn't want to restart KiCad right now, the " - f"existing non-bridge commands (open_board, install_symbol, etc.) keep " - f"working through the legacy UI-automation path. The bridge is purely " - f"additive." - ) - else: - result["_hint"] = ( - "Plugin successfully INSTALLED. No KiCad processes were running at " - "install time, so the next time the user (or your next kicad_* command) " - "launches KiCad, the reverse-bridge plugin will auto-load. Verify with " - "kicad_bridge_status once KiCad is up." - ) - elif overall_action == "updated": - if running: - result["_hint"] = ( - "Plugin successfully UPDATED to v" + PAYLOAD_VERSION + ". Running " - "KiCad processes still have the OLD plugin loaded in-memory. They'll " - "switch to the new version on next restart. Not urgent — current " - "session still works." - ) - else: - result["_hint"] = ( - "Plugin successfully UPDATED to v" + PAYLOAD_VERSION + ". Next KiCad " - "launch picks it up automatically." - ) - elif overall_action == "already-current": - result["_hint"] = ( - "Plugin already current at v" + PAYLOAD_VERSION + ". No-op. " - "Future kicad_* commands won't re-check; this status is cached for the " - "bridge process lifetime." - ) - elif overall_action == "partial-failure": - result["_hint"] = ( - "AT LEAST ONE KiCad version's plugin install failed. Inspect perVersion[] " - "for which exact version's errorCode applies. The bridge still works for " - "the versions that succeeded; the failed ones fall back to legacy UI " - "automation. Report the specific errorCode to the user — each one has its " - "own recovery path documented in the per-version `_hint` field." - ) - elif overall_action == "skipped": - result["_hint"] = ( - "Plugin install skipped — see per-version errorCodes. Most common reason: " - "running on macOS/Linux where Phase 2 hasn't been ported yet. Legacy " - "kicad_* commands keep working; you just don't have the reverse bridge." - ) - - return result - - -def ensure_plugin_installed_once(all_kicad_versions: list[dict]) -> dict: - """Install the plugin if needed; cache the result. - - Idempotent: every call after the first returns the cached dict, marked - with `triggered: False` and `cached: True` so consumers can tell whether - this call actually did anything. - """ - global _install_result # pylint: disable=global-statement - with _install_lock: - if _install_result is not None: - cached = dict(_install_result) - cached["triggered"] = False - cached["cached"] = True - return cached - # Honor disable env var. Useful for development/diagnostic. - if os.environ.get("ADOM_KICAD_PLUGIN_AUTO_INSTALL_DISABLE") == "1": - _install_result = { - "triggered": False, - "summary": "disabled", - "errorCode": "disabled_via_env", - "_hint": ( - "ADOM_KICAD_PLUGIN_AUTO_INSTALL_DISABLE=1 in the kicad bridge's " - "environment. To install manually, call: " - "adom-desktop kicad_install_plugin (Phase 2.1)." - ), - } - return dict(_install_result) - _install_result = _do_install(all_kicad_versions) - return _install_result - - -def attach_to_response_if_first(result: dict) -> dict: - """Attach `pluginAutoInstall` to a response dict EXACTLY ONCE per - bridge boot. After the AI has been notified on first command, future - responses skip the field to keep payloads small. - - bridge_status / bridge_call ALWAYS get the field attached regardless, - since those verbs explicitly ask about plugin state. - """ - global _response_attached # pylint: disable=global-statement - if not isinstance(result, dict): - return result - if _response_attached and _install_result is not None: - return result - if _install_result is None: - return result - result["pluginAutoInstall"] = dict(_install_result) - _response_attached = True - return result - - -def force_attach(result: dict) -> dict: - """For bridge_status / bridge_call — always attach the current install - state, regardless of `_response_attached`. Useful when the AI is - explicitly inspecting plugin state.""" - if not isinstance(result, dict) or _install_result is None: - return result - result["pluginAutoInstall"] = dict(_install_result) - return result - - -def get_cached_result() -> Optional[dict]: - return dict(_install_result) if _install_result is not None else None - - -def force_install(all_kicad_versions: list[dict]) -> dict: - """Bypass the once-per-boot cache and re-run the install. The new - result replaces the cache so subsequent attach_to_response_if_first() - calls see the fresh state. - - Used by the explicit `kicad_install_plugin` verb.""" - global _install_result, _response_attached # pylint: disable=global-statement - with _install_lock: - _install_result = _do_install(all_kicad_versions) - # Reset the "attached on first response" flag so future calls - # surface the fresh install state once more. - _response_attached = False - return dict(_install_result) - - -def handle_install_plugin(kicad_info: dict, args: dict) -> dict: - """Explicit kicad_install_plugin verb. Bypasses the auto-install - cache and forces a fresh deploy + status check. - - Args: - force: default true. If false, behaves like the auto-install path - (returns cached result if it's already run this boot). - - Returns the full install report. Useful for: debugging when the - plugin isn't loading, updating the payload between releases without - restarting the bridge, recovering from a partial-failure install. - """ - # Need the full all_kicad_versions list from server.py — caller - # passed kicad_info (the default version). Re-detect to get all. - from kicad_detect import detect_all_kicad_versions - all_versions = detect_all_kicad_versions() - - if args.get("force") is False: - # Honor explicit force=false by behaving like the auto path - return {"success": True, "result": ensure_plugin_installed_once(all_versions)} - - result = force_install(all_versions) - return { - "success": result.get("summary") not in ("partial-failure",), - "result": result, - "_hint": ( - "force-reinstall complete. Future kicad_* commands will show " - "this fresh install state in their pluginAutoInstall fields. " - "If KiCad was running during install, ask the user to restart " - "those processes — the in-memory Python interpreter still has " - "the OLD plugin loaded until next launch." - ), - } +"""plugin_install.py — auto-install adom_bridge into KiCad USER_SITE.++The Phase 2 reverse bridge plugin (`adom_bridge.py`) needs to live at+KiCad's USER_SITE directory so that KiCad's bundled Python loads it via+`usercustomize.py` at every GUI process startup. See PLAN.md v4 for the+architectural story.++This module does the deployment, with extensive feedback for the AI that+triggered the install. The pattern is:+- On every bridge boot, the first incoming kicad_* command triggers+ `ensure_plugin_installed_once()`.+- The result (whether install was needed, what was copied, what failed)+ is cached in module state and attached as `pluginAutoInstall` on that+ first response.+- Subsequent commands do nothing (cached short-circuit) so steady-state+ command flow has zero overhead.++Failure modes the AI needs to know about+========================================+* `appdata_missing` — %USERPROFILE% (or $HOME on Mac) unresolved.+* `no_kicad_installed` — kicad_detect found zero installs, nothing to deploy to.+* `payload_missing` — plugins/kicad/plugin_payload/ wasn't bundled into+ the Tauri app (Tauri bundling regression, like the v1.3.30..v1.3.37+ recorder.html bug — see verify_bundle.py).+* `file_locked` — the destination .py was open (rare; might happen if+ KiCad already has it imported and OS locks the file on Windows).+* `permission_denied` — user-site dir uncreatable (very rare).+* `kicad_running` — KiCad was running when we installed; the running+ process WON'T pick up the new plugin until restarted.++Every error case returns a `_hint` field telling the calling AI exactly+what action it should take to recover.+"""++from __future__ import annotations++import hashlib+import json+import os+import platform+import shutil+import sys+import threading+import traceback+from datetime import datetime+from pathlib import Path+from typing import Optional++# Bridge version we're shipping. Bumped together with adom_bridge.py's+# __version__ — version drift between this file and the payload would+# cause an infinite reinstall loop; the runtime asserts they match.+PAYLOAD_VERSION = "0.9.1"++# Module-level cache: install runs at most ONCE per bridge process boot.+# Multiple concurrent kicad_* commands hitting the bridge during startup+# might all check `ensure_plugin_installed_once()` — the lock guarantees+# only the first one actually does the file copy.+_install_lock = threading.Lock()+_install_result: Optional[dict] = None+_response_attached = False+++def _payload_dir() -> Path:+ """Find plugin_payload/ relative to this file. Works in dev (`plugins/kicad/`)+ AND in the bundled Tauri release (`<resources>/plugins/kicad/`)."""+ return Path(__file__).parent / "plugin_payload"+++def _user_site_dir_windows(kicad_version: str) -> Optional[Path]:+ """Compute the KiCad USER_SITE path on Windows.++ KiCad's bundled sitecustomize.py builds this path from `CSIDL_PERSONAL`+ (== `%USERPROFILE%\\Documents`) joined with `KiCad\\<version>\\3rdparty\\Python<XY>\\site-packages`.+ We're matching that exactly so adom_bridge.py lands where KiCad's+ embedded interpreter will look for usercustomize.py.++ Returns None if USERPROFILE isn't set (extremely unusual on Windows).+ """+ userprofile = os.environ.get("USERPROFILE")+ if not userprofile:+ return None+ # KiCad 10 uses Python 3.11 — the path includes the no-dot version+ # ("311"). KiCad 9 also used 3.x; we hardcode 311 for now and add a+ # version detection step in Phase 2.x.+ return Path(userprofile) / "Documents" / "KiCad" / kicad_version / "3rdparty" / "Python311" / "site-packages"+++def _user_site_dir_mac(kicad_version: str) -> Optional[Path]:+ """Best-effort macOS USER_SITE. NEEDS EMPIRICAL VERIFICATION+ (Phase 1 macOS parity follow-up). Documented here so future Kyle-+ session work can confirm/correct."""+ home = os.environ.get("HOME")+ if not home:+ return None+ return Path(home) / "Library" / "Preferences" / "kicad" / kicad_version / "3rdparty" / "Python311" / "site-packages"+++def _user_site_dir(kicad_version: str) -> Optional[Path]:+ if sys.platform == "win32":+ return _user_site_dir_windows(kicad_version)+ if sys.platform == "darwin":+ return _user_site_dir_mac(kicad_version)+ # Linux KiCad install paths vary wildly (flatpak vs apt vs build-+ # from-source). Caller will get "skipped_unsupported_platform".+ return None+++def _file_hash(path: Path) -> Optional[str]:+ try:+ with open(path, "rb") as f:+ return hashlib.sha256(f.read()).hexdigest()+ except OSError:+ return None+++_BOOT_PY = """\+\"\"\"adom_bridge_boot - .pth-driven loader for the Adom KiCad reverse bridge.++Imported by adom_bridge_boot.pth during site.addsitedir(), which KiCad's own+sitecustomize.py always calls. This path does NOT depend on the interpreter+running site.execusercustomize() - KiCad 10.0.5 GUI processes stopped doing+that, which silently killed the plugin (issue #31).++Delegates to usercustomize.py so there is exactly one loader implementation;+Python's module cache makes a later 'import usercustomize' a no-op.+\"\"\"+try:+ import os+ import sys++ _exe = os.path.basename(getattr(sys, "executable", "") or "").lower()+ # Only inside real KiCad GUI processes: a bare python.exe running -c (the+ # bridge's own probes) must not start a server that dies a second later.+ if any(_exe.startswith(n) for n in ("kicad", "eeschema", "pcbnew", "gerbview",+ "pl_editor", "bitmap2component")):+ import usercustomize # noqa: F401 (its import IS the bootstrap)+except Exception:+ pass+"""+++def _file_state(dest: Path) -> dict:+ """Inspect a destination file and return state for the feedback report.++ Returns a dict with `exists`, `size`, `sha256`, `mtime` (or `_error`).+ """+ if not dest.exists():+ return {"exists": False}+ try:+ st = dest.stat()+ return {+ "exists": True,+ "size": st.st_size,+ "sha256": _file_hash(dest),+ "mtime": datetime.utcfromtimestamp(st.st_mtime).isoformat() + "Z",+ }+ except OSError as e:+ return {"exists": True, "_error": f"stat failed: {e}"}+++def _is_kicad_running() -> list[dict]:+ """Best-effort: return PIDs of running KiCad GUI exes. So the AI can+ warn the user that the freshly-installed plugin won't be active until+ they restart KiCad."""+ if sys.platform != "win32":+ return []+ try:+ import subprocess+ out = subprocess.run(+ ["tasklist", "/FO", "CSV", "/NH"],+ capture_output=True, text=True, timeout=10,+ )+ running: list[dict] = []+ targets = {"kicad.exe", "pcbnew.exe", "eeschema.exe", "kicad-cli.exe"}+ for line in out.stdout.splitlines():+ parts = [p.strip('"') for p in line.split(",")]+ if len(parts) < 2:+ continue+ name = parts[0].lower()+ if name in targets:+ try:+ running.append({"exeName": name, "pid": int(parts[1])})+ except ValueError:+ pass+ return running+ except (OSError, subprocess.SubprocessError):+ return []+++def _install_one_version(version: str, version_info: dict) -> dict:+ """Install / refresh the plugin for ONE KiCad version. Returns a+ dict describing what happened."""+ report: dict = {+ "kicadVersion": version,+ "installPath": version_info.get("base_dir"),+ }+ payload = _payload_dir()+ if not payload.exists():+ report["action"] = "failed"+ report["errorCode"] = "payload_missing"+ report["error"] = f"plugin payload directory not found at {payload}"+ report["_hint"] = (+ "The plugin_payload/ directory wasn't shipped with the bundle. This is a "+ "build regression — see verify_bundle.py in src-tauri/. Report to the user "+ "and ask them to upgrade adom-desktop to a build that includes the payload "+ "(verify with: adom-desktop --version, expecting v1.7.5 or newer)."+ )+ return report++ user_site = _user_site_dir(version)+ if user_site is None:+ report["action"] = "skipped"+ report["errorCode"] = "user_site_unavailable"+ report["error"] = (+ f"could not compute USER_SITE path on {sys.platform} for KiCad {version} "+ f"(missing USERPROFILE / HOME?)"+ )+ report["_hint"] = (+ f"Plugin auto-install is only supported on Windows in v1.7.x; "+ f"this is {sys.platform}. macOS parity is Phase 1 follow-up. "+ "Other kicad_* commands still work via the legacy UI-automation path; "+ "you just won't have the in-process reverse bridge."+ )+ return report++ report["userSitePath"] = str(user_site)+ src_bridge = payload / "adom_bridge.py"+ src_loader = payload / "usercustomize.py"+ dest_bridge = user_site / "adom_bridge.py"+ dest_loader = user_site / "usercustomize.py"+ # 0.9.162 - THE 10.0.5 BIND REGRESSION (#31), root-caused 2026-08-18.+ # usercustomize.py only runs if the interpreter executes site's+ # execusercustomize() step, and KiCad 10.0.5's GUI processes no longer do:+ # a 10.0.3 kicad.exe wrote its discovery breadcrumb, the 10.0.5 one never+ # did, so adom_bridge.start() was never called and every kicad_bridge_status+ # read "0 alive" while KiCad sat right there running.+ #+ # A .pth file does not depend on that step. KiCad's OWN sitecustomize.py+ # calls site.addsitedir(user_site) to make its 3rdparty packages importable,+ # and addsitedir EXECUTES any line in a .pth that begins with "import" - so+ # this loader fires wherever KiCad's Python is set up at all. Re-importing+ # usercustomize is free: Python caches modules, so if execusercustomize DOES+ # run later it is a no-op rather than a second server start.+ dest_boot = user_site / "adom_bridge_boot.py"+ dest_pth = user_site / "adom_bridge_boot.pth"++ # Pre-flight: inspect current state so feedback shows before/after+ pre_state = {+ "adom_bridge.py": _file_state(dest_bridge),+ "usercustomize.py": _file_state(dest_loader),+ "adom_bridge_boot.pth": _file_state(dest_pth),+ }+ report["preState"] = pre_state++ # Compare hashes — short-circuit if already up to date+ src_bridge_hash = _file_hash(src_bridge)+ src_loader_hash = _file_hash(src_loader)+ dest_bridge_hash = pre_state["adom_bridge.py"].get("sha256")+ dest_loader_hash = pre_state["usercustomize.py"].get("sha256")++ if (dest_bridge_hash == src_bridge_hash and dest_loader_hash == src_loader_hash+ and pre_state["adom_bridge_boot.pth"]["exists"]):+ report["action"] = "already-current"+ report["payloadVersion"] = PAYLOAD_VERSION+ report["message"] = (+ f"plugin already deployed at {user_site} and content matches the shipped "+ f"payload (sha256 identical for both files). No write performed."+ )+ return report++ # Mkdir the user-site if missing. The KiCad sitecustomize.py creates+ # this on first launch too, so we're paving the same path it would.+ try:+ user_site.mkdir(parents=True, exist_ok=True)+ except OSError as e:+ report["action"] = "failed"+ report["errorCode"] = "permission_denied"+ report["error"] = f"could not create {user_site}: {e}"+ report["_hint"] = (+ "Filesystem permission issue creating the KiCad user-site directory. "+ "This is rare on Windows. Possible causes: (1) Documents folder is on "+ "OneDrive with sync errors; (2) antivirus blocking. Report exact error "+ "to the user and have them try: `mkdir -p '<userSitePath>'` manually, "+ "then retry the kicad_* command. If that also fails, the user has a "+ "broken filesystem state."+ )+ return report++ # Copy. shutil.copy2 preserves timestamps which is harmless. We do+ # one file at a time so a half-failure still leaves a coherent state.+ written: list[dict] = []+ for src, dest in ((src_bridge, dest_bridge), (src_loader, dest_loader)):+ try:+ shutil.copy2(src, dest)+ written.append({+ "name": src.name,+ "src": str(src),+ "dest": str(dest),+ "size": dest.stat().st_size,+ "sha256": _file_hash(dest),+ })+ except OSError as e:+ report["action"] = "failed"+ report["errorCode"] = "copy_failed"+ report["error"] = f"copying {src.name} -> {dest}: {e}"+ report["filesWritten"] = written+ report["_hint"] = (+ "Failed mid-copy. Most likely cause on Windows: the destination .py "+ "is currently imported by a running KiCad process which has Windows-"+ "level locks on the file. Fix: ask the user to close ALL KiCad windows "+ "(Project Manager, PCB Editor, Schematic Editor), then retry the "+ "kicad_* command. If the file isn't held, this could also be an "+ "antivirus quarantine — check Windows Defender's protection history."+ )+ return report++ # The .pth loader pair (see the note above). Written after the payload so a+ # half-failed copy never leaves a .pth pointing at a missing module.+ try:+ dest_boot.write_text(_BOOT_PY, encoding="utf-8")+ dest_pth.write_text("import adom_bridge_boot\n", encoding="utf-8")+ for p in (dest_boot, dest_pth):+ written.append({"name": p.name, "src": "generated", "dest": str(p),+ "size": p.stat().st_size, "sha256": _file_hash(p)})+ except OSError as e:+ report["pthLoaderError"] = str(e)++ report["action"] = "installed" if not pre_state["adom_bridge.py"]["exists"] else "updated"+ report["payloadVersion"] = PAYLOAD_VERSION+ report["filesWritten"] = written+ report["message"] = (+ f"plugin {'installed' if report['action'] == 'installed' else 'updated to v' + PAYLOAD_VERSION} "+ f"at {user_site}. KiCad GUI processes launched AFTER this point will auto-load it via "+ f"sitecustomize.py + usercustomize.py."+ )+ return report+++def _do_install(all_kicad_versions: list[dict]) -> dict:+ """Internal entry. Iterates over every detected KiCad version and+ installs into each. Returns a top-level summary dict."""+ started_at = datetime.utcnow().isoformat() + "Z"++ if not all_kicad_versions:+ return {+ "triggered": True,+ "startedAt": started_at,+ "completedAt": datetime.utcnow().isoformat() + "Z",+ "summary": "no-kicad",+ "errorCode": "no_kicad_installed",+ "perVersion": [],+ "_hint": (+ "Auto-install skipped because kicad_detect found zero KiCad installs. "+ "Report this to the user; recommend installing KiCad from "+ "https://www.kicad.org/download/ (or via `adom-desktop desktop_install_kicad` "+ "which uses winget). After install, the next kicad_* command will retry "+ "the auto-install for you."+ ),+ }++ running = _is_kicad_running()+ per_version = []+ overall_action = "already-current"+ overall_error = None+ for v in all_kicad_versions:+ version = v.get("version") or "10.0"+ try:+ per_version.append(_install_one_version(version, v))+ except Exception as e: # pylint: disable=broad-except+ per_version.append({+ "kicadVersion": version,+ "action": "failed",+ "errorCode": "unexpected_error",+ "error": f"{type(e).__name__}: {e}",+ "_traceback": traceback.format_exc(),+ "_hint": (+ "Unexpected exception during plugin install for this version. "+ "Full traceback in `_traceback`. Report to John on the adom-desktop "+ "side; this should not happen and is a real bug, not an environment "+ "issue."+ ),+ })++ # Pick the most-severe action across all versions as the overall summary.+ actions = [pv.get("action") for pv in per_version]+ if any(a == "failed" for a in actions):+ overall_action = "partial-failure"+ overall_error = next(pv for pv in per_version if pv.get("action") == "failed").get("errorCode")+ elif any(a == "installed" for a in actions):+ overall_action = "installed"+ elif any(a == "updated" for a in actions):+ overall_action = "updated"+ elif any(a == "skipped" for a in actions):+ overall_action = "skipped"++ result: dict = {+ "triggered": True,+ "startedAt": started_at,+ "completedAt": datetime.utcnow().isoformat() + "Z",+ "summary": overall_action,+ "payloadVersion": PAYLOAD_VERSION,+ "perVersion": per_version,+ "runningKiCad": running,+ }++ if overall_error:+ result["errorCode"] = overall_error++ # Top-level hint depends on what happened + whether KiCad was running+ if overall_action == "installed":+ if running:+ running_summary = ", ".join(f"{r['exeName']} pid={r['pid']}" for r in running)+ result["_hint"] = (+ f"Plugin successfully INSTALLED for the first time. HOWEVER, KiCad is "+ f"currently running ({running_summary}). Those running processes will "+ f"NOT pick up the new plugin (usercustomize.py only fires at Python "+ f"interpreter init, which happens at KiCad startup). "+ f"\n\nTo use the new reverse bridge, ask the user to: (1) Save any work "+ f"in their open KiCad windows; (2) Close ALL KiCad windows; (3) Reopen "+ f"KiCad. After that, run kicad_bridge_status to verify the plugin came "+ f"online.\n\nIf the user doesn't want to restart KiCad right now, the "+ f"existing non-bridge commands (open_board, install_symbol, etc.) keep "+ f"working through the legacy UI-automation path. The bridge is purely "+ f"additive."+ )+ else:+ result["_hint"] = (+ "Plugin successfully INSTALLED. No KiCad processes were running at "+ "install time, so the next time the user (or your next kicad_* command) "+ "launches KiCad, the reverse-bridge plugin will auto-load. Verify with "+ "kicad_bridge_status once KiCad is up."+ )+ elif overall_action == "updated":+ if running:+ result["_hint"] = (+ "Plugin successfully UPDATED to v" + PAYLOAD_VERSION + ". Running "+ "KiCad processes still have the OLD plugin loaded in-memory. They'll "+ "switch to the new version on next restart. Not urgent — current "+ "session still works."+ )+ else:+ result["_hint"] = (+ "Plugin successfully UPDATED to v" + PAYLOAD_VERSION + ". Next KiCad "+ "launch picks it up automatically."+ )+ elif overall_action == "already-current":+ result["_hint"] = (+ "Plugin already current at v" + PAYLOAD_VERSION + ". No-op. "+ "Future kicad_* commands won't re-check; this status is cached for the "+ "bridge process lifetime."+ )+ elif overall_action == "partial-failure":+ result["_hint"] = (+ "AT LEAST ONE KiCad version's plugin install failed. Inspect perVersion[] "+ "for which exact version's errorCode applies. The bridge still works for "+ "the versions that succeeded; the failed ones fall back to legacy UI "+ "automation. Report the specific errorCode to the user — each one has its "+ "own recovery path documented in the per-version `_hint` field."+ )+ elif overall_action == "skipped":+ result["_hint"] = (+ "Plugin install skipped — see per-version errorCodes. Most common reason: "+ "running on macOS/Linux where Phase 2 hasn't been ported yet. Legacy "+ "kicad_* commands keep working; you just don't have the reverse bridge."+ )++ return result+++def ensure_plugin_installed_once(all_kicad_versions: list[dict]) -> dict:+ """Install the plugin if needed; cache the result.++ Idempotent: every call after the first returns the cached dict, marked+ with `triggered: False` and `cached: True` so consumers can tell whether+ this call actually did anything.+ """+ global _install_result # pylint: disable=global-statement+ with _install_lock:+ if _install_result is not None:+ cached = dict(_install_result)+ cached["triggered"] = False+ cached["cached"] = True+ return cached+ # Honor disable env var. Useful for development/diagnostic.+ if os.environ.get("ADOM_KICAD_PLUGIN_AUTO_INSTALL_DISABLE") == "1":+ _install_result = {+ "triggered": False,+ "summary": "disabled",+ "errorCode": "disabled_via_env",+ "_hint": (+ "ADOM_KICAD_PLUGIN_AUTO_INSTALL_DISABLE=1 in the kicad bridge's "+ "environment. To install manually, call: "+ "adom-desktop kicad_install_plugin (Phase 2.1)."+ ),+ }+ return dict(_install_result)+ _install_result = _do_install(all_kicad_versions)+ return _install_result+++def attach_to_response_if_first(result: dict) -> dict:+ """Attach `pluginAutoInstall` to a response dict EXACTLY ONCE per+ bridge boot. After the AI has been notified on first command, future+ responses skip the field to keep payloads small.++ bridge_status / bridge_call ALWAYS get the field attached regardless,+ since those verbs explicitly ask about plugin state.+ """+ global _response_attached # pylint: disable=global-statement+ if not isinstance(result, dict):+ return result+ if _response_attached and _install_result is not None:+ return result+ if _install_result is None:+ return result+ result["pluginAutoInstall"] = dict(_install_result)+ _response_attached = True+ return result+++def force_attach(result: dict) -> dict:+ """For bridge_status / bridge_call — always attach the current install+ state, regardless of `_response_attached`. Useful when the AI is+ explicitly inspecting plugin state."""+ if not isinstance(result, dict) or _install_result is None:+ return result+ result["pluginAutoInstall"] = dict(_install_result)+ return result+++def get_cached_result() -> Optional[dict]:+ return dict(_install_result) if _install_result is not None else None+++def force_install(all_kicad_versions: list[dict]) -> dict:+ """Bypass the once-per-boot cache and re-run the install. The new+ result replaces the cache so subsequent attach_to_response_if_first()+ calls see the fresh state.++ Used by the explicit `kicad_install_plugin` verb."""+ global _install_result, _response_attached # pylint: disable=global-statement+ with _install_lock:+ _install_result = _do_install(all_kicad_versions)+ # Reset the "attached on first response" flag so future calls+ # surface the fresh install state once more.+ _response_attached = False+ return dict(_install_result)+++def handle_install_plugin(kicad_info: dict, args: dict) -> dict:+ """Explicit kicad_install_plugin verb. Bypasses the auto-install+ cache and forces a fresh deploy + status check.++ Args:+ force: default true. If false, behaves like the auto-install path+ (returns cached result if it's already run this boot).++ Returns the full install report. Useful for: debugging when the+ plugin isn't loading, updating the payload between releases without+ restarting the bridge, recovering from a partial-failure install.+ """+ # Need the full all_kicad_versions list from server.py — caller+ # passed kicad_info (the default version). Re-detect to get all.+ from kicad_detect import detect_all_kicad_versions+ all_versions = detect_all_kicad_versions()++ if args.get("force") is False:+ # Honor explicit force=false by behaving like the auto path+ return {"success": True, "result": ensure_plugin_installed_once(all_versions)}++ result = force_install(all_versions)+ return {+ "success": result.get("summary") not in ("partial-failure",),+ "result": result,+ "_hint": (+ "force-reinstall complete. Future kicad_* commands will show "+ "this fresh install state in their pluginAutoInstall fields. "+ "If KiCad was running during install, ask the user to restart "+ "those processes — the in-memory Python interpreter still has "+ "the OLD plugin loaded until next launch."+ ),+ }