Merge pull request #116 from thesamprice/feat/ipc-annotation-generator
feat: Claude-powered annotation generator for KiCad IPC API tools
This commit is contained in:
4
python/annotations/__init__.py
Normal file
4
python/annotations/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""KiCad IPC API tool annotations package."""
|
||||
from .loader import AnnotationLoader
|
||||
|
||||
__all__ = ["AnnotationLoader"]
|
||||
565
python/annotations/kicad_ipc_tool_annotations.json
Normal file
565
python/annotations/kicad_ipc_tool_annotations.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
169
python/annotations/loader.py
Normal file
169
python/annotations/loader.py
Normal file
@@ -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)"
|
||||
@@ -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")
|
||||
|
||||
777
scripts/generate_tool_annotations.py
Normal file
777
scripts/generate_tool_annotations.py
Normal file
@@ -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())
|
||||
Reference in New Issue
Block a user