app
Adom Desktop - 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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843
"""Headless KiCad lint pre-checks via `kicad-cli`.
This module ships three top-level handlers — `handle_run_erc`,
`handle_lint_board`, `handle_lint_schematic` — that wrap `kicad-cli`
subprocess invocations to give AIs fast, structured validation
*before* attempting GUI automation. GUI automation is slow and fragile;
kicad-cli is fast (no wx, no event loop) and emits clean JSON.
Why lint before GUI:
- Bad file? Parse error caught in 1s instead of a confusing
"the board didn't open" 30 seconds later.
- Schematic ↔ PCB parity broken? Surface the mismatch via DRC
`--schematic-parity` flag BEFORE the AI thinks the board is fine.
- File-format outdated? Tell the user to upgrade BEFORE opening
triggers KiCad's modal "this file was made with an older version"
dialog (which blocks the GUI and confuses screenshot-based recovery).
Each handler returns a `_hint` field that explicitly tells the calling
AI what to do next: "ok to proceed to kicad_open_board", "fix these
violations first", "ask user to upgrade", etc.
Shared concerns extracted into helpers:
- File existence + extension check
- Magic-byte sniff (first ~50 bytes, looking for `(kicad_pcb` etc.)
- subprocess wrapper with timeout + JSON capture
- Violation parser (DRC + ERC share JSON shape mostly)
"""
from __future__ import annotations
import json
import os
import subprocess
import tempfile
from pathlib import Path
from typing import Any
# Magic-byte sniff: KiCad files are S-expression with a known root atom.
# This catches "wrong file type" before kicad-cli even runs.
_PCB_MAGIC_TOKENS = ("(kicad_pcb", "(kicad_pcb (")
_SCH_MAGIC_TOKENS = ("(kicad_sch", "(kicad_sch (")
def _file_format_sniff(path: Path, expected: str) -> dict:
"""Return {ok, expected, magic, fileVersion?, _error?}.
expected: "kicad_pcb" or "kicad_sch"
"""
if not path.exists():
return {
"ok": False,
"_error": f"file not found: {path}",
"_hint": "Verify the path. If the AI is on Docker, the path must exist on the Windows host (use push_file to copy from Docker first).",
}
if not path.is_file():
return {"ok": False, "_error": f"not a file: {path}"}
try:
with open(path, "rb") as f:
head = f.read(512)
except OSError as e:
return {"ok": False, "_error": f"read failed: {e}"}
head_text = head.decode("utf-8", errors="replace").lstrip()
magic_tokens = _PCB_MAGIC_TOKENS if expected == "kicad_pcb" else _SCH_MAGIC_TOKENS
magic_match = any(head_text.startswith(t) for t in magic_tokens)
if not magic_match:
# The file might be valid KiCad but a different type
# (.kicad_sch passed where .kicad_pcb expected), or pure garbage.
wrong_type = None
if expected == "kicad_pcb" and head_text.startswith("(kicad_sch"):
wrong_type = "kicad_sch"
elif expected == "kicad_sch" and head_text.startswith("(kicad_pcb"):
wrong_type = "kicad_pcb"
return {
"ok": False,
"expected": expected,
"actual": wrong_type or "non-kicad",
"_error": f"file is not a {expected} (first bytes: {head_text[:80]!r})",
"_hint": (
f"You passed what looks like a {wrong_type} file to a verb "
f"expecting {expected}. Use the matching verb instead "
f"(kicad_open_board for .kicad_pcb, kicad_open_schematic / "
f"kicad_lint_schematic for .kicad_sch)."
if wrong_type else
f"The file doesn't start with the expected {expected} S-expression header. "
"It may be corrupted, truncated, or not a KiCad file at all."
),
}
# Try to pluck the file-format version. KiCad ≥6 embeds a `(version YYYYMMDD)`
# near the top. We look for the first `(version ...)` in the first 512 bytes.
file_version = None
try:
import re
m = re.search(r"\(version\s+(\d+)\)", head_text)
if m:
file_version = int(m.group(1))
except Exception: # pylint: disable=broad-except
pass
return {
"ok": True,
"expected": expected,
"magic": "matched",
"fileVersion": file_version,
"_note": "magic + format sniff only — kicad-cli will do the real validation",
}
def _run_kicad_cli_json(cli_exe: str, args: list[str], timeout_secs: int = 120) -> dict:
"""Run kicad-cli and capture the JSON output it wrote to --output.
Both `pcb drc` and `sch erc` write their structured report to a file
path supplied via `--output`; stdout/stderr just carry status text.
We make a tempfile, pass it in, parse it back, clean up.
Returns {ok, exitCode, stdout, stderr, data?, _error?}.
Caller adds higher-level `_hint`s based on what the data says.
"""
fd, tmp_path = tempfile.mkstemp(suffix=".json")
os.close(fd)
full_args = list(args) + ["--output", tmp_path]
try:
proc = subprocess.run(
[cli_exe] + full_args,
capture_output=True,
text=True,
timeout=timeout_secs,
)
out_file = Path(tmp_path)
data: Any = None
if out_file.exists() and out_file.stat().st_size > 0:
try:
with open(tmp_path, "r", encoding="utf-8") as f:
data = json.load(f)
except json.JSONDecodeError as e:
return {
"ok": False,
"exitCode": proc.returncode,
"stdout": proc.stdout,
"stderr": proc.stderr,
"_error": f"could not parse kicad-cli JSON output: {e}",
}
return {
"ok": True,
"exitCode": proc.returncode,
"stdout": proc.stdout,
"stderr": proc.stderr,
"data": data,
}
except subprocess.TimeoutExpired:
return {
"ok": False,
"_error": f"kicad-cli timed out after {timeout_secs}s",
"_hint": "Bump the timeout, or the board/schematic may be pathologically large.",
}
except Exception as e: # pylint: disable=broad-except
return {
"ok": False,
"_error": f"kicad-cli invocation failed: {type(e).__name__}: {e}",
}
finally:
try:
Path(tmp_path).unlink(missing_ok=True)
except OSError:
pass
def _parse_violations(data: dict | None) -> tuple[list[dict], dict]:
"""Extract violation list + per-severity counts from DRC/ERC JSON.
Returns (violations[], counts{error,warning,info,exclusion}).
"""
if not data or not isinstance(data, dict):
return [], {"error": 0, "warning": 0, "info": 0, "exclusion": 0}
out: list[dict] = []
counts = {"error": 0, "warning": 0, "info": 0, "exclusion": 0}
# `violations` is the standard list. `unconnected_items` is a DRC-only
# sibling that surfaces ratlines kicad-cli reports separately.
for bucket_key, bucket_label in (("violations", None), ("unconnected_items", "unconnected_items")):
for v in data.get(bucket_key, []):
row = {
"type": v.get("type", bucket_label or "unknown"),
"severity": v.get("severity", "warning"),
"description": v.get("description", ""),
"items": [],
}
for item in v.get("items", []):
row["items"].append({
"description": item.get("description", ""),
"pos": item.get("pos", {}),
})
out.append(row)
sev = row["severity"]
counts[sev] = counts.get(sev, 0) + 1
return out, counts
def _cli_exe_or_error(kicad_info: dict) -> tuple[str | None, dict | None]:
cli = kicad_info.get("kicad_cli_exe")
if not cli or not Path(cli).exists():
return None, {
"success": False,
"error": "kicad-cli not found",
"_hint": "Run kicad_list_versions to check the install. If kicad_cli_exe is missing, the user should reinstall KiCad — kicad-cli ships in the same Bin dir as kicad.exe.",
}
return cli, None
# ── handle_run_erc ──────────────────────────────────────────────────
def handle_run_erc(kicad_info: dict, args: dict) -> dict:
"""Run ERC on a .kicad_sch via `kicad-cli sch erc --format json`.
Mirrors handle_run_drc. Args: {filePath}. Returns the same structured
shape as run_drc: {success, output, data:{violations, summary,
source_file}}.
This was missing as a top-level verb prior to v1.7.12 — schematic
designers had no equivalent of `kicad_run_drc` for catching wiring
errors / missing labels / un-driven nets / etc. before opening the
GUI.
"""
if not kicad_info.get("installed"):
return {"success": False, "error": "KiCad not installed",
"_hint": "Ask the user to install KiCad from https://www.kicad.org/download/."}
file_path = args.get("filePath", "")
if not file_path:
return {"success": False, "error": "filePath is required",
"_hint": "Pass {\"filePath\":\"<absolute path to a .kicad_sch>\"}."}
sch_path = Path(file_path)
sniff = _file_format_sniff(sch_path, "kicad_sch")
if not sniff.get("ok"):
return {"success": False, **sniff}
cli, err = _cli_exe_or_error(kicad_info)
if err:
return err
res = _run_kicad_cli_json(cli, [
"sch", "erc",
"--format", "json",
"--severity-all",
str(sch_path),
])
if not res.get("ok"):
return {
"success": False,
"error": res.get("_error", "kicad-cli failed"),
"stdout": res.get("stdout"),
"stderr": res.get("stderr"),
"_hint": "If exitCode is nonzero with no parsed data, KiCad's CLI couldn't even open the file — fix the file before retrying.",
}
violations, counts = _parse_violations(res.get("data"))
total = len(violations)
return {
"success": True,
"output": f"ERC complete: {counts.get('error', 0)} error(s), {counts.get('warning', 0)} warning(s), {total} total",
"data": {
"violations": violations,
"summary": {
"total": total,
"errors": counts.get("error", 0),
"warnings": counts.get("warning", 0),
"info": counts.get("info", 0),
},
"fileFormat": {
"expected": "kicad_sch",
"fileVersion": sniff.get("fileVersion"),
},
"source_file": str(sch_path),
},
"_hint": (
"ERC passed cleanly — safe to open this schematic in eeschema for further editing."
if counts.get("error", 0) == 0
else
f"{counts.get('error', 0)} ERC error(s). Common causes: unconnected pins (add a NoConnect flag if intentional), "
"duplicate references (run Annotate Schematic), hierarchical-label mismatches, mixed input/output drivers on the "
"same net. Inspect violations[].description for specifics. Fix in eeschema (kicad_open_schematic) before running "
"DRC on the corresponding board."
),
}
# ── handle_lint_board ───────────────────────────────────────────────
def handle_lint_board(kicad_info: dict, args: dict) -> dict:
"""Comprehensive pre-flight lint for a .kicad_pcb. Combines:
- File-existence + magic-byte check
- DRC with --severity-all + --all-track-errors
- Schematic parity (DRC --schematic-parity) — catches board-vs-
schematic drift, a common bug class that's hard to spot from
the PCB alone.
Returns a single structured payload designed for AI consumption:
`{success, fileFormat, drc:{violations, summary}, _hint}`.
Distinct from `kicad_run_drc`:
- run_drc is the existing minimal wrapper around `pcb drc`.
- lint_board adds the file-format sanity layer, enables schematic-
parity (which run_drc didn't), and returns a single AI-friendly
hint string that EXPLICITLY tells the calling AI what to do
next (proceed / fix violations / upgrade file / etc.).
"""
if not kicad_info.get("installed"):
return {"success": False, "error": "KiCad not installed",
"_hint": "Ask the user to install KiCad from https://www.kicad.org/download/."}
file_path = args.get("filePath", "")
if not file_path:
return {"success": False, "error": "filePath is required",
"_hint": "Pass {\"filePath\":\"<absolute path to a .kicad_pcb>\"}."}
pcb_path = Path(file_path)
sniff = _file_format_sniff(pcb_path, "kicad_pcb")
if not sniff.get("ok"):
return {"success": False, **sniff}
cli, err = _cli_exe_or_error(kicad_info)
if err:
return err
schematic_parity = bool(args.get("schematicParity", True))
cli_args = [
"pcb", "drc",
"--format", "json",
"--severity-all",
"--all-track-errors",
str(pcb_path),
]
if schematic_parity:
# Only meaningful if the matching .kicad_sch exists in the project dir
cli_args.insert(-1, "--schematic-parity")
res = _run_kicad_cli_json(cli, cli_args)
if not res.get("ok"):
return {
"success": False,
"error": res.get("_error", "kicad-cli failed"),
"stdout": res.get("stdout"),
"stderr": res.get("stderr"),
"fileFormat": {
"expected": "kicad_pcb",
"fileVersion": sniff.get("fileVersion"),
},
"_hint": (
"If exitCode is nonzero and stderr mentions 'older format', the file needs upgrading — "
"`kicad-cli pcb upgrade` (or just opening it in KiCad and saving) does this. "
"If schematic-parity failed because no matching .kicad_sch was found in the project dir, "
"retry with schematicParity:false."
),
}
violations, counts = _parse_violations(res.get("data"))
total = len(violations)
errors = counts.get("error", 0)
warnings = counts.get("warning", 0)
# Build a tiered _hint so the AI knows what to do next
if errors == 0 and warnings == 0:
hint = "✅ DRC clean. Safe to open kicad_open_board, generate gerbers via export, or hand off to fabrication. No human intervention needed."
elif errors == 0:
hint = (f"⚠️ {warnings} DRC warning(s), 0 errors. The board is fabricable as-is but the warnings may "
"indicate sloppy design (silkscreen overlapping pads, courtyard violations, etc.). "
"Review violations[].description and decide if any need fixing. Safe to proceed with kicad_open_board.")
else:
# Group failure types for the hint
types = sorted({v["type"] for v in violations if v["severity"] == "error"})[:5]
types_str = ", ".join(types) if types else "various"
hint = (f"❌ {errors} DRC error(s) + {warnings} warning(s). Top error types: {types_str}. "
"DO NOT proceed to fabrication. Open kicad_open_board and resolve these in the PCB Editor, "
"OR fix the underlying schematic if these are schematic-parity errors (e.g. nets connected on the schematic "
"but unrouted on the board). Re-run kicad_lint_board after fixing to confirm clean.")
return {
"success": True,
"output": f"Lint complete: {errors} error(s), {warnings} warning(s), {total} total",
"data": {
"violations": violations,
"summary": {
"total": total,
"errors": errors,
"warnings": warnings,
"info": counts.get("info", 0),
},
"fileFormat": {
"expected": "kicad_pcb",
"fileVersion": sniff.get("fileVersion"),
},
"schematicParityChecked": schematic_parity,
"source_file": str(pcb_path),
},
"_hint": hint,
}
# ── handle_lint_schematic ───────────────────────────────────────────
def handle_lint_schematic(kicad_info: dict, args: dict) -> dict:
"""Comprehensive pre-flight lint for a .kicad_sch.
File-existence + magic-byte check + ERC with --severity-all. Same
AI-friendly hint structure as kicad_lint_board.
"""
if not kicad_info.get("installed"):
return {"success": False, "error": "KiCad not installed",
"_hint": "Ask the user to install KiCad from https://www.kicad.org/download/."}
file_path = args.get("filePath", "")
if not file_path:
return {"success": False, "error": "filePath is required",
"_hint": "Pass {\"filePath\":\"<absolute path to a .kicad_sch>\"}."}
sch_path = Path(file_path)
sniff = _file_format_sniff(sch_path, "kicad_sch")
if not sniff.get("ok"):
return {"success": False, **sniff}
cli, err = _cli_exe_or_error(kicad_info)
if err:
return err
res = _run_kicad_cli_json(cli, [
"sch", "erc",
"--format", "json",
"--severity-all",
str(sch_path),
])
if not res.get("ok"):
return {
"success": False,
"error": res.get("_error", "kicad-cli failed"),
"stdout": res.get("stdout"),
"stderr": res.get("stderr"),
"fileFormat": {
"expected": "kicad_sch",
"fileVersion": sniff.get("fileVersion"),
},
"_hint": "If exitCode is nonzero and stderr mentions an older format, run `kicad-cli sch upgrade` (or open + save in eeschema).",
}
violations, counts = _parse_violations(res.get("data"))
total = len(violations)
errors = counts.get("error", 0)
warnings = counts.get("warning", 0)
if errors == 0 and warnings == 0:
hint = "✅ ERC clean. Safe to open kicad_open_schematic or move on to PCB layout."
elif errors == 0:
hint = (f"⚠️ {warnings} ERC warning(s), 0 errors. Common warnings: pin-type mismatch on connections, "
"missing power flags. Inspect violations[].description and decide. Schematic is functionally OK.")
else:
types = sorted({v["type"] for v in violations if v["severity"] == "error"})[:5]
types_str = ", ".join(types) if types else "various"
hint = (f"❌ {errors} ERC error(s) + {warnings} warning(s). Top error types: {types_str}. "
"Common causes: unconnected pins (add NoConnect flag), duplicate references (run Annotate Schematic), "
"hierarchical-label mismatches, no-driver on a net (e.g. an input pin with no source). "
"Fix in eeschema via kicad_open_schematic, then re-run kicad_lint_schematic to confirm.")
return {
"success": True,
"output": f"Lint complete: {errors} error(s), {warnings} warning(s), {total} total",
"data": {
"violations": violations,
"summary": {
"total": total,
"errors": errors,
"warnings": warnings,
"info": counts.get("info", 0),
},
"fileFormat": {
"expected": "kicad_sch",
"fileVersion": sniff.get("fileVersion"),
},
"source_file": str(sch_path),
},
"_hint": hint,
}
# ── handle_lint_library ─────────────────────────────────────────────
# Magic patterns for library files
_LIB_MAGIC = {
"kicad_sym": ("(kicad_symbol_lib",),
"kicad_mod": ("(footprint", "(module",), # KiCad ≤6 used "module"
}
def _parse_kicad_sym(path: Path) -> dict:
"""Parse a .kicad_sym file. Returns:
{symbolCount, symbols:[{name, hasPins, unitCount?, ...}],
fileVersion, generator, _warnings:[...]}.
Uses a regex over the file (s-expr parser would be cleaner but
KiCad's symbol files have predictable top-level structure that's
easy to grep — same approach kicad_detect.py uses for adomLibrary)."""
import re
out: dict = {
"symbolCount": 0,
"symbols": [],
"fileVersion": None,
"generator": None,
"_warnings": [],
}
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError as e:
out["_warnings"].append(f"read failed: {e}")
return out
# File-format version
m = re.search(r"\(version\s+(\d+)\)", text[:500])
if m:
out["fileVersion"] = int(m.group(1))
m = re.search(r"\(generator\s+(?:\"([^\"]+)\"|(\S+))\)", text[:500])
if m:
out["generator"] = m.group(1) or m.group(2)
# Top-level symbols: `(symbol "Name"` at the start of a line, NOT
# nested unit variants (those have names like `R_0_0` or `R_1_1`).
# The top-level `(symbol "Name"` lines have NO `_<digit>_<digit>` suffix.
for sym_match in re.finditer(r'^\s*\(symbol\s+"([^"]+)"', text, re.MULTILINE):
name = sym_match.group(1)
# Skip nested unit variants like "R_0_0", "R_1_1"
if re.search(r"_\d+_\d+$", name):
continue
# Snapshot a small window around this symbol to inspect pin count
# and units. Find the matching closing paren — quick heuristic:
# count up to ~5000 chars or until paren-balance reaches 0.
start = sym_match.start()
window = text[start:start + 5000]
# Count pins (rough): `(pin <type> <shape>` lines belonging to
# this symbol. May over-count if window slices into the next
# symbol; for inventory purposes this is good enough.
pin_count = len(re.findall(r"\(pin\s+\w+", window))
# Unit count: look for `_<N>_<M>` subsymbol declarations
units = set(re.findall(rf'\(symbol\s+"{re.escape(name)}_(\d+)_\d+"', text))
out["symbols"].append({
"name": name,
"pinCount": pin_count,
"unitCount": max(1, len(units)),
})
out["symbolCount"] = len(out["symbols"])
return out
def _parse_kicad_mod(path: Path) -> dict:
"""Parse a .kicad_mod file. Returns:
{name, padCount, layers, fileVersion, generator, _warnings:[...]}.
"""
import re
out: dict = {
"name": None,
"padCount": 0,
"layers": [],
"fileVersion": None,
"generator": None,
"_warnings": [],
}
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError as e:
out["_warnings"].append(f"read failed: {e}")
return out
# Name: `(footprint "Name"` (KiCad 7+) or `(module Name` (KiCad ≤6)
m = re.search(r'\(footprint\s+"([^"]+)"', text[:500])
if m:
out["name"] = m.group(1)
else:
m = re.search(r"\(module\s+(\S+)", text[:500])
if m:
out["name"] = m.group(1)
# Version + generator
m = re.search(r"\(version\s+(\d+)\)", text[:500])
if m:
out["fileVersion"] = int(m.group(1))
m = re.search(r"\(generator\s+(?:\"([^\"]+)\"|(\S+))\)", text[:500])
if m:
out["generator"] = m.group(1) or m.group(2)
# Pad count
out["padCount"] = len(re.findall(r"\(pad\s", text))
# Top-layers used by pads / drawings (rough)
layer_strs = set(re.findall(r'\(layer\s+(?:"([^"]+)"|(\S+))\)', text))
out["layers"] = sorted({a or b for a, b in layer_strs})[:10]
return out
def handle_lint_library(kicad_info: dict, args: dict) -> dict:
"""Validate a KiCad library file or directory. v1.7.13+.
Inputs accepted:
- Single `.kicad_sym` file → returns symbol inventory + file-version
- Single `.kicad_mod` file → returns footprint metadata + version
- `.pretty` directory → walks .kicad_mod files inside
- Directory containing one or more `.kicad_sym` files → walks them
Does NOT call kicad-cli (no --dry-run is available for sym/fp upgrade,
and the upgrade commands actually rewrite files which we don't want
during lint). Instead uses lightweight regex parsing — same approach
as kicad_detect.py's adomLibrary inventory code.
Returns a single AI-friendly structured response with a tiered `_hint`
that tells the calling AI whether to proceed with install or fix the
library first.
"""
file_path = args.get("libraryPath", "") or args.get("filePath", "")
if not file_path:
return {
"success": False,
"error": "libraryPath is required",
"_hint": "Pass {\"libraryPath\":\"<absolute path to .kicad_sym / .kicad_mod / .pretty dir>\"}.",
}
path = Path(file_path)
if not path.exists():
return {
"success": False,
"error": f"path not found: {file_path}",
"_hint": "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker first.",
}
# Discover what kind of input this is
files_found: dict = {"kicad_sym": [], "kicad_mod": []}
kind = None
if path.is_file():
if path.suffix.lower() == ".kicad_sym":
files_found["kicad_sym"].append(path)
kind = "symbol_lib_file"
elif path.suffix.lower() == ".kicad_mod":
files_found["kicad_mod"].append(path)
kind = "footprint_file"
else:
return {
"success": False,
"error": f"unsupported file extension: {path.suffix}",
"_hint": "Pass a .kicad_sym, .kicad_mod, or a directory containing them (.pretty for footprint libraries).",
}
elif path.is_dir():
if path.suffix.lower() == ".pretty" or path.name.endswith(".pretty"):
kind = "footprint_lib_dir"
files_found["kicad_mod"] = sorted(path.glob("*.kicad_mod"))
else:
kind = "mixed_dir"
files_found["kicad_sym"] = sorted(path.glob("*.kicad_sym"))
files_found["kicad_mod"] = sorted(path.glob("*.kicad_mod"))
if not files_found["kicad_sym"] and not files_found["kicad_mod"]:
return {
"success": False,
"error": f"directory contains no .kicad_sym or .kicad_mod files: {path}",
"_hint": "Symbol libraries are single .kicad_sym files. Footprint libraries are .pretty/ directories containing .kicad_mod files.",
}
# Parse each file
parsed_sym = []
for f in files_found["kicad_sym"]:
# Magic check
try:
with open(f, "rb") as fh:
head = fh.read(200).decode("utf-8", errors="replace").lstrip()
if not any(head.startswith(t) for t in _LIB_MAGIC["kicad_sym"]):
parsed_sym.append({"path": str(f), "_error": "not a kicad_symbol_lib (bad magic)"})
continue
except OSError as e:
parsed_sym.append({"path": str(f), "_error": f"read failed: {e}"})
continue
info = _parse_kicad_sym(f)
info["path"] = str(f)
parsed_sym.append(info)
parsed_mod = []
for f in files_found["kicad_mod"]:
try:
with open(f, "rb") as fh:
head = fh.read(200).decode("utf-8", errors="replace").lstrip()
if not any(head.startswith(t) for t in _LIB_MAGIC["kicad_mod"]):
parsed_mod.append({"path": str(f), "_error": "not a kicad_mod (bad magic)"})
continue
except OSError as e:
parsed_mod.append({"path": str(f), "_error": f"read failed: {e}"})
continue
info = _parse_kicad_mod(f)
info["path"] = str(f)
parsed_mod.append(info)
# Summary numbers
symbol_count = sum(s.get("symbolCount", 0) for s in parsed_sym if "_error" not in s)
footprint_count = sum(1 for m in parsed_mod if "_error" not in m)
sym_file_errors = [s for s in parsed_sym if "_error" in s]
mod_file_errors = [m for m in parsed_mod if "_error" in m]
# All file versions seen
versions = set()
for s in parsed_sym + parsed_mod:
v = s.get("fileVersion")
if v:
versions.add(v)
# Build tiered hint
if sym_file_errors or mod_file_errors:
hint = (
f"❌ {len(sym_file_errors)} symbol file(s) + {len(mod_file_errors)} footprint file(s) "
f"failed to parse. Inspect _errors[] for specifics. Common causes: file truncated mid-write, "
f"wrong file type renamed with .kicad_sym extension, file from a much newer KiCad than installed. "
f"DO NOT install via kicad_install_library / kicad_install_symbol / kicad_install_footprint — "
f"the install will land broken files."
)
else:
# Detect outdated format. KiCad 10 uses YYYYMMDD versions like
# 20240108. Anything below 20200101 is KiCad 5-era, definitely
# outdated. Versions in the 2023xxxx range are KiCad 7-era and
# may still load but warrant a `kicad_format_upgrade` first.
outdated = sorted([v for v in versions if v < 20231101])
if outdated:
hint = (
f"⚠️ {symbol_count} symbol(s) + {footprint_count} footprint(s) parsed cleanly, "
f"BUT file-format version(s) {outdated} are pre-KiCad-7. KiCad 10 can read these but "
f"will silently upgrade them on first save. To migrate proactively, call "
f"kicad_format_upgrade (or just run `kicad-cli sym/fp upgrade` manually). "
f"OK to proceed with install but expect a one-time format change."
)
else:
hint = (
f"✅ {symbol_count} symbol(s) + {footprint_count} footprint(s) parsed cleanly, "
f"format version(s) {sorted(versions) if versions else 'unspecified'}. Safe to install via "
f"kicad_install_library / kicad_install_symbol / kicad_install_footprint."
)
return {
"success": True,
"output": f"Lint: {symbol_count} symbol(s), {footprint_count} footprint(s), "
f"{len(sym_file_errors) + len(mod_file_errors)} file error(s)",
"data": {
"kind": kind,
"source_path": str(path),
"summary": {
"symbolCount": symbol_count,
"footprintCount": footprint_count,
"symFileErrorCount": len(sym_file_errors),
"modFileErrorCount": len(mod_file_errors),
"fileFormatVersions": sorted(versions),
},
"symbols": parsed_sym,
"footprints": parsed_mod,
},
"_hint": hint,
}
# ── handle_format_upgrade ───────────────────────────────────────────
def handle_format_upgrade(kicad_info: dict, args: dict) -> dict:
"""Wraps `kicad-cli {pcb,sch,sym,fp} upgrade` to migrate older
KiCad files to the current format. v1.7.13+.
Args:
filePath: required — file or directory to upgrade
kind: optional — "pcb" | "sch" | "sym" | "fp". Auto-detected
from extension or directory contents if omitted.
force: optional bool (default false) — passes `--force` to
kicad-cli, which resaves the file even if it's already
at the current version. Useful for round-tripping any
quirks the importer fixed.
WARNING: This MUTATES the file in place (kicad-cli has no --dry-run).
Callers should expect the file to be rewritten. If you want
inspection without write, call kicad_lint_library first.
"""
if not kicad_info.get("installed"):
return {"success": False, "error": "KiCad not installed",
"_hint": "Ask the user to install KiCad."}
file_path = args.get("filePath", "")
if not file_path:
return {"success": False, "error": "filePath is required",
"_hint": "Pass {\"filePath\":\"<path>\", \"kind\":\"pcb|sch|sym|fp\"}."}
path = Path(file_path)
if not path.exists():
return {"success": False, "error": f"path not found: {file_path}",
"_hint": "Verify the path; use push_file from Docker if needed."}
# Auto-detect kind if not given
kind = (args.get("kind") or "").lower()
if not kind:
if path.is_file():
if path.suffix == ".kicad_pcb":
kind = "pcb"
elif path.suffix == ".kicad_sch":
kind = "sch"
elif path.suffix == ".kicad_sym":
kind = "sym"
elif path.suffix == ".kicad_mod":
kind = "fp"
elif path.is_dir():
# .pretty dir = footprint lib; dir with .kicad_sym = sym lib
if path.suffix.lower() == ".pretty" or any(path.glob("*.kicad_mod")):
kind = "fp"
elif any(path.glob("*.kicad_sym")):
kind = "sym"
if kind not in ("pcb", "sch", "sym", "fp"):
return {
"success": False,
"error": f"could not auto-detect kind for {file_path}",
"_hint": "Pass kind explicitly: \"pcb\" / \"sch\" / \"sym\" / \"fp\".",
}
cli, err = _cli_exe_or_error(kicad_info)
if err:
return err
cli_args = [kind, "upgrade"]
if args.get("force"):
cli_args.append("--force")
cli_args.append(str(path))
try:
proc = subprocess.run(
[cli] + cli_args,
capture_output=True,
text=True,
timeout=120,
)
except subprocess.TimeoutExpired:
return {"success": False, "error": "kicad-cli upgrade timed out after 120s",
"_hint": "Likely a huge directory of footprints. Re-run with a single file at a time."}
except Exception as e: # pylint: disable=broad-except
return {"success": False, "error": f"{type(e).__name__}: {e}"}
success = proc.returncode == 0
return {
"success": success,
"exitCode": proc.returncode,
"kind": kind,
"stdout": proc.stdout,
"stderr": proc.stderr,
"source_path": str(path),
"_hint": (
f"✅ kicad-cli {kind} upgrade completed. File(s) rewritten to current KiCad format. "
f"Any open KiCad GUI windows with this file loaded should be closed + reopened — they "
f"have the OLD in-memory state. Re-run kicad_lint_{kind if kind in ('board','schematic') else 'library'} "
f"to confirm the post-upgrade file is clean."
if success else
f"❌ kicad-cli {kind} upgrade failed with exitCode={proc.returncode}. "
f"stderr likely names the offending file or syntax issue. If it's a 'file format too new' "
f"error, the user's KiCad install is OLDER than the source file — surface to user. "
f"If 'file already up to date', pass force:true to resave anyway."
),
}