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.
kicad-lane: 1.0.98, document_info #60
1.0.98: the standard's document_info.
kicad_document_info: the active KiCad document, meaning the topmost KiCad window. It reports:
- name and kind: board, schematic, library, project or model;
- modified: the leading * KiCad puts in the title;
- saved, path, project, and the sub-sheet when a schematic shows one.
- version is null, because KiCad keeps no versions.
- Read only: it opens nothing.
Where the path comes from:
- the editor's own command line, read through Adom Bridge's process list;
- the project folder, for an editor opened inside the project manager;
- KiCad's recent-file list, as a last resort.
pathFrom says which. windows[] lists every KiCad window, topmost first.
Checked live on KiCad 10.0.8: with the rp2040 project's project manager, PCB editor and schematic open, it named the schematic (topmost) with its path from the command line and the project beside it.
Battery 356 pass / 0 fail / 1 skip on KiCad 10.0.8 (bridge 1.0.98), no coverage gap, no foreground grabs.
🤖 Generated with Claude Code
Diff Skip to comments
@@ -1,476 +1,478 @@⋯ 358 unchanged lines ⋯ "kicad_set_track_width", "kicad_footprint_primitives", "kicad_rules_by_kind",+ "kicad_document_info", "kicad_review_board" ], "statusVerb": "kicad_status",⋯ 109 unchanged lines ⋯ "releasedAt": "2026-06-26T16:00:00Z", "uninstall": "kicad_uninstall" }+@@ -1,1307 +1,1308 @@⋯ 318 unchanged lines ⋯ [[package]] name = "kicad-bridge"-version = "1.0.97"+version = "1.0.98" dependencies = [ "adom-eda-checks", "kicad-core",⋯ 5 unchanged lines ⋯ [[package]] name = "kicad-core"-version = "1.0.97"+version = "1.0.98" dependencies = [ "kicad-ipc-rs", "kicad-platform",⋯ 20 unchanged lines ⋯ [[package]] name = "kicad-platform"-version = "1.0.97"+version = "1.0.98" dependencies = [ "serde", "serde_json",⋯ 940 unchanged lines ⋯ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"+@@ -1,27 +1,28 @@⋯ 2 unchanged lines ⋯ members = ["crates/*"] [workspace.package]-version = "1.0.97"+version = "1.0.98" edition = "2021" license = "MIT" publish = false⋯ 15 unchanged lines ⋯ codegen-units = 1 strip = true panic = "abort"+@@ -1,504 +1,624 @@⋯ 112 unchanged lines ⋯ related: &["kicad_board_rules", "kicad_netclasses", "kicad_custom_rules", "kicad_run_drc"], pitfalls: &["Reads the saved project files: rules changed in an open Board Setup dialog count once saved."], },+ Verb {+ name: "kicad_document_info",+ summary: "The active KiCad document (the topmost KiCad window): its name, kind (board, schematic, library, project, model), whether it has unsaved changes, where it is saved, and its project (the standard's document_info). Read only; opens nothing.",+ mechanism: Mechanism::File, risk: "read", timeout_sec: 30,+ input: "{}",+ example: "kicad_document_info {}",+ hint: "modified is the leading * KiCad puts in the title. path comes from the editor's own command line (via Adom Bridge), or the project folder for an editor opened inside the project manager, else KiCad's recent-file list (pathFrom says which). version is null: KiCad keeps no versions. windows[] lists every KiCad window, topmost first.",+ related: &["kicad_state", "kicad_activate_document", "kicad_open_document"],+ pitfalls: &["With no KiCad window open it answers no_document.", "A new, never-saved sheet is untitled with saved false and path null."],+ }, ]; pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {⋯ 8 unchanged lines ⋯ "kicad_set_track_width" => set_track_width(state, args), "kicad_footprint_primitives" => footprint_primitives(state, args), "kicad_rules_by_kind" => rules_by_kind(args),+ "kicad_document_info" => document_info(), _ => return None, }) }⋯ 214 unchanged lines ⋯ }) } +/// A KiCad window title: (name, kind, unsaved, sub-sheet), or None for a window that holds no document.+/// "*rp2040 — PCB Editor", "untitled [Unsaved] — Schematic Editor", "board [/power/] — Schematic Editor",+/// "rp2040 — KiCad 10.0" (the project manager).+fn parse_title(title: &str) -> Option<(String, &'static str, bool, Option<String>)> {+ let (left, right) = title.rsplit_once(" — ").or_else(|| title.rsplit_once(" - "))?;+ let kind = match right.trim() {+ "PCB Editor" => "board",+ "Schematic Editor" => "schematic",+ "Symbol Editor" | "Footprint Editor" => "library",+ r if r.starts_with("KiCad") => "project",+ r if r.contains("3D Viewer") => "model",+ _ => return None,+ };+ let modified = left.starts_with('*');+ let mut name = left.trim_start_matches('*').trim().to_string();+ let mut sub = None;+ if let Some(i) = name.find(" [") {+ let tag = name[i + 2..].trim_end_matches(']').to_string();+ if tag != "Unsaved" {+ sub = Some(tag);+ }+ name.truncate(i);+ }+ Some((name, kind, modified, sub))+}++/// The document a KiCad process was started on: the .kicad_pcb, .kicad_sch or .kicad_pro in its command line.+fn doc_arg(cmd: &str) -> Option<String> {+ let exts = [".kicad_pcb", ".kicad_sch", ".kicad_pro"];+ let quoted: Vec<&str> = cmd.split('"').skip(1).step_by(2).collect();+ let pool: Vec<&str> = if quoted.is_empty() { cmd.split_whitespace().collect() } else { quoted };+ pool.into_iter().rev().find(|t| exts.iter().any(|e| t.to_ascii_lowercase().ends_with(e))).map(|t| t.replace('\\', "/"))+}++fn document_info() -> Value {+ use kicad_platform::native;+ const KICAD: &[&str] = &["kicad.exe", "pcbnew.exe", "eeschema.exe", "kicad", "pcbnew", "eeschema"];+ let wins: Vec<_> = native().list_windows().unwrap_or_default().into_iter().filter(|w| KICAD.contains(&w.exe.as_str()) && w.owner == 0).collect();+ let docs: Vec<(kicad_platform::WindowInfo, (String, &'static str, bool, Option<String>))> = wins.iter().filter_map(|w| parse_title(&w.title).map(|t| (w.clone(), t))).collect();+ // the topmost editor; the project manager only when no editor is open+ let Some((win, (name, kind, modified, sub))) = docs.iter().find(|(_, t)| t.1 != "project").or_else(|| docs.first()).cloned() else {+ return fail("no_document", "no KiCad document is open", "kicad_open_document opens one; kicad_state lists KiCad's windows.");+ };+ let ext = match kind { "board" => "kicad_pcb", "schematic" => "kicad_sch", "project" => "kicad_pro", _ => "" };+ let untitled = name == "untitled";+ // the editor's command line (Adom Bridge's process_list), or the project manager's for an editor it hosts+ let cmd_of = |pid: u32| -> Option<String> {+ let r = kicad_core::ab::call("process_list", json!({}), Duration::from_secs(15)).ok()?;+ let v = kicad_core::ab::unwrap_payload(&r.raw);+ let ps = v.get("processes").or_else(|| v.get("data")).and_then(Value::as_array)?.clone();+ ps.iter().find(|p| p["pid"].as_u64() == Some(pid as u64)).and_then(|p| p["commandLine"].as_str().map(str::to_string))+ };+ let mut path: Option<String> = None;+ let mut path_from = "none";+ if !untitled && !ext.is_empty() {+ if let Some(arg) = cmd_of(win.pid).and_then(|c| doc_arg(&c)) {+ let p = PathBuf::from(&arg);+ let candidate = p.with_file_name(format!("{name}.{ext}"));+ if p.extension().map_or(false, |e| e == ext) && p.file_stem().map_or(false, |s| s.to_string_lossy() == name) {+ path = Some(norm(&p));+ path_from = "commandLine";+ } else if candidate.exists() || p.extension().map_or(false, |e| e == "kicad_pro") {+ path = Some(norm(&candidate));+ path_from = "projectFolder";+ }+ }+ if path.is_none() {+ // KiCad's recent-file list, newest first+ let file = match kind { "board" => "pcbnew.json", "schematic" => "eeschema.json", _ => "kicad.json" };+ let hist = kicad_core::detect::detect().primary().and_then(|i| kicad_core::detect::config_dir(&i.version))+ .and_then(|d| std::fs::read_to_string(d.join(file)).ok()).and_then(|t| serde_json::from_str::<Value>(&t).ok())+ .and_then(|v| v.pointer("/system/file_history").and_then(Value::as_array).cloned()).unwrap_or_default();+ if let Some(h) = hist.iter().filter_map(Value::as_str).find(|h| Path::new(&h.replace('\\', "/")).file_stem().map_or(false, |s| s.to_string_lossy() == name)) {+ path = Some(h.replace('\\', "/"));+ path_from = "recentFiles";+ }+ }+ }+ let project = path.as_ref().map(|p| PathBuf::from(p)).and_then(|p| {+ let pro = p.with_extension("kicad_pro");+ if pro.exists() { Some(norm(&pro)) } else { std::fs::read_dir(p.parent()?).ok()?.flatten().map(|e| e.path()).find(|x| ext_of(x) == "kicad_pro").map(|x| norm(&x)) }+ });+ json!({+ "success": true, "name": name, "kind": kind, "modified": modified,+ "saved": path.as_ref().map_or(false, |p| Path::new(p).exists()), "path": path, "pathFrom": path_from,+ "version": Value::Null, "project": project, "subSheet": sub,+ "window": {"title": win.title, "exe": win.exe, "pid": win.pid},+ "windows": wins.iter().map(|w| json!({"title": w.title, "exe": w.exe})).collect::<Vec<_>>(),+ })+}++fn ext_of(p: &Path) -> String {+ ext(p)+}+ fn rules_by_kind(args: &Value) -> Value { let Some(pro) = arg_any(args, &["projectPath", "filePath", "boardPath"]).map(|p| PathBuf::from(p).with_extension("kicad_pro")) else { return fail("missing_arg", "projectPath or filePath is required", "The project, or its board: the .kicad_pro and .kicad_dru sit beside it.");⋯ 143 unchanged lines ⋯ } #[test]+ fn window_titles_and_command_lines_name_the_document() {+ assert_eq!(parse_title("*esc — PCB Editor"), Some(("esc".into(), "board", true, None)));+ assert_eq!(parse_title("rp2040-breakout — Schematic Editor"), Some(("rp2040-breakout".into(), "schematic", false, None)));+ assert_eq!(parse_title("untitled [Unsaved] — Schematic Editor"), Some(("untitled".into(), "schematic", false, None)));+ assert_eq!(parse_title("board [/power/] — Schematic Editor"), Some(("board".into(), "schematic", false, Some("/power/".into()))));+ assert_eq!(parse_title("rp2040-breakout — KiCad 10.0").map(|t| t.1), Some("project"));+ assert_eq!(parse_title("Load PCB"), None);+ assert_eq!(doc_arg("C:/K/bin/pcbnew.exe C:/p/b.kicad_pcb").as_deref(), Some("C:/p/b.kicad_pcb"));+ assert_eq!(doc_arg("\"C:\\Program Files\\K\\eeschema.exe\" \"C:\\My Boards\\a.kicad_sch\"").as_deref(), Some("C:/My Boards/a.kicad_sch"));+ assert_eq!(doc_arg("C:/K/bin/kicad.exe"), None);+ }++ #[test] fn every_preset_names_layers_and_bottom_ones_are_mirrored() { assert!(PRESETS.iter().all(|p| !p.1.is_empty() && !p.4.is_empty())); assert!(PRESETS.iter().filter(|p| p.0.starts_with("bottom") || p.0 == "assembly_bottom").all(|p| p.2)); assert_eq!(PRESETS.len(), 14, "the standard's fourteen presets"); } }+@@ -1,2027 +1,2031 @@⋯ 205 unchanged lines ⋯ "library_views", "generate_body", "plan_route", "export_spice", "simulate", "license_status", "eval", "run_script", "dialog_log", "foreground", "schematic_review", "review_board", "variants", "sch_zoom", "swap_footprint", "plate_vias", "normalize_bodies", "symbol_mode", "add_symbol_art", "build_symbol_modes", "cleanup_libs", "export_3d", "edit_pours", "import_design", "sch_part_verbs_standard", "tool_update", "import_step", "cross_check",- "list_library", "render_board_layers", "export_bom", "open_document", "activate_document", "open_project", "routing_drc", "set_track_width", "footprint_primitives", "rules_by_kind", "net_connections", "remove_items")},+ "list_library", "render_board_layers", "export_bom", "open_document", "activate_document", "open_project", "routing_drc", "set_track_width", "footprint_primitives", "rules_by_kind", "net_connections", "remove_items", "document_info")}, "panelize": dict(phase=8, scenario=True), # ---- phase 8: the 1.0.61 verbs (Konnect parity), run in order by k161_phase() on scratch files "add_footprint": dict(phase=8, scenario=True),⋯ 1588 unchanged lines ⋯ ad = C("activate_document", {"path": xd + "/rp2040-breakout.kicad_pcb"}, 200) own("open_document", od, ok(od) and (od.get("data") or {}).get("objectKind") == "Board", "the scratch board did not open") own("activate_document", ad, ok(ad) and (ad.get("data") or {}).get("kind") == "pcb", "the open scratch board was not activated")+ di = C("document_info", {}, 60)+ own("document_info", di, ok(di) and di.get("kind") in ("board", "schematic", "project") and str(di.get("path") or "").startswith(xd) and di.get("pathFrom") != "none" and isinstance(di.get("modified"), bool),+ "the active KiCad document was not named with its kind and its path in the scratch project") ab(a.target, "kicad_close", {"force": True, "reason": "kicad-bridge verb test: close the scratch board open_document opened"}, timeout=120) op = C("open_project", {"filePath": xd + "/rp2040-breakout.kicad_pro"}, 200) own("open_project", op, ok(op) and (op.get("data") or {}).get("objectKind") == "Project", "the scratch project did not open in the project manager")⋯ 218 unchanged lines ⋯ if __name__ == "__main__": sys.exit(main())+
Comments
Log in to comment.
No comments yet.