← Commit history

Merge PR #11: Truing-up: offering/terms README, self-host, CLI 1.1.0, GLB gate + provenance stamp (#106)

Drew Owens ·77be6cd5d2 ·3mo ago ·parent 03ffff1
15 files changed +2135
.gitignoreadded+4
@@ -0,0 +1,4 @@+target/+Cargo.lock+*.log+.cache/
Cargo.tomladded+19
@@ -0,0 +1,19 @@+[workspace]+resolver = "2"+members = ["api", "cli"]++[workspace.package]+version = "1.1.0"+edition = "2021"+license = "AGPL-3.0-or-later"+repository = "https://github.com/adom-inc/service-kicad"+authors = ["Adom <[email protected]>"]++[workspace.dependencies]+anyhow = "1"+serde = { version = "1", features = ["derive"] }+serde_json = "1"+tokio = { version = "1", features = ["full"] }+tracing = "0.1"+tracing-subscriber = "0.3"+tempfile = "3"
Dockerfileadded+44
@@ -0,0 +1,44 @@+# Dockerfile for service-kicad — DEFAULT channel (KiCad 10).+# REFERENCE only; production uses the default-light image + deploy.sh.+# For the v9 fallback channel, see Dockerfile.v9.+FROM ubuntu:24.04++ENV DEBIAN_FRONTEND=noninteractive++RUN apt-get update && apt-get install -y \+    software-properties-common curl build-essential pkg-config git jq cron \+ && add-apt-repository -y ppa:kicad/kicad-10.0-releases \+ && apt-get update \+ && apt-get install -y kicad kicad-symbols kicad-footprints kicad-packages3d \+ && rm -rf /var/lib/apt/lists/*++# Library overlay: prefer the upstream `10.0` stable branch when it+# exists, fall back to master. Same rationale as Dockerfile (the apt+# library packages lag upstream by years; the v10 stable branch is the+# closest matched pair to the v10 kicad-cli we just installed).+RUN for repo in kicad-footprints kicad-symbols kicad-packages3D; do \+      dst="/opt/${repo,,}-upstream"; \+      git clone --depth 1 --branch 10.0 "https://github.com/KiCad/$repo.git" "$dst" 2>/dev/null \+        || git clone --depth 1 "https://github.com/KiCad/$repo.git" "$dst"; \+    done++ENV KICAD_FOOTPRINTS_DIR=/opt/kicad-footprints-upstream+ENV KICAD_SYMBOLS_DIR=/opt/kicad-symbols-upstream+ENV KICAD_PACKAGES3D_DIR=/opt/kicad-packages3d-upstream++# Weekly pull keeps the libraries current; logs to /var/log/kicad-lib-pull.log.+RUN echo '0 4 * * 1 root for d in /opt/kicad-footprints-upstream /opt/kicad-symbols-upstream /opt/kicad-packages3d-upstream; do git -C "$d" pull --quiet --ff-only 2>&1 | tee -a /var/log/kicad-lib-pull.log; done' > /etc/cron.d/kicad-lib-pull \+ && chmod 0644 /etc/cron.d/kicad-lib-pull++RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y+ENV PATH="/root/.cargo/bin:${PATH}"++WORKDIR /app+COPY . .++RUN cd api && cargo build --release \+ && install target/release/service-kicad /usr/local/bin/service-kicad++EXPOSE 8780++CMD ["/usr/local/bin/service-kicad"]
Dockerfile.v9added+43
@@ -0,0 +1,43 @@+# Dockerfile.v9 for service-kicad — V9 FALLBACK channel.+# REFERENCE only; production uses the default-light image + deploy-v9.sh.+# Most users want the default Dockerfile (KiCad 10).+FROM ubuntu:24.04++ENV DEBIAN_FRONTEND=noninteractive++RUN apt-get update && apt-get install -y \+    software-properties-common curl build-essential pkg-config git jq cron \+ && add-apt-repository -y ppa:kicad/kicad-9.0-releases \+ && apt-get update \+ && apt-get install -y kicad kicad-symbols kicad-footprints kicad-packages3d \+ && rm -rf /var/lib/apt/lists/*++# The Ubuntu apt packages of kicad-symbols/footprints/packages3d lag the+# upstream KiCad libraries by ~years (the 24.04 packages are ~2020 snapshot).+# Overlay the upstream github repos so users see the latest parts. Set the+# *_DIR env vars so service-kicad serves these instead of the apt copies.+# A cron job below refreshes them weekly.+RUN git clone --depth 1 https://github.com/KiCad/kicad-footprints.git /opt/kicad-footprints-upstream \+ && git clone --depth 1 https://github.com/KiCad/kicad-symbols.git    /opt/kicad-symbols-upstream    \+ && git clone --depth 1 https://github.com/KiCad/kicad-packages3D.git /opt/kicad-packages3d-upstream++ENV KICAD_FOOTPRINTS_DIR=/opt/kicad-footprints-upstream+ENV KICAD_SYMBOLS_DIR=/opt/kicad-symbols-upstream+ENV KICAD_PACKAGES3D_DIR=/opt/kicad-packages3d-upstream++# Weekly pull keeps the libraries current; logs to /var/log/kicad-lib-pull.log.+RUN echo '0 4 * * 1 root cd /opt/kicad-footprints-upstream && git pull --quiet 2>&1 | tee -a /var/log/kicad-lib-pull.log; cd /opt/kicad-symbols-upstream && git pull --quiet 2>&1 | tee -a /var/log/kicad-lib-pull.log; cd /opt/kicad-packages3d-upstream && git pull --quiet 2>&1 | tee -a /var/log/kicad-lib-pull.log' > /etc/cron.d/kicad-lib-pull \+ && chmod 0644 /etc/cron.d/kicad-lib-pull++RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y+ENV PATH="/root/.cargo/bin:${PATH}"++WORKDIR /app+COPY . .++RUN cd api && cargo build --release \+ && install target/release/service-kicad /usr/local/bin/service-kicad++EXPOSE 8780++CMD ["/usr/local/bin/service-kicad"]
api/Cargo.tomladded+24
@@ -0,0 +1,24 @@+[package]+name = "service-kicad-api"+version.workspace = true+edition.workspace = true+license.workspace = true+repository.workspace = true+authors.workspace = true+description = "service-kicad HTTP API — shared headless KiCad + kicad-packages3d cache for every Adom tool. See docs/service-kicad-api.md."++[[bin]]+name = "service-kicad-api"+path = "src/main.rs"++[dependencies]+anyhow.workspace = true+axum = "0.7"+tokio.workspace = true+tower-http = { version = "0.5", features = ["fs", "limit"] }+serde.workspace = true+serde_json.workspace = true+tracing.workspace = true+tracing-subscriber.workspace = true+tempfile.workspace = true+urlencoding = "2.1"
api/build.rsadded+24
@@ -0,0 +1,24 @@+//! Bake the git SHA of HEAD into the binary at compile time, surfaced+//! as `git_sha` in /health. Compile-time (not runtime `git -C` like+//! service-step2glb) because the deployed binary can outlive the repo+//! checkout it was built from — the watchdog resets the repo *then*+//! rebuilds, and a failed rebuild keeps the old binary running.++use std::process::Command;++fn main() {+    let sha = Command::new("git")+        .args(["rev-parse", "HEAD"])+        .output()+        .ok()+        .filter(|o| o.status.success())+        .and_then(|o| String::from_utf8(o.stdout).ok())+        .map(|s| s.trim().to_string())+        .unwrap_or_else(|| "unknown".into());+    println!("cargo:rustc-env=GIT_SHA={sha}");++    // Recompile when HEAD moves even if no source file changed+    // (e.g. a docs- or cli-only commit) so the SHA can't go stale.+    println!("cargo:rerun-if-changed=../.git/HEAD");+    println!("cargo:rerun-if-changed=../.git/refs/heads/main");+}
api/src/main.rsadded+927
@@ -0,0 +1,927 @@+//! service-kicad — shared headless KiCad + kicad-packages3d cache.+//!+//! Replaces the legacy `service-kicad` container (being decommissioned)+//! and extracts the KiCad bits from `service-aci` so every Adom tool+//! shares one source of truth for:+//!+//! - `kicad-cli` subprocess wrappers (DRC, ERC, SVG/STEP/GLB/Gerber export).+//! - `kicad-packages3d` static asset serving (the ~5 GB model library).+//! - `kicad-symbols` + `kicad-footprints` static asset serving.+//!+//! This binary is **tool-agnostic by design** — it does NOT depend on+//! any aci-* crate. That means symbol-creator, footprint-creator,+//! library-creator, adom-tsci, adom-chipfit, adom-circuit, etc. all+//! hit the same endpoints with no ordering constraint on releases.+//!+//! Contract: `docs/service-kicad-api.md`.++use axum::{+    body::Bytes,+    extract::{DefaultBodyLimit, Path as AxumPath, Query, RawQuery},+    http::StatusCode,+    response::{IntoResponse, Response},+    routing::{get, post},+    Json, Router,+};+use serde::Deserialize;+use serde_json::{json, Value};+use std::{net::SocketAddr, path::PathBuf, process::Command};+use tempfile::NamedTempFile;+use tower_http::services::ServeDir;++const MAX_UPLOAD_BYTES: usize = 100 * 1024 * 1024; // 100 MB++#[tokio::main]+async fn main() -> anyhow::Result<()> {+    tracing_subscriber::fmt::init();++    let models_dir     = dir_or_default("KICAD_PACKAGES3D_DIR", "/usr/share/kicad/3dmodels");+    let footprints_dir = dir_or_default("KICAD_FOOTPRINTS_DIR", "/usr/share/kicad/footprints");+    let symbols_dir    = dir_or_default("KICAD_SYMBOLS_DIR",    "/usr/share/kicad/symbols");++    let app = Router::new()+        .route("/health",  get(health))+        .route("/version", get(version))+        // kicad-cli pcb operations+        .route("/kicad/pcb/drc",             post(pcb_drc))+        .route("/kicad/pcb/export/svg",      post(pcb_export_svg))+        .route("/kicad/pcb/export/gerbers",  post(pcb_export_gerbers))+        .route("/kicad/pcb/export/step",     post(pcb_export_step))+        .route("/kicad/pcb/export/glb",      post(pcb_export_glb))+        .route("/kicad/pcb/export/glb-zup",  post(pcb_export_glb_zup))+        .route("/kicad/pcb/export/glb-package", post(pcb_export_glb_package))+        .route("/kicad/pcb/export/glb-package-zup", post(pcb_export_glb_package_zup))+        // kicad-cli sch operations+        .route("/kicad/sch/erc",             post(sch_erc))+        .route("/kicad/sch/export/svg",      post(sch_export_svg))+        .route("/kicad/sch/export/svg-project", post(sch_export_svg_project))+        .route("/kicad/sch/export/pdf",      post(sch_export_pdf))+        .route("/kicad/sch/export/bom",      post(sch_export_bom))+        // Altium → KiCad import (KiCad 10's plugin IO auto-detects the OLE+        // format). POST raw .SchLib → .kicad_sym; POST raw .PcbLib → .kicad_mod.+        .route("/kicad/altium/sym",          post(altium_sym_upgrade))+        .route("/kicad/altium/fp",           post(altium_fp_upgrade))+        // Symbol + footprint export by library/name — cached lookup.+        .route("/sym/export/svg/:library/:name", get(sym_export_svg))+        .route("/fp/export/svg/:library/:name",  get(fp_export_svg))+        // Static library mounts+        .nest_service("/models",     ServeDir::new(&models_dir))+        .nest_service("/footprints", ServeDir::new(&footprints_dir))+        .nest_service("/symbols",    ServeDir::new(&symbols_dir))+        .layer(DefaultBodyLimit::max(MAX_UPLOAD_BYTES));++    let port: u16 = std::env::var("PORT").ok().and_then(|s| s.parse().ok()).unwrap_or(8780);+    let addr: SocketAddr = format!("0.0.0.0:{port}").parse()?;+    tracing::info!(%addr, "service-kicad listening");+    let listener = tokio::net::TcpListener::bind(addr).await?;+    axum::serve(listener, app).await?;+    Ok(())+}++fn dir_or_default(env_key: &str, default: &str) -> String {+    std::env::var(env_key).unwrap_or_else(|_| default.to_string())+}++async fn health() -> Json<Value> {+    let kicad_version = Command::new(kicad_cli())+        .arg("version")+        .output()+        .ok()+        .and_then(|o| String::from_utf8(o.stdout).ok())+        .map(|s| s.trim().to_string())+        .unwrap_or_else(|| "unknown".into());+    Json(json!({+        "ok": true,+        "service": "service-kicad",+        "version": env!("CARGO_PKG_VERSION"),+        "kicad_version": kicad_version,+        "git_sha": GIT_SHA,+        "git_sha_short": git_sha_short(),+    }))+}++async fn version() -> Json<Value> {+    Json(json!({+        "service": "service-kicad",+        "version": env!("CARGO_PKG_VERSION"),+        "api_version": "1.0.0",+        "git_sha": GIT_SHA,+        "git_sha_short": git_sha_short(),+    }))+}++/// SHA the running binary was built from (baked in by api/build.rs).+/// Deploy verification: if this lags origin/main on GitHub, the prod+/// watchdog's fetch-rebuild loop is failing silently.+const GIT_SHA: &str = env!("GIT_SHA");++fn git_sha_short() -> &'static str {+    &GIT_SHA[..GIT_SHA.len().min(8)]+}++fn kicad_cli() -> String {+    std::env::var("KICAD_CLI").unwrap_or_else(|_| "kicad-cli".into())+}++/// kicad-cli invocation wrapper — always sets KICAD{6,7,8,9,10}_3DMODEL_DIR+/// so STEP/GLB exports resolve component models. Matches the fix baked+/// into aci-validate (PLAN Part 25). Setting unused versions is a no-op,+/// so the same binary works on v9 and v10 containers.+fn run_kicad(args: &[&str]) -> Result<Vec<u8>, (StatusCode, String)> {+    let model_dir = dir_or_default("KICAD_PACKAGES3D_DIR", "/usr/share/kicad/3dmodels");+    let out = Command::new(kicad_cli())+        .args(args)+        .env("KICAD6_3DMODEL_DIR", &model_dir)+        .env("KICAD7_3DMODEL_DIR", &model_dir)+        .env("KICAD8_3DMODEL_DIR", &model_dir)+        .env("KICAD9_3DMODEL_DIR", &model_dir)+        .env("KICAD10_3DMODEL_DIR", &model_dir)+        .output()+        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("spawn kicad-cli: {e}")))?;+    if !out.status.success() {+        let stderr = String::from_utf8_lossy(&out.stderr).into_owned();+        return Err((StatusCode::BAD_REQUEST, stderr));+    }+    Ok(out.stdout)+}++fn write_temp(body: &Bytes, ext: &str) -> Result<NamedTempFile, (StatusCode, String)> {+    let tmp = tempfile::Builder::new()+        .suffix(&format!(".{ext}"))+        .tempfile()+        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("tempfile: {e}")))?;+    std::fs::write(tmp.path(), body)+        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("write: {e}")))?;+    Ok(tmp)+}++fn err(code: StatusCode, message: &str) -> Response {+    (code, Json(json!({ "error": message, "code": code.as_u16() }))).into_response()+}++// ─── PCB operations ───────────────────────────────────────────────────++async fn pcb_drc(body: Bytes) -> Response {+    let pcb = match write_temp(&body, "kicad_pcb") { Ok(t) => t, Err((c, m)) => return err(c, &m) };+    let out_tmp = match NamedTempFile::with_suffix(".json") { Ok(t) => t, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempfile: {e}")) };+    if let Err((c, m)) = run_kicad(&[+        "pcb", "drc",+        pcb.path().to_str().unwrap_or_default(),+        "-o", out_tmp.path().to_str().unwrap_or_default(),+        "--format", "json",+    ]) { return err(c, &m); }+    let bytes = match std::fs::read(out_tmp.path()) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };+    (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "application/json")], bytes).into_response()+}++#[derive(Deserialize)]+struct SvgQuery { side: Option<String>, layers: Option<String> }++/// Extract repeated `?D=name=value` pairs from a raw query string.+///+/// We can't use `Query<Vec<String>>` because axum's default Query+/// extractor uses serde_urlencoded, which collapses repeated keys into+/// a single value (and errors when deserializing into a Vec). Parsing+/// the raw query string ourselves keeps the API ergonomic from the+/// caller side (`?D=name1=value1&D=name2=value2`) without pulling in+/// a serde_qs / serde_html_form dependency just for this.+///+/// Same validation as before: each entry must contain `=`, the name+/// must match `[A-Za-z_][A-Za-z0-9_-]*`. Bad entries are silently+/// dropped (logging would be noise on a hot endpoint).+fn parse_define_pairs(raw: &Option<String>) -> Vec<(String, String)> {+    let Some(q) = raw.as_deref() else { return Vec::new() };+    let mut out = Vec::new();+    for pair in q.split('&') {+        let Some(rest) = pair.strip_prefix("D=") else { continue };+        // URL-decode the value portion. `name=value` is the SHAPE we+        // expect AFTER decoding, so decode first then split.+        let decoded = urlencoding::decode(rest).unwrap_or(std::borrow::Cow::Borrowed(rest)).into_owned();+        let Some(eq) = decoded.find('=') else { continue };+        let name = decoded[..eq].to_string();+        let value = decoded[eq + 1..].to_string();+        if name.is_empty() { continue }+        let valid = name.chars().enumerate().all(|(i, c)|+            (i == 0 && (c.is_ascii_alphabetic() || c == '_'))+            || (i > 0 && (c.is_ascii_alphanumeric() || c == '_' || c == '-'))+        );+        if !valid { continue }+        out.push((name, value));+    }+    out+}++/// Flatten parsed pairs into the `-D name=value` arg sequence.+fn define_var_args(pairs: &[(String, String)]) -> Vec<String> {+    let mut out = Vec::with_capacity(pairs.len() * 2);+    for (name, value) in pairs {+        out.push("-D".to_string());+        out.push(format!("{name}={value}"));+    }+    out+}++/// Allowed kicad-cli pcb export flags (boolean and value-bearing).+/// These are the flags from `kicad-cli pcb export glb --help` and+/// `kicad-cli pcb export step --help` (KiCad 10). Flags already+/// hardcoded (`--subst-models`, `--no-dnp`) are excluded — passing+/// them again is harmless but pointless.+///+/// Callers pass `?F=--include-pads&F=--no-board-body` in the query+/// string. Unknown flags are silently dropped.+const ALLOWED_PCB_EXPORT_FLAGS: &[&str] = &[+    // boolean flags+    "--board-only",+    "--cut-vias-in-body",+    "--fill-all-vias",+    "--fuse-shapes",+    "--grid-origin",+    "--drill-origin",+    "--include-inner-copper",+    "--include-pads",+    "--include-silkscreen",+    "--include-soldermask",+    "--include-tracks",+    "--include-zones",+    "--no-board-body",+    "--no-components",+    "--no-extra-pad-thickness",+    "--no-optimize-step",+    "--no-unspecified",+    // value flags (passed as --flag=value or --flag value)+    "--component-filter",+    "--min-distance",+    "--net-filter",+    "--user-origin",+    "--variant",+];++/// Parse `F=<flag>` entries from the raw query string. Each flag is+/// validated against `ALLOWED_PCB_EXPORT_FLAGS`. Boolean flags are+/// returned as-is; value flags (e.g. `F=--min-distance%3D0.01`) are+/// split into flag + value and returned as two entries.+fn parse_extra_flags(raw: &Option<String>) -> Vec<String> {+    let Some(q) = raw.as_deref() else { return Vec::new() };+    let mut out = Vec::new();+    for pair in q.split('&') {+        let Some(rest) = pair.strip_prefix("F=") else { continue };+        let decoded = urlencoding::decode(rest)+            .unwrap_or(std::borrow::Cow::Borrowed(rest))+            .into_owned();+        // Split on first '=' after the flag name: "--min-distance=0.01"+        if let Some(eq) = decoded.strip_prefix("--").and_then(|s| s.find('=').map(|i| i + 2)) {+            let flag_name = &decoded[..eq];+            let flag_value = &decoded[eq + 1..];+            if ALLOWED_PCB_EXPORT_FLAGS.contains(&flag_name) {+                out.push(flag_name.to_string());+                out.push(flag_value.to_string());+            }+        } else if ALLOWED_PCB_EXPORT_FLAGS.contains(&decoded.as_str()) {+            out.push(decoded);+        }+    }+    out+}++async fn pcb_export_svg(Query(q): Query<SvgQuery>, RawQuery(raw): RawQuery, body: Bytes) -> Response {+    let pcb = match write_temp(&body, "kicad_pcb") { Ok(t) => t, Err((c, m)) => return err(c, &m) };+    let out_tmp = match NamedTempFile::with_suffix(".svg") { Ok(t) => t, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempfile: {e}")) };+    // Side defaults to top; layer set matches aci-validate's default. Callers can+    // override the layer set with `?layers=F.Cu,F.Mask,...` (comma-separated KiCad+    // layer names); we allow only layer-name characters so it's safe to forward.+    let side = q.side.unwrap_or_else(|| "top".into());+    let default_layers = if side == "bottom" { "B.Cu,B.Silkscreen,Edge.Cuts" } else { "F.Cu,F.Silkscreen,Edge.Cuts" };+    let valid = |l: &str| !l.is_empty() && l.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | ',' | '_'));+    let custom = q.layers.as_deref().map(valid).unwrap_or(false);+    let layers = if custom { q.layers.as_deref().unwrap() } else { default_layers };+    let pairs = parse_define_pairs(&raw);+    let var_args = define_var_args(&pairs);+    let mut args: Vec<&str> = vec![+        "pcb", "export", "svg",+        pcb.path().to_str().unwrap_or_default(),+        "-o", out_tmp.path().to_str().unwrap_or_default(),+        "--layers", layers,+        "--mode-single",+    ];+    // a custom layer set is a board-preview request → drop the paper frame + title block+    if custom { args.push("--exclude-drawing-sheet"); }+    for s in &var_args { args.push(s); }+    if let Err((c, m)) = run_kicad(&args) { return err(c, &m); }+    let bytes = match std::fs::read(out_tmp.path()) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };+    (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "image/svg+xml")], bytes).into_response()+}++/// Altium symbol library (`.SchLib`, OLE binary) → KiCad `.kicad_sym`.+/// KiCad 10's `sym upgrade` runs every SCH_IO plugin's CanRead probe, so it+/// auto-detects and converts the Altium format — no Altium install needed.+/// The input MUST keep its `.SchLib` extension for the plugin to bite.+async fn altium_sym_upgrade(body: Bytes) -> Response {+    let lib = match write_temp(&body, "SchLib") { Ok(t) => t, Err((c, m)) => return err(c, &m) };+    // kicad-cli refuses to write to a path that already exists, so the output+    // must NOT be pre-created — hand it a fresh name inside a temp dir.+    let out_dir = match tempfile::tempdir() { Ok(d) => d, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempdir: {e}")) };+    let out_path = out_dir.path().join("out.kicad_sym");+    let args = vec![+        "sym", "upgrade", "--force",+        "-o", out_path.to_str().unwrap_or_default(),+        lib.path().to_str().unwrap_or_default(),+    ];+    if let Err((c, m)) = run_kicad(&args) { return err(c, &m); }+    let bytes = match std::fs::read(&out_path) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };+    if bytes.is_empty() { return err(StatusCode::BAD_REQUEST, "kicad-cli produced an empty .kicad_sym (no symbol in library?)"); }+    (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response()+}++/// Altium footprint library (`.PcbLib`, OLE binary) → KiCad `.kicad_mod`.+/// `fp upgrade` writes a `.pretty` directory of `.kicad_mod` files; a single+/// part library yields one, which we return. (Multi-footprint libraries return+/// the first — chip-fetcher libraries are single-part.)+async fn altium_fp_upgrade(body: Bytes) -> Response {+    let lib = match write_temp(&body, "PcbLib") { Ok(t) => t, Err((c, m)) => return err(c, &m) };+    // -o must be a .pretty dir that does NOT exist yet — kicad-cli creates it+    // and refuses if the path already exists. Nest it inside a temp dir.+    let work = match tempfile::tempdir() { Ok(d) => d, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempdir: {e}")) };+    let out_pretty = work.path().join("out.pretty");+    let args = vec![+        "fp", "upgrade", "--force",+        "-o", out_pretty.to_str().unwrap_or_default(),+        lib.path().to_str().unwrap_or_default(),+    ];+    if let Err((c, m)) = run_kicad(&args) { return err(c, &m); }+    // The .pretty output dir holds one or more .kicad_mod files; pick the first.+    let mod_path = std::fs::read_dir(&out_pretty)+        .ok()+        .and_then(|rd| rd.flatten()+            .map(|e| e.path())+            .find(|p| p.extension().map(|x| x == "kicad_mod").unwrap_or(false)));+    let Some(mod_path) = mod_path else {+        return err(StatusCode::BAD_REQUEST, "kicad-cli produced no .kicad_mod (no footprint in library?)");+    };+    let bytes = match std::fs::read(&mod_path) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };+    (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response()+}++async fn pcb_export_gerbers(RawQuery(raw): RawQuery, body: Bytes) -> Response {+    let pcb = match write_temp(&body, "kicad_pcb") { Ok(t) => t, Err((c, m)) => return err(c, &m) };+    let out_dir = match tempfile::tempdir() { Ok(d) => d, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempdir: {e}")) };+    let pairs = parse_define_pairs(&raw);+    let var_args = define_var_args(&pairs);+    let mut args: Vec<&str> = vec![+        "pcb", "export", "gerbers",+        pcb.path().to_str().unwrap_or_default(),+        "-o", out_dir.path().to_str().unwrap_or_default(),+        "--layers", "F.Cu,B.Cu,F.Mask,B.Mask,F.Silkscreen,B.Silkscreen,Edge.Cuts",+    ];+    for s in &var_args { args.push(s); }+    if let Err((c, m)) = run_kicad(&args) { return err(c, &m); }+    // Drill file is a separate kicad-cli subcommand; emit Excellon+    // (PTH+NPTH merged, metric, decimal) into the same tempdir so the+    // tar-roll picks it up. Without drills, pcb-stackup downstream+    // can't punch holes through the substrate/mask/pads — which is+    // what we want for any preview render or fab pack. Failures here+    // are non-fatal: a drill-less gerber set is still useful for the+    // copper / mask / silk preview, just without holes.+    // --generate-map is a boolean flag with NO argument in kicad-cli 9 + 10.+    // Passing "false" as a value makes the CLI parse it as the INPUT_FILE+    // positional, which then errors with "Maximum number of positional+    // arguments exceeded". Default is no-map, so just omit the flag.+    let _ = run_kicad(&[+        "pcb", "export", "drill",+        pcb.path().to_str().unwrap_or_default(),+        "-o", out_dir.path().to_str().unwrap_or_default(),+        "--format", "excellon",+        "--excellon-units", "mm",+        "--excellon-zeros-format", "decimal",+    ]);+    // Tar the directory for transport.+    let tar = match NamedTempFile::with_suffix(".tar") { Ok(t) => t, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tar tempfile: {e}")) };+    let status = Command::new("tar")+        .args(["-cf", tar.path().to_str().unwrap_or_default(), "-C", out_dir.path().to_str().unwrap_or_default(), "."])+        .status();+    match status {+        Ok(s) if s.success() => {}+        Ok(_) => return err(StatusCode::INTERNAL_SERVER_ERROR, "tar failed"),+        Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tar spawn: {e}")),+    }+    let bytes = match std::fs::read(tar.path()) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read tar: {e}")) };+    (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "application/x-tar")], bytes).into_response()+}++async fn pcb_export_step(RawQuery(raw): RawQuery, body: Bytes) -> Response {+    let pairs = parse_define_pairs(&raw);+    let flags = parse_extra_flags(&raw);+    kicad_pcb_binary_export(body, "step", "model/step", &pairs, &flags)+}+async fn pcb_export_glb(RawQuery(raw): RawQuery, body: Bytes) -> Response {+    let pairs = parse_define_pairs(&raw);+    let flags = parse_extra_flags(&raw);+    let resp = kicad_pcb_binary_export(body, "glb",  "model/gltf-binary", &pairs, &flags);+    glb_stamp_provenance_response(resp).await+}+async fn pcb_export_glb_zup(RawQuery(raw): RawQuery, body: Bytes) -> Response {+    let pairs = parse_define_pairs(&raw);+    let flags = parse_extra_flags(&raw);+    let resp = kicad_pcb_binary_export(body, "glb", "model/gltf-binary", &pairs, &flags);+    let resp = glb_apply_zup(resp).await;+    glb_stamp_provenance_response(resp).await+}++/// GLB export from a *packaged* board: tar.gz body containing a `.kicad_pcb`+/// plus any sibling files it references (typically `3dmodels/*.step`). The+/// archive is extracted to a tempdir and `KIPRJMOD` is set to the directory+/// containing the `.kicad_pcb`, so model paths like+/// `${KIPRJMOD}/3dmodels/MachineContactLarge.step` resolve correctly during+/// `kicad-cli pcb export glb --subst-models`.+///+/// The flat /glb endpoint can't do this — it sees only the .kicad_pcb bytes+/// and KIPRJMOD points at a tempdir with no siblings, so any+/// project-relative model is rendered as a bare hole. This endpoint is the+/// fix for that case (driven by adom-inc/kicad-to-molecule moleculizer+/// packages).+async fn pcb_export_glb_package(RawQuery(raw): RawQuery, body: Bytes) -> Response {+    let pairs = parse_define_pairs(&raw);+    let flags = parse_extra_flags(&raw);+    let workdir = match tempfile::tempdir() {+        Ok(d) => d,+        Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempdir: {e}")),+    };+    let tar = match write_temp(&body, "tar.gz") {+        Ok(t) => t,+        Err((c, m)) => return err(c, &m),+    };+    let extract = Command::new("tar")+        .args([+            "-xzf",+            tar.path().to_str().unwrap_or_default(),+            "-C",+            workdir.path().to_str().unwrap_or_default(),+        ])+        .output();+    match extract {+        Ok(o) if o.status.success() => {}+        Ok(o) => return err(+            StatusCode::BAD_REQUEST,+            &format!("tar -xzf failed: {}", String::from_utf8_lossy(&o.stderr)),+        ),+        Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tar spawn: {e}")),+    }++    let pcb = match find_first_with_extension(workdir.path(), "kicad_pcb") {+        Some(p) => p,+        None => return err(StatusCode::BAD_REQUEST, "no .kicad_pcb file inside archive"),+    };+    let kiprjmod = pcb.parent().map(PathBuf::from).unwrap_or_else(|| workdir.path().to_path_buf());++    let out_tmp = match NamedTempFile::with_suffix(".glb") {+        Ok(t) => t,+        Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempfile: {e}")),+    };++    let var_args = define_var_args(&pairs);+    let model_dir = dir_or_default("KICAD_PACKAGES3D_DIR", "/usr/share/kicad/3dmodels");+    let pcb_str = pcb.to_str().unwrap_or_default().to_string();+    let out_str = out_tmp.path().to_str().unwrap_or_default().to_string();+    let mut args: Vec<String> = vec![+        "pcb".into(), "export".into(), "glb".into(),+        pcb_str, "-o".into(), out_str,+        "--subst-models".into(), "--no-dnp".into(),+    ];+    for s in &var_args { args.push(s.clone()); }+    for s in &flags { args.push(s.clone()); }+    let result = Command::new(kicad_cli())+        .args(&args)+        .env("KICAD6_3DMODEL_DIR", &model_dir)+        .env("KICAD7_3DMODEL_DIR", &model_dir)+        .env("KICAD8_3DMODEL_DIR", &model_dir)+        .env("KICAD9_3DMODEL_DIR", &model_dir)+        .env("KICAD10_3DMODEL_DIR", &model_dir)+        .env("KIPRJMOD", &kiprjmod)+        .output();+    let out = match result {+        Ok(o) => o,+        Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("spawn kicad-cli: {e}")),+    };+    if !out.status.success() {+        let stderr = String::from_utf8_lossy(&out.stderr).into_owned();+        return err(StatusCode::BAD_REQUEST, &stderr);+    }+    let bytes = match std::fs::read(out_tmp.path()) {+        Ok(b) => b,+        Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")),+    };+    let resp = (+        StatusCode::OK,+        [(axum::http::header::CONTENT_TYPE, "model/gltf-binary")],+        bytes,+    )+        .into_response();+    glb_stamp_provenance_response(resp).await+}++async fn pcb_export_glb_package_zup(RawQuery(raw): RawQuery, body: Bytes) -> Response {+    let resp = pcb_export_glb_package(RawQuery(raw), body).await;+    glb_apply_zup(resp).await+    // zup output is already stamped: pcb_export_glb_package stamps before+    // the rotation, and both edits merge into asset.extras.adom.+}++/// Apply Y-up → Z-up rotation to a GLB response. Passes errors through.+async fn glb_apply_zup(resp: Response) -> Response {+    let (parts, body) = resp.into_parts();+    if !parts.status.is_success() {+        return Response::from_parts(parts, body);+    }+    let bytes = match axum::body::to_bytes(body, 200 * 1024 * 1024).await {+        Ok(b) => b.to_vec(),+        Err(_) => return err(StatusCode::INTERNAL_SERVER_ERROR, "failed to read glb body"),+    };+    match glb_rotate_y_to_z(bytes) {+        Ok(rotated) => (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "model/gltf-binary")], rotated).into_response(),+        Err(e) => err(StatusCode::INTERNAL_SERVER_ERROR, &format!("glb rotation: {e}")),+    }+}++/// Parse a GLB, apply `edit` to its JSON chunk, and reassemble with the+/// binary chunk untouched (JSON chunk re-padded to 4-byte alignment).+fn glb_edit_json(glb: Vec<u8>, edit: impl FnOnce(&mut Value) -> Result<(), String>) -> Result<Vec<u8>, String> {+    if glb.len() < 20 { return Err("GLB too short".into()); }+    let magic = u32::from_le_bytes(glb[0..4].try_into().unwrap());+    if magic != 0x46546C67 { return Err("not a GLB".into()); }+    let json_len = u32::from_le_bytes(glb[12..16].try_into().unwrap()) as usize;+    let json_type = u32::from_le_bytes(glb[16..20].try_into().unwrap());+    if json_type != 0x4E4F534A { return Err("first chunk not JSON".into()); }+    let json_end = 20 + json_len;+    if glb.len() < json_end { return Err("truncated JSON chunk".into()); }+    let json_str = std::str::from_utf8(&glb[20..json_end]).map_err(|e| e.to_string())?;+    let mut doc: Value = serde_json::from_str(json_str).map_err(|e| e.to_string())?;++    edit(&mut doc)?;++    let new_json = serde_json::to_vec(&doc).map_err(|e| e.to_string())?;+    let padded_len = (new_json.len() + 3) & !3;+    let mut padded = new_json;+    padded.resize(padded_len, b' ');+    let bin_chunk = &glb[json_end..];+    let total = 12 + 8 + padded.len() + bin_chunk.len();+    let mut out = Vec::with_capacity(total);+    out.extend_from_slice(&0x46546C67u32.to_le_bytes());+    out.extend_from_slice(&2u32.to_le_bytes());+    out.extend_from_slice(&(total as u32).to_le_bytes());+    out.extend_from_slice(&(padded.len() as u32).to_le_bytes());+    out.extend_from_slice(&0x4E4F534Au32.to_le_bytes());+    out.extend_from_slice(&padded);+    out.extend_from_slice(bin_chunk);+    Ok(out)+}++/// Merge keys into `asset.extras.adom` without clobbering what other+/// edits (zup rotation, provenance stamp) already wrote there.+fn merge_extras_adom(doc: &mut Value, entries: Value) -> Result<(), String> {+    if doc.get("asset").is_none() { doc["asset"] = json!({}); }+    let asset = doc.get_mut("asset").ok_or("no asset")?;+    if !asset.get("extras").map(Value::is_object).unwrap_or(false) { asset["extras"] = json!({}); }+    let extras = asset.get_mut("extras").ok_or("no extras")?;+    if !extras.get("adom").map(Value::is_object).unwrap_or(false) { extras["adom"] = json!({}); }+    let adom = extras.get_mut("adom").and_then(|v| v.as_object_mut()).ok_or("adom extras not an object")?;+    let obj = entries.as_object().ok_or("entries not an object")?;+    for (k, v) in obj { adom.insert(k.clone(), v.clone()); }+    Ok(())+}++/// Rotate a GLB binary from Y-up (glTF default) to Z-up by wrapping+/// all scene root nodes under a new parent with +90° X rotation.+/// Merges `asset.extras.adom.zUp = true` as metadata.+fn glb_rotate_y_to_z(glb: Vec<u8>) -> Result<Vec<u8>, String> {+    glb_edit_json(glb, |doc| {+        let s = std::f64::consts::FRAC_PI_4.sin();+        let c = std::f64::consts::FRAC_PI_4.cos();++        // Read scene_idx and old_roots before taking mutable borrows+        let scene_idx = doc.get("scene").and_then(|v| v.as_u64()).unwrap_or(0) as usize;+        let old_roots = doc.get("scenes")+            .and_then(|s| s.as_array())+            .and_then(|a| a.get(scene_idx))+            .and_then(|s| s.get("nodes"))+            .and_then(|n| n.as_array())+            .cloned()+            .unwrap_or_default();++        // Push new root node+        let root_idx = doc.get("nodes").and_then(|n| n.as_array()).map(|a| a.len()).unwrap_or(0);+        doc.get_mut("nodes")+            .and_then(|n| n.as_array_mut())+            .ok_or("no nodes")?+            .push(json!({ "name": "zup_root", "rotation": [s, 0.0, 0.0, c], "children": old_roots }));++        // Rewrite scene roots+        doc.get_mut("scenes")+            .and_then(|s| s.as_array_mut())+            .and_then(|a| a.get_mut(scene_idx))+            .ok_or("scene index OOB")?+            ["nodes"] = json!([root_idx]);++        merge_extras_adom(doc, json!({"zUp": true}))+    })+}++/// Provenance marker for KiCad-native GLB output (page issue #106).+/// These exports bypass the molecule pipeline (no MP-marker anchoring,+/// gold machine-pin material, meter normalization, or footprint emit),+/// so every GLB that leaves this service is stamped in+/// `asset.extras.adom` where downstream importers — and humans with+/// `strings board.glb | grep provenance` — can catch it.+const GLB_PROVENANCE: &str = "kicad-native-glb";+const GLB_PROVENANCE_WARNING: &str =+    "Exported by kicad-cli via service-kicad, NOT the molecule pipeline: \+     no MP-marker anchoring, gold machine-pin material, meter normalization, \+     or footprint emit. For molecule use, export STEP here and convert with \+     step2glb/service-occt. See wiki.adom.inc/adom/service-kicad issue #106.";++fn glb_stamp_provenance(glb: Vec<u8>) -> Result<Vec<u8>, String> {+    glb_edit_json(glb, |doc| {+        merge_extras_adom(doc, json!({+            "provenance": GLB_PROVENANCE,+            "provenanceWarning": GLB_PROVENANCE_WARNING,+            "generator": format!("service-kicad {}", git_sha_short()),+        }))+    })+}++/// Stamp a successful GLB response body and add the X-Adom-Provenance+/// header. A stamp failure must never break the export — the endpoints+/// stay behaviorally identical for existing consumers — so on error the+/// original bytes go out with the header as the only marker.+async fn glb_stamp_provenance_response(resp: Response) -> Response {+    let (parts, body) = resp.into_parts();+    if !parts.status.is_success() {+        return Response::from_parts(parts, body);+    }+    let bytes = match axum::body::to_bytes(body, 200 * 1024 * 1024).await {+        Ok(b) => b.to_vec(),+        Err(_) => return err(StatusCode::INTERNAL_SERVER_ERROR, "failed to read glb body"),+    };+    let stamped = match glb_stamp_provenance(bytes.clone()) {+        Ok(s) => s,+        Err(e) => {+            tracing::warn!("glb provenance stamp failed ({e}) — returning unstamped");+            bytes+        }+    };+    (+        StatusCode::OK,+        [+            (axum::http::header::CONTENT_TYPE, "model/gltf-binary"),+            (axum::http::header::HeaderName::from_static("x-adom-provenance"), GLB_PROVENANCE),+        ],+        stamped,+    )+        .into_response()+}++/// BFS the directory for the first file with the given extension.+fn find_first_with_extension(root: &std::path::Path, ext: &str) -> Option<PathBuf> {+    use std::collections::VecDeque;+    let mut queue: VecDeque<PathBuf> = VecDeque::new();+    queue.push_back(root.to_path_buf());+    while let Some(d) = queue.pop_front() {+        let entries = match std::fs::read_dir(&d) {+            Ok(e) => e,+            Err(_) => continue,+        };+        for entry in entries.flatten() {+            let p = entry.path();+            if p.is_dir() {+                queue.push_back(p);+                continue;+            }+            if p.extension().and_then(|s| s.to_str()) == Some(ext) {+                return Some(p);+            }+        }+    }+    None+}++fn kicad_pcb_binary_export(body: Bytes, kind: &str, content_type: &'static str, pairs: &[(String, String)], flags: &[String]) -> Response {+    let pcb = match write_temp(&body, "kicad_pcb") { Ok(t) => t, Err((c, m)) => return err(c, &m) };+    let out_tmp = match NamedTempFile::with_suffix(&format!(".{kind}")) { Ok(t) => t, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempfile: {e}")) };+    let var_args = define_var_args(pairs);+    let mut args: Vec<&str> = vec![+        "pcb", "export", kind,+        pcb.path().to_str().unwrap_or_default(),+        "-o", out_tmp.path().to_str().unwrap_or_default(),+        "--subst-models", "--no-dnp",+    ];+    for s in &var_args { args.push(s); }+    for s in flags { args.push(s); }+    if let Err((c, m)) = run_kicad(&args) { return err(c, &m); }+    let bytes = match std::fs::read(out_tmp.path()) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };+    (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, content_type)], bytes).into_response()+}++// ─── Schematic operations ─────────────────────────────────────────────++async fn sch_erc(body: Bytes) -> Response {+    let sch = match write_temp(&body, "kicad_sch") { Ok(t) => t, Err((c, m)) => return err(c, &m) };+    let out_tmp = match NamedTempFile::with_suffix(".json") { Ok(t) => t, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempfile: {e}")) };+    if let Err((c, m)) = run_kicad(&[+        "sch", "erc",+        sch.path().to_str().unwrap_or_default(),+        "-o", out_tmp.path().to_str().unwrap_or_default(),+        "--format", "json",+    ]) { return err(c, &m); }+    let bytes = match std::fs::read(out_tmp.path()) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };+    (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "application/json")], bytes).into_response()+}++async fn sch_export_svg(body: Bytes) -> Response { kicad_sch_file_export(body, "svg", "image/svg+xml") }+async fn sch_export_pdf(body: Bytes) -> Response { kicad_sch_file_export(body, "pdf", "application/pdf") }++fn kicad_sch_file_export(body: Bytes, kind: &str, content_type: &'static str) -> Response {+    let sch = match write_temp(&body, "kicad_sch") { Ok(t) => t, Err((c, m)) => return err(c, &m) };+    let out_dir = match tempfile::tempdir() { Ok(d) => d, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempdir: {e}")) };+    if let Err((c, m)) = run_kicad(&[+        "sch", "export", kind,+        sch.path().to_str().unwrap_or_default(),+        "-o", out_dir.path().to_str().unwrap_or_default(),+    ]) { return err(c, &m); }+    // Pick the first emitted file matching the extension.+    let found = std::fs::read_dir(out_dir.path()).ok()+        .and_then(|d| d.flatten().find(|e| e.path().extension().and_then(|s| s.to_str()) == Some(kind))+            .map(|e| e.path()));+    let path = match found { Some(p) => p, None => return err(StatusCode::INTERNAL_SERVER_ERROR, "no file produced") };+    let bytes = match std::fs::read(&path) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };+    (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, content_type)], bytes).into_response()+}++/// Render a whole hierarchical schematic. The body is a `.tar` of all the+/// project's `.kicad_sch` (+ optional `.kicad_pro`) files, preserving filenames+/// so kicad-cli can resolve the sheet hierarchy (a single-file render can't).+/// Returns a `.tar` of one SVG per sheet.+async fn sch_export_svg_project(body: Bytes) -> Response {+    let workdir = match tempfile::tempdir() { Ok(d) => d, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempdir: {e}")) };+    let src = workdir.path().join("src");+    if let Err(e) = std::fs::create_dir_all(&src) { return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("mkdir: {e}")); }+    let tar = match write_temp(&body, "tar") { Ok(t) => t, Err((c, m)) => return err(c, &m) };+    match Command::new("tar").args(["-xf", tar.path().to_str().unwrap_or_default(), "-C", src.to_str().unwrap_or_default()]).output() {+        Ok(o) if o.status.success() => {}+        Ok(o) => return err(StatusCode::BAD_REQUEST, &format!("tar -xf failed: {}", String::from_utf8_lossy(&o.stderr))),+        Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tar spawn: {e}")),+    }+    let entries: Vec<PathBuf> = std::fs::read_dir(&src).ok().map(|d| d.flatten().map(|e| e.path()).collect()).unwrap_or_default();+    let is_ext = |p: &PathBuf, e: &str| p.extension().and_then(|s| s.to_str()) == Some(e);+    // root = the .kicad_sch whose stem matches the .kicad_pro, else the first sheet+    let pro_stem = entries.iter().find(|p| is_ext(p, "kicad_pro")).and_then(|p| p.file_stem().and_then(|s| s.to_str()).map(String::from));+    let root = pro_stem.as_ref()+        .and_then(|st| entries.iter().find(|p| is_ext(p, "kicad_sch") && p.file_stem().and_then(|s| s.to_str()) == Some(st.as_str())).cloned())+        .or_else(|| entries.iter().find(|p| is_ext(p, "kicad_sch")).cloned());+    let root = match root { Some(r) => r, None => return err(StatusCode::BAD_REQUEST, "no .kicad_sch inside archive") };+    let out = workdir.path().join("out");+    if let Err(e) = std::fs::create_dir_all(&out) { return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("mkdir: {e}")); }+    if let Err((c, m)) = run_kicad(&["sch", "export", "svg", root.to_str().unwrap_or_default(), "-o", out.to_str().unwrap_or_default()]) { return err(c, &m); }+    let out_tar = match NamedTempFile::with_suffix(".tar") { Ok(t) => t, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tar tempfile: {e}")) };+    match Command::new("tar").args(["-cf", out_tar.path().to_str().unwrap_or_default(), "-C", out.to_str().unwrap_or_default(), "."]).status() {+        Ok(s) if s.success() => {}+        _ => return err(StatusCode::INTERNAL_SERVER_ERROR, "tar output failed"),+    }+    let bytes = match std::fs::read(out_tar.path()) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };+    (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "application/x-tar")], bytes).into_response()+}++async fn sch_export_bom(body: Bytes) -> Response {+    let sch = match write_temp(&body, "kicad_sch") { Ok(t) => t, Err((c, m)) => return err(c, &m) };+    let out_tmp = match NamedTempFile::with_suffix(".csv") { Ok(t) => t, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempfile: {e}")) };+    if let Err((c, m)) = run_kicad(&[+        "sch", "export", "bom",+        sch.path().to_str().unwrap_or_default(),+        "-o", out_tmp.path().to_str().unwrap_or_default(),+    ]) { return err(c, &m); }+    let bytes = match std::fs::read(out_tmp.path()) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };+    (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "text/csv")], bytes).into_response()+}++// ─── Library lookup — by library+name convenience wrappers ────────────++async fn sym_export_svg(AxumPath((library, name)): AxumPath<(String, String)>) -> Response {+    let symbols_dir = dir_or_default("KICAD_SYMBOLS_DIR", "/usr/share/kicad/symbols");+    let src = PathBuf::from(&symbols_dir).join(format!("{library}.kicad_sym"));+    if !src.exists() { return err(StatusCode::NOT_FOUND, &format!("symbol library {library} not installed")); }+    // `kicad-cli sym export svg -o` wants a DIRECTORY on both v9 and v10+    // (help text: "Output directory"). It emits `<symbol>_unit1.svg` etc.+    // The pre-7ce0f04 form passed a NamedTempFile path here, which kicad+    // tried to create files inside — silently broken on v9 too.+    let out_dir = match tempfile::tempdir() { Ok(d) => d, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempdir: {e}")) };+    if let Err((c, m)) = run_kicad(&[+        "sym", "export", "svg",+        src.to_str().unwrap_or_default(),+        "-o", out_dir.path().to_str().unwrap_or_default(),+        "--symbol", &name,+    ]) { return err(c, &m); }+    let path = std::fs::read_dir(out_dir.path()).ok()+        .and_then(|d| d.flatten().find(|e| e.path().extension().and_then(|s| s.to_str()) == Some("svg")).map(|e| e.path()));+    let path = match path { Some(p) => p, None => return err(StatusCode::INTERNAL_SERVER_ERROR, "kicad-cli produced no SVG") };+    let bytes = match std::fs::read(&path) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };+    (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "image/svg+xml")], bytes).into_response()+}++async fn fp_export_svg(AxumPath((library, name)): AxumPath<(String, String)>) -> Response {+    let fp_dir = dir_or_default("KICAD_FOOTPRINTS_DIR", "/usr/share/kicad/footprints");+    let src = PathBuf::from(&fp_dir).join(format!("{library}.pretty"));+    if !src.exists() { return err(StatusCode::NOT_FOUND, &format!("footprint library {library} not installed")); }+    let mod_path = src.join(format!("{name}.kicad_mod"));+    if !mod_path.exists() { return err(StatusCode::NOT_FOUND, &format!("footprint {library}/{name} not installed")); }+    // `kicad-cli fp export svg` takes the .pretty DIRECTORY + `--footprint+    // <name>` on both v9 and v10 (help text: "Input directory"). The+    // pre-7ce0f04 form passed a single .kicad_mod file, which v9 also+    // rejected with "Footprint library does not exist or is not accessible".+    let out_dir = match tempfile::tempdir() { Ok(d) => d, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("tempdir: {e}")) };+    if let Err((c, m)) = run_kicad(&[+        "fp", "export", "svg",+        src.to_str().unwrap_or_default(),+        "-o", out_dir.path().to_str().unwrap_or_default(),+        "--footprint", &name,+    ]) { return err(c, &m); }+    let path = std::fs::read_dir(out_dir.path()).ok()+        .and_then(|d| d.flatten().find(|e| e.path().extension().and_then(|s| s.to_str()) == Some("svg")).map(|e| e.path()));+    let path = match path { Some(p) => p, None => return err(StatusCode::INTERNAL_SERVER_ERROR, "kicad-cli produced no SVG") };+    let bytes = match std::fs::read(&path) { Ok(b) => b, Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, &format!("read: {e}")) };+    (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "image/svg+xml")], bytes).into_response()+}++#[cfg(test)]+mod tests {+    use super::*;++    /// Minimal valid GLB: JSON chunk + empty BIN chunk.+    fn tiny_glb() -> Vec<u8> {+        let json = serde_json::to_vec(&json!({+            "asset": {"version": "2.0"},+            "scene": 0,+            "scenes": [{"nodes": [0]}],+            "nodes": [{"name": "board"}],+        })).unwrap();+        let padded_len = (json.len() + 3) & !3;+        let mut padded = json;+        padded.resize(padded_len, b' ');+        let total = 12 + 8 + padded.len();+        let mut glb = Vec::with_capacity(total);+        glb.extend_from_slice(&0x46546C67u32.to_le_bytes());+        glb.extend_from_slice(&2u32.to_le_bytes());+        glb.extend_from_slice(&(total as u32).to_le_bytes());+        glb.extend_from_slice(&(padded.len() as u32).to_le_bytes());+        glb.extend_from_slice(&0x4E4F534Au32.to_le_bytes());+        glb.extend_from_slice(&padded);+        glb+    }++    fn json_chunk(glb: &[u8]) -> Value {+        let json_len = u32::from_le_bytes(glb[12..16].try_into().unwrap()) as usize;+        serde_json::from_slice(&glb[20..20 + json_len]).unwrap()+    }++    #[test]+    fn stamp_sets_provenance() {+        let stamped = glb_stamp_provenance(tiny_glb()).unwrap();+        let doc = json_chunk(&stamped);+        let adom = &doc["asset"]["extras"]["adom"];+        assert_eq!(adom["provenance"], GLB_PROVENANCE);+        assert!(adom["provenanceWarning"].as_str().unwrap().contains("molecule pipeline"));+        assert!(adom["generator"].as_str().unwrap().starts_with("service-kicad "));+    }++    #[test]+    fn zup_then_stamp_coexist() {+        let rotated = glb_rotate_y_to_z(tiny_glb()).unwrap();+        let stamped = glb_stamp_provenance(rotated).unwrap();+        let doc = json_chunk(&stamped);+        let adom = &doc["asset"]["extras"]["adom"];+        assert_eq!(adom["zUp"], true);+        assert_eq!(adom["provenance"], GLB_PROVENANCE);+        // rotation actually applied+        assert_eq!(doc["nodes"].as_array().unwrap().len(), 2);+        assert_eq!(doc["scenes"][0]["nodes"], json!([1]));+    }++    #[test]+    fn stamp_then_zup_coexist() {+        let stamped = glb_stamp_provenance(tiny_glb()).unwrap();+        let rotated = glb_rotate_y_to_z(stamped).unwrap();+        let adom = &json_chunk(&rotated)["asset"]["extras"]["adom"];+        assert_eq!(adom["provenance"], GLB_PROVENANCE);+        assert_eq!(adom["zUp"], true);+    }++    #[test]+    fn stamp_rejects_non_glb() {+        assert!(glb_stamp_provenance(b"not a glb at all".to_vec()).is_err());+    }++    #[test]+    fn json_chunk_stays_aligned() {+        let stamped = glb_stamp_provenance(tiny_glb()).unwrap();+        let json_len = u32::from_le_bytes(stamped[12..16].try_into().unwrap()) as usize;+        assert_eq!(json_len % 4, 0);+        let total = u32::from_le_bytes(stamped[8..12].try_into().unwrap()) as usize;+        assert_eq!(total, stamped.len());+    }+}
cli/Cargo.tomladded+20
@@ -0,0 +1,20 @@+[package]+name = "service-kicad"+version.workspace = true+edition.workspace = true+license.workspace = true+repository.workspace = true+authors.workspace = true+description = "CLI for service-kicad — every Adom tool shells to this binary instead of calling the HTTP API directly. See PLAN Part 29."++[[bin]]+name = "service-kicad"+path = "src/main.rs"++[dependencies]+anyhow.workspace = true+clap = { version = "4", features = ["derive"] }+reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "json"] }+serde.workspace = true+serde_json.workspace = true+tempfile.workspace = true
cli/build.rsadded+42
@@ -0,0 +1,42 @@+//! Bake the production service-kicad URL into the binary at compile time.+//!+//! Precedence:+//!   1. $SERVICE_KICAD_URL (set by wiki-publish or release CI).+//!   2. The `url` field from /service-kicad/service.json (updated by+//!      deploy.sh at container provisioning).+//!   3. Local dev fallback: `http://127.0.0.1:8780`.+//!+//! Binaries built without an explicit URL default to localhost so the+//! `service-kicad --version` smoke still passes in a plain `cargo build`.++use std::{fs, path::PathBuf};++fn main() {+    println!("cargo:rerun-if-env-changed=SERVICE_KICAD_URL");+    println!("cargo:rerun-if-changed=../service.json");++    let url = std::env::var("SERVICE_KICAD_URL")+        .or_else(|_| read_url_from_service_json())+        .unwrap_or_else(|_| "http://127.0.0.1:8780".to_string());++    println!("cargo:rustc-env=SERVICE_KICAD_URL={}", url);+}++fn read_url_from_service_json() -> Result<String, std::io::Error> {+    let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));+    let svc_json = manifest.parent().and_then(|p| p.join("service.json").canonicalize().ok());+    let Some(path) = svc_json else {+        return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "service.json"));+    };+    let contents = fs::read_to_string(&path)?;+    // Minimal JSON scrape — avoids pulling serde_json into the build+    // dependency graph. Looks for `"url": "…"`.+    for line in contents.lines() {+        if let Some(rest) = line.split_once("\"url\"") {+            if let Some(v) = rest.1.split('"').nth(1) {+                return Ok(v.to_string());+            }+        }+    }+    Err(std::io::Error::new(std::io::ErrorKind::NotFound, "no url in service.json"))+}
cli/src/main.rsadded+504
@@ -0,0 +1,504 @@+//! `service-kicad` CLI — every Adom tool shells to this binary instead of+//! calling the HTTP API directly. Base URL is baked at build time from+//! `$SERVICE_KICAD_URL` or `../service.json::url`; at runtime+//! `$SERVICE_KICAD_URL` (or the legacy `$KICAD_SERVICE_API`) overrides it,+//! which is how a self-hosted instance is addressed.+//!+//! See PLAN Part 29.4 for the canonical command surface.++use anyhow::{anyhow, Context, Result};+use clap::{Parser, Subcommand};+use reqwest::blocking::Client;+use std::{io::Write, path::PathBuf, time::Duration};++const PROD_URL: &str = env!("SERVICE_KICAD_URL");++#[derive(Parser)]+#[command(name = "service-kicad", version, about = "Shared headless KiCad CLI — see PLAN Part 29")]+struct Cli {+    #[command(subcommand)]+    command: Cmd,+}++#[derive(Subcommand)]+enum Cmd {+    /// GET /health.+    Health,+    /// GET /version.+    Version,+    /// Print the effective service URL (verify env override).+    Config,++    /// PCB operations (body = `.kicad_pcb` file on disk).+    #[command(subcommand)]+    Pcb(PcbCmd),++    /// Schematic operations (body = `.kicad_sch` file on disk).+    #[command(subcommand)]+    Sch(SchCmd),++    /// Symbol library lookup.+    #[command(subcommand)]+    Sym(SymCmd),++    /// Footprint library lookup.+    #[command(subcommand)]+    Fp(FpCmd),++    /// Download a static model file from kicad-packages3d.+    Model {+        #[command(subcommand)]+        cmd: ModelCmd,+    },++    /// Altium library upgrades (body = `.SchLib` / `.PcbLib` on disk).+    #[command(subcommand)]+    Altium(AltiumCmd),++    /// Drop SKILL.md + bash/zsh completions into ~/.claude/skills/ ++    /// ~/.bash_completion.d/ (tool-publisher pattern).+    Install {+        #[arg(long)] skill_only: bool,+        #[arg(long)] completions_only: bool,+    },+}++#[derive(Subcommand)]+enum PcbCmd {+    /// POST /kicad/pcb/drc.+    Drc {+        file: PathBuf,+        #[arg(long, default_value = "text")] format: String,+        #[arg(long)] out: Option<PathBuf>,+    },+    /// POST /kicad/pcb/export/{svg,gerbers,step}.+    #[command(subcommand)]+    Export(PcbExportCmd),+}++#[derive(Subcommand)]+enum PcbExportCmd {+    Svg {+        file: PathBuf,+        #[arg(long, default_value = "top")] side: String,+        /// Comma-separated KiCad layer names (e.g. F.Cu,F.Mask,Edge.Cuts).+        /// Overrides the side's default set; drops the drawing sheet.+        #[arg(long)] layers: Option<String>,+        #[arg(long)] out: Option<PathBuf>,+    },+    Gerbers { file: PathBuf, #[arg(long)] out: Option<PathBuf> },+    Step   { file: PathBuf, #[arg(long)] out: Option<PathBuf>, #[arg(short = 'F', long = "flag", allow_hyphen_values = true)] flags: Vec<String> },+    // Gated per page issue #106: KiCad-native GLB bypasses the molecule+    // pipeline (no anchoring, gold pins, normalization, footprint emit).+    // Hidden from help and refused without the acknowledgment flag, so+    // agents that reach for the old verb get routed to STEP + step2glb.+    // Output GLBs are provenance-stamped server-side (asset.extras.adom).+    #[command(hide = true)]+    Glb    { file: PathBuf, #[arg(long)] out: Option<PathBuf>, #[arg(short = 'F', long = "flag", allow_hyphen_values = true)] flags: Vec<String>, #[arg(long)] i_know_this_skips_the_pipeline: bool },+    #[command(hide = true)]+    GlbZup { file: PathBuf, #[arg(long)] out: Option<PathBuf>, #[arg(short = 'F', long = "flag", allow_hyphen_values = true)] flags: Vec<String>, #[arg(long)] i_know_this_skips_the_pipeline: bool },+}++#[derive(Subcommand)]+enum SchCmd {+    /// POST /kicad/sch/erc.+    Erc {+        file: PathBuf,+        #[arg(long, default_value = "text")] format: String,+        #[arg(long)] out: Option<PathBuf>,+    },+    #[command(subcommand)]+    Export(SchExportCmd),+}++#[derive(Subcommand)]+enum SchExportCmd {+    Svg { file: PathBuf, #[arg(long)] out: Option<PathBuf> },+    /// Hierarchical project → one SVG per sheet. DIR is the project+    /// directory (.kicad_pro + all .kicad_sch); output is a directory.+    SvgProject { dir: PathBuf, #[arg(long)] out: Option<PathBuf> },+    Pdf { file: PathBuf, #[arg(long)] out: Option<PathBuf> },+    Bom { file: PathBuf, #[arg(long)] out: Option<PathBuf> },+}++#[derive(Subcommand)]+enum SymCmd {+    /// GET /sym/export/svg/:library/:name.+    Svg { library: String, name: String, #[arg(long)] out: Option<PathBuf> },+    /// GET /symbols/<library>.kicad_sym (raw fetch).+    Fetch { library: String, #[arg(long)] out: Option<PathBuf> },+}++#[derive(Subcommand)]+enum FpCmd {+    /// GET /fp/export/svg/:library/:name.+    Svg { library: String, name: String, #[arg(long)] out: Option<PathBuf> },+    /// GET /footprints/<library>.pretty/<name>.kicad_mod.+    Fetch { library: String, name: String, #[arg(long)] out: Option<PathBuf> },+}++#[derive(Subcommand)]+enum ModelCmd {+    /// GET /models/<path>.+    Fetch { path: String, #[arg(long)] out: Option<PathBuf> },+}++#[derive(Subcommand)]+enum AltiumCmd {+    /// POST /kicad/altium/sym — Altium `.SchLib` → KiCad `.kicad_sym`.+    Sym { file: PathBuf, #[arg(long)] out: Option<PathBuf> },+    /// POST /kicad/altium/fp — Altium `.PcbLib` → KiCad `.kicad_mod`+    /// (single-part libraries; multi-part returns the first footprint).+    Fp { file: PathBuf, #[arg(long)] out: Option<PathBuf> },+}++// ─── Transport ────────────────────────────────────────────────────────++/// Resolve the service URL. Order: $SERVICE_KICAD_URL env →+/// $KICAD_SERVICE_API env (legacy alias) → baked-in production URL.+/// Trailing slash trimmed. The env override is how a self-hosted+/// instance is addressed — same commands, your container.+fn base_url() -> String {+    std::env::var("SERVICE_KICAD_URL")+        .or_else(|_| std::env::var("KICAD_SERVICE_API"))+        .unwrap_or_else(|_| PROD_URL.to_string())+        .trim_end_matches('/')+        .to_string()+}++fn http_client() -> Client {+    Client::builder()+        .timeout(Duration::from_secs(120))+        .build()+        .expect("reqwest client")+}++/// Exit 2 on unreachable service, 3 on auth failure, 4 on any other+/// service-side error. 1 is reserved for input validation.+fn http_error_exit_code(status: u16) -> i32 {+    match status {+        401 | 403 => 3,+        _ => 4,+    }+}++fn get_bytes(path: &str) -> Result<Vec<u8>> {+    let url = format!("{}{}", base_url(), path);+    let resp = http_client().get(&url).send().unwrap_or_else(|e| {+        eprintln!("service-kicad: unreachable ({e})");+        std::process::exit(2);+    });+    let status = resp.status();+    if !status.is_success() {+        eprintln!("service-kicad: {} — {}", status, resp.text().unwrap_or_default());+        std::process::exit(http_error_exit_code(status.as_u16()));+    }+    Ok(resp.bytes().map(|b| b.to_vec()).unwrap_or_default())+}++fn flags_to_query_string(flags: &[String]) -> String {+    if flags.is_empty() { return String::new(); }+    let params: Vec<String> = flags.iter().map(|f| {+        let encoded = f.replace('%', "%25").replace('&', "%26").replace('=', "%3D").replace(' ', "%20");+        format!("F={encoded}")+    }).collect();+    format!("?{}", params.join("&"))+}++fn post_file_bytes(path: &str, file: &PathBuf) -> Result<Vec<u8>> {+    let body = std::fs::read(file)+        .with_context(|| format!("read {}", file.display()))?;+    let url = format!("{}{}", base_url(), path);+    let resp = http_client().post(&url).body(body).send().unwrap_or_else(|e| {+        eprintln!("service-kicad: unreachable ({e})");+        std::process::exit(2);+    });+    let status = resp.status();+    if !status.is_success() {+        eprintln!("service-kicad: {} — {}", status, resp.text().unwrap_or_default());+        std::process::exit(http_error_exit_code(status.as_u16()));+    }+    Ok(resp.bytes().map(|b| b.to_vec()).unwrap_or_default())+}++#[allow(dead_code)]+fn _unused() -> Result<()> {+    Ok(())+}++fn write_or_stdout(bytes: &[u8], out: Option<&PathBuf>) -> Result<()> {+    match out {+        Some(p) => {+            std::fs::write(p, bytes).with_context(|| format!("write {}", p.display()))?;+            eprintln!("→ {} ({} bytes)", p.display(), bytes.len());+        }+        None => {+            std::io::stdout().write_all(bytes).context("stdout")?;+        }+    }+    Ok(())+}++fn extract_tarball(bytes: &[u8], dest: &PathBuf) -> Result<()> {+    std::fs::create_dir_all(dest).with_context(|| format!("create {}", dest.display()))?;+    let tmp = tempfile::Builder::new().suffix(".tar").tempfile()?;+    std::fs::write(tmp.path(), bytes)?;+    let status = std::process::Command::new("tar")+        .args(["-xf", tmp.path().to_str().unwrap_or_default(),+               "-C",  dest.to_str().unwrap_or_default()])+        .status()+        .context("tar")?;+    if !status.success() { anyhow::bail!("tar exit {}", status.code().unwrap_or(-1)); }+    Ok(())+}++fn print_json_pretty_or_write(bytes: &[u8], format: &str, out: Option<&PathBuf>) -> Result<()> {+    if format == "json" || out.is_some() {+        return write_or_stdout(bytes, out);+    }+    // Pretty text for humans. Fall back to raw JSON if it doesn't parse.+    let Ok(v) = serde_json::from_slice::<serde_json::Value>(bytes) else {+        return write_or_stdout(bytes, None);+    };+    let errs = v.get("errors").and_then(|x| x.as_u64()).unwrap_or(0);+    let warns = v.get("warnings").and_then(|x| x.as_u64())+        .or_else(|| v.get("warning").and_then(|x| x.as_u64()))+        .unwrap_or(0);+    let total = v.get("count").and_then(|x| x.as_u64()).unwrap_or(errs + warns);+    println!("errors: {errs}  warnings: {warns}  total: {total}");+    if let Some(violations) = v.get("violations").and_then(|x| x.as_array()) {+        for violation in violations.iter().take(20) {+            let sev = violation.get("severity").and_then(|s| s.as_str()).unwrap_or("?");+            let typ = violation.get("type").and_then(|s| s.as_str()).unwrap_or("?");+            let msg = violation.get("description").or_else(|| violation.get("message")).and_then(|s| s.as_str()).unwrap_or("");+            println!("  [{sev}] {typ}: {msg}");+        }+        if violations.len() > 20 { println!("  … {} more", violations.len() - 20); }+    }+    Ok(())+}++// ─── Commands ─────────────────────────────────────────────────────────++fn main() -> Result<()> {+    let cli = Cli::parse();+    match cli.command {+        Cmd::Health => {+            let bytes = get_bytes("/health")?;+            std::io::stdout().write_all(&bytes)?;+            println!();+            Ok(())+        }+        Cmd::Version => {+            let bytes = get_bytes("/version")?;+            std::io::stdout().write_all(&bytes)?;+            println!();+            Ok(())+        }+        Cmd::Config => {+            let effective = base_url();+            let prod = PROD_URL;+            let source = if std::env::var("SERVICE_KICAD_URL").is_ok() {+                "SERVICE_KICAD_URL env"+            } else if std::env::var("KICAD_SERVICE_API").is_ok() {+                "KICAD_SERVICE_API env (legacy alias)"+            } else {+                "compile-time baked-in URL"+            };+            println!("url       : {effective}");+            println!("source    : {source}");+            println!("prod bake : {prod}");+            if source.starts_with("compile") {+                println!("hint      : export SERVICE_KICAD_URL=<url> to target a self-hosted instance");+            }+            Ok(())+        }+        Cmd::Pcb(PcbCmd::Drc { file, format, out }) => {+            let bytes = post_file_bytes("/kicad/pcb/drc", &file)?;+            print_json_pretty_or_write(&bytes, &format, out.as_ref())+        }+        Cmd::Pcb(PcbCmd::Export(PcbExportCmd::Svg { file, side, layers, out })) => {+            let mut path = format!("/kicad/pcb/export/svg?side={}", urlencode(&side));+            if let Some(l) = layers {+                path.push_str(&format!("&layers={}", urlencode(&l)));+            }+            let bytes = post_file_bytes(&path, &file)?;+            write_or_stdout(&bytes, out.as_ref())+        }+        Cmd::Pcb(PcbCmd::Export(PcbExportCmd::Gerbers { file, out })) => {+            let bytes = post_file_bytes("/kicad/pcb/export/gerbers", &file)?;+            match out {+                Some(ref d) => extract_tarball(&bytes, d),+                None => {+                    let d = PathBuf::from("gerbers");+                    extract_tarball(&bytes, &d)+                }+            }+        }+        Cmd::Pcb(PcbCmd::Export(PcbExportCmd::Step { file, out, flags })) => {+            let qs = flags_to_query_string(&flags);+            let path = format!("/kicad/pcb/export/step{qs}");+            write_or_stdout(&post_file_bytes(&path, &file)?, out.as_ref())+        }+        Cmd::Pcb(PcbCmd::Export(PcbExportCmd::Glb { file, out, flags, i_know_this_skips_the_pipeline })) => {+            glb_gate(i_know_this_skips_the_pipeline);+            let qs = flags_to_query_string(&flags);+            let path = format!("/kicad/pcb/export/glb{qs}");+            write_or_stdout(&post_file_bytes(&path, &file)?, out.as_ref())+        }+        Cmd::Pcb(PcbCmd::Export(PcbExportCmd::GlbZup { file, out, flags, i_know_this_skips_the_pipeline })) => {+            glb_gate(i_know_this_skips_the_pipeline);+            let qs = flags_to_query_string(&flags);+            let path = format!("/kicad/pcb/export/glb-zup{qs}");+            write_or_stdout(&post_file_bytes(&path, &file)?, out.as_ref())+        }++        Cmd::Sch(SchCmd::Erc { file, format, out }) => {+            let bytes = post_file_bytes("/kicad/sch/erc", &file)?;+            print_json_pretty_or_write(&bytes, &format, out.as_ref())+        }+        Cmd::Sch(SchCmd::Export(SchExportCmd::Svg { file, out })) =>+            write_or_stdout(&post_file_bytes("/kicad/sch/export/svg", &file)?, out.as_ref()),+        Cmd::Sch(SchCmd::Export(SchExportCmd::SvgProject { dir, out })) => {+            if !dir.is_dir() {+                anyhow::bail!("{} is not a directory — pass the project dir (.kicad_pro + sheets)", dir.display());+            }+            // Tar the project dir client-side; the service picks the root+            // sheet (stem matching the .kicad_pro) and returns a tar of SVGs.+            let tmp = tempfile::Builder::new().suffix(".tar").tempfile()?;+            let status = std::process::Command::new("tar")+                .args(["-cf", tmp.path().to_str().unwrap_or_default(),+                       "-C",  dir.to_str().unwrap_or_default(), "."])+                .status().context("tar")?;+            if !status.success() { anyhow::bail!("tar exit {}", status.code().unwrap_or(-1)); }+            let bytes = post_file_bytes("/kicad/sch/export/svg-project", &tmp.path().to_path_buf())?;+            let dest = out.unwrap_or_else(|| PathBuf::from("sch-svg"));+            extract_tarball(&bytes, &dest)?;+            eprintln!("→ {}/ (per-sheet SVGs)", dest.display());+            Ok(())+        }+        Cmd::Sch(SchCmd::Export(SchExportCmd::Pdf { file, out })) =>+            write_or_stdout(&post_file_bytes("/kicad/sch/export/pdf", &file)?, out.as_ref()),+        Cmd::Sch(SchCmd::Export(SchExportCmd::Bom { file, out })) =>+            write_or_stdout(&post_file_bytes("/kicad/sch/export/bom", &file)?, out.as_ref()),++        Cmd::Sym(SymCmd::Svg { library, name, out }) => {+            let bytes = get_bytes(&format!("/sym/export/svg/{}/{}", urlencode(&library), urlencode(&name)))?;+            write_or_stdout(&bytes, out.as_ref())+        }+        Cmd::Sym(SymCmd::Fetch { library, out }) => {+            let bytes = get_bytes(&format!("/symbols/{}.kicad_sym", urlencode(&library)))?;+            write_or_stdout(&bytes, out.as_ref())+        }+        Cmd::Fp(FpCmd::Svg { library, name, out }) => {+            let bytes = get_bytes(&format!("/fp/export/svg/{}/{}", urlencode(&library), urlencode(&name)))?;+            write_or_stdout(&bytes, out.as_ref())+        }+        Cmd::Fp(FpCmd::Fetch { library, name, out }) => {+            let bytes = get_bytes(&format!("/footprints/{}.pretty/{}.kicad_mod", urlencode(&library), urlencode(&name)))?;+            write_or_stdout(&bytes, out.as_ref())+        }+        Cmd::Model { cmd: ModelCmd::Fetch { path, out } } => {+            let bytes = get_bytes(&format!("/models/{}", path.trim_start_matches('/')))?;+            write_or_stdout(&bytes, out.as_ref())+        }++        Cmd::Altium(AltiumCmd::Sym { file, out }) => {+            let bytes = post_file_bytes("/kicad/altium/sym", &file)?;+            let dest = out.unwrap_or_else(|| file.with_extension("kicad_sym"));+            write_or_stdout(&bytes, Some(&dest))+        }+        Cmd::Altium(AltiumCmd::Fp { file, out }) => {+            let bytes = post_file_bytes("/kicad/altium/fp", &file)?;+            let dest = out.unwrap_or_else(|| file.with_extension("kicad_mod"));+            write_or_stdout(&bytes, Some(&dest))+        }++        Cmd::Install { skill_only, completions_only } => do_install(skill_only, completions_only),+    }+}++/// Refuse KiCad-native GLB export unless explicitly acknowledged (page+/// issue #106). Acknowledged exports still come back provenance-stamped+/// by the service (`asset.extras.adom.provenance = "kicad-native-glb"`).+fn glb_gate(acknowledged: bool) {+    if acknowledged {+        eprintln!("WARNING: KiCad-native GLB skips the molecule pipeline; output is");+        eprintln!("provenance-stamped (asset.extras.adom.provenance = \"kicad-native-glb\").");+        return;+    }+    eprintln!("ERROR: `pcb export glb` is gated (page issue #106).");+    eprintln!("KiCad-native GLB bypasses the molecule pipeline — no MP-marker");+    eprintln!("anchoring, gold machine-pin material, meter normalization, or");+    eprintln!("footprint emit. Export STEP here, then convert with step2glb:");+    eprintln!("Hint: service-kicad pcb export step board.kicad_pcb --out board.step");+    eprintln!("Hint: adom-step2glb board.step   # molecule mode when the board has machine pins");+    eprintln!("Hint: for a quick non-molecule preview only, re-run with --i-know-this-skips-the-pipeline");+    std::process::exit(1);+}++/// Absolute-minimum URL encoder — just the three characters we actually+/// see in library/name path segments.+fn urlencode(s: &str) -> String {+    s.replace('%', "%25")+     .replace(' ', "%20")+     .replace('/', "%2F")+}++const WIKI_BASE: &str = "https://wiki.adom.inc";++/// Fetch the canonical SKILL.md from the wiki page repo's raw-file+/// endpoint. No fallback bundled via include_str! — we never bake skill+/// prose into the binary because prose is edited far more often than the+/// binary ships (per adom-cli-design rule).+fn fetch_skill_from_wiki() -> Result<String> {+    let url = format!("{}/api/pages/adom/service-kicad/files/SKILL.md", WIKI_BASE);+    let text = http_client()+        .get(&url)+        .send()?+        .error_for_status()?+        .text()?;+    if text.trim().is_empty() || !text.starts_with("---") {+        return Err(anyhow!("wiki returned unexpected SKILL.md content from {url}"));+    }+    Ok(text)+}++fn do_install(skill_only: bool, completions_only: bool) -> Result<()> {+    let home = std::env::var("HOME").map(PathBuf::from)+        .map_err(|_| anyhow!("no $HOME"))?;+    if !completions_only {+        let skill_dir = home.join(".claude/skills/service-kicad");+        std::fs::create_dir_all(&skill_dir)?;+        let dst = skill_dir.join("SKILL.md");+        match fetch_skill_from_wiki() {+            Ok(skill) => {+                std::fs::write(&dst, skill)?;+                eprintln!("✓ skill fetched from wiki → {}", dst.display());+            }+            Err(e) if dst.exists() => {+                // Gallia (Tier A path) or a prior install already wrote+                // SKILL.md. A transient wiki outage isn't a reason to+                // overwrite or blow up.+                eprintln!("! wiki fetch failed ({e}) — keeping existing {}", dst.display());+            }+            Err(e) => {+                return Err(anyhow!("could not fetch SKILL.md from wiki and no existing skill to keep: {e}"));+            }+        }+    }+    if !skill_only {+        let comp_dir = home.join(".bash_completion.d");+        std::fs::create_dir_all(&comp_dir)?;+        let bash_comp = comp_dir.join("service-kicad");+        std::fs::write(&bash_comp,+            "# service-kicad bash completions\n\+             _service_kicad() {\n\+             \tlocal cur=\"${COMP_WORDS[COMP_CWORD]}\"\n\+             \tCOMPREPLY=($(compgen -W \"health version config pcb sch sym fp model altium install\" -- \"$cur\"))\n\+             }\n\+             complete -F _service_kicad service-kicad\n")?;+        eprintln!("✓ completions at {}", bash_comp.display());+    }+    Ok(())+}
deploy-v9.shadded+114
@@ -0,0 +1,114 @@+#!/usr/bin/env bash+# service-kicad deploy — V9 FALLBACK channel.+#+# Run ONCE on a fresh default-light container. Most users want+# `deploy.sh` (the default v10 channel); this script exists only for+# callers that need to pin to KiCad 9 (file-format compat, etc.).+# The v9 PPA overwrites the v10 `kicad` package, so a host can be on+# exactly one channel at a time.++set -euo pipefail++REPO_DIR="$HOME/service-kicad"++echo "==> service-kicad deploy starting (v9 fallback channel)"+echo "==> repo dir: $REPO_DIR"++# 1. System deps — KiCad 9 + libraries. No aci-* deps; this container+# is tool-agnostic and consumed by symbol-creator, footprint-creator,+# library-creator, adom-chipfit, adom-tsci, adom-circuit, etc.+echo "==> installing apt packages (KiCad 9 + libraries + cron)"+sudo apt-get update+sudo apt-get install -y \+  software-properties-common curl build-essential pkg-config git jq cron+sudo add-apt-repository -y ppa:kicad/kicad-9.0-releases+sudo apt-get update+sudo apt-get install -y kicad kicad-symbols kicad-footprints kicad-packages3d++# The Ubuntu apt-package mirrors of KiCad libraries lag upstream by ~years+# (24.04 ships the ~2020 snapshot — e.g., SOIC-8W_5.3x5.3mm_P1.27mm,+# committed upstream 2020-09-29, was missing from the apt copy as of+# 2026-04-26 dogfood). Overlay the upstream github repos so users get+# the latest parts. service-kicad's KICAD_*_DIR env vars point at these+# clones; a weekly `git pull` cron keeps them fresh.+LIB_BASE=/opt+for repo in kicad-footprints kicad-symbols kicad-packages3D; do+  dir="$LIB_BASE/${repo,,}-upstream"   # lowercase+  if [ ! -d "$dir/.git" ]; then+    echo "==> cloning upstream $repo → $dir"+    sudo git clone --depth 1 "https://github.com/KiCad/$repo.git" "$dir"+  else+    echo "==> upstream $repo already at $dir; refreshing"+    sudo git -C "$dir" pull --quiet --ff-only || true+  fi+done++# Persist KICAD_*_DIR for service-kicad-api (read at startup).+sudo tee /etc/default/service-kicad >/dev/null <<'EOF'+KICAD_FOOTPRINTS_DIR=/opt/kicad-footprints-upstream+KICAD_SYMBOLS_DIR=/opt/kicad-symbols-upstream+KICAD_PACKAGES3D_DIR=/opt/kicad-packages3d-upstream+EOF++# Weekly refresh — Mondays @ 04:00 UTC.+sudo tee /etc/cron.d/kicad-lib-pull >/dev/null <<'EOF'+0 4 * * 1 root for d in /opt/kicad-footprints-upstream /opt/kicad-symbols-upstream /opt/kicad-packages3d-upstream; do git -C "$d" pull --quiet --ff-only 2>&1 | logger -t kicad-lib-pull; done+EOF+sudo chmod 0644 /etc/cron.d/kicad-lib-pull++# default-light has sshd as PID 1 (no systemd) — cron has to be+# launched manually and kept alive by the user.+if ! pgrep -x cron >/dev/null; then+  echo "==> starting cron daemon"+  sudo /usr/sbin/cron+fi++# 2. Rust toolchain+echo "==> installing rustup"+if ! command -v rustup &>/dev/null; then+  curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable+  source "$HOME/.cargo/env"+fi++# 3. Clone + build the service API+if [ ! -d "$REPO_DIR" ]; then+  echo "==> cloning service-kicad"+  git clone https://github.com/adom-inc/service-kicad.git "$REPO_DIR"+fi+cd "$REPO_DIR"+git pull --rebase origin main++echo "==> building service-kicad-api (http server) and service-kicad (cli)"+cargo build --release --bin service-kicad-api --bin service-kicad+sudo install "$REPO_DIR/target/release/service-kicad-api" /usr/local/bin/service-kicad-api+sudo install "$REPO_DIR/target/release/service-kicad"     /usr/local/bin/service-kicad++# 4. Install watchdog cron+echo "==> installing watchdog cron"+mkdir -p "$HOME/.cache"+CRON_LINE="*/2 * * * * $REPO_DIR/watchdog.sh >> $HOME/.cache/service-kicad-watchdog.log 2>&1"+# crontab -l exits 1 when there's no existing crontab; the subshell+# must not abort under `set -e`, so disable pipefail around this block.+set +e+( crontab -l 2>/dev/null | grep -v "service-kicad/watchdog.sh" ; echo "$CRON_LINE" ) | crontab -+cron_exit=$?+set -e+[ $cron_exit -ne 0 ] && { echo "crontab install failed (exit $cron_exit)"; exit $cron_exit; }++# 5. First launch + health check+echo "==> starting service-kicad-api"+sudo touch /var/log/service-kicad.log && sudo chown "$USER" /var/log/service-kicad.log+pkill -x service-kicad-api || true+# Source /etc/default/service-kicad for KICAD_*_DIR env vars before launch.+set -a; [ -f /etc/default/service-kicad ] && . /etc/default/service-kicad; set +a+nohup /usr/local/bin/service-kicad-api >> /var/log/service-kicad.log 2>&1 &+sleep 3+if curl -sf http://127.0.0.1:8780/health | jq -e '.ok' > /dev/null; then+  echo "==> service-kicad is healthy ✓"+  curl -sf http://127.0.0.1:8780/health | jq+else+  echo "==> service-kicad health check FAILED" >&2+  exit 1+fi++echo "==> deploy complete"
deploy.shadded+119
@@ -0,0 +1,119 @@+#!/usr/bin/env bash+# service-kicad deploy — DEFAULT channel (KiCad 10).+#+# Run ONCE on a fresh default-light container at carbon.adom.inc.+# This script is the canonical primary deploy; the v10 PPA overwrites+# the v9 `kicad` package so a host can be on exactly one channel at a+# time. For a v9 fallback container, use [`deploy-v9.sh`](deploy-v9.sh)+# on a separate host.++set -euo pipefail++REPO_DIR="$HOME/service-kicad"++echo "==> service-kicad deploy starting (default channel — KiCad 10)"+echo "==> repo dir: $REPO_DIR"++# Hard guard: refuse to run on a host that already has v9 installed.+# Mixing PPAs would leave kicad-cli in a half-upgraded state. The+# in-place v9 → v10 swap of the original prod container was a one-time+# event done outside this script.+if dpkg -l kicad 2>/dev/null | awk '/^ii/ {print $3}' | grep -q '^9\.'; then+  echo "==> ERROR: KiCad 9 is already installed on this host. Provision a separate container, or run deploy-v9.sh if you actually want the fallback channel." >&2+  exit 1+fi++# 1. System deps — KiCad 10 + libraries.+echo "==> installing apt packages (KiCad 10 + libraries + cron)"+sudo apt-get update+sudo apt-get install -y \+  software-properties-common curl build-essential pkg-config git jq cron+sudo add-apt-repository -y ppa:kicad/kicad-10.0-releases+sudo apt-get update+sudo apt-get install -y kicad kicad-symbols kicad-footprints kicad-packages3d++# Library overlay — prefer the upstream `10.0` stable branch, fall back+# to master if it doesn't exist yet. (Library repos branch when stable+# cuts; master is the dev branch that may already be drifting toward 11.)+LIB_BASE=/opt+for repo in kicad-footprints kicad-symbols kicad-packages3D; do+  dir="$LIB_BASE/${repo,,}-upstream"   # lowercase+  if [ ! -d "$dir/.git" ]; then+    echo "==> cloning upstream $repo → $dir (prefer branch 10.0)"+    if ! sudo git clone --depth 1 --branch 10.0 "https://github.com/KiCad/$repo.git" "$dir" 2>/dev/null; then+      echo "==>   no 10.0 branch yet; falling back to master"+      sudo git clone --depth 1 "https://github.com/KiCad/$repo.git" "$dir"+    fi+  else+    echo "==> upstream $repo already at $dir; refreshing"+    sudo git -C "$dir" pull --quiet --ff-only || true+  fi+done++# Persist KICAD_*_DIR for service-kicad-api (read at startup).+sudo tee /etc/default/service-kicad >/dev/null <<'EOF'+KICAD_FOOTPRINTS_DIR=/opt/kicad-footprints-upstream+KICAD_SYMBOLS_DIR=/opt/kicad-symbols-upstream+KICAD_PACKAGES3D_DIR=/opt/kicad-packages3d-upstream+EOF++# Weekly refresh — Mondays @ 04:00 UTC.+sudo tee /etc/cron.d/kicad-lib-pull >/dev/null <<'EOF'+0 4 * * 1 root for d in /opt/kicad-footprints-upstream /opt/kicad-symbols-upstream /opt/kicad-packages3d-upstream; do git -C "$d" pull --quiet --ff-only 2>&1 | logger -t kicad-lib-pull; done+EOF+sudo chmod 0644 /etc/cron.d/kicad-lib-pull++# default-light has sshd as PID 1 (no systemd) — cron has to be+# launched manually and kept alive by the user.+if ! pgrep -x cron >/dev/null; then+  echo "==> starting cron daemon"+  sudo /usr/sbin/cron+fi++# 2. Rust toolchain+echo "==> installing rustup"+if ! command -v rustup &>/dev/null; then+  curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable+  source "$HOME/.cargo/env"+fi++# 3. Clone + build the service API. Same source as v9 — the binary is+# channel-agnostic; it shells to whichever kicad-cli is on $PATH.+if [ ! -d "$REPO_DIR" ]; then+  echo "==> cloning service-kicad"+  git clone https://github.com/adom-inc/service-kicad.git "$REPO_DIR"+fi+cd "$REPO_DIR"+git pull --rebase origin main++echo "==> building service-kicad-api (http server) and service-kicad (cli)"+cargo build --release --bin service-kicad-api --bin service-kicad+sudo install "$REPO_DIR/target/release/service-kicad-api" /usr/local/bin/service-kicad-api+sudo install "$REPO_DIR/target/release/service-kicad"     /usr/local/bin/service-kicad++# 4. Install watchdog cron (same script as v9 — rebuilds whatever's in main)+echo "==> installing watchdog cron"+mkdir -p "$HOME/.cache"+CRON_LINE="*/2 * * * * $REPO_DIR/watchdog.sh >> $HOME/.cache/service-kicad-watchdog.log 2>&1"+set +e+( crontab -l 2>/dev/null | grep -v "service-kicad/watchdog.sh" ; echo "$CRON_LINE" ) | crontab -+cron_exit=$?+set -e+[ $cron_exit -ne 0 ] && { echo "crontab install failed (exit $cron_exit)"; exit $cron_exit; }++# 5. First launch + health check+echo "==> starting service-kicad-api"+sudo touch /var/log/service-kicad.log && sudo chown "$USER" /var/log/service-kicad.log+pkill -x service-kicad-api || true+set -a; [ -f /etc/default/service-kicad ] && . /etc/default/service-kicad; set +a+nohup /usr/local/bin/service-kicad-api >> /var/log/service-kicad.log 2>&1 &+sleep 3+if curl -sf http://127.0.0.1:8780/health | jq -e '.ok' > /dev/null; then+  echo "==> service-kicad (v10) is healthy ✓"+  curl -sf http://127.0.0.1:8780/health | jq+else+  echo "==> service-kicad health check FAILED" >&2+  exit 1+fi++echo "==> deploy complete (KiCad 10 channel)"
docs/service-kicad-api.mdadded+141
@@ -0,0 +1,141 @@+# service-kicad API contract++HTTP API for the `service-kicad` container. Every Adom tool that needs+headless KiCad or the stock library hits this surface.++## Version++- API version: `1.0.0` (matches `service.json::api_version`).+- Compatibility: clients check `/version` on startup and refuse to+  connect when **major** differs.+- Service is stateless — safe to restart at any time.++## Versioning++```+GET /version+→ { "service": "service-kicad", "version": "0.0.1", "api_version": "1.0.0" }++GET /health+→ { "ok": true, "service": "service-kicad", "version": "0.0.1",+    "kicad_version": "9.0.8" }+```++Consumers: `adom-cli carbon` watchdog, `aci doctor`, CI smoke.++## Auth++Per PLAN decision 17 (Adom-platform JWT). `Authorization: Bearer <jwt>`.+Service validates against `carbon.adom.inc`'s JWKS. Dev: no auth.++## kicad-cli PCB operations++```+POST /kicad/pcb/drc+  Request body:  <raw .kicad_pcb bytes>+  Content-Type:  application/octet-stream+  Response:      application/json — same shape as `kicad-cli pcb drc+                 --format json` output.++POST /kicad/pcb/export/svg?side=top|bottom+  Request body:  <raw .kicad_pcb bytes>+  Response:      image/svg+xml++POST /kicad/pcb/export/gerbers+  Request body:  <raw .kicad_pcb bytes>+  Response:      application/x-tar — the full fab layer set+                 (F.Cu, B.Cu, F.Mask, B.Mask, F.Silkscreen,+                  B.Silkscreen, Edge.Cuts).++POST /kicad/pcb/export/step+  Request body:  <raw .kicad_pcb bytes>+  Response:      model/step — with component 3D models substituted+                 (--subst-models --no-dnp).++POST /kicad/pcb/export/glb+  Request body:  <raw .kicad_pcb bytes>+  Response:      model/gltf-binary — with component 3D models.+```++## kicad-cli schematic operations++```+POST /kicad/sch/erc+  Request body:  <raw .kicad_sch bytes>+  Response:      application/json — same shape as `kicad-cli sch erc+                 --format json`.++POST /kicad/sch/export/svg+  Request body:  <raw .kicad_sch bytes>+  Response:      image/svg+xml++POST /kicad/sch/export/pdf+  Request body:  <raw .kicad_sch bytes>+  Response:      application/pdf++POST /kicad/sch/export/bom+  Request body:  <raw .kicad_sch bytes>+  Response:      text/csv+```++## Symbol + footprint lookup (by library + name)++Convenience wrappers for callers that know the canonical+library/name but don't ship a local copy of the source file.++```+GET /sym/export/svg/:library/:name+  Path params:   library (e.g. "Device"), name (e.g. "R")+  Response:      image/svg+xml — rendered via `kicad-cli sym export svg`+                 on /usr/share/kicad/symbols/<library>.kicad_sym.++GET /fp/export/svg/:library/:name+  Path params:   library (e.g. "Package_SO"), name (e.g. "SOT-23")+  Response:      image/svg+xml — rendered via `kicad-cli fp export svg`+                 on /usr/share/kicad/footprints/<library>.pretty/<name>.kicad_mod.+```++## Static library mounts++The ~5 GB kicad-packages3d library + the symbol/footprint trees are+served read-only as static files. Clients stream whatever they need+(most call sites only need a handful at a time).++```+GET /models/<path>        → static from /usr/share/kicad/3dmodels/+GET /footprints/<path>    → static from /usr/share/kicad/footprints/+GET /symbols/<path>       → static from /usr/share/kicad/symbols/+```++Example: `curl -o LM1117.step $KICAD_SERVICE_API/models/Package_TO_SOT_SMD.3dshapes/SOT-223.step`++## Rate limits++- **kicad-cli endpoints** (`/kicad/*`): 30 req/min/user, burst 10.+- **Static library** (`/models`, `/footprints`, `/symbols`): 600+  req/min/user, no burst cap — it's just disk reads.+- Exceeding → HTTP 429 with `Retry-After`.++## Error shape++```json+{ "error": "human-readable message", "code": 400 }+```++Body is always JSON for error responses; HTTP status carries the+primary signal.++## Port configuration++The binary honors `$PORT`. Default 8780 (matches the legacy service-+kicad container's port, so consumers that already point at 8780+migrate cleanly).++## Stability policy++- **Major bump**: any breaking change to an existing endpoint's+  request or response shape.+- **Minor bump**: new endpoints.+- **Patch bump**: bug fixes, dependency updates, no contract change.++Every bump edits `service.json::api_version` AND this doc's header.
service.jsonadded+28
@@ -0,0 +1,28 @@+{+  "name": "service-kicad",+  "url": "https://kicad-rk5ue5pcfemi.adom.cloud",+  "port": 8780,+  "health": "/health",+  "start": "/usr/local/bin/service-kicad",+  "env_required": [],+  "env_optional": [+    "KICAD_CLI",+    "KICAD_PACKAGES3D_DIR",+    "KICAD_FOOTPRINTS_DIR",+    "KICAD_SYMBOLS_DIR",+    "PORT"+  ],+  "log": "/var/log/service-kicad.log",+  "api_version": "1.0.0",+  "channels": {+    "default": {+      "url": "https://kicad-rk5ue5pcfemi.adom.cloud",+      "kicad_major": 10+    },+    "v9": {+      "url": "https://kicad-v9-7f1gq8j5ohbq.adom.cloud",+      "kicad_major": 9,+      "opt_in": "set $KICAD_SERVICE_API to this URL"+    }+  }+}
watchdog.shadded+82
@@ -0,0 +1,82 @@+#!/usr/bin/env bash+# service-kicad watchdog — run by cron every 2 minutes on the+# production container. Pulls origin/main, rebuilds on new commits,+# and health-checks the running service.+#+# Kill uses /proc/PID/exe readlink instead of pkill -f path matching,+# which broke when the process was started from a different path than+# the installed binary (and pkill -x fails because the 17-char binary+# name gets truncated to 15 in /proc/PID/comm).++set -uo pipefail++REPO_DIR="$HOME/service-kicad"+BINARY="/usr/local/bin/service-kicad-api"+CLI_BINARY="/usr/local/bin/service-kicad"+HEALTH_URL="http://127.0.0.1:8780/health"+LOG="${SERVICE_KICAD_LOG:-/var/log/service-kicad.log}"++kill_api() {+  local pids+  pids=$(pgrep -f "service-kicad-api" 2>/dev/null | grep -v "^$$\$" || true)+  for pid in $pids; do+    local exe+    exe=$(readlink "/proc/$pid/exe" 2>/dev/null || true)+    if [[ "$exe" == *service-kicad-api* ]]; then+      kill "$pid" 2>/dev/null || true+      sleep 1+      kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null || true+    fi+  done+}++launch_api() {+  [ -f /etc/default/service-kicad ] && { set -a; . /etc/default/service-kicad; set +a; }+  nohup "$BINARY" >> "$LOG" 2>&1 &+}++cd "$REPO_DIR" || exit 1++# 1. Check for new commits.+#+# `git fetch` MUST surface its own failure — without this guard, an+# auth-rotated container silently logs `fatal: could not read Username+# for 'https://github.com'` then proceeds with stale LOCAL/REMOTE SHAs+# (both still point at the old HEAD), the equality test trivially+# passes, and the rebuild branch never fires. Symptom: weeks of+# "no-op" watchdog runs while origin/main races ahead. Exit 1 here+# triggers cron stderr so the next failure is loud, not silent.+if ! git fetch origin main --quiet 2>&1; then+  echo "[$(date -Is)] FETCH FAILED — verify ~/.git-credentials and origin URL"+  exit 1+fi+LOCAL_SHA=$(git rev-parse HEAD)+REMOTE_SHA=$(git rev-parse origin/main)++if [ "$LOCAL_SHA" != "$REMOTE_SHA" ]; then+  echo "[$(date -Is)] origin moved $LOCAL_SHA → $REMOTE_SHA — rebuilding"+  git reset --hard origin/main+  if cargo build --release --bin service-kicad-api --bin service-kicad 2>&1 | tail -20; then+    sudo install target/release/service-kicad-api "$BINARY"+    sudo install target/release/service-kicad     "$CLI_BINARY"+    kill_api+    launch_api+    echo "[$(date -Is)] rebuilt + relaunched"+  else+    echo "[$(date -Is)] REBUILD FAILED — keeping old binary"+    exit 1+  fi+fi++# 2. Health check — relaunch if down+if ! curl -sf "$HEALTH_URL" | jq -e '.ok' > /dev/null 2>&1; then+  echo "[$(date -Is)] health check failed, relaunching"+  kill_api+  launch_api+  sleep 2+  if curl -sf "$HEALTH_URL" | jq -e '.ok' > /dev/null 2>&1; then+    echo "[$(date -Is)] recovered"+  else+    echo "[$(date -Is)] STILL UNHEALTHY after relaunch"+  fi+fi