app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
← Commit history
1.0.37: 1.0.37: kicad_uninstall_library (PR #20, from the adom-footprint thread): the inverse of kicad_install_library, removes a library row from sym-lib-table and/or fp-lib-table and, with deleteFiles:true, the installed copy under Documents/KiCad/<ver> only; refuses the shared Adom library and KiCad's stock libraries. Owner's additions on top: a row of type Table (how a fresh KiCad 10 holds ALL its stock libraries) is stock and never removed, and the managed-folder test is a path boundary, not a string prefix. Wiki #111: kicad_install_library answers kicadRunning, tableReloadNeeded and a _reloadHint when a running KiCad cannot see the new library yet, and kicad_open_footprint_editor tells three failures apart from the disk (library_not_registered, footprint_not_in_library with availableFootprints, library_not_loaded_by_running_kicad with the exact rescan call) instead of a generic did-not-load. Verb battery: install_library_bundle is a real test now (installs a generated bundle, uninstalls it again); 118 verbs, one stated skip.
5 files changed
+260−6
rust/Cargo.lock+3−3@@ -244,7 +244,7 @@ dependencies = [ [[package]] name = "kicad-bridge"-version = "1.0.36"+version = "1.0.37" dependencies = [ "kicad-core", "kicad-platform",@@ -255,7 +255,7 @@ dependencies = [ [[package]] name = "kicad-core"-version = "1.0.36"+version = "1.0.37" dependencies = [ "kicad-ipc-rs", "kicad-platform",@@ -281,7 +281,7 @@ dependencies = [ [[package]] name = "kicad-platform"-version = "1.0.36"+version = "1.0.37" dependencies = [ "serde", "serde_json",
rust/Cargo.toml+1−1@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package]-version = "1.0.36"+version = "1.0.37" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/verbs_install.rs+33−2@@ -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.",@@ -133,7 +148,23 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> Some(match command { "kicad_install_library" => { let info = state.kicad_info();- install::install_library(&lib_ctx(&info), info.installed, args)+ let mut out = install::install_library(&lib_ctx(&info), info.installed, args);+ // Wiki #111: a RUNNING KiCad holds its lib tables in memory, so a row written to disk now is+ // invisible to it, and the very next open of a part from this library fails. Say so here,+ // where the cause is known, instead of letting the open verb fail without one.+ if out.get("success").and_then(Value::as_bool).unwrap_or(false) {+ let running = crate::verbs_show::kicad_running();+ out["kicadRunning"] = serde_json::json!(running);+ out["tableReloadNeeded"] = serde_json::json!(running);+ if running {+ out["_reloadHint"] = serde_json::json!("KiCad is RUNNING and read its library tables when it started: this library is on disk and in the table, but the running KiCad does not know it yet, so opening a part from it will fail with [no footprint loaded] / [no symbol loaded]. Next: kicad_rescan_libraries {\"footprints\":[\"<Lib>:<Name>\"],\"force\":true,\"confirmRestart\":true} (restarts KiCad, windows put back where they were; ask the user first if they have work open), or have the user press OK in Preferences > Manage Footprint/Symbol Libraries. Headless verbs (kicad_lint_library, exports, kicad-cli) already see it.");+ }+ }+ out+ }+ "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();
rust/crates/kicad-bridge/src/verbs_windows.rs+26@@ -2020,6 +2020,32 @@ pub(crate) fn open_footprint_editor(state: &mut State, args: &Value) -> Value { "error": format!("Footprint Editor open but '{footprint_name}' didn't load in the background after two attempts (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }), "_hint": "Two background attempts did not confirm - check that the footprint really exists in this library (kicad_list_footprints). If it was installed after the editor opened, KiCad's library index predates it: kicad_close_footprint_editor and re-call, or kicad_rescan_libraries force:true. The keystroke fallback (allowFocusSteal) is not in the native build.", });+ // Wiki #111: tell the three causes apart from the DISK, which the bridge can read and the running+ // KiCad may not have: no such library row, no such footprint in the library, or both are there and+ // this KiCad simply started before the library was registered.+ if !library_name.is_empty() {+ let ctx = lib_ctx(&state.kicad_info());+ let (libs, _) = libraries::list_footprints(&ctx, Some(&library_name));+ match libs.first() {+ None => {+ v["errorCode"] = json!("library_not_registered");+ v["error"] = json!(format!("No footprint library named '{library_name}' in the fp-lib-table of KiCad {} (or its folder is missing), so the Footprint Editor cannot load '{footprint_name}'", ctx.version));+ v["_hint"] = json!("kicad_list_footprints lists the registered libraries (names are case-sensitive in KiCad). Register one with kicad_install_library {libraryPath, libraryType:\"footprint\"}.");+ }+ Some((_, info)) if !info.footprints.iter().any(|f| f.eq_ignore_ascii_case(&footprint_name)) => {+ v["errorCode"] = json!("footprint_not_in_library");+ v["error"] = json!(format!("Library '{library_name}' is registered ({}), but it holds no footprint named '{footprint_name}'", info.path));+ v["availableFootprints"] = json!(info.footprints.iter().take(40).collect::<Vec<_>>());+ v["_hint"] = json!("Pick a name from availableFootprints (kicad_list_footprints {library} has the full list).");+ }+ Some((_, info)) => {+ v["errorCode"] = json!("library_not_loaded_by_running_kicad");+ v["libraryOnDisk"] = json!(info.path);+ v["error"] = json!(format!("'{library_name}:{footprint_name}' exists on disk and the library is in fp-lib-table, but the running KiCad did not load it (title: {}). KiCad reads its library tables when it starts, so a library registered after launch is unknown to it", if title.is_empty() { "unknown" } else { title.as_str() }));+ v["_hint"] = json!(format!("kicad_rescan_libraries {{\"footprints\":[\"{library_name}:{footprint_name}\"],\"force\":true,\"confirmRestart\":true}} restarts KiCad so it re-reads the tables (windows go back where they were; ask the user first if they have unsaved work), then call this verb again. Retrying without that will fail the same way."));+ }+ }+ } merge(&mut v, wm::resolved_fields(&title, &library_name, "Footprint Editor")); v }
rust/crates/kicad-core/src/install.rs+197@@ -2018,6 +2018,133 @@ 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 };+ // A fresh KiCad 10 install holds ALL its stock libraries in one row of type "Table" (named+ // "KiCad", uri = the template table, an absolute path with no ${KICAD..} in it). Removing that+ // row would take every stock library away, so a nested-table row is stock by definition.+ if entry.lib_type.eq_ignore_ascii_case("Table") {+ stock.push(format!("{kind}: nested table {}", entry.uri));+ 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" });+ // a path component boundary, not a bare string prefix: "footprints-old/x" is NOT under "footprints"+ let (t, base) = (crate::libraries::norm_path(&target), format!("{}/", crate::libraries::norm_path(&sub).trim_end_matches('/')));+ t.starts_with(&base) && !t.split('/').any(|c| c == "..")+ }).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 +2681,74 @@ 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_never_removes_the_nested_stock_table_and_respects_the_folder_boundary() {+ let root = unique_temp_dir("kb-uninstall-c");+ let ctx = uninstall_ctx(&root);+ let cfg = ctx.config_dir.clone().unwrap();+ let user = ctx.user_dir.clone().unwrap();+ // a fresh KiCad 10 box: ONE row of type Table carries every stock library+ let sibling = user.join("footprints-old").join("Sib.pretty");+ std::fs::create_dir_all(&sibling).unwrap();+ let sib_uri = sibling.to_string_lossy().replace('\\', "/");+ std::fs::write(cfg.join("fp-lib-table"), format!("(fp_lib_table\n (version 7)\n (lib (name \"KiCad\")(type \"Table\")(uri \"C:/Program Files/KiCad/10.0/share/kicad/template/fp-lib-table\")(options \"\")(descr \"KiCad Default Libraries\"))\n (lib (name \"Sib\")(type \"KiCad\")(uri \"{sib_uri}\")(options \"\")(descr \"\"))\n)\n")).unwrap();+ let r = uninstall_library(&ctx, false, &json!({"libraryName": "KiCad", "deleteFiles": true}));+ assert_eq!(r["errorCode"], json!("stock_library_protected"), "{r}");+ assert!(LibTable::parse_file(&cfg.join("fp-lib-table")).unwrap().has_library("KiCad"));+ // "footprints-old" shares a string prefix with "footprints" but is not inside it: files stay+ let r = uninstall_library(&ctx, false, &json!({"libraryName": "Sib", "deleteFiles": true}));+ assert_eq!(r["success"], json!(true), "{r}");+ assert!(sibling.is_dir() && r["filesDeleted"].as_array().unwrap().is_empty());+ 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);+ } }