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: phase 1 skeleton (server, catalog, detect, kicad-cli runner, 8 headless verbs); smoke-tested on ConfRoomROG
8 files changed
+1070
rust/.gitignoreadded+1@@ -0,0 +1 @@+target/
rust/Cargo.lockadded+234@@ -0,0 +1,234 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "ascii"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"++[[package]]+name = "cfg-if"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"++[[package]]+name = "chunked_transfer"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"++[[package]]+name = "httpdate"+version = "1.0.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"++[[package]]+name = "itoa"+version = "1.0.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"++[[package]]+name = "kicad-bridge"+version = "1.0.0-alpha.1"+dependencies = [+ "serde",+ "serde_json",+ "tiny_http",+ "winreg",+]++[[package]]+name = "log"+version = "0.4.34"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"++[[package]]+name = "memchr"+version = "2.8.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"++[[package]]+name = "proc-macro2"+version = "1.0.107"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "quote"+version = "1.0.47"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "serde"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"+dependencies = [+ "serde_core",+ "serde_derive",+]++[[package]]+name = "serde_core"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"+dependencies = [+ "serde_derive",+]++[[package]]+name = "serde_derive"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "serde_json"+version = "1.0.151"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"+dependencies = [+ "itoa",+ "memchr",+ "serde",+ "serde_core",+ "zmij",+]++[[package]]+name = "syn"+version = "3.0.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "tiny_http"+version = "0.12.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"+dependencies = [+ "ascii",+ "chunked_transfer",+ "httpdate",+ "log",+]++[[package]]+name = "unicode-ident"+version = "1.0.24"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"++[[package]]+name = "windows-sys"+version = "0.59.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"+dependencies = [+ "windows-targets",+]++[[package]]+name = "windows-targets"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"+dependencies = [+ "windows_aarch64_gnullvm",+ "windows_aarch64_msvc",+ "windows_i686_gnu",+ "windows_i686_gnullvm",+ "windows_i686_msvc",+ "windows_x86_64_gnu",+ "windows_x86_64_gnullvm",+ "windows_x86_64_msvc",+]++[[package]]+name = "windows_aarch64_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"++[[package]]+name = "windows_aarch64_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"++[[package]]+name = "windows_i686_gnu"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"++[[package]]+name = "windows_i686_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"++[[package]]+name = "windows_i686_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"++[[package]]+name = "windows_x86_64_gnu"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"++[[package]]+name = "windows_x86_64_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"++[[package]]+name = "windows_x86_64_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"++[[package]]+name = "winreg"+version = "0.55.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97"+dependencies = [+ "cfg-if",+ "windows-sys",+]++[[package]]+name = "zmij"+version = "1.0.23"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
rust/Cargo.tomladded+26@@ -0,0 +1,26 @@+[package]+name = "kicad-bridge"+version = "1.0.0-alpha.1"+edition = "2021"+publish = false+description = "Adom KiCad Bridge, native binary (Rust port of the Python bridge)"+license = "MIT"++[[bin]]+name = "kicad-bridge"+path = "src/main.rs"++[dependencies]+tiny_http = "0.12"+serde = { version = "1", features = ["derive"] }+serde_json = "1"++[target.'cfg(windows)'.dependencies]+winreg = "0.55"++[profile.release]+opt-level = "z"+lto = true+codegen-units = 1+strip = true+panic = "abort"
rust/src/catalog.rsadded+150@@ -0,0 +1,150 @@+//! 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: "{\"boardPath\": \"C:/.../x.kicad_pcb\", \"refillZones\"?: bool, \"backend\"?: \"local\"}",+ example: "kicad_run_drc {\"boardPath\":\"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: "{\"schematicPath\": \"C:/.../x.kicad_sch\", \"backend\"?: \"local\"}",+ example: "kicad_run_erc {\"schematicPath\":\"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_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/src/cli.rsadded+117@@ -0,0 +1,117 @@+//! 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) {+ cmd.stdin(std::process::Stdio::null());+ #[cfg(windows)]+ {+ use std::os::windows::process::CommandExt;+ const CREATE_NO_WINDOW: u32 = 0x0800_0000;+ cmd.creation_flags(CREATE_NO_WINDOW);+ }+}++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"),+ })+}
rust/src/detect.rsadded+179@@ -0,0 +1,179 @@+//! Find KiCad on this machine. Windows: registry (HKLM/HKCU SOFTWARE\KiCad\<ver>\InstallationPath)+//! plus the standard folders. Other OSes: PATH and the app bundle. No subprocess except+//! `kicad-cli version` for the exact patch level.++use std::path::{Path, PathBuf};++use serde::Serialize;++#[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,+}++#[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 (registry and standard folders checked)".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 cli_name() -> &'static str {+ if cfg!(windows) { "kicad-cli.exe" } else { "kicad-cli" }+}++fn probe_cli_version(cli: &Path) -> String {+ let mut cmd = std::process::Command::new(cli);+ cmd.arg("version");+ crate::cli::quiet(&mut cmd);+ match cmd.output() {+ Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),+ _ => String::new(),+ }+}++fn install_from_base(base: &Path, version_hint: &str, source: &str, per_user: bool) -> Option<KicadInstall> {+ let bin = base.join("bin");+ let cli = bin.join(cli_name());+ if !cli.is_file() {+ return None;+ }+ let cli_version = probe_cli_version(&cli);+ 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: base.to_string_lossy().replace('\\', "/"),+ bin_dir: bin.to_string_lossy().replace('\\', "/"),+ kicad_cli: cli.to_string_lossy().replace('\\', "/"),+ cli_version,+ source: source.into(),+ per_user,+ })+}++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))+}++#[cfg(windows)]+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 };+ if let Some(i) = install_from_base(Path::new(&path), &ver, &format!("registry {name}"), per_user) {+ out.push(i);+ }+ }+ }+ out+}++#[cfg(not(windows))]+fn registry_installs() -> Vec<KicadInstall> {+ Vec::new()+}++fn folder_installs() -> Vec<KicadInstall> {+ let mut out = Vec::new();+ let mut bases: Vec<(PathBuf, bool)> = Vec::new();+ if cfg!(windows) {+ for (var, per_user) in [("ProgramFiles", false), ("ProgramFiles(x86)", false)] {+ if let Ok(p) = std::env::var(var) {+ bases.push((PathBuf::from(p).join("KiCad"), per_user));+ }+ }+ let local = std::env::var("LOCALAPPDATA")+ .map(PathBuf::from)+ .or_else(|_| std::env::var("USERPROFILE").map(|u| PathBuf::from(u).join("AppData").join("Local")));+ if let Ok(l) = local {+ bases.push((l.join("Programs").join("KiCad"), true));+ }+ } else if cfg!(target_os = "macos") {+ bases.push((PathBuf::from("/Applications/KiCad/KiCad.app/Contents/MacOS"), false));+ } else {+ bases.push((PathBuf::from("/usr"), false));+ bases.push((PathBuf::from("/usr/local"), false));+ }+ for (base, per_user) in bases {+ if cfg!(windows) {+ 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) {+ if let Some(i) = install_from_base(&e.path(), &name, "folder", per_user) {+ out.push(i);+ }+ }+ }+ } else if let Some(i) = install_from_base(&base, "", "folder", per_user) {+ out.push(i);+ }+ }+ out+}++pub fn detect() -> KicadInfo {+ 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.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 }+}++/// %APPDATA%/kicad/<major.minor>/ on Windows; ~/.config/kicad/<ver> elsewhere.+pub fn config_dir(version: &str) -> Option<PathBuf> {+ let mm: String = version.split('.').take(2).collect::<Vec<_>>().join(".");+ if cfg!(windows) {+ let appdata = std::env::var("APPDATA").map(PathBuf::from).ok().or_else(|| {+ std::env::var("USERPROFILE").ok().map(|u| PathBuf::from(u).join("AppData").join("Roaming"))+ })?;+ Some(appdata.join("kicad").join(mm))+ } else if cfg!(target_os = "macos") {+ Some(PathBuf::from(std::env::var("HOME").ok()?).join("Library/Preferences/kicad").join(mm))+ } else {+ let base = std::env::var("XDG_CONFIG_HOME")+ .map(PathBuf::from)+ .unwrap_or_else(|_| PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config"));+ Some(base.join("kicad").join(mm))+ }+}
rust/src/main.rsadded+152@@ -0,0 +1,152 @@+//! 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 cli;+mod detect;+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/src/verbs.rsadded+211@@ -0,0 +1,211 @@+//! 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 crate::cli;+use crate::detect::{self, KicadInfo};+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())+}++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_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 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},+ });+ 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,+ "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 key = if drc { "boardPath" } else { "schematicPath" };+ let Some(path) = arg_str(args, key) else {+ return fail("missing_arg", format!("{key} is required"), format!("Pass 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!("{} does not exist", 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 is not installed on this box", "See alternatives.");+ v["alternatives"] = alternatives();+ return v;+ };+ let cli_path = PathBuf::from(&inst.kicad_cli);+ let report = std::env::temp_dir().join(format!("adom-kicad-{}-{}.json", if drc { "drc" } else { "erc" }, std::process::id()));+ let report_s = report.to_string_lossy().to_string();+ let path_s = path.to_string_lossy().to_string();+ 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);+ json!({+ "success": true,+ "check": if drc { "drc" } else { "erc" },+ "path": path_s,+ "summary": summary,+ "cliExitCode": r.exit_code,+ "cliElapsedMs": r.elapsed_ms,+ "cliVersion": inst.cli_version,+ "report": rep,+ "_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!("kicad-cli exit {}: {}", r.exit_code, 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+ }+ }+}++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."})+}