app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge: multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
← Commit history
PCM proof: wiki page as a KiCad PCM repository (verbs, sweep fix, send_key menu, docs with screenshots, runner phase fix), version 1.0.2
16 files changed
+269−25
SKILL.md+27−2docs/pcm.md+143−12docs/pcm/05-installed-1.0.0.pngdocs/pcm/07-update-available.pngdocs/pcm/08-applying.pngdocs/pcm/08-installed-1.1.0.pngdocs/pcm/08-pending.pngdocs/pcm/09-symbol-1.1.0.pngdocs/pcm/10-installed-1.2.0-headless.pngdocs/pcm/10-symbol-1.2.0-headless.pngdocs/rust-port-plan.md+3rust/Cargo.lock+3−3rust/Cargo.toml+1−1rust/crates/kicad-bridge/src/verbs_windows.rs+26−3rust/crates/kicad-core/src/windows_model.rs+34skills/kicad-bridge-test/run_verb_tests.py+32−4SKILL.md+27−2@@ -1,7 +1,7 @@ --- name: adom-desktop-kicad public: true-description: "Launch and drive KiCad on the user's laptop (KiCad 7/8/9/10 supported, multi-version side-by-side) from this container via the adom-bridge CLI. Open schematics/boards/symbols/footprints, run DRC, install sym-lib-table / fp-lib-table libraries, show the 3D viewer, capture KiCad window screenshots, send keyboard shortcuts and click coordinates. Trigger words: kicad, open schematic, open board, open pcb, open symbol, open footprint, KiCad symbol editor, KiCad footprint editor, KiCad 3D viewer, run DRC, install KiCad library, sym-lib-table, fp-lib-table, KiCad screenshot, kicad_send_key, kicad_click, kicad_window_info, kicad_screenshot_all, close KiCad, kicad versions, kicad_list_versions, KiCad bridge."+description: "Launch and drive KiCad on the user's laptop (KiCad 7/8/9/10 supported, multi-version side-by-side) from this container via the adom-bridge CLI. Open schematics/boards/symbols/footprints, run DRC, install sym-lib-table / fp-lib-table libraries, show the 3D viewer, capture KiCad window screenshots, send keyboard shortcuts and click coordinates. Trigger words: kicad, open schematic, open board, open pcb, open symbol, open footprint, KiCad symbol editor, KiCad footprint editor, KiCad 3D viewer, run DRC, install KiCad library, sym-lib-table, fp-lib-table, KiCad screenshot, kicad_send_key, kicad_click, kicad_window_info, kicad_screenshot_all, close KiCad, kicad versions, kicad_list_versions, KiCad bridge, PCM, Plugin and Content Manager, PCM repository, kicad_pcm_install, kicad_pcm_add_repository, PCM update." --- # adom-bridge: KiCad bridge@@ -104,7 +104,7 @@ If you pass a version that's not installed, the bridge returns a hint listing th | `kicad_run_drc` | `run_drc` | Run DRC on the current board, return violations | `filePath`, optional `kicadVersion` | | `kicad_fix_keyboard` | `fix_keyboard` | Workaround stuck-modifier bug after screen lock |: | | `kicad_screenshot_all` | `screenshot_all` | Capture every open KiCad window (lossless PNGs) |: |-| `kicad_send_key` | `send_key` | Single key to an EXACT `hwnd` (e.g. Enter/Escape to a dialog). For modifier CHORDS ("ctrl+s", "alt+3") use Bridge's `desktop_press_key`: don't route chords here. | `hwnd`, `key` |+| `kicad_send_key` | `send_key` | Single key to an EXACT `hwnd` (e.g. Enter/Escape to a dialog). For modifier CHORDS ("ctrl+s", "alt+3") use Bridge's `desktop_press_key`: don't route chords here. From 1.0.2 `menu` fires a menu item by label substring on the window's menu bar, background, no focus change (see "PCM repositories on the wiki") | `hwnd`, `key` or `menu` | | `kicad_click` | `click` | Send a click at (x, y) in a KiCad window | `hwnd`, `x`, `y` | | `kicad_adom_library_status` | `adom_library_status` | Report whether the Adom shared library is registered |: | | `kicad_adom_library_heal` | `adom_library_heal` | Reconcile the Adom library: `mode` merge (default) / repoint / prune; `dryRun` previews. From 0.9.279 `prune` drops symbols KiCad refuses | `mode`, `dryRun` |@@ -307,6 +307,31 @@ Every response: `success`, `output`, `path`, `backup`, `uuid`, `erc {ran, before - **`ai` is the recommendation.** It returns a plan request (pads, nets with their pads, the live routing state when the PCB editor has the board open) and mutates nothing; you then route with `kicad_route_net`, one native undo step per trace, DRC-checked per step, and you can explain every trace. Frontier models route real boards this way (the recorded Astra runs on the adom/codex page rebuilt a 94-footprint board to zero unconnected items). - **`freerouting`** is for the user who wants a deterministic pass. It is not installed by default: `kicad_freerouting {"action":"status"|"install"|"uninstall"}` fetches Freerouting's own bundle (88 MB download, 141 MB on disk, its own Java runtime inside, nothing installed on the PC, no UAC) into the bridge cache only when the user says yes, and removes it on its own. The full choice, the AI recipe and the verification rules are in the `kicad-autorouting` skill. +## PCM repositories on the wiki (from 1.0.2)++KiCad's Plugin and Content Manager (PCM) installs libraries and plugins from a "repository", which is three files behind anonymous HTTPS GETs: `repository.json`, `packages.json` and one zip per version. Every wiki page serves its files that way, so a wiki page IS a PCM repository, and a release is a version bump plus `adom-wiki repo push`. The bridge drives the same registry and the same 3rdparty folder KiCad's own dialog uses, so the two stay in agreement. The worked example, proven end to end on KiCad 10.0.5, is [docs/pcm.md](docs/pcm.md); its sample repository URL is `https://wiki.adom.inc/api/pages/adom/kicad-bridge/files/pcm/repository.json` and its package identifier is `com.adom.kicad-bridge.sample-lib`.++| Verb | Args | Key output |+|---|---|---|+| `kicad_pcm_add_repository` | `url`, optional `name` | `added`, `alreadyRegistered`, `name`, `repositoryId`, `verified` (the repository.json was fetched and is one), `file`, `backup` (`kicad.json.adom-bak`), `repositories[]`, `restartNeeded` |+| `kicad_pcm_remove_repository` | `url` | `removed`, `backup`, `repositories[]`. Packages installed from it stay installed |+| `kicad_pcm_list` | optional `checkUpdates` (default true) | per package `version`, `latestVersion`, `updateAvailable`, `updateCheck` (`ok`, `pinned`, `repository_not_registered`, `package_not_in_repository`, `fetch_failed`), `repositoryUrl`; top-level `count`, `updatesAvailable`, `repositories[]` |+| `kicad_pcm_install` | `identifier`, optional `repositoryUrl`, `version`, `update`, `libPrefix`, `force` | `version`, `installedTo`, `symbolLibraries`, `footprintLibraries`, `restartNeeded`; with `update:true` either `upToDate:true` or `previousVersion`, `version`, `updated:true` |+| `kicad_pcm_uninstall` | `identifier` | `removedDirs`, `recordRemoved`, `restartNeeded` |++The flow, in the order a user asks for it:++1. **Register**: `kicad_pcm_add_repository '{"url": REPO_URL}'`. Idempotent (`alreadyRegistered:true` on a second call). It edits `kicad.json`, which a running KiCad overwrites on exit, so do it with KiCad closed, or tell the user KiCad's own dialog (Plugin and Content Manager > Manage) is the alternative while it runs. `restartNeeded:true` is always reported: KiCad reads the list at start.+2. **Install**: `kicad_pcm_install '{"repositoryUrl": REPO_URL, "identifier": ID}'`. Newest version compatible with the detected KiCad unless `version` pins one; zip verified against `download_sha256`; unpacked under the 3rdparty root; the row written to `installed_packages.json` with the same `repository_id` KiCad computes, which is why KiCad's own dialog then shows the package as Installed. KiCad registers the `PCM_<name>` library rows itself on its next start (`pcm.lib_auto_add`).+3. **Check for updates**: `kicad_pcm_list '{}'`. `updatesAvailable` above 0 means a newer version is served; the per-package row says which (`latestVersion`) and `updateCheck` says why a check could not run.+4. **Update**, two ways, the user picks:+ - **Headless**: `kicad_pcm_install '{"identifier": ID, "update": true}'`. Resolves the repository from the installed row (no `repositoryUrl` needed), replaces the version on disk and in the registry, answers `previousVersion`, `version`, `updated:true`. KiCad's own PCM dialog shows the new version the next time it opens (no restart), and an editor shows the new content on its next open. `upToDate:true` means nothing to do.+ - **In KiCad**: tell the user that KiCad's PCM dialog shows the Update button on the package after a restart (its update check runs at startup, and the Refresh button inside an already open dialog does not recompute that flag). Update All, then Apply Pending Changes, then Close: KiCad writes `installed_packages.json` when the dialog closes, so `kicad_pcm_list` agrees only after Close.++To open the PCM dialog for a screenshot without touching the user's focus: `kicad_send_key '{"menu": "Plugin and Content Manager"}'` on the project manager (pass `hwnd` from `kicad_window_info` when more than one KiCad window is up), then `kicad_screenshot_all '{}'` captures the dialog as a child of that window. The same `menu` arg fires any menu item by label substring (`{"menu": "zoom to fit"}` on an editor). Two related facts from 1.0.2: the automatic dialog sweep leaves windows the user opened on purpose alone (the PCM dialog, "Applying Package Changes", Preferences, Manage Symbol/Footprint Libraries, Configure Paths, Board Setup, Schematic Setup, Symbol Fields Table, Design Rules), and `kicad_dismiss_dialogs '{"dismissAll": true}'` is the call that still closes them. Inside the PCM dialog, UI Automation reaches the tabs (`desktop_ui_select` with `role:"tab"`) and the "Update All", "Apply Pending Changes", "Refresh" and "Close" buttons (`desktop_ui_click`); the per-card Update button is not exposed to UIA.++Publishing your own repository (the packer, the layout, the release loop, and what bit): [docs/pcm.md](docs/pcm.md).+ ## Rescan after installs: `kicad_rescan_libraries` Install N parts, call this once, then show them all (wiki #51). It reloads every open editor's library tree in place (seconds, nothing closed) and, given `footprints: ["Lib:Name", ...]`, loads each in the Footprint Editor as proof (`verified`, `probed[]`). `force: true` is the process restart and refuses while KiCad runs unless `confirmRestart: true`.
docs/pcm.md+143−12@@ -182,7 +182,9 @@ skips the network. installed package already at the newest version answers `upToDate: true`. Either way the Symbol Editor shows `PCM_Adom_Sample` > `AdomSample_R` with the Value-`R v1.1.0` after the restart.+`R v1.1.0` on its next open (the library row already exists; only the file under it+changed). The proof below has the measured details, including the step KiCad's own+Refresh button does not do. ## Verb shapes @@ -220,14 +222,143 @@ it cannot yet tell whether KiCad is running, and it never restarts KiCad for you ## Proof on ConfRoomROG (KiCad 10.0.5) -Placeholders for the run; each line gets its output or a screenshot.--1. Repository served: `curl -s https://wiki.adom.inc/api/pages/adom/kicad-bridge/files/pcm/repository.json` returns the JSON; the zip at `packages.json`'s `download_url` hashes to its `download_sha256`. Result: DONE 2026-09-12 from the container, anonymously: repository.json HTTP 200 `application/json`; served packages.json sha256 `6ad4fc7b7cec57ed...` equals `packages.sha256`; served zip 13143 bytes, sha256 `8a7e632acd313993...` equals `download_sha256`.-2. `kicad_pcm_add_repository {url}` on ConfRoomROG: `added: true`, `kicad.json.adom-bak` present. Result: PENDING-3. `kicad_pcm_install {repositoryUrl, identifier}`: `version: "1.0.0"`, `symbolLibraries: ["PCM_Adom_Sample"]`, `footprintLibraries: ["PCM_Adom_Sample"]`. Result: PENDING-4. `kicad_pcm_list {}`: `updateAvailable: false`, `latestVersion: "1.0.0"`, `updateCheck: "ok"`. Result: PENDING-5. KiCad restarted; Plugin and Content Manager > Installed shows Adom Sample Library 1.0.0 from "Adom KiCad Bridge sample PCM"; Symbol Editor shows `PCM_Adom_Sample` with ten symbols. Screenshot: PENDING-6. `python3 tools/build_pcm.py --bump 1.1.0 --stamp`, push. `kicad_pcm_list {}`: `updateAvailable: true`, `latestVersion: "1.1.0"`. Result: PENDING-7. KiCad restarted; Plugin and Content Manager shows Update on Adom Sample Library (opened through the native menu). Screenshot: PENDING-8. `kicad_pcm_install {identifier, update: true}`: `previousVersion: "1.0.0"`, `version: "1.1.0"`, `updated: true`. Result: PENDING-9. KiCad restarted; Symbol Editor > `PCM_Adom_Sample` > `AdomSample_R` shows Value `R v1.1.0`. Screenshot: PENDING+Run on 2026-09-12 against ConfRoomROG (KiCad 10.0.5, Windows) from this container over+the relay, on a staged 1.0.1 build of the native bridge carrying the PCM verbs (the verbs+and the two fixes below ship as 1.0.2). Every line below is taken from the real verb+output or the real screen; nothing here is paraphrased from the plan.++1. **Repository served.** From the container, anonymously: `repository.json` answers+ HTTP 200 `application/json` with name "Adom KiCad Bridge sample PCM" (repositoryId+ `7f2e2eb5b2536094`); the served `packages.json` sha256 `6ad4fc7b7cec57ed...` equals+ `packages.sha256`; the served zip is 13143 bytes with sha256 `8a7e632acd313993...`,+ equal to `download_sha256`.+2. **`kicad_pcm_add_repository {url}`**, KiCad closed: `added: true`,+ `alreadyRegistered: false`, `verified: true`, backup+ `C:/Users/john/AppData/Roaming/kicad/10.0/kicad.json.adom-bak`, `restartNeeded: true`.+ `repositories[]` after the write: KiCad official repository, Adom KiCad Bridge sample PCM.+3. **`kicad_pcm_install {repositoryUrl, identifier: "com.adom.kicad-bridge.sample-lib"}`**:+ `version: "1.0.0"`, `installedTo`+ `C:/Users/john/Documents/KiCad/10.0/3rdparty/*/com_adom_kicad-bridge_sample-lib`,+ `symbolLibraries: ["PCM_Adom_Sample"]`, `footprintLibraries: ["PCM_Adom_Sample"]`,+ `restartNeeded: true`.+4. **`kicad_pcm_list {}`**: `count: 1`, `updatesAvailable: 0`; the package row reads+ `version: "1.0.0"`, `latestVersion: "1.0.0"`, `updateAvailable: false`, `updateCheck: "ok"`.+5. **KiCad launched.** The Symbol Editor lists `PCM_Adom_Sample` with the description+ "Added by Plugin and Content Manager" (KiCad registered it itself through+ `pcm.lib_auto_add`) and `AdomSample_R` with Value `R`. The PCM dialog, opened through the+ native menu with `kicad_send_key {"menu": "Plugin and Content Manager"}` on the project+ manager, shows Installed (1): Adom Sample Library 1.0.0.++ ++6. **Bump.** `python3 tools/build_pcm.py --bump 1.1.0 --stamp` (stamps `AdomSample_R`'s+ Value to `R v1.1.0`), five files pushed. The served `packages.json` then lists 1.0.0 and+ 1.1.0 (it is served with `cache-control: no-cache`, so KiCad always reads the new file).+ `kicad_pcm_list {}`: `updatesAvailable: 1`; the row reads `version: "1.0.0"`,+ `latestVersion: "1.1.0"`, `updateAvailable: true`.+7. **KiCad restarted** (its own update check runs at startup). The PCM dialog's Installed+ tab shows the Update button on the card.++ ++8. **Update, the user's way.** Update All, then Apply Pending Changes, both pressed through+ UI Automation with no focus change. The Pending tab listed "Update, Adom Sample Library,+ 1.0.0 -> , Adom KiCad Bridge sample PCM"; the "Applying Package Changes" dialog+ downloaded 14/14 kB; Installed then showed 1.1.0 in bold with Uninstall back and no+ Update. On disk `Adom_Sample.kicad_sym` carries Value `R v1.1.0`. KiCad writes+ `installed_packages.json` when the PCM dialog closes, so `kicad_pcm_list` reported+ `version: "1.1.0"`, `latestVersion: "1.1.0"`, `updateAvailable: false` only after Close.++ ++ ++ ++9. **Symbol Editor reopened** on `PCM_Adom_Sample:AdomSample_R`: Value `R v1.1.0`, canvas painted.++ ++10. **Update, the headless way.** Bumped to 1.2.0 and pushed (served versions 1.0.0, 1.1.0,+ 1.2.0). `kicad_pcm_list`: `version: "1.1.0"`, `latestVersion: "1.2.0"`,+ `updateAvailable: true`. `kicad_pcm_install {identifier, update: true}`:+ `previousVersion: "1.1.0"`, `version: "1.2.0"`, `updated: true`, `restartNeeded: true`.+ On disk the Value is `R v1.2.0`; `kicad_pcm_list` reads 1.2.0 with+ `updateAvailable: false`. The Symbol Editor, reopened, shows Value `R v1.2.0`, and+ KiCad's own PCM dialog, opened without restarting KiCad, shows Installed 1.2.0.++ ++ ++### Headless update++The bridge path needs no dialog and no restart of KiCad for the registry to agree:++```bash+adom-bridge kicad_pcm_list '{}'+# packages[0]: version 1.1.0, latestVersion 1.2.0, updateAvailable true, updateCheck ok; updatesAvailable 1+adom-bridge kicad_pcm_install '{"identifier":"com.adom.kicad-bridge.sample-lib","update":true}'+# previousVersion 1.1.0, version 1.2.0, updated true, restartNeeded true+adom-bridge kicad_pcm_list '{}'+# packages[0]: version 1.2.0, latestVersion 1.2.0, updateAvailable false; updatesAvailable 0+```++`update: true` resolves the repository from the installed row's `repository_id` (no+`repositoryUrl` needed), fetches `repository.json` and `packages.json`, verifies the zip+against `download_sha256`, removes the previous version's directories, unpacks the new+one and rewrites the row in `installed_packages.json`. A package already at the newest+version answers `upToDate: true`. What KiCad sees afterwards, measured in step 10: its own+PCM dialog shows the new version immediately (it reads `installed_packages.json` when the+dialog opens), and an editor shows the new content on its next open, because the library+row already exists and the file under it changed. `restartNeeded: true` stays in the+response because an editor that already has the library loaded keeps the old copy in+memory until it is reopened.++### What the proof taught us++Two bridge fixes came out of the run, both in the same build:++- **The dialog sweep left tool windows alone.** The post-verb sweep used to expire every+ non-progress dialog. That closed the PCM dialog behind the user's back and, worse,+ pressed Close on "Applying Package Changes" mid-apply, which aborted the first update+ attempt. Windows opened on purpose (Plugin and Content Manager, Applying Package+ Changes, Preferences, Manage Symbol Libraries, Manage Footprint Libraries, Configure+ Paths, Board Setup, Schematic Setup, Symbol Fields Table, Design Rules) are now left+ alone by the automatic sweep; `kicad_dismiss_dialogs {"dismissAll": true}` still closes them.+- **`kicad_send_key` gained `menu`.** A label substring is resolved from the target window's+ menu bar and fired as a menu command without touching focus:+ `{"menu": "Plugin and Content Manager"}` on the project manager opens the PCM dialog,+ `{"menu": "zoom to fit"}` on an editor repaints it. The dialog it opens is a child of that+ window, so `kicad_screenshot_all` captures it.++Three KiCad facts worth keeping:++- **`installed_packages.json` is written when the PCM dialog closes**, not when Apply+ finishes. A `kicad_pcm_list` between Apply and Close still reports the old version; the+ files on disk are already new.+- **Refresh in an open dialog does not recompute the installed-package update flag.** The+ startup update check does, and so does reopening the dialog. To show a user the Update+ button after a bump, restart KiCad (or close and reopen the dialog); do not press Refresh+ and conclude the repository is stale.+- **UI Automation on the PCM dialog.** Tabs need SelectionItem+ (`ab desktop_ui_select {window, contains, role: "tab"}`); the per-card Update button is+ not exposed to UIA, but "Update All", "Apply Pending Changes", "Refresh" and "Close" are+ (`ab desktop_ui_click`); `ab desktop_ui_window {action: "resize"}` widens the dialog so+ the card text is readable in a capture. All of it runs in the background with no focus steal.++### What this means for releases++Ray's point, now measured: the wiki page is git under the hood, every file in it has an+anonymous URL, and the two JSON files are served `no-cache`. So the whole release process+for a KiCad library or plugin is a version bump plus a push:++```bash+python3 tools/build_pcm.py --bump 1.3.0+adom-wiki repo push adom/kicad-bridge --files pcm/src/adom-sample-lib/metadata.json pcm/packages.json pcm/repository.json pcm/packages/com.adom.kicad-bridge.sample-lib-1.3.0.zip -m "sample-lib 1.3.0"+```++KiCad's PCM picks it up on its next startup check with no help from the bridge (step 7),+and the bridge picks it up on the next `kicad_pcm_list` (step 6). The page's commit history+is the changelog, the old zips stay where the old `packages.json` rows point, and there is+no host, bucket or CDN to run beside the wiki.
docs/pcm/05-installed-1.0.0.pngadded⋯ 1 unchanged line ⋯
docs/pcm/07-update-available.pngadded⋯ 1 unchanged line ⋯
docs/pcm/08-applying.pngadded⋯ 1 unchanged line ⋯
docs/pcm/08-installed-1.1.0.pngadded⋯ 1 unchanged line ⋯
docs/pcm/08-pending.pngadded⋯ 1 unchanged line ⋯
docs/pcm/09-symbol-1.1.0.pngadded⋯ 1 unchanged line ⋯
docs/pcm/10-installed-1.2.0-headless.pngadded⋯ 1 unchanged line ⋯
docs/pcm/10-symbol-1.2.0-headless.pngadded⋯ 1 unchanged line ⋯
docs/rust-port-plan.md+3@@ -289,3 +289,6 @@ Total: roughly 18 to 21 weeks of one AI thread's time with human test time on th - [How the bridge compares](comparison.md): the competitive audit this plan was written alongside. - [Dashboard](dashboard.md): the dock card and the dashboard. - Verb reference and the live routing contract: the main [README](../README.md) and [SKILL.md](../SKILL.md).++- 2026-09-12 (later): comparison.md reworked after John's review. The three "where others lead" items are now "three claims we chose not to match" (KiCad reads its own files, permissions live in the AI harness and in ab, discovery is skills plus describe plus wiki auto-discovery). SPICE simulation added as the one open gap with an ecosystem-wide plan (ngspice runner in the container, kicad_export_spice, kicad_simulate); verified kicad-cli spice export and bundled ngspice on ConfRoomROG. PCM proof in progress (sample repository on the page, install, bump, update).+- 2026-09-12 (later): PCM proof passed end to end on ConfRoomROG (KiCad 10.0.5) and is written up in [pcm.md](pcm.md). The sample repository on this page (three anonymous files, `no-cache` JSON) was registered with `kicad_pcm_add_repository`, installed with `kicad_pcm_install` (1.0.0, `PCM_Adom_Sample` registered by KiCad itself on launch), bumped to 1.1.0 with the packer and pushed, seen by `kicad_pcm_list` (`updatesAvailable: 1`) and by KiCad's own PCM dialog after a restart (Update button on the card), updated the user's way through UI Automation (Update All, Apply Pending Changes, Close) with the Symbol Editor showing `R v1.1.0`, then bumped to 1.2.0 and updated headlessly with `kicad_pcm_install {update:true}` (`previousVersion 1.1.0, version 1.2.0, updated true`), which KiCad's dialog showed without a restart. Two fixes came out of it, both in the build that ships as 1.0.2: the automatic dialog sweep no longer closes tool windows opened on purpose (it had pressed Close on "Applying Package Changes" mid-apply and aborted the first update), and `kicad_send_key` gained `menu` (a label substring fired on the window's menu bar as a background menu command; how the PCM dialog is opened). New verbs: `kicad_pcm_add_repository`, `kicad_pcm_remove_repository`, `kicad_pcm_list` with the update check, `kicad_pcm_install {update:true}`; documented in SKILL.md under "PCM repositories on the wiki" and covered by the verb runner (real add in phase 4, conditional remove in phase 6). Three KiCad facts recorded for the port: `installed_packages.json` is written when the PCM dialog closes, Refresh inside an open dialog does not recompute the installed-package update flag (the startup check does), and the per-card Update button is not exposed to UIA while Update All, Apply Pending Changes, Refresh and Close are.
rust/Cargo.lock+3−3@@ -244,7 +244,7 @@ dependencies = [ [[package]] name = "kicad-bridge"-version = "1.0.1"+version = "1.0.2" dependencies = [ "kicad-core", "kicad-platform",@@ -255,7 +255,7 @@ dependencies = [ [[package]] name = "kicad-core"-version = "1.0.1"+version = "1.0.2" dependencies = [ "kicad-ipc-rs", "kicad-platform",@@ -280,7 +280,7 @@ dependencies = [ [[package]] name = "kicad-platform"-version = "1.0.1"+version = "1.0.2" dependencies = [ "serde", "serde_json",
rust/Cargo.toml+1−1@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package]-version = "1.0.1"+version = "1.0.2" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/verbs_windows.rs+26−3@@ -195,11 +195,11 @@ pub static VERBS: &[Verb] = &[ }, Verb { name: "kicad_send_key",- summary: "Send a key to KiCad; chords auto-resolve to menu commands (background)",+ summary: "Send a key to KiCad, or fire a menu item by label; chords and menu labels resolve to menu commands (background)", mechanism: Mechanism::Window, risk: "input", timeout_sec: 30,- input: "{\"key\": \"enter\"|\"escape\"|\"a\"|\"ctrl+s\", \"hwnd\"?: 123}",+ input: "{\"key\"?: \"enter\"|\"escape\"|\"a\"|\"ctrl+s\", \"menu\"?: \"Plugin and Content Manager\", \"hwnd\"?: 123}", example: "kicad_send_key {\"hwnd\":123456,\"key\":\"escape\"}",- hint: "For modifier CHORDS ('ctrl+s', 'alt+3', 'ctrl+shift+p') use Bridge's desktop_press_key; it owns full chord parsing; do not route chords here. Use this verb only for the case Bridge's focus-first model cannot cover: a single key delivered to an EXACT hwnd (e.g. Enter/Escape to a specific dialog from window_info).",+ hint: "menu: a label substring fired as a menu command on the target window without touching focus (e.g. menu:\"Plugin and Content Manager\" on the project manager opens the PCM dialog; the dialog is a child, kicad_screenshot_all captures it). For modifier CHORDS ('ctrl+s', 'alt+3', 'ctrl+shift+p') use Bridge's desktop_press_key; it owns full chord parsing; do not route chords here. Use this verb only for the case Bridge's focus-first model cannot cover: a single key delivered to an EXACT hwnd (e.g. Enter/Escape to a specific dialog from window_info).", related: &["kicad_window_info", "kicad_click", "kicad_fix_keyboard"], pitfalls: &["not for chords; use desktop_press_key", "needs a target hwnd (from window_info)", "allowForeground (real keystrokes) is not in the native build"], },@@ -2343,6 +2343,29 @@ fn foreground_kicad_hwnd() -> Option<u64> { } fn send_key(args: &Value) -> Value {+ // `menu`: a menu-item label (substring, case-insensitive) fired as WM_COMMAND on the+ // target window's menu bar, fully background. The way the PCM dialog is opened.+ if let Some(needle) = arg_str(args, "menu").map(str::trim).filter(|s| !s.is_empty()) {+ let target = match arg_any_u64(args, &["hwnd"]) {+ Some(h) => h,+ None => match foreground_kicad_hwnd() {+ Some(h) => h,+ None => return fail("no_window", "No KiCad window found to send the menu command to.", "kicad_window_info lists the windows; pass hwnd."),+ },+ };+ let title = title_of(target);+ return match menu_invoke_by_text(target, needle) {+ Ok(Some((id, label))) => json!({+ "success": true, "mechanism": "menu",+ "output": format!("Menu item '{label}' fired on '{title}' (background)"),+ "data": {"method": "menu-command", "cmdId": id, "menuItem": label, "hwnd": target, "title": title, "foreground": false},+ "_hint": "Resolved from the window's menu bar by label and fired with WM_COMMAND; no focus was touched. A dialog it opens is a child of this window: kicad_screenshot_all captures it.",+ }),+ Ok(None) => fail("menu_item_not_found", &format!("No menu item on '{title}' contains '{needle}'."), "kicad_window_info lists windows; the label match is a case-insensitive substring of the item text without '&'."),+ Err(e) if is_not_impl(&e) => unsupported("native menu walk (menu_tree)"),+ Err(e) => fail("menu_invoke_failed", &e, "Retry once the window is idle; pass hwnd explicitly."),+ };+ } let Some(key) = arg_str(args, "key").map(str::trim) else { return fail("missing_arg", "Missing required arg: key", "Pass key: a named key (enter/escape/tab/space/f1-f12), a single character, or a chord like 'ctrl+s'."); };
rust/crates/kicad-core/src/windows_model.rs+34@@ -200,6 +200,29 @@ pub fn is_progress_dialog(title: &str, body: &str) -> bool { PROGRESS_TITLE_PATTERNS.iter().any(|p| t.starts_with(p) || t == *p) } +/// Tool windows KiCad opens on purpose from a menu (PCM, Preferences, the library+/// tables, Board/Schematic Setup). They are dialogs by window class but never an error+/// box: the sweep leaves them for the AI or the user to close.+pub const TOOL_DIALOG_TITLES: &[&str] = &[+ "plugin and content manager",+ "applying package changes",+ "preferences",+ "manage symbol libraries",+ "manage footprint libraries",+ "symbol libraries",+ "footprint libraries",+ "configure paths",+ "board setup",+ "schematic setup",+ "symbol fields table",+ "design rules",+];++pub fn is_tool_dialog(title: &str) -> bool {+ let t = title.trim().to_lowercase();+ TOOL_DIALOG_TITLES.iter().any(|p| t == *p || t.starts_with(&format!("{p} ")))+}+ pub fn is_save_prompt(title: &str, body: &str) -> bool { let t = format!("{title} {body}").to_lowercase(); SAVE_PROMPT_PATTERNS.iter().any(|p| t.contains(p))@@ -232,6 +255,9 @@ pub fn sweep_decision(title: &str, body: &str, dismiss_all: bool) -> DialogActio if is_progress_dialog(title, body) { return DialogAction::LeaveProgress; }+ if is_tool_dialog(title) && !dismiss_all {+ return DialogAction::Leave;+ } let benign = is_benign_dialog(title, body); if benign || dismiss_all { DialogAction::Dismiss { benign }@@ -245,6 +271,8 @@ pub fn sweep_decision(title: &str, body: &str, dismiss_all: bool) -> DialogActio pub fn auto_sweep_decision(title: &str, body: &str) -> DialogAction { if is_progress_dialog(title, body) { DialogAction::LeaveProgress+ } else if is_tool_dialog(title) {+ DialogAction::Leave } else { DialogAction::Dismiss { benign: is_benign_dialog(title, body) } }@@ -590,6 +618,12 @@ mod tests { assert_eq!(auto_sweep_decision("Error", "Cannot open"), DialogAction::Dismiss { benign: false }); assert_eq!(auto_sweep_decision("Rendering", ""), DialogAction::LeaveProgress); assert_eq!(auto_sweep_decision("Information", "converted to the new format"), DialogAction::Dismiss { benign: true });+ // Tool windows opened on purpose (PCM, Preferences, library tables) are never swept.+ assert_eq!(auto_sweep_decision("Plugin And Content Manager", ""), DialogAction::Leave);+ assert_eq!(auto_sweep_decision("Applying Package Changes", ""), DialogAction::Leave);+ assert_eq!(sweep_decision("Preferences", "", false), DialogAction::Leave);+ assert_eq!(sweep_decision("Plugin And Content Manager", "", true), DialogAction::Dismiss { benign: false });+ assert!(!is_tool_dialog("Error")); } #[test]
skills/kicad-bridge-test/run_verb_tests.py+32−4@@ -39,6 +39,8 @@ AI_THREAD = "kicad-bridge daily-test" REPO = Path(__file__).resolve().parents[2] PACK = REPO / "tour-pack-rp2040" / "template" WINDIR = "C:/Users/{user}/Documents/adom-kicad-verbtest"+# The sample PCM repository on this page (docs/pcm.md): three anonymous files, no login.+PCM_REPO_URL = "https://wiki.adom.inc/api/pages/adom/kicad-bridge/files/pcm/repository.json" # ---------------------------------------------------------------- CLI plumbing @@ -79,6 +81,9 @@ CHECKS = { "launched_or_running": lambda r: ok(r) and (r.get("launched") or r.get("alreadyRunning")), "demo_beat": lambda r: ok(r) and (r.get("say") or (r.get("data") or {}).get("say") or r.get("stage")), "state_shots": lambda r: ok(r) and r.get("running") and any(s.get("image") for s in (r.get("shots") or [])),+ # pcm_list from 1.0.2 carries the update-check fields; a bridge without them fails this check on purpose+ "pcm_listed": lambda r: ok(r) and isinstance(r.get("packages"), list) and "updatesAvailable" in r,+ "repo_added": lambda r: ok(r) and (r.get("added") is True or r.get("alreadyRegistered") is True), } def matrix(w: dict) -> dict:@@ -111,7 +116,9 @@ def matrix(w: dict) -> dict: "verb_times": dict(phase=1, args={}), "errors": dict(phase=1, args={}, note="win32 text read of KiCad message boxes; none open is fine"), "plugin_diagnose": dict(phase=1, args={}),- "pcm_list": dict(phase=1, args={}),+ "pcm_list": dict(phase=1, args={}, check="pcm_listed", timeout=90,+ note="checkUpdates defaults on: fetches each registered repository (1.0.2 fields "+ "updatesAvailable, latestVersion, updateCheck)"), "ipc_api": dict(phase=1, args={}, note="READ ONLY here: no enable arg, so nothing is written"), "adom_library_heal": dict(phase=4.5, args={"mode": "prune", "dryRun": True}, note="SAFETY: dryRun only; after the installs, because a fresh box has no Adom "@@ -196,6 +203,10 @@ def matrix(w: dict) -> dict: "install_footprint": dict(phase=4, args={"__special": "install_footprint"}, timeout=160), "install_plugin": dict(phase=4, args={}, timeout=160, note="idempotent payload re-deploy"), "place_footprint": dict(phase=4, args={"__special": "place_footprint"}, timeout=220),+ "pcm_add_repository": dict(phase=4, args={"url": PCM_REPO_URL}, check="repo_added", timeout=90,+ note="a REAL add of the sample repository (docs/pcm.md) into kicad.json, backup kept; "+ "added or alreadyRegistered both pass. KiCad may be running here, so the row "+ "can be overwritten when it exits; the verb says restartNeeded either way"), "make_part_project": dict(phase=4.5, args={"partName": "ADOM_VERBTEST"}, timeout=220, note="after install_symbol/install_footprint: a pro/sch/pcb around the test part"), "export_part": dict(phase=4.5, args={"partName": "ADOM_VERBTEST", "what": ["symbol", "footprint"]}, timeout=160,@@ -209,6 +220,10 @@ def matrix(w: dict) -> dict: "close_3d_viewer": dict(phase=6, args={}), "close": dict(phase=6, args={"force": True}, timeout=120, note="final teardown — leaves the box with no KiCad windows"),+ "pcm_remove_repository": dict(phase=6, args={"__special": "pcm_remove_repository"}, timeout=90,+ note="cleanup pair of pcm_add_repository, after close (KiCad closed, so the "+ "kicad.json edit sticks): removes the row ONLY when the add wrote it "+ "(added:true); alreadyRegistered means the user had it and it stays"), # Routing verbs need KiCad IPC and a dedicated unrouted board: covered by tests/ and demo/routing/run_demo.py. **{verb: dict(skip="Covered by tests/test_routing_ipc.py and demo/routing/run_demo.py; requires a dedicated unrouted IPC board") for verb in ("routing_state", "route_net", "remove_route", "routing_validate",@@ -216,7 +231,7 @@ def matrix(w: dict) -> dict: "install_library_bundle": dict(skip="needs a Cloud Library bundle zip staged on the box; covered by the " "install_library/install_symbol/install_footprint trio it wraps"), "pcm_install": dict(skip="writes into %KICAD_3RD_PARTY% and installed_packages.json (a real PCM package "- "download); run by hand when touching handlers/pcm.py"),+ "download); run by hand per the proof in docs/pcm.md when touching the PCM code"), "pcm_uninstall": dict(skip="pair of pcm_install; same reason"), "rescan_libraries": dict(skip="a full KiCad restart (~150 s, #51); exercised implicitly by the install verbs' " "staleEditorOpen path"),@@ -235,6 +250,7 @@ SPECIALS_DOC = """Specials are composed calls the runner fills at runtime: install_symbol -> installs a generated test symbol ADOM_VERBTEST into the Adom lib install_footprint -> installs a generated 2-pad test footprint ADOM_VERBTEST into Adom.pretty place_footprint -> places Adom:ADOM_VERBTEST on a scratch copy of the template board+ pcm_remove_repository -> removes the sample PCM repository row only if pcm_add_repository added it this run """ TEST_SYMBOL = '''(symbol "ADOM_VERBTEST" (pin_numbers hide) (in_bom yes) (on_board yes)@@ -487,6 +503,14 @@ def main(): results[verb] = {"result": "SKIP", "reason": "sch_place_symbol returned no uuid to delete"} return args = {"filePath": w["sch"], "uuid": uid}+ elif special == "pcm_remove_repository":+ added = (results.get("pcm_add_repository") or {}).get("response") or {}+ if added.get("added") is not True:+ why = ("the user already had the sample repository registered; left in place"+ if added.get("alreadyRegistered") else "pcm_add_repository did not add the row this run")+ results[verb] = {"result": "SKIP", "reason": why}+ return+ args = {"url": PCM_REPO_URL} elif special == "install_symbol": import base64 as _b64 args = {"fileName": "ADOM_VERBTEST.kicad_sym",@@ -542,13 +566,17 @@ def main(): "window_info", "screenshot_all", "dismiss_dialogs", "send_key", "click", "bridge_call"] for phase in (1, 2, 3, 4, 5, 6):- entries = [(v, s) for v, s in M.items() if s.get("phase") == phase]+ # Fractional phases (2.2 .. 2.5, 4.5) belong to the whole phase they sit in and run+ # after its integer entries, in fractional order. The old `== phase` filter never+ # matched them, so the six sch edit steps, adom_library_heal, make_part_project and+ # export_part were silently never run (2026-09-12).+ entries = [(v, s) for v, s in M.items() if int(s.get("phase", 9)) == phase] if not entries: continue if phase == 3: entries.sort(key=lambda kv: PHASE3_ORDER.index(kv[0]) if kv[0] in PHASE3_ORDER else 99) else:- entries.sort()+ entries.sort(key=lambda kv: (kv[1].get("phase", 9), kv[0])) print(f"[{phase}] {len(entries)} verbs") for verb, spec in entries: run(verb, spec)