Files Branches

Comparing master ← feature/uninstall-library

Changes on feature/uninstall-library that are not yet on master (three-dot, from the merge base).

5 files changed, 191 insertions(+), 1 deletion(-)
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; "