diff --git a/python/annotations/__init__.py b/python/annotations/__init__.py new file mode 100644 index 0000000..b8daf4b --- /dev/null +++ b/python/annotations/__init__.py @@ -0,0 +1,4 @@ +"""KiCad IPC API tool annotations package.""" +from .loader import AnnotationLoader + +__all__ = ["AnnotationLoader"] diff --git a/python/annotations/kicad_ipc_tool_annotations.json b/python/annotations/kicad_ipc_tool_annotations.json new file mode 100644 index 0000000..c713a06 --- /dev/null +++ b/python/annotations/kicad_ipc_tool_annotations.json @@ -0,0 +1,565 @@ +{ + "annotations": { + "AddToSelection": { + "description": "Adds the given items to the current selection in the editor.", + "parameters": { + "header": "ItemHeader specifying the target document.", + "items": "List of KIIDs of items to add to the selection." + }, + "returns": "SelectionResponse containing the updated set of selected items." + }, + "BeginCommit": { + "description": "Begins a staged set of changes. Modifications made after this call are held in a pending commit until EndCommit.", + "parameters": { + "header": "ItemHeader specifying which document to associate the commit with." + }, + "returns": "BeginCommitResponse with an opaque KIID tracking the commit." + }, + "BoardEditorAppearanceSettings": { + "description": "Message describing the appearance settings of the PCB editor (inactive layers, net colors, flip, ratsnest).", + "parameters": { + "inactive_layer_display": "How inactive layers are displayed (dimmed, hidden, normal).", + "net_color_display": "How net colors are applied in the editor.", + "board_flip": "Whether the board view is flipped.", + "ratsnest_display": "How the ratsnest is shown." + } + }, + "BoardLayers": { + "description": "A simple wrapper containing a list of BoardLayer enum values.", + "parameters": { + "layers": "List of BoardLayer enum values." + } + }, + "CheckPadstackPresenceOnLayers": { + "description": "Tests whether the given pads/vias have copper presence on the given set of board layers.", + "parameters": { + "board": "DocumentSpecifier identifying the target board.", + "items": "List of pad/via KIIDs to test.", + "layers": "List of BoardLayer values to test against." + }, + "returns": "PadstackPresenceResponse with an entry per (item, layer) pair." + }, + "ClearSelection": { + "description": "Removes all items from the current selection.", + "parameters": { + "header": "ItemHeader specifying the target document." + } + }, + "CloseDocument": { + "description": "Closes the given open document in KiCad.", + "parameters": { + "document": "DocumentSpecifier identifying the document to close." + } + }, + "CreateItems": { + "description": "Creates new items on a given document, optionally inside a container (symbol or footprint).", + "parameters": { + "header": "ItemHeader specifying the target document and field mask.", + "items": "List of items to create (Any-wrapped typed messages).", + "container": "Optional KIID of a container (symbol/footprint) the items should be added to. Omit for top-level insertion." + }, + "returns": "CreateItemsResponse with per-item status and the created items (with assigned KIIDs)." + }, + "DeleteItems": { + "description": "Deletes items from a given document by KIID.", + "parameters": { + "header": "ItemHeader specifying the target document.", + "item_ids": "List of KIIDs of items to delete." + }, + "returns": "DeleteItemsResponse with a per-item deletion status.", + "warnings": [ + "Deletion is destructive; use BeginCommit/EndCommit to make it undoable." + ] + }, + "EndCommit": { + "description": "Finalizes or discards a pending commit that was started with BeginCommit.", + "parameters": { + "id": "Opaque KIID of the commit, as returned by BeginCommit.", + "action": "CommitAction indicating whether to commit or roll back the staged changes.", + "message": "Optional human-readable changeset message.", + "header": "ItemHeader specifying which document the commit is associated with." + } + }, + "ExpandTextVariables": { + "description": "Expands KiCad text variables (like ${VAR}) in the supplied strings using the given document's variable table.", + "parameters": { + "document": "DocumentSpecifier identifying the document whose variable table to use.", + "text": "List of input strings potentially containing text variable references." + }, + "returns": "ExpandTextVariablesResponse with one expanded string per input, in order." + }, + "GetActiveLayer": { + "description": "Returns the currently active (drawing) layer of the PCB editor for the given board.", + "parameters": { + "board": "DocumentSpecifier identifying the target board." + }, + "returns": "BoardLayerResponse containing the active BoardLayer." + }, + "GetBoardDesignRules": { + "description": "Retrieves the design rules (constraints, predefined sizes, mask/paste, teardrops, severities, exclusions) for a board.", + "parameters": { + "board": "DocumentSpecifier identifying the target board." + }, + "returns": "BoardDesignRulesResponse with the rules and custom rules status." + }, + "GetBoardEditorAppearanceSettings": { + "description": "Returns the current appearance settings of the PCB editor.", + "parameters": {}, + "returns": "BoardEditorAppearanceSettings with the current editor appearance state." + }, + "GetBoardEnabledLayers": { + "description": "Returns the set of layers currently enabled in the given board's stackup.", + "parameters": { + "board": "DocumentSpecifier identifying the target board." + }, + "returns": "BoardEnabledLayersResponse with copper_layer_count and the full list of enabled layers." + }, + "GetBoardLayerName": { + "description": "Returns the user-facing display name of a single board layer (may be default or user-customized).", + "parameters": { + "board": "DocumentSpecifier identifying the target board.", + "layer": "The BoardLayer whose display name should be returned." + }, + "returns": "BoardLayerNameResponse containing the layer's GUI name." + }, + "GetBoardOrigin": { + "description": "Retrieves a specific origin point (drill/place, grid, etc.) for the given board.", + "parameters": { + "board": "DocumentSpecifier identifying the target board.", + "type": "BoardOriginType selecting which origin to retrieve." + }, + "returns": "Vector2 with the origin's x and y coordinates in nanometers." + }, + "GetBoardStackup": { + "description": "Retrieves the full board stackup (finish, impedance, edge settings, and layer list) for the given board.", + "parameters": { + "board": "DocumentSpecifier identifying the target board." + }, + "returns": "BoardStackupResponse with the board's BoardStackup." + }, + "GetBoundingBox": { + "description": "Returns the bounding boxes of the given items in the target document.", + "parameters": { + "header": "ItemHeader specifying the target document.", + "items": "List of KIIDs to measure.", + "mode": "BoundingBoxMode controlling whether movable child text (e.g. footprint fields) is included." + }, + "returns": "GetBoundingBoxResponse with a Box2 (in nanometers) for each item." + }, + "GetConnectedItems": { + "description": "Retrieves all copper-connected items reachable from one or more source items.", + "parameters": { + "header": "ItemHeader specifying the target document and field mask.", + "items": "One or more source item KIIDs used as the connectivity roots.", + "types": "Optional KiCadObjectType filter. If empty, all connected item types are returned." + }, + "returns": "GetItemsResponse with the list of connected items." + }, + "GetCustomDesignRules": { + "description": "Retrieves the custom (user-defined) DRC rules associated with the given board.", + "parameters": { + "board": "DocumentSpecifier identifying the target board." + }, + "returns": "CustomRulesResponse with the parse status and the list of custom rules." + }, + "GetGraphicsDefaults": { + "description": "Retrieves the per-layer-class graphics defaults (text attributes and line thickness) for the given board.", + "parameters": { + "board": "DocumentSpecifier identifying the target board." + }, + "returns": "GraphicsDefaultsResponse with the board's GraphicsDefaults." + }, + "GetItems": { + "description": "Retrieves items of the given KiCad object types from the target document.", + "parameters": { + "header": "ItemHeader specifying the target document and field mask.", + "types": "List of KiCadObjectType values identifying which item types to retrieve." + }, + "returns": "GetItemsResponse with the matching items wrapped in Any." + }, + "GetItemsById": { + "description": "Retrieves specific items from the target document by their KIIDs.", + "parameters": { + "header": "ItemHeader specifying the target document and field mask.", + "items": "List of KIIDs to look up." + }, + "returns": "GetItemsResponse with the requested items (Any-wrapped)." + }, + "GetItemsByNet": { + "description": "Retrieves copper items (pads, vias, tracks, zones by default) that belong to the given nets.", + "parameters": { + "header": "ItemHeader specifying the target document and field mask.", + "types": "Optional list of item types to filter by. Defaults to pads, vias, tracks, and zones.", + "nets": "List of Nets used as filters; items belonging to any of them are returned." + }, + "returns": "GetItemsResponse with the matching copper items." + }, + "GetItemsByNetClass": { + "description": "Retrieves copper items that belong to any of the given net classes.", + "parameters": { + "header": "ItemHeader specifying the target document and field mask.", + "types": "Optional list of KiCadObjectType filters. Defaults to pads, vias, tracks, and zones.", + "net_classes": "List of net class names used as filters." + }, + "returns": "GetItemsResponse with the matching copper items." + }, + "GetKiCadBinaryPath": { + "description": "Returns the full path to a KiCad binary (e.g. kicad-cli) on the current system.", + "parameters": { + "binary_name": "Short name of the binary, e.g. 'kicad-cli'. On Windows, a '.exe' extension is assumed if missing." + }, + "returns": "PathResponse with the absolute path to the binary." + }, + "GetNetClassForNets": { + "description": "Computes the effective (merged) netclass for each provided net, combining all classes a net belongs to by priority.", + "parameters": { + "net": "List of Nets to resolve effective netclasses for." + }, + "returns": "NetClassForNetsResponse mapping each net to its effective netclass." + }, + "GetNetClasses": { + "description": "Returns all netclasses defined in the current project.", + "parameters": {}, + "returns": "NetClassesResponse with the list of NetClass definitions." + }, + "GetNets": { + "description": "Retrieves the nets defined on a board, optionally filtered by one or more netclass names.", + "parameters": { + "board": "DocumentSpecifier identifying the target board.", + "netclass_filter": "Optional list of netclass names. If provided, only nets in any of these classes are returned." + }, + "returns": "NetsResponse with the matching nets." + }, + "GetOpenDocuments": { + "description": "Retrieves a list of currently open documents of the requested type (board, schematic, etc.).", + "parameters": { + "type": "DocumentType specifying which kind of document to query." + }, + "returns": "GetOpenDocumentsResponse with DocumentSpecifiers for each open document of that type." + }, + "GetPadShapeAsPolygon": { + "description": "Computes the polygon outline of one or more pads on a given layer, merging custom shapes and approximating curves as segments.", + "parameters": { + "board": "DocumentSpecifier identifying the target board.", + "pads": "List of pad KIIDs to process.", + "layer": "BoardLayer on which to evaluate the pad shape." + }, + "returns": "PadShapeAsPolygonResponse containing parallel lists of pad KIIDs and their PolygonWithHoles outlines." + }, + "GetPluginSettingsPath": { + "description": "Returns a writeable directory path a plugin can use for persistent configuration/data. The directory may not yet exist.", + "parameters": { + "identifier": "The plugin's identifier (used to namespace the settings directory)." + }, + "returns": "StringResponse with the plugin's settings directory path." + }, + "GetSchematicHierarchy": { + "description": "Retrieves the hierarchical sheet structure of a schematic project.", + "parameters": { + "document": "DocumentSpecifier with type DT_SCHEMATIC; only the project field is used." + }, + "returns": "SchematicHierarchyResponse with the top-level SheetInstances and their children." + }, + "GetSelection": { + "description": "Retrieves the currently selected items in the editor, optionally filtered by type.", + "parameters": { + "header": "ItemHeader specifying the target document.", + "types": "Optional KiCadObjectType filter. If empty, all selected items are returned." + }, + "returns": "SelectionResponse with the selected items." + }, + "GetTextAsShapes": { + "description": "Renders the given text or textbox objects as shapes (polygons or stroke segments, depending on font).", + "parameters": { + "text": "List of TextOrTextBox objects to render." + }, + "returns": "GetTextAsShapesResponse pairing each input with its CompoundShape representation." + }, + "GetTextExtents": { + "description": "Calculates the bounding box of a temporary text item as it would be rendered.", + "parameters": { + "text": "A temporary Text item whose extents should be measured." + }, + "returns": "Box2 (in nanometers) giving the text's bounding box." + }, + "GetTextVariables": { + "description": "Retrieves the text variable table defined for the given document.", + "parameters": { + "document": "DocumentSpecifier identifying the target document." + }, + "returns": "TextVariables with the name/value map of text variables." + }, + "GetTitleBlockInfo": { + "description": "Retrieves the drawing-sheet title block fields (title, date, revision, company, comments) for the given document.", + "parameters": { + "document": "DocumentSpecifier identifying the target document." + }, + "returns": "TitleBlockInfo with the title block's field values." + }, + "GetVersion": { + "description": "Retrieves the version of the running KiCad instance.", + "parameters": {}, + "returns": "GetVersionResponse containing a KiCadVersion (major/minor/patch and full version string)." + }, + "GetVisibleLayers": { + "description": "Returns the list of layers currently set visible in the PCB editor for the given board.", + "parameters": { + "board": "DocumentSpecifier identifying the target board." + }, + "returns": "BoardLayers with the visible BoardLayer list." + }, + "HitTest": { + "description": "Tests whether a point lies within a given tolerance of an item's geometry.", + "parameters": { + "header": "ItemHeader specifying the target document.", + "id": "KIID of the item to test against.", + "position": "Point to test, in nanometer coordinates.", + "tolerance": "Tolerance distance in nanometers." + }, + "returns": "HitTestResponse with a HitTestResult indicating hit or miss." + }, + "InjectDrcError": { + "description": "Injects a synthetic DRC marker onto the board at a given location, referencing specific items. Useful for plugins emitting custom rule violations.", + "parameters": { + "board": "DocumentSpecifier identifying the target board.", + "severity": "DrcSeverity level of the injected marker.", + "message": "Human-readable message describing the violation.", + "position": "Location of the marker in nanometer coordinates.", + "items": "List of KIIDs of items associated with this DRC error." + }, + "returns": "InjectDrcErrorResponse with the KIID of the newly created marker." + }, + "InteractiveMoveItems": { + "description": "Selects the given items and starts an interactive move in the PCB editor; KiCad becomes unresponsive to API calls until the user confirms or cancels.", + "parameters": { + "board": "DocumentSpecifier identifying the target board.", + "items": "List of KIIDs of items to move." + }, + "warnings": [ + "Takes ownership of the active commit if one exists: the move tool pushes it on confirm or rolls it back on cancel. Account for this when combining with explicit commits." + ], + "interactive": true + }, + "ItemStatus": { + "description": "Per-item status returned alongside creation and update results.", + "parameters": { + "code": "ItemStatusCode indicating success or a specific failure reason.", + "error_message": "Human-readable error message when the code indicates failure." + } + }, + "OpenDocument": { + "description": "Opens a document of a given type from a file path in KiCad.", + "parameters": { + "type": "DocumentType of the document to open (board, schematic, etc.).", + "path": "Filesystem path to the document to open." + }, + "returns": "OpenDocumentResponse with a DocumentSpecifier for the opened document." + }, + "PadstackPresenceEntry": { + "description": "One entry in a PadstackPresenceResponse, describing an (item, layer) pair's padstack presence.", + "parameters": { + "item": "KIID of the pad or via being reported on.", + "layer": "BoardLayer being reported on.", + "presence": "PadstackPresence value indicating whether the item has copper on this layer." + } + }, + "ParseAndCreateItemsFromString": { + "description": "Parses an s-expression item container (like the Paste action in the editor) and creates the resulting items in the document.", + "parameters": { + "document": "DocumentSpecifier identifying the target document.", + "contents": "S-expression text describing items to create." + }, + "returns": "CreateItemsResponse with per-item creation status." + }, + "Ping": { + "description": "Pings KiCad to verify the API connection is alive.", + "parameters": {} + }, + "RefillZones": { + "description": "Refills some or all copper zones on the given board. Blocking: returns Empty immediately and KiCad returns AS_BUSY to further API calls until the fill completes.", + "parameters": { + "board": "DocumentSpecifier identifying the target board.", + "zones": "List of zone KIIDs to refill. If empty, all zones on the board are refilled." + }, + "blocking": true + }, + "RefreshEditor": { + "description": "Requests that the given editor frame redraw itself, if open.", + "parameters": { + "frame": "FrameType identifying which editor frame to refresh." + } + }, + "RemoveFromSelection": { + "description": "Removes the given items from the current selection.", + "parameters": { + "header": "ItemHeader specifying the target document.", + "items": "List of KIIDs of items to deselect." + }, + "returns": "SelectionResponse with the updated selection." + }, + "RevertDocument": { + "description": "Reverts the given document to its on-disk state, discarding unsaved changes.", + "parameters": { + "document": "DocumentSpecifier identifying the document to revert." + }, + "warnings": [ + "Discards all unsaved changes in the target document; this cannot be undone." + ] + }, + "RunAction": { + "description": "Runs a TOOL_ACTION through the TOOL_MANAGER of a frame. Intended for low-level prototyping only.", + "parameters": { + "action": "Action name, e.g. 'eeschema.InteractiveSelection.ClearSelection'." + }, + "returns": "RunActionResponse with a RunActionStatus.", + "warnings": [ + "TOOL_ACTIONs are specifically NOT a stable API. Action names may change or be removed as code is refactored; use only for prototyping." + ] + }, + "SaveCopyOfDocument": { + "description": "Saves a copy of the given document to a new path, without opening the copy.", + "parameters": { + "document": "DocumentSpecifier identifying the source document.", + "path": "Destination file path for the saved copy.", + "options": "SaveOptions controlling overwrite and whether to save a project alongside the file." + } + }, + "SaveDocument": { + "description": "Saves the given open document to its current location on disk.", + "parameters": { + "document": "DocumentSpecifier identifying the document to save." + } + }, + "SaveDocumentToString": { + "description": "Serializes the given document to a string (s-expression) without writing to disk.", + "parameters": { + "document": "DocumentSpecifier identifying the document to serialize." + }, + "returns": "SavedDocumentResponse with the serialized contents." + }, + "SaveOptions": { + "description": "Options controlling how SaveCopyOfDocument behaves.", + "parameters": { + "overwrite": "If true, destination file(s) may be overwritten if they already exist.", + "include_project": "If true, save a new project alongside the file when the file type normally requires one (board or schematic)." + } + }, + "SaveSelectionToString": { + "description": "Serializes the current selection to a string (s-expression) suitable for later pasting.", + "parameters": {}, + "returns": "SavedSelectionResponse with the selected items' KIIDs and serialized contents." + }, + "SetActiveLayer": { + "description": "Sets the active (drawing) layer in the PCB editor for the given board.", + "parameters": { + "board": "DocumentSpecifier identifying the target board.", + "layer": "The BoardLayer to make active." + } + }, + "SetBoardDesignRules": { + "description": "Replaces the board's design rules (constraints, predefined sizes, mask/paste, teardrops, severities, exclusions).", + "parameters": { + "board": "DocumentSpecifier identifying the target board.", + "rules": "BoardDesignRules to install on the board." + }, + "returns": "BoardDesignRulesResponse with the resulting rules and custom rules status." + }, + "SetBoardEditorAppearanceSettings": { + "description": "Updates the PCB editor's appearance settings (inactive layer display, net colors, flip, ratsnest).", + "parameters": { + "settings": "BoardEditorAppearanceSettings to apply." + } + }, + "SetBoardEnabledLayers": { + "description": "Changes which layers are enabled in the board stackup.", + "parameters": { + "board": "DocumentSpecifier identifying the target board.", + "copper_layer_count": "Number of copper layers to enable; must currently be an even number >= 2.", + "layers": "Non-copper layers to enable. Copper layers in this list are ignored (use copper_layer_count). F/B.Courtyard, Edge.Cuts, and Margin cannot be disabled and remain enabled regardless." + }, + "returns": "BoardEnabledLayersResponse with the updated enabled layer set.", + "warnings": [ + "Any existing content on layers removed by this call is deleted. This operation cannot be undone." + ] + }, + "SetBoardOrigin": { + "description": "Sets a specific origin point (drill/place, grid, etc.) for the given board.", + "parameters": { + "board": "DocumentSpecifier identifying the target board.", + "type": "BoardOriginType selecting which origin to set.", + "origin": "New origin location in nanometer coordinates." + } + }, + "SetCustomDesignRules": { + "description": "Replaces the custom (user-defined) DRC rules for the given board.", + "parameters": { + "board": "DocumentSpecifier identifying the target board.", + "rules": "List of CustomRule definitions to install." + }, + "returns": "CustomRulesResponse with parse status, resulting rules, and any error text." + }, + "SetNetClasses": { + "description": "Updates the project's netclass definitions, merging with or replacing the existing set based on merge_mode.", + "parameters": { + "net_classes": "List of NetClass definitions to apply.", + "merge_mode": "MapMergeMode controlling merge vs. replace semantics. With MMM_MERGE, existing classes not named here are kept; named classes are replaced. With MMM_REPLACE, unnamed classes are erased except 'Default', which always persists." + } + }, + "SetTextVariables": { + "description": "Updates the text variable table for a document, merging with or replacing the existing variables.", + "parameters": { + "document": "DocumentSpecifier identifying the target document.", + "variables": "TextVariables map to apply.", + "merge_mode": "MapMergeMode controlling whether to merge with or replace the existing variables." + } + }, + "SetTitleBlockInfo": { + "description": "Sets the drawing-sheet title block fields (title, date, revision, company, comments) for the given document.", + "parameters": { + "document": "DocumentSpecifier identifying the target document.", + "title_block": "TitleBlockInfo with the new field values." + } + }, + "SetVisibleLayers": { + "description": "Sets which layers are visible in the PCB editor for the given board.", + "parameters": { + "board": "DocumentSpecifier identifying the target board.", + "layers": "List of BoardLayers to mark visible." + } + }, + "TextOrTextBox": { + "description": "A union-style wrapper holding either a Text or TextBox object, used as input for text-related commands.", + "parameters": {} + }, + "TextWithShapes": { + "description": "Pairs an input text/textbox with its rendered CompoundShape, as returned by GetTextAsShapes.", + "parameters": { + "text": "The original TextOrTextBox input.", + "shapes": "CompoundShape rendering (polygons or stroke segments) of the text." + } + }, + "UpdateBoardStackup": { + "description": "Replaces the board stackup with the contents of this message. Not yet implemented.", + "parameters": { + "board": "DocumentSpecifier identifying the target board.", + "stackup": "The new BoardStackup to install." + }, + "returns": "BoardStackupResponse with the updated stackup in normalized form.", + "warnings": [ + "Any existing content on layers removed by this call is deleted. This operation cannot be undone." + ] + }, + "UpdateItems": { + "description": "Modifies existing items in a document; fields updated are controlled by the ItemHeader field mask.", + "parameters": { + "header": "ItemHeader specifying the target document and field mask.", + "items": "List of items (Any-wrapped) with updated values. Each item's KIID must identify an existing item." + }, + "returns": "UpdateItemsResponse with per-item update status." + } + }, + "_meta": { + "kicad_ref": "master", + "generator": "generate_tool_annotations.py" + } +} \ No newline at end of file diff --git a/python/annotations/loader.py b/python/annotations/loader.py new file mode 100644 index 0000000..c51a2db --- /dev/null +++ b/python/annotations/loader.py @@ -0,0 +1,169 @@ +""" +AnnotationLoader — resolves KiCad IPC proto annotations by MCP tool name. + +The annotations JSON (generated by scripts/generate_tool_annotations.py) maps +proto message names in PascalCase to structured descriptions. MCP tool names +are snake_case and don't always match 1:1, so resolution uses three layers: + + 1. An explicit TOOL_TO_PROTO mapping for cases where auto-conversion fails. + 2. Automatic snake_case → PascalCase conversion (get_nets → GetNets). + 3. Suffix-stripped variants (get_nets_list → GetNets). + +Usage:: + + loader = AnnotationLoader() # loads bundled annotations + ann = loader.get("refill_zones") # -> {"description": ..., "blocking": True, ...} + desc = loader.description("refill_zones") # -> str | None +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Optional + +# --------------------------------------------------------------------------- +# Explicit overrides where snake→Pascal conversion would produce the wrong name +# --------------------------------------------------------------------------- + +TOOL_TO_PROTO: dict[str, str] = { + # board + "get_nets_list": "GetNets", + "add_copper_pour": "AddZone", + "get_board_info": "GetBoardEnabledLayers", + "get_layer_list": "GetBoardEnabledLayers", + "set_active_layer": "SetActiveLayer", + "set_design_rules": "SetBoardDesignRules", + "get_design_rules": "GetBoardDesignRules", + "refill_zones": "RefillZones", + # editor lifecycle + "begin_commit": "BeginCommit", + "end_commit": "EndCommit", + # schematic + "run_erc": "GetSchematicHierarchy", # closest available proto + # common + "check_kicad_ui": "Ping", +} + +# --------------------------------------------------------------------------- +# Annotation loader +# --------------------------------------------------------------------------- + +_DEFAULT_ANNOTATIONS_PATH = Path(__file__).parent / "kicad_ipc_tool_annotations.json" + + +def _snake_to_pascal(name: str) -> str: + """Convert snake_case to PascalCase: get_nets_list → GetNetsList.""" + return "".join(part.capitalize() for part in name.split("_")) + + +def _candidate_names(tool_name: str) -> list[str]: + """ + Return a prioritised list of proto message names to try for a given tool name. + + Tries: + 1. Explicit override from TOOL_TO_PROTO + 2. Direct PascalCase conversion + 3. PascalCase with common suffixes stripped (_list, _info, _view, _region) + """ + candidates: list[str] = [] + + if tool_name in TOOL_TO_PROTO: + candidates.append(TOOL_TO_PROTO[tool_name]) + + pascal = _snake_to_pascal(tool_name) + candidates.append(pascal) + + # Try stripping trailing noise words that appear in tool names but not proto names + for suffix in ("List", "Info", "View", "Region", "Violations", "Url"): + if pascal.endswith(suffix): + candidates.append(pascal[: -len(suffix)]) + + return candidates + + +class AnnotationLoader: + """ + Loads and queries KiCad IPC API tool annotations. + + Parameters + ---------- + path: + Path to the annotations JSON file. Defaults to the bundled file at + ``python/annotations/kicad_ipc_tool_annotations.json``. + """ + + def __init__(self, path: Optional[Path] = None) -> None: + self._path = path or _DEFAULT_ANNOTATIONS_PATH + self._annotations: dict[str, dict] = {} + self._load() + + def _load(self) -> None: + if not self._path.exists(): + return + try: + data = json.loads(self._path.read_text(encoding="utf-8")) + self._annotations = data.get("annotations", {}) + except (json.JSONDecodeError, OSError): + pass + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def get(self, tool_name: str) -> Optional[dict]: + """Return the full annotation dict for a tool, or None if not found.""" + for candidate in _candidate_names(tool_name): + ann = self._annotations.get(candidate) + if ann: + return ann + return None + + def description(self, tool_name: str) -> Optional[str]: + """Return the description string for a tool, or None if not found.""" + ann = self.get(tool_name) + return ann.get("description") if ann else None + + def enrich_schema(self, tool_name: str, schema: dict) -> dict: + """ + Return a copy of ``schema`` enriched with annotation data. + + Fills in ``description`` and ``annotations`` (MCP ToolAnnotations) if + the schema doesn't already have them. Existing values are preserved. + """ + ann = self.get(tool_name) + if not ann: + return schema + + schema = schema.copy() + + if not schema.get("description") and ann.get("description"): + schema["description"] = ann["description"] + + # Build MCP ToolAnnotations block from blocking/interactive flags + mcp_hints: dict[str, bool] = {} + if ann.get("blocking"): + # Blocking tools are read-only from the caller's perspective + # (they mutate KiCad state but don't modify files via the API) + mcp_hints["readOnlyHint"] = False + if ann.get("interactive"): + mcp_hints["readOnlyHint"] = False + + if ann.get("warnings"): + schema.setdefault("_warnings", ann["warnings"]) + + if mcp_hints: + schema.setdefault("annotations", {}).update(mcp_hints) + + return schema + + def summary(self) -> dict[str, str]: + """Return a dict of proto_message_name → description for all loaded annotations.""" + return {k: v.get("description", "") for k, v in self._annotations.items()} + + def __len__(self) -> int: + return len(self._annotations) + + def __repr__(self) -> str: + return f"AnnotationLoader({self._path}, {len(self)} annotations)" diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 96dd6ae..7db8718 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -17,8 +17,11 @@ from typing import Any, Dict, List, Optional, Tuple from resources.resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read -# Import tool schemas and resource definitions +# Import tool schemas, resource definitions, and IPC API annotations from schemas.tool_schemas import TOOL_SCHEMAS +from annotations import AnnotationLoader + +_annotation_loader = AnnotationLoader() # Configure logging log_dir = os.path.join(os.path.expanduser("~"), ".kicad-mcp", "logs") @@ -5319,22 +5322,32 @@ def main() -> None: # Return list of available tools with proper schemas tools = [] for cmd_name in interface.command_routes.keys(): - # Get schema from TOOL_SCHEMAS if available if cmd_name in TOOL_SCHEMAS: - tool_def = TOOL_SCHEMAS[cmd_name].copy() + # Enrich the existing schema with IPC annotation data + # (adds description/blocking hints where the schema lacks them) + tool_def = _annotation_loader.enrich_schema( + cmd_name, TOOL_SCHEMAS[cmd_name] + ) tools.append(tool_def) else: - # Fallback for tools without schemas - logger.warning(f"No schema defined for tool: {cmd_name}") + # Build a best-effort schema from IPC annotations + ann_desc = _annotation_loader.description(cmd_name) + if ann_desc: + logger.debug(f"Using IPC annotation for tool: {cmd_name}") + else: + logger.warning(f"No schema or annotation for tool: {cmd_name}") tools.append( - { - "name": cmd_name, - "description": f"KiCAD command: {cmd_name}", - "inputSchema": { - "type": "object", - "properties": {}, + _annotation_loader.enrich_schema( + cmd_name, + { + "name": cmd_name, + "description": ann_desc or f"KiCAD command: {cmd_name}", + "inputSchema": { + "type": "object", + "properties": {}, + }, }, - } + ) ) logger.info(f"Returning {len(tools)} tools") diff --git a/scripts/generate_tool_annotations.py b/scripts/generate_tool_annotations.py new file mode 100644 index 0000000..0e61f0d --- /dev/null +++ b/scripts/generate_tool_annotations.py @@ -0,0 +1,777 @@ +#!/usr/bin/env python3 +""" +generate_tool_annotations.py — Annotate KiCad IPC API proto messages with Claude + +Reads KiCad's protobuf API definitions and uses the Claude API to generate rich, +user-facing descriptions suitable for MCP tool metadata. The output JSON file can +be loaded by an MCP server to annotate auto-generated tools with descriptions that +go beyond what's in the proto files (e.g., unit conventions, commit ownership +semantics, blocking/interactive behavior). + +Because the proto content is large and static, it is sent once as a cached prompt +block; only the lightweight annotation-request portion is billed at full rate. +Re-running the script against the same proto revision is therefore very cheap. + +Usage +----- +Annotate from a local KiCad source checkout:: + + python scripts/generate_tool_annotations.py \\ + --proto-dir /path/to/kicad/api/proto \\ + --output data/tool_annotations.json + +Fetch proto files directly from GitLab (no checkout needed):: + + python scripts/generate_tool_annotations.py \\ + --fetch-from-gitlab \\ + --kicad-ref master \\ + --output data/tool_annotations.json + +Resume an interrupted run (skips messages already in the output file):: + + python scripts/generate_tool_annotations.py \\ + --proto-dir ./api/proto \\ + --output data/tool_annotations.json \\ + --resume + +Preview what would be annotated without calling the API:: + + python scripts/generate_tool_annotations.py \\ + --proto-dir ./api/proto \\ + --dry-run + +Environment variables +--------------------- +ANTHROPIC_API_KEY + Required. Your Anthropic API key. + +Dependencies +------------ + anthropic>=0.40.0 + requests>=2.28.0 (only needed with --fetch-from-gitlab) +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import textwrap +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +GITLAB_RAW_BASE = "https://gitlab.com/kicad/code/kicad/-/raw" + +# Relative paths inside the KiCad repo that contain API proto definitions. +# Extend this list when KiCad adds new proto files. +PROTO_RELATIVE_PATHS: list[str] = [ + "api/proto/board/board_commands.proto", + "api/proto/board/board.proto", + "api/proto/board/board_types.proto", + "api/proto/schematic/schematic_commands.proto", + "api/proto/schematic/schematic_types.proto", + "api/proto/common/commands/base_commands.proto", + "api/proto/common/commands/editor_commands.proto", + "api/proto/common/commands/project_commands.proto", + "api/proto/common/types/base_types.proto", + "api/proto/common/types/enums.proto", +] + +DEFAULT_MODEL = "claude-opus-4-7" +DEFAULT_OUTPUT = "tool_annotations.json" + +# --------------------------------------------------------------------------- +# Data model +# --------------------------------------------------------------------------- + + +@dataclass +class ProtoField: + name: str + type_name: str + number: int + comment: str = "" + repeated: bool = False + optional: bool = False + + def summary(self) -> str: + qualifier = "repeated " if self.repeated else ("optional " if self.optional else "") + parts = [f"{qualifier}{self.type_name} {self.name}"] + if self.comment: + parts.append(f" // {self.comment}") + return "".join(parts) + + +@dataclass +class ProtoMessage: + name: str + comment: str + fields: list[ProtoField] = field(default_factory=list) + source_file: str = "" + is_response: bool = False + + def as_text(self) -> str: + lines = [] + if self.comment: + for line in textwrap.wrap(self.comment, width=80): + lines.append(f"// {line}") + lines.append(f"message {self.name} {{") + for f in self.fields: + lines.append(f" {f.summary()}") + lines.append("}") + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Proto file fetching +# --------------------------------------------------------------------------- + + +def fetch_proto_from_gitlab(ref: str) -> dict[str, str]: + """Fetch proto files from KiCad's GitLab. Returns {relative_path: content}.""" + try: + import requests + except ImportError: + sys.exit("requests is required for --fetch-from-gitlab. Install with: pip install requests") + + files: dict[str, str] = {} + session = requests.Session() + + for rel_path in PROTO_RELATIVE_PATHS: + url = f"{GITLAB_RAW_BASE}/{ref}/{rel_path}" + print(f" Fetching {rel_path} ...", flush=True) + resp = session.get(url, timeout=30) + if resp.status_code == 200: + files[rel_path] = resp.text + elif resp.status_code == 404: + print(f" WARNING: {rel_path} not found at ref '{ref}' — skipping", file=sys.stderr) + else: + sys.exit(f"HTTP {resp.status_code} fetching {url}") + + return files + + +def load_proto_from_dir(proto_dir: Path) -> dict[str, str]: + """Load proto files from a local directory. Returns {relative_path: content}.""" + files: dict[str, str] = {} + for proto_file in sorted(proto_dir.rglob("*.proto")): + rel = str(proto_file.relative_to(proto_dir)) + files[rel] = proto_file.read_text(encoding="utf-8") + if not files: + sys.exit(f"No .proto files found under {proto_dir}") + return files + + +# --------------------------------------------------------------------------- +# Proto parser +# --------------------------------------------------------------------------- + +# Matches license/copyright headers so we can suppress them from comment text. +_LICENSE_KEYWORDS = frozenset(["copyright", "gnu general public", "program source code", "free software"]) + +# Matches proto field declarations (handles repeated/optional qualifiers). +_FIELD_RE = re.compile( + r"^(repeated\s+|optional\s+)?([\w.]+)\s+(\w+)\s*=\s*(\d+)\s*;?\s*(?://(.*))?$" +) + + +def _is_license_comment(text: str) -> bool: + lower = text.lower() + return any(kw in lower for kw in _LICENSE_KEYWORDS) + + +def parse_proto_text(text: str, source_name: str = "") -> list[ProtoMessage]: + """ + Extract top-level message definitions from proto3 source text. + + Returns a list of ProtoMessage objects in declaration order. + Comments immediately preceding a message declaration are captured + as its docstring. Field-level comments (both inline and preceding) + are attached to each field. + """ + lines = text.splitlines() + messages: list[ProtoMessage] = [] + + i = 0 + pending_comments: list[str] = [] + in_block = False + block_buf: list[str] = [] + + while i < len(lines): + raw = lines[i] + stripped = raw.strip() + + # ── block comment handling ────────────────────────────────────────── + if "/*" in stripped and "*/" not in stripped: + in_block = True + block_buf = [re.sub(r"^\s*/\*+", "", stripped).strip()] + i += 1 + continue + + if in_block: + if "*/" in stripped: + in_block = False + tail = re.sub(r"\*+/.*$", "", stripped).strip().lstrip("* ").strip() + if tail: + block_buf.append(tail) + comment_text = " ".join(l for l in block_buf if l) + if not _is_license_comment(comment_text): + pending_comments = block_buf[:] + else: + pending_comments = [] + block_buf = [] + else: + block_buf.append(stripped.lstrip("* ").strip()) + i += 1 + continue + + # Inline block comment on one line: /* ... */ + if "/*" in stripped and "*/" in stripped: + m = re.search(r"/\*(.*?)\*/", stripped) + if m: + comment_text = m.group(1).strip() + if not _is_license_comment(comment_text): + pending_comments = [comment_text] + else: + pending_comments = [] + i += 1 + continue + + # ── line comment ──────────────────────────────────────────────────── + if stripped.startswith("//"): + comment_text = stripped.lstrip("/").strip() + pending_comments.append(comment_text) + i += 1 + continue + + # ── message declaration ───────────────────────────────────────────── + msg_match = re.match(r"^message\s+(\w+)\s*\{?\s*$", stripped) + if msg_match: + msg_name = msg_match.group(0).split()[1] + doc = " ".join(l for l in pending_comments if l).strip() + if _is_license_comment(doc): + doc = "" + pending_comments = [] + + # Collect fields inside the message body + proto_fields: list[ProtoField] = [] + brace_depth = 1 if "{" in stripped else 0 + field_comments: list[str] = [] + j = i + 1 + + # If the opening brace is on the next line + if brace_depth == 0 and j < len(lines) and "{" in lines[j]: + brace_depth = 1 + j += 1 + + while j < len(lines) and brace_depth > 0: + fraw = lines[j] + fstripped = fraw.strip() + + if fstripped.startswith("//"): + field_comments.append(fstripped.lstrip("/").strip()) + j += 1 + continue + + opens = fstripped.count("{") + closes = fstripped.count("}") + brace_depth += opens - closes + + if brace_depth <= 0: + j += 1 + break + + if brace_depth == 1: + fm = _FIELD_RE.match(fstripped) + if fm: + qualifier = fm.group(1) or "" + type_name = (fm.group(2) or "").split(".")[-1] + field_name = fm.group(3) or "" + field_num = int(fm.group(4) or 0) + inline = (fm.group(5) or "").strip() + + combined = " ".join(field_comments).strip() + if inline: + combined = (combined + " " + inline).strip() + + proto_fields.append( + ProtoField( + name=field_name, + type_name=type_name, + number=field_num, + comment=combined, + repeated="repeated" in qualifier, + optional="optional" in qualifier, + ) + ) + field_comments = [] + elif fstripped and not fstripped.startswith(("/*", "*", "enum", "oneof", "map")): + field_comments = [] + + j += 1 + + messages.append( + ProtoMessage( + name=msg_name, + comment=doc, + fields=proto_fields, + source_file=source_name, + is_response=msg_name.endswith(("Response", "Result")), + ) + ) + i = j + continue + + # ── anything else resets pending comments ─────────────────────────── + if stripped and not stripped.startswith(("syntax", "package", "import", "option")): + pending_comments = [] + + i += 1 + + return messages + + +def parse_all_protos(files: dict[str, str]) -> dict[str, ProtoMessage]: + """Parse all proto file contents and return a flat dict of message_name -> ProtoMessage.""" + all_messages: dict[str, ProtoMessage] = {} + for source_name, content in files.items(): + for msg in parse_proto_text(content, source_name): + all_messages[msg.name] = msg + return all_messages + + +# --------------------------------------------------------------------------- +# Annotation generation via Claude +# --------------------------------------------------------------------------- + +_SYSTEM_PROMPT = """\ +You are a technical writer generating MCP (Model Context Protocol) tool annotations +for the KiCad IPC API. The KiCad IPC API is a protobuf-based API for scripting and +automating the KiCad EDA suite. + +Your task: given a set of protobuf message definitions, produce a JSON object mapping +each REQUEST message name to a structured annotation. Skip pure response messages +(those whose names end in Response or Result). + +Important KiCad API conventions to include when relevant: +- All coordinates and distances are in **nanometers** (nm). Multiply mm values by 1e6. +- `DocumentSpecifier` identifies which open KiCad document to target (PCB, schematic, etc.). +- `ItemHeader` wraps a DocumentSpecifier plus an optional container KIID and field mask. +- `KIID` is a UUID string identifying a specific design object. +- `BeginCommit`/`EndCommit` must bracket any write operations that should be undoable. +- Messages marked "blocking" cause KiCad to return AS_BUSY until the operation completes. +- Messages marked "interactive" transfer control to the user; KiCad becomes unresponsive + to further API calls until the user confirms or cancels. +- `WARNING:` comments in the proto indicate destructive or irreversible operations. + +Output format — a single JSON object, no markdown fences, no explanation:: + +{ + "MessageName": { + "description": "One or two sentences. What does this command do? Who calls it and why?", + "parameters": { + "field_name": "What this field controls. Include units, allowed values, or defaults." + }, + "returns": "What the paired response message contains. Omit if obvious.", + "warnings": ["Any WARNING or irreversibility notes from the proto, verbatim or paraphrased."], + "blocking": true, + "interactive": false + } +} + +Rules: +- Omit `warnings` if the array would be empty. +- Set `blocking` true only for operations explicitly documented as blocking. +- Set `interactive` true only for operations that hand control to the user. +- Keep `description` under 120 characters when possible. +- Field descriptions should mention units (nanometers for coordinates/distances) where applicable. +- If a field has an obvious name and no comment, a one-word description is fine. +""" + + +def _build_proto_context(messages: dict[str, ProtoMessage]) -> str: + """Render all parsed messages as structured text for the prompt.""" + sections: list[str] = [] + by_file: dict[str, list[ProtoMessage]] = {} + for msg in messages.values(): + by_file.setdefault(msg.source_file, []).append(msg) + + for source in sorted(by_file): + sections.append(f"# {source}") + for msg in by_file[source]: + sections.append(msg.as_text()) + sections.append("") + + return "\n".join(sections) + + +def _filter_command_messages( + messages: dict[str, ProtoMessage], existing: dict, resume: bool +) -> tuple[dict[str, ProtoMessage], dict[str, ProtoMessage]]: + """Return (all_messages_for_context, todo_commands) after applying --resume filter.""" + command_messages = { + name: msg + for name, msg in messages.items() + if "_commands" in msg.source_file and not msg.is_response + } + if resume: + already_done = set(existing.get("annotations", {}).keys()) + todo = {n: m for n, m in command_messages.items() if n not in already_done} + print(f" Resuming: {len(already_done)} already annotated, {len(todo)} remaining") + else: + todo = command_messages + return command_messages, todo + + +def _build_full_prompt(proto_context: str, target_names: list[str]) -> str: + """Build the complete prompt text used by both the SDK and CLI backends.""" + return ( + _SYSTEM_PROMPT + + "\n\n## KiCad IPC API — proto definitions\n\n" + + proto_context + + "\n\n## Annotation request\n\n" + "Generate MCP annotations for the following request messages:\n" + + "\n".join(f"- {n}" for n in target_names) + + "\n\nReturn only the JSON object described in your instructions." + ) + + +def _parse_response(raw: str) -> dict: + """Parse a Claude text response to a JSON dict, stripping markdown fences.""" + raw = raw.strip() + if raw.startswith("```"): + raw = re.sub(r"^```[a-z]*\n?", "", raw).rstrip("`").strip() + try: + return json.loads(raw) + except json.JSONDecodeError as exc: + print(f"ERROR: Claude returned invalid JSON: {exc}", file=sys.stderr) + print("--- raw response (first 2000 chars) ---", file=sys.stderr) + print(raw[:2000], file=sys.stderr) + sys.exit(1) + + +def call_claude_sdk( + messages: dict[str, ProtoMessage], + model: str, + existing: dict, + resume: bool, +) -> dict: + """ + Annotate messages via the Anthropic Python SDK (requires ANTHROPIC_API_KEY). + + Uses prompt caching on the static proto context block so repeated runs against + the same proto definitions only bill the small annotation-request portion. + """ + try: + import anthropic + except ImportError: + sys.exit("anthropic SDK is required. Install with: pip install anthropic") + + api_key = os.environ.get("ANTHROPIC_API_KEY") + if not api_key: + sys.exit("ANTHROPIC_API_KEY is not set. Use --use-cli to call Claude Code instead.") + + client = anthropic.Anthropic(api_key=api_key) + + _, todo = _filter_command_messages(messages, existing, resume) + if not todo: + print(" Nothing to annotate.") + return existing + + proto_context = _build_proto_context(messages) + target_names = sorted(todo.keys()) + print(f" Sending {len(target_names)} messages to {model} via SDK ...") + + response = client.messages.create( + model=model, + max_tokens=8192, + system=_SYSTEM_PROMPT, + messages=[ + { + "role": "user", + "content": [ + # Cache the large, static proto context block + { + "type": "text", + "text": "## KiCad IPC API — proto definitions\n\n" + proto_context, + "cache_control": {"type": "ephemeral"}, + }, + { + "type": "text", + "text": ( + "## Annotation request\n\n" + "Generate MCP annotations for the following request messages:\n" + + "\n".join(f"- {n}" for n in target_names) + + "\n\nReturn only the JSON object described in your instructions." + ), + }, + ], + } + ], + ) + + usage = response.usage + if hasattr(usage, "cache_creation_input_tokens"): + print( + f" Tokens — input: {usage.input_tokens}, " + f"cache_write: {usage.cache_creation_input_tokens}, " + f"cache_read: {usage.cache_read_input_tokens}, " + f"output: {usage.output_tokens}" + ) + + new_annotations = _parse_response(response.content[0].text) + result = dict(existing) + result.setdefault("annotations", {}).update(new_annotations) + return result + + +def call_claude_cli( + messages: dict[str, ProtoMessage], + model: str, + existing: dict, + resume: bool, +) -> dict: + """ + Annotate messages by shelling out to the ``claude`` CLI (Claude Code). + + Works with a Claude.ai monthly subscription — no API key required. + The ``claude`` binary must be on PATH (install Claude Code from claude.ai/code). + + Note: prompt caching is not available via the CLI; the full context is sent + each time. Use --resume between interrupted runs to avoid redundant work. + """ + import shutil + import subprocess + + claude_bin = shutil.which("claude") + if not claude_bin: + sys.exit( + "claude CLI not found on PATH.\n" + "Install Claude Code from https://claude.ai/code, then re-run." + ) + + _, todo = _filter_command_messages(messages, existing, resume) + if not todo: + print(" Nothing to annotate.") + return existing + + proto_context = _build_proto_context(messages) + target_names = sorted(todo.keys()) + print(f" Sending {len(target_names)} messages to claude CLI ...") + + prompt = _build_full_prompt(proto_context, target_names) + + cmd = [claude_bin, "--output-format", "text", "-p", prompt] + if model: + cmd += ["--model", model] + + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=300, + ) + except subprocess.TimeoutExpired: + sys.exit("ERROR: claude CLI timed out after 5 minutes.") + except FileNotFoundError: + sys.exit(f"ERROR: could not execute {claude_bin}") + + if proc.returncode != 0: + print(f"ERROR: claude CLI exited {proc.returncode}", file=sys.stderr) + if proc.stderr: + print(proc.stderr[:1000], file=sys.stderr) + sys.exit(1) + + new_annotations = _parse_response(proc.stdout) + result = dict(existing) + result.setdefault("annotations", {}).update(new_annotations) + return result + + +def call_claude( + messages: dict[str, ProtoMessage], + model: str, + existing: dict, + resume: bool, + use_cli: bool, +) -> dict: + """Dispatch to the appropriate Claude backend.""" + if use_cli: + return call_claude_cli(messages, model, existing, resume) + return call_claude_sdk(messages, model, existing, resume) + + +# --------------------------------------------------------------------------- +# Output helpers +# --------------------------------------------------------------------------- + + +def load_existing(output_path: Path) -> dict: + """Load an existing annotations file, returning an empty structure if absent.""" + if output_path.exists(): + try: + return json.loads(output_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + pass + return {"annotations": {}} + + +def write_output(data: dict, output_path: Path, kicad_ref: str) -> None: + data["_meta"] = { + "kicad_ref": kicad_ref, + "generator": "generate_tool_annotations.py", + } + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + print(f" Written: {output_path} ({len(data.get('annotations', {}))} annotations)") + + +def dry_run(messages: dict[str, ProtoMessage]) -> None: + print(f"\nDry run — {len(messages)} command messages found:\n") + for name in sorted(messages): + msg = messages[name] + comment = (msg.comment[:72] + "…") if len(msg.comment) > 75 else msg.comment + source = f" [{msg.source_file}]" + print(f" {name:<40} {comment or '(no comment)'}") + print(f" {'':40} {source}") + if msg.fields: + for f in msg.fields[:3]: + print(f" {f.name}: {f.type_name}") + if len(msg.fields) > 3: + print(f" … and {len(msg.fields) - 3} more field(s)") + print() + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="generate_tool_annotations", + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + source = p.add_mutually_exclusive_group(required=True) + source.add_argument( + "--proto-dir", + metavar="PATH", + type=Path, + help="Local directory containing KiCad proto files (e.g. /path/to/kicad/api/proto).", + ) + source.add_argument( + "--fetch-from-gitlab", + action="store_true", + help="Download proto files directly from KiCad's GitLab repository.", + ) + + p.add_argument( + "--kicad-ref", + metavar="REF", + default="master", + help="Git ref (branch, tag, or commit) to fetch from GitLab. Default: master.", + ) + p.add_argument( + "--output", + metavar="FILE", + type=Path, + default=Path(DEFAULT_OUTPUT), + help=f"Output JSON file. Default: {DEFAULT_OUTPUT}.", + ) + p.add_argument( + "--model", + metavar="MODEL", + default=DEFAULT_MODEL, + help=f"Claude model to use for annotation. Default: {DEFAULT_MODEL}.", + ) + p.add_argument( + "--resume", + action="store_true", + help="Skip messages that already have annotations in the output file.", + ) + p.add_argument( + "--dry-run", + action="store_true", + help="Parse proto files and list what would be annotated; do not call the API.", + ) + + backend = p.add_mutually_exclusive_group() + backend.add_argument( + "--use-cli", + action="store_true", + help=( + "Use the 'claude' CLI (Claude Code) instead of the SDK. " + "Works with a Claude.ai monthly plan — no API key needed. " + "Requires the 'claude' binary on PATH." + ), + ) + backend.add_argument( + "--use-sdk", + action="store_true", + default=True, + help="Use the Anthropic Python SDK (requires ANTHROPIC_API_KEY). This is the default.", + ) + + return p + + +def main(argv: Optional[list[str]] = None) -> int: + args = build_parser().parse_args(argv) + + # ── load proto files ───────────────────────────────────────────────────── + if args.fetch_from_gitlab: + print(f"Fetching proto files from GitLab (ref={args.kicad_ref}) ...") + proto_files = fetch_proto_from_gitlab(args.kicad_ref) + kicad_ref = args.kicad_ref + else: + proto_dir = args.proto_dir.expanduser().resolve() + if not proto_dir.is_dir(): + sys.exit(f"--proto-dir does not exist: {proto_dir}") + print(f"Loading proto files from {proto_dir} ...") + proto_files = load_proto_from_dir(proto_dir) + kicad_ref = "local" + + print(f" Loaded {len(proto_files)} proto file(s)") + + # ── parse ──────────────────────────────────────────────────────────────── + messages = parse_all_protos(proto_files) + request_count = sum(1 for m in messages.values() if not m.is_response) + print(f" Parsed {len(messages)} messages ({request_count} request, " + f"{len(messages) - request_count} response/type)") + + if args.dry_run: + dry_run_cmd = { + name: msg + for name, msg in messages.items() + if "_commands" in msg.source_file and not msg.is_response + } + dry_run(dry_run_cmd) + return 0 + + # ── annotate ───────────────────────────────────────────────────────────── + existing = load_existing(args.output) if args.resume else {"annotations": {}} + result = call_claude( + messages, + args.model, + existing, + resume=args.resume, + use_cli=args.use_cli, + ) + + # ── write ──────────────────────────────────────────────────────────────── + write_output(result, args.output, kicad_ref) + return 0 + + +if __name__ == "__main__": + sys.exit(main())