← All Pull Requests

kicad_uninstall_library: remove a library the bridge registered (inverse of kicad_install_library) #20

Closed opened by John Lauer 2026-09-20
Merges feature/uninstall-library → master

What

New verb kicad_uninstall_library: the inverse of kicad_install_library. Until now the bridge could register a library but never remove one, so test and scratch libraries pile up in the user's tables (the daily verb test itself registers AdomVerbTest on every run and never removes it).

Requested by John (2026-09-20) and referenced from #111.

Base sha: 14e12dec0247c5863abb202f5a6034a2bdd255ec (1.0.36, the head when pushed). branch compare: 5 files, 191 insertions, 1 deletion; every hunk is additive except the module doc line in verbs_install.rs that lists the group's verbs.

Contract

kicad_uninstall_library {libraryName, libraryType?: "symbol|footprint|both" (default both), deleteFiles?: false, kicadVersion?}

  • Removes the named row from the selected version's sym-lib-table and/or fp-lib-table using the existing LibTable::remove_library + write_file. Replies removed[] {table, uri}, filesDeleted[], filesKept[] {path, why}, kicadRunning, tableReloadNeeded.
  • deleteFiles:true deletes the installed copy only when it resolves under Documents/KiCad/<ver>/symbols|footprints (the folders the install verbs write to), plus 3dmodels/<name>.3dshapes for a footprint library. A library registered in place from anywhere else is unregistered and its files are left alone, named in filesKept.
  • Refusals, each a named errorCode with the next call in Hint: library_name_required, bad_library_type, adom_library_protected (the shared Adom library), stock_library_protected (a ${KICAD*_..._DIR} row: KiCad's own libraries, never the bridge's to remove), library_not_registered, lib_table_unreadable, lib_table_write_failed.
  • When KiCad is running the reply says so: the running instance holds its tables in memory, keeps showing the library, and can write the row back when it saves. Same root cause as #111.

Files

  • rust/crates/kicad-core/src/install.rs: uninstall_library + 2 unit tests.
  • rust/crates/kicad-bridge/src/verbs_install.rs: catalog entry + dispatch (crate::verbs_show::kicad_running() for the running flag).
  • bridge.json: verb declared. SKILL.md: one table row.
  • skills/kicad-bridge-test/run_verb_tests.py: coverage entry in phase 4, right after the installs; it removes the AdomVerbTest scratch library with deleteFiles:true, so the daily run cleans up after itself.

Verified

  • cargo test -p kicad-core --no-default-features --features net: 224 passed, 0 failed (includes the 2 new tests: row removed, managed files deleted, in-place library's files kept, Adom / stock / unknown / missing name / bad type all refused, stock row still present afterwards).
  • cargo test -p kicad-bridge --no-default-features: 26 passed (catalog consistency).
  • cargo check -p kicad-bridge --target x86_64-pc-windows-gnu --no-default-features: clean.
  • run_verb_tests.py parses.

NOT verified (needs your native acceptance)

  • The full default-feature build and the release exe: this container has no cmake, so nng-sys (the ipc feature) does not build here. Nothing in this change touches ipc.
  • The verb against a real KiCad on Windows: not run. In particular, please check on a test box (a) the phase order in run_verb_tests.py puts uninstall_library after install_library as intended, (b) expand_kicad_uri + norm_path classify a ${KIPRJMOD}-free absolute Windows uri under Documents/KiCad/<ver>/footprints as managed, and (c) whether kicad_running() is the right signal for tableReloadNeeded.
  • I did not bump the version or touch RELEASE_NOTES.md, per CONTRIBUTING.

Leftover this would clean

ConfRoomROG currently has an Adom_Check footprint library I registered for an adom-footprint check (KiCad 10.0); I will remove it with this verb once it ships.

Diff Skip to comments (1)

SKILL.md+1
@@ -99,6 +99,7 @@ If you pass a version that's not installed, the bridge returns a hint listing th | `kicad_close` | `close` | Close KiCad (all editors). From 0.9.347 an editor with unsaved work is NOT killed: the reply says errorCode unsaved_changes with the dialog text. `discardChanges:true` presses Discard for the caller; `force:true` is the only kill | optional `discardChanges`, `force` | | `kicad_window_info` | `window_info` | Enumerate open KiCad windows (HWND, title, bounds, editor type) |: | | `kicad_install_library` | `install_library` | Register a `.kicad_sym` + `.pretty/` pair in sym-lib-table + fp-lib-table | `libraryPath`, `libraryType`, `libraryName`, optional `kicadVersion` |+| `kicad_uninstall_library` | `uninstall_library` | Remove a library row from sym-lib-table / fp-lib-table (the inverse of install). `deleteFiles:true` also deletes the installed copy, only under `Documents/KiCad/<ver>/`; refuses `Adom` and KiCad's stock libraries | `libraryName`, optional `libraryType` (symbol/footprint/both), `deleteFiles`, `kicadVersion` | | `kicad_install_symbol` | `install_symbol` | Add a single symbol to an existing library | `fileName`, `fileContent`, optional `kicadVersion` | | `kicad_install_footprint` | `install_footprint` | Add a single footprint to an existing library | `library`, `footprint_path`, optional `kicadVersion` | | `kicad_run_drc` | `run_drc` | Run DRC on the current board, return violations | `filePath`, optional `kicadVersion` |
bridge.json+1
@@ -36,6 +36,7 @@     "kicad_describe",     "kicad_diagnostics",     "kicad_install_library",+    "kicad_uninstall_library",     "kicad_install_library_bundle",     "kicad_install_symbol",     "kicad_install_footprint",
rust/crates/kicad-bridge/src/verbs_install.rs+20−1
@@ -1,4 +1,4 @@-//! Verb group "install": kicad_install_library, kicad_install_symbol,+//! Verb group "install": kicad_install_library, kicad_uninstall_library, kicad_install_symbol, //! kicad_install_footprint, kicad_install_library_bundle, kicad_adom_library_heal, //! kicad_make_part_project, kicad_export_part. Same arg names and response shapes //! as the Python handlers (`handlers/install_library.py`, `handlers/install_symbol.py`,@@ -38,6 +38,21 @@ pub static VERBS: &[Verb] = &[             "a .kicad_sym KiCad's own parser refuses is not copied or registered (errorCode kicad_refuses_library, wiki #71); without kicad-cli there is no verdict and the install proceeds",         ],     },+    Verb {+        name: "kicad_uninstall_library",+        summary: "Remove a library from the user's sym-/fp-lib-table: the inverse of kicad_install_library. Optionally deletes the copy the install made.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 60,+        input: "{\"libraryName\": \"MyParts\", \"libraryType\"?: \"symbol|footprint|both\" (default both), \"deleteFiles\"?: false, \"kicadVersion\"?: \"10.0\"}",+        example: "kicad_uninstall_library {\"libraryName\":\"Adom_Check\",\"deleteFiles\":true}",+        hint: "Removes the named row from the selected KiCad version's lib table(s) and answers removed[] (table + uri). deleteFiles:true also deletes the installed copy, but ONLY under Documents/KiCad/<ver>/symbols|footprints (plus 3dmodels/<name>.3dshapes): a library registered in place from anywhere else is unregistered and its files are listed in filesKept. kicadRunning/tableReloadNeeded tell you when the running KiCad still holds the old table.",+        related: &["kicad_install_library", "kicad_show_library", "kicad_list_versions", "kicad_adom_library_heal"],+        pitfalls: &[+            "refuses the shared 'Adom' library (adom_library_protected) and KiCad's own stock libraries (stock_library_protected): it only removes libraries that were added to the user tables",+            "each KiCad version has its OWN lib tables: uninstall under every version it was installed for",+            "a RUNNING KiCad holds its tables in memory and can write the row back when it saves them: uninstall with KiCad closed, or tell the user to restart it",+            "library names are case-sensitive; library_not_registered means the name is not in that version's table",+        ],+    },     Verb {         name: "kicad_install_symbol",         summary: "Merge the symbol(s) of a .kicad_sym into the single global Adom library (Adom.kicad_sym), creating and registering it on first use.",@@ -135,6 +150,10 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value>             let info = state.kicad_info();             install::install_library(&lib_ctx(&info), info.installed, args)         }+        "kicad_uninstall_library" => {+            let info = state.kicad_info();+            install::uninstall_library(&lib_ctx(&info), crate::verbs_show::kicad_running(), args)+        }         "kicad_install_symbol" => {             let info = state.kicad_info();             install::install_symbol(&lib_ctx(&info), info.installed, args)
rust/crates/kicad-core/src/install.rs+167
@@ -2018,6 +2018,124 @@ pub fn export_part(ctx: &LibCtx, args: &Value) -> Value {     }) } +// ---- uninstall_library ---------------------------------------------------------------------++/// The exact inverse of `install_library`: remove a library's row from the user's+/// sym-lib-table and/or fp-lib-table and, only when asked, delete the files the install copied.+///+/// Safety rules (each refusal is a named errorCode with the next call in the hint):+/// - the shared `Adom` library is never removed here (every Adom part lives in it);+/// - a row whose uri is a KiCad stock path (`${KICAD*_..._DIR}`) is never removed: that is+///   KiCad's own library set, not something the bridge installed;+/// - `deleteFiles` only ever deletes inside `Documents/KiCad/<ver>/{symbols,footprints,3dmodels}`,+///   the folders the install verbs write to. A library registered in place from anywhere else+///   is unregistered and its files are left alone (`filesKept` says why).+pub fn uninstall_library(ctx: &LibCtx, kicad_running: bool, args: &Value) -> Value {+    let name = s_arg(args, "libraryName");+    if name.is_empty() {+        return json!({"success": false, "errorCode": "library_name_required",+            "error": "libraryName is required",+            "Hint": "kicad_uninstall_library {\"libraryName\":\"MyParts\"}; the name is the row name in Preferences > Manage Symbol/Footprint Libraries (kicad_install_library returned it as data.name)."});+    }+    let which = s_arg(args, "libraryType").to_lowercase();+    if !matches!(which.as_str(), "" | "both" | "symbol" | "footprint") {+        return json!({"success": false, "errorCode": "bad_library_type",+            "error": format!("libraryType must be symbol, footprint or both (default), got '{which}'"),+            "Hint": "omit libraryType to remove the name from both tables"});+    }+    if name.eq_ignore_ascii_case("Adom") {+        return json!({"success": false, "errorCode": "adom_library_protected",+            "error": "the shared 'Adom' library is not removed by this verb: every part installed with kicad_install_symbol / kicad_install_footprint lives in it",+            "Hint": "remove one part with the Symbol/Footprint Editor, or repair the library with kicad_adom_library_heal"});+    }+    let delete_files = args.get("deleteFiles").and_then(Value::as_bool).unwrap_or(false);+    let user_dir = ctx.user_dir.clone();+    let mut removed: Vec<Value> = Vec::new();+    let mut files_deleted: Vec<String> = Vec::new();+    let mut files_kept: Vec<Value> = Vec::new();+    let mut stock: Vec<String> = Vec::new();++    let tables: Vec<(&str, Option<PathBuf>)> = vec![("symbol", ctx.sym_lib_table()), ("footprint", ctx.fp_lib_table())];+    for (kind, table_path) in tables {+        if !(which.is_empty() || which == "both" || which == kind) {+            continue;+        }+        let Some(table_path) = table_path else { continue };+        if !table_path.is_file() {+            continue;+        }+        let mut table = match LibTable::parse_file(&table_path) {+            Ok(t) => t,+            Err(e) => return json!({"success": false, "errorCode": "lib_table_unreadable",+                "error": format!("cannot parse {}: {e}", table_path.display()),+                "Hint": "kicad_adom_library_heal repairs a damaged lib table"}),+        };+        let Some(entry) = table.get(&name).cloned() else { continue };+        if entry.uri.contains("${KICAD") && entry.uri.contains("_DIR}") && !entry.uri.contains("3RD_PARTY") {+            stock.push(format!("{kind}: {}", entry.uri));+            continue;+        }+        table.remove_library(&name);+        if let Err(e) = table.write_file(&table_path) {+            return json!({"success": false, "errorCode": "lib_table_write_failed",+                "error": format!("cannot write {}: {e}", table_path.display()),+                "Hint": "close KiCad's Manage Libraries dialog if it is open, then retry"});+        }+        removed.push(json!({"table": if kind == "symbol" { "sym-lib-table" } else { "fp-lib-table" }, "uri": entry.uri}));++        let resolved = crate::libraries::expand_kicad_uri(&entry.uri, ctx).unwrap_or_else(|| entry.uri.clone());+        let target = PathBuf::from(&resolved);+        if !delete_files {+            continue;+        }+        let managed = user_dir.as_ref().map(|u| {+            let sub = u.join(if kind == "symbol" { "symbols" } else { "footprints" });+            crate::libraries::norm_path(&target).starts_with(&crate::libraries::norm_path(&sub))+        }).unwrap_or(false);+        if !managed {+            files_kept.push(json!({"path": resolved, "why": "outside Documents/KiCad/<ver>/symbols|footprints: registered in place, not a copy the bridge made"}));+            continue;+        }+        let gone = if target.is_dir() { std::fs::remove_dir_all(&target).is_ok() } else if target.is_file() { std::fs::remove_file(&target).is_ok() } else { false };+        if gone {+            files_deleted.push(resolved.clone());+        }+        if kind == "footprint" {+            if let Some(u) = &user_dir {+                let models = u.join("3dmodels").join(format!("{name}.3dshapes"));+                if models.is_dir() && std::fs::remove_dir_all(&models).is_ok() {+                    files_deleted.push(models.to_string_lossy().replace('\\', "/"));+                }+            }+        }+    }++    if removed.is_empty() {+        if !stock.is_empty() {+            return json!({"success": false, "errorCode": "stock_library_protected", "libraryName": name, "stock": stock,+                "error": format!("'{name}' is one of KiCad's own stock libraries; this verb only removes libraries that were added to the user tables"),+                "Hint": "disable a stock library in Preferences > Manage Libraries instead (the Active checkbox)"});+        }+        return json!({"success": false, "errorCode": "library_not_registered", "libraryName": name,+            "error": format!("no library named '{name}' in the {} lib table(s) of KiCad {}", if which.is_empty() || which == "both" { "symbol or footprint" } else { which.as_str() }, ctx.version),+            "Hint": "names are case-sensitive and per KiCad version: kicad_list_versions, then pass kicadVersion; kicad_show_library lists what is registered"});+    }+    let mut out = json!({"success": true, "libraryName": name, "kicadVersion": ctx.version,+        "removed": removed, "filesDeleted": files_deleted, "filesKept": files_kept,+        "deleteFiles": delete_files, "kicadRunning": kicad_running, "tableReloadNeeded": kicad_running});+    if !stock.is_empty() {+        out["stockRowsLeft"] = json!(stock);+    }+    out["Hint"] = json!(if kicad_running {+        "Removed from the table on disk. KiCad is RUNNING and holds its lib tables in memory: the library stays listed (and usable) in this session until KiCad restarts or the user presses OK in Preferences > Manage Libraries. If KiCad then saves its tables it can write the row back: prefer uninstalling with KiCad closed."+    } else if delete_files {+        "Removed from the lib table(s) and the installed copy deleted. kicad_install_library puts it back."+    } else {+        "Removed from the lib table(s); the library files were left in place. Pass deleteFiles:true to also delete the copy under Documents/KiCad/<ver>/ (never files outside it)."+    });+    out+}+ // --------------------------------------------------------------------------- // Tests // ---------------------------------------------------------------------------@@ -2554,4 +2672,53 @@ mod tests {         assert!(w.contains("(model \"C:/new.step\" (offset"), "{w}");         assert!(!w.contains("old.step"));     }++    // ---- uninstall_library ----++    fn uninstall_ctx(root: &Path) -> LibCtx {+        let cfg = root.join("cfg"); let user = root.join("user");+        std::fs::create_dir_all(&cfg).unwrap();+        std::fs::create_dir_all(user.join("footprints").join("Mine.pretty")).unwrap();+        std::fs::create_dir_all(user.join("3dmodels").join("Mine.3dshapes")).unwrap();+        std::fs::write(user.join("footprints").join("Mine.pretty").join("a.kicad_mod"), "(footprint \"a\")").unwrap();+        let fp_uri = user.join("footprints").join("Mine.pretty").to_string_lossy().replace('\\', "/");+        std::fs::write(cfg.join("fp-lib-table"), format!("(fp_lib_table\n  (version 7)\n  (lib (name \"Mine\")(type \"KiCad\")(uri \"{fp_uri}\")(options \"\")(descr \"\"))\n  (lib (name \"Elsewhere\")(type \"KiCad\")(uri \"{}/outside.pretty\")(options \"\")(descr \"\"))\n  (lib (name \"Resistor_SMD\")(type \"KiCad\")(uri \"${{KICAD10_FOOTPRINT_DIR}}/Resistor_SMD.pretty\")(options \"\")(descr \"\"))\n)\n", root.to_string_lossy().replace('\\', "/"))).unwrap();+        std::fs::write(cfg.join("sym-lib-table"), "(sym_lib_table\n  (version 7)\n)\n").unwrap();+        std::fs::create_dir_all(root.join("outside.pretty")).unwrap();+        LibCtx { version: "10.0".into(), base_dir: None, config_dir: Some(cfg), user_dir: Some(user), kicad_cli: None, env_overrides: BTreeMap::new() }+    }++    #[test]+    fn uninstall_removes_row_and_only_managed_files() {+        let root = unique_temp_dir("kb-uninstall-a");+        let ctx = uninstall_ctx(&root);+        let r = uninstall_library(&ctx, false, &json!({"libraryName": "Mine", "deleteFiles": true}));+        assert_eq!(r["success"], json!(true), "{r}");+        assert_eq!(r["removed"].as_array().unwrap().len(), 1);+        assert!(!ctx.user_dir.as_ref().unwrap().join("footprints").join("Mine.pretty").exists());+        assert!(!ctx.user_dir.as_ref().unwrap().join("3dmodels").join("Mine.3dshapes").exists());+        let table = LibTable::parse_file(&ctx.fp_lib_table().unwrap()).unwrap();+        assert!(!table.has_library("Mine") && table.has_library("Elsewhere") && table.has_library("Resistor_SMD"));++        // registered in place, outside the managed folders: unregistered, files kept+        let r = uninstall_library(&ctx, true, &json!({"libraryName": "Elsewhere", "deleteFiles": true}));+        assert_eq!(r["success"], json!(true), "{r}");+        assert!(root.join("outside.pretty").exists());+        assert_eq!(r["filesKept"].as_array().unwrap().len(), 1);+        assert_eq!(r["tableReloadNeeded"], json!(true));+        let _ = std::fs::remove_dir_all(&root);+    }++    #[test]+    fn uninstall_refuses_adom_stock_and_unknown() {+        let root = unique_temp_dir("kb-uninstall-b");+        let ctx = uninstall_ctx(&root);+        assert_eq!(uninstall_library(&ctx, false, &json!({"libraryName": "Adom"}))["errorCode"], json!("adom_library_protected"));+        assert_eq!(uninstall_library(&ctx, false, &json!({"libraryName": "Resistor_SMD"}))["errorCode"], json!("stock_library_protected"));+        assert_eq!(uninstall_library(&ctx, false, &json!({"libraryName": "Nope"}))["errorCode"], json!("library_not_registered"));+        assert_eq!(uninstall_library(&ctx, false, &json!({}))["errorCode"], json!("library_name_required"));+        assert_eq!(uninstall_library(&ctx, false, &json!({"libraryName": "Mine", "libraryType": "banana"}))["errorCode"], json!("bad_library_type"));+        assert!(LibTable::parse_file(&ctx.fp_lib_table().unwrap()).unwrap().has_library("Resistor_SMD"));+        let _ = std::fs::remove_dir_all(&root);+    } }
skills/kicad-bridge-test/run_verb_tests.py+2
@@ -225,6 +225,8 @@ def matrix(w: dict) -> dict:         "install_symbol":    dict(phase=4, args={"__special": "install_symbol"}, timeout=160),         "install_footprint": dict(phase=4, args={"__special": "install_footprint"}, timeout=160),         "install_plugin":    dict(phase=4, args={}, timeout=160, note="idempotent payload re-deploy"),+        "uninstall_library": dict(phase=4, args={"libraryName": "AdomVerbTest", "deleteFiles": True}, timeout=60,+                                  note="removes the scratch library install_library registered above, so daily runs stop piling it up"),         "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; "

Comments

John Lauer · 1mo ago

Accepted and shipped in 1.0.37 (insiders). Thank you: clean branch PR, honest "not verified" list, and it removed one of the two skips in the verb battery.

Why this PR shows closed, not merged. The release pushes the merged source to the page, so by the time I ran pr merge the wiki saw install.rs already changed on main and reported a conflict. The content is in: install.rs and verbs_install.rs on the page are your files plus the two changes below.

What I changed on top

  1. A row of type Table is stock. A fresh KiCad 10 install keeps ALL its stock libraries in one row named KiCad, type Table, whose uri is an absolute path to the template table (found today on a from-scratch VM). Your ${KICAD..._DIR} check does not match it, so kicad_uninstall_library {"libraryName":"KiCad"} would have removed every stock library. It now answers stock_library_protected. Unit test added.
  2. The managed-folder test is a path boundary. starts_with on strings let footprints-old/X.pretty count as inside footprints. It now compares against .../footprints/ and refuses ... Same unit test.
  3. bridge.json: your copy dropped kicad_silk_text_batch and kicad_apply_zone_plan from the verb list (an older base of that one file). I kept only your added line.

Your three open questions, answered on ConfRoomROG (KiCad 10.0.5, KiCad running)

  • (a) Phase order: yes. Phase 4 sorts by name, so uninstall_library runs after all four install_* verbs.
  • (b) Managed classification: yes. An absolute C:/Users/john/Documents/KiCad/10.0/footprints/Owner_Accept.pretty uri was classed managed and deleted: removed[{fp-lib-table}], filesDeleted[...Owner_Accept.pretty], filesKept[].
  • (c) kicad_running() is the right signal: kicadRunning:true, tableReloadNeeded:true with a project manager open.
  • Refusals live: Adom gives adom_library_protected, Resistor_SMD gives stock_library_protected, an unknown name gives library_not_registered.

I left your Adom_Check library on ConfRoomROG for you to remove. The battery also uses the verb now to make kicad_install_library_bundle a real test (install a generated bundle, uninstall it again).

Log in to comment.