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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
"""kicad_diagnostics — read-only self-inspection so debugging the bridge never
needs Bridge's approval-gated `run_script`/shell.
Everything here runs INSIDE the bridge process, which already has full filesystem
+ subprocess access on the user's desktop (no approval prompt). During the
ADOMBASELINE bring-up we kept reaching for Bridge's `run_script` (powershell) just to
answer "where did KiCad install?", "what env does the bridge see?", "is pcbnew
running?" — all of which the bridge can answer directly. This verb exposes that,
so an agent can diagnose a detection/config/env problem with zero shell approval.
Args (all optional):
probePaths: [str] — for each path report exists/isDir/isFile/size + (dirs) a
capped child listing. Read-only metadata.
readFile: str — return the text content of a single file if it's small
(<256 KB) and decodes as UTF-8. For peeking config/output.
maxEntries: int — cap on dir-listing length per probed dir (default 60).
"""
import os
import sys
import platform
import subprocess
from pathlib import Path
_READ_LIMIT = 256 * 1024
_ENV_KEYS = (
"LOCALAPPDATA", "APPDATA", "USERPROFILE", "HOMEDRIVE", "HOMEPATH",
"PROGRAMFILES", "PROGRAMFILES(X86)", "PROGRAMDATA", "TEMP", "TMP",
"ADOM_BIND_HOST", "PATH",
)
_PROC_NAMES = ("kicad", "kicad-cli", "pcbnew", "eeschema", "gerbview",
"pl_editor", "bitmap2component", "pcb_calculator")
def _list_processes() -> list:
"""Running KiCad-family processes (name + pid + exe). On Windows uses the
ctypes EnumProcesses API (instant, in-process) instead of spawning tasklist,
which timed out dumping the full process table on a busy VM."""
procs = []
exes = {n + ".exe" for n in _PROC_NAMES}
if sys.platform == "win32":
try:
import ctypes
import ctypes.wintypes
psapi = ctypes.windll.psapi
kernel32 = ctypes.windll.kernel32
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
arr = (ctypes.wintypes.DWORD * 8192)()
br = ctypes.wintypes.DWORD()
if not psapi.EnumProcesses(ctypes.byref(arr), ctypes.sizeof(arr), ctypes.byref(br)):
return [{"error": "EnumProcesses failed"}]
count = br.value // ctypes.sizeof(ctypes.wintypes.DWORD)
buf = ctypes.create_unicode_buffer(1024)
for i in range(count):
pid = arr[i]
if not pid:
continue
h = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not h:
continue
try:
size = ctypes.wintypes.DWORD(1024)
if kernel32.QueryFullProcessImageNameW(h, 0, buf, ctypes.byref(size)):
p = buf.value
if os.path.basename(p).lower() in exes:
procs.append({"name": os.path.basename(p), "pid": pid, "exe": p})
finally:
kernel32.CloseHandle(h)
except Exception as e: # pylint: disable=broad-except
return [{"error": f"EnumProcesses failed: {e}"}]
else:
try:
out = subprocess.run(["ps", "-eo", "pid,comm"], capture_output=True,
text=True, timeout=10).stdout
for line in out.splitlines()[1:]:
bits = line.split(None, 1)
if len(bits) == 2 and any(n in bits[1].lower() for n in _PROC_NAMES):
procs.append({"name": bits[1].strip(), "pid": bits[0]})
except Exception as e: # pylint: disable=broad-except
return [{"error": f"ps failed: {e}"}]
return procs
def _probe(path_str: str, max_entries: int) -> dict:
p = Path(path_str)
info = {"path": str(p), "exists": False, "isDir": False, "isFile": False}
try:
info["exists"] = p.exists()
if not info["exists"]:
return info
info["isDir"] = p.is_dir()
info["isFile"] = p.is_file()
if info["isFile"]:
info["size"] = p.stat().st_size
elif info["isDir"]:
names = []
for i, entry in enumerate(sorted(p.iterdir(), key=lambda e: e.name)):
if i >= max_entries:
info["truncated"] = True
break
names.append(entry.name + ("/" if entry.is_dir() else ""))
info["entries"] = names
except Exception as e: # pylint: disable=broad-except
info["error"] = str(e)
return info
def handle_diagnostics(kicad_info: dict, args: dict) -> dict:
args = args or {}
out = {"success": True}
# 1. What environment does THE BRIDGE see (not the desktop user — Bridge strips it).
out["environment"] = {
"vars": {k: os.environ.get(k) for k in _ENV_KEYS},
"homeResolved": str(Path.home()),
"pythonExe": sys.executable,
"pythonVersion": sys.version.split()[0],
"platform": platform.platform(),
"pid": os.getpid(),
"cwd": os.getcwd(),
}
# 2. KiCad-detection breakdown: every base the detector scans, whether it
# exists, and which version dirs / kicad.exe it finds. This is the direct
# lens on the "%LOCALAPPDATA% stripped → per-user install invisible" class.
try:
import kicad_detect
bases = []
for b in getattr(kicad_detect, "WIN_KICAD_BASES", []):
bp = Path(b)
entry = {"base": str(bp), "exists": bp.exists(), "versionDirs": []}
if entry["exists"]:
try:
for d in sorted(bp.iterdir()):
if d.is_dir():
exe = d / "bin" / "kicad.exe"
entry["versionDirs"].append(
{"dir": d.name, "kicadExe": str(exe), "exeExists": exe.exists()})
except Exception as e: # pylint: disable=broad-except
entry["error"] = str(e)
bases.append(entry)
out["kicadSearch"] = {
"bases": bases,
"detected": kicad_detect.detect_all_kicad_versions(),
}
except Exception as e: # pylint: disable=broad-except
out["kicadSearch"] = {"error": str(e)}
# 3. Running KiCad-family processes.
out["processes"] = _list_processes()
# 4. The kicad_info the bridge is currently operating with + dir existence.
ki = {k: kicad_info.get(k) for k in
("version", "activeVersion", "kicad_exe", "base_dir",
"config_dir", "user_dir", "installed")}
for key in ("config_dir", "user_dir", "base_dir"):
v = kicad_info.get(key)
ki[key + "_exists"] = bool(v and Path(v).exists())
out["kicadInfo"] = ki
# 5. Optional scoped path probes + a small-file read (read-only).
probe_paths = args.get("probePaths") or []
if isinstance(probe_paths, str):
probe_paths = [probe_paths]
max_entries = int(args.get("maxEntries", 60))
if probe_paths:
out["probes"] = [_probe(p, max_entries) for p in probe_paths]
read_file = args.get("readFile")
if read_file:
rp = Path(read_file)
fr = {"path": str(rp)}
try:
if not rp.is_file():
fr["error"] = "not a file"
elif rp.stat().st_size > _READ_LIMIT:
fr["error"] = f"too large ({rp.stat().st_size} bytes > {_READ_LIMIT})"
else:
fr["content"] = rp.read_text(encoding="utf-8", errors="replace")
except Exception as e: # pylint: disable=broad-except
fr["error"] = str(e)
out["fileRead"] = fr
out["_hint"] = (
"Read-only bridge self-diagnostics — use this INSTEAD of Bridge's approval-gated "
"run_script/shell when debugging the bridge. `environment` shows what env the "
"BRIDGE sees (Bridge strips it — a missing LOCALAPPDATA here explains a per-user "
"KiCad going undetected). `kicadSearch.bases` shows every path the detector "
"scans and what it finds. `processes` lists running KiCad exes. Pass "
"probePaths:[...] to stat/list any path and readFile:\"...\" to peek a small "
"config/output file — all without a shell-approval prompt."
)
return out