app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge: multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
← Commit history
rust: workspace with kicad-platform (windows, macos, linux), kicad-core, kicad-bridge; 13 headless verbs; alpha.2
16 files changed
+1341−15
rust/Cargo.lock+20−1rust/Cargo.toml+15−14rust/README.md+39rust/crates/kicad-bridge/Cargo.toml+18rust/crates/kicad-bridge/src/catalog.rs+196rust/crates/kicad-bridge/src/main.rs+150rust/crates/kicad-bridge/src/verbs.rs+309rust/crates/kicad-core/Cargo.toml+12rust/crates/kicad-core/src/cli.rs+142rust/crates/kicad-core/src/detect.rs+42rust/crates/kicad-core/src/lib.rs+3rust/crates/kicad-platform/Cargo.toml+21rust/crates/kicad-platform/src/lib.rs+138rust/crates/kicad-platform/src/linux.rs+67rust/crates/kicad-platform/src/macos.rs+56rust/crates/kicad-platform/src/windows.rs+113rust/Cargo.lock+20−1@@ -34,11 +34,30 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "kicad-bridge"-version = "1.0.0-alpha.1"+version = "1.0.0-alpha.2" dependencies = [+ "kicad-core",+ "kicad-platform", "serde", "serde_json", "tiny_http",+]++[[package]]+name = "kicad-core"+version = "1.0.0-alpha.2"+dependencies = [+ "kicad-platform",+ "serde",+ "serde_json",+]++[[package]]+name = "kicad-platform"+version = "1.0.0-alpha.2"+dependencies = [+ "serde",+ "serde_json", "winreg", ]
rust/Cargo.toml+15−14@@ -1,23 +1,24 @@-[package]-name = "kicad-bridge"-version = "1.0.0-alpha.1"+[workspace]+resolver = "2"+members = ["crates/*"]++[workspace.package]+version = "1.0.0-alpha.2" edition = "2021"-publish = false-description = "Adom KiCad Bridge, native binary (Rust port of the Python bridge)" license = "MIT"+publish = false -[[bin]]-name = "kicad-bridge"-path = "src/main.rs"--[dependencies]-tiny_http = "0.12"+[workspace.dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1"+tiny_http = "0.12" -[target.'cfg(windows)'.dependencies]-winreg = "0.55"-+# One binary per OS. The OS-specific code lives in crates/kicad-platform behind the+# Platform trait; cargo compiles exactly one implementation in, chosen by the build+# target (Windows today; macOS is Kyle's, Linux is Barrett's). Build with:+# cargo build --release (host OS)+# cargo build --release --target x86_64-pc-windows-gnu (Windows, from Linux with mingw)+# cargo build --release --target aarch64-apple-darwin (on a Mac) [profile.release] opt-level = "z" lto = true
rust/README.mdadded+39@@ -0,0 +1,39 @@+# kicad-bridge (native)++The Rust port of the Adom KiCad Bridge. One binary per OS, no runtime to provision. Plan and phases: [docs/rust-port-plan.md](../docs/rust-port-plan.md).++## Layout++| Crate | What | OS-specific? |+|---|---|---|+| `crates/kicad-platform` | The `Platform` trait and exactly one implementation compiled in per target: `windows.rs`, `macos.rs` (Kyle), `linux.rs` (Barrett). Install discovery, settings and documents dirs, quiet child processes; later window control, UI automation, dialog sweep and focus etiquette. | Yes, this is the only crate that is |+| `crates/kicad-core` | Detection ordering, the kicad-cli runner and report parsers, KiCad file editing (phase 1b), IPC client (phase 2). Talks to the OS only through `kicad_platform::native()`. | No |+| `crates/kicad-bridge` | The binary: HTTP server (`/status`, `/command`), the verb catalog and the verb handlers. | No |++A verb that needs something the current OS does not implement yet returns `kicad_platform::unsupported("...")`: `errorCode: not_supported_on_platform`, the OS, and the owner's name in the hint. Readiness and diagnostics carry a `platform.capabilities` block so an AI can tell what this build can do before calling anything.++## Build++```+cargo build --release # host OS+cargo build --release --target x86_64-pc-windows-gnu # Windows, from Linux (mingw-w64 installed)+cargo check --target aarch64-apple-darwin # type-check the macOS file from anywhere+```++Per-OS dependencies go in `crates/kicad-platform/Cargo.toml` under the matching `[target.'cfg(...)'.dependencies]` table. Nothing OS-specific belongs in the other two crates.++## Run by hand++```+kicad-bridge --port 47731+curl http://127.0.0.1:47731/status+curl -X POST http://127.0.0.1:47731/command -d '{"command":"kicad_describe"}'+```++ab spawns it with `spawn.kind: "exe"` and appends `--port`; bind host comes from `ADOM_BIND_HOST`.++## Adding a verb++1. Add a `Verb` entry to `catalog.rs` (summary, mechanism, risk, timeout, input, example, hint, related, pitfalls). ab's Verbs tab renders it verbatim.+2. Add the match arm in `verbs.rs::dispatch`.+3. Keep the Python contract: same arg names, same `success`/`output`/`data` shape, so the verb runner in `skills/kicad-bridge-test` passes unchanged.
rust/crates/kicad-bridge/Cargo.tomladded+18@@ -0,0 +1,18 @@+[package]+name = "kicad-bridge"+description = "Adom KiCad Bridge, native binary (Rust port of the Python bridge)"+version.workspace = true+edition.workspace = true+license.workspace = true+publish.workspace = true++[[bin]]+name = "kicad-bridge"+path = "src/main.rs"++[dependencies]+serde = { workspace = true }+serde_json = { workspace = true }+tiny_http = { workspace = true }+kicad-core = { path = "../kicad-core" }+kicad-platform = { path = "../kicad-platform" }
rust/crates/kicad-bridge/src/catalog.rsadded+196@@ -0,0 +1,196 @@+//! One table for every verb: name, mechanism, risk, timeout and the describe entry.+//! ab's Verbs tab renders `kicad_describe` verbatim, so the text here is what the AI reads.++use serde_json::{json, Value};++#[allow(dead_code)] // File, Ipc and Window arrive in phases 1b to 3+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum Mechanism {+ /// Pure bridge logic, no KiCad involved.+ Local,+ /// kicad-cli subprocess.+ Cli,+ /// Reads or edits KiCad files on disk.+ File,+ /// KiCad IPC API (phase 2).+ Ipc,+ /// Window control (phase 3).+ Window,+}++impl Mechanism {+ pub fn as_str(self) -> &'static str {+ match self {+ Mechanism::Local => "local",+ Mechanism::Cli => "cli",+ Mechanism::File => "file",+ Mechanism::Ipc => "ipc",+ Mechanism::Window => "window",+ }+ }+}++pub struct Verb {+ pub name: &'static str,+ pub summary: &'static str,+ pub mechanism: Mechanism,+ /// ab risk class: read | write | exec | process | config | input+ pub risk: &'static str,+ pub timeout_sec: u32,+ pub input: &'static str,+ pub example: &'static str,+ pub hint: &'static str,+ pub related: &'static [&'static str],+ pub pitfalls: &'static [&'static str],+}++pub static VERBS: &[Verb] = &[+ Verb {+ name: "kicad_describe",+ summary: "List every verb with input, example, hint, related verbs and pitfalls.",+ mechanism: Mechanism::Local, risk: "read", timeout_sec: 5,+ input: "{}", example: "kicad_describe {}",+ hint: "Read this once per session; every response also carries _hint and _next.",+ related: &["kicad_readiness", "kicad_status"], pitfalls: &[],+ },+ Verb {+ name: "kicad_status",+ summary: "Bridge health, KiCad version, install location and uptime.",+ mechanism: Mechanism::Local, risk: "read", timeout_sec: 10,+ input: "{}", example: "kicad_status {}",+ hint: "Cheap; call it before any window verb to learn what KiCad is on this box.",+ related: &["kicad_readiness", "kicad_diagnostics"], pitfalls: &[],+ },+ Verb {+ name: "kicad_readiness",+ summary: "Is KiCad installed and usable here; if not, the three alternatives (service-kicad, kicad-cli in your container, another box).",+ mechanism: Mechanism::Local, risk: "read", timeout_sec: 15,+ input: "{}", example: "kicad_readiness {}",+ hint: "When installed is false read `alternatives`: the headless verbs still run against service-kicad or a named target.",+ related: &["kicad_status", "kicad_list_versions", "kicad_upgrade"],+ pitfalls: &["The bridge never installs KiCad from readiness; kicad_upgrade does that on request."],+ },+ Verb {+ name: "kicad_list_versions",+ summary: "Every KiCad install found (registry and standard folders) with bin dir, kicad-cli path and version.",+ mechanism: Mechanism::Local, risk: "read", timeout_sec: 15,+ input: "{}", example: "kicad_list_versions {}",+ hint: "The first entry is the one the bridge drives.",+ related: &["kicad_readiness"], pitfalls: &[],+ },+ Verb {+ name: "kicad_diagnostics",+ summary: "Environment the bridge runs in: user, paths, env vars that matter, KiCad config dir.",+ mechanism: Mechanism::Local, risk: "read", timeout_sec: 10,+ input: "{}", example: "kicad_diagnostics {}",+ hint: "Attach this to any bug report.",+ related: &["kicad_status"], pitfalls: &[],+ },+ Verb {+ name: "kicad_run_drc",+ summary: "Run kicad-cli pcb drc on a board and return the parsed JSON report (violations, unconnected, counts by severity).",+ mechanism: Mechanism::Cli, risk: "read", timeout_sec: 300,+ input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"refillZones\"?: bool, \"backend\"?: \"local\"}",+ example: "kicad_run_drc {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+ hint: "Headless: works with no KiCad window open. refillZones true matches what the editor does before its own DRC.",+ related: &["kicad_run_erc", "kicad_lint_board"],+ pitfalls: &["A board saved by a newer KiCad than kicad-cli refuses to load; the error names both versions."],+ },+ Verb {+ name: "kicad_run_erc",+ summary: "Run kicad-cli sch erc on a schematic and return the parsed JSON report.",+ mechanism: Mechanism::Cli, risk: "read", timeout_sec: 300,+ input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"backend\"?: \"local\"}",+ example: "kicad_run_erc {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\"}",+ hint: "Headless. Pass the root sheet; hierarchical sheets are followed by kicad-cli.",+ related: &["kicad_run_drc", "kicad_lint_schematic"], pitfalls: &[],+ },+ Verb {+ name: "kicad_export_gerber",+ summary: "Export Gerbers (and drill files) from a board via kicad-cli.",+ mechanism: Mechanism::Cli, risk: "write", timeout_sec: 300,+ input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"outputDir\"?: \"C:/...\"}",+ example: "kicad_export_gerber {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+ hint: "Absolute board path; outputDir defaults to <board dir>/gerbers_output.",+ related: &["kicad_export_step", "kicad_export_bom_csv", "kicad_run_drc"],+ pitfalls: &["Run kicad_run_drc first; never ship Gerbers from a board with DRC errors."],+ },+ Verb {+ name: "kicad_export_pdf",+ summary: "Plot a board or schematic to PDF via kicad-cli.",+ mechanism: Mechanism::Cli, risk: "write", timeout_sec: 300,+ input: "{\"filePath\": \"C:/.../x.kicad_pcb|.kicad_sch\", \"outputDir\"?: \"C:/...\", \"layers\"?: [\"F.Cu\", ...]}",+ example: "kicad_export_pdf {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\"}",+ hint: "The file extension picks board or schematic. Board plots default to the seven main layers.",+ related: &["kicad_export_svg", "kicad_export_gerber"], pitfalls: &[],+ },+ Verb {+ name: "kicad_export_svg",+ summary: "Plot a board or schematic to SVG via kicad-cli.",+ mechanism: Mechanism::Cli, risk: "write", timeout_sec: 300,+ input: "{\"filePath\": \"C:/.../x.kicad_pcb|.kicad_sch\", \"outputDir\"?: \"C:/...\", \"layers\"?: [\"F.Cu\", ...]}",+ example: "kicad_export_svg {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"layers\":[\"F.Cu\",\"Edge.Cuts\"]}",+ hint: "The file extension picks board or schematic.",+ related: &["kicad_export_pdf"], pitfalls: &[],+ },+ Verb {+ name: "kicad_export_step",+ summary: "Export a STEP model of the board via kicad-cli.",+ mechanism: Mechanism::Cli, risk: "write", timeout_sec: 600,+ input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"outputDir\"?: \"C:/...\"}",+ example: "kicad_export_step {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+ hint: "Slow on big boards; missing 3D models are reported by kicad-cli on stderr and by kicad_model_check beforehand.",+ related: &["kicad_export_gerber"], pitfalls: &["A footprint whose 3D model is missing exports as a bare pad set."],+ },+ Verb {+ name: "kicad_export_bom_csv",+ summary: "Export a BOM CSV from a schematic via kicad-cli.",+ mechanism: Mechanism::Cli, risk: "write", timeout_sec: 300,+ input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"outputDir\"?: \"C:/...\"}",+ example: "kicad_export_bom_csv {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\"}",+ hint: "Pass the root sheet of a hierarchical design.",+ related: &["kicad_export_gerber"], pitfalls: &[],+ },+ Verb {+ name: "kicad_uninstall",+ summary: "Remove only this bridge's own artifacts (scratch dir, backups it made). Never KiCad, never user data.",+ mechanism: Mechanism::Local, risk: "write", timeout_sec: 120,+ input: "{\"cascade\"?: bool}", example: "kicad_uninstall {}",+ hint: "ab calls this during bridge_uninstall; it is idempotent and non-interactive.",+ related: &[], pitfalls: &[],+ },+];++pub fn find(name: &str) -> Option<&'static Verb> {+ VERBS.iter().find(|v| v.name == name)+}++pub fn describe(version: &str) -> Value {+ let verbs: Vec<Value> = VERBS+ .iter()+ .map(|v| {+ json!({+ "name": v.name,+ "summary": v.summary,+ "mechanism": v.mechanism.as_str(),+ "risk": v.risk,+ "timeoutSeconds": v.timeout_sec,+ "longRunning": v.timeout_sec > 60,+ "input": v.input,+ "example": v.example,+ "hint": v.hint,+ "related": v.related,+ "pitfalls": v.pitfalls,+ })+ })+ .collect();+ json!({+ "success": true,+ "bridge": "kicad",+ "version": version,+ "runtime": "native",+ "verbCount": verbs.len(),+ "verbs": verbs,+ "_hint": "Phase 1 of the Rust port: headless verbs only. Window, show, routing and demo verbs are still served by the Python bridge until each phase passes the verb runner.",+ })+}
rust/crates/kicad-bridge/src/main.rsadded+150@@ -0,0 +1,150 @@+//! Adom KiCad Bridge, native binary. Phase 1 of docs/rust-port-plan.md.+//!+//! Contract (same as the Python bridge and the hello-rust sample):+//! - `--port N` from ab, bind ADOM_BIND_HOST (never 0.0.0.0)+//! - GET /status -> health JSON with the optional led/summary/tooltip chip+//! - POST /command {"command":"kicad_x","args":{}} -> {"success","output"|"error",...}+//! Every verb lives in the catalog (catalog.rs) so describe, routing and risk+//! come from one table.++mod catalog;+mod verbs;++use std::time::Instant;++use serde::Deserialize;+use serde_json::{json, Value};+use tiny_http::{Header, Method, Response, Server, StatusCode};++pub const VERSION: &str = env!("CARGO_PKG_VERSION");+pub const SLUG: &str = "adom/kicad-bridge";++#[derive(Debug, Deserialize)]+struct Command {+ #[serde(default)]+ command: String,+ #[serde(default)]+ args: Value,+}++pub fn report_issues_line() -> String {+ format!(+ "THIS MAY BE A KICAD BRIDGE BUG. Check for a duplicate with `adom-wiki issue list {SLUG}`, \+ then file: `adom-wiki issue create {SLUG} --title \"<symptom>\" --category bug-report \+ --body \"<what you ran, expected, got, the verbatim response JSON, bridge version, OS>\"`. \+ An ab-core problem (relay, lifecycle, identity) belongs on adom/adom-bridge instead."+ )+}++pub struct Caller {+ pub thread: String,+ pub container: String,+ pub reason: String,+}++fn header_value(req: &tiny_http::Request, name: &str) -> Option<String> {+ req.headers()+ .iter()+ .find(|h| h.field.as_str().as_str().eq_ignore_ascii_case(name))+ .map(|h| h.value.as_str().to_string())+}++fn parse_port_arg() -> Option<u16> {+ let args: Vec<String> = std::env::args().collect();+ let mut i = 1;+ while i < args.len() {+ if args[i] == "--port" && i + 1 < args.len() {+ return args[i + 1].parse().ok();+ }+ if let Some(v) = args[i].strip_prefix("--port=") {+ return v.parse().ok();+ }+ i += 1;+ }+ None+}++fn json_response(body: Value, code: u16) -> Response<std::io::Cursor<Vec<u8>>> {+ let text = body.to_string();+ Response::from_string(text)+ .with_status_code(StatusCode(code))+ .with_header(Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap())+}++fn main() {+ let port = parse_port_arg().unwrap_or(0);+ let host = std::env::var("ADOM_BIND_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());+ let started = Instant::now();+ let mut request_count: u64 = 0;+ let mut verb_count: u64 = 0;++ let server = Server::http((host.as_str(), port))+ .unwrap_or_else(|e| panic!("[kicad-bridge] bind {host}:{port} failed: {e}"));+ let actual_port = server.server_addr().to_ip().map(|a| a.port()).unwrap_or(port);+ eprintln!("[kicad-bridge] v{VERSION} listening on {host}:{actual_port}");++ let mut state = verbs::State::new();++ for mut req in server.incoming_requests() {+ request_count += 1;+ let uptime = started.elapsed().as_secs();+ let (body, code) = match (req.method(), req.url()) {+ (Method::Get, "/status") | (Method::Get, "/health") => {+ let info = state.kicad_info();+ let led = if info.installed { "green" } else { "yellow" };+ (+ json!({+ "ok": true,+ "bridge": "kicad",+ "version": VERSION,+ "runtime": "native",+ "uptimeSec": uptime,+ "requestCount": request_count,+ "led": led,+ "summary": if info.installed { format!("KiCad {}", info.version) } else { "KiCad not found".into() },+ "tooltip": format!("kicad-bridge v{VERSION} (native)\nup {uptime}s, {request_count} requests\n{}", info.summary()),+ }),+ 200,+ )+ }+ (Method::Post, "/command") => {+ let caller = Caller {+ thread: header_value(&req, "X-Adom-Caller-Thread").unwrap_or_else(|| "?".into()),+ container: header_value(&req, "X-Adom-Caller-Container").unwrap_or_else(|| "?".into()),+ reason: header_value(&req, "X-Adom-Caller-Reason").unwrap_or_default(),+ };+ let mut raw = String::new();+ let _ = req.as_reader().read_to_string(&mut raw);+ if raw.trim().is_empty() {+ raw = "{}".into();+ }+ match serde_json::from_str::<Command>(&raw) {+ Err(e) => (+ json!({"success": false, "error": format!("bad JSON: {e}"), "_reportIssues": report_issues_line()}),+ 400,+ ),+ Ok(cmd) => {+ verb_count += 1;+ eprintln!(+ "[kicad-bridge] {} <- thread={} container={} reason={}",+ cmd.command, caller.thread, caller.container, caller.reason+ );+ let t0 = Instant::now();+ let mut out = verbs::dispatch(&mut state, &cmd.command, &cmd.args, &caller);+ if let Some(o) = out.as_object_mut() {+ o.insert("elapsedMs".into(), json!(t0.elapsed().as_millis() as u64));+ o.entry("bridgeVersion").or_insert(json!(VERSION));+ let failed = !o.get("success").and_then(Value::as_bool).unwrap_or(false);+ if failed || verb_count == 1 {+ o.insert("_reportIssues".into(), json!(report_issues_line()));+ }+ }+ (out, 200)+ }+ }+ }+ _ => (json!({"success": false, "error": "not found"}), 404),+ };+ let _ = req.respond(json_response(body, code));+ }+}
rust/crates/kicad-bridge/src/verbs.rsadded+309@@ -0,0 +1,309 @@+//! Verb handlers for phase 1 (headless). Window, routing and demo verbs come in later phases.++use std::path::PathBuf;+use std::time::Duration;++use serde_json::{json, Value};++use crate::catalog;+use kicad_core::cli;+use kicad_core::detect::{self, KicadInfo};+use kicad_platform::native;+use crate::Caller;++pub struct State {+ info: Option<(std::time::Instant, KicadInfo)>,+}++impl State {+ pub fn new() -> Self {+ State { info: None }+ }+ /// Detection is cached for 60 s; a verb that needs fresh truth calls refresh().+ pub fn kicad_info(&mut self) -> KicadInfo {+ if let Some((t, ref i)) = self.info {+ if t.elapsed() < Duration::from_secs(60) {+ return i.clone();+ }+ }+ self.refresh()+ }+ pub fn refresh(&mut self) -> KicadInfo {+ let i = detect::detect();+ self.info = Some((std::time::Instant::now(), i.clone()));+ i+ }+}++fn fail(code: &str, msg: impl Into<String>, hint: impl Into<String>) -> Value {+ json!({"success": false, "errorCode": code, "error": msg.into(), "_hint": hint.into()})+}++fn arg_str<'a>(args: &'a Value, key: &str) -> Option<&'a str> {+ args.get(key).and_then(Value::as_str).filter(|s| !s.trim().is_empty())+}++/// First non-empty string among several arg names (Python contract uses `filePath`;+/// the port also accepts the more specific names).+fn arg_any<'a>(args: &'a Value, keys: &[&str]) -> Option<&'a str> {+ keys.iter().find_map(|k| arg_str(args, k))+}++fn norm(p: &std::path::Path) -> String {+ p.to_string_lossy().replace('\\', "/")+}++fn alternatives() -> Value {+ json!([+ {"backend": "service", "what": "The shared service-kicad container runs DRC, ERC, exports, renders and library lookups with no desktop KiCad.", "hint": "Load the service-kicad skill; pass backend:\"service\" to the headless verbs."},+ {"backend": "container", "what": "Install kicad-cli into your own container.", "hint": "The service-kicad skill has the install; then the headless verbs run locally."},+ {"backend": "target", "what": "Run KiCad on another box (a second laptop, a conference room machine, a Grok Computer session) and address it by name through Adom Bridge.", "hint": "adom-bridge targets lists the boxes; add --target <name> to every call."}+ ])+}++pub fn dispatch(state: &mut State, command: &str, args: &Value, _caller: &Caller) -> Value {+ let Some(verb) = catalog::find(command) else {+ let names: Vec<&str> = catalog::VERBS.iter().map(|v| v.name).collect();+ return fail("unknown_verb", format!("Unknown command: {command}"), format!("Valid commands: {}", names.join(", ")));+ };+ let mut out = match command {+ "kicad_describe" => catalog::describe(crate::VERSION),+ "kicad_status" => status(state),+ "kicad_readiness" => readiness(state),+ "kicad_list_versions" => list_versions(state),+ "kicad_diagnostics" => diagnostics(state),+ "kicad_run_drc" => run_check(state, args, true),+ "kicad_run_erc" => run_check(state, args, false),+ "kicad_export_gerber" => export(state, args, "pcb", "gerbers", "Gerber"),+ "kicad_export_pdf" => export(state, args, if is_sch(args) { "sch" } else { "pcb" }, "pdf", "PDF"),+ "kicad_export_svg" => export(state, args, if is_sch(args) { "sch" } else { "pcb" }, "svg", "SVG"),+ "kicad_export_step" => export(state, args, "pcb", "step", "STEP"),+ "kicad_export_bom_csv" => export(state, args, "sch", "bom", "BOM CSV"),+ "kicad_uninstall" => uninstall(),+ _ => fail("not_implemented", format!("{command} is catalogued but not implemented in this phase"), "Use the Python bridge for this verb until the phase that ports it lands."),+ };+ if let Some(o) = out.as_object_mut() {+ o.entry("mechanism").or_insert(json!(verb.mechanism.as_str()));+ }+ out+}++fn is_sch(args: &Value) -> bool {+ arg_any(args, &["filePath", "schematicPath"]).map(|p| p.to_ascii_lowercase().ends_with(".kicad_sch")).unwrap_or(false)+}++fn status(state: &mut State) -> Value {+ let info = state.kicad_info();+ json!({+ "success": true,+ "bridgeVersion": crate::VERSION,+ "runtime": "native",+ "installed": info.installed,+ "kicadVersion": info.version,+ "install": info.primary(),+ "_hint": if info.installed { "KiCad found. Headless verbs (run_drc, run_erc) are ready." } else { "KiCad not found here. kicad_readiness lists the alternatives." },+ })+}++fn readiness(state: &mut State) -> Value {+ let info = state.refresh();+ let mut v = json!({+ "success": true,+ "installed": info.installed,+ "ready": info.installed,+ "kicadVersion": info.version,+ "installs": info.installs,+ "configDir": info.primary().and_then(|p| detect::config_dir(&p.version)).map(|p| p.to_string_lossy().replace('\\', "/")),+ "runtime": {"kind": "native", "provisioned": false},+ "platform": native().capabilities(),+ });+ if !info.installed {+ v["errorCode"] = json!("not_installed");+ v["alternatives"] = alternatives();+ v["_hint"] = json!("No KiCad on this box. Pick one of `alternatives`, or call kicad_upgrade to install KiCad silently (asks the user first).");+ } else {+ v["_hint"] = json!("Ready. Headless verbs need nothing else; window verbs are served by the Python bridge until phase 3.");+ }+ v+}++fn list_versions(state: &mut State) -> Value {+ let info = state.refresh();+ json!({"success": true, "count": info.installs.len(), "installs": info.installs, "primary": info.primary()})+}++fn diagnostics(state: &mut State) -> Value {+ let info = state.kicad_info();+ let keys = ["USERPROFILE", "APPDATA", "LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)", "ADOM_BIND_HOST", "ADOM_DIRECT_API_URL", "HOME", "PATH"];+ let env: serde_json::Map<String, Value> = keys+ .iter()+ .map(|k| (k.to_string(), json!(std::env::var(k).ok())))+ .collect();+ json!({+ "success": true,+ "bridgeVersion": crate::VERSION,+ "runtime": "native",+ "exe": std::env::current_exe().ok().map(|p| p.to_string_lossy().replace('\\', "/")),+ "cwd": std::env::current_dir().ok().map(|p| p.to_string_lossy().replace('\\', "/")),+ "os": std::env::consts::OS,+ "arch": std::env::consts::ARCH,+ "env": env,+ "kicad": info,+ "platform": native().capabilities(),+ "configDir": info.primary().and_then(|p| detect::config_dir(&p.version)).map(|p| p.to_string_lossy().replace('\\', "/")),+ })+}++fn run_check(state: &mut State, args: &Value, drc: bool) -> Value {+ let keys: &[&str] = if drc { &["filePath", "boardPath"] } else { &["filePath", "schematicPath"] };+ let Some(path) = arg_any(args, keys) else {+ return fail("missing_arg", "No filePath specified", format!("Pass filePath: the absolute path of the {} file.", if drc { ".kicad_pcb" } else { ".kicad_sch" }));+ };+ let path = PathBuf::from(path);+ if !path.is_file() {+ return fail("file_not_found", format!("{} file not found: {}", if drc { "PCB" } else { "Schematic" }, path.display()), "Check the path; the bridge runs on the desktop, so use the desktop's path, not the container's.");+ }+ let backend = arg_str(args, "backend").unwrap_or("local");+ if backend != "local" {+ return fail("backend_not_implemented", format!("backend {backend} lands with phase 1b"), "Use backend local for now, or run service-kicad from the container directly.");+ }+ let info = state.kicad_info();+ let Some(inst) = info.primary() else {+ let mut v = fail("not_installed", "KiCad not installed", "See alternatives.");+ v["alternatives"] = alternatives();+ return v;+ };+ let cli_path = PathBuf::from(&inst.kicad_cli);+ let kind = if drc { "drc" } else { "erc" };+ let report = std::env::temp_dir().join(format!("adom-kicad-{kind}-{}.json", std::process::id()));+ let report_s = norm(&report);+ let path_s = norm(&path);+ let mut argv: Vec<&str> = if drc {+ vec!["pcb", "drc", "--format", "json", "--severity-all", "--all-track-errors"]+ } else {+ vec!["sch", "erc", "--format", "json", "--severity-all"]+ };+ if drc && args.get("refillZones").and_then(Value::as_bool).unwrap_or(false) {+ argv.push("--refill-zones");+ }+ argv.extend(["--output", report_s.as_str(), path_s.as_str()]);+ let r = cli::run(&cli_path, &argv, Duration::from_secs(280));+ let parsed = std::fs::read_to_string(&report).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok());+ let _ = std::fs::remove_file(&report);+ match parsed {+ Some(rep) => {+ let summary = cli::summarize_report(&rep);+ let violations = cli::flatten_violations(&rep);+ let errors = summary["errors"].as_u64().unwrap_or(0);+ let warnings = summary["warnings"].as_u64().unwrap_or(0);+ let total = violations.len();+ let label = kind.to_uppercase();+ json!({+ "success": true,+ "output": format!("{label} complete: {errors} error(s), {warnings} warning(s), {total} total"),+ "data": {+ "violations": violations,+ "summary": {"total": total, "errors": errors, "warnings": warnings},+ },+ "check": kind,+ "path": path_s,+ "summary": summary,+ "cliExitCode": r.exit_code,+ "cliElapsedMs": r.elapsed_ms,+ "cliVersion": inst.cli_version,+ "_hint": if drc { "summary.clean is true only with zero errors and zero unconnected items; warnings are listed but do not fail the board." } else { "summary.clean means zero errors; ERC warnings are listed." },+ })+ }+ None => {+ let mut v = fail(+ if r.timed_out { "cli_timeout" } else { "cli_failed" },+ format!("{} produced no output. stderr: {}", kind.to_uppercase(), r.stderr.trim()),+ "kicad-cli wrote no report. The stderr above usually names the reason (file version, missing library, bad path).",+ );+ v["stdout"] = json!(r.stdout);+ v["cliElapsedMs"] = json!(r.elapsed_ms);+ v+ }+ }+}++/// kicad-cli exports, same contract as the Python handlers: filePath, outputDir (default+/// <file dir>/<type>_output), layers for svg/pdf; returns data.output_path and data.files.+fn export(state: &mut State, args: &Value, doc: &str, export_type: &str, label: &str) -> Value {+ let Some(path) = arg_any(args, &["filePath", "boardPath", "schematicPath"]) else {+ return fail("missing_arg", "No filePath specified", format!("Pass filePath: the absolute path of the {}.", if doc == "pcb" { ".kicad_pcb" } else { ".kicad_sch" }));+ };+ let path = PathBuf::from(path);+ if !path.is_file() {+ return fail("file_not_found", format!("{} file not found: {}", if doc == "pcb" { "PCB" } else { "Schematic" }, path.display()), "Use the desktop's absolute path.");+ }+ let info = state.kicad_info();+ let Some(inst) = info.primary() else {+ let mut v = fail("not_installed", "KiCad not installed", "See alternatives.");+ v["alternatives"] = alternatives();+ return v;+ };+ let cli_path = PathBuf::from(&inst.kicad_cli);+ let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "export".into());+ let out_dir = arg_str(args, "outputDir")+ .map(PathBuf::from)+ .unwrap_or_else(|| path.parent().unwrap_or(std::path::Path::new(".")).join(format!("{export_type}_output")));+ if let Err(e) = std::fs::create_dir_all(&out_dir) {+ return fail("output_dir", format!("cannot create {}: {e}", out_dir.display()), "Pass an outputDir the desktop user can write.");+ }+ let ext = match export_type { "pdf" => ".pdf", "svg" => ".svg", "step" => ".step", "bom" => ".csv", _ => "" };+ let out_arg = if export_type == "gerbers" { out_dir.clone() } else { out_dir.join(format!("{stem}{ext}")) };+ let out_s = norm(&out_arg);+ let path_s = norm(&path);+ let mut argv: Vec<String> = vec![doc.into(), "export".into(), export_type.into(), "--output".into(), out_s.clone()];+ if doc == "pcb" && (export_type == "svg" || export_type == "pdf") {+ let layers = match args.get("layers") {+ Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(","),+ Some(Value::String(s)) => s.clone(),+ _ => String::new(),+ };+ let layers = if layers.is_empty() { "F.Cu,B.Cu,F.Silkscreen,B.Silkscreen,F.Mask,B.Mask,Edge.Cuts".to_string() } else { layers };+ argv.push("--layers".into());+ argv.push(layers);+ }+ argv.push(path_s.clone());+ let argv_ref: Vec<&str> = argv.iter().map(String::as_str).collect();+ let r = cli::run(&cli_path, &argv_ref, Duration::from_secs(280));+ if r.exit_code != 0 {+ let mut v = fail(if r.timed_out { "cli_timeout" } else { "cli_failed" }, format!("{label} export failed: {}", r.stderr.trim()), "kicad-cli's stderr names the reason (bad layer name, file version, missing 3D model for STEP).");+ v["stdout"] = json!(r.stdout);+ return v;+ }+ let files: Vec<String> = if export_type == "gerbers" {+ std::fs::read_dir(&out_dir).map(|rd| rd.flatten().filter(|e| e.path().is_file()).map(|e| e.file_name().to_string_lossy().to_string()).collect()).unwrap_or_default()+ } else if out_arg.is_file() {+ vec![out_arg.file_name().unwrap().to_string_lossy().to_string()]+ } else {+ Vec::new()+ };+ let bytes: u64 = files.iter().filter_map(|f| std::fs::metadata(out_dir.join(f)).ok()).map(|m| m.len()).sum();+ json!({+ "success": true,+ "output": format!("{label} exported to {out_s} ({} file(s))", files.len()),+ "data": {"output_path": out_s, "files": files, "bytes": bytes},+ "path": path_s,+ "cliElapsedMs": r.elapsed_ms,+ "cliVersion": inst.cli_version,+ "_hint": if export_type == "gerbers" { "Run kicad_run_drc first; never ship Gerbers from a board with DRC errors." } else { "Pull the file with ab pull_file to bring it into the container." },+ })+}++fn uninstall() -> Value {+ let mut cleaned: Vec<String> = Vec::new();+ for name in ["adom-kicad-drc", "adom-kicad-erc"] {+ if let Ok(rd) = std::fs::read_dir(std::env::temp_dir()) {+ for e in rd.flatten() {+ let n = e.file_name().to_string_lossy().to_string();+ if n.starts_with(name) && n.ends_with(".json") && std::fs::remove_file(e.path()).is_ok() {+ cleaned.push(n);+ }+ }+ }+ }+ json!({"success": true, "cleaned": cleaned, "_hint": "Only bridge scratch files are removed. KiCad, its settings and user libraries are untouched."})+}
rust/crates/kicad-core/Cargo.tomladded+12@@ -0,0 +1,12 @@+[package]+name = "kicad-core"+description = "OS-neutral core of the Adom KiCad Bridge: detection, kicad-cli runner and report parsing, KiCad file editing"+version.workspace = true+edition.workspace = true+license.workspace = true+publish.workspace = true++[dependencies]+serde = { workspace = true }+serde_json = { workspace = true }+kicad-platform = { path = "../kicad-platform" }
rust/crates/kicad-core/src/cli.rsadded+142@@ -0,0 +1,142 @@+//! kicad-cli runner. Every call is quiet (no console window), has a deadline, and returns+//! stdout, stderr and the parsed JSON report when one was requested.++use std::path::Path;+use std::process::Command;+use std::time::{Duration, Instant};++use serde_json::{json, Value};++/// Never let a console child blip a window onto the user's screen.+pub fn quiet(cmd: &mut Command) {+ kicad_platform::native().quiet_command(cmd);+}++pub struct CliResult {+ pub exit_code: i32,+ pub stdout: String,+ pub stderr: String,+ pub elapsed_ms: u64,+ pub timed_out: bool,+}++pub fn run(cli: &Path, args: &[&str], timeout: Duration) -> CliResult {+ let mut cmd = Command::new(cli);+ cmd.args(args);+ quiet(&mut cmd);+ cmd.stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped());+ let t0 = Instant::now();+ let mut child = match cmd.spawn() {+ Ok(c) => c,+ Err(e) => {+ return CliResult { exit_code: -1, stdout: String::new(), stderr: format!("spawn failed: {e}"), elapsed_ms: 0, timed_out: false }+ }+ };+ // Poll with a deadline; kill on timeout so a hung kicad-cli never hangs a verb.+ loop {+ match child.try_wait() {+ Ok(Some(_)) => break,+ Ok(None) => {+ if t0.elapsed() > timeout {+ let _ = child.kill();+ let _ = child.wait();+ return CliResult { exit_code: -2, stdout: String::new(), stderr: "kicad-cli timed out".into(), elapsed_ms: t0.elapsed().as_millis() as u64, timed_out: true };+ }+ std::thread::sleep(Duration::from_millis(50));+ }+ Err(e) => {+ return CliResult { exit_code: -1, stdout: String::new(), stderr: format!("wait failed: {e}"), elapsed_ms: t0.elapsed().as_millis() as u64, timed_out: false }+ }+ }+ }+ let out = child.wait_with_output().unwrap();+ CliResult {+ exit_code: out.status.code().unwrap_or(-1),+ stdout: String::from_utf8_lossy(&out.stdout).to_string(),+ stderr: String::from_utf8_lossy(&out.stderr).to_string(),+ elapsed_ms: t0.elapsed().as_millis() as u64,+ timed_out: false,+ }+}++/// Summarise a kicad-cli drc/erc JSON report the way the Python bridge does:+/// counts by severity, unconnected items, and the first violations verbatim.+pub fn summarize_report(report: &Value) -> Value {+ let mut errors = 0u64;+ let mut warnings = 0u64;+ let mut exclusions = 0u64;+ let mut first: Vec<Value> = Vec::new();+ let mut walk = |arr: &Value| {+ if let Some(items) = arr.as_array() {+ for v in items {+ if v.get("excluded").and_then(Value::as_bool).unwrap_or(false) {+ exclusions += 1;+ continue;+ }+ match v.get("severity").and_then(Value::as_str) {+ Some("error") => errors += 1,+ Some("warning") => warnings += 1,+ _ => {}+ }+ if first.len() < 25 {+ first.push(json!({+ "type": v.get("type"),+ "severity": v.get("severity"),+ "description": v.get("description"),+ "items": v.get("items").and_then(Value::as_array).map(|a| a.iter().filter_map(|i| i.get("description").cloned()).collect::<Vec<_>>()),+ }));+ }+ }+ }+ };+ walk(report.get("violations").unwrap_or(&Value::Null));+ let unconnected = report.get("unconnected_items").and_then(Value::as_array).map(|a| a.len()).unwrap_or(0);+ walk(report.get("unconnected_items").unwrap_or(&Value::Null));+ walk(report.get("schematic_parity").unwrap_or(&Value::Null));+ if let Some(sheets) = report.get("sheets").and_then(Value::as_array) {+ for s in sheets {+ walk(s.get("violations").unwrap_or(&Value::Null));+ }+ }+ json!({+ "errors": errors,+ "warnings": warnings,+ "excluded": exclusions,+ "unconnected": unconnected,+ "clean": errors == 0 && unconnected == 0,+ "first": first,+ "kicadVersion": report.get("kicad_version"),+ "source": report.get("source"),+ })+}++/// The Python bridge's `data.violations` shape: one entry per violation and per+/// unconnected item, each with its items' description and pos.+pub fn flatten_violations(report: &Value) -> Vec<Value> {+ let mut out = Vec::new();+ let items_of = |v: &Value| -> Vec<Value> {+ v.get("items").and_then(Value::as_array).map(|a| a.iter().map(|i| json!({"description": i.get("description").cloned().unwrap_or(json!("")), "pos": i.get("pos").cloned().unwrap_or(json!({}))})).collect()).unwrap_or_default()+ };+ let mut push = |arr: Option<&Value>, forced_type: Option<&str>| {+ if let Some(items) = arr.and_then(Value::as_array) {+ for v in items {+ out.push(json!({+ "type": forced_type.map(|t| json!(t)).unwrap_or_else(|| v.get("type").cloned().unwrap_or(json!("unknown"))),+ "severity": v.get("severity").cloned().unwrap_or(json!(if forced_type.is_some() { "error" } else { "warning" })),+ "description": v.get("description").cloned().unwrap_or(json!("")),+ "excluded": v.get("excluded").and_then(Value::as_bool).unwrap_or(false),+ "items": items_of(v),+ }));+ }+ }+ };+ push(report.get("violations"), None);+ push(report.get("unconnected_items"), Some("unconnected_items"));+ push(report.get("schematic_parity"), Some("schematic_parity"));+ if let Some(sheets) = report.get("sheets").and_then(Value::as_array) {+ for s in sheets {+ push(s.get("violations"), None);+ }+ }+ out+}
rust/crates/kicad-core/src/detect.rsadded+42@@ -0,0 +1,42 @@+//! Find KiCad. The OS layer lists candidates; this sorts them and picks the primary.++use serde::Serialize;++pub use kicad_platform::KicadInstall;++#[derive(Clone, Debug, Default, Serialize)]+pub struct KicadInfo {+ pub installed: bool,+ pub version: String,+ pub installs: Vec<KicadInstall>,+}++impl KicadInfo {+ pub fn summary(&self) -> String {+ if !self.installed {+ return "KiCad: not found".into();+ }+ let i = &self.installs[0];+ format!("KiCad {} at {} ({})", i.cli_version, i.base_dir, i.source)+ }+ pub fn primary(&self) -> Option<&KicadInstall> {+ self.installs.first()+ }+}++fn version_key(v: &str) -> (u32, u32, u32) {+ let mut it = v.split('.').map(|p| p.trim().parse::<u32>().unwrap_or(0));+ (it.next().unwrap_or(0), it.next().unwrap_or(0), it.next().unwrap_or(0))+}++pub fn detect() -> KicadInfo {+ let mut installs = kicad_platform::native().find_installs();+ installs.sort_by(|a, b| version_key(&b.cli_version).cmp(&version_key(&a.cli_version)));+ let installed = !installs.is_empty();+ let version = installs.first().map(|i| i.cli_version.clone()).unwrap_or_default();+ KicadInfo { installed, version, installs }+}++pub fn config_dir(version: &str) -> Option<std::path::PathBuf> {+ kicad_platform::native().config_dir(version)+}
rust/crates/kicad-core/src/lib.rsadded+3@@ -0,0 +1,3 @@+//! OS-neutral core. Talks to the OS only through `kicad_platform::native()`.+pub mod cli;+pub mod detect;
rust/crates/kicad-platform/Cargo.tomladded+21@@ -0,0 +1,21 @@+[package]+name = "kicad-platform"+description = "OS-specific layer of the Adom KiCad Bridge: one Platform trait, one implementation compiled in per target OS"+version.workspace = true+edition.workspace = true+license.workspace = true+publish.workspace = true++[dependencies]+serde = { workspace = true }+serde_json = { workspace = true }++# Windows-only crates. Add uiautomation and windows here in phase 3.+[target.'cfg(windows)'.dependencies]+winreg = "0.55"++# macOS-only crates go here (Kyle). Candidates: objc2, core-foundation, accessibility.+[target.'cfg(target_os = "macos")'.dependencies]++# Linux-only crates go here (Barrett). Candidates: x11rb, zbus (portal screenshots), atspi.+[target.'cfg(target_os = "linux")'.dependencies]
rust/crates/kicad-platform/src/lib.rsadded+138@@ -0,0 +1,138 @@+//! The OS layer. Everything above this crate is OS-neutral: the HTTP server, the verb+//! catalog, kicad-cli parsing, KiCad file editing and (phase 2) the IPC client.+//!+//! Exactly one `Native` implementation is compiled in, picked by `cfg(target_os)`.+//! Each OS file implements the `Platform` trait. A method an OS does not support yet+//! returns `unsupported(...)`, which carries an honest `not_supported_on_platform`+//! error and names the owner, so a verb never pretends.+//!+//! Ownership: windows.rs (this thread), macos.rs (Kyle), linux.rs (Barrett).++use std::path::PathBuf;+use std::process::Command;++use serde::Serialize;+use serde_json::{json, Value};++#[derive(Clone, Debug, Default, Serialize)]+pub struct KicadInstall {+ pub version: String,+ #[serde(rename = "baseDir")]+ pub base_dir: String,+ #[serde(rename = "binDir")]+ pub bin_dir: String,+ #[serde(rename = "kicadCli")]+ pub kicad_cli: String,+ #[serde(rename = "cliVersion")]+ pub cli_version: String,+ pub source: String,+ #[serde(rename = "perUser")]+ pub per_user: bool,+}++/// What this build of the bridge can do on this OS. Reported by readiness and+/// diagnostics so an AI never assumes a Windows-only verb works on a Mac.+#[derive(Clone, Debug, Serialize)]+pub struct Capabilities {+ pub os: &'static str,+ pub owner: &'static str,+ #[serde(rename = "kicadCli")]+ pub kicad_cli: bool,+ #[serde(rename = "windowControl")]+ pub window_control: bool,+ #[serde(rename = "uiAutomation")]+ pub ui_automation: bool,+ #[serde(rename = "dialogSweep")]+ pub dialog_sweep: bool,+ #[serde(rename = "focusEtiquette")]+ pub focus_etiquette: bool,+ #[serde(rename = "ipcApi")]+ pub ipc_api: bool,+ #[serde(rename = "silentInstall")]+ pub silent_install: bool,+}++pub trait Platform: Sync + Send {+ fn capabilities(&self) -> Capabilities;+ /// `kicad-cli.exe` on Windows, `kicad-cli` elsewhere.+ fn cli_file_name(&self) -> &'static str;+ /// Every KiCad install this OS can see. Order does not matter; the core sorts by version.+ fn find_installs(&self) -> Vec<KicadInstall>;+ /// Per-user settings dir for a KiCad major.minor (where kicad_common.json lives).+ fn config_dir(&self, version: &str) -> Option<PathBuf>;+ /// Per-user documents dir KiCad uses for libraries and 3rdparty (Documents/KiCad/<ver>).+ fn user_dir(&self, version: &str) -> Option<PathBuf>;+ /// Make a child process invisible and detached from any console.+ fn quiet_command(&self, cmd: &mut Command);+}++/// Standard "this build cannot do that here" reply.+pub fn unsupported(feature: &str) -> Value {+ let caps = native().capabilities();+ json!({+ "success": false,+ "errorCode": "not_supported_on_platform",+ "error": format!("{feature} is not implemented in the {} build of the KiCad bridge yet", caps.os),+ "platform": caps.os,+ "_hint": format!("The {} port is owned by {}. Headless verbs (kicad-cli, file edits) work everywhere; window verbs are per OS.", caps.os, caps.owner),+ })+}++/// Shared helper: describe an install from its kicad-cli path, probing the exact version.+pub fn install_from_cli(cli: &std::path::Path, version_hint: &str, source: &str, per_user: bool) -> Option<KicadInstall> {+ if !cli.is_file() {+ return None;+ }+ let mut cmd = Command::new(cli);+ cmd.arg("version");+ native().quiet_command(&mut cmd);+ let cli_version = match cmd.output() {+ Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),+ _ => String::new(),+ };+ let bin = cli.parent()?.to_path_buf();+ let base = bin.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| bin.clone());+ let version = if !version_hint.is_empty() {+ version_hint.to_string()+ } else {+ cli_version.split('.').take(2).collect::<Vec<_>>().join(".")+ };+ Some(KicadInstall {+ version,+ base_dir: norm(&base),+ bin_dir: norm(&bin),+ kicad_cli: norm(cli),+ cli_version,+ source: source.into(),+ per_user,+ })+}++pub fn norm(p: &std::path::Path) -> String {+ p.to_string_lossy().replace('\\', "/")+}++pub fn major_minor(version: &str) -> String {+ version.split('.').take(2).collect::<Vec<_>>().join(".")+}++#[cfg(windows)]+mod windows;+#[cfg(windows)]+pub use windows::Native;++#[cfg(target_os = "macos")]+mod macos;+#[cfg(target_os = "macos")]+pub use macos::Native;++#[cfg(target_os = "linux")]+mod linux;+#[cfg(target_os = "linux")]+pub use linux::Native;++static NATIVE: Native = Native;++pub fn native() -> &'static dyn Platform {+ &NATIVE+}
rust/crates/kicad-platform/src/linux.rsadded+67@@ -0,0 +1,67 @@+//! Linux implementation (owner: Barrett). Headless parts are complete: kicad-cli on PATH,+//! /usr and /usr/local, snap and flatpak locations, XDG config, ~/Documents/KiCad/<ver>.+//! Window control on Linux depends on the session (X11 versus Wayland portals); add it+//! here in phase 3 and flip the capability flags.++use std::path::PathBuf;+use std::process::Command;++use crate::{install_from_cli, major_minor, Capabilities, KicadInstall, Platform};++pub struct Native;++fn home() -> Option<PathBuf> {+ std::env::var("HOME").ok().map(PathBuf::from)+}++impl Platform for Native {+ fn capabilities(&self) -> Capabilities {+ Capabilities {+ os: "linux",+ owner: "Barrett (Ubuntu build of the bridge)",+ kicad_cli: true,+ window_control: false,+ ui_automation: false,+ dialog_sweep: false,+ focus_etiquette: false,+ ipc_api: false,+ silent_install: false,+ }+ }+ fn cli_file_name(&self) -> &'static str {+ "kicad-cli"+ }+ fn find_installs(&self) -> Vec<KicadInstall> {+ let mut out = Vec::new();+ let mut candidates: Vec<(PathBuf, &str)> = vec![+ (PathBuf::from("/usr/bin/kicad-cli"), "distro package"),+ (PathBuf::from("/usr/local/bin/kicad-cli"), "local build"),+ (PathBuf::from("/snap/bin/kicad-cli"), "snap"),+ (PathBuf::from("/var/lib/flatpak/exports/bin/org.kicad.KiCad"), "flatpak"),+ ];+ if let Ok(path) = std::env::var("PATH") {+ for dir in path.split(':') {+ candidates.push((PathBuf::from(dir).join("kicad-cli"), "PATH"));+ }+ }+ for (c, source) in candidates {+ if out.iter().any(|i: &KicadInstall| i.kicad_cli == crate::norm(&c)) {+ continue;+ }+ if let Some(inst) = install_from_cli(&c, "", source, false) {+ out.push(inst);+ }+ }+ out+ }+ fn config_dir(&self, version: &str) -> Option<PathBuf> {+ let base = std::env::var("XDG_CONFIG_HOME").ok().map(PathBuf::from).or_else(|| home().map(|h| h.join(".config")))?;+ Some(base.join("kicad").join(major_minor(version)))+ }+ fn user_dir(&self, version: &str) -> Option<PathBuf> {+ Some(home()?.join("Documents/KiCad").join(major_minor(version)))+ }+ fn quiet_command(&self, cmd: &mut Command) {+ cmd.stdin(std::process::Stdio::null());+ }+}
rust/crates/kicad-platform/src/macos.rsadded+56@@ -0,0 +1,56 @@+//! macOS implementation (owner: Kyle). Headless parts are complete: the app bundle's+//! kicad-cli, ~/Library/Preferences/kicad/<ver>, ~/Documents/KiCad/<ver>. Window control+//! on macOS is `open -a`, osascript and the Accessibility API; add them here in phase 3+//! and flip the capability flags.++use std::path::PathBuf;+use std::process::Command;++use crate::{install_from_cli, major_minor, Capabilities, KicadInstall, Platform};++pub struct Native;++fn home() -> Option<PathBuf> {+ std::env::var("HOME").ok().map(PathBuf::from)+}++impl Platform for Native {+ fn capabilities(&self) -> Capabilities {+ Capabilities {+ os: "macos",+ owner: "Kyle (macOS Hydrogen and Adom Bridge)",+ kicad_cli: true,+ window_control: false,+ ui_automation: false,+ dialog_sweep: false,+ focus_etiquette: false,+ ipc_api: false,+ silent_install: false,+ }+ }+ fn cli_file_name(&self) -> &'static str {+ "kicad-cli"+ }+ fn find_installs(&self) -> Vec<KicadInstall> {+ let mut out = Vec::new();+ let mut candidates = vec![PathBuf::from("/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli")];+ if let Some(h) = home() {+ candidates.push(h.join("Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli"));+ }+ for (i, c) in candidates.iter().enumerate() {+ if let Some(inst) = install_from_cli(c, "", "app bundle", i == 1) {+ out.push(inst);+ }+ }+ out+ }+ fn config_dir(&self, version: &str) -> Option<PathBuf> {+ Some(home()?.join("Library/Preferences/kicad").join(major_minor(version)))+ }+ fn user_dir(&self, version: &str) -> Option<PathBuf> {+ Some(home()?.join("Documents/KiCad").join(major_minor(version)))+ }+ fn quiet_command(&self, cmd: &mut Command) {+ cmd.stdin(std::process::Stdio::null());+ }+}
rust/crates/kicad-platform/src/windows.rsadded+113@@ -0,0 +1,113 @@+//! Windows implementation. Registry (HKLM/HKCU SOFTWARE\KiCad\<ver>\InstallationPath) plus+//! the standard install folders, %APPDATA%/kicad/<ver> settings, %USERPROFILE%/Documents/KiCad/<ver>.+//! Phase 3 adds window control (windows crate), UIA (uiautomation crate) and the etiquette loop.++use std::path::{Path, PathBuf};+use std::process::Command;++use crate::{install_from_cli, major_minor, Capabilities, KicadInstall, Platform};++pub struct Native;++fn env_path(var: &str) -> Option<PathBuf> {+ std::env::var(var).ok().filter(|s| !s.is_empty()).map(PathBuf::from)+}++/// ab provisions bridges with a stripped environment where APPDATA/LOCALAPPDATA can be+/// missing (documented in the Python bridge). Fall back from USERPROFILE.+fn userprofile() -> Option<PathBuf> {+ env_path("USERPROFILE").or_else(|| std::env::var("HOMEDRIVE").ok().zip(std::env::var("HOMEPATH").ok()).map(|(d, p)| PathBuf::from(format!("{d}{p}"))))+}++fn appdata() -> Option<PathBuf> {+ env_path("APPDATA").or_else(|| userprofile().map(|u| u.join("AppData").join("Roaming")))+}++fn localappdata() -> Option<PathBuf> {+ env_path("LOCALAPPDATA").or_else(|| userprofile().map(|u| u.join("AppData").join("Local")))+}++fn registry_installs() -> Vec<KicadInstall> {+ use winreg::enums::*;+ use winreg::RegKey;+ let mut out = Vec::new();+ for (hive, name, per_user) in [(HKEY_LOCAL_MACHINE, "HKLM", false), (HKEY_CURRENT_USER, "HKCU", true)] {+ let root = RegKey::predef(hive);+ let Ok(k) = root.open_subkey("SOFTWARE\\KiCad") else { continue };+ for ver in k.enum_keys().flatten() {+ let Ok(vk) = k.open_subkey(&ver) else { continue };+ let Ok(path): Result<String, _> = vk.get_value("InstallationPath") else { continue };+ let cli = Path::new(&path).join("bin").join("kicad-cli.exe");+ if let Some(i) = install_from_cli(&cli, &ver, &format!("registry {name}"), per_user) {+ out.push(i);+ }+ }+ }+ out+}++fn folder_installs() -> Vec<KicadInstall> {+ let mut bases: Vec<(PathBuf, bool)> = Vec::new();+ for var in ["ProgramFiles", "ProgramFiles(x86)"] {+ if let Some(p) = env_path(var) {+ bases.push((p.join("KiCad"), false));+ }+ }+ if let Some(l) = localappdata() {+ bases.push((l.join("Programs").join("KiCad"), true));+ }+ let mut out = Vec::new();+ for (base, per_user) in bases {+ let Ok(rd) = std::fs::read_dir(&base) else { continue };+ for e in rd.flatten() {+ let name = e.file_name().to_string_lossy().to_string();+ if e.path().is_dir() && name.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) {+ let cli = e.path().join("bin").join("kicad-cli.exe");+ if let Some(i) = install_from_cli(&cli, &name, "folder", per_user) {+ out.push(i);+ }+ }+ }+ }+ out+}++impl Platform for Native {+ fn capabilities(&self) -> Capabilities {+ Capabilities {+ os: "windows",+ owner: "the kicad-bridge maintainer thread",+ kicad_cli: true,+ window_control: false, // phase 3+ ui_automation: false, // phase 3+ dialog_sweep: false, // phase 3+ focus_etiquette: false, // phase 4+ ipc_api: false, // phase 2+ silent_install: false, // phase 1b+ }+ }+ fn cli_file_name(&self) -> &'static str {+ "kicad-cli.exe"+ }+ fn find_installs(&self) -> Vec<KicadInstall> {+ let mut installs = registry_installs();+ for f in folder_installs() {+ if !installs.iter().any(|i| i.bin_dir.eq_ignore_ascii_case(&f.bin_dir)) {+ installs.push(f);+ }+ }+ installs+ }+ fn config_dir(&self, version: &str) -> Option<PathBuf> {+ Some(appdata()?.join("kicad").join(major_minor(version)))+ }+ fn user_dir(&self, version: &str) -> Option<PathBuf> {+ Some(userprofile()?.join("Documents").join("KiCad").join(major_minor(version)))+ }+ fn quiet_command(&self, cmd: &mut Command) {+ use std::os::windows::process::CommandExt;+ const CREATE_NO_WINDOW: u32 = 0x0800_0000;+ cmd.stdin(std::process::Stdio::null());+ cmd.creation_flags(CREATE_NO_WINDOW);+ }+}