app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
master
John Lauer
v0.9.109: kicad_export_part (one-off single-part library exports: symbol-only .kicad_sym, footprint-only .pretty, bare…
9d49110
2mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
"""uninstall.py — remove everything this bridge ever created on the machine.
Implements the Bridge SDK uninstall contract (kicad-bridge issue #18, ab 2.0.28):
Adom Bridge's uninstaller CASCADES — it calls each bridge's `<prefix>uninstall`
before removing itself — so this verb must leave nothing of ours behind.
Contract requirements honored here:
- IDEMPOTENT + NON-INTERACTIVE: safe when already clean; never prompts, never
blocks; everything goes to the log/report, not a dialog.
- BEST-EFFORT PER ITEM: one locked file doesn't abort the rest; the report says
exactly what was and wasn't removed.
- SCOPED TO OUR OWN ARTIFACTS: never touches KiCad itself, other bridges, or
the user's own projects/data.
What this bridge creates (and this verb removes):
1. Plugin payload — adom_bridge.py + usercustomize.py (+ their __pycache__)
in Documents/KiCad/<ver>/3rdparty/Python311/site-packages/
2. Adom symbol lib — Documents/KiCad/<ver>/symbols/Adom.kicad_sym + its
"Adom" row in %APPDATA%/kicad/<ver>/sym-lib-table
3. Adom footprints — Documents/KiCad/<ver>/footprints/Adom.pretty/ + its
"Adom" row in fp-lib-table + Documents/KiCad/<ver>/3dmodels/Adom/
(3D models delivered via install_footprint's model arg, 0.9.107)
4. Mesa fallback — opengl32.dll / libgallium_wgl.dll / dxil.dll (+ *.local)
that kicad_enable_software_opengl dropped into a KiCad bin/ (KiCad never
ships these in bin, so presence == our deploy)
5. Discovery files — %TEMP%/adom-kicad-bridge-*.json
6. Screenshot dir — C:/tmp/adom-desktop-screenshots
7. Demo project — Documents/adom-kicad-demo (generated by kicad_demo)
Deliberately NOT removed: the user's KiCad config beyond our two lib-table rows,
any user project, KiCad itself.
"""
import os
import shutil
import sys
import tempfile
from pathlib import Path
IS_WINDOWS = sys.platform == "win32"
_MESA_DLLS = ("opengl32.dll", "libgallium_wgl.dll", "dxil.dll")
def _kicad_user_roots() -> list[Path]:
"""Documents/KiCad/<ver> dirs (all versions, not just the active one)."""
up = os.environ.get("USERPROFILE")
if not up:
return []
root = Path(up) / "Documents" / "KiCad"
if not root.is_dir():
return []
return [d for d in root.iterdir() if d.is_dir()]
def _kicad_config_roots() -> list[Path]:
"""%APPDATA%/kicad/<ver> dirs."""
ap = os.environ.get("APPDATA")
if not ap:
return []
root = Path(ap) / "kicad"
if not root.is_dir():
return []
return [d for d in root.iterdir() if d.is_dir()]
def _kicad_bin_dirs(kicad_info: dict) -> list[Path]:
"""Every KiCad bin/ we can find (all installs, both scopes)."""
dirs: list[Path] = []
bd = kicad_info.get("bin_dir")
if bd:
dirs.append(Path(bd))
for env, sub in (("ProgramFiles", "KiCad"), ("LOCALAPPDATA", "Programs/KiCad")):
base = os.environ.get(env)
if not base:
continue
root = Path(base) / sub
if root.is_dir():
for ver in root.iterdir():
b = ver / "bin"
if b.is_dir():
dirs.append(b)
seen, out = set(), []
for d in dirs:
k = str(d).lower()
if k not in seen:
seen.add(k)
out.append(d)
return out
def handle_uninstall(kicad_info: dict, args: dict) -> dict:
"""Remove every artifact this bridge created.
Args:
dryRun (default false) — preview only.
testResidue (default false) — remove ONLY what the daily verb suite
creates (kicad-bridge-test skill): the verbtest fixture dir, the demo
project, the generated ADOM_VERBTEST symbol/footprint inside the Adom
libraries, the AdomVerbTest lib-table registration, and stale discovery
files. Leaves the plugin, the Adom libraries themselves, and every
user artifact alone. This is how the suite cleans up after every run
(John, 2026-08-14: "clean up after yourself").
"""
dry = bool(args.get("dryRun"))
test_residue = bool(args.get("testResidue"))
removed: list[str] = []
failed: list[dict] = []
absent = 0
def rm_file(p: Path, label: str):
nonlocal absent
try:
if not p.exists():
absent += 1
return
if not dry:
p.unlink()
removed.append(f"{label}: {p}")
except Exception as e:
failed.append({"item": f"{label}: {p}", "error": str(e)})
def _rmtree_onerror(func, path, _exc):
# Windows: git object files are read-only; chmod +w and retry.
try:
os.chmod(path, 0o700)
func(path)
except Exception:
raise
def rm_tree(p: Path, label: str):
nonlocal absent
try:
if not p.exists():
absent += 1
return
if not dry:
shutil.rmtree(p, onerror=_rmtree_onerror)
removed.append(f"{label}: {p}")
except Exception as e:
failed.append({"item": f"{label}: {p}", "error": str(e)})
if test_residue:
return _remove_test_residue(kicad_info, dry, rm_file, rm_tree,
removed, failed, lambda: absent)
# 1) plugin payload (+ pycache), 2) Adom symbol lib file, 3) Adom.pretty
for ver_dir in _kicad_user_roots():
site = ver_dir / "3rdparty" / "Python311" / "site-packages"
rm_file(site / "adom_bridge.py", "plugin payload")
rm_file(site / "usercustomize.py", "plugin loader")
pyc = site / "__pycache__"
if pyc.is_dir():
for f in pyc.glob("adom_bridge*.pyc"):
rm_file(f, "plugin pycache")
for f in pyc.glob("usercustomize*.pyc"):
rm_file(f, "plugin pycache")
rm_file(ver_dir / "symbols" / "Adom.kicad_sym", "Adom symbol library")
rm_tree(ver_dir / "footprints" / "Adom.pretty", "Adom footprint library")
rm_tree(ver_dir / "3dmodels" / "Adom", "Adom 3D model library")
# lib-table rows (sym + fp) in every config version
try:
from lib_table import LibTable
for cfg in _kicad_config_roots():
for table_name in ("sym-lib-table", "fp-lib-table"):
tp = cfg / table_name
if not tp.exists():
absent += 1
continue
try:
table = LibTable.parse_file(str(tp))
if table.has_library("Adom"):
if not dry:
table.remove_library("Adom")
table.write_file(str(tp))
removed.append(f"'Adom' row in {tp}")
else:
absent += 1
except Exception as e:
failed.append({"item": f"'Adom' row in {tp}", "error": str(e)})
except Exception as e:
failed.append({"item": "lib-table cleanup", "error": str(e)})
# 4) Mesa software-OpenGL deploy
for b in _kicad_bin_dirs(kicad_info):
for dll in _MESA_DLLS:
rm_file(b / dll, "mesa fallback")
rm_file(b / (dll + ".local"), "mesa fallback marker")
for loc in b.glob("*.exe.local"):
rm_file(loc, "mesa dotlocal")
# 5) discovery files
tmp = Path(tempfile.gettempdir())
for f in tmp.glob("adom-kicad-bridge-*.json"):
rm_file(f, "plugin discovery file")
# 6) screenshot cache, 7) demo project, 8) part exports/staging (0.9.109)
rm_tree(Path("C:/tmp/adom-desktop-screenshots"), "screenshot cache")
up = os.environ.get("USERPROFILE")
if up:
rm_tree(Path(up) / "Documents" / "adom-kicad-demo", "demo project")
rm_tree(Path(up) / "Documents" / "adom-part-exports", "one-off part exports")
rm_tree(Path(up) / "Documents" / "adom-part-staging", "part install staging")
ok = len(failed) == 0
return {
"success": ok,
# ab 2.0.35+: bridge_uninstall cascades into this verb with
# {cascade:true, name:"kicad"} while our files still exist; echo it so
# the cascade report on ab's side shows this ran as part of a cascade.
"cascade": bool(args.get("cascade")),
"dryRun": dry,
"removed": removed,
"failed": failed,
"alreadyClean": len(removed) == 0 and ok,
"summary": f"{'would remove' if dry else 'removed'} {len(removed)} item(s), "
f"{len(failed)} failed, {absent} already absent",
"_hint": ("Bridge-created artifacts only — KiCad itself, user projects, and other bridges are "
"untouched. Idempotent: safe to re-run. A running KiCad may hold the plugin's "
".pyc open; re-run after KiCad exits if anything failed. Bridge removes its own "
"bridges-cache during the cascade."),
}
def _remove_test_residue(kicad_info, dry, rm_file, rm_tree, removed, failed, absent_fn):
"""The suite-residue subset (see handle_uninstall docstring)."""
up = os.environ.get("USERPROFILE")
if up:
rm_tree(Path(up) / "Documents" / "adom-kicad-verbtest", "verbtest fixture dir")
rm_tree(Path(up) / "Documents" / "adom-kicad-demo", "demo project")
# ADOM_VERBTEST entries inside the Adom libraries (surgical, not the libs)
for ver_dir in _kicad_user_roots():
rm_file(ver_dir / "footprints" / "Adom.pretty" / "ADOM_VERBTEST.kicad_mod",
"test footprint")
sym = ver_dir / "symbols" / "Adom.kicad_sym"
if sym.exists():
try:
import adom_library
text = sym.read_text(encoding="utf-8")
blocks = adom_library._extract_top_level_symbols(text)
hit = [b for n, b in blocks if n == "ADOM_VERBTEST"]
if hit:
if not dry:
for b in hit:
text = text.replace(b, "")
sym.write_text(text, encoding="utf-8")
removed.append(f"ADOM_VERBTEST symbol in {sym}")
except Exception as e:
failed.append({"item": f"ADOM_VERBTEST in {sym}", "error": str(e)})
# AdomVerbTest lib-table registration (points at the deleted fixture dir)
try:
from lib_table import LibTable
for cfg in _kicad_config_roots():
for table_name in ("sym-lib-table", "fp-lib-table"):
tp = cfg / table_name
if not tp.exists():
continue
try:
table = LibTable.parse_file(str(tp))
if table.has_library("AdomVerbTest"):
if not dry:
table.remove_library("AdomVerbTest")
table.write_file(str(tp))
removed.append(f"'AdomVerbTest' row in {tp}")
except Exception as e:
failed.append({"item": f"'AdomVerbTest' row in {tp}", "error": str(e)})
except Exception as e:
failed.append({"item": "lib-table cleanup", "error": str(e)})
# stale discovery files (dead pids)
tmp = Path(tempfile.gettempdir())
for f in tmp.glob("adom-kicad-bridge-*.json"):
try:
pid = int(f.stem.rsplit("-", 1)[-1])
except ValueError:
rm_file(f, "legacy discovery file")
continue
try:
import ctypes
h = ctypes.windll.kernel32.OpenProcess(0x1000, False, pid)
if h:
ctypes.windll.kernel32.CloseHandle(h)
else:
rm_file(f, "stale discovery file")
except Exception:
pass
ok = len(failed) == 0
return {
"success": ok, "testResidue": True, "dryRun": dry,
"removed": removed, "failed": failed,
"alreadyClean": len(removed) == 0 and ok,
"summary": f"{'would remove' if dry else 'removed'} {len(removed)} test-residue item(s), "
f"{len(failed)} failed",
"_hint": ("Suite residue only — plugin, Adom libraries, KiCad and user files untouched. "
"Run after every verb-suite pass; idempotent."),
}