← Commit history
bridge.json+9−2
@@ -309,7 +309,12 @@     "kicad_generate_body",     "kicad_export_spice",     "kicad_simulate",-    "kicad_zoom_to"+    "kicad_zoom_to",+    "kicad_foreground",+    "kicad_dialog_log",+    "kicad_license_status",+    "kicad_run_script",+    "kicad_eval"   ],   "statusVerb": "kicad_status",   "timeouts": {@@ -331,7 +336,9 @@       "autoroute": 900,       "prewarm": 120,       "library_views": 300,-      "simulate": 300+      "simulate": 300,+      "run_script": 300,+      "eval": 300     }   },   "dependencies": {
rust/Cargo.lock+3−3
@@ -319,7 +319,7 @@ dependencies = [  [[package]] name = "kicad-bridge"-version = "1.0.81"+version = "1.0.82" dependencies = [  "adom-eda-checks",  "kicad-core",@@ -331,7 +331,7 @@ dependencies = [  [[package]] name = "kicad-core"-version = "1.0.81"+version = "1.0.82" dependencies = [  "kicad-ipc-rs",  "kicad-platform",@@ -358,7 +358,7 @@ dependencies = [  [[package]] name = "kicad-platform"-version = "1.0.81"+version = "1.0.82" dependencies = [  "serde",  "serde_json",
rust/Cargo.toml+1−1
@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"]  [workspace.package]-version = "1.0.81"+version = "1.0.82" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/groups.rs+1
@@ -46,6 +46,7 @@ pub static GROUPS: &[Group] = &[     Group { name: "land_pattern", verbs: || crate::verbs_land_pattern::VERBS, dispatch: crate::verbs_land_pattern::dispatch },     Group { name: "library_views", verbs: || crate::verbs_library_views::VERBS, dispatch: crate::verbs_library_views::dispatch },     Group { name: "sim", verbs: || crate::verbs_sim::VERBS, dispatch: crate::verbs_sim::dispatch },+    Group { name: "app", verbs: || crate::verbs_app::VERBS, dispatch: crate::verbs_app::dispatch },     Group { name: "build", verbs: || crate::verbs_build::VERBS, dispatch: crate::verbs_build::dispatch }, ]; 
rust/crates/kicad-bridge/src/main.rs+1
@@ -43,6 +43,7 @@ mod verbs_order; mod verbs_land_pattern; mod verbs_library_views; mod verbs_sim;+mod verbs_app; mod verbs_build; mod verbs_windows; 
rust/crates/kicad-bridge/src/verbs_app.rsadded+201
@@ -0,0 +1,201 @@+//! Verb group "app": the EDA Bridge Standard's app tier where KiCad has an answer.+//! - `kicad_license_status`: KiCad's licence in plain words (it is free software: GPL, no seat to claim).+//! - `kicad_run_script`: a Python script of your own run by KiCad's bundled Python, where `import pcbnew` works.+//! - `kicad_eval`: a Python snippet run the same way, with a board loaded as `board` (pcbnew.LoadBoard), its+//!   `result` returned as JSON. Both run headless in a child process with a deadline; KiCad need not be open.++use std::path::{Path, PathBuf};+use std::time::{Duration, Instant};++use serde_json::{json, Value};++use crate::catalog::{Mechanism, Verb};+use crate::util::*;++pub static VERBS: &[Verb] = &[+    Verb {+        name: "kicad_license_status",+        summary: "What licence KiCad is on, in plain words: KiCad is free, open-source software (GPL-3.0-or-later), so there is no seat, sign-in or trial; with the installed version.",+        mechanism: Mechanism::Local, risk: "read", timeout_sec: 10,+        input: "{}",+        example: "kicad_license_status {}",+        hint: "licensed is true whenever KiCad is installed; seatRequired is false. Libraries and add-ons carry their own licences (mostly CC-BY-SA for KiCad's libraries).",+        related: &["kicad_status", "kicad_readiness", "kicad_upgrade"],+        pitfalls: &["Not installed: installed is false and kicad_upgrade installs it."],+    },+    Verb {+        name: "kicad_run_script",+        summary: "Run a Python script of your own with KiCad's bundled Python, where `import pcbnew` works, headless; its output comes back.",+        mechanism: Mechanism::Cli, risk: "write", timeout_sec: 300,+        input: "{\"script\": \"C:/.../my_script.py\", \"args\"?: [\"C:/.../board.kicad_pcb\"], \"timeoutSec\"?: 120}",+        example: "kicad_run_script {\"script\":\"C:/Users/john/scripts/count_vias.py\",\"args\":[\"C:/Users/john/proj/board.kicad_pcb\"]}",+        hint: "stdout, stderr and exitCode come back (the last 20,000 characters of each). The script runs in its own folder. pcbnew.LoadBoard(path) reads a board; board.Save(path) writes one, so close it in KiCad first.",+        related: &["kicad_eval", "kicad_routing_state", "kicad_run_drc"],+        pitfalls: &["A script that saves a board open in the PCB editor fights the editor: save and close it there first.", "KiCad's own Python only (bin/python.exe on Windows); it is not the system Python."],+    },+    Verb {+        name: "kicad_eval",+        summary: "Run a Python snippet with KiCad's pcbnew module, headless: with filePath the board is loaded as `board`; set `result` to return a value as JSON.",+        mechanism: Mechanism::Cli, risk: "write", timeout_sec: 300,+        input: "{\"code\": \"result = len(board.GetFootprints())\", \"filePath\"?: \"C:/.../x.kicad_pcb\", \"timeoutSec\"?: 60}",+        example: "kicad_eval {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"code\":\"result = sorted(f.GetReference() for f in board.GetFootprints())\"}",+        hint: "result is whatever the snippet sets it to (anything json.dumps takes, else its str). print() lands in stdout. pcbnew is imported. Nothing is saved unless the snippet calls board.Save().",+        related: &["kicad_run_script", "kicad_routing_state", "kicad_describe"],+        pitfalls: &["The board is read from the file: unsaved edits in an open editor are not in it.", "Saving a board open in the PCB editor fights the editor: close it there first."],+    },+];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+    Some(match command {+        "kicad_license_status" => license_status(state),+        "kicad_run_script" => run_script(state, args),+        "kicad_eval" => eval(state, args),+        _ => return None,+    })+}++fn license_status(state: &mut State) -> Value {+    let info = state.kicad_info();+    let inst = info.primary();+    let installed = inst.is_some();+    let version = inst.map(|i| i.cli_version.clone()).unwrap_or_default();+    json!({"success": true, "product": "KiCad", "installed": installed, "version": if version.is_empty() { Value::Null } else { json!(version) },+        "licensed": installed, "license": "GPL-3.0-or-later", "seatRequired": false, "signInRequired": false, "trial": false,+        "output": if installed { format!("KiCad {version} is free, open-source software (GPL-3.0-or-later): no licence seat, sign-in or trial") } else { "KiCad is not installed; it is free (GPL-3.0-or-later): kicad_upgrade installs it".into() },+        "_hint": "There is nothing to claim or renew. KiCad's libraries are CC-BY-SA 4.0 with an exception for designs that use them."})+}++/// KiCad's bundled Python beside kicad-cli (bin/python.exe on Windows, else bin/python3).+fn kicad_python(state: &mut State) -> Result<PathBuf, Value> {+    let info = state.kicad_info();+    let Some(inst) = info.primary() else {+        let mut v = fail("not_installed", "KiCad not installed", "Its bundled Python runs the script.");+        v["alternatives"] = alternatives();+        return Err(v);+    };+    let bin = PathBuf::from(&inst.kicad_cli).parent().map(Path::to_path_buf).unwrap_or_default();+    for n in ["python.exe", "python3", "python"] {+        let p = bin.join(n);+        if p.is_file() {+            return Ok(p);+        }+    }+    Err(fail("python_missing", format!("KiCad's Python is not in {}", norm(&bin)), "KiCad's Windows installer ships bin/python.exe; on Linux the distribution's python3 with KiCad's pcbnew module is used by kicad-cli instead."))+}++/// stderr without wxWidgets' start-up noise ("Adding duplicate image handler ...", printed by every pcbnew import).+fn quiet(s: &str) -> String {+    s.lines().filter(|l| !l.starts_with("Adding duplicate image handler")).collect::<Vec<_>>().join("\n")+}++fn tail(s: &str) -> String {+    let n = s.chars().count();+    if n <= 20000 { s.to_string() } else { s.chars().skip(n - 20000).collect() }+}++/// `python argv` in `cwd` with a deadline: (exit code, stdout, stderr, seconds), or a timeout.+fn run_python(py: &Path, argv: &[String], cwd: &Path, timeout: Duration) -> Result<(i32, String, String, f64), Value> {+    let started = Instant::now();+    let mut child = std::process::Command::new(py)+        .args(argv)+        .current_dir(cwd)+        .env("PYTHONIOENCODING", "utf-8")+        .stdin(std::process::Stdio::null())+        .stdout(std::process::Stdio::piped())+        .stderr(std::process::Stdio::piped())+        .spawn()+        .map_err(|e| fail("spawn_failed", format!("cannot start {}: {e}", norm(py)), ""))?;+    let (mut so, mut se) = (child.stdout.take(), child.stderr.take());+    let ro = std::thread::spawn(move || { let mut s = String::new(); if let Some(o) = so.as_mut() { use std::io::Read; let _ = o.read_to_string(&mut s); } s });+    let re = std::thread::spawn(move || { let mut s = String::new(); if let Some(o) = se.as_mut() { use std::io::Read; let _ = o.read_to_string(&mut s); } s });+    loop {+        match child.try_wait() {+            Ok(Some(st)) => {+                let (o, e) = (ro.join().unwrap_or_default(), re.join().unwrap_or_default());+                return Ok((st.code().unwrap_or(-1), o, e, (started.elapsed().as_secs_f64() * 100.0).round() / 100.0));+            }+            Ok(None) if started.elapsed() > timeout => {+                let _ = child.kill();+                let _ = child.wait();+                return Err(fail("timeout", format!("the script ran past {} s and was stopped", timeout.as_secs()), "Pass timeoutSec for a longer run."));+            }+            Ok(None) => std::thread::sleep(Duration::from_millis(100)),+            Err(e) => return Err(fail("spawn_failed", format!("{e}"), "")),+        }+    }+}++fn run_script(state: &mut State, args: &Value) -> Value {+    let Some(script) = arg_any(args, &["script", "path"]).map(PathBuf::from) else {+        return fail("missing_arg", "script is required", "The .py file to run (an absolute path on this machine).");+    };+    if !script.is_file() {+        return fail("file_not_found", format!("{} does not exist", norm(&script)), "Use the desktop's absolute path; send_files puts a script there.");+    }+    let py = match kicad_python(state) { Ok(p) => p, Err(e) => return e };+    let mut argv = vec![norm(&script)];+    argv.extend(args.get("args").and_then(Value::as_array).into_iter().flatten().map(|a| a.as_str().map(String::from).unwrap_or_else(|| a.to_string())));+    let timeout = Duration::from_secs(args.get("timeoutSec").and_then(Value::as_u64).unwrap_or(120).clamp(1, 290));+    let cwd = script.parent().unwrap_or(Path::new(".")).to_path_buf();+    match run_python(&py, &argv, &cwd, timeout) {+        Ok((code, out, err, secs)) => json!({"success": code == 0, "exitCode": code, "stdout": tail(&out), "stderr": tail(&quiet(&err)), "seconds": secs, "python": norm(&py),+            "errorCode": if code == 0 { Value::Null } else { json!("script_failed") },+            "output": format!("{} exited {code} after {secs} s", norm(&script)),+            "_hint": if code == 0 { "stdout holds what the script printed." } else { "The script failed: stderr has Python's traceback." }}),+        Err(e) => e,+    }+}++/// The harness kicad_eval runs: the snippet from a file beside it, the board as `board`, `result` as JSON on the+/// last line of stdout behind a marker.+const HARNESS: &str = r#"import json, sys+import pcbnew+board = pcbnew.LoadBoard(sys.argv[2]) if len(sys.argv) > 2 and sys.argv[2] else None+result = None+code = open(sys.argv[1], encoding="utf-8").read()+ns = {"pcbnew": pcbnew, "board": board, "__name__": "__kicad_eval__", "result": None, "json": json}+exec(compile(code, "<kicad_eval>", "exec"), ns)+result = ns.get("result", None)+try:+    text = json.dumps(result)+except TypeError:+    text = json.dumps(str(result))+print("\n@@ADOM_RESULT@@" + text)+"#;++fn eval(state: &mut State, args: &Value) -> Value {+    let Some(code) = arg_str(args, "code").filter(|c| !c.trim().is_empty()) else {+        return fail("missing_arg", "code is required", "Python, e.g. \"result = len(board.GetFootprints())\".");+    };+    let board = arg_any(args, &["filePath", "boardPath"]).map(PathBuf::from);+    if let Some(b) = &board {+        if !b.is_file() {+            return fail("file_not_found", format!("{} does not exist", norm(b)), "Use the desktop's absolute path of the .kicad_pcb.");+        }+    }+    let py = match kicad_python(state) { Ok(p) => p, Err(e) => return e };+    let dir = std::env::temp_dir().join(format!("adom-kicad-eval-{}-{}", std::process::id(), std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0)));+    let _ = std::fs::create_dir_all(&dir);+    let (harness, snippet) = (dir.join("harness.py"), dir.join("snippet.py"));+    if std::fs::write(&harness, HARNESS).is_err() || std::fs::write(&snippet, code).is_err() {+        return fail("write_failed", format!("cannot write to {}", norm(&dir)), "");+    }+    let argv = vec![norm(&harness), norm(&snippet), board.as_ref().map(|b| norm(b)).unwrap_or_default()];+    let timeout = Duration::from_secs(args.get("timeoutSec").and_then(Value::as_u64).unwrap_or(60).clamp(1, 290));+    let cwd = board.as_ref().and_then(|b| b.parent().map(Path::to_path_buf)).unwrap_or_else(|| dir.clone());+    let r = run_python(&py, &argv, &cwd, timeout);+    let _ = std::fs::remove_dir_all(&dir);+    match r {+        Ok((code, out, err, secs)) => {+            let (printed, result) = match out.rsplit_once("@@ADOM_RESULT@@") {+                Some((p, r)) => (p.trim_end().to_string(), serde_json::from_str::<Value>(r.trim()).unwrap_or(Value::Null)),+                None => (out.clone(), Value::Null),+            };+            json!({"success": code == 0, "exitCode": code, "result": result, "stdout": tail(&printed), "stderr": tail(&quiet(&err)), "seconds": secs,+                "errorCode": if code == 0 { Value::Null } else { json!("eval_failed") },+                "output": if code == 0 { format!("ran in {secs} s") } else { "the snippet raised: see stderr".into() },+                "_hint": if code == 0 { "result is the snippet's result variable as JSON; stdout holds what it printed." } else { "stderr has Python's traceback; board is null when no filePath was given." }})+        }+        Err(e) => e,+    }+}
rust/crates/kicad-bridge/src/verbs_windows.rs+88−1
@@ -184,6 +184,26 @@ pub static VERBS: &[Verb] = &[         related: &["kicad_open_3d_viewer", "kicad_close"],         pitfalls: &[],     },+    Verb {+        name: "kicad_foreground",+        summary: "Bring a KiCad window in front of the user: the one sanctioned exception to the never-foreground rule. A reason written for the person is required; the bridge captions it first.",+        mechanism: Mechanism::Window, risk: "input", timeout_sec: 30,+        input: "{\"reason\": \"why the person needs to see this (10 characters or more)\", \"editor\"?: \"pcb|sch|fp|sym|3d|manager\", \"hwnd\"?: 123456}",+        example: "kicad_foreground {\"editor\":\"pcb\",\"reason\":\"the routed board is ready for your review\"}",+        hint: "Without editor or hwnd the PCB editor is chosen, else the schematic editor, else any KiCad window. Every other verb works in the background: use this only when the person has to look or act.",+        related: &["kicad_window_info", "kicad_screenshot_all", "kicad_show_2d_board"],+        pitfalls: &["Refused (reason_required) without a reason of 10 characters or more.", "It takes the user's screen: ask first when someone may be working."],+    },+    Verb {+        name: "kicad_dialog_log",+        summary: "What the bridge's dialog sweeps pressed since it started, newest last: each KiCad dialog it dismissed (benign notices, error boxes after a verb), with its title, body, the button and when.",+        mechanism: Mechanism::Window, risk: "read", timeout_sec: 10,+        input: "{\"limit\"?: 25}",+        example: "kicad_dialog_log {\"limit\":10}",+        hint: "entries[{at, title, body, benign, action, verb}]. A dialog left open (read-only verbs leave non-benign ones) is not here: kicad_window_info lists those. Kept in memory: a restarted bridge starts empty.",+        related: &["kicad_dismiss_dialogs", "kicad_window_info", "kicad_screenshot_all"],+        pitfalls: &["The last 200 dismissals are kept."],+    },     Verb {         name: "kicad_dismiss_dialogs",         summary: "Programmatically expire KiCad dialogs (auto-clears the benign OpenGL/software-render notice; {all:true} clears every dialog).",@@ -333,6 +353,8 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value>         "kicad_close_footprint_editor" => close_kind(WindowKind::FootprintEditor, "Footprint Editor"),         "kicad_close_3d_viewer" => close_kind(WindowKind::Viewer3d, "3D Viewer"),         "kicad_dismiss_dialogs" => dismiss_dialogs(state, args),+        "kicad_foreground" => foreground(args),+        "kicad_dialog_log" => dialog_log(args),         "kicad_window_info" => window_info(state, args),         "kicad_state" => kicad_state(state, args),         "kicad_screenshot_all" => screenshot_all(state),@@ -868,6 +890,68 @@ fn one_shot_focus_check(fg_before: Option<u64>) -> Option<Value> {  /// Explicit, user-requested foreground (win_focus.bring_to_user): caption the reason /// first, then restore and activate. Refused without a reason.+/// Every dialog a sweep dismissed, newest last (kicad_dialog_log): in memory, the last 200.+static DIALOG_LOG: std::sync::Mutex<std::collections::VecDeque<Value>> = std::sync::Mutex::new(std::collections::VecDeque::new());++fn log_dialog(entry: &Value, benign: bool) {+    let at = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);+    let mut e = json!({"at": at, "title": entry["title"], "body": entry["body"], "benign": benign, "action": entry["action"]});+    if let Some(s) = entry["screenshot"].as_str().filter(|s| !s.is_empty()) {+        e["screenshot"] = json!(s);+    }+    if let Ok(mut l) = DIALOG_LOG.lock() {+        l.push_back(e);+        while l.len() > 200 {+            l.pop_front();+        }+    }+}++/// kicad_dialog_log: the last `limit` dismissals.+fn dialog_log(args: &Value) -> Value {+    let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(25).clamp(1, 200) as usize;+    let all: Vec<Value> = DIALOG_LOG.lock().map(|l| l.iter().cloned().collect()).unwrap_or_default();+    let entries: Vec<Value> = all.iter().skip(all.len().saturating_sub(limit)).cloned().collect();+    json!({"success": true, "entries": entries, "total": all.len(),+        "output": format!("{} dialog(s) dismissed since the bridge started; {} shown", all.len(), entries.len()),+        "_hint": "Newest last. at is Unix seconds. Dialogs left open are not here: kicad_window_info lists them."})+}++/// kicad_foreground: one KiCad window to the front, captioned with the reason first (bring_to_user).+fn foreground(args: &Value) -> Value {+    let reason = arg_any(args, &["reason", "foregroundReason"]).unwrap_or("").trim().to_string();+    if reason.chars().count() < 10 {+        return fail("reason_required", "kicad_foreground needs a reason of 10 characters or more, written for the person", "Say why they need to see it, e.g. \"the routed board is ready for your review\".");+    }+    invalidate();+    let kind = |k: &str| match k {+        "pcb" | "board" => Some(WindowKind::PcbEditor),+        "sch" | "schematic" => Some(WindowKind::SchematicEditor),+        "fp" | "footprint" => Some(WindowKind::FootprintEditor),+        "sym" | "symbol" => Some(WindowKind::SymbolEditor),+        "3d" => Some(WindowKind::Viewer3d),+        "manager" | "project" => Some(WindowKind::ProjectManager),+        _ => None,+    };+    let hwnd = match (arg_any_u64(args, &["hwnd"]), arg_str(args, "editor")) {+        (Some(h), _) => Some(h),+        (None, Some(e)) => match kind(&e.to_lowercase()) {+            Some(k) => first_of_kind(k),+            None => return fail("bad_args", format!("unknown editor {e}"), "editor is pcb, sch, fp, sym, 3d or manager."),+        },+        (None, None) => first_of_kind(WindowKind::PcbEditor).or_else(|| first_of_kind(WindowKind::SchematicEditor)).or_else(|| kicad_windows().into_iter().map(|w| w.hwnd).next()),+    };+    let Some(h) = hwnd else {+        return fail("no_window", "no KiCad window is open", "Open one first (kicad_open_board, kicad_open_schematic, kicad_launch).");+    };+    let title = kicad_windows().into_iter().find(|w| w.hwnd == h).map(|w| w.title).unwrap_or_default();+    let shown = bring_to_user(h, &reason);+    json!({"success": shown, "hwnd": h, "title": title, "broughtToUser": shown, "windowEtiquette": if shown { "foreground" } else { "background" },+        "errorCode": if shown { Value::Null } else { json!("foreground_failed") },+        "output": if shown { format!("{title} is in front of the user: {reason}") } else { format!("could not bring {title} forward") },+        "_hint": "Hand the screen back when they are done: every other verb works in the background."})+}+ fn bring_to_user(hwnd: u64, reason: &str) -> bool {     if reason.trim().is_empty() {         return false;@@ -1048,6 +1132,7 @@ fn sweep_dialogs(info: &KicadInfo, dismiss_all: bool, screenshot: bool) -> Value                         shot.push(d.hwnd);                     }                     entry["action"] = json!(dismiss_dialog(d.hwnd));+                    log_dialog(&entry, benign);                     dismissed.push(entry);                     acted = true;                     if benign && !forced {@@ -1113,7 +1198,9 @@ fn auto_sweep_dialogs(_info: &KicadInfo, screenshot: bool, settle_rounds: usize,                     }                     let shot = if screenshot { screenshot_dialog(d.hwnd, &d.title) } else { String::new() };                     let action = dismiss_dialog(d.hwnd);-                    dialogs.push(json!({"title": d.title, "body": d.body, "benign": benign, "action": action, "screenshot": shot}));+                    let entry = json!({"title": d.title, "body": d.body, "benign": benign, "action": action, "screenshot": shot});+                    log_dialog(&entry, benign);+                    dialogs.push(entry);                     acted = true;                 }                 DialogAction::Leave => {}
skills/kicad-bridge-test/run_verb_tests.py+20−1
@@ -196,7 +196,8 @@ def matrix(w: dict) -> dict:             "check_orientation", "find_space", "height_check", "placement_check", "pin_pad_check", "auto_classes", "thermal_vias", "via_fence", "board_report",             "bom", "assembly_check", "jlcpcb_order", "footprint_from_land_pattern", "silk_designators",             "read_sheet_file", "build_board", "test_board", "length_tune", "silkscreen_prep", "component_classes", "build_schematic",-            "library_views", "generate_body", "plan_route", "export_spice", "simulate")},+            "library_views", "generate_body", "plan_route", "export_spice", "simulate",+            "license_status", "eval", "run_script", "dialog_log", "foreground")},         "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),@@ -1616,6 +1617,24 @@ def main():         bad = C("simulate", {"schPath": rc, "analysis": ".dc V9 0 1 0.1"}, 200)         own("simulate", {"ac": ac, "bad": bad}, ok(ac) and sig.get("signal") == "V(OUT)" and sig.get("yUnit") == "dB" and str(ac.get("engine") or "").startswith("ngspice ") and f3 is not None and abs(f3 - 1591.5) / 1591.5 < 0.02 and ac.get("csvPath") and bad.get("errorCode") == "simulation_failed",             f"the RC's -3 dB point was {f3} Hz, not about 1592, or a failing analysis did not fail")+        # 1.0.82: the app tier where KiCad answers: licence, KiCad's own Python (pcbnew), the dialog log, foreground (its refusal+        # only: a real foreground would be a grab this battery counts against the bridge)+        r = C("license_status", {}); own("license_status", r, ok(r) and r.get("license") == "GPL-3.0-or-later" and r.get("seatRequired") is False)+        r = C("eval", {"filePath": CK, "code": "result = {'footprints': len(board.GetFootprints()), 'version': pcbnew.GetBuildVersion()}"}, 200)+        bad = C("eval", {"code": "result = 1/0"}, 200)+        own("eval", {"ok": r, "bad": bad}, ok(r) and ((r.get("result") or {}).get("footprints") or 0) > 100 and bad.get("errorCode") == "eval_failed" and "ZeroDivisionError" in (bad.get("stderr") or ""),+            "pcbnew did not load the board, or a failing snippet did not fail")+        scr = w["out"] + "/scripts/count_footprints.py"+        import tempfile as _tfs+        sf = Path(_tfs.gettempdir()) / "count_footprints.py"+        sf.write_text("import sys, pcbnew\nprint(len(pcbnew.LoadBoard(sys.argv[1]).GetFootprints()))\n")+        ab(a.target, "send_files", {"filePaths": [str(sf)], "dest": w["out"] + "/scripts"}, timeout=120)+        r = C("run_script", {"script": scr, "args": [CK]}, 200)+        own("run_script", r, ok(r) and r.get("exitCode") == 0 and (r.get("stdout") or "").strip().isdigit() and int(r["stdout"].strip()) > 100, "KiCad's Python did not run the script on the board")+        r = C("dialog_log", {"limit": 5}); own("dialog_log", r, ok(r) and isinstance(r.get("entries"), list))+        # sent straight (C adds the battery's own long reason, which would pass the check)+        r = ab(a.target, "kicad_foreground", {"reason": "short"}, timeout=60)+        own("foreground", r, r.get("errorCode") == "reason_required", "foreground was not refused without a real reason")         pr = C("plan_route", {"filePath": CK, "source": "file", "net": "/DRV_GHC", "from": "R12.1", "to": "U4.13"}, 200)         rn = pr.get("routeNet") or {}         own("plan_route", pr, ok(pr) and eng(pr) and pr.get("found") is True and (pr.get("planner") or {}).get("widthFrom") and isinstance(pr.get("crossesPours"), list) and rn.get("expectedRevision") == pr.get("revision") and rn.get("filePath") and rn.get("paths") and rn["paths"][0][0] == {"pad": "R12.1"},
skills/kicad-schematic-and-board-edits/SKILL.md+11−1
@@ -1,7 +1,7 @@ --- name: kicad-schematic-and-board-edits description: >--  Build and change a KiCad design through the KiCad Bridge without touching the GUI: create a project, draw a schematic (place, rotate, move, wire pin to pin, labels and net stubs, junctions, buses and bus entries, hierarchical sheets and sheet pins, annotate, replace a symbol, reset fields, update from library, batches), read it back (components, pins, items, trace a point, check, design review, PNG render), make symbols and footprints, and edit the board (add, edit, flip, align, delete footprints, mounting holes, outline, layer count, graphics and SVG logos, 3D models, netclasses, board and custom rules, modify or copy tracks, diff pairs, refill, save, update the PCB from the schematic) plus fab outputs (DXF, GenCAD, IPC-2581, IPC-D-356, ODB++, one-zip fab package, fab pre-flight, cost). Trigger words: simulate the circuit, spice simulation, ngspice, transient analysis, AC sweep, frequency response, bode plot, operating point, export spice netlist, draw a schematic, wire these pins, add a net label, add a bus, hierarchical sheet, annotate, replace symbol, create a symbol, create a footprint, new kicad project, add a footprint to the board, flip a part, align parts, mounting hole, board outline, import a logo, netclass, design rules, update pcb from schematic, odb++, ipc-2581, fab package, pcb cost estimate, design review.+  Build and change a KiCad design through the KiCad Bridge without touching the GUI: create a project, draw a schematic (place, rotate, move, wire pin to pin, labels and net stubs, junctions, buses and bus entries, hierarchical sheets and sheet pins, annotate, replace a symbol, reset fields, update from library, batches), read it back (components, pins, items, trace a point, check, design review, PNG render), make symbols and footprints, and edit the board (add, edit, flip, align, delete footprints, mounting holes, outline, layer count, graphics and SVG logos, 3D models, netclasses, board and custom rules, modify or copy tracks, diff pairs, refill, save, update the PCB from the schematic) plus fab outputs (DXF, GenCAD, IPC-2581, IPC-D-356, ODB++, one-zip fab package, fab pre-flight, cost). Trigger words: run python in kicad, pcbnew script, eval, kicad license, bring kicad to front, which dialogs were closed, simulate the circuit, spice simulation, ngspice, transient analysis, AC sweep, frequency response, bode plot, operating point, export spice netlist, draw a schematic, wire these pins, add a net label, add a bus, hierarchical sheet, annotate, replace symbol, create a symbol, create a footprint, new kicad project, add a footprint to the board, flip a part, align parts, mounting hole, board outline, import a logo, netclass, design rules, update pcb from schematic, odb++, ipc-2581, fab package, pcb cost estimate, design review. ---  # Schematic and board edits@@ -43,6 +43,16 @@ Every part needs a SPICE model: R, C and L work from their values, sources come library (VSIN, VDC, VPULSE...), ICs need a model file (their Sim.Library field). `kicad_export_spice` writes KiCad's netlist (`.cir`, `dialect: ngspice`) and says what it holds. Checked: an RC of 1k and 100n is -3 dB at 1.58 kHz, -45 deg. +## KiCad's own Python, headless++`kicad_eval {code, filePath?}` runs a Python snippet with KiCad's bundled Python and its `pcbnew` module; with+`filePath` the board is loaded as `board`, and whatever the snippet sets `result` to comes back as JSON+(`result = sorted(f.GetReference() for f in board.GetFootprints())`). `kicad_run_script {script, args}` runs a+.py of your own the same way. Both read the file, not an open editor's unsaved edits; nothing is saved unless+the code calls `board.Save()` (close the board in KiCad first). `kicad_license_status` answers the licence+question plainly (GPL, no seat); `kicad_foreground {reason}` brings a KiCad window to the person, captioned,+and is refused without a reason; `kicad_dialog_log` lists the dialogs the bridge dismissed.+ ## Board (`kicad_*` on a .kicad_pcb)  - **Parts**: `add_footprint` (from a library, a .kicad_mod or a copy of a placed part), `edit_footprint` (value,