← All Pull Requests

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

Merged opened by Drew Owens 2026-07-14

Truing-up per the July 8 handoff (Drew's molecule-pipeline session), built on branch truing-up of adom-inc/service-kicad (mirrored there as a PR for the code review + watchdog deploy).

What changed

Page/docs

  • README leads with the service offering + approved fair-use terms; dead wiki-ufypy5dpx93o install instructions replaced with adom-wiki pkg install adom/service-kicad; full self-host walkthrough (deploy.sh, watchdog pattern, port/env, SERVICE_KICAD_URL override) linking adom/standalone-service for provisioning; channels table (v10 default / v9 fallback) and design doctrine kept from the repo README.
  • SKILL.md refreshed: real CLI surface, no dead host, GLB examples replaced with STEP + step2glb.
  • Source now lives on the page (this PR adds the actual files — no tarball), so the self-host promise is real.

CLI (published as 1.1.0, linux binary attached as a release asset)

  • Runtime SERVICE_KICAD_URL override (legacy KICAD_SERVICE_API still honored) — the self-host linchpin; config shows which is active.
  • pcb export svg --layers F.Cu,… (Barrett's ?layers= server support, now CLI-reachable).
  • sch export svg-project <dir> — hierarchical projects, one SVG per sheet.
  • altium sym/fp — the June Altium→KiCad endpoints, now CLI-surfaced.
  • install fetches SKILL.md from wiki.adom.inc (old slug host is dead).
  • install.sh actually installs the binary — 1.0.1's pkg install delivered a skill with no CLI at all. 1.0.0/1.0.1 deprecated.

GLB (issue #106 — gated + watermarked, NOT removed)

  • Endpoints kept in place (possible unknown consumers; behaviorally identical).
  • Every GLB response is provenance-stamped: asset.extras.adom.provenance = "kicad-native-glb" + provenanceWarning + generator, plus X-Adom-Provenance header — downstream importers/humans can catch pipeline-bypassing GLBs. Stamp failure never breaks the export. Zup rotation now merges into asset.extras.adom instead of clobbering it. 5 unit tests.
  • CLI verbs hidden from help and refused with step2glb redirect hints unless --i-know-this-skips-the-pipeline is passed.

Verified

  • Clean adom-wiki pkg install adom/service-kicad → working 1.1.0 CLI + skill; health OK against prod.
  • --layers, altium fp (real COSMIIC .PcbLib), svg-project (20-sheet PM1C project) e2e against prod.
  • SERVICE_KICAD_URL override confirmed via config.
  • Provenance stamp: unit-tested; live verify pending the GitHub-side merge → watchdog deploy (prod still runs 6fa7660).

Notes for review

  • The service-kicad binary file tracked in this page repo is now stale-by-design (binary rides release assets; the old /static/apps/... URL already 404s). Propose adom-wiki repo rm after merge.
  • Adom machine-pin 3D libs are NOT installed server-side — documented in README; molecule STEP export for Adom-lib boards stays on the laptop.
  • Service Watcher false alarms: John already shipped git_sha in /health (6fa7660); the stale-ref compare fix lives in the watcher, not this repo.

Diff

--- a/README.md+++ b/README.md@@ -1,225 +1,226 @@-# service-kicad — shared headless KiCad--**The shared service.** `service-kicad` is a headless KiCad 10 service Adom-runs as free shared infrastructure — DRC/ERC, Gerber/SVG/STEP export,-Altium library conversion, and symbol/footprint/3D-model lookups with no-local KiCad install. Every Adom tool and user container shells to the-`service-kicad` CLI rather than hitting the HTTP API raw.--**Fair use.** It's free today and we intend to keep it that way. We reserve-the right to rate-limit or block abusive usage, and if scale ever demands-it, heavy usage may become paid. Nothing about your designs is retained-beyond the job lifecycle.--**Self-hosting.** The service is a single container you can run yourself —-full source on this page (Files tab / `adom-wiki repo clone-adom/service-kicad`). If the shared instance doesn't fit, spin up your own-and point the CLI at it. See [Self-hosting](#self-hosting) below.--## Install--```bash-adom-wiki pkg install adom/service-kicad-```--That installs the CLI to your PATH, the Claude skill, and bash-completions. Verify:--```bash-service-kicad health-```--## Usage--```bash-# Health / diagnostics-service-kicad health                  # service + KiCad version + running git_sha-service-kicad config                  # effective service URL and where it came from--# DRC / ERC-service-kicad pcb drc board.kicad_pcb-service-kicad sch erc schematic.kicad_sch--# PCB export-service-kicad pcb export gerbers board.kicad_pcb --out ./gerbers/-service-kicad pcb export step board.kicad_pcb --out board.step-service-kicad pcb export svg board.kicad_pcb --out board.svg-service-kicad pcb export svg board.kicad_pcb --layers F.Cu,F.Mask,Edge.Cuts --out board.svg--# Schematic export-service-kicad sch export svg schematic.kicad_sch --out sch.svg-service-kicad sch export svg-project ./project-dir --out ./sheets/   # hierarchical: one SVG per sheet-service-kicad sch export pdf schematic.kicad_sch --out sch.pdf-service-kicad sch export bom schematic.kicad_sch --out bom.csv--# Altium → KiCad library conversion-service-kicad altium sym part.SchLib      # → part.kicad_sym-service-kicad altium fp part.PcbLib       # → part.kicad_mod--# Symbol / footprint / 3D lookups (KiCad official libraries)-service-kicad sym svg Connector_Generic Conn_01x04-service-kicad fp svg Connector_PinHeader_2.54mm PinHeader_1x04_P2.54mm_Vertical-service-kicad model fetch Connector_PinHeader_2.54mm.3dshapes/PinHeader_1x04_P2.54mm_Vertical.step-```--### 3D models: STEP is the contract--For board 3D models, export **STEP** here and convert with-`adom-step2glb` / service-occt — that is the only supported GLB path.-KiCad-native GLB export is gated (page issue #106) because it bypasses the-molecule pipeline: no MP-marker anchoring, no gold machine-pin material,-no meter normalization, no footprint emit. The endpoints remain for-legacy callers, but every GLB that leaves this service is-provenance-stamped in its glTF JSON-(`asset.extras.adom.provenance = "kicad-native-glb"`, plus an-`X-Adom-Provenance` response header) so importers and humans can catch it-downstream, and the CLI verb requires an explicit-`--i-know-this-skips-the-pipeline` acknowledgment.--The shared instance carries the official KiCad symbol/footprint/packages3D-libraries only. Adom's machine-pin 3D libraries are **not** installed-server-side, so molecule STEP export for boards using Adom library parts-still runs on the laptop (see the `kicad-export-molecule` skill); boards-built purely from the official libraries can use `pcb export step` here-directly.--## Service URLs — channels--Two channels run in parallel. The default URL serves KiCad 10; KiCad 9 is-opt-in for callers that need to pin to the older toolchain.--| Channel | URL | KiCad | When to use |-|---|---|---|---|-| **default (v10)** | `https://kicad-rk5ue5pcfemi.adom.cloud` | 10.0.x | every consumer, all the time |-| **v9 fallback** | `https://kicad-v9-7f1gq8j5ohbq.adom.cloud` | 9.0.x | only if your project explicitly pins to v9 (e.g., file-format compat for an older kicad-symbols revision) |--The default URL is baked into the CLI. To pin a run to v9 (or any other-instance):--```bash-SERVICE_KICAD_URL=https://kicad-v9-7f1gq8j5ohbq.adom.cloud service-kicad pcb drc board.kicad_pcb-```--(`KICAD_SERVICE_API` is honored as a legacy alias.) Health probe shape is-the same on both channels; `kicad_version` and `git_sha` differ:--```bash-curl -sf https://kicad-rk5ue5pcfemi.adom.cloud/health-# → {"git_sha":"…","git_sha_short":"…","kicad_version":"10.0.2","ok":true,"service":"service-kicad","version":"1.1.0"}-```--## Self-hosting--The whole service is one container: a Rust API binary shelling to-`kicad-cli`, plus the official KiCad libraries. Everything you need is in-this repo.--1. **Provision a container.** Follow the-   [standalone service guide](https://wiki.adom.inc/adom/standalone-service)-   for creating a dedicated lightweight container with SSH. Any Ubuntu-   24.04 host with ~10 GB free (KiCad + libraries) works too.-2. **Get the source and deploy.**-   ```bash-   adom-wiki repo clone adom/service-kicad && cd service-kicad-   bash deploy.sh-   ```-   `deploy.sh` is the canonical one-shot setup: installs KiCad 10 from the-   official PPA, overlays the upstream symbol/footprint/packages3D-   libraries (see [library staleness](#library-staleness) below), builds-   the API, and installs the watchdog cron. `Dockerfile` documents the-   same steps as a reference image. KiCad 9 fallback: `deploy-v9.sh` on a-   separate host — the two PPAs ship the same `kicad` package, so it's one-   channel per host.-3. **The watchdog keeps it running.** `watchdog.sh` (cron, every 2 min)-   fetches origin, rebuilds on new commits, and health-checks the running-   service. It kills the old process by `/proc/PID/exe` readlink — not-   `pkill -f` path matching, which self-kills SSH sessions and can match-   unrelated processes. Keep that.-4. **Expose the port.** The API listens on `PORT` (default 8780). On an-   Adom container: `adom-cli carbon containers port-add <slug> --port 8780-   --prefix kicad`. Health check: `curl <url>/health` — `git_sha` tells-   you exactly what's deployed.-5. **Point the CLI at it.**-   ```bash-   export SERVICE_KICAD_URL=https://your-instance.example-   service-kicad config   # confirms: source = SERVICE_KICAD_URL env-   ```-   Same commands, your container.--### Service configuration--| Env var | Default | Purpose |-|---|---|---|-| `PORT` | `8780` | API listen port |-| `KICAD_CLI` | `kicad-cli` | kicad-cli binary to shell to |-| `KICAD_SYMBOLS_DIR` | `/usr/share/kicad/symbols` | symbol library root |-| `KICAD_FOOTPRINTS_DIR` | `/usr/share/kicad/footprints` | footprint library root |-| `KICAD_PACKAGES3D_DIR` | `/usr/share/kicad/3dmodels` | 3D model library root |--Uploads are capped at 100 MB per request.--### Library staleness--The Ubuntu apt packages of `kicad-symbols` / `kicad-footprints` /-`kicad-packages3d` lag the upstream KiCad GitHub repos by *years* (24.04-ships a ~2020 snapshot). `Dockerfile` + `deploy.sh` overlay shallow git-clones of the upstream libraries at `/opt/kicad-*-upstream`, point the-`KICAD_*_DIR` env vars at them, and refresh weekly via-`/etc/cron.d/kicad-lib-pull`. The apt packages stay installed — they bring-`kicad-cli` itself — but their library data is shadowed by the upstream-clones.--## HTTP API--Full contract: [docs/service-kicad-api.md](docs/service-kicad-api.md).-Prefer the CLI over curling the API — it has the production URL baked in-and insulates you from container reprovisions. Major endpoints:--| Endpoint | Body | Returns |-|---|---|---|-| `GET  /health`                              | — | `{ok, service, version, kicad_version, git_sha}` |-| `GET  /version`                             | — | `{service, version, api_version, git_sha}` |-| `POST /kicad/pcb/drc`                       | `.kicad_pcb` bytes | JSON |-| `POST /kicad/sch/erc`                       | `.kicad_sch` bytes | JSON |-| `POST /kicad/pcb/export/svg?side=top\|bottom&layers=F.Cu,…` | `.kicad_pcb` bytes | `image/svg+xml` |-| `POST /kicad/pcb/export/gerbers`            | `.kicad_pcb` bytes | tarball |-| `POST /kicad/pcb/export/step`               | `.kicad_pcb` bytes | `model/step` |-| `POST /kicad/pcb/export/glb[-zup]`          | `.kicad_pcb` bytes | `model/gltf-binary` — legacy; provenance-stamped, see [STEP is the contract](#3d-models-step-is-the-contract) |-| `POST /kicad/pcb/export/glb-package[-zup]`  | tar.gz (board + 3dmodels) | `model/gltf-binary` — legacy; provenance-stamped |-| `POST /kicad/sch/export/svg`                | `.kicad_sch` bytes | `image/svg+xml` |-| `POST /kicad/sch/export/svg-project`        | tar (project dir) | tar of per-sheet SVGs |-| `POST /kicad/sch/export/pdf`                | `.kicad_sch` bytes | `application/pdf` |-| `POST /kicad/sch/export/bom`                | `.kicad_sch` bytes | `text/csv` |-| `POST /kicad/altium/sym`                    | `.SchLib` bytes | `.kicad_sym` bytes |-| `POST /kicad/altium/fp`                     | `.PcbLib` bytes | `.kicad_mod` bytes |-| `GET  /sym/export/svg/:library/:name`       | — | `image/svg+xml` |-| `GET  /fp/export/svg/:library/:name`        | — | `image/svg+xml` |-| `GET  /models/<path>`                       | — | static file from `kicad-packages3d` |-| `GET  /footprints/<path>`                   | — | static file from `kicad-footprints` |-| `GET  /symbols/<path>`                      | — | static file from `kicad-symbols` |--## Design doctrine--- **Tool-agnostic by design.** Zero `aci-*` deps — nothing in this-  repo imports `adom-circuit`. Bug fixes land once for every consumer.-- **Body is the file.** Every POST endpoint takes the raw `.kicad_*`-  bytes as the request body, not a multipart upload. Keeps clients-  trivial (`curl --data-binary @file`).-- **Shell out; don't reimplement.** `kicad-cli` is the source of truth.-  We're a thin HTTP layer over subprocess calls, not a KiCad reimpl.-- **All exports pass `KICAD{6,7,8,9,10}_3DMODEL_DIR`** so component 3D-  models resolve during STEP export, regardless of which kicad-cli-  major version is on the host.-- **Static library mounts are read-only** — nobody writes back through-  the service.-- **`$PORT` configurable** so the same binary can host side-by-side-  dev/prod instances on one container.--## Contributing--Every change on `main` triggers the prod watchdog: on the next 2-minute-cycle the container pulls, rebuilds, and relaunches — `/health`'s-`git_sha` confirms what's actually serving. Breaking changes bump-`api_version` in `service.json` + `docs/service-kicad-api.md`. Open PRs-against the wiki page (`adom-wiki pr`) so review happens where users-look; the GitHub repo (`adom-inc/service-kicad`) is the working origin.+# service-kicad — shared headless KiCad++**The shared service.** `service-kicad` is a headless KiCad 10 service Adom+runs as free shared infrastructure — DRC/ERC, Gerber/SVG/STEP export,+Altium library conversion, and symbol/footprint/3D-model lookups with no+local KiCad install. Every Adom tool and user container shells to the+`service-kicad` CLI rather than hitting the HTTP API raw.++**Fair use.** It's free today and we intend to keep it that way. We reserve+the right to rate-limit or block abusive usage, and if scale ever demands+it, heavy usage may become paid. Nothing about your designs is retained+beyond the job lifecycle.++**Self-hosting.** The service is a single container you can run yourself —+full source on this page (Files tab / `adom-wiki repo clone+adom/service-kicad`). If the shared instance doesn't fit, spin up your own+and point the CLI at it. See [Self-hosting](#self-hosting) below.++## Install++```bash+adom-wiki pkg install adom/service-kicad+```++That installs the CLI to your PATH, the Claude skill, and bash+completions. Verify:++```bash+service-kicad health+```++## Usage++```bash+# Health / diagnostics+service-kicad health                  # service + KiCad version + running git_sha+service-kicad config                  # effective service URL and where it came from++# DRC / ERC+service-kicad pcb drc board.kicad_pcb+service-kicad sch erc schematic.kicad_sch++# PCB export+service-kicad pcb export gerbers board.kicad_pcb --out ./gerbers/+service-kicad pcb export step board.kicad_pcb --out board.step+service-kicad pcb export svg board.kicad_pcb --out board.svg+service-kicad pcb export svg board.kicad_pcb --layers F.Cu,F.Mask,Edge.Cuts --out board.svg++# Schematic export+service-kicad sch export svg schematic.kicad_sch --out sch.svg+service-kicad sch export svg-project ./project-dir --out ./sheets/   # hierarchical: one SVG per sheet+service-kicad sch export pdf schematic.kicad_sch --out sch.pdf+service-kicad sch export bom schematic.kicad_sch --out bom.csv++# Altium → KiCad library conversion+service-kicad altium sym part.SchLib      # → part.kicad_sym+service-kicad altium fp part.PcbLib       # → part.kicad_mod++# Symbol / footprint / 3D lookups (KiCad official libraries)+service-kicad sym svg Connector_Generic Conn_01x04+service-kicad fp svg Connector_PinHeader_2.54mm PinHeader_1x04_P2.54mm_Vertical+service-kicad model fetch Connector_PinHeader_2.54mm.3dshapes/PinHeader_1x04_P2.54mm_Vertical.step+```++### 3D models: STEP is the contract++For board 3D models, export **STEP** here and convert with+`adom-step2glb` / service-occt — that is the only supported GLB path.+KiCad-native GLB export is gated (page issue #106) because it bypasses the+molecule pipeline: no MP-marker anchoring, no gold machine-pin material,+no meter normalization, no footprint emit. The endpoints remain for+legacy callers, but every GLB that leaves this service is+provenance-stamped in its glTF JSON+(`asset.extras.adom.provenance = "kicad-native-glb"`, plus an+`X-Adom-Provenance` response header) so importers and humans can catch it+downstream, and the CLI verb requires an explicit+`--i-know-this-skips-the-pipeline` acknowledgment.++The shared instance carries the official KiCad symbol/footprint/packages3D+libraries only. Adom's machine-pin 3D libraries are **not** installed+server-side, so molecule STEP export for boards using Adom library parts+still runs on the laptop (see the `kicad-export-molecule` skill); boards+built purely from the official libraries can use `pcb export step` here+directly.++## Service URLs — channels++Two channels run in parallel. The default URL serves KiCad 10; KiCad 9 is+opt-in for callers that need to pin to the older toolchain.++| Channel | URL | KiCad | When to use |+|---|---|---|---|+| **default (v10)** | `https://kicad-rk5ue5pcfemi.adom.cloud` | 10.0.x | every consumer, all the time |+| **v9 fallback** | `https://kicad-v9-7f1gq8j5ohbq.adom.cloud` | 9.0.x | only if your project explicitly pins to v9 (e.g., file-format compat for an older kicad-symbols revision) |++The default URL is baked into the CLI. To pin a run to v9 (or any other+instance):++```bash+SERVICE_KICAD_URL=https://kicad-v9-7f1gq8j5ohbq.adom.cloud service-kicad pcb drc board.kicad_pcb+```++(`KICAD_SERVICE_API` is honored as a legacy alias.) Health probe shape is+the same on both channels; `kicad_version` and `git_sha` differ:++```bash+curl -sf https://kicad-rk5ue5pcfemi.adom.cloud/health+# → {"git_sha":"…","git_sha_short":"…","kicad_version":"10.0.2","ok":true,"service":"service-kicad","version":"1.1.0"}+```++## Self-hosting++The whole service is one container: a Rust API binary shelling to+`kicad-cli`, plus the official KiCad libraries. Everything you need is in+this repo.++1. **Provision a container.** Follow the+   [standalone service guide](https://wiki.adom.inc/adom/standalone-service)+   for creating a dedicated lightweight container with SSH. Any Ubuntu+   24.04 host with ~10 GB free (KiCad + libraries) works too.+2. **Get the source and deploy.**+   ```bash+   adom-wiki repo clone adom/service-kicad && cd service-kicad+   bash deploy.sh+   ```+   `deploy.sh` is the canonical one-shot setup: installs KiCad 10 from the+   official PPA, overlays the upstream symbol/footprint/packages3D+   libraries (see [library staleness](#library-staleness) below), builds+   the API, and installs the watchdog cron. `Dockerfile` documents the+   same steps as a reference image. KiCad 9 fallback: `deploy-v9.sh` on a+   separate host — the two PPAs ship the same `kicad` package, so it's one+   channel per host.+3. **The watchdog keeps it running.** `watchdog.sh` (cron, every 2 min)+   fetches origin, rebuilds on new commits, and health-checks the running+   service. It kills the old process by `/proc/PID/exe` readlink — not+   `pkill -f` path matching, which self-kills SSH sessions and can match+   unrelated processes. Keep that.+4. **Expose the port.** The API listens on `PORT` (default 8780). On an+   Adom container: `adom-cli carbon containers port-add <slug> --port 8780+   --prefix kicad`. Health check: `curl <url>/health` — `git_sha` tells+   you exactly what's deployed.+5. **Point the CLI at it.**+   ```bash+   export SERVICE_KICAD_URL=https://your-instance.example+   service-kicad config   # confirms: source = SERVICE_KICAD_URL env+   ```+   Same commands, your container.++### Service configuration++| Env var | Default | Purpose |+|---|---|---|+| `PORT` | `8780` | API listen port |+| `KICAD_CLI` | `kicad-cli` | kicad-cli binary to shell to |+| `KICAD_SYMBOLS_DIR` | `/usr/share/kicad/symbols` | symbol library root |+| `KICAD_FOOTPRINTS_DIR` | `/usr/share/kicad/footprints` | footprint library root |+| `KICAD_PACKAGES3D_DIR` | `/usr/share/kicad/3dmodels` | 3D model library root |++Uploads are capped at 100 MB per request.++### Library staleness++The Ubuntu apt packages of `kicad-symbols` / `kicad-footprints` /+`kicad-packages3d` lag the upstream KiCad GitHub repos by *years* (24.04+ships a ~2020 snapshot). `Dockerfile` + `deploy.sh` overlay shallow git+clones of the upstream libraries at `/opt/kicad-*-upstream`, point the+`KICAD_*_DIR` env vars at them, and refresh weekly via+`/etc/cron.d/kicad-lib-pull`. The apt packages stay installed — they bring+`kicad-cli` itself — but their library data is shadowed by the upstream+clones.++## HTTP API++Full contract: [docs/service-kicad-api.md](docs/service-kicad-api.md).+Prefer the CLI over curling the API — it has the production URL baked in+and insulates you from container reprovisions. Major endpoints:++| Endpoint | Body | Returns |+|---|---|---|+| `GET  /health`                              | — | `{ok, service, version, kicad_version, git_sha}` |+| `GET  /version`                             | — | `{service, version, api_version, git_sha}` |+| `POST /kicad/pcb/drc`                       | `.kicad_pcb` bytes | JSON |+| `POST /kicad/sch/erc`                       | `.kicad_sch` bytes | JSON |+| `POST /kicad/pcb/export/svg?side=top\|bottom&layers=F.Cu,…` | `.kicad_pcb` bytes | `image/svg+xml` |+| `POST /kicad/pcb/export/gerbers`            | `.kicad_pcb` bytes | tarball |+| `POST /kicad/pcb/export/step`               | `.kicad_pcb` bytes | `model/step` |+| `POST /kicad/pcb/export/glb[-zup]`          | `.kicad_pcb` bytes | `model/gltf-binary` — legacy; provenance-stamped, see [STEP is the contract](#3d-models-step-is-the-contract) |+| `POST /kicad/pcb/export/glb-package[-zup]`  | tar.gz (board + 3dmodels) | `model/gltf-binary` — legacy; provenance-stamped |+| `POST /kicad/sch/export/svg`                | `.kicad_sch` bytes | `image/svg+xml` |+| `POST /kicad/sch/export/svg-project`        | tar (project dir) | tar of per-sheet SVGs |+| `POST /kicad/sch/export/pdf`                | `.kicad_sch` bytes | `application/pdf` |+| `POST /kicad/sch/export/bom`                | `.kicad_sch` bytes | `text/csv` |+| `POST /kicad/altium/sym`                    | `.SchLib` bytes | `.kicad_sym` bytes |+| `POST /kicad/altium/fp`                     | `.PcbLib` bytes | `.kicad_mod` bytes |+| `GET  /sym/export/svg/:library/:name`       | — | `image/svg+xml` |+| `GET  /fp/export/svg/:library/:name`        | — | `image/svg+xml` |+| `GET  /models/<path>`                       | — | static file from `kicad-packages3d` |+| `GET  /footprints/<path>`                   | — | static file from `kicad-footprints` |+| `GET  /symbols/<path>`                      | — | static file from `kicad-symbols` |++## Design doctrine++- **Tool-agnostic by design.** Zero `aci-*` deps — nothing in this+  repo imports `adom-circuit`. Bug fixes land once for every consumer.+- **Body is the file.** Every POST endpoint takes the raw `.kicad_*`+  bytes as the request body, not a multipart upload. Keeps clients+  trivial (`curl --data-binary @file`).+- **Shell out; don't reimplement.** `kicad-cli` is the source of truth.+  We're a thin HTTP layer over subprocess calls, not a KiCad reimpl.+- **All exports pass `KICAD{6,7,8,9,10}_3DMODEL_DIR`** so component 3D+  models resolve during STEP export, regardless of which kicad-cli+  major version is on the host.+- **Static library mounts are read-only** — nobody writes back through+  the service.+- **`$PORT` configurable** so the same binary can host side-by-side+  dev/prod instances on one container.++## Contributing++Every change on `main` triggers the prod watchdog: on the next 2-minute+cycle the container pulls, rebuilds, and relaunches — `/health`'s+`git_sha` confirms what's actually serving. Breaking changes bump+`api_version` in `service.json` + `docs/service-kicad-api.md`. Open PRs+against the wiki page (`adom-wiki pr`) so review happens where users+look; the GitHub repo (`adom-inc/service-kicad`) is the working origin.+--- a/SKILL.md+++ b/SKILL.md@@ -1,109 +1,110 @@-----name: service-kicad-description: >-  Shared headless KiCad service for every Adom tool and user container.-  Run DRC, ERC, export to SVG/Gerber/STEP, convert Altium libraries,-  look up symbols, footprints, and 3D models — all via the-  `service-kicad` CLI. No local KiCad install needed.-  Trigger words: kicad service, service-kicad, kicad-cli remote,-  kicad DRC, kicad ERC, export gerbers, export step, board svg,-  schematic svg, altium to kicad, schlib, pcblib, kicad-packages3d,-  shared kicad library, headless kicad.------# service-kicad — shared headless KiCad--A shared KiCad 10 service running on a dedicated container. Every Adom-tool that needs KiCad operations (DRC, ERC, exports, library lookups)-shells to the `service-kicad` CLI instead of installing KiCad locally.--## Installation--```bash-adom-wiki pkg install adom/service-kicad-```--Installs the CLI, this skill, and bash completions. Verify with-`service-kicad health`.--## When to use--**Always use the `service-kicad` CLI** — never curl the service URL-directly. The CLI has the production endpoint baked in and insulates you-from container reprovisions that change the URL.--```bash-# Good — shell to the CLI-service-kicad pcb drc board.kicad_pcb-service-kicad pcb export step board.kicad_pcb --out board.step-service-kicad sym svg Connector_Generic Conn_01x04--# Bad — hardcoded URL, breaks on reprovision-curl -X POST --data-binary @board.kicad_pcb https://...adom.cloud/kicad/pcb/drc-```--**Don't** apt-install `kicad` on your container just to render one SVG.--**Board GLB is not exported here.** For a board 3D model, export STEP and-convert with `adom-step2glb` (molecule mode when the board has machine-pins). KiCad-native GLB bypasses the molecule pipeline — no anchoring,-gold pins, normalization, or footprint emit — and is gated behind-`--i-know-this-skips-the-pipeline`; anything it produces is-provenance-stamped (`asset.extras.adom.provenance = "kicad-native-glb"`).--## CLI commands--```bash-# Diagnostics-service-kicad health          # includes running git_sha-service-kicad version-service-kicad config          # effective URL + which override is active--# PCB-service-kicad pcb drc board.kicad_pcb [--format json] [--out report.json]-service-kicad pcb export svg board.kicad_pcb [--side top|bottom] [--layers F.Cu,F.Mask,Edge.Cuts] [--out board.svg]-service-kicad pcb export gerbers board.kicad_pcb [--out ./gerbers/]-service-kicad pcb export step board.kicad_pcb [--out board.step] [-F --include-pads ...]--# Schematic-service-kicad sch erc schematic.kicad_sch [--format json]-service-kicad sch export svg schematic.kicad_sch [--out sch.svg]-service-kicad sch export svg-project ./project-dir [--out ./sheets/]   # hierarchical: one SVG per sheet-service-kicad sch export pdf schematic.kicad_sch [--out sch.pdf]-service-kicad sch export bom schematic.kicad_sch [--out bom.csv]--# Altium → KiCad library conversion-service-kicad altium sym part.SchLib [--out part.kicad_sym]-service-kicad altium fp part.PcbLib [--out part.kicad_mod]--# Symbol / footprint SVG + raw fetch-service-kicad sym svg LIBRARY NAME [--out symbol.svg]-service-kicad sym fetch LIBRARY [--out lib.kicad_sym]-service-kicad fp svg LIBRARY NAME [--out footprint.svg]-service-kicad fp fetch LIBRARY NAME [--out fp.kicad_mod]--# 3D model download (official kicad-packages3d)-service-kicad model fetch PATH [--out model.step]-```--## Pointing at another instance (v9 pin, self-host)--```bash-export SERVICE_KICAD_URL=https://kicad-v9-7f1gq8j5ohbq.adom.cloud   # KiCad 9 fallback channel-export SERVICE_KICAD_URL=https://your-own-instance.example          # self-hosted-```--`service-kicad config` shows which URL is in effect and why.-(`KICAD_SERVICE_API` still works as a legacy alias.) Self-hosting is a-one-script deploy — see the README on the wiki page-(`adom-wiki repo clone adom/service-kicad`).--## Troubleshooting--| Exit | Meaning | Fix |-|---|---|---|-| **0** | ok | — |-| **1** | input invalid | Check path + that file parses under kicad-cli |-| **2** | service unreachable | `service-kicad health` to confirm; the container may be restarting |-| **3** | auth failed (401/403) | `adom-cli auth refresh` |-| **4** | service-side error | CLI prints stderr; usually input-specific |+---+name: service-kicad+description: >+  Shared headless KiCad service for every Adom tool and user container.+  Run DRC, ERC, export to SVG/Gerber/STEP, convert Altium libraries,+  look up symbols, footprints, and 3D models — all via the+  `service-kicad` CLI. No local KiCad install needed.+  Trigger words: kicad service, service-kicad, kicad-cli remote,+  kicad DRC, kicad ERC, export gerbers, export step, board svg,+  schematic svg, altium to kicad, schlib, pcblib, kicad-packages3d,+  shared kicad library, headless kicad.+---++# service-kicad — shared headless KiCad++A shared KiCad 10 service running on a dedicated container. Every Adom+tool that needs KiCad operations (DRC, ERC, exports, library lookups)+shells to the `service-kicad` CLI instead of installing KiCad locally.++## Installation++```bash+adom-wiki pkg install adom/service-kicad+```++Installs the CLI, this skill, and bash completions. Verify with+`service-kicad health`.++## When to use++**Always use the `service-kicad` CLI** — never curl the service URL+directly. The CLI has the production endpoint baked in and insulates you+from container reprovisions that change the URL.++```bash+# Good — shell to the CLI+service-kicad pcb drc board.kicad_pcb+service-kicad pcb export step board.kicad_pcb --out board.step+service-kicad sym svg Connector_Generic Conn_01x04++# Bad — hardcoded URL, breaks on reprovision+curl -X POST --data-binary @board.kicad_pcb https://...adom.cloud/kicad/pcb/drc+```++**Don't** apt-install `kicad` on your container just to render one SVG.++**Board GLB is not exported here.** For a board 3D model, export STEP and+convert with `adom-step2glb` (molecule mode when the board has machine+pins). KiCad-native GLB bypasses the molecule pipeline — no anchoring,+gold pins, normalization, or footprint emit — and is gated behind+`--i-know-this-skips-the-pipeline`; anything it produces is+provenance-stamped (`asset.extras.adom.provenance = "kicad-native-glb"`).++## CLI commands++```bash+# Diagnostics+service-kicad health          # includes running git_sha+service-kicad version+service-kicad config          # effective URL + which override is active++# PCB+service-kicad pcb drc board.kicad_pcb [--format json] [--out report.json]+service-kicad pcb export svg board.kicad_pcb [--side top|bottom] [--layers F.Cu,F.Mask,Edge.Cuts] [--out board.svg]+service-kicad pcb export gerbers board.kicad_pcb [--out ./gerbers/]+service-kicad pcb export step board.kicad_pcb [--out board.step] [-F --include-pads ...]++# Schematic+service-kicad sch erc schematic.kicad_sch [--format json]+service-kicad sch export svg schematic.kicad_sch [--out sch.svg]+service-kicad sch export svg-project ./project-dir [--out ./sheets/]   # hierarchical: one SVG per sheet+service-kicad sch export pdf schematic.kicad_sch [--out sch.pdf]+service-kicad sch export bom schematic.kicad_sch [--out bom.csv]++# Altium → KiCad library conversion+service-kicad altium sym part.SchLib [--out part.kicad_sym]+service-kicad altium fp part.PcbLib [--out part.kicad_mod]++# Symbol / footprint SVG + raw fetch+service-kicad sym svg LIBRARY NAME [--out symbol.svg]+service-kicad sym fetch LIBRARY [--out lib.kicad_sym]+service-kicad fp svg LIBRARY NAME [--out footprint.svg]+service-kicad fp fetch LIBRARY NAME [--out fp.kicad_mod]++# 3D model download (official kicad-packages3d)+service-kicad model fetch PATH [--out model.step]+```++## Pointing at another instance (v9 pin, self-host)++```bash+export SERVICE_KICAD_URL=https://kicad-v9-7f1gq8j5ohbq.adom.cloud   # KiCad 9 fallback channel+export SERVICE_KICAD_URL=https://your-own-instance.example          # self-hosted+```++`service-kicad config` shows which URL is in effect and why.+(`KICAD_SERVICE_API` still works as a legacy alias.) Self-hosting is a+one-script deploy — see the README on the wiki page+(`adom-wiki repo clone adom/service-kicad`).++## Troubleshooting++| Exit | Meaning | Fix |+|---|---|---|+| **0** | ok | — |+| **1** | input invalid | Check path + that file parses under kicad-cli |+| **2** | service unreachable | `service-kicad health` to confirm; the container may be restarting |+| **3** | auth failed (401/403) | `adom-cli auth refresh` |+| **4** | service-side error | CLI prints stderr; usually input-specific |+--- a/install.sh+++ b/install.sh@@ -1,44 +1,45 @@-#!/bin/bash-# adom-wiki package install script. Runs from the extracted tarball dir.-# The CLI binary is a RELEASE asset (per wiki doctrine, binaries never-# ride the install tarball) — declared as install.binary_name in-# package.json; if the harness hasn't already placed it next to us,-# fetch it explicitly. v1.0.1's script only copied the skill, so-# `pkg install` delivered no binary at all — everything the consumer-# keeps must be installed out of here.-set -e-PKG_DIR="$(cd "$(dirname "$0")" && pwd)"-VERSION="$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' "$PKG_DIR/package.json" | head -1)"--# 1. CLI binary → ~/.local/bin (on PATH for Adom containers, no sudo).-BIN_DIR="$HOME/.local/bin"-mkdir -p "$BIN_DIR"-if [ ! -f "$PKG_DIR/service-kicad" ]; then-  adom-wiki release download adom/service-kicad "$VERSION" --platform linux --out "$PKG_DIR" >/dev/null-fi-if [ -f "$PKG_DIR/service-kicad" ]; then-  install -m 0755 "$PKG_DIR/service-kicad" "$BIN_DIR/service-kicad"-  echo "✓ CLI → $BIN_DIR/service-kicad"-else-  echo "! could not obtain service-kicad binary (release asset missing?) — CLI not installed" >&2-  exit 1-fi--# 2. Skill(s) → ~/.claude/skills/-SKILLS_DIR="$HOME/.claude/skills"-mkdir -p "$SKILLS_DIR/service-kicad"-if [ -f "$PKG_DIR/SKILL.md" ]; then-  cp "$PKG_DIR/SKILL.md" "$SKILLS_DIR/service-kicad/SKILL.md"-  echo "✓ skill → $SKILLS_DIR/service-kicad/SKILL.md"-fi-for skill_dir in "$PKG_DIR/skills"/*/; do-  [ -d "$skill_dir" ] || continue-  skill_name=$(basename "$skill_dir")-  mkdir -p "$SKILLS_DIR/$skill_name"-  cp -r "$skill_dir"* "$SKILLS_DIR/$skill_name/"-done--# 3. Bash completions (binary writes them; skill already installed above).-"$BIN_DIR/service-kicad" install --completions-only 2>/dev/null || true--echo "Installed service-kicad — verify with: service-kicad health"+#!/bin/bash+# adom-wiki package install script. Runs from the extracted tarball dir.+# The CLI binary is a RELEASE asset (per wiki doctrine, binaries never+# ride the install tarball) — declared as install.binary_name in+# package.json; if the harness hasn't already placed it next to us,+# fetch it explicitly. v1.0.1's script only copied the skill, so+# `pkg install` delivered no binary at all — everything the consumer+# keeps must be installed out of here.+set -e+PKG_DIR="$(cd "$(dirname "$0")" && pwd)"+VERSION="$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' "$PKG_DIR/package.json" | head -1)"++# 1. CLI binary → ~/.local/bin (on PATH for Adom containers, no sudo).+BIN_DIR="$HOME/.local/bin"+mkdir -p "$BIN_DIR"+if [ ! -f "$PKG_DIR/service-kicad" ]; then+  adom-wiki release download adom/service-kicad "$VERSION" --platform linux --out "$PKG_DIR" >/dev/null+fi+if [ -f "$PKG_DIR/service-kicad" ]; then+  install -m 0755 "$PKG_DIR/service-kicad" "$BIN_DIR/service-kicad"+  echo "✓ CLI → $BIN_DIR/service-kicad"+else+  echo "! could not obtain service-kicad binary (release asset missing?) — CLI not installed" >&2+  exit 1+fi++# 2. Skill(s) → ~/.claude/skills/+SKILLS_DIR="$HOME/.claude/skills"+mkdir -p "$SKILLS_DIR/service-kicad"+if [ -f "$PKG_DIR/SKILL.md" ]; then+  cp "$PKG_DIR/SKILL.md" "$SKILLS_DIR/service-kicad/SKILL.md"+  echo "✓ skill → $SKILLS_DIR/service-kicad/SKILL.md"+fi+for skill_dir in "$PKG_DIR/skills"/*/; do+  [ -d "$skill_dir" ] || continue+  skill_name=$(basename "$skill_dir")+  mkdir -p "$SKILLS_DIR/$skill_name"+  cp -r "$skill_dir"* "$SKILLS_DIR/$skill_name/"+done++# 3. Bash completions (binary writes them; skill already installed above).+"$BIN_DIR/service-kicad" install --completions-only 2>/dev/null || true++echo "Installed service-kicad — verify with: service-kicad health"+--- a/uninstall.sh+++ b/uninstall.sh@@ -1,6 +1,7 @@-#!/bin/bash-set -e-rm -f "$HOME/.local/bin/service-kicad"-rm -rf "$HOME/.claude/skills/service-kicad"-rm -f "$HOME/.bash_completion.d/service-kicad"-echo "Uninstalled service-kicad"+#!/bin/bash+set -e+rm -f "$HOME/.local/bin/service-kicad"+rm -rf "$HOME/.claude/skills/service-kicad"+rm -f "$HOME/.bash_completion.d/service-kicad"+echo "Uninstalled service-kicad"+--- a/package.json+++ b/package.json@@ -1,55 +1,56 @@-{-  "slug": "service-kicad",-  "version": "1.1.0",-  "type": "app",-  "description": "Shared headless KiCad 10: DRC, ERC, SVG/Gerber/STEP export, Altium library conversion, symbol/footprint/3D-model lookup. Every Adom tool shells to this CLI instead of calling the HTTP API directly.",-  "keywords": [-    "service-kicad",-    "kicad",-    "kicad-cli",-    "drc",-    "erc",-    "gerbers",-    "step",-    "altium",-    "pcb",-    "schematic",-    "cli"-  ],-  "tags": [-    "kicad",-    "pcb",-    "eda",-    "cli",-    "shared-service"-  ],-  "discovery_pitch": "Run KiCad operations (DRC, ERC, Gerber/SVG/STEP export, Altium conversion, library lookups) from any container with no local KiCad install — free shared infrastructure.",-  "discovery_triggers": [-    "run drc on my board",-    "export gerbers",-    "export step from kicad",-    "render my schematic",-    "convert altium library to kicad",-    "kicad without installing kicad",-    "headless kicad",-    "kicad footprint svg",-    "service-kicad"-  ],-  "sample_prompts": [-    { "label": "Run DRC", "prompt": "Run DRC on board.kicad_pcb and summarize the violations" },-    { "label": "Export Gerbers", "prompt": "Export gerbers for board.kicad_pcb into ./gerbers/" },-    { "label": "Board STEP", "prompt": "Export a STEP model of board.kicad_pcb" },-    { "label": "Altium → KiCad", "prompt": "Convert part.PcbLib to a KiCad footprint" }-  ],-  "install": {-    "binary_name": "service-kicad"-  },-  "hero": {-    "path": "service-kicad-hero.png"-  },-  "dependencies": {},-  "scripts": {-    "install": "./install.sh",-    "uninstall": "./uninstall.sh"-  }-}+{+  "slug": "service-kicad",+  "version": "1.1.0",+  "type": "app",+  "description": "Shared headless KiCad 10: DRC, ERC, SVG/Gerber/STEP export, Altium library conversion, symbol/footprint/3D-model lookup. Every Adom tool shells to this CLI instead of calling the HTTP API directly.",+  "keywords": [+    "service-kicad",+    "kicad",+    "kicad-cli",+    "drc",+    "erc",+    "gerbers",+    "step",+    "altium",+    "pcb",+    "schematic",+    "cli"+  ],+  "tags": [+    "kicad",+    "pcb",+    "eda",+    "cli",+    "shared-service"+  ],+  "discovery_pitch": "Run KiCad operations (DRC, ERC, Gerber/SVG/STEP export, Altium conversion, library lookups) from any container with no local KiCad install — free shared infrastructure.",+  "discovery_triggers": [+    "run drc on my board",+    "export gerbers",+    "export step from kicad",+    "render my schematic",+    "convert altium library to kicad",+    "kicad without installing kicad",+    "headless kicad",+    "kicad footprint svg",+    "service-kicad"+  ],+  "sample_prompts": [+    { "label": "Run DRC", "prompt": "Run DRC on board.kicad_pcb and summarize the violations" },+    { "label": "Export Gerbers", "prompt": "Export gerbers for board.kicad_pcb into ./gerbers/" },+    { "label": "Board STEP", "prompt": "Export a STEP model of board.kicad_pcb" },+    { "label": "Altium → KiCad", "prompt": "Convert part.PcbLib to a KiCad footprint" }+  ],+  "install": {+    "binary_name": "service-kicad"+  },+  "hero": {+    "path": "service-kicad-hero.png"+  },+  "dependencies": {},+  "scripts": {+    "install": "./install.sh",+    "uninstall": "./uninstall.sh"+  }+}+--- a/.gitignore+++ b/.gitignore@@ -0,0 +1,5 @@+target/+Cargo.lock+*.log+.cache/+--- a/Cargo.toml+++ b/Cargo.toml@@ -0,0 +1,20 @@+[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"+--- a/Dockerfile+++ b/Dockerfile@@ -0,0 +1,45 @@+# 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"]+--- a/Dockerfile.v9+++ b/Dockerfile.v9@@ -0,0 +1,44 @@+# 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"]+--- a/deploy.sh+++ b/deploy.sh@@ -0,0 +1,120 @@+#!/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)"+--- a/deploy-v9.sh+++ b/deploy-v9.sh@@ -0,0 +1,115 @@+#!/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"+--- a/watchdog.sh+++ b/watchdog.sh@@ -0,0 +1,83 @@+#!/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+--- a/service.json+++ b/service.json@@ -0,0 +1,29 @@+{+  "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"+    }+  }+}+--- a/docs/service-kicad-api.md+++ b/docs/service-kicad-api.md@@ -0,0 +1,142 @@+# 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.+--- a/api/Cargo.toml+++ b/api/Cargo.toml@@ -0,0 +1,25 @@+[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"+--- a/api/build.rs+++ b/api/build.rs@@ -0,0 +1,25 @@+//! 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");+}+--- a/api/src/main.rs+++ b/api/src/main.rs@@ -0,0 +1,928 @@+//! 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());+    }+}+--- a/cli/Cargo.toml+++ b/cli/Cargo.toml@@ -0,0 +1,21 @@+[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+--- a/cli/build.rs+++ b/cli/build.rs@@ -0,0 +1,43 @@+//! 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"))+}+--- a/cli/src/main.rs+++ b/cli/src/main.rs@@ -0,0 +1,505 @@+//! `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(())+}+

Comments

No comments yet.

Log in to comment.