← Commit history
rust/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);+    }+}