app
service-kicad
Public Made by Adomby adom
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.
← 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-ufypy5dpx93oinstall instructions replaced withadom-wiki pkg install adom/service-kicad; full self-host walkthrough (deploy.sh, watchdog pattern, port/env,SERVICE_KICAD_URLoverride) linkingadom/standalone-servicefor 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_URLoverride (legacyKICAD_SERVICE_APIstill honored) — the self-host linchpin;configshows 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.installfetches SKILL.md from wiki.adom.inc (old slug host is dead).- install.sh actually installs the binary — 1.0.1's
pkg installdelivered 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, plusX-Adom-Provenanceheader — downstream importers/humans can catch pipeline-bypassing GLBs. Stamp failure never breaks the export. Zup rotation now merges intoasset.extras.adominstead of clobbering it. 5 unit tests. - CLI verbs hidden from help and refused with step2glb redirect hints unless
--i-know-this-skips-the-pipelineis passed.
Verified
- Clean
adom-wiki pkg install adom/service-kicad→ working 1.1.0 CLI + skill;healthOK against prod. --layers,altium fp(real COSMIIC .PcbLib),svg-project(20-sheet PM1C project) e2e against prod.SERVICE_KICAD_URLoverride confirmed viaconfig.- Provenance stamp: unit-tested; live verify pending the GitHub-side merge → watchdog deploy (prod still runs 6fa7660).
Notes for review
- The
service-kicadbinary file tracked in this page repo is now stale-by-design (binary rides release assets; the old/static/apps/...URL already 404s). Proposeadom-wiki repo rmafter 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_shain/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.