From 2fb54802b6a52c906c6ec293c3fdfbd11ee886ff Mon Sep 17 00:00:00 2001 From: Gavin Colonese Date: Fri, 12 Jun 2026 10:51:29 -0400 Subject: [PATCH 01/22] chore: add fitz to mypy ignore_missing_imports overrides PyMuPDF (`fitz`) has no type stubs, so mypy's import-not-found fails on any edit to files importing it (commands/board/view.py, kicad_interface.py). Same treatment as the existing pcbnew/kipy/PIL entries. Needed so the export-tool commits on this branch pass the mypy pre-commit hook independently. Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index a353a50..f8ed51b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ module = [ "cairosvg", "sexpdata", "skip", + "fitz", "kipy", "kipy.*", "schematic", From 82e5de7aa94696ac768276db1f5f9d0390bbbd08 Mon Sep 17 00:00:00 2001 From: Gavin Colonese Date: Fri, 12 Jun 2026 10:53:19 -0400 Subject: [PATCH 02/22] feat: add export_gerbers tool (kicad-cli, full Plot option set) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New MCP tool `export_gerbers` wrapping `kicad-cli pcb export gerbers`, exposing the complete Plot-dialog option surface that the existing SWIG-based export_gerber lacks: layer + common-layer lists, X2 on/off, netlist attributes, soldermask subtraction, aperture macros, the three DNP fab-layer modes, refdes/value exclusion, border+title, drill-file origin, coordinate precision (5/6), Protel vs KiCad extensions, and --board-plot-params to reuse the board's stored plot settings. Implemented as a self-contained interface handler (_handle_export_gerbers) that resolves the board path (param or current board) and shells out to kicad-cli, mirroring the _handle_export_netlist pattern — no dependence on a live SWIG board, since the KiCad IPC API exposes no plot/export endpoints. Reads the saved .kicad_pcb on disk. Wiring: dispatch map + src/tools/export.ts registration + export category in registry.ts. Co-Authored-By: Claude Opus 4.8 (1M context) --- python/kicad_interface.py | 108 +++++++++++++++++++++++++++++++++++++- src/tools/export.ts | 70 ++++++++++++++++++++++++ src/tools/registry.ts | 1 + 3 files changed, 177 insertions(+), 2 deletions(-) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index eb15e26..0ecb4fd 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -325,9 +325,9 @@ try: from commands.project import ProjectCommands from commands.routing import RoutingCommands from commands.schematic import SchematicManager - from commands.schematic_hierarchy import SchematicHierarchyCommands - from commands.schematic_field_layout import SchematicFieldLayoutCommands from commands.schematic_batch import SchematicBatchCommands + from commands.schematic_field_layout import SchematicFieldLayoutCommands + from commands.schematic_hierarchy import SchematicHierarchyCommands from commands.symbol_creator import SymbolCreator from commands.symbol_pins import SymbolPinCommands @@ -587,6 +587,7 @@ class KiCADInterface: "get_net_at_point": self._handle_get_net_at_point, "run_erc": self._handle_run_erc, "export_netlist": self._handle_export_netlist, + "export_gerbers": self._handle_export_gerbers, "generate_netlist": self._handle_generate_netlist, "sync_schematic_to_board": self._handle_sync_schematic_to_board, "list_schematic_libraries": self._handle_list_schematic_libraries, @@ -4496,6 +4497,109 @@ class KiCADInterface: logger.error(f"Error exporting netlist: {e}") return {"success": False, "message": str(e)} + def _handle_export_gerbers(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Plot multiple Gerbers for a PCB via kicad-cli (`pcb export gerbers`). + + Exposes the full Plot-dialog option set. Reads the board from disk, so it + reflects the last *saved* state of the .kicad_pcb. Pass ``boardPath`` to + target a specific file; otherwise the current board path is used. + """ + import subprocess + + logger.info("Exporting Gerbers via kicad-cli") + try: + board_path = params.get("boardPath") or self._current_board_path() + output_dir = params.get("outputDir") + + if not board_path: + return { + "success": False, + "message": "boardPath is required (no current board could be resolved)", + } + if not os.path.exists(board_path): + return {"success": False, "message": f"Board not found: {board_path}"} + if not output_dir: + return {"success": False, "message": "outputDir is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_dir = os.path.abspath(os.path.expanduser(output_dir)) + os.makedirs(output_dir, exist_ok=True) + + cmd = [kicad_cli, "pcb", "export", "gerbers", "--output", output_dir] + + # Layer selection (accept list or comma string) + layers = params.get("layers") + if layers: + cmd += ["--layers", ",".join(layers) if isinstance(layers, list) else str(layers)] + common_layers = params.get("commonLayers") + if common_layers: + cmd += [ + "--common-layers", + ( + ",".join(common_layers) + if isinstance(common_layers, list) + else str(common_layers) + ), + ] + + if params.get("drawingSheet"): + cmd += ["--drawing-sheet", params["drawingSheet"]] + for kv in params.get("defineVar", []) or []: + cmd += ["--define-var", kv] + + # Boolean flags (omit to leave at kicad-cli default) + flag_map = { + "excludeRefdes": "--exclude-refdes", + "excludeValue": "--exclude-value", + "includeBorderTitle": "--include-border-title", + "sketchPadsOnFabLayers": "--sketch-pads-on-fab-layers", + "hideDnpFootprintsOnFabLayers": "--hide-DNP-footprints-on-fab-layers", + "sketchDnpFootprintsOnFabLayers": "--sketch-DNP-footprints-on-fab-layers", + "crossoutDnpFootprintsOnFabLayers": "--crossout-DNP-footprints-on-fab-layers", + "noX2": "--no-x2", + "noNetlist": "--no-netlist", + "subtractSoldermask": "--subtract-soldermask", + "disableApertureMacros": "--disable-aperture-macros", + "useDrillFileOrigin": "--use-drill-file-origin", + "noProtelExt": "--no-protel-ext", + "boardPlotParams": "--board-plot-params", + } + for key, flag in flag_map.items(): + if params.get(key): + cmd.append(flag) + + precision = params.get("precision") + if precision is not None: + cmd += ["--precision", str(precision)] + + cmd.append(board_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + + files = sorted( + f for f in os.listdir(output_dir) if os.path.isfile(os.path.join(output_dir, f)) + ) + return {"success": True, "outputDir": output_dir, "files": files} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting Gerbers: {e}") + return {"success": False, "message": str(e)} + def _handle_generate_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]: """Generate netlist from schematic and return structured JSON. diff --git a/src/tools/export.ts b/src/tools/export.ts index 41d3f90..a03588d 100644 --- a/src/tools/export.ts +++ b/src/tools/export.ts @@ -323,5 +323,75 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF }, ); + // ------------------------------------------------------ + // Export Gerbers Tool (kicad-cli, full option set) + // ------------------------------------------------------ + server.tool( + "export_gerbers", + "Plot Gerber files for a PCB via kicad-cli, exposing the full Plot-dialog option set (X2, netlist attributes, DNP handling, soldermask subtraction, precision, drill-file origin, stored board plot settings, etc). Reads the board from disk, so it reflects the last SAVED state of the .kicad_pcb.", + { + outputDir: z.string().describe("Output directory for the Gerber files"), + boardPath: z.string().optional().describe("Path to the .kicad_pcb (default: current board)"), + layers: z + .array(z.string()) + .optional() + .describe("Layers to plot, untranslated names e.g. ['F.Cu','B.Cu','Edge.Cuts']"), + commonLayers: z + .array(z.string()) + .optional() + .describe("Layers to include on every plot (e.g. ['Edge.Cuts'])"), + drawingSheet: z.string().optional().describe("Path to a drawing sheet override"), + defineVar: z + .array(z.string()) + .optional() + .describe("Project variable overrides as 'KEY=VALUE' strings"), + excludeRefdes: z.boolean().optional().describe("Exclude reference designator text"), + excludeValue: z.boolean().optional().describe("Exclude value text"), + includeBorderTitle: z.boolean().optional().describe("Include border and title block"), + sketchPadsOnFabLayers: z + .boolean() + .optional() + .describe("Draw pad outlines and numbers on fab layers"), + hideDnpFootprintsOnFabLayers: z + .boolean() + .optional() + .describe("Don't plot DNP footprint text/graphics on fab layers"), + sketchDnpFootprintsOnFabLayers: z + .boolean() + .optional() + .describe("Plot DNP footprints in sketch mode on fab layers"), + crossoutDnpFootprintsOnFabLayers: z + .boolean() + .optional() + .describe("Plot an 'X' over DNP footprint courtyards and strike out their refdes"), + noX2: z.boolean().optional().describe("Do not use the extended X2 Gerber format"), + noNetlist: z.boolean().optional().describe("Do not generate netlist attributes"), + subtractSoldermask: z.boolean().optional().describe("Subtract soldermask from silkscreen"), + disableApertureMacros: z.boolean().optional().describe("Disable aperture macros"), + useDrillFileOrigin: z.boolean().optional().describe("Use the drill/place file origin"), + noProtelExt: z + .boolean() + .optional() + .describe("Use KiCad Gerber file extensions instead of Protel"), + boardPlotParams: z + .boolean() + .optional() + .describe("Use the Gerber plot settings already stored in the board file"), + precision: z.number().optional().describe("Gerber coordinate precision: 5 or 6 (default 6)"), + }, + async (args) => { + logger.debug(`Exporting Gerbers to: ${args.outputDir}`); + const result = await callKicadScript("export_gerbers", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + logger.info("Export tools registered"); } diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 6f1f569..482ede6 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -58,6 +58,7 @@ export const toolCategories: ToolCategory[] = [ description: "File export for fabrication and documentation: Gerber, PDF, BOM, 3D models", tools: [ "export_gerber", + "export_gerbers", "export_pdf", "export_svg", "export_3d", From b37ca6a225e9dbe4f954990077c53f4fdfb9707e Mon Sep 17 00:00:00 2001 From: Gavin Colonese Date: Fri, 12 Jun 2026 10:53:45 -0400 Subject: [PATCH 03/22] feat: add export_drill tool (kicad-cli, full drill option set) New MCP tool `export_drill` wrapping `kicad-cli pcb export drill`: format (excellon/gerber), drill origin, Excellon zero-suppression and oval formats, units, mirror-Y, minimal header, separate PTH/NPTH files, drill map generation + map format, and Gerber precision. Self-contained interface handler reading the saved .kicad_pcb. Co-Authored-By: Claude Opus 4.8 (1M context) --- python/kicad_interface.py | 85 +++++++++++++++++++++++++++++++++++++++ src/tools/export.ts | 56 ++++++++++++++++++++++++++ src/tools/registry.ts | 1 + 3 files changed, 142 insertions(+) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 0ecb4fd..a998cfe 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -588,6 +588,7 @@ class KiCADInterface: "run_erc": self._handle_run_erc, "export_netlist": self._handle_export_netlist, "export_gerbers": self._handle_export_gerbers, + "export_drill": self._handle_export_drill, "generate_netlist": self._handle_generate_netlist, "sync_schematic_to_board": self._handle_sync_schematic_to_board, "list_schematic_libraries": self._handle_list_schematic_libraries, @@ -4600,6 +4601,90 @@ class KiCADInterface: logger.error(f"Error exporting Gerbers: {e}") return {"success": False, "message": str(e)} + def _handle_export_drill(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Generate drill files via kicad-cli (`pcb export drill`). + + Exposes the full Excellon/Gerber drill option set. Reads the saved + .kicad_pcb on disk. + """ + import subprocess + + logger.info("Exporting drill files via kicad-cli") + try: + board_path = params.get("boardPath") or self._current_board_path() + output_dir = params.get("outputDir") + + if not board_path: + return { + "success": False, + "message": "boardPath is required (no current board could be resolved)", + } + if not os.path.exists(board_path): + return {"success": False, "message": f"Board not found: {board_path}"} + if not output_dir: + return {"success": False, "message": "outputDir is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_dir = os.path.abspath(os.path.expanduser(output_dir)) + # kicad-cli drill requires the output dir path to end with a separator + os.makedirs(output_dir, exist_ok=True) + + cmd = [kicad_cli, "pcb", "export", "drill", "--output", output_dir + os.sep] + + # Valued options (omit to use kicad-cli defaults) + value_map = { + "format": "--format", + "drillOrigin": "--drill-origin", + "excellonZerosFormat": "--excellon-zeros-format", + "excellonOvalFormat": "--excellon-oval-format", + "excellonUnits": "--excellon-units", + "mapFormat": "--map-format", + "gerberPrecision": "--gerber-precision", + } + for key, flag in value_map.items(): + val = params.get(key) + if val is not None: + cmd += [flag, str(val)] + + # Boolean flags + flag_map = { + "excellonMirrorY": "--excellon-mirror-y", + "excellonMinHeader": "--excellon-min-header", + "excellonSeparateTh": "--excellon-separate-th", + "generateMap": "--generate-map", + } + for key, flag in flag_map.items(): + if params.get(key): + cmd.append(flag) + + cmd.append(board_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + + files = sorted( + f for f in os.listdir(output_dir) if os.path.isfile(os.path.join(output_dir, f)) + ) + return {"success": True, "outputDir": output_dir, "files": files} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 120 seconds"} + except Exception as e: + logger.error(f"Error exporting drill files: {e}") + return {"success": False, "message": str(e)} + def _handle_generate_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]: """Generate netlist from schematic and return structured JSON. diff --git a/src/tools/export.ts b/src/tools/export.ts index a03588d..39e9573 100644 --- a/src/tools/export.ts +++ b/src/tools/export.ts @@ -393,5 +393,61 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF }, ); + // ------------------------------------------------------ + // Export Drill Files Tool (kicad-cli) + // ------------------------------------------------------ + server.tool( + "export_drill", + "Generate drill files for a PCB via kicad-cli, exposing the full Excellon/Gerber drill option set (format, drill origin, zero suppression, oval format, units, mirror-Y, minimal header, separate PTH/NPTH files, drill map + map format). Reads the last SAVED state of the .kicad_pcb.", + { + outputDir: z.string().describe("Output directory for the drill files"), + boardPath: z.string().optional().describe("Path to the .kicad_pcb (default: current board)"), + format: z + .enum(["excellon", "gerber"]) + .optional() + .describe("Drill file format (default excellon)"), + drillOrigin: z + .enum(["absolute", "plot"]) + .optional() + .describe("Drill coordinate origin (default absolute)"), + excellonZerosFormat: z + .enum(["decimal", "suppressleading", "suppresstrailing", "keep"]) + .optional() + .describe("Excellon zero-suppression format (default decimal)"), + excellonOvalFormat: z + .enum(["route", "alternate"]) + .optional() + .describe("Excellon oval hole format (default alternate)"), + excellonUnits: z.enum(["in", "mm"]).optional().describe("Excellon output units (default mm)"), + excellonMirrorY: z.boolean().optional().describe("Mirror the Y axis (Excellon)"), + excellonMinHeader: z.boolean().optional().describe("Use a minimal Excellon header"), + excellonSeparateTh: z + .boolean() + .optional() + .describe("Generate independent files for NPTH and PTH holes"), + generateMap: z.boolean().optional().describe("Generate a drill map / summary file"), + mapFormat: z + .enum(["pdf", "gerberx2", "ps", "dxf", "svg"]) + .optional() + .describe("Drill map format when generateMap is set (default pdf)"), + gerberPrecision: z + .number() + .optional() + .describe("Gerber coordinate precision (5 or 6) when format=gerber"), + }, + async (args) => { + logger.debug(`Exporting drill files to: ${args.outputDir}`); + const result = await callKicadScript("export_drill", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + logger.info("Export tools registered"); } diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 482ede6..6e4e4da 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -59,6 +59,7 @@ export const toolCategories: ToolCategory[] = [ tools: [ "export_gerber", "export_gerbers", + "export_drill", "export_pdf", "export_svg", "export_3d", From 88226d567bbd22fae28b3bf02760ebca933fe01b Mon Sep 17 00:00:00 2001 From: Gavin Colonese Date: Fri, 12 Jun 2026 10:54:11 -0400 Subject: [PATCH 04/22] feat: add export_ipc2581 tool (kicad-cli, with BOM column mapping) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New MCP tool `export_ipc2581` wrapping `kicad-cli pcb export ipc2581`: precision, compression, standard version, units, and the five --bom-col-* field mappings (internal id, mfg P/N, mfg, distributor P/N, distributor) that control the BOM part data embedded in the IPC-2581 file — directly useful for MES/assembly imports that need a real internal P/N rather than the description. Self-contained interface handler reading the saved .kicad_pcb. Co-Authored-By: Claude Opus 4.8 (1M context) --- python/kicad_interface.py | 78 +++++++++++++++++++++++++++++++++++++++ src/tools/export.ts | 50 +++++++++++++++++++++++++ src/tools/registry.ts | 1 + 3 files changed, 129 insertions(+) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index a998cfe..fdc680d 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -589,6 +589,7 @@ class KiCADInterface: "export_netlist": self._handle_export_netlist, "export_gerbers": self._handle_export_gerbers, "export_drill": self._handle_export_drill, + "export_ipc2581": self._handle_export_ipc2581, "generate_netlist": self._handle_generate_netlist, "sync_schematic_to_board": self._handle_sync_schematic_to_board, "list_schematic_libraries": self._handle_list_schematic_libraries, @@ -4685,6 +4686,83 @@ class KiCADInterface: logger.error(f"Error exporting drill files: {e}") return {"success": False, "message": str(e)} + def _handle_export_ipc2581(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Export the PCB in IPC-2581 format via kicad-cli (`pcb export ipc2581`). + + IPC-2581 is a single-file MES/CAD interchange format carrying placement, + nets, and BOM part data inline. The bom-col-* params map schematic fields + to the BOM columns embedded in the file. Reads the saved .kicad_pcb. + """ + import subprocess + + logger.info("Exporting IPC-2581 via kicad-cli") + try: + board_path = params.get("boardPath") or self._current_board_path() + output_path = params.get("outputPath") + + if not board_path: + return { + "success": False, + "message": "boardPath is required (no current board could be resolved)", + } + if not os.path.exists(board_path): + return {"success": False, "message": f"Board not found: {board_path}"} + if not output_path: + return {"success": False, "message": "outputPath is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_path = os.path.abspath(os.path.expanduser(output_path)) + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + cmd = [kicad_cli, "pcb", "export", "ipc2581", "--output", output_path] + + if params.get("drawingSheet"): + cmd += ["--drawing-sheet", params["drawingSheet"]] + for kv in params.get("defineVar", []) or []: + cmd += ["--define-var", kv] + + value_map = { + "precision": "--precision", + "version": "--version", + "units": "--units", + "bomColIntId": "--bom-col-int-id", + "bomColMfgPn": "--bom-col-mfg-pn", + "bomColMfg": "--bom-col-mfg", + "bomColDistPn": "--bom-col-dist-pn", + "bomColDist": "--bom-col-dist", + } + for key, flag in value_map.items(): + val = params.get(key) + if val is not None and val != "": + cmd += [flag, str(val)] + + if params.get("compress"): + cmd.append("--compress") + + cmd.append(board_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + return {"success": True, "outputPath": output_path} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting IPC-2581: {e}") + return {"success": False, "message": str(e)} + def _handle_generate_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]: """Generate netlist from schematic and return structured JSON. diff --git a/src/tools/export.ts b/src/tools/export.ts index 39e9573..911d548 100644 --- a/src/tools/export.ts +++ b/src/tools/export.ts @@ -449,5 +449,55 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF }, ); + // ------------------------------------------------------ + // Export IPC-2581 Tool (kicad-cli) + // ------------------------------------------------------ + server.tool( + "export_ipc2581", + "Export the PCB in IPC-2581 format via kicad-cli. Single-file MES/CAD interchange carrying placement, nets and BOM part data inline. The bomCol* params map schematic fields to the embedded BOM columns (e.g. internal P/N, manufacturer P/N) — useful for assembly/MES imports. Reads the last SAVED state of the .kicad_pcb.", + { + outputPath: z.string().describe("Output .xml file path"), + boardPath: z.string().optional().describe("Path to the .kicad_pcb (default: current board)"), + drawingSheet: z.string().optional().describe("Path to a drawing sheet override"), + defineVar: z + .array(z.string()) + .optional() + .describe("Project variable overrides as 'KEY=VALUE' strings"), + precision: z.number().optional().describe("Coordinate precision (default 6)"), + compress: z.boolean().optional().describe("Compress the output"), + version: z.string().optional().describe("IPC-2581 standard version (default 'C')"), + units: z.enum(["mm", "in"]).optional().describe("Units (default mm)"), + bomColIntId: z + .string() + .optional() + .describe("Schematic field to use for the BOM Internal Id column"), + bomColMfgPn: z + .string() + .optional() + .describe("Schematic field to use for the BOM Manufacturer Part Number column"), + bomColMfg: z + .string() + .optional() + .describe("Schematic field to use for the BOM Manufacturer column"), + bomColDistPn: z + .string() + .optional() + .describe("Schematic field to use for the BOM Distributor Part Number column"), + bomColDist: z.string().optional().describe("Value to insert into the BOM Distributor column"), + }, + async (args) => { + logger.debug(`Exporting IPC-2581 to: ${args.outputPath}`); + const result = await callKicadScript("export_ipc2581", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + logger.info("Export tools registered"); } diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 6e4e4da..d5742c6 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -60,6 +60,7 @@ export const toolCategories: ToolCategory[] = [ "export_gerber", "export_gerbers", "export_drill", + "export_ipc2581", "export_pdf", "export_svg", "export_3d", From 0f07ccd593300501a02274cae5516ea670d181b3 Mon Sep 17 00:00:00 2001 From: Gavin Colonese Date: Fri, 12 Jun 2026 10:54:37 -0400 Subject: [PATCH 05/22] feat: add export_odb tool (kicad-cli, ODB++ archive) New MCP tool `export_odb` wrapping `kicad-cli pcb export odb`: precision, compression mode (zip/tgz/none), and units. Single ODB++ job archive for CAM/MES/assembly. Self-contained interface handler reading the saved .kicad_pcb. Co-Authored-By: Claude Opus 4.8 (1M context) --- python/kicad_interface.py | 69 +++++++++++++++++++++++++++++++++++++++ src/tools/export.ts | 35 ++++++++++++++++++++ src/tools/registry.ts | 1 + 3 files changed, 105 insertions(+) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index fdc680d..1ee6bd0 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -590,6 +590,7 @@ class KiCADInterface: "export_gerbers": self._handle_export_gerbers, "export_drill": self._handle_export_drill, "export_ipc2581": self._handle_export_ipc2581, + "export_odb": self._handle_export_odb, "generate_netlist": self._handle_generate_netlist, "sync_schematic_to_board": self._handle_sync_schematic_to_board, "list_schematic_libraries": self._handle_list_schematic_libraries, @@ -4763,6 +4764,74 @@ class KiCADInterface: logger.error(f"Error exporting IPC-2581: {e}") return {"success": False, "message": str(e)} + def _handle_export_odb(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Export the PCB in ODB++ format via kicad-cli (`pcb export odb`). + + ODB++ is a fab/assembly job archive. ``compression`` selects the output + container (zip / tgz / none). Reads the saved .kicad_pcb. + """ + import subprocess + + logger.info("Exporting ODB++ via kicad-cli") + try: + board_path = params.get("boardPath") or self._current_board_path() + output_path = params.get("outputPath") + + if not board_path: + return { + "success": False, + "message": "boardPath is required (no current board could be resolved)", + } + if not os.path.exists(board_path): + return {"success": False, "message": f"Board not found: {board_path}"} + if not output_path: + return {"success": False, "message": "outputPath is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_path = os.path.abspath(os.path.expanduser(output_path)) + parent = os.path.dirname(output_path) + if parent: + os.makedirs(parent, exist_ok=True) + + cmd = [kicad_cli, "pcb", "export", "odb", "--output", output_path] + + if params.get("drawingSheet"): + cmd += ["--drawing-sheet", params["drawingSheet"]] + for kv in params.get("defineVar", []) or []: + cmd += ["--define-var", kv] + for key, flag in { + "precision": "--precision", + "compression": "--compression", + "units": "--units", + }.items(): + val = params.get(key) + if val is not None and val != "": + cmd += [flag, str(val)] + + cmd.append(board_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + return {"success": True, "outputPath": output_path} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting ODB++: {e}") + return {"success": False, "message": str(e)} + def _handle_generate_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]: """Generate netlist from schematic and return structured JSON. diff --git a/src/tools/export.ts b/src/tools/export.ts index 911d548..8485e43 100644 --- a/src/tools/export.ts +++ b/src/tools/export.ts @@ -499,5 +499,40 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF }, ); + // ------------------------------------------------------ + // Export ODB++ Tool (kicad-cli) + // ------------------------------------------------------ + server.tool( + "export_odb", + "Export the PCB in ODB++ format via kicad-cli. Single job archive (copper, drill, placement, components, nets, outline) widely used by CAM/MES/assembly. Reads the last SAVED state of the .kicad_pcb.", + { + outputPath: z.string().describe("Output file path (archive or directory per compression)"), + boardPath: z.string().optional().describe("Path to the .kicad_pcb (default: current board)"), + drawingSheet: z.string().optional().describe("Path to a drawing sheet override"), + defineVar: z + .array(z.string()) + .optional() + .describe("Project variable overrides as 'KEY=VALUE' strings"), + precision: z.number().optional().describe("Coordinate precision (default 2)"), + compression: z + .enum(["zip", "tgz", "none"]) + .optional() + .describe("Output container/compression mode (default zip)"), + units: z.enum(["mm", "in"]).optional().describe("Units (default mm)"), + }, + async (args) => { + logger.debug(`Exporting ODB++ to: ${args.outputPath}`); + const result = await callKicadScript("export_odb", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + logger.info("Export tools registered"); } diff --git a/src/tools/registry.ts b/src/tools/registry.ts index d5742c6..1249961 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -61,6 +61,7 @@ export const toolCategories: ToolCategory[] = [ "export_gerbers", "export_drill", "export_ipc2581", + "export_odb", "export_pdf", "export_svg", "export_3d", From cdaa1eb63547b8e1bae910377017b18c3fd8084c Mon Sep 17 00:00:00 2001 From: Gavin Colonese Date: Fri, 12 Jun 2026 10:55:02 -0400 Subject: [PATCH 06/22] feat: add export_ipcd356 tool (kicad-cli, IPC-D-356 netlist) New MCP tool `export_ipcd356` wrapping `kicad-cli pcb export ipcd356`: generates an IPC-D-356 bare-board electrical-test netlist for flying-probe and bed-of-nails testers. Minimal option set (outputPath + boardPath only, matching the subcommand's --help). Self-contained interface handler reading the saved .kicad_pcb. Co-Authored-By: Claude Opus 4.8 (1M context) --- python/kicad_interface.py | 56 +++++++++++++++++++++++++++++++++++++++ src/tools/export.ts | 24 +++++++++++++++++ src/tools/registry.ts | 1 + 3 files changed, 81 insertions(+) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 1ee6bd0..340c721 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -591,6 +591,7 @@ class KiCADInterface: "export_drill": self._handle_export_drill, "export_ipc2581": self._handle_export_ipc2581, "export_odb": self._handle_export_odb, + "export_ipcd356": self._handle_export_ipcd356, "generate_netlist": self._handle_generate_netlist, "sync_schematic_to_board": self._handle_sync_schematic_to_board, "list_schematic_libraries": self._handle_list_schematic_libraries, @@ -4832,6 +4833,61 @@ class KiCADInterface: logger.error(f"Error exporting ODB++: {e}") return {"success": False, "message": str(e)} + def _handle_export_ipcd356(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Generate an IPC-D-356 netlist file via kicad-cli (`pcb export ipcd356`). + + IPC-D-356 is a bare-board electrical-test netlist consumed by flying-probe + and bed-of-nails testers. Minimal option set (output + input only). Reads + the saved .kicad_pcb. + """ + import subprocess + + logger.info("Exporting IPC-D-356 netlist via kicad-cli") + try: + board_path = params.get("boardPath") or self._current_board_path() + output_path = params.get("outputPath") + + if not board_path: + return { + "success": False, + "message": "boardPath is required (no current board could be resolved)", + } + if not os.path.exists(board_path): + return {"success": False, "message": f"Board not found: {board_path}"} + if not output_path: + return {"success": False, "message": "outputPath is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_path = os.path.abspath(os.path.expanduser(output_path)) + parent = os.path.dirname(output_path) + if parent: + os.makedirs(parent, exist_ok=True) + + cmd = [kicad_cli, "pcb", "export", "ipcd356", "--output", output_path] + cmd.append(board_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + return {"success": True, "outputPath": output_path} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting IPC-D-356: {e}") + return {"success": False, "message": str(e)} + def _handle_generate_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]: """Generate netlist from schematic and return structured JSON. diff --git a/src/tools/export.ts b/src/tools/export.ts index 8485e43..727beff 100644 --- a/src/tools/export.ts +++ b/src/tools/export.ts @@ -534,5 +534,29 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF }, ); + // ------------------------------------------------------ + // Export IPC-D-356 Netlist Tool (kicad-cli) + // ------------------------------------------------------ + server.tool( + "export_ipcd356", + "Generate an IPC-D-356 bare-board electrical-test netlist via kicad-cli. Consumed by flying-probe and bed-of-nails testers. Reads the last SAVED state of the .kicad_pcb.", + { + outputPath: z.string().describe("Output .ipc / netlist file path"), + boardPath: z.string().optional().describe("Path to the .kicad_pcb (default: current board)"), + }, + async (args) => { + logger.debug(`Exporting IPC-D-356 netlist to: ${args.outputPath}`); + const result = await callKicadScript("export_ipcd356", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + logger.info("Export tools registered"); } diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 1249961..8b1a6a5 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -62,6 +62,7 @@ export const toolCategories: ToolCategory[] = [ "export_drill", "export_ipc2581", "export_odb", + "export_ipcd356", "export_pdf", "export_svg", "export_3d", From ab7a2101afcba34428d7f0a483af0bbdd9d52dba Mon Sep 17 00:00:00 2001 From: Gavin Colonese Date: Fri, 12 Jun 2026 10:55:26 -0400 Subject: [PATCH 07/22] feat: add export_gencad tool (kicad-cli, GenCAD assembly/test format) New MCP tool `export_gencad` wrapping `kicad-cli pcb export gencad`: exposes define-var plus the flip-bottom-pads, unique-pins, unique-footprints, use-drill-origin and store-origin-coord flags. Self-contained interface handler reading the saved .kicad_pcb. Co-Authored-By: Claude Opus 4.8 (1M context) --- python/kicad_interface.py | 71 +++++++++++++++++++++++++++++++++++++++ src/tools/export.ts | 36 ++++++++++++++++++++ src/tools/registry.ts | 1 + 3 files changed, 108 insertions(+) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 340c721..e2ca486 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -592,6 +592,7 @@ class KiCADInterface: "export_ipc2581": self._handle_export_ipc2581, "export_odb": self._handle_export_odb, "export_ipcd356": self._handle_export_ipcd356, + "export_gencad": self._handle_export_gencad, "generate_netlist": self._handle_generate_netlist, "sync_schematic_to_board": self._handle_sync_schematic_to_board, "list_schematic_libraries": self._handle_list_schematic_libraries, @@ -4888,6 +4889,76 @@ class KiCADInterface: logger.error(f"Error exporting IPC-D-356: {e}") return {"success": False, "message": str(e)} + def _handle_export_gencad(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Export the PCB in GenCAD format via kicad-cli (`pcb export gencad`). + + GenCAD is an assembly/test interchange format. Exposes the padstack-flip, + unique-pin/footprint, drill-origin, and store-origin flags. Reads the + saved .kicad_pcb. + """ + import subprocess + + logger.info("Exporting GenCAD via kicad-cli") + try: + board_path = params.get("boardPath") or self._current_board_path() + output_path = params.get("outputPath") + + if not board_path: + return { + "success": False, + "message": "boardPath is required (no current board could be resolved)", + } + if not os.path.exists(board_path): + return {"success": False, "message": f"Board not found: {board_path}"} + if not output_path: + return {"success": False, "message": "outputPath is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_path = os.path.abspath(os.path.expanduser(output_path)) + parent = os.path.dirname(output_path) + if parent: + os.makedirs(parent, exist_ok=True) + + cmd = [kicad_cli, "pcb", "export", "gencad", "--output", output_path] + + for kv in params.get("defineVar", []) or []: + cmd += ["--define-var", kv] + + flag_map = { + "flipBottomPads": "--flip-bottom-pads", + "uniquePins": "--unique-pins", + "uniqueFootprints": "--unique-footprints", + "useDrillOrigin": "--use-drill-origin", + "storeOriginCoord": "--store-origin-coord", + } + for key, flag in flag_map.items(): + if params.get(key): + cmd.append(flag) + + cmd.append(board_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + return {"success": True, "outputPath": output_path} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting GenCAD: {e}") + return {"success": False, "message": str(e)} + def _handle_generate_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]: """Generate netlist from schematic and return structured JSON. diff --git a/src/tools/export.ts b/src/tools/export.ts index 727beff..cd42f57 100644 --- a/src/tools/export.ts +++ b/src/tools/export.ts @@ -558,5 +558,41 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF }, ); + // ------------------------------------------------------ + // Export GenCAD Tool (kicad-cli) + // ------------------------------------------------------ + server.tool( + "export_gencad", + "Export the PCB in GenCAD format via kicad-cli. Assembly/test interchange format. Exposes padstack flip, unique pin/footprint shape generation, drill-file origin, and store-origin-coordinate options. Reads the last SAVED state of the .kicad_pcb.", + { + outputPath: z.string().describe("Output .cad file path"), + boardPath: z.string().optional().describe("Path to the .kicad_pcb (default: current board)"), + defineVar: z + .array(z.string()) + .optional() + .describe("Project variable overrides as 'KEY=VALUE' strings"), + flipBottomPads: z.boolean().optional().describe("Flip bottom footprint padstacks"), + uniquePins: z.boolean().optional().describe("Generate unique pin names"), + uniqueFootprints: z + .boolean() + .optional() + .describe("Generate a new shape for each footprint instance (do not reuse shapes)"), + useDrillOrigin: z.boolean().optional().describe("Use drill/place file origin as origin"), + storeOriginCoord: z.boolean().optional().describe("Save the origin coordinates in the file"), + }, + async (args) => { + logger.debug(`Exporting GenCAD to: ${args.outputPath}`); + const result = await callKicadScript("export_gencad", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + logger.info("Export tools registered"); } diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 8b1a6a5..92d5c6c 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -63,6 +63,7 @@ export const toolCategories: ToolCategory[] = [ "export_ipc2581", "export_odb", "export_ipcd356", + "export_gencad", "export_pdf", "export_svg", "export_3d", From f6d38f194211ed2205e12b01badad9e4a31e02cb Mon Sep 17 00:00:00 2001 From: Gavin Colonese Date: Fri, 12 Jun 2026 10:55:50 -0400 Subject: [PATCH 08/22] feat: add export_pos tool (kicad-cli, full placement-file option set) New MCP tool `export_pos` wrapping `kicad-cli pcb export pos`: exposes side, format, units, bottom-negate-X, drill-file origin, SMD-only, exclude-through-hole, exclude-DNP and gerber-board-edge. Rich CLI sibling of the existing SWIG-limited export_position_file (left untouched). Self-contained interface handler reading the saved .kicad_pcb. Co-Authored-By: Claude Opus 4.8 (1M context) --- python/kicad_interface.py | 80 +++++++++++++++++++++++++++++++++++++++ src/tools/export.ts | 54 ++++++++++++++++++++++++++ src/tools/registry.ts | 1 + 3 files changed, 135 insertions(+) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index e2ca486..6f36c5a 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -593,6 +593,7 @@ class KiCADInterface: "export_odb": self._handle_export_odb, "export_ipcd356": self._handle_export_ipcd356, "export_gencad": self._handle_export_gencad, + "export_pos": self._handle_export_pos, "generate_netlist": self._handle_generate_netlist, "sync_schematic_to_board": self._handle_sync_schematic_to_board, "list_schematic_libraries": self._handle_list_schematic_libraries, @@ -4959,6 +4960,85 @@ class KiCADInterface: logger.error(f"Error exporting GenCAD: {e}") return {"success": False, "message": str(e)} + def _handle_export_pos(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Generate a component placement (position) file via kicad-cli + (`pcb export pos`). + + This is the rich CLI sibling of export_position_file: full option set + (side, format, units, bottom-negate-X, drill origin, SMD-only, TH/DNP + exclusion, gerber board edge). Reads the saved .kicad_pcb. + """ + import subprocess + + logger.info("Exporting position file via kicad-cli") + try: + board_path = params.get("boardPath") or self._current_board_path() + output_path = params.get("outputPath") + + if not board_path: + return { + "success": False, + "message": "boardPath is required (no current board could be resolved)", + } + if not os.path.exists(board_path): + return {"success": False, "message": f"Board not found: {board_path}"} + if not output_path: + return {"success": False, "message": "outputPath is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_path = os.path.abspath(os.path.expanduser(output_path)) + parent = os.path.dirname(output_path) + if parent: + os.makedirs(parent, exist_ok=True) + + cmd = [kicad_cli, "pcb", "export", "pos", "--output", output_path] + + value_map = { + "side": "--side", + "format": "--format", + "units": "--units", + } + for key, flag in value_map.items(): + val = params.get(key) + if val is not None and val != "": + cmd += [flag, str(val)] + + flag_map = { + "bottomNegateX": "--bottom-negate-x", + "useDrillFileOrigin": "--use-drill-file-origin", + "smdOnly": "--smd-only", + "excludeFpTh": "--exclude-fp-th", + "excludeDnp": "--exclude-dnp", + "gerberBoardEdge": "--gerber-board-edge", + } + for key, flag in flag_map.items(): + if params.get(key): + cmd.append(flag) + + cmd.append(board_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + return {"success": True, "outputPath": output_path} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting position file: {e}") + return {"success": False, "message": str(e)} + def _handle_generate_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]: """Generate netlist from schematic and return structured JSON. diff --git a/src/tools/export.ts b/src/tools/export.ts index cd42f57..8dae799 100644 --- a/src/tools/export.ts +++ b/src/tools/export.ts @@ -594,5 +594,59 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF }, ); + // ------------------------------------------------------ + // Export Position File Tool (kicad-cli, full option set) + // ------------------------------------------------------ + server.tool( + "export_pos", + "Generate a component placement (position / pick-and-place) file via kicad-cli, exposing the full CLI option set (side, format, units, bottom-negate-X, drill-file origin, SMD-only, exclude through-hole / DNP, gerber board edge). Rich CLI sibling of export_position_file. Reads the last SAVED state of the .kicad_pcb.", + { + outputPath: z.string().describe("Output position file path"), + boardPath: z.string().optional().describe("Path to the .kicad_pcb (default: current board)"), + side: z + .enum(["front", "back", "both"]) + .optional() + .describe("Board side (gerber format only supports front or back; default both)"), + format: z + .enum(["ascii", "csv", "gerber"]) + .optional() + .describe("Output format (default ascii)"), + units: z + .enum(["in", "mm"]) + .optional() + .describe("Output units; ascii or csv format only (default in)"), + bottomNegateX: z + .boolean() + .optional() + .describe("Use negative X coordinates for bottom-layer footprints (ascii/csv only)"), + useDrillFileOrigin: z + .boolean() + .optional() + .describe("Use drill/place file origin (ascii/csv only)"), + smdOnly: z.boolean().optional().describe("Include only SMD footprints (ascii/csv only)"), + excludeFpTh: z + .boolean() + .optional() + .describe("Exclude all footprints with through-hole pads (ascii/csv only)"), + excludeDnp: z + .boolean() + .optional() + .describe("Exclude all footprints with the Do Not Populate flag set"), + gerberBoardEdge: z.boolean().optional().describe("Include board edge layer (Gerber only)"), + }, + async (args) => { + logger.debug(`Exporting position file to: ${args.outputPath}`); + const result = await callKicadScript("export_pos", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + logger.info("Export tools registered"); } diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 92d5c6c..e38a605 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -64,6 +64,7 @@ export const toolCategories: ToolCategory[] = [ "export_odb", "export_ipcd356", "export_gencad", + "export_pos", "export_pdf", "export_svg", "export_3d", From f491df4eeac9e7d577185a71f23f2847c42f75c6 Mon Sep 17 00:00:00 2001 From: Gavin Colonese Date: Fri, 12 Jun 2026 10:56:17 -0400 Subject: [PATCH 09/22] feat: add export_pcb_pdf tool (kicad-cli, full layer-plot PDF) New MCP tool `export_pcb_pdf` wrapping `kicad-cli pcb export pdf`: exposes the full layer-plot surface (layer/common-layer lists, mirror, refdes/value exclusion, border+title, soldermask subtraction, the four DNP fab-layer modes, negative, B&W, theme, drill-shape, and the single/separate/multipage output modes). Rich CLI sibling of the existing SWIG export_pdf (left untouched). Reads the saved .kicad_pcb. Co-Authored-By: Claude Opus 4.8 (1M context) --- python/kicad_interface.py | 106 ++++++++++++++++++++++++++++++++++++++ src/tools/export.ts | 71 +++++++++++++++++++++++++ src/tools/registry.ts | 1 + 3 files changed, 178 insertions(+) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 6f36c5a..dc00d69 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -594,6 +594,7 @@ class KiCADInterface: "export_ipcd356": self._handle_export_ipcd356, "export_gencad": self._handle_export_gencad, "export_pos": self._handle_export_pos, + "export_pcb_pdf": self._handle_export_pcb_pdf, "generate_netlist": self._handle_generate_netlist, "sync_schematic_to_board": self._handle_sync_schematic_to_board, "list_schematic_libraries": self._handle_list_schematic_libraries, @@ -5039,6 +5040,111 @@ class KiCADInterface: logger.error(f"Error exporting position file: {e}") return {"success": False, "message": str(e)} + def _handle_export_pcb_pdf(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Plot the PCB to PDF via kicad-cli (`pcb export pdf`). + + Exposes the full layer-plot option set: layer lists, mirror, refdes/value + exclusion, border+title, soldermask subtraction, DNP fab-layer modes, + negative/B&W, theme, drill-shape, and the single/separate/multipage + output modes. Reads the saved .kicad_pcb. + """ + import subprocess + + logger.info("Exporting PCB PDF via kicad-cli") + try: + board_path = params.get("boardPath") or self._current_board_path() + output_path = params.get("outputPath") + + if not board_path: + return { + "success": False, + "message": "boardPath is required (no current board could be resolved)", + } + if not os.path.exists(board_path): + return {"success": False, "message": f"Board not found: {board_path}"} + if not output_path: + return {"success": False, "message": "outputPath is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_path = os.path.abspath(os.path.expanduser(output_path)) + parent = os.path.dirname(output_path) + if parent: + os.makedirs(parent, exist_ok=True) + + cmd = [kicad_cli, "pcb", "export", "pdf", "--output", output_path] + + layers = params.get("layers") + if layers: + cmd += ["--layers", ",".join(layers) if isinstance(layers, list) else str(layers)] + common_layers = params.get("commonLayers") + if common_layers: + cmd += [ + "--common-layers", + ( + ",".join(common_layers) + if isinstance(common_layers, list) + else str(common_layers) + ), + ] + + if params.get("drawingSheet"): + cmd += ["--drawing-sheet", params["drawingSheet"]] + for kv in params.get("defineVar", []) or []: + cmd += ["--define-var", kv] + + value_map = { + "theme": "--theme", + "drillShapeOpt": "--drill-shape-opt", + } + for key, flag in value_map.items(): + val = params.get(key) + if val is not None and val != "": + cmd += [flag, str(val)] + + flag_map = { + "mirror": "--mirror", + "excludeRefdes": "--exclude-refdes", + "excludeValue": "--exclude-value", + "includeBorderTitle": "--include-border-title", + "subtractSoldermask": "--subtract-soldermask", + "sketchPadsOnFabLayers": "--sketch-pads-on-fab-layers", + "hideDnpFootprintsOnFabLayers": "--hide-DNP-footprints-on-fab-layers", + "sketchDnpFootprintsOnFabLayers": "--sketch-DNP-footprints-on-fab-layers", + "crossoutDnpFootprintsOnFabLayers": "--crossout-DNP-footprints-on-fab-layers", + "negative": "--negative", + "blackAndWhite": "--black-and-white", + "modeSingle": "--mode-single", + "modeSeparate": "--mode-separate", + "modeMultipage": "--mode-multipage", + } + for key, flag in flag_map.items(): + if params.get(key): + cmd.append(flag) + + cmd.append(board_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + return {"success": True, "outputPath": output_path} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting PCB PDF: {e}") + return {"success": False, "message": str(e)} + def _handle_generate_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]: """Generate netlist from schematic and return structured JSON. diff --git a/src/tools/export.ts b/src/tools/export.ts index 8dae799..db44ab7 100644 --- a/src/tools/export.ts +++ b/src/tools/export.ts @@ -648,5 +648,76 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF }, ); + // ------------------------------------------------------ + // Export PCB PDF Tool (kicad-cli, full layer-plot option set) + // ------------------------------------------------------ + server.tool( + "export_pcb_pdf", + "Plot the PCB layout to PDF via kicad-cli, exposing the full layer-plot option set (layer + common-layer lists, mirror, refdes/value exclusion, border+title, soldermask subtraction, DNP fab-layer modes, negative, black-and-white, theme, drill shape, and single/separate/multipage output modes). Rich CLI sibling of export_pdf. Reads the last SAVED state of the .kicad_pcb.", + { + outputPath: z.string().describe("Output PDF file path (or directory in separate mode)"), + boardPath: z.string().optional().describe("Path to the .kicad_pcb (default: current board)"), + layers: z + .array(z.string()) + .optional() + .describe("Layers to plot, untranslated names e.g. ['F.Cu','B.Cu','Edge.Cuts']"), + commonLayers: z + .array(z.string()) + .optional() + .describe("Layers to include on every plot (e.g. ['Edge.Cuts'])"), + drawingSheet: z.string().optional().describe("Path to a drawing sheet override"), + defineVar: z + .array(z.string()) + .optional() + .describe("Project variable overrides as 'KEY=VALUE' strings"), + mirror: z.boolean().optional().describe("Mirror the board (to show bottom layers)"), + excludeRefdes: z.boolean().optional().describe("Exclude reference designator text"), + excludeValue: z.boolean().optional().describe("Exclude value text"), + includeBorderTitle: z.boolean().optional().describe("Include the border and title block"), + subtractSoldermask: z.boolean().optional().describe("Subtract soldermask from silkscreen"), + sketchPadsOnFabLayers: z + .boolean() + .optional() + .describe("Draw pad outlines and numbers on fab layers"), + hideDnpFootprintsOnFabLayers: z + .boolean() + .optional() + .describe("Don't plot DNP footprint text/graphics on fab layers"), + sketchDnpFootprintsOnFabLayers: z + .boolean() + .optional() + .describe("Plot DNP footprints in sketch mode on fab layers"), + crossoutDnpFootprintsOnFabLayers: z + .boolean() + .optional() + .describe("Plot an 'X' over DNP footprint courtyards and strike out their refdes"), + negative: z.boolean().optional().describe("Plot as negative"), + blackAndWhite: z.boolean().optional().describe("Black and white only"), + theme: z.string().optional().describe("Color theme to use (default: PCB editor settings)"), + drillShapeOpt: z + .number() + .optional() + .describe("Pad/via drill shape option (0 none, 1 small, 2 actual; default 2)"), + modeSingle: z + .boolean() + .optional() + .describe("Single file; output path is full path; LAYER_LIST controls all layers"), + modeSeparate: z.boolean().optional().describe("Plot the layers to individual PDF files"), + modeMultipage: z.boolean().optional().describe("Plot the layers to a single multi-page PDF"), + }, + async (args) => { + logger.debug(`Exporting PCB PDF to: ${args.outputPath}`); + const result = await callKicadScript("export_pcb_pdf", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + logger.info("Export tools registered"); } diff --git a/src/tools/registry.ts b/src/tools/registry.ts index e38a605..a53d71f 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -65,6 +65,7 @@ export const toolCategories: ToolCategory[] = [ "export_ipcd356", "export_gencad", "export_pos", + "export_pcb_pdf", "export_pdf", "export_svg", "export_3d", From 494c2e1ffb35ca1887b2b18e70594351a0e9a1cd Mon Sep 17 00:00:00 2001 From: Gavin Colonese Date: Fri, 12 Jun 2026 10:56:41 -0400 Subject: [PATCH 10/22] feat: add export_pcb_svg tool (kicad-cli, full layer-plot SVG) New MCP tool `export_pcb_svg` wrapping `kicad-cli pcb export svg`: exposes the full layer-plot surface (layer/common-layer lists, mirror, soldermask subtraction, negative, B&W, theme, the four DNP fab-layer modes, page-size-mode, fit-page-to-board, exclude-drawing-sheet, drill-shape, and the single/multi output modes). Rich CLI sibling of the existing SWIG export_svg (left untouched). Reads the saved .kicad_pcb. Co-Authored-By: Claude Opus 4.8 (1M context) --- python/kicad_interface.py | 105 ++++++++++++++++++++++++++++++++++++++ src/tools/export.ts | 76 +++++++++++++++++++++++++++ src/tools/registry.ts | 1 + 3 files changed, 182 insertions(+) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index dc00d69..6617c85 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -595,6 +595,7 @@ class KiCADInterface: "export_gencad": self._handle_export_gencad, "export_pos": self._handle_export_pos, "export_pcb_pdf": self._handle_export_pcb_pdf, + "export_pcb_svg": self._handle_export_pcb_svg, "generate_netlist": self._handle_generate_netlist, "sync_schematic_to_board": self._handle_sync_schematic_to_board, "list_schematic_libraries": self._handle_list_schematic_libraries, @@ -5145,6 +5146,110 @@ class KiCADInterface: logger.error(f"Error exporting PCB PDF: {e}") return {"success": False, "message": str(e)} + def _handle_export_pcb_svg(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Plot the PCB to SVG via kicad-cli (`pcb export svg`). + + Exposes the full layer-plot option set: layer lists, mirror, soldermask + subtraction, negative/B&W, theme, DNP fab-layer modes, page-size mode, + fit-page, drill-shape, and single/multi output modes. Reads the saved + .kicad_pcb. + """ + import subprocess + + logger.info("Exporting PCB SVG via kicad-cli") + try: + board_path = params.get("boardPath") or self._current_board_path() + output_path = params.get("outputPath") + + if not board_path: + return { + "success": False, + "message": "boardPath is required (no current board could be resolved)", + } + if not os.path.exists(board_path): + return {"success": False, "message": f"Board not found: {board_path}"} + if not output_path: + return {"success": False, "message": "outputPath is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_path = os.path.abspath(os.path.expanduser(output_path)) + parent = os.path.dirname(output_path) + if parent: + os.makedirs(parent, exist_ok=True) + + cmd = [kicad_cli, "pcb", "export", "svg", "--output", output_path] + + layers = params.get("layers") + if layers: + cmd += ["--layers", ",".join(layers) if isinstance(layers, list) else str(layers)] + common_layers = params.get("commonLayers") + if common_layers: + cmd += [ + "--common-layers", + ( + ",".join(common_layers) + if isinstance(common_layers, list) + else str(common_layers) + ), + ] + + if params.get("drawingSheet"): + cmd += ["--drawing-sheet", params["drawingSheet"]] + for kv in params.get("defineVar", []) or []: + cmd += ["--define-var", kv] + + value_map = { + "theme": "--theme", + "pageSizeMode": "--page-size-mode", + "drillShapeOpt": "--drill-shape-opt", + } + for key, flag in value_map.items(): + val = params.get(key) + if val is not None and val != "": + cmd += [flag, str(val)] + + flag_map = { + "subtractSoldermask": "--subtract-soldermask", + "mirror": "--mirror", + "negative": "--negative", + "blackAndWhite": "--black-and-white", + "sketchPadsOnFabLayers": "--sketch-pads-on-fab-layers", + "hideDnpFootprintsOnFabLayers": "--hide-DNP-footprints-on-fab-layers", + "sketchDnpFootprintsOnFabLayers": "--sketch-DNP-footprints-on-fab-layers", + "crossoutDnpFootprintsOnFabLayers": "--crossout-DNP-footprints-on-fab-layers", + "fitPageToBoard": "--fit-page-to-board", + "excludeDrawingSheet": "--exclude-drawing-sheet", + "modeSingle": "--mode-single", + "modeMulti": "--mode-multi", + } + for key, flag in flag_map.items(): + if params.get(key): + cmd.append(flag) + + cmd.append(board_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + return {"success": True, "outputPath": output_path} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting PCB SVG: {e}") + return {"success": False, "message": str(e)} + def _handle_generate_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]: """Generate netlist from schematic and return structured JSON. diff --git a/src/tools/export.ts b/src/tools/export.ts index db44ab7..031d214 100644 --- a/src/tools/export.ts +++ b/src/tools/export.ts @@ -719,5 +719,81 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF }, ); + // ------------------------------------------------------ + // Export PCB SVG Tool (kicad-cli, full layer-plot option set) + // ------------------------------------------------------ + server.tool( + "export_pcb_svg", + "Plot the PCB layout to SVG via kicad-cli, exposing the full layer-plot option set (layer + common-layer lists, mirror, soldermask subtraction, negative, black-and-white, theme, DNP fab-layer modes, page-size mode, fit-page-to-board, exclude-drawing-sheet, drill shape, single/multi output modes). Rich CLI sibling of export_svg. Reads the last SAVED state of the .kicad_pcb.", + { + outputPath: z.string().describe("Output SVG file path (or directory in multi mode)"), + boardPath: z.string().optional().describe("Path to the .kicad_pcb (default: current board)"), + layers: z + .array(z.string()) + .optional() + .describe("Layers to plot, untranslated names e.g. ['F.Cu','B.Cu','Edge.Cuts']"), + commonLayers: z + .array(z.string()) + .optional() + .describe("Layers to include on every plot (e.g. ['Edge.Cuts'])"), + drawingSheet: z.string().optional().describe("Path to a drawing sheet override"), + defineVar: z + .array(z.string()) + .optional() + .describe("Project variable overrides as 'KEY=VALUE' strings"), + subtractSoldermask: z.boolean().optional().describe("Subtract soldermask from silkscreen"), + mirror: z.boolean().optional().describe("Mirror the board (to show bottom layers)"), + negative: z.boolean().optional().describe("Plot as negative"), + blackAndWhite: z.boolean().optional().describe("Black and white only"), + theme: z.string().optional().describe("Color theme to use (default: PCB editor settings)"), + sketchPadsOnFabLayers: z + .boolean() + .optional() + .describe("Draw pad outlines and numbers on fab layers"), + hideDnpFootprintsOnFabLayers: z + .boolean() + .optional() + .describe("Don't plot DNP footprint text/graphics on fab layers"), + sketchDnpFootprintsOnFabLayers: z + .boolean() + .optional() + .describe("Plot DNP footprints in sketch mode on fab layers"), + crossoutDnpFootprintsOnFabLayers: z + .boolean() + .optional() + .describe("Plot an 'X' over DNP footprint courtyards and strike out their refdes"), + pageSizeMode: z + .number() + .optional() + .describe("Page sizing mode (0 frame+title block, 1 current page size, 2 board area only)"), + fitPageToBoard: z.boolean().optional().describe("Fit the page to the board"), + excludeDrawingSheet: z.boolean().optional().describe("No drawing sheet"), + drillShapeOpt: z + .number() + .optional() + .describe("Pad/via drill shape option (0 none, 1 small, 2 actual; default 2)"), + modeSingle: z + .boolean() + .optional() + .describe("Single file; output path is full path; LAYER_LIST controls all layers"), + modeMulti: z + .boolean() + .optional() + .describe("Multi output; output path is a directory (GUI-like plotting)"), + }, + async (args) => { + logger.debug(`Exporting PCB SVG to: ${args.outputPath}`); + const result = await callKicadScript("export_pcb_svg", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + logger.info("Export tools registered"); } diff --git a/src/tools/registry.ts b/src/tools/registry.ts index a53d71f..8d5eb79 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -66,6 +66,7 @@ export const toolCategories: ToolCategory[] = [ "export_gencad", "export_pos", "export_pcb_pdf", + "export_pcb_svg", "export_pdf", "export_svg", "export_3d", From ce6cff1b39ce2e1ccb0bfb45759c1568537c0a70 Mon Sep 17 00:00:00 2001 From: Gavin Colonese Date: Fri, 12 Jun 2026 10:57:09 -0400 Subject: [PATCH 11/22] feat: add export_pcb_dxf tool (kicad-cli, full layer-plot DXF) New MCP tool `export_pcb_dxf` wrapping `kicad-cli pcb export dxf`: exposes the full layer-plot surface (layer/common-layer lists, refdes/value exclusion, soldermask subtraction, use-contours, use-drill-origin, border+title, output-units, the four DNP fab-layer modes, drill-shape, and the single/multi output modes). Reads the saved .kicad_pcb. Co-Authored-By: Claude Opus 4.8 (1M context) --- python/kicad_interface.py | 104 ++++++++++++++++++++++++++++++++++++++ src/tools/export.ts | 72 ++++++++++++++++++++++++++ src/tools/registry.ts | 1 + 3 files changed, 177 insertions(+) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 6617c85..826efa5 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -596,6 +596,7 @@ class KiCADInterface: "export_pos": self._handle_export_pos, "export_pcb_pdf": self._handle_export_pcb_pdf, "export_pcb_svg": self._handle_export_pcb_svg, + "export_pcb_dxf": self._handle_export_pcb_dxf, "generate_netlist": self._handle_generate_netlist, "sync_schematic_to_board": self._handle_sync_schematic_to_board, "list_schematic_libraries": self._handle_list_schematic_libraries, @@ -5250,6 +5251,109 @@ class KiCADInterface: logger.error(f"Error exporting PCB SVG: {e}") return {"success": False, "message": str(e)} + def _handle_export_pcb_dxf(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Plot the PCB to DXF via kicad-cli (`pcb export dxf`). + + Exposes the full layer-plot option set: layer lists, refdes/value + exclusion, soldermask subtraction, contours, drill origin, border+title, + output units, DNP fab-layer modes, drill-shape, and single/multi output + modes. Reads the saved .kicad_pcb. + """ + import subprocess + + logger.info("Exporting PCB DXF via kicad-cli") + try: + board_path = params.get("boardPath") or self._current_board_path() + output_path = params.get("outputPath") + + if not board_path: + return { + "success": False, + "message": "boardPath is required (no current board could be resolved)", + } + if not os.path.exists(board_path): + return {"success": False, "message": f"Board not found: {board_path}"} + if not output_path: + return {"success": False, "message": "outputPath is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_path = os.path.abspath(os.path.expanduser(output_path)) + parent = os.path.dirname(output_path) + if parent: + os.makedirs(parent, exist_ok=True) + + cmd = [kicad_cli, "pcb", "export", "dxf", "--output", output_path] + + layers = params.get("layers") + if layers: + cmd += ["--layers", ",".join(layers) if isinstance(layers, list) else str(layers)] + common_layers = params.get("commonLayers") + if common_layers: + cmd += [ + "--common-layers", + ( + ",".join(common_layers) + if isinstance(common_layers, list) + else str(common_layers) + ), + ] + + if params.get("drawingSheet"): + cmd += ["--drawing-sheet", params["drawingSheet"]] + for kv in params.get("defineVar", []) or []: + cmd += ["--define-var", kv] + + value_map = { + "outputUnits": "--output-units", + "drillShapeOpt": "--drill-shape-opt", + } + for key, flag in value_map.items(): + val = params.get(key) + if val is not None and val != "": + cmd += [flag, str(val)] + + flag_map = { + "excludeRefdes": "--exclude-refdes", + "excludeValue": "--exclude-value", + "sketchPadsOnFabLayers": "--sketch-pads-on-fab-layers", + "hideDnpFootprintsOnFabLayers": "--hide-DNP-footprints-on-fab-layers", + "sketchDnpFootprintsOnFabLayers": "--sketch-DNP-footprints-on-fab-layers", + "crossoutDnpFootprintsOnFabLayers": "--crossout-DNP-footprints-on-fab-layers", + "subtractSoldermask": "--subtract-soldermask", + "useContours": "--use-contours", + "useDrillOrigin": "--use-drill-origin", + "includeBorderTitle": "--include-border-title", + "modeSingle": "--mode-single", + "modeMulti": "--mode-multi", + } + for key, flag in flag_map.items(): + if params.get(key): + cmd.append(flag) + + cmd.append(board_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + return {"success": True, "outputPath": output_path} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting PCB DXF: {e}") + return {"success": False, "message": str(e)} + def _handle_generate_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]: """Generate netlist from schematic and return structured JSON. diff --git a/src/tools/export.ts b/src/tools/export.ts index 031d214..25c3fea 100644 --- a/src/tools/export.ts +++ b/src/tools/export.ts @@ -795,5 +795,77 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF }, ); + // ------------------------------------------------------ + // Export PCB DXF Tool (kicad-cli, full layer-plot option set) + // ------------------------------------------------------ + server.tool( + "export_pcb_dxf", + "Plot the PCB layout to DXF via kicad-cli, exposing the full layer-plot option set (layer + common-layer lists, refdes/value exclusion, soldermask subtraction, use-contours, use-drill-origin, border+title, output units, DNP fab-layer modes, drill shape, single/multi output modes). Reads the last SAVED state of the .kicad_pcb.", + { + outputPath: z.string().describe("Output DXF file path (or directory in multi mode)"), + boardPath: z.string().optional().describe("Path to the .kicad_pcb (default: current board)"), + layers: z + .array(z.string()) + .optional() + .describe("Layers to plot, untranslated names e.g. ['F.Cu','B.Cu','Edge.Cuts']"), + commonLayers: z + .array(z.string()) + .optional() + .describe("Layers to include on every plot (e.g. ['Edge.Cuts'])"), + drawingSheet: z.string().optional().describe("Path to a drawing sheet override"), + defineVar: z + .array(z.string()) + .optional() + .describe("Project variable overrides as 'KEY=VALUE' strings"), + excludeRefdes: z.boolean().optional().describe("Exclude reference designator text"), + excludeValue: z.boolean().optional().describe("Exclude value text"), + sketchPadsOnFabLayers: z + .boolean() + .optional() + .describe("Draw pad outlines and numbers on fab layers"), + hideDnpFootprintsOnFabLayers: z + .boolean() + .optional() + .describe("Don't plot DNP footprint text/graphics on fab layers"), + sketchDnpFootprintsOnFabLayers: z + .boolean() + .optional() + .describe("Plot DNP footprints in sketch mode on fab layers"), + crossoutDnpFootprintsOnFabLayers: z + .boolean() + .optional() + .describe("Plot an 'X' over DNP footprint courtyards and strike out their refdes"), + subtractSoldermask: z.boolean().optional().describe("Subtract soldermask from silkscreen"), + useContours: z.boolean().optional().describe("Plot graphic items using their contours"), + useDrillOrigin: z.boolean().optional().describe("Plot using the drill/place file origin"), + includeBorderTitle: z.boolean().optional().describe("Include the border and title block"), + outputUnits: z.enum(["mm", "in"]).optional().describe("Output units (default in)"), + drillShapeOpt: z + .number() + .optional() + .describe("Pad/via drill shape option (0 none, 1 small, 2 actual; default 2)"), + modeSingle: z + .boolean() + .optional() + .describe("Single file; output path is full path; LAYER_LIST controls all layers"), + modeMulti: z + .boolean() + .optional() + .describe("Multi output; output path is a directory (GUI-like plotting)"), + }, + async (args) => { + logger.debug(`Exporting PCB DXF to: ${args.outputPath}`); + const result = await callKicadScript("export_pcb_dxf", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + logger.info("Export tools registered"); } diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 8d5eb79..465912c 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -67,6 +67,7 @@ export const toolCategories: ToolCategory[] = [ "export_pos", "export_pcb_pdf", "export_pcb_svg", + "export_pcb_dxf", "export_pdf", "export_svg", "export_3d", From 0faef0765094f8d263eacf69ae0492b20dffaf0a Mon Sep 17 00:00:00 2001 From: Gavin Colonese Date: Fri, 12 Jun 2026 10:57:35 -0400 Subject: [PATCH 12/22] feat: add export_gerber_single tool (kicad-cli, single-file Gerber) New MCP tool `export_gerber_single` wrapping `kicad-cli pcb export gerber`: plots the selected layers to a SINGLE Gerber file (singular sibling of export_gerbers). Exposes the full single-file Plot option set (X2, netlist attributes, the four DNP fab-layer modes, soldermask subtraction, aperture macros, drill-file origin, precision, Protel extension). Reads the saved .kicad_pcb. Note: this subcommand is deprecated in KiCad 9.0 (still exits 0). Co-Authored-By: Claude Opus 4.8 (1M context) --- python/kicad_interface.py | 102 ++++++++++++++++++++++++++++++++++++++ src/tools/export.ts | 66 ++++++++++++++++++++++++ src/tools/registry.ts | 1 + 3 files changed, 169 insertions(+) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 826efa5..9fd4f24 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -597,6 +597,7 @@ class KiCADInterface: "export_pcb_pdf": self._handle_export_pcb_pdf, "export_pcb_svg": self._handle_export_pcb_svg, "export_pcb_dxf": self._handle_export_pcb_dxf, + "export_gerber_single": self._handle_export_gerber_single, "generate_netlist": self._handle_generate_netlist, "sync_schematic_to_board": self._handle_sync_schematic_to_board, "list_schematic_libraries": self._handle_list_schematic_libraries, @@ -5354,6 +5355,107 @@ class KiCADInterface: logger.error(f"Error exporting PCB DXF: {e}") return {"success": False, "message": str(e)} + def _handle_export_gerber_single(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Plot the given layers to a single Gerber file via kicad-cli + (`pcb export gerber`). + + Singular sibling of export_gerbers: emits ONE Gerber file containing the + selected layers. Exposes the full single-file Plot option set (X2, + netlist attributes, DNP fab-layer modes, soldermask subtraction, aperture + macros, drill-file origin, precision, Protel extension). Reads the saved + .kicad_pcb. + """ + import subprocess + + logger.info("Exporting single Gerber via kicad-cli") + try: + board_path = params.get("boardPath") or self._current_board_path() + output_path = params.get("outputPath") + + if not board_path: + return { + "success": False, + "message": "boardPath is required (no current board could be resolved)", + } + if not os.path.exists(board_path): + return {"success": False, "message": f"Board not found: {board_path}"} + if not output_path: + return {"success": False, "message": "outputPath is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_path = os.path.abspath(os.path.expanduser(output_path)) + parent = os.path.dirname(output_path) + if parent: + os.makedirs(parent, exist_ok=True) + + cmd = [kicad_cli, "pcb", "export", "gerber", "--output", output_path] + + layers = params.get("layers") + if layers: + cmd += ["--layers", ",".join(layers) if isinstance(layers, list) else str(layers)] + common_layers = params.get("commonLayers") + if common_layers: + cmd += [ + "--common-layers", + ( + ",".join(common_layers) + if isinstance(common_layers, list) + else str(common_layers) + ), + ] + + if params.get("drawingSheet"): + cmd += ["--drawing-sheet", params["drawingSheet"]] + for kv in params.get("defineVar", []) or []: + cmd += ["--define-var", kv] + + flag_map = { + "excludeRefdes": "--exclude-refdes", + "excludeValue": "--exclude-value", + "includeBorderTitle": "--include-border-title", + "sketchPadsOnFabLayers": "--sketch-pads-on-fab-layers", + "hideDnpFootprintsOnFabLayers": "--hide-DNP-footprints-on-fab-layers", + "sketchDnpFootprintsOnFabLayers": "--sketch-DNP-footprints-on-fab-layers", + "crossoutDnpFootprintsOnFabLayers": "--crossout-DNP-footprints-on-fab-layers", + "noX2": "--no-x2", + "noNetlist": "--no-netlist", + "subtractSoldermask": "--subtract-soldermask", + "disableApertureMacros": "--disable-aperture-macros", + "useDrillFileOrigin": "--use-drill-file-origin", + "noProtelExt": "--no-protel-ext", + } + for key, flag in flag_map.items(): + if params.get(key): + cmd.append(flag) + + precision = params.get("precision") + if precision is not None: + cmd += ["--precision", str(precision)] + + cmd.append(board_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + return {"success": True, "outputPath": output_path} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting single Gerber: {e}") + return {"success": False, "message": str(e)} + def _handle_generate_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]: """Generate netlist from schematic and return structured JSON. diff --git a/src/tools/export.ts b/src/tools/export.ts index 25c3fea..acc0d4f 100644 --- a/src/tools/export.ts +++ b/src/tools/export.ts @@ -867,5 +867,71 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF }, ); + // ------------------------------------------------------ + // Export Single Gerber Tool (kicad-cli, full Plot option set) + // ------------------------------------------------------ + server.tool( + "export_gerber_single", + "Plot the given layers to a SINGLE Gerber file via kicad-cli (`pcb export gerber`). Singular sibling of export_gerbers. Exposes the full single-file Plot option set (X2, netlist attributes, DNP fab-layer modes, soldermask subtraction, aperture macros, drill-file origin, precision, Protel extension). Reads the last SAVED state of the .kicad_pcb.", + { + outputPath: z.string().describe("Output Gerber file path"), + boardPath: z.string().optional().describe("Path to the .kicad_pcb (default: current board)"), + layers: z + .array(z.string()) + .optional() + .describe("Layers to plot, untranslated names e.g. ['F.Cu','B.Cu','Edge.Cuts']"), + commonLayers: z + .array(z.string()) + .optional() + .describe("Layers to include on every plot (e.g. ['Edge.Cuts'])"), + drawingSheet: z.string().optional().describe("Path to a drawing sheet override"), + defineVar: z + .array(z.string()) + .optional() + .describe("Project variable overrides as 'KEY=VALUE' strings"), + excludeRefdes: z.boolean().optional().describe("Exclude reference designator text"), + excludeValue: z.boolean().optional().describe("Exclude value text"), + includeBorderTitle: z.boolean().optional().describe("Include the border and title block"), + sketchPadsOnFabLayers: z + .boolean() + .optional() + .describe("Draw pad outlines and numbers on fab layers"), + hideDnpFootprintsOnFabLayers: z + .boolean() + .optional() + .describe("Don't plot DNP footprint text/graphics on fab layers"), + sketchDnpFootprintsOnFabLayers: z + .boolean() + .optional() + .describe("Plot DNP footprints in sketch mode on fab layers"), + crossoutDnpFootprintsOnFabLayers: z + .boolean() + .optional() + .describe("Plot an 'X' over DNP footprint courtyards and strike out their refdes"), + noX2: z.boolean().optional().describe("Do not use the extended X2 Gerber format"), + noNetlist: z.boolean().optional().describe("Do not generate netlist attributes"), + subtractSoldermask: z.boolean().optional().describe("Subtract soldermask from silkscreen"), + disableApertureMacros: z.boolean().optional().describe("Disable aperture macros"), + useDrillFileOrigin: z.boolean().optional().describe("Use the drill/place file origin"), + noProtelExt: z + .boolean() + .optional() + .describe("Use KiCad Gerber file extensions instead of Protel"), + precision: z.number().optional().describe("Gerber coordinate precision: 5 or 6 (default 6)"), + }, + async (args) => { + logger.debug(`Exporting single Gerber to: ${args.outputPath}`); + const result = await callKicadScript("export_gerber_single", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + logger.info("Export tools registered"); } diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 465912c..557d7c1 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -68,6 +68,7 @@ export const toolCategories: ToolCategory[] = [ "export_pcb_pdf", "export_pcb_svg", "export_pcb_dxf", + "export_gerber_single", "export_pdf", "export_svg", "export_3d", From f23f1f658ace08600b6744d9b6d796dcbb0948fd Mon Sep 17 00:00:00 2001 From: Gavin Colonese Date: Fri, 12 Jun 2026 10:58:01 -0400 Subject: [PATCH 13/22] feat: add export_3d_cli tool (kicad-cli, format-dispatched 3D export) New MCP tool `export_3d_cli` wrapping the kicad-cli 3D subcommands. A `format` enum {step,glb,stl,ply,brep,xao,vrml} selects the subcommand and the handler forwards only flags valid for that subcommand: the shared geometry/include set for step/glb/stl/ply/brep/xao, no-optimize-step for STEP only, and units + models-dir/models-relative for VRML. Rich CLI sibling of the existing export_3d / export_vrml (left untouched). Reads the saved .kicad_pcb. Co-Authored-By: Claude Opus 4.8 (1M context) --- python/kicad_interface.py | 127 ++++++++++++++++++++++++++++++++++++++ src/tools/export.ts | 100 ++++++++++++++++++++++++++++++ src/tools/registry.ts | 1 + 3 files changed, 228 insertions(+) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 9fd4f24..e1ef9be 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -598,6 +598,7 @@ class KiCADInterface: "export_pcb_svg": self._handle_export_pcb_svg, "export_pcb_dxf": self._handle_export_pcb_dxf, "export_gerber_single": self._handle_export_gerber_single, + "export_3d_cli": self._handle_export_3d_cli, "generate_netlist": self._handle_generate_netlist, "sync_schematic_to_board": self._handle_sync_schematic_to_board, "list_schematic_libraries": self._handle_list_schematic_libraries, @@ -5456,6 +5457,132 @@ class KiCADInterface: logger.error(f"Error exporting single Gerber: {e}") return {"success": False, "message": str(e)} + def _handle_export_3d_cli(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Export a 3D model of the PCB via kicad-cli (`pcb export `). + + Rich CLI sibling of export_3d / export_vrml. The ``format`` param selects + the subcommand (step, glb, stl, ply, brep, xao, vrml) and only flags valid + for that subcommand are forwarded. STEP/glb/stl/ply/brep/xao share the + geometry/include flag set; vrml uses units + models-dir instead. Reads the + saved .kicad_pcb. + """ + import subprocess + + logger.info("Exporting 3D model via kicad-cli") + try: + board_path = params.get("boardPath") or self._current_board_path() + output_path = params.get("outputPath") + fmt = params.get("format") + + if not board_path: + return { + "success": False, + "message": "boardPath is required (no current board could be resolved)", + } + if not os.path.exists(board_path): + return {"success": False, "message": f"Board not found: {board_path}"} + if not output_path: + return {"success": False, "message": "outputPath is required"} + valid_formats = ("step", "glb", "stl", "ply", "brep", "xao", "vrml") + if fmt not in valid_formats: + return { + "success": False, + "message": f"format must be one of {valid_formats}", + } + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_path = os.path.abspath(os.path.expanduser(output_path)) + parent = os.path.dirname(output_path) + if parent: + os.makedirs(parent, exist_ok=True) + + cmd = [kicad_cli, "pcb", "export", fmt, "--output", output_path] + + for kv in params.get("defineVar", []) or []: + cmd += ["--define-var", kv] + + # Flags common to ALL 3D subcommands + common_flags = { + "force": "--force", + "noUnspecified": "--no-unspecified", + "noDnp": "--no-dnp", + } + # Flags shared by step/glb/stl/ply/brep/xao (NOT vrml) + mesh_flags = { + "gridOrigin": "--grid-origin", + "drillOrigin": "--drill-origin", + "substModels": "--subst-models", + "boardOnly": "--board-only", + "cutViasInBody": "--cut-vias-in-body", + "noBoardBody": "--no-board-body", + "noComponents": "--no-components", + "includeTracks": "--include-tracks", + "includePads": "--include-pads", + "includeZones": "--include-zones", + "includeInnerCopper": "--include-inner-copper", + "includeSilkscreen": "--include-silkscreen", + "includeSoldermask": "--include-soldermask", + "fuseShapes": "--fuse-shapes", + "fillAllVias": "--fill-all-vias", + } + # STEP-only flag + step_flags = { + "noOptimizeStep": "--no-optimize-step", + } + + flag_map = dict(common_flags) + if fmt != "vrml": + flag_map.update(mesh_flags) + if fmt == "step": + flag_map.update(step_flags) + for key, flag in flag_map.items(): + if params.get(key): + cmd.append(flag) + + # Valued flags common to all: user-origin + if params.get("userOrigin") is not None and params.get("userOrigin") != "": + cmd += ["--user-origin", str(params["userOrigin"])] + # Component filter + net filter + min distance: mesh subcommands only + if fmt != "vrml": + if params.get("componentFilter") is not None and params["componentFilter"] != "": + cmd += ["--component-filter", str(params["componentFilter"])] + if params.get("netFilter") is not None and params["netFilter"] != "": + cmd += ["--net-filter", str(params["netFilter"])] + if params.get("minDistance") is not None and params["minDistance"] != "": + cmd += ["--min-distance", str(params["minDistance"])] + # VRML-only valued flags + if fmt == "vrml": + if params.get("units") is not None and params["units"] != "": + cmd += ["--units", str(params["units"])] + if params.get("modelsDir") is not None and params["modelsDir"] != "": + cmd += ["--models-dir", str(params["modelsDir"])] + if params.get("modelsRelative"): + cmd.append("--models-relative") + + cmd.append(board_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + return {"success": True, "outputPath": output_path} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 300 seconds"} + except Exception as e: + logger.error(f"Error exporting 3D model: {e}") + return {"success": False, "message": str(e)} + def _handle_generate_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]: """Generate netlist from schematic and return structured JSON. diff --git a/src/tools/export.ts b/src/tools/export.ts index acc0d4f..afa242a 100644 --- a/src/tools/export.ts +++ b/src/tools/export.ts @@ -933,5 +933,105 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF }, ); + // ------------------------------------------------------ + // Export 3D Model Tool (kicad-cli, format dispatch + full flag union) + // ------------------------------------------------------ + server.tool( + "export_3d_cli", + "Export a 3D model of the PCB via kicad-cli. The `format` param selects the subcommand (step, glb, stl, ply, brep, xao, vrml); only flags valid for that subcommand are forwarded. STEP/glb/stl/ply/brep/xao share the geometry/include flag set (no-board-body, include-tracks/pads/zones/inner-copper/silkscreen/soldermask, fuse-shapes, fill-all-vias, component/net filters, min-distance, etc.; no-optimize-step is STEP-only); vrml uses units + models-dir/models-relative instead. Rich CLI sibling of export_3d and export_vrml. Reads the last SAVED state of the .kicad_pcb.", + { + outputPath: z.string().describe("Output 3D model file path"), + format: z + .enum(["step", "glb", "stl", "ply", "brep", "xao", "vrml"]) + .describe("3D output format (selects the kicad-cli subcommand)"), + boardPath: z.string().optional().describe("Path to the .kicad_pcb (default: current board)"), + defineVar: z + .array(z.string()) + .optional() + .describe("Project variable overrides as 'KEY=VALUE' strings"), + // common to all formats + force: z.boolean().optional().describe("Overwrite output file"), + noUnspecified: z + .boolean() + .optional() + .describe("Exclude 3D models for components with 'Unspecified' footprint type"), + noDnp: z + .boolean() + .optional() + .describe("Exclude 3D models for components with 'Do not populate' attribute"), + userOrigin: z + .string() + .optional() + .describe("User-specified output origin e.g. '1x1in', '25.4x25.4mm' (default unit mm)"), + // mesh subcommands only (step/glb/stl/ply/brep/xao) + gridOrigin: z.boolean().optional().describe("Use Grid Origin for output origin"), + drillOrigin: z.boolean().optional().describe("Use Drill Origin for output origin"), + substModels: z + .boolean() + .optional() + .describe("Substitute STEP/IGS models in place of VRML models"), + boardOnly: z.boolean().optional().describe("Only generate a board with no components"), + cutViasInBody: z + .boolean() + .optional() + .describe("Cut via holes in board body even if conductor layers not exported"), + noBoardBody: z.boolean().optional().describe("Exclude board body"), + noComponents: z.boolean().optional().describe("Exclude 3D models for components"), + componentFilter: z + .string() + .optional() + .describe("Only include component models matching this refdes list (comma, wildcards)"), + includeTracks: z.boolean().optional().describe("Export tracks and vias"), + includePads: z.boolean().optional().describe("Export pads"), + includeZones: z.boolean().optional().describe("Export zones"), + includeInnerCopper: z.boolean().optional().describe("Export elements on inner copper layers"), + includeSilkscreen: z + .boolean() + .optional() + .describe("Export silkscreen graphics as flat faces"), + includeSoldermask: z.boolean().optional().describe("Export soldermask layers as flat faces"), + fuseShapes: z.boolean().optional().describe("Fuse overlapping geometry together"), + fillAllVias: z.boolean().optional().describe("Don't cut via holes in conductor layers"), + minDistance: z + .string() + .optional() + .describe("Min distance between points to treat as separate (default '0.01mm')"), + netFilter: z + .string() + .optional() + .describe("Only include copper items belonging to nets matching this wildcard"), + // STEP only + noOptimizeStep: z + .boolean() + .optional() + .describe("Do not optimize STEP file (enables writing parametric curves; STEP only)"), + // VRML only + units: z + .enum(["mm", "m", "in", "tenths"]) + .optional() + .describe("Output units (VRML only; default in)"), + modelsDir: z + .string() + .optional() + .describe("Folder to store 3D models in (VRML only; empty = embed in main file)"), + modelsRelative: z + .boolean() + .optional() + .describe("Use relative model paths with modelsDir (VRML only)"), + }, + async (args) => { + logger.debug(`Exporting 3D model (${args.format}) to: ${args.outputPath}`); + const result = await callKicadScript("export_3d_cli", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + logger.info("Export tools registered"); } diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 557d7c1..8441b22 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -69,6 +69,7 @@ export const toolCategories: ToolCategory[] = [ "export_pcb_svg", "export_pcb_dxf", "export_gerber_single", + "export_3d_cli", "export_pdf", "export_svg", "export_3d", From 7210e0b59c16f8b70f80b0628aa6cadf740f282a Mon Sep 17 00:00:00 2001 From: Gavin Colonese Date: Fri, 12 Jun 2026 10:58:25 -0400 Subject: [PATCH 14/22] feat: add export_sch_bom tool (kicad-cli, schematic BOM) New MCP tool `export_sch_bom` wrapping `kicad-cli sch export bom`: exposes the full BOM option set (presets, field/label lists, group-by, sort field/direction, filter, exclude-DNP, include-excluded-from-BOM, and the field/string/ref/ref-range delimiters, plus keep-tabs/keep-line-breaks). schematicPath is required and validated. Self-contained interface handler. Co-Authored-By: Claude Opus 4.8 (1M context) --- python/kicad_interface.py | 84 +++++++++++++++++++++++++++++++++++++++ src/tools/export.ts | 73 ++++++++++++++++++++++++++++++++++ src/tools/registry.ts | 1 + 3 files changed, 158 insertions(+) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index e1ef9be..d892640 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -599,6 +599,7 @@ class KiCADInterface: "export_pcb_dxf": self._handle_export_pcb_dxf, "export_gerber_single": self._handle_export_gerber_single, "export_3d_cli": self._handle_export_3d_cli, + "export_sch_bom": self._handle_export_sch_bom, "generate_netlist": self._handle_generate_netlist, "sync_schematic_to_board": self._handle_sync_schematic_to_board, "list_schematic_libraries": self._handle_list_schematic_libraries, @@ -5583,6 +5584,89 @@ class KiCADInterface: logger.error(f"Error exporting 3D model: {e}") return {"success": False, "message": str(e)} + def _handle_export_sch_bom(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Generate a Bill of Materials from a schematic via kicad-cli + (`sch export bom`). + + Exposes the full BOM option set: presets, field/label lists, grouping, + sorting, filtering, DNP/excluded handling, and the field/string/ref + delimiters. schematicPath is required (no current-schematic resolver). + """ + import subprocess + + logger.info("Exporting schematic BOM via kicad-cli") + try: + schematic_path = params.get("schematicPath") + output_path = params.get("outputPath") + + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + if not os.path.exists(schematic_path): + return {"success": False, "message": f"Schematic not found: {schematic_path}"} + if not output_path: + return {"success": False, "message": "outputPath is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_path = os.path.abspath(os.path.expanduser(output_path)) + parent = os.path.dirname(output_path) + if parent: + os.makedirs(parent, exist_ok=True) + + cmd = [kicad_cli, "sch", "export", "bom", "--output", output_path] + + value_map = { + "preset": "--preset", + "formatPreset": "--format-preset", + "fields": "--fields", + "labels": "--labels", + "groupBy": "--group-by", + "sortField": "--sort-field", + "sortAsc": "--sort-asc", + "filter": "--filter", + "fieldDelimiter": "--field-delimiter", + "stringDelimiter": "--string-delimiter", + "refDelimiter": "--ref-delimiter", + "refRangeDelimiter": "--ref-range-delimiter", + } + for key, flag in value_map.items(): + val = params.get(key) + if val is not None and val != "": + cmd += [flag, str(val)] + + flag_map = { + "excludeDnp": "--exclude-dnp", + "includeExcludedFromBom": "--include-excluded-from-bom", + "keepTabs": "--keep-tabs", + "keepLineBreaks": "--keep-line-breaks", + } + for key, flag in flag_map.items(): + if params.get(key): + cmd.append(flag) + + cmd.append(schematic_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + return {"success": True, "outputPath": output_path} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting schematic BOM: {e}") + return {"success": False, "message": str(e)} + def _handle_generate_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]: """Generate netlist from schematic and return structured JSON. diff --git a/src/tools/export.ts b/src/tools/export.ts index afa242a..d1364e9 100644 --- a/src/tools/export.ts +++ b/src/tools/export.ts @@ -1033,5 +1033,78 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF }, ); + // ------------------------------------------------------ + // Export Schematic BOM Tool (kicad-cli, full option set) + // ------------------------------------------------------ + server.tool( + "export_sch_bom", + "Generate a Bill of Materials from a schematic via kicad-cli (`sch export bom`), exposing the full option set (presets, field/label lists, grouping, sorting, filtering, DNP/excluded-from-BOM handling, and field/string/ref delimiters). schematicPath is REQUIRED.", + { + schematicPath: z.string().describe("Path to the .kicad_sch (required)"), + outputPath: z.string().describe("Output BOM file path"), + preset: z + .string() + .optional() + .describe("Named BOM preset from the schematic, e.g. 'Grouped By Value'"), + formatPreset: z + .string() + .optional() + .describe("Named BOM format preset from the schematic, e.g. 'CSV'"), + fields: z + .string() + .optional() + .describe("Ordered comma list of fields to export (supports special substitutions)"), + labels: z + .string() + .optional() + .describe("Ordered comma list of labels to apply to the exported fields"), + groupBy: z + .string() + .optional() + .describe("Fields to group references by when field values match"), + sortField: z.string().optional().describe("Field name to sort by (default Reference)"), + sortAsc: z.string().optional().describe("Sort ascending ('true') or descending ('false')"), + filter: z.string().optional().describe("Filter string to remove output lines"), + excludeDnp: z.boolean().optional().describe("Exclude symbols marked Do-Not-Populate"), + includeExcludedFromBom: z + .boolean() + .optional() + .describe("Include symbols marked 'Exclude from BOM'"), + fieldDelimiter: z + .string() + .optional() + .describe("Separator between output fields/columns (default ',')"), + stringDelimiter: z + .string() + .optional() + .describe("Character to surround fields with (default '\"')"), + refDelimiter: z + .string() + .optional() + .describe("Character between individual references (default ',')"), + refRangeDelimiter: z + .string() + .optional() + .describe("Character for reference ranges; blank disables ranges (default '-')"), + keepTabs: z.boolean().optional().describe("Keep tab characters from input fields"), + keepLineBreaks: z + .boolean() + .optional() + .describe("Keep line break characters from input fields"), + }, + async (args) => { + logger.debug(`Exporting schematic BOM to: ${args.outputPath}`); + const result = await callKicadScript("export_sch_bom", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + logger.info("Export tools registered"); } diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 8441b22..f04a979 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -70,6 +70,7 @@ export const toolCategories: ToolCategory[] = [ "export_pcb_dxf", "export_gerber_single", "export_3d_cli", + "export_sch_bom", "export_pdf", "export_svg", "export_3d", From b257b7276de433b2ef85517f122dfa40b0f27445 Mon Sep 17 00:00:00 2001 From: Gavin Colonese Date: Fri, 12 Jun 2026 10:58:50 -0400 Subject: [PATCH 15/22] feat: add export_sch_pdf tool (kicad-cli, schematic PDF) New MCP tool `export_sch_pdf` wrapping `kicad-cli sch export pdf`: exposes the full option set (drawing-sheet override, theme, B&W, exclude-drawing-sheet, default-font, the PDF property-popup / hierarchical-link / metadata excludes, no-background-color, and page selection). schematicPath is required and validated. Co-Authored-By: Claude Opus 4.8 (1M context) --- python/kicad_interface.py | 82 +++++++++++++++++++++++++++++++++++++++ src/tools/export.ts | 53 +++++++++++++++++++++++++ src/tools/registry.ts | 1 + 3 files changed, 136 insertions(+) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index d892640..b019cd8 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -600,6 +600,7 @@ class KiCADInterface: "export_gerber_single": self._handle_export_gerber_single, "export_3d_cli": self._handle_export_3d_cli, "export_sch_bom": self._handle_export_sch_bom, + "export_sch_pdf": self._handle_export_sch_pdf, "generate_netlist": self._handle_generate_netlist, "sync_schematic_to_board": self._handle_sync_schematic_to_board, "list_schematic_libraries": self._handle_list_schematic_libraries, @@ -5667,6 +5668,87 @@ class KiCADInterface: logger.error(f"Error exporting schematic BOM: {e}") return {"success": False, "message": str(e)} + def _handle_export_sch_pdf(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Export a schematic to PDF via kicad-cli (`sch export pdf`). + + Exposes the full option set: drawing-sheet override, theme, B&W, + exclude-drawing-sheet, default-font, the PDF popup/link/metadata + excludes, no-background-color, and page selection. schematicPath is + required. + """ + import subprocess + + logger.info("Exporting schematic PDF via kicad-cli") + try: + schematic_path = params.get("schematicPath") + output_path = params.get("outputPath") + + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + if not os.path.exists(schematic_path): + return {"success": False, "message": f"Schematic not found: {schematic_path}"} + if not output_path: + return {"success": False, "message": "outputPath is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_path = os.path.abspath(os.path.expanduser(output_path)) + parent = os.path.dirname(output_path) + if parent: + os.makedirs(parent, exist_ok=True) + + cmd = [kicad_cli, "sch", "export", "pdf", "--output", output_path] + + if params.get("drawingSheet"): + cmd += ["--drawing-sheet", params["drawingSheet"]] + for kv in params.get("defineVar", []) or []: + cmd += ["--define-var", kv] + + value_map = { + "theme": "--theme", + "defaultFont": "--default-font", + "pages": "--pages", + } + for key, flag in value_map.items(): + val = params.get(key) + if val is not None and val != "": + cmd += [flag, str(val)] + + flag_map = { + "blackAndWhite": "--black-and-white", + "excludeDrawingSheet": "--exclude-drawing-sheet", + "excludePdfPropertyPopups": "--exclude-pdf-property-popups", + "excludePdfHierarchicalLinks": "--exclude-pdf-hierarchical-links", + "excludePdfMetadata": "--exclude-pdf-metadata", + "noBackgroundColor": "--no-background-color", + } + for key, flag in flag_map.items(): + if params.get(key): + cmd.append(flag) + + cmd.append(schematic_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + return {"success": True, "outputPath": output_path} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting schematic PDF: {e}") + return {"success": False, "message": str(e)} + def _handle_generate_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]: """Generate netlist from schematic and return structured JSON. diff --git a/src/tools/export.ts b/src/tools/export.ts index d1364e9..9653081 100644 --- a/src/tools/export.ts +++ b/src/tools/export.ts @@ -1106,5 +1106,58 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF }, ); + // ------------------------------------------------------ + // Export Schematic PDF Tool (kicad-cli, full option set) + // ------------------------------------------------------ + server.tool( + "export_sch_pdf", + "Export a schematic to PDF via kicad-cli (`sch export pdf`), exposing the full option set (drawing-sheet override, theme, black-and-white, exclude-drawing-sheet, default-font, the PDF property-popup / hierarchical-link / metadata excludes, no-background-color, and page selection). schematicPath is REQUIRED.", + { + schematicPath: z.string().describe("Path to the .kicad_sch (required)"), + outputPath: z.string().describe("Output PDF file path"), + drawingSheet: z.string().optional().describe("Path to a drawing sheet override"), + defineVar: z + .array(z.string()) + .optional() + .describe("Project variable overrides as 'KEY=VALUE' strings"), + theme: z.string().optional().describe("Color theme to use (default: schematic settings)"), + blackAndWhite: z.boolean().optional().describe("Black and white only"), + excludeDrawingSheet: z.boolean().optional().describe("No drawing sheet"), + defaultFont: z.string().optional().describe("Default font name"), + excludePdfPropertyPopups: z + .boolean() + .optional() + .describe("Do not generate property popups in PDF"), + excludePdfHierarchicalLinks: z + .boolean() + .optional() + .describe("Do not generate clickable links for hierarchical elements in PDF"), + excludePdfMetadata: z + .boolean() + .optional() + .describe("Do not generate PDF metadata from AUTHOR and SUBJECT variables"), + noBackgroundColor: z + .boolean() + .optional() + .describe("Avoid setting a background color (regardless of theme)"), + pages: z + .string() + .optional() + .describe("Comma list of page numbers to print (blank = all pages)"), + }, + async (args) => { + logger.debug(`Exporting schematic PDF to: ${args.outputPath}`); + const result = await callKicadScript("export_sch_pdf", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + logger.info("Export tools registered"); } diff --git a/src/tools/registry.ts b/src/tools/registry.ts index f04a979..0ff3617 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -71,6 +71,7 @@ export const toolCategories: ToolCategory[] = [ "export_gerber_single", "export_3d_cli", "export_sch_bom", + "export_sch_pdf", "export_pdf", "export_svg", "export_3d", From 95f803900dc81266614726a737de636fde055c10 Mon Sep 17 00:00:00 2001 From: Gavin Colonese Date: Fri, 12 Jun 2026 10:59:16 -0400 Subject: [PATCH 16/22] feat: add export_sch_svg tool (kicad-cli, schematic SVG) New MCP tool `export_sch_svg` wrapping `kicad-cli sch export svg`: outputs one SVG per page into a directory. Exposes drawing-sheet override, theme, B&W, exclude-drawing-sheet, default-font, no-background-color, and page selection. schematicPath required; directory output uses outputDir. Co-Authored-By: Claude Opus 4.8 (1M context) --- python/kicad_interface.py | 80 +++++++++++++++++++++++++++++++++++++++ src/tools/export.ts | 41 ++++++++++++++++++++ src/tools/registry.ts | 1 + 3 files changed, 122 insertions(+) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index b019cd8..a6f4e0b 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -601,6 +601,7 @@ class KiCADInterface: "export_3d_cli": self._handle_export_3d_cli, "export_sch_bom": self._handle_export_sch_bom, "export_sch_pdf": self._handle_export_sch_pdf, + "export_sch_svg": self._handle_export_sch_svg, "generate_netlist": self._handle_generate_netlist, "sync_schematic_to_board": self._handle_sync_schematic_to_board, "list_schematic_libraries": self._handle_list_schematic_libraries, @@ -5749,6 +5750,85 @@ class KiCADInterface: logger.error(f"Error exporting schematic PDF: {e}") return {"success": False, "message": str(e)} + def _handle_export_sch_svg(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Export a schematic to SVG via kicad-cli (`sch export svg`). + + Output is a directory (one SVG per page). Exposes drawing-sheet override, + theme, B&W, exclude-drawing-sheet, default-font, no-background-color, and + page selection. schematicPath is required. + """ + import subprocess + + logger.info("Exporting schematic SVG via kicad-cli") + try: + schematic_path = params.get("schematicPath") + output_dir = params.get("outputDir") + + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + if not os.path.exists(schematic_path): + return {"success": False, "message": f"Schematic not found: {schematic_path}"} + if not output_dir: + return {"success": False, "message": "outputDir is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_dir = os.path.abspath(os.path.expanduser(output_dir)) + os.makedirs(output_dir, exist_ok=True) + + cmd = [kicad_cli, "sch", "export", "svg", "--output", output_dir] + + if params.get("drawingSheet"): + cmd += ["--drawing-sheet", params["drawingSheet"]] + for kv in params.get("defineVar", []) or []: + cmd += ["--define-var", kv] + + value_map = { + "theme": "--theme", + "defaultFont": "--default-font", + "pages": "--pages", + } + for key, flag in value_map.items(): + val = params.get(key) + if val is not None and val != "": + cmd += [flag, str(val)] + + flag_map = { + "blackAndWhite": "--black-and-white", + "excludeDrawingSheet": "--exclude-drawing-sheet", + "noBackgroundColor": "--no-background-color", + } + for key, flag in flag_map.items(): + if params.get(key): + cmd.append(flag) + + cmd.append(schematic_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + + files = sorted( + f for f in os.listdir(output_dir) if os.path.isfile(os.path.join(output_dir, f)) + ) + return {"success": True, "outputDir": output_dir, "files": files} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting schematic SVG: {e}") + return {"success": False, "message": str(e)} + def _handle_generate_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]: """Generate netlist from schematic and return structured JSON. diff --git a/src/tools/export.ts b/src/tools/export.ts index 9653081..9711cb1 100644 --- a/src/tools/export.ts +++ b/src/tools/export.ts @@ -1159,5 +1159,46 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF }, ); + // ------------------------------------------------------ + // Export Schematic SVG Tool (kicad-cli, full option set) + // ------------------------------------------------------ + server.tool( + "export_sch_svg", + "Export a schematic to SVG via kicad-cli (`sch export svg`), one SVG per page into a directory. Exposes drawing-sheet override, theme, black-and-white, exclude-drawing-sheet, default-font, no-background-color, and page selection. schematicPath is REQUIRED.", + { + schematicPath: z.string().describe("Path to the .kicad_sch (required)"), + outputDir: z.string().describe("Output directory for the SVG files"), + drawingSheet: z.string().optional().describe("Path to a drawing sheet override"), + defineVar: z + .array(z.string()) + .optional() + .describe("Project variable overrides as 'KEY=VALUE' strings"), + theme: z.string().optional().describe("Color theme to use (default: schematic settings)"), + blackAndWhite: z.boolean().optional().describe("Black and white only"), + excludeDrawingSheet: z.boolean().optional().describe("No drawing sheet"), + defaultFont: z.string().optional().describe("Default font name"), + noBackgroundColor: z + .boolean() + .optional() + .describe("Avoid setting a background color (regardless of theme)"), + pages: z + .string() + .optional() + .describe("Comma list of page numbers to print (blank = all pages)"), + }, + async (args) => { + logger.debug(`Exporting schematic SVG to: ${args.outputDir}`); + const result = await callKicadScript("export_sch_svg", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + logger.info("Export tools registered"); } diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 0ff3617..eb45c1e 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -72,6 +72,7 @@ export const toolCategories: ToolCategory[] = [ "export_3d_cli", "export_sch_bom", "export_sch_pdf", + "export_sch_svg", "export_pdf", "export_svg", "export_3d", From cff3addc95f632c57cab074cf603e4919820061c Mon Sep 17 00:00:00 2001 From: Gavin Colonese Date: Fri, 12 Jun 2026 10:59:40 -0400 Subject: [PATCH 17/22] feat: add export_sch_dxf tool (kicad-cli, schematic DXF) New MCP tool `export_sch_dxf` wrapping `kicad-cli sch export dxf`: outputs one DXF per page into a directory. Exposes drawing-sheet override, theme, B&W, exclude-drawing-sheet, default-font, and page selection. schematicPath required; directory output uses outputDir. Co-Authored-By: Claude Opus 4.8 (1M context) --- python/kicad_interface.py | 79 +++++++++++++++++++++++++++++++++++++++ src/tools/export.ts | 37 ++++++++++++++++++ src/tools/registry.ts | 1 + 3 files changed, 117 insertions(+) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index a6f4e0b..45fb782 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -602,6 +602,7 @@ class KiCADInterface: "export_sch_bom": self._handle_export_sch_bom, "export_sch_pdf": self._handle_export_sch_pdf, "export_sch_svg": self._handle_export_sch_svg, + "export_sch_dxf": self._handle_export_sch_dxf, "generate_netlist": self._handle_generate_netlist, "sync_schematic_to_board": self._handle_sync_schematic_to_board, "list_schematic_libraries": self._handle_list_schematic_libraries, @@ -5829,6 +5830,84 @@ class KiCADInterface: logger.error(f"Error exporting schematic SVG: {e}") return {"success": False, "message": str(e)} + def _handle_export_sch_dxf(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Export a schematic to DXF via kicad-cli (`sch export dxf`). + + Output is a directory (one DXF per page). Exposes drawing-sheet override, + theme, B&W, exclude-drawing-sheet, default-font, and page selection. + schematicPath is required. + """ + import subprocess + + logger.info("Exporting schematic DXF via kicad-cli") + try: + schematic_path = params.get("schematicPath") + output_dir = params.get("outputDir") + + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + if not os.path.exists(schematic_path): + return {"success": False, "message": f"Schematic not found: {schematic_path}"} + if not output_dir: + return {"success": False, "message": "outputDir is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_dir = os.path.abspath(os.path.expanduser(output_dir)) + os.makedirs(output_dir, exist_ok=True) + + cmd = [kicad_cli, "sch", "export", "dxf", "--output", output_dir] + + if params.get("drawingSheet"): + cmd += ["--drawing-sheet", params["drawingSheet"]] + for kv in params.get("defineVar", []) or []: + cmd += ["--define-var", kv] + + value_map = { + "theme": "--theme", + "defaultFont": "--default-font", + "pages": "--pages", + } + for key, flag in value_map.items(): + val = params.get(key) + if val is not None and val != "": + cmd += [flag, str(val)] + + flag_map = { + "blackAndWhite": "--black-and-white", + "excludeDrawingSheet": "--exclude-drawing-sheet", + } + for key, flag in flag_map.items(): + if params.get(key): + cmd.append(flag) + + cmd.append(schematic_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + + files = sorted( + f for f in os.listdir(output_dir) if os.path.isfile(os.path.join(output_dir, f)) + ) + return {"success": True, "outputDir": output_dir, "files": files} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting schematic DXF: {e}") + return {"success": False, "message": str(e)} + def _handle_generate_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]: """Generate netlist from schematic and return structured JSON. diff --git a/src/tools/export.ts b/src/tools/export.ts index 9711cb1..afd8e6d 100644 --- a/src/tools/export.ts +++ b/src/tools/export.ts @@ -1200,5 +1200,42 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF }, ); + // ------------------------------------------------------ + // Export Schematic DXF Tool (kicad-cli, full option set) + // ------------------------------------------------------ + server.tool( + "export_sch_dxf", + "Export a schematic to DXF via kicad-cli (`sch export dxf`), one DXF per page into a directory. Exposes drawing-sheet override, theme, black-and-white, exclude-drawing-sheet, default-font, and page selection. schematicPath is REQUIRED.", + { + schematicPath: z.string().describe("Path to the .kicad_sch (required)"), + outputDir: z.string().describe("Output directory for the DXF files"), + drawingSheet: z.string().optional().describe("Path to a drawing sheet override"), + defineVar: z + .array(z.string()) + .optional() + .describe("Project variable overrides as 'KEY=VALUE' strings"), + theme: z.string().optional().describe("Color theme to use (default: schematic settings)"), + blackAndWhite: z.boolean().optional().describe("Black and white only"), + excludeDrawingSheet: z.boolean().optional().describe("No drawing sheet"), + defaultFont: z.string().optional().describe("Default font name"), + pages: z + .string() + .optional() + .describe("Comma list of page numbers to print (blank = all pages)"), + }, + async (args) => { + logger.debug(`Exporting schematic DXF to: ${args.outputDir}`); + const result = await callKicadScript("export_sch_dxf", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + logger.info("Export tools registered"); } diff --git a/src/tools/registry.ts b/src/tools/registry.ts index eb45c1e..ea70989 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -73,6 +73,7 @@ export const toolCategories: ToolCategory[] = [ "export_sch_bom", "export_sch_pdf", "export_sch_svg", + "export_sch_dxf", "export_pdf", "export_svg", "export_3d", From c8935099939457fdbc76972606f984235ffd3e84 Mon Sep 17 00:00:00 2001 From: Gavin Colonese Date: Fri, 12 Jun 2026 11:00:05 -0400 Subject: [PATCH 18/22] feat: add export_sch_hpgl tool (kicad-cli, schematic HPGL) New MCP tool `export_sch_hpgl` wrapping `kicad-cli sch export hpgl`: outputs one plot per page into a directory. Exposes drawing-sheet override, exclude-drawing-sheet, default-font, page selection, pen size, and the origin/scale mode. schematicPath required; directory output uses outputDir. Co-Authored-By: Claude Opus 4.8 (1M context) --- python/kicad_interface.py | 75 +++++++++++++++++++++++++++++++++++++++ src/tools/export.ts | 40 +++++++++++++++++++++ src/tools/registry.ts | 1 + 3 files changed, 116 insertions(+) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 45fb782..2d0614f 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -603,6 +603,7 @@ class KiCADInterface: "export_sch_pdf": self._handle_export_sch_pdf, "export_sch_svg": self._handle_export_sch_svg, "export_sch_dxf": self._handle_export_sch_dxf, + "export_sch_hpgl": self._handle_export_sch_hpgl, "generate_netlist": self._handle_generate_netlist, "sync_schematic_to_board": self._handle_sync_schematic_to_board, "list_schematic_libraries": self._handle_list_schematic_libraries, @@ -5908,6 +5909,80 @@ class KiCADInterface: logger.error(f"Error exporting schematic DXF: {e}") return {"success": False, "message": str(e)} + def _handle_export_sch_hpgl(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Export a schematic to HPGL via kicad-cli (`sch export hpgl`). + + Output is a directory (one plot per page). Exposes drawing-sheet + override, exclude-drawing-sheet, default-font, page selection, pen size, + and the origin/scale mode. schematicPath is required. + """ + import subprocess + + logger.info("Exporting schematic HPGL via kicad-cli") + try: + schematic_path = params.get("schematicPath") + output_dir = params.get("outputDir") + + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + if not os.path.exists(schematic_path): + return {"success": False, "message": f"Schematic not found: {schematic_path}"} + if not output_dir: + return {"success": False, "message": "outputDir is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_dir = os.path.abspath(os.path.expanduser(output_dir)) + os.makedirs(output_dir, exist_ok=True) + + cmd = [kicad_cli, "sch", "export", "hpgl", "--output", output_dir] + + if params.get("drawingSheet"): + cmd += ["--drawing-sheet", params["drawingSheet"]] + for kv in params.get("defineVar", []) or []: + cmd += ["--define-var", kv] + + value_map = { + "defaultFont": "--default-font", + "pages": "--pages", + "penSize": "--pen-size", + "origin": "--origin", + } + for key, flag in value_map.items(): + val = params.get(key) + if val is not None and val != "": + cmd += [flag, str(val)] + + if params.get("excludeDrawingSheet"): + cmd.append("--exclude-drawing-sheet") + + cmd.append(schematic_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + + files = sorted( + f for f in os.listdir(output_dir) if os.path.isfile(os.path.join(output_dir, f)) + ) + return {"success": True, "outputDir": output_dir, "files": files} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting schematic HPGL: {e}") + return {"success": False, "message": str(e)} + def _handle_generate_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]: """Generate netlist from schematic and return structured JSON. diff --git a/src/tools/export.ts b/src/tools/export.ts index afd8e6d..856b6aa 100644 --- a/src/tools/export.ts +++ b/src/tools/export.ts @@ -1237,5 +1237,45 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF }, ); + // ------------------------------------------------------ + // Export Schematic HPGL Tool (kicad-cli, full option set) + // ------------------------------------------------------ + server.tool( + "export_sch_hpgl", + "Export a schematic to HPGL via kicad-cli (`sch export hpgl`), one plot per page into a directory. Exposes drawing-sheet override, exclude-drawing-sheet, default-font, page selection, pen size, and the origin/scale mode. schematicPath is REQUIRED.", + { + schematicPath: z.string().describe("Path to the .kicad_sch (required)"), + outputDir: z.string().describe("Output directory for the HPGL files"), + drawingSheet: z.string().optional().describe("Path to a drawing sheet override"), + defineVar: z + .array(z.string()) + .optional() + .describe("Project variable overrides as 'KEY=VALUE' strings"), + excludeDrawingSheet: z.boolean().optional().describe("No drawing sheet"), + defaultFont: z.string().optional().describe("Default font name"), + pages: z + .string() + .optional() + .describe("Comma list of page numbers to print (blank = all pages)"), + penSize: z.number().optional().describe("Pen size in mm (default 0.5)"), + origin: z + .number() + .optional() + .describe("Origin and scale: 0 bottom left, 1 centered, 2 page fit, 3 content fit"), + }, + async (args) => { + logger.debug(`Exporting schematic HPGL to: ${args.outputDir}`); + const result = await callKicadScript("export_sch_hpgl", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + logger.info("Export tools registered"); } diff --git a/src/tools/registry.ts b/src/tools/registry.ts index ea70989..982412c 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -74,6 +74,7 @@ export const toolCategories: ToolCategory[] = [ "export_sch_pdf", "export_sch_svg", "export_sch_dxf", + "export_sch_hpgl", "export_pdf", "export_svg", "export_3d", From 91477e8d02b61a9d00d6086b88d57172005ea8ff Mon Sep 17 00:00:00 2001 From: Gavin Colonese Date: Fri, 12 Jun 2026 11:00:31 -0400 Subject: [PATCH 19/22] feat: add export_sch_ps tool (kicad-cli, schematic PostScript) New MCP tool `export_sch_ps` wrapping `kicad-cli sch export ps`: outputs one PS per page into a directory. Exposes drawing-sheet override, theme, B&W, exclude-drawing-sheet, default-font, no-background-color, and page selection. schematicPath required; directory output uses outputDir. Co-Authored-By: Claude Opus 4.8 (1M context) --- python/kicad_interface.py | 80 +++++++++++++++++++++++++++++++++++++++ src/tools/export.ts | 41 ++++++++++++++++++++ src/tools/registry.ts | 1 + 3 files changed, 122 insertions(+) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 2d0614f..8b77697 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -604,6 +604,7 @@ class KiCADInterface: "export_sch_svg": self._handle_export_sch_svg, "export_sch_dxf": self._handle_export_sch_dxf, "export_sch_hpgl": self._handle_export_sch_hpgl, + "export_sch_ps": self._handle_export_sch_ps, "generate_netlist": self._handle_generate_netlist, "sync_schematic_to_board": self._handle_sync_schematic_to_board, "list_schematic_libraries": self._handle_list_schematic_libraries, @@ -5983,6 +5984,85 @@ class KiCADInterface: logger.error(f"Error exporting schematic HPGL: {e}") return {"success": False, "message": str(e)} + def _handle_export_sch_ps(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Export a schematic to PostScript via kicad-cli (`sch export ps`). + + Output is a directory (one PS per page). Exposes drawing-sheet override, + theme, B&W, exclude-drawing-sheet, default-font, no-background-color, and + page selection. schematicPath is required. + """ + import subprocess + + logger.info("Exporting schematic PostScript via kicad-cli") + try: + schematic_path = params.get("schematicPath") + output_dir = params.get("outputDir") + + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + if not os.path.exists(schematic_path): + return {"success": False, "message": f"Schematic not found: {schematic_path}"} + if not output_dir: + return {"success": False, "message": "outputDir is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_dir = os.path.abspath(os.path.expanduser(output_dir)) + os.makedirs(output_dir, exist_ok=True) + + cmd = [kicad_cli, "sch", "export", "ps", "--output", output_dir] + + if params.get("drawingSheet"): + cmd += ["--drawing-sheet", params["drawingSheet"]] + for kv in params.get("defineVar", []) or []: + cmd += ["--define-var", kv] + + value_map = { + "theme": "--theme", + "defaultFont": "--default-font", + "pages": "--pages", + } + for key, flag in value_map.items(): + val = params.get(key) + if val is not None and val != "": + cmd += [flag, str(val)] + + flag_map = { + "blackAndWhite": "--black-and-white", + "excludeDrawingSheet": "--exclude-drawing-sheet", + "noBackgroundColor": "--no-background-color", + } + for key, flag in flag_map.items(): + if params.get(key): + cmd.append(flag) + + cmd.append(schematic_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + + files = sorted( + f for f in os.listdir(output_dir) if os.path.isfile(os.path.join(output_dir, f)) + ) + return {"success": True, "outputDir": output_dir, "files": files} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting schematic PostScript: {e}") + return {"success": False, "message": str(e)} + def _handle_generate_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]: """Generate netlist from schematic and return structured JSON. diff --git a/src/tools/export.ts b/src/tools/export.ts index 856b6aa..d5b5dfd 100644 --- a/src/tools/export.ts +++ b/src/tools/export.ts @@ -1277,5 +1277,46 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF }, ); + // ------------------------------------------------------ + // Export Schematic PostScript Tool (kicad-cli, full option set) + // ------------------------------------------------------ + server.tool( + "export_sch_ps", + "Export a schematic to PostScript via kicad-cli (`sch export ps`), one PS per page into a directory. Exposes drawing-sheet override, theme, black-and-white, exclude-drawing-sheet, default-font, no-background-color, and page selection. schematicPath is REQUIRED.", + { + schematicPath: z.string().describe("Path to the .kicad_sch (required)"), + outputDir: z.string().describe("Output directory for the PostScript files"), + drawingSheet: z.string().optional().describe("Path to a drawing sheet override"), + defineVar: z + .array(z.string()) + .optional() + .describe("Project variable overrides as 'KEY=VALUE' strings"), + theme: z.string().optional().describe("Color theme to use (default: schematic settings)"), + blackAndWhite: z.boolean().optional().describe("Black and white only"), + excludeDrawingSheet: z.boolean().optional().describe("No drawing sheet"), + defaultFont: z.string().optional().describe("Default font name"), + noBackgroundColor: z + .boolean() + .optional() + .describe("Avoid setting a background color (regardless of theme)"), + pages: z + .string() + .optional() + .describe("Comma list of page numbers to print (blank = all pages)"), + }, + async (args) => { + logger.debug(`Exporting schematic PostScript to: ${args.outputDir}`); + const result = await callKicadScript("export_sch_ps", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + logger.info("Export tools registered"); } diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 982412c..4c8bd19 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -75,6 +75,7 @@ export const toolCategories: ToolCategory[] = [ "export_sch_svg", "export_sch_dxf", "export_sch_hpgl", + "export_sch_ps", "export_pdf", "export_svg", "export_3d", From d48f4835b073607d1a47a9b813d5d96216f50b44 Mon Sep 17 00:00:00 2001 From: Gavin Colonese Date: Fri, 12 Jun 2026 11:00:57 -0400 Subject: [PATCH 20/22] feat: add export_sch_python_bom tool (kicad-cli, legacy Python-BOM XML) New MCP tool `export_sch_python_bom` wrapping `kicad-cli sch export python-bom`: emits the legacy intermediate XML netlist consumed by the schematic editor's Python BOM scripts. Minimal option set (outputPath + schematicPath only, matching the subcommand's --help). schematicPath required and validated. Co-Authored-By: Claude Opus 4.8 (1M context) --- python/kicad_interface.py | 54 +++++++++++++++++++++++++++++++++++++++ src/tools/export.ts | 24 +++++++++++++++++ src/tools/registry.ts | 1 + 3 files changed, 79 insertions(+) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 8b77697..3273430 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -605,6 +605,7 @@ class KiCADInterface: "export_sch_dxf": self._handle_export_sch_dxf, "export_sch_hpgl": self._handle_export_sch_hpgl, "export_sch_ps": self._handle_export_sch_ps, + "export_sch_python_bom": self._handle_export_sch_python_bom, "generate_netlist": self._handle_generate_netlist, "sync_schematic_to_board": self._handle_sync_schematic_to_board, "list_schematic_libraries": self._handle_list_schematic_libraries, @@ -6063,6 +6064,59 @@ class KiCADInterface: logger.error(f"Error exporting schematic PostScript: {e}") return {"success": False, "message": str(e)} + def _handle_export_sch_python_bom(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Export the legacy Python-BOM XML from a schematic via kicad-cli + (`sch export python-bom`). + + Emits the legacy intermediate XML netlist consumed by the schematic + editor's Python BOM scripts. Minimal option set (output + input only). + schematicPath is required. + """ + import subprocess + + logger.info("Exporting schematic Python-BOM via kicad-cli") + try: + schematic_path = params.get("schematicPath") + output_path = params.get("outputPath") + + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + if not os.path.exists(schematic_path): + return {"success": False, "message": f"Schematic not found: {schematic_path}"} + if not output_path: + return {"success": False, "message": "outputPath is required"} + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found in PATH"} + + output_path = os.path.abspath(os.path.expanduser(output_path)) + parent = os.path.dirname(output_path) + if parent: + os.makedirs(parent, exist_ok=True) + + cmd = [kicad_cli, "sch", "export", "python-bom", "--output", output_path] + cmd.append(schematic_path) + + logger.info(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed (exit {result.returncode}): " + f"{result.stderr.strip()}", + } + return {"success": True, "outputPath": output_path} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except subprocess.TimeoutExpired: + return {"success": False, "message": "kicad-cli timed out after 180 seconds"} + except Exception as e: + logger.error(f"Error exporting schematic Python-BOM: {e}") + return {"success": False, "message": str(e)} + def _handle_generate_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]: """Generate netlist from schematic and return structured JSON. diff --git a/src/tools/export.ts b/src/tools/export.ts index d5b5dfd..955d1c2 100644 --- a/src/tools/export.ts +++ b/src/tools/export.ts @@ -1318,5 +1318,29 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF }, ); + // ------------------------------------------------------ + // Export Schematic Python-BOM Tool (kicad-cli) + // ------------------------------------------------------ + server.tool( + "export_sch_python_bom", + "Export the legacy Python-BOM intermediate XML from a schematic via kicad-cli (`sch export python-bom`). This is the XML netlist consumed by the schematic editor's Python BOM scripts. Minimal option set (output + input only). schematicPath is REQUIRED.", + { + schematicPath: z.string().describe("Path to the .kicad_sch (required)"), + outputPath: z.string().describe("Output XML file path"), + }, + async (args) => { + logger.debug(`Exporting schematic Python-BOM to: ${args.outputPath}`); + const result = await callKicadScript("export_sch_python_bom", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + logger.info("Export tools registered"); } diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 4c8bd19..b0e76ab 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -76,6 +76,7 @@ export const toolCategories: ToolCategory[] = [ "export_sch_dxf", "export_sch_hpgl", "export_sch_ps", + "export_sch_python_bom", "export_pdf", "export_svg", "export_3d", From 605dac7fd322689d254102422f2170a38ad899df Mon Sep 17 00:00:00 2001 From: Gavin Colonese Date: Fri, 12 Jun 2026 11:06:24 -0400 Subject: [PATCH 21/22] test: add unit coverage for kicad-cli export handlers Covers all 19 new _handle_export_* handlers: parametrized success / kicad-cli-not-found / subprocess-failure paths, plus validation (missing output, unresolvable board, missing schematicPath) and detailed flag-construction assertions for gerbers/drill/ipc2581. Subprocess + filesystem are mocked; no real kicad-cli or board touched. 64 tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_export_cli.py | 176 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 tests/test_export_cli.py diff --git a/tests/test_export_cli.py b/tests/test_export_cli.py new file mode 100644 index 0000000..3cf027a --- /dev/null +++ b/tests/test_export_cli.py @@ -0,0 +1,176 @@ +"""Unit tests for the kicad-cli-backed export handlers on ``KiCADInterface``. + +These handlers (``_handle_export_*``) shell out to ``kicad-cli``. The tests mock +the subprocess call and the filesystem, then assert command construction, +validation, and error handling. No real ``kicad-cli`` binary, board, or +schematic is touched. + +conftest.py pre-installs a MagicMock for ``pcbnew`` in sys.modules so +kicad_interface imports without a real KiCAD install. +""" + +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "python")) + +pytestmark = pytest.mark.unit + +from kicad_interface import KiCADInterface # noqa: E402 + +# (handler suffix, minimal params) — correct output key per handler, schematicPath for sch tools +PCB_HANDLERS = [ + ("gerbers", {"outputDir": "/out"}), + ("drill", {"outputDir": "/out"}), + ("ipc2581", {"outputPath": "/out/x.xml"}), + ("odb", {"outputPath": "/out/x.zip"}), + ("ipcd356", {"outputPath": "/out/x.d356"}), + ("gencad", {"outputPath": "/out/x.cad"}), + ("pos", {"outputPath": "/out/x.pos"}), + ("pcb_pdf", {"outputPath": "/out/x.pdf"}), + ("pcb_svg", {"outputPath": "/out/x.svg"}), + ("pcb_dxf", {"outputPath": "/out/x.dxf"}), + ("gerber_single", {"outputPath": "/out/x.gbr", "layers": ["F.Cu"]}), + ("3d_cli", {"outputPath": "/out/x.step", "format": "step"}), +] +SCH_HANDLERS = [ + ("sch_bom", {"outputPath": "/out/b.csv", "schematicPath": "/p/x.kicad_sch"}), + ("sch_pdf", {"outputPath": "/out/b.pdf", "schematicPath": "/p/x.kicad_sch"}), + ("sch_python_bom", {"outputPath": "/out/b.xml", "schematicPath": "/p/x.kicad_sch"}), + ("sch_svg", {"outputDir": "/out", "schematicPath": "/p/x.kicad_sch"}), + ("sch_dxf", {"outputDir": "/out", "schematicPath": "/p/x.kicad_sch"}), + ("sch_hpgl", {"outputDir": "/out", "schematicPath": "/p/x.kicad_sch"}), + ("sch_ps", {"outputDir": "/out", "schematicPath": "/p/x.kicad_sch"}), +] +ALL_HANDLERS = PCB_HANDLERS + SCH_HANDLERS + + +def _make_iface(): + """KiCADInterface instance with the cli/board-path helpers mocked.""" + iface = KiCADInterface.__new__(KiCADInterface) + iface.board = None + iface._find_kicad_cli_static = MagicMock(return_value="kicad-cli") + iface._current_board_path = MagicMock(return_value="/proj/board.kicad_pcb") + return iface + + +def _call(iface, suffix, params, rc=0, stderr=""): + """Invoke a handler with subprocess + filesystem mocked. Returns (result, run_mock).""" + method = getattr(iface, f"_handle_export_{suffix}") + fake = SimpleNamespace(returncode=rc, stdout="", stderr=stderr) + with ( + patch("subprocess.run", return_value=fake) as run, + patch("os.path.exists", return_value=True), + patch("os.makedirs"), + patch("os.listdir", return_value=["a.out"]), + patch("os.path.isfile", return_value=True), + ): + result = method(dict(params)) + return result, run + + +class TestAllHandlers: + @pytest.mark.parametrize("suffix,params", ALL_HANDLERS) + def test_success_invokes_kicad_cli(self, suffix, params): + iface = _make_iface() + result, run = _call(iface, suffix, params, rc=0) + assert result["success"] is True, result + run.assert_called_once() + cmd = run.call_args.args[0] + assert cmd[0] == "kicad-cli" + assert cmd[1] in ("pcb", "sch") + assert cmd[2] == "export" + + @pytest.mark.parametrize("suffix,params", ALL_HANDLERS) + def test_cli_not_found(self, suffix, params): + iface = _make_iface() + iface._find_kicad_cli_static = MagicMock(return_value=None) + result, _ = _call(iface, suffix, params) + assert result["success"] is False + assert "kicad-cli" in result["message"].lower() + + @pytest.mark.parametrize("suffix,params", ALL_HANDLERS) + def test_subprocess_failure_propagates(self, suffix, params): + iface = _make_iface() + result, _ = _call(iface, suffix, params, rc=1, stderr="boom") + assert result["success"] is False + + +class TestValidation: + def test_missing_output_errors(self): + iface = _make_iface() + result, _ = _call(iface, "gerbers", {}) + assert result["success"] is False + + def test_pcb_no_board_resolvable_errors(self): + iface = _make_iface() + iface._current_board_path = MagicMock(return_value=None) + result, _ = _call(iface, "ipc2581", {"outputPath": "/o/x.xml"}) + assert result["success"] is False + + def test_sch_missing_schematic_errors(self): + iface = _make_iface() + result, _ = _call(iface, "sch_bom", {"outputPath": "/o/b.csv"}) + assert result["success"] is False + + +class TestFlagConstruction: + """Detailed flag mapping for the originally-authored handlers.""" + + def test_gerbers_flags(self): + iface = _make_iface() + _, run = _call( + iface, + "gerbers", + { + "outputDir": "/out", + "layers": ["F.Cu", "B.Cu"], + "subtractSoldermask": True, + "noX2": True, + "precision": 5, + }, + ) + cmd = run.call_args.args[0] + assert "--subtract-soldermask" in cmd + assert "--no-x2" in cmd + assert "--layers" in cmd + assert "F.Cu,B.Cu" in cmd + assert cmd[cmd.index("--precision") + 1] == "5" + + def test_gerbers_omitted_flags_absent(self): + iface = _make_iface() + _, run = _call(iface, "gerbers", {"outputDir": "/out"}) + cmd = run.call_args.args[0] + assert "--no-x2" not in cmd + assert "--subtract-soldermask" not in cmd + + def test_drill_flags(self): + iface = _make_iface() + _, run = _call( + iface, + "drill", + { + "outputDir": "/out", + "format": "gerber", + "excellonSeparateTh": True, + "generateMap": True, + }, + ) + cmd = run.call_args.args[0] + assert "--excellon-separate-th" in cmd + assert "--generate-map" in cmd + assert cmd[cmd.index("--format") + 1] == "gerber" + + def test_ipc2581_bom_column_mapping(self): + iface = _make_iface() + _, run = _call( + iface, + "ipc2581", + {"outputPath": "/o/x.xml", "bomColIntId": "FAST P/N"}, + ) + cmd = run.call_args.args[0] + assert cmd[cmd.index("--bom-col-int-id") + 1] == "FAST P/N" From 59aed24d05ab7fc83a8772ef40ae8bba3d450a74 Mon Sep 17 00:00:00 2001 From: Gavin Colonese Date: Fri, 12 Jun 2026 11:17:57 -0400 Subject: [PATCH 22/22] refactor: use pathlib for file paths in new export handlers CONTRIBUTING.md mandates pathlib.Path over os.path (added for cross-platform/Linux support); the rest of the codebase's newer code already follows it. Converts the 19 new _handle_export_* handlers to pathlib (exists/mkdir/iterdir/is_file, expanduser().resolve()) so this feature's new code is compliant. Tests updated to patch pathlib.Path. Scope is limited to the new export-handler block; the pre-existing os.path usage elsewhere in kicad_interface.py/export.py is untouched here and will be migrated in a dedicated refactor PR to keep that sweep reviewable in isolation. Co-Authored-By: Claude Opus 4.8 (1M context) --- python/kicad_interface.py | 162 ++++++++++++++++++-------------------- tests/test_export_cli.py | 8 +- 2 files changed, 79 insertions(+), 91 deletions(-) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 3273430..e4219d5 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -4534,7 +4534,7 @@ class KiCADInterface: "success": False, "message": "boardPath is required (no current board could be resolved)", } - if not os.path.exists(board_path): + if not Path(board_path).exists(): return {"success": False, "message": f"Board not found: {board_path}"} if not output_dir: return {"success": False, "message": "outputDir is required"} @@ -4543,8 +4543,8 @@ class KiCADInterface: if not kicad_cli: return {"success": False, "message": "kicad-cli not found in PATH"} - output_dir = os.path.abspath(os.path.expanduser(output_dir)) - os.makedirs(output_dir, exist_ok=True) + output_dir = str(Path(output_dir).expanduser().resolve()) + Path(output_dir).mkdir(parents=True, exist_ok=True) cmd = [kicad_cli, "pcb", "export", "gerbers", "--output", output_dir] @@ -4605,9 +4605,7 @@ class KiCADInterface: f"{result.stderr.strip()}", } - files = sorted( - f for f in os.listdir(output_dir) if os.path.isfile(os.path.join(output_dir, f)) - ) + files = sorted(p.name for p in Path(output_dir).iterdir() if p.is_file()) return {"success": True, "outputDir": output_dir, "files": files} except FileNotFoundError: @@ -4636,7 +4634,7 @@ class KiCADInterface: "success": False, "message": "boardPath is required (no current board could be resolved)", } - if not os.path.exists(board_path): + if not Path(board_path).exists(): return {"success": False, "message": f"Board not found: {board_path}"} if not output_dir: return {"success": False, "message": "outputDir is required"} @@ -4645,9 +4643,9 @@ class KiCADInterface: if not kicad_cli: return {"success": False, "message": "kicad-cli not found in PATH"} - output_dir = os.path.abspath(os.path.expanduser(output_dir)) + output_dir = str(Path(output_dir).expanduser().resolve()) # kicad-cli drill requires the output dir path to end with a separator - os.makedirs(output_dir, exist_ok=True) + Path(output_dir).mkdir(parents=True, exist_ok=True) cmd = [kicad_cli, "pcb", "export", "drill", "--output", output_dir + os.sep] @@ -4689,9 +4687,7 @@ class KiCADInterface: f"{result.stderr.strip()}", } - files = sorted( - f for f in os.listdir(output_dir) if os.path.isfile(os.path.join(output_dir, f)) - ) + files = sorted(p.name for p in Path(output_dir).iterdir() if p.is_file()) return {"success": True, "outputDir": output_dir, "files": files} except FileNotFoundError: @@ -4721,7 +4717,7 @@ class KiCADInterface: "success": False, "message": "boardPath is required (no current board could be resolved)", } - if not os.path.exists(board_path): + if not Path(board_path).exists(): return {"success": False, "message": f"Board not found: {board_path}"} if not output_path: return {"success": False, "message": "outputPath is required"} @@ -4730,8 +4726,8 @@ class KiCADInterface: if not kicad_cli: return {"success": False, "message": "kicad-cli not found in PATH"} - output_path = os.path.abspath(os.path.expanduser(output_path)) - os.makedirs(os.path.dirname(output_path), exist_ok=True) + output_path = str(Path(output_path).expanduser().resolve()) + Path(output_path).parent.mkdir(parents=True, exist_ok=True) cmd = [kicad_cli, "pcb", "export", "ipc2581", "--output", output_path] @@ -4797,7 +4793,7 @@ class KiCADInterface: "success": False, "message": "boardPath is required (no current board could be resolved)", } - if not os.path.exists(board_path): + if not Path(board_path).exists(): return {"success": False, "message": f"Board not found: {board_path}"} if not output_path: return {"success": False, "message": "outputPath is required"} @@ -4806,10 +4802,10 @@ class KiCADInterface: if not kicad_cli: return {"success": False, "message": "kicad-cli not found in PATH"} - output_path = os.path.abspath(os.path.expanduser(output_path)) - parent = os.path.dirname(output_path) + output_path = str(Path(output_path).expanduser().resolve()) + parent = Path(output_path).parent if parent: - os.makedirs(parent, exist_ok=True) + Path(parent).mkdir(parents=True, exist_ok=True) cmd = [kicad_cli, "pcb", "export", "odb", "--output", output_path] @@ -4866,7 +4862,7 @@ class KiCADInterface: "success": False, "message": "boardPath is required (no current board could be resolved)", } - if not os.path.exists(board_path): + if not Path(board_path).exists(): return {"success": False, "message": f"Board not found: {board_path}"} if not output_path: return {"success": False, "message": "outputPath is required"} @@ -4875,10 +4871,10 @@ class KiCADInterface: if not kicad_cli: return {"success": False, "message": "kicad-cli not found in PATH"} - output_path = os.path.abspath(os.path.expanduser(output_path)) - parent = os.path.dirname(output_path) + output_path = str(Path(output_path).expanduser().resolve()) + parent = Path(output_path).parent if parent: - os.makedirs(parent, exist_ok=True) + Path(parent).mkdir(parents=True, exist_ok=True) cmd = [kicad_cli, "pcb", "export", "ipcd356", "--output", output_path] cmd.append(board_path) @@ -4921,7 +4917,7 @@ class KiCADInterface: "success": False, "message": "boardPath is required (no current board could be resolved)", } - if not os.path.exists(board_path): + if not Path(board_path).exists(): return {"success": False, "message": f"Board not found: {board_path}"} if not output_path: return {"success": False, "message": "outputPath is required"} @@ -4930,10 +4926,10 @@ class KiCADInterface: if not kicad_cli: return {"success": False, "message": "kicad-cli not found in PATH"} - output_path = os.path.abspath(os.path.expanduser(output_path)) - parent = os.path.dirname(output_path) + output_path = str(Path(output_path).expanduser().resolve()) + parent = Path(output_path).parent if parent: - os.makedirs(parent, exist_ok=True) + Path(parent).mkdir(parents=True, exist_ok=True) cmd = [kicad_cli, "pcb", "export", "gencad", "--output", output_path] @@ -4992,7 +4988,7 @@ class KiCADInterface: "success": False, "message": "boardPath is required (no current board could be resolved)", } - if not os.path.exists(board_path): + if not Path(board_path).exists(): return {"success": False, "message": f"Board not found: {board_path}"} if not output_path: return {"success": False, "message": "outputPath is required"} @@ -5001,10 +4997,10 @@ class KiCADInterface: if not kicad_cli: return {"success": False, "message": "kicad-cli not found in PATH"} - output_path = os.path.abspath(os.path.expanduser(output_path)) - parent = os.path.dirname(output_path) + output_path = str(Path(output_path).expanduser().resolve()) + parent = Path(output_path).parent if parent: - os.makedirs(parent, exist_ok=True) + Path(parent).mkdir(parents=True, exist_ok=True) cmd = [kicad_cli, "pcb", "export", "pos", "--output", output_path] @@ -5071,7 +5067,7 @@ class KiCADInterface: "success": False, "message": "boardPath is required (no current board could be resolved)", } - if not os.path.exists(board_path): + if not Path(board_path).exists(): return {"success": False, "message": f"Board not found: {board_path}"} if not output_path: return {"success": False, "message": "outputPath is required"} @@ -5080,10 +5076,10 @@ class KiCADInterface: if not kicad_cli: return {"success": False, "message": "kicad-cli not found in PATH"} - output_path = os.path.abspath(os.path.expanduser(output_path)) - parent = os.path.dirname(output_path) + output_path = str(Path(output_path).expanduser().resolve()) + parent = Path(output_path).parent if parent: - os.makedirs(parent, exist_ok=True) + Path(parent).mkdir(parents=True, exist_ok=True) cmd = [kicad_cli, "pcb", "export", "pdf", "--output", output_path] @@ -5176,7 +5172,7 @@ class KiCADInterface: "success": False, "message": "boardPath is required (no current board could be resolved)", } - if not os.path.exists(board_path): + if not Path(board_path).exists(): return {"success": False, "message": f"Board not found: {board_path}"} if not output_path: return {"success": False, "message": "outputPath is required"} @@ -5185,10 +5181,10 @@ class KiCADInterface: if not kicad_cli: return {"success": False, "message": "kicad-cli not found in PATH"} - output_path = os.path.abspath(os.path.expanduser(output_path)) - parent = os.path.dirname(output_path) + output_path = str(Path(output_path).expanduser().resolve()) + parent = Path(output_path).parent if parent: - os.makedirs(parent, exist_ok=True) + Path(parent).mkdir(parents=True, exist_ok=True) cmd = [kicad_cli, "pcb", "export", "svg", "--output", output_path] @@ -5280,7 +5276,7 @@ class KiCADInterface: "success": False, "message": "boardPath is required (no current board could be resolved)", } - if not os.path.exists(board_path): + if not Path(board_path).exists(): return {"success": False, "message": f"Board not found: {board_path}"} if not output_path: return {"success": False, "message": "outputPath is required"} @@ -5289,10 +5285,10 @@ class KiCADInterface: if not kicad_cli: return {"success": False, "message": "kicad-cli not found in PATH"} - output_path = os.path.abspath(os.path.expanduser(output_path)) - parent = os.path.dirname(output_path) + output_path = str(Path(output_path).expanduser().resolve()) + parent = Path(output_path).parent if parent: - os.makedirs(parent, exist_ok=True) + Path(parent).mkdir(parents=True, exist_ok=True) cmd = [kicad_cli, "pcb", "export", "dxf", "--output", output_path] @@ -5385,7 +5381,7 @@ class KiCADInterface: "success": False, "message": "boardPath is required (no current board could be resolved)", } - if not os.path.exists(board_path): + if not Path(board_path).exists(): return {"success": False, "message": f"Board not found: {board_path}"} if not output_path: return {"success": False, "message": "outputPath is required"} @@ -5394,10 +5390,10 @@ class KiCADInterface: if not kicad_cli: return {"success": False, "message": "kicad-cli not found in PATH"} - output_path = os.path.abspath(os.path.expanduser(output_path)) - parent = os.path.dirname(output_path) + output_path = str(Path(output_path).expanduser().resolve()) + parent = Path(output_path).parent if parent: - os.makedirs(parent, exist_ok=True) + Path(parent).mkdir(parents=True, exist_ok=True) cmd = [kicad_cli, "pcb", "export", "gerber", "--output", output_path] @@ -5486,7 +5482,7 @@ class KiCADInterface: "success": False, "message": "boardPath is required (no current board could be resolved)", } - if not os.path.exists(board_path): + if not Path(board_path).exists(): return {"success": False, "message": f"Board not found: {board_path}"} if not output_path: return {"success": False, "message": "outputPath is required"} @@ -5501,10 +5497,10 @@ class KiCADInterface: if not kicad_cli: return {"success": False, "message": "kicad-cli not found in PATH"} - output_path = os.path.abspath(os.path.expanduser(output_path)) - parent = os.path.dirname(output_path) + output_path = str(Path(output_path).expanduser().resolve()) + parent = Path(output_path).parent if parent: - os.makedirs(parent, exist_ok=True) + Path(parent).mkdir(parents=True, exist_ok=True) cmd = [kicad_cli, "pcb", "export", fmt, "--output", output_path] @@ -5607,7 +5603,7 @@ class KiCADInterface: if not schematic_path: return {"success": False, "message": "schematicPath is required"} - if not os.path.exists(schematic_path): + if not Path(schematic_path).exists(): return {"success": False, "message": f"Schematic not found: {schematic_path}"} if not output_path: return {"success": False, "message": "outputPath is required"} @@ -5616,10 +5612,10 @@ class KiCADInterface: if not kicad_cli: return {"success": False, "message": "kicad-cli not found in PATH"} - output_path = os.path.abspath(os.path.expanduser(output_path)) - parent = os.path.dirname(output_path) + output_path = str(Path(output_path).expanduser().resolve()) + parent = Path(output_path).parent if parent: - os.makedirs(parent, exist_ok=True) + Path(parent).mkdir(parents=True, exist_ok=True) cmd = [kicad_cli, "sch", "export", "bom", "--output", output_path] @@ -5690,7 +5686,7 @@ class KiCADInterface: if not schematic_path: return {"success": False, "message": "schematicPath is required"} - if not os.path.exists(schematic_path): + if not Path(schematic_path).exists(): return {"success": False, "message": f"Schematic not found: {schematic_path}"} if not output_path: return {"success": False, "message": "outputPath is required"} @@ -5699,10 +5695,10 @@ class KiCADInterface: if not kicad_cli: return {"success": False, "message": "kicad-cli not found in PATH"} - output_path = os.path.abspath(os.path.expanduser(output_path)) - parent = os.path.dirname(output_path) + output_path = str(Path(output_path).expanduser().resolve()) + parent = Path(output_path).parent if parent: - os.makedirs(parent, exist_ok=True) + Path(parent).mkdir(parents=True, exist_ok=True) cmd = [kicad_cli, "sch", "export", "pdf", "--output", output_path] @@ -5770,7 +5766,7 @@ class KiCADInterface: if not schematic_path: return {"success": False, "message": "schematicPath is required"} - if not os.path.exists(schematic_path): + if not Path(schematic_path).exists(): return {"success": False, "message": f"Schematic not found: {schematic_path}"} if not output_dir: return {"success": False, "message": "outputDir is required"} @@ -5779,8 +5775,8 @@ class KiCADInterface: if not kicad_cli: return {"success": False, "message": "kicad-cli not found in PATH"} - output_dir = os.path.abspath(os.path.expanduser(output_dir)) - os.makedirs(output_dir, exist_ok=True) + output_dir = str(Path(output_dir).expanduser().resolve()) + Path(output_dir).mkdir(parents=True, exist_ok=True) cmd = [kicad_cli, "sch", "export", "svg", "--output", output_dir] @@ -5820,9 +5816,7 @@ class KiCADInterface: f"{result.stderr.strip()}", } - files = sorted( - f for f in os.listdir(output_dir) if os.path.isfile(os.path.join(output_dir, f)) - ) + files = sorted(p.name for p in Path(output_dir).iterdir() if p.is_file()) return {"success": True, "outputDir": output_dir, "files": files} except FileNotFoundError: @@ -5849,7 +5843,7 @@ class KiCADInterface: if not schematic_path: return {"success": False, "message": "schematicPath is required"} - if not os.path.exists(schematic_path): + if not Path(schematic_path).exists(): return {"success": False, "message": f"Schematic not found: {schematic_path}"} if not output_dir: return {"success": False, "message": "outputDir is required"} @@ -5858,8 +5852,8 @@ class KiCADInterface: if not kicad_cli: return {"success": False, "message": "kicad-cli not found in PATH"} - output_dir = os.path.abspath(os.path.expanduser(output_dir)) - os.makedirs(output_dir, exist_ok=True) + output_dir = str(Path(output_dir).expanduser().resolve()) + Path(output_dir).mkdir(parents=True, exist_ok=True) cmd = [kicad_cli, "sch", "export", "dxf", "--output", output_dir] @@ -5898,9 +5892,7 @@ class KiCADInterface: f"{result.stderr.strip()}", } - files = sorted( - f for f in os.listdir(output_dir) if os.path.isfile(os.path.join(output_dir, f)) - ) + files = sorted(p.name for p in Path(output_dir).iterdir() if p.is_file()) return {"success": True, "outputDir": output_dir, "files": files} except FileNotFoundError: @@ -5927,7 +5919,7 @@ class KiCADInterface: if not schematic_path: return {"success": False, "message": "schematicPath is required"} - if not os.path.exists(schematic_path): + if not Path(schematic_path).exists(): return {"success": False, "message": f"Schematic not found: {schematic_path}"} if not output_dir: return {"success": False, "message": "outputDir is required"} @@ -5936,8 +5928,8 @@ class KiCADInterface: if not kicad_cli: return {"success": False, "message": "kicad-cli not found in PATH"} - output_dir = os.path.abspath(os.path.expanduser(output_dir)) - os.makedirs(output_dir, exist_ok=True) + output_dir = str(Path(output_dir).expanduser().resolve()) + Path(output_dir).mkdir(parents=True, exist_ok=True) cmd = [kicad_cli, "sch", "export", "hpgl", "--output", output_dir] @@ -5972,9 +5964,7 @@ class KiCADInterface: f"{result.stderr.strip()}", } - files = sorted( - f for f in os.listdir(output_dir) if os.path.isfile(os.path.join(output_dir, f)) - ) + files = sorted(p.name for p in Path(output_dir).iterdir() if p.is_file()) return {"success": True, "outputDir": output_dir, "files": files} except FileNotFoundError: @@ -6001,7 +5991,7 @@ class KiCADInterface: if not schematic_path: return {"success": False, "message": "schematicPath is required"} - if not os.path.exists(schematic_path): + if not Path(schematic_path).exists(): return {"success": False, "message": f"Schematic not found: {schematic_path}"} if not output_dir: return {"success": False, "message": "outputDir is required"} @@ -6010,8 +6000,8 @@ class KiCADInterface: if not kicad_cli: return {"success": False, "message": "kicad-cli not found in PATH"} - output_dir = os.path.abspath(os.path.expanduser(output_dir)) - os.makedirs(output_dir, exist_ok=True) + output_dir = str(Path(output_dir).expanduser().resolve()) + Path(output_dir).mkdir(parents=True, exist_ok=True) cmd = [kicad_cli, "sch", "export", "ps", "--output", output_dir] @@ -6051,9 +6041,7 @@ class KiCADInterface: f"{result.stderr.strip()}", } - files = sorted( - f for f in os.listdir(output_dir) if os.path.isfile(os.path.join(output_dir, f)) - ) + files = sorted(p.name for p in Path(output_dir).iterdir() if p.is_file()) return {"success": True, "outputDir": output_dir, "files": files} except FileNotFoundError: @@ -6081,7 +6069,7 @@ class KiCADInterface: if not schematic_path: return {"success": False, "message": "schematicPath is required"} - if not os.path.exists(schematic_path): + if not Path(schematic_path).exists(): return {"success": False, "message": f"Schematic not found: {schematic_path}"} if not output_path: return {"success": False, "message": "outputPath is required"} @@ -6090,10 +6078,10 @@ class KiCADInterface: if not kicad_cli: return {"success": False, "message": "kicad-cli not found in PATH"} - output_path = os.path.abspath(os.path.expanduser(output_path)) - parent = os.path.dirname(output_path) + output_path = str(Path(output_path).expanduser().resolve()) + parent = Path(output_path).parent if parent: - os.makedirs(parent, exist_ok=True) + Path(parent).mkdir(parents=True, exist_ok=True) cmd = [kicad_cli, "sch", "export", "python-bom", "--output", output_path] cmd.append(schematic_path) diff --git a/tests/test_export_cli.py b/tests/test_export_cli.py index 3cf027a..6b9d248 100644 --- a/tests/test_export_cli.py +++ b/tests/test_export_cli.py @@ -64,10 +64,10 @@ def _call(iface, suffix, params, rc=0, stderr=""): fake = SimpleNamespace(returncode=rc, stdout="", stderr=stderr) with ( patch("subprocess.run", return_value=fake) as run, - patch("os.path.exists", return_value=True), - patch("os.makedirs"), - patch("os.listdir", return_value=["a.out"]), - patch("os.path.isfile", return_value=True), + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.mkdir"), + patch("pathlib.Path.iterdir", return_value=[]), + patch("pathlib.Path.is_file", return_value=True), ): result = method(dict(params)) return result, run