Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
"""Robust resolution of the ``kicad-cli`` executable.
KiCad's Windows installer does **not** add ``KiCad\\<ver>\\bin`` to ``PATH``, so a
bare ``shutil.which("kicad-cli")`` fails on an otherwise-normal install even though
``kicad-cli.exe`` is sitting right there in the install's ``bin`` directory. Every MCP
tool that shells out to ``kicad-cli`` (netlist/gerber/drill/pos/pdf/svg/... exports,
ERC/DRC) then fails with a misleading "not found in PATH" error.
This module centralises resolution so every cli-backed tool uses the same logic:
1. Explicit override: ``$KICAD_CLI`` / ``$KICAD_CLI_PATH`` (if set). An explicit
override is authoritative — if it is set but does not point at a real file we
refuse to silently fall back to a different binary.
2. Adjacent to the running interpreter: ``Path(sys.executable).parent / kicad-cli``.
The MCP backend runs under KiCad's bundled Python (``KiCad\\<ver>\\bin\\python``,
which is how it imports ``pcbnew``); ``kicad-cli`` lives in that same ``bin``
directory, making this the most reliable source for this server.
3. ``PATH`` lookup.
4. Known per-OS install locations (newest KiCad version first).
The resolved path is cached. The environment override is re-checked on every call so a
test (or a user) can change it without a stale cache masking the new value.
"""
import logging
import os
import platform
import shutil
import sys
from pathlib import Path
from typing import List, Optional
logger = logging.getLogger("kicad_interface")
_ENV_VARS = ("KICAD_CLI", "KICAD_CLI_PATH")
_cached_cli: Optional[str] = None
def _cli_name() -> str:
"""Executable basename for the current OS."""
return "kicad-cli.exe" if platform.system() == "Windows" else "kicad-cli"
def _override_value() -> Optional[str]:
"""Return the first non-empty KICAD_CLI / KICAD_CLI_PATH value, or None."""
for env in _ENV_VARS:
val = os.environ.get(env)
if val:
return val
return None
def _candidate_paths() -> List[Path]:
"""Well-known per-OS install locations for kicad-cli, newest version first."""
name = _cli_name()
system = platform.system()
candidates: List[Path] = []
if system == "Windows":
# Shared root discovery (registry + Program Files + custom C:\KiCad roots,
# newest first) so cli/symbol/footprint lookups can't drift apart (#286).
from utils.kicad_roots import windows_kicad_roots
candidates.extend(root / "bin" / name for root in windows_kicad_roots())
elif system == "Darwin":
candidates.extend(
[
Path("/Applications/KiCad/KiCad.app/Contents/MacOS") / name,
Path("/usr/local/bin") / name,
Path("/opt/homebrew/bin") / name,
]
)
else: # Linux / *nix
candidates.extend(
[
Path("/usr/bin") / name,
Path("/usr/local/bin") / name,
Path("/app/bin") / name, # Flatpak sandbox
Path("/var/lib/flatpak/app/org.kicad.KiCad/current/active/files/bin") / name,
Path.home() / ".local" / "bin" / name,
]
)
return candidates
def resolve_kicad_cli(force: bool = False) -> Optional[str]:
"""Return the absolute path to ``kicad-cli``, or ``None`` if it cannot be found.
The environment override is honoured first and re-checked on every call. An override
that is set but invalid returns ``None`` (we do not fall back to auto-detection — the
user asked for a specific binary). Auto-detected results are cached; pass
``force=True`` to bypass the cache.
"""
global _cached_cli
# 1. Explicit override — authoritative, always checked first (never cached so a
# changed/cleared env var takes effect immediately).
override = _override_value()
if override is not None:
path = Path(override)
if path.is_file():
return str(path)
logger.warning(
"KICAD_CLI override %r does not point at a file; refusing to fall back to "
"another kicad-cli. Fix or unset the variable.",
override,
)
return None
if not force and _cached_cli is not None:
return _cached_cli
name = _cli_name()
# 2. Adjacent to the running interpreter (KiCad's bundled python bin/).
try:
adjacent = Path(sys.executable).parent / name
if adjacent.is_file():
_cached_cli = str(adjacent)
return _cached_cli
except (OSError, ValueError):
pass
# 3. PATH.
on_path = shutil.which(name) or shutil.which("kicad-cli")
if on_path:
_cached_cli = on_path
return _cached_cli
# 4. Known install locations.
for candidate in _candidate_paths():
if candidate.is_file():
_cached_cli = str(candidate)
return _cached_cli
return None
def kicad_cli_not_found_message() -> str:
"""Build a clear, actionable error message listing every location tried.
Replaces the old bare ``"kicad-cli not found in PATH"`` so users can see exactly
where the resolver looked and how to point it at their install.
"""
name = _cli_name()
lines: List[str] = ["Could not locate kicad-cli. Tried, in order:"]
override = _override_value()
if override is not None:
lines.append(f" - ${{KICAD_CLI}} = {override} (set, but not a file)")
else:
lines.append(" - $KICAD_CLI / $KICAD_CLI_PATH (not set)")
try:
lines.append(f" - next to the Python interpreter: {Path(sys.executable).parent / name}")
except (OSError, ValueError):
pass
lines.append(f" - PATH lookup of {name}")
for candidate in _candidate_paths():
lines.append(f" - {candidate}")
lines.append(
"Set the KICAD_CLI environment variable to the full path of kicad-cli "
"(e.g. on Windows: C:\\Program Files\\KiCad\\10.0\\bin\\kicad-cli.exe) and retry."
)
return "\n".join(lines)
def reset_cache() -> None:
"""Clear the cached resolution (primarily for tests)."""
global _cached_cli
_cached_cli = None
670d2a9c4This file is pinned to the selected release. Download the original to open it in your design tool.