app
Chip Fetcher (cf)
Public Made by Adomby adom
Crawls the web on your behalf to gather everything about the chips you care about: datasheets, symbols, footprints, 3D models, stock and pricing, straight from the manufacturer first, into your EDA tool. Say "cf" to your AI.
← Commit history
Upload full chip-fetcher source (MIT) for Barrett / adom org
40 files changed
+11076−175
.gitignore+6Cargo.lock+293Cargo.toml+21INVENTORY.md+86LICENSE+21README.md+45−70SKILL.md+103−103install.sh+1−1selftest.cjs+142skills/CONTRIBUTING.md+68skills/SKILL.md+84skills/sites/digikey/SKILL.md+63skills/sites/manufacturer/SKILL.md+47skills/sites/mouser/SKILL.md+30skills/sites/nxp/SKILL.md+18skills/sites/snapeda/SKILL.md+51skills/sites/st/SKILL.md+18skills/sites/ti/SKILL.md+19skills/sites/ultralibrarian/SKILL.md+27src/cli.rs+1871src/creds.rs+147src/deliver.rs+126src/enrich.rs+217src/hints.rs+665src/import.rs+376src/inventory.rs+422src/library.rs+56src/main.rs+20src/projects.rs+112src/provenance.rs+73src/pup.rs+719src/serve.rs+1222src/sources/mod.rs+1src/sources/ti.rs+43src/sourcing.rs+403src/thumbnails.rs+377src/ui/index.html+2222src/validate.rs+662tool-map.html+197uninstall.sh+2−1.gitignoreadded+6@@ -0,0 +1,6 @@+target/+library/+incoming/+archive/+*.log+.DS_Store
Cargo.lockadded+293@@ -0,0 +1,293 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "adom-chip-fetcher"+version = "1.2.24"+dependencies = [+ "anyhow",+ "clap",+ "serde",+ "serde_json",+ "tiny_http",+]++[[package]]+name = "anstream"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"+dependencies = [+ "anstyle",+ "anstyle-parse",+ "anstyle-query",+ "anstyle-wincon",+ "colorchoice",+ "is_terminal_polyfill",+ "utf8parse",+]++[[package]]+name = "anstyle"+version = "1.0.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"++[[package]]+name = "anstyle-parse"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"+dependencies = [+ "utf8parse",+]++[[package]]+name = "anstyle-query"+version = "1.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"+dependencies = [+ "windows-sys",+]++[[package]]+name = "anstyle-wincon"+version = "3.0.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"+dependencies = [+ "anstyle",+ "once_cell_polyfill",+ "windows-sys",+]++[[package]]+name = "anyhow"+version = "1.0.102"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"++[[package]]+name = "ascii"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"++[[package]]+name = "chunked_transfer"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"++[[package]]+name = "clap"+version = "4.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"+dependencies = [+ "clap_builder",+ "clap_derive",+]++[[package]]+name = "clap_builder"+version = "4.6.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"+dependencies = [+ "anstream",+ "anstyle",+ "clap_lex",+ "strsim",+]++[[package]]+name = "clap_derive"+version = "4.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"+dependencies = [+ "heck",+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "clap_lex"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"++[[package]]+name = "colorchoice"+version = "1.0.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"++[[package]]+name = "heck"+version = "0.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"++[[package]]+name = "httpdate"+version = "1.0.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"++[[package]]+name = "is_terminal_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"++[[package]]+name = "itoa"+version = "1.0.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"++[[package]]+name = "log"+version = "0.4.29"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"++[[package]]+name = "memchr"+version = "2.8.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"++[[package]]+name = "once_cell_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"++[[package]]+name = "proc-macro2"+version = "1.0.106"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "quote"+version = "1.0.45"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "serde"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"+dependencies = [+ "serde_core",+ "serde_derive",+]++[[package]]+name = "serde_core"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"+dependencies = [+ "serde_derive",+]++[[package]]+name = "serde_derive"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "serde_json"+version = "1.0.149"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"+dependencies = [+ "itoa",+ "memchr",+ "serde",+ "serde_core",+ "zmij",+]++[[package]]+name = "strsim"+version = "0.11.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"++[[package]]+name = "syn"+version = "2.0.117"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "tiny_http"+version = "0.12.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"+dependencies = [+ "ascii",+ "chunked_transfer",+ "httpdate",+ "log",+]++[[package]]+name = "unicode-ident"+version = "1.0.24"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"++[[package]]+name = "utf8parse"+version = "0.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"++[[package]]+name = "windows-link"+version = "0.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"++[[package]]+name = "windows-sys"+version = "0.61.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"+dependencies = [+ "windows-link",+]++[[package]]+name = "zmij"+version = "1.0.21"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
Cargo.tomladded+21@@ -0,0 +1,21 @@+[package]+name = "adom-chip-fetcher"+version = "1.2.24"+edition = "2021"+description = "Download manufacturer-grade chip CAD bundles (STEP, KiCAD symbol/footprint, datasheets) into a structured library by driving Chrome via pup."++[[bin]]+name = "adom-chip-fetcher"+path = "src/main.rs"++[dependencies]+anyhow = "1"+clap = { version = "4", features = ["derive"] }+serde = { version = "1", features = ["derive"] }+serde_json = "1"+tiny_http = "0.12"++[profile.release]+opt-level = 3+lto = "thin"+strip = true
INVENTORY.mdadded+86@@ -0,0 +1,86 @@+# chip-fetcher library inventory++Generated: Sat May 2 12:09:44 UTC 2026++## Summary++- **18 fully-bundled chips** (STEP + symbol + footprint) — wiki-publishable+- **9 stubs** (incomplete — see Gaps section)+- **162.8 MB total**++## Vendor coverage++| Vendor | Fully-bundled | Stubs |+|---|---:|---:|+| TI | 9 | 4 |+| Espressif | 2 | 0 |+| NXP | 2 | 0 |+| ADI | 1 | 0 |+| Microchip | 1 | 0 |+| Lattice Semiconductor | 1 | 0 |+| Renesas Electronics | 1 | 0 |+| ST | 1 | 0 |+| Bosch | 0 | 2 |+| Diodes | 0 | 1 |+| WAGO | 0 | 1 |+| Nordic | 0 | 1 |++## Fully-bundled chips (publish-ready)++| MPN | Vendor | Package | PDFs | info.json | Source | Size |+|---|---|---|---:|---|---|---:|+| AD7124-8BBCPZ | ADI | ? | 1 | — | — | 1.9 MB |+| ADS1115IDGSR | TI | ? | 1 | — | — | 2.8 MB |+| ADS131M04IPBSR | TI | ? | 1 | — | — | 3.6 MB |+| ATSAMD51J20A-AUT | Microchip | ? | 0 | — | — | 1.1 MB |+| BQ25798RQMR | TI | ? | 0 | — | — | 1.4 MB |+| DRV8316RRGFR | TI | ? | 1 | — | — | 5.1 MB |+| ESP32-S3-WROOM-1-N4 | Espressif | ? | 3 | — | — | 18.6 MB |+| ESP32-S3FN8 | Espressif | ? | 2 | — | — | 16.5 MB |+| ICE40UP5K-SG48ITR | Lattice Semiconductor | QFN-48 (SG48) | 18 | ✓ | mouser-cse-fallback | 39.2 MB |+| INA226AIDGSR | TI | ? | 1 | — | — | 1.6 MB |+| LM358APE4 | TI | ? | 1 | — | — | 4.2 MB |+| MCF8316A1RRYR | TI | ? | 1 | — | — | 7.1 MB |+| MCXN947VNLT | NXP | ? | 16 | — | — | 24.8 MB |+| PCA9685 | NXP | ? | 1 | — | — | 2.3 MB |+| R7FA4M1AB3CFP | Renesas Electronics | LQFP-100 | 3 | ✓ | mouser-ecad-model-fallback | 7.4 MB |+| TPS54302DDC | TI | ? | 1 | — | — | 2.3 MB |+| TPS62840YBGR | TI | ? | 0 | — | — | 0.0 MB |+| VL53L8CX | ST | ? | 1 | — | — | 5.1 MB |++## Stubs / partials (NOT publish-ready)++| MPN | Vendor | step | sym | mod | pdf | Action |+|---|---|:-:|:-:|:-:|:-:|---|+| BME688 | Bosch | ✗ | ✗ | ✗ | ✓ | Bosch — datasheet only (Bosch doesn't self-host CAD); fetch via Mouser ECAD-Model with approval |+| BMI270 | Bosch | ✗ | ✗ | ✗ | ✓ | Bosch — datasheet only; same as BME688 |+| DMG2305UX-7 | Diodes | ✗ | ✗ | ✗ | ✓ | Diodes Inc MOSFET — datasheet only; fetch via Mouser ECAD-Model |+| LM358DR | TI | ✗ | ✗ | ✗ | ✓ | datasheet-only variant of full sibling — likely DELETE |+| MSP430G2553IPW20 | TI | ✗ | ✗ | ✗ | ✓ | datasheet-only TI part — fetch via UL (mfr-direct) |+| TPS62840DLCR | TI | ✗ | ✗ | ✗ | ✓ | datasheet-only variant of YBGR — likely DELETE (covered by sibling) |+| TPS65987DDH | TI | ✗ | ✗ | ✗ | ✓ | USB-C PD; TI page didn't expose UL link for this part — try Mouser ECAD-Model |+| WAGO-2601-3105 | WAGO | ✓ | ✗ | ✗ | ✗ | connector — STEP only, intentional (no sym/mod for connectors yet) |+| nRF52840 | Nordic | ✗ | ✗ | ✗ | ✗ | empty stub — DELETE or fetch (Nordic mfr-direct + UL) |++## Recommended cleanup before wiki-publishing++**Delete (covered by full sibling):**+- LM358DR (we have LM358APE4 fully bundled)+- TPS62840DLCR (we have TPS62840YBGR fully bundled — different package though, keep if you want both DSBGA + WSON variants)++**Fetch CAD to complete:**+- MSP430G2553IPW20 (TI, easy via UL deep-link)+- TPS65987DDH (TI USB-C PD)+- nRF52840 (Nordic; check Nordic site first per the rule)+- BME688 / BMI270 (Bosch; will need Mouser ECAD-Model fallback with explicit approval)+- DMG2305UX-7 (Diodes MOSFET)++**Keep as-is:**+- WAGO-2601-3105 (connector; STEP only is intentional — connectors don't fit the chip schema)++## Provenance audit++Most fully-bundled chips don't yet have `info.json`. We need to retrofit one per chip before publishing so chip-pads downstream knows trust tier:++- **16 chips need info.json** retrofit: AD7124-8BBCPZ, ADS1115IDGSR, ADS131M04IPBSR, ATSAMD51J20A-AUT, BQ25798RQMR, DRV8316RRGFR, ESP32-S3-WROOM-1-N4, ESP32-S3FN8, INA226AIDGSR, LM358APE4, MCF8316A1RRYR, MCXN947VNLT, PCA9685, TPS54302DDC, TPS62840YBGR, VL53L8CX+- **2 chips already have info.json**: ICE40UP5K-SG48ITR, R7FA4M1AB3CFP
LICENSEadded+21@@ -0,0 +1,21 @@+MIT License++Copyright (c) 2026 Adom Industries Inc.++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE+SOFTWARE.
README.md+45−70@@ -1,86 +1,61 @@-# adom-chip-fetcher+# chip-fetcher -**Download manufacturer-grade chip CAD bundles â 3D STEP + symbol & footprint + Fusion `.lbr` + datasheet â into a structured library, and open or ship each part straight to your EDA tool.**+Download manufacturer-grade chip CAD bundles (STEP + KiCAD symbol + KiCAD footprint + datasheet + design-help PDFs) into a structured library by driving Chrome via [pup](https://github.com/adom-inc/adom-desktop). -adom-chip-fetcher drives a real Chrome session through a manufacturer-first sourcing ladder (Manufacturer â SnapEDA â Mouser â DigiKey â Arrow â Ultra Librarian â Component Search Engine), pulls the CAD bundle for a part, validates it, and lands it in `library/<MPN>/`. A live dashboard then turns every chip into a launch-pad for the rest of the Adom EDA family. Standalone Rust â no Node, no gallia, no local CAD install (rendering goes through the shared `service-kicad`).+The defining rule: **fetch from the manufacturer's own website first** — only fall back to Mouser, DigiKey, or Component Search Engine when the manufacturer doesn't self-host CAD, and only with explicit user approval. -## The dashboard+## What it does -Your whole parts library at a glance â every chip a rich card with live thumbnails, status, stock and price, and one-tap launchers.--- **Projects** â filter the library to a working set with a project chip (`Library 53`, `nRF54L15`, â¦); the dashboard opens on the project you last activated.-- **Working-on heartbeat** â while a part is being fetched/validated, its card shows a live status badge ("rendering thumbnails", "walking the ladderâ¦") and the view auto-scrolls to it.-- **Status at a glance** â FULL/STUB, STEP·KICAD·ALTIUM·FUSION presence, lifecycle, **stock + price** (Mouser), and the validation badge per card.--### The chip thumbnail picker + source carats----Each card's **SYM** and **FP** tile carries a **â¾ source carat**. When a part was pulled from more than one place (SnapEDA · Ultra Librarian · Component Search · ds2sf-from-datasheet), tap the carat to **switch source** â the tile re-renders to that vendor's actual drawing and the choice sticks. The **3D** tiles (Zâ / Yâ) are an orientation picker, with a â¾ for all six face-up axes. Pick the part, the source, and the orientation you trust.--### Open in / Send to â the detail panel----Selecting a chip fills a **tabbed panel** (Actions · Details · Files â so the long file list never dominates):--- **Open in** â `adom-symbol`, `adom-footprint`, `adom-lbr`, `adom-3D`, `chipsmith`, `adom-ds2sf extraction`. Opens in **your** browser via `window.open`, so it works on a phone, in a pup window, or a Hydrogen webview â and a toast tells you exactly how it opened.-- **Send to** â KiCad (native symbol + footprint), Fusion 360 (real `.lbr`), Altium / OrCAD (`.lbr` download + import steps; native exporters on the roadmap).--### Zoom â the focus view----Tap **ð** on any card to zoom it full-screen (with an â to return) â the focus view carries the part's tiles, carats, **Open in**, and **Send to**, so on a phone everything for one chip is in one place.+```bash+chip-fetcher fetch TPS62840 # opens the TI page → UL deep-link → drives the form → auto-pulls+chip-fetcher pull '<windows-path>' # pulls a downloaded zip from the user's desktop+chip-fetcher import path/to/zip --mpn # absorbs a downloaded file into library/<MPN>/+chip-fetcher list # shows what's in library/ vs missing per slot+chip-fetcher login <source> # opens a vendor login page in the chip-fetcher pup profile+chip-fetcher install # deploys SKILL.md to ~/.claude/skills/chip-fetcher/+``` -## The source-site skill pack+After running, each chip lands in: -adom-chip-fetcher's per-vendor navigation knowledge lives **in this repo** as an extensible skill pack (`skills/`) â one installable skill per site, documenting how to reach the CAD download **and how to recover when a site changes or blocks automation**. The crawler is the binary; the *how-to-drive-each-site* layer is community-owned.+```+library/<MPN>/+├── <MPN>.step # rich manufacturer STEP, named PRODUCTs (Body, ThermalPin, PinsArrayLR, PinsArrayTB)+├── <MPN>.kicad_sym+├── <MPN>.kicad_mod+├── <MPN>.pdf # datasheet+├── <MPN>-errata-*.pdf+├── <MPN>-appnote-*.pdf+├── <MPN>-hwguide.pdf+└── info.json # provenance trail (mfr-direct vs mouser-fallback)+``` -### Skills shipped today+## Verified vendors -| Site | Skill | Covers |+| Vendor | Path | Notes | |---|---|---|-| **SnapEDA / SnapMagic** | `sites/snapeda` | login + multi-format download (the canonical template) |-| **DigiKey** | `sites/digikey` | product-page CAD **+ the "pup is blocked" recovery playbook** |-| **Mouser** | `sites/mouser` | CAD + the authoritative stock/price/lifecycle source |-| **Ultra Librarian / CSE** | `sites/ultralibrarian` | the format-picker flow many vendor "ECAD model" buttons embed |-| **Manufacturer (generic)** | `sites/manufacturer` | the top-rung approach for any maker's product page |-| **Texas Instruments** | `sites/ti` | ti.com CAD section (Ultra Librarian embed + native STEP) |-| **STMicroelectronics** | `sites/st` | st.com CAD Resources tab behind myST |-| **NXP** | `sites/nxp` | nxp.com Design Resources + export-control click-through |--### Sites we still need skills for â **volunteer one!**--The community uses far more sources than we've covered. **If a site you use isn't listed above, you can add it** â these are the gaps we'd most love help on:--- **Manufacturers:** Microchip · Analog Devices · Infineon · onsemi · Renesas · Nordic · Espressif · Diodes Inc · Vishay · Murata · TDK · Bosch Sensortec-- **Distributors / aggregates:** Arrow · LCSC / EasyEDA · Newark/Farnell · RS · Octopart · SamacSys/Component Search Engine-- **Anything else** the electronics community pulls CAD from.+| Texas Instruments | `vendor.ultralibrarian.com/TI/embedded/...` | mfr-direct via UL |+| STMicroelectronics | `vendor.ultralibrarian.com/stmicro/embedded/...` | mfr-direct via UL |+| Analog Devices | `vendor.ultralibrarian.com/adi/embedded/...` | mfr-direct via UL |+| Microchip | `vendor.ultralibrarian.com/microchip/...` | mfr-direct via UL (iframe) |+| NXP | `nxp.com` (BXL) + `nxp.componentsearchengine.com` (STEP) | mfr-direct |+| Espressif | Mouser ECAD-Model fallback | mfr-uploaded to SamacSys |+| Lattice | Mouser ECAD-Model fallback | mfr-uploaded to SamacSys |+| Renesas | Mouser ECAD-Model fallback | mfr-uploaded to SamacSys | -If adom-chip-fetcher gets stuck on a site, or you know a site it doesn't cover, the best contribution you can make is a **site skill** â and the day a vendor changes its layout or blocks automation, the freshest skill wins.+See [SKILL.md](https://wiki-ufypy5dpx93o.adom.cloud/apps/chip-fetcher) for the full skill documentation, vendor URL patterns, and the failure-mode notes captured from real Adom-user feedback. -### How to make + contribute a site skill+## Install -1. **Copy the template** â `sites/snapeda/SKILL.md`. Fill in the five sections: **Reach** (the product-page URL pattern), **Gate** (login/region/cookie wall + which creds key), **Download flow** (the exact path to the CAD bundle), **Catch** (what the artifact is + where it lands), and **When it breaks** (the failure signatures + the recovery playbook â the highest-value part).-2. **Give it real trigger words** (the vendor name, "blocked", "captcha", the file types) so the AI surfaces it at the right moment.-3. **Contribute it** â two paths, the maintainer's call per contribution (full detail in `skills/CONTRIBUTING.md`):- - **(A) PR into the pack** *(vetted â the default)* â open a PR adding `sites/<vendor>/SKILL.md`; the maintainer reviews + merges and it ships with the pack.- - **(B) Linked external skill** *(trusted â opt-in)* â own the skill in **your own wiki repo** (you `adompkg publish` updates whenever you like) and PR a one-line **link** into the pack; it self-updates without a maintainer round-trip â ideal for fast-moving vendor sites.-4. **Security:** "getting past a block" means navigating the site as a human would (the right warm profile, human pacing, the path the site's own assistant suggests) â **never** defeating CAPTCHAs or security controls. Credentials come from `adom-chip-fetcher creds`, never inline.--Either way, your AI â and everyone else's â gets smarter the next time that site comes up.--## Part of the Adom EDA family+```bash+curl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/chip-fetcher/chip-fetcher \+ -o /usr/local/bin/chip-fetcher && chmod +x /usr/local/bin/chip-fetcher && chip-fetcher install+``` -adom-chip-fetcher is the hub; it hands parts to the standalone apps it ships alongside â **adom-symbol**, **adom-footprint**, **adom-lbr**, and **adom-ds2sf**.+## Companion specs -## Run it+Two adom-desktop fix-it specs live in [scripts/](scripts/): -```bash-adom-chip-fetcher serve # the live dashboard (AI-drivable: /api/library, /api/open, â¦)-adom-chip-fetcher fetch <MPN> # source a part into the library-adom-chip-fetcher sources # print the canonical sourcing ladder-```+- `adom-desktop-pullfile-speed-prompt.md` — streaming binary WS frames + HTTP fallback so 50+ MB files (NXP reference manuals) don't time out+- `adom-desktop-credential-vault-prompt.md` — DPAPI/Keychain-encrypted credential vault hooked into `puppeteer.page.authenticate()` so basic-auth popups never appear -MIT, contributions welcome â start with a **site skill** (above).+Paste either into Claude Code on the desktop to implement.
SKILL.md+103−103@@ -1,50 +1,50 @@ ----name: adom-chip-fetcher-description: Download manufacturer-grade chip CAD bundles (STEP + KiCad symbol + KiCad footprint + Altium IntLib + Fusion .lbr + datasheet) into a structured library by driving Chrome via pup on the user's desktop. Manufacturer-first sourcing ladder, then SnapMagic â Mouser â DigiKey â Arrow â CSE/UL â LCSC (LCSC is last resort, avoid). Handles NRND/EOL lifecycle and multi-package variants. Use when the user wants to fetch a chip's 3D model, build a chip test library, source CAD files for a part, grab STEP files, populate a chip-pads test set, or "fetch <MPN>". Trigger words â adom-chip-fetcher, fetch chip, chip cad, chip step, chip footprint, fetch step file, snapeda fetch, snapmagic fetch, altium intlib, fusion 360 lbr, ultralibrarian fetch, mouser fetch, digikey fetch, nrnd alternate, ti cad download, st cad download, nxp cad download, manufacturer chip step, mpn fetch.+name: chip-fetcher+description: Download manufacturer-grade chip CAD bundles (STEP + KiCad symbol + KiCad footprint + Altium IntLib + Fusion .lbr + datasheet) into a structured library by driving Chrome via pup on the user's desktop. Manufacturer-first sourcing ladder, then SnapMagic → Mouser → DigiKey → Arrow → CSE/UL → LCSC (LCSC is last resort, avoid). Handles NRND/EOL lifecycle and multi-package variants. Use when the user wants to fetch a chip's 3D model, build a chip test library, source CAD files for a part, grab STEP files, populate a chip-pads test set, or "fetch <MPN>". Trigger words — chip-fetcher, fetch chip, chip cad, chip step, chip footprint, fetch step file, snapeda fetch, snapmagic fetch, altium intlib, fusion 360 lbr, ultralibrarian fetch, mouser fetch, digikey fetch, nrnd alternate, ti cad download, st cad download, nxp cad download, manufacturer chip step, mpn fetch. --- -# adom-chip-fetcher+# chip-fetcher -Rust CLI at `/home/adom/project/adom-chip-fetcher/`. Pulls manufacturer-grade chip bundles (STEP + `.kicad_sym` + `.kicad_mod` + Altium `.IntLib` + Fusion `.lbr` + datasheet PDFs) from the manufacturer's site first, then SnapEDA â Mouser â DigiKey â Arrow â CSE/UL â **LCSC (last resort)**, using a persistent pup Chrome profile. Yanks the downloaded zip from the user's desktop via `adom-desktop pull_file`. Run `adom-chip-fetcher sources` any time to print the canonical priority order â single source of truth for the ladder.+Rust CLI at `/home/adom/project/chip-fetcher/`. Pulls manufacturer-grade chip bundles (STEP + `.kicad_sym` + `.kicad_mod` + Altium `.IntLib` + Fusion `.lbr` + datasheet PDFs) from the manufacturer's site first, then SnapEDA → Mouser → DigiKey → Arrow → CSE/UL → **LCSC (last resort)**, using a persistent pup Chrome profile. Yanks the downloaded zip from the user's desktop via `adom-desktop pull_file`. Run `chip-fetcher sources` any time to print the canonical priority order — single source of truth for the ladder. ## Bones ```-adom-chip-fetcher/-âââ src/ # Cargo CLI-â âââ main.rs · cli.rs · pup.rs · library.rs · import.rs · creds.rs · inventory.rs · serve.rs-â âââ sources/ti.rs # one source plugin so far-â âââ ui/index.html # viewer-âââ library/<MPN>/ # final home for each part's bundle-â âââ <MPN>.step # rich manufacturer STEP-â âââ <MPN>.kicad_mod # ground-truth pad data-â âââ <MPN>.kicad_sym # pin names-â âââ <MPN>.IntLib # Altium (when SnapEDA fetched)-â âââ <MPN>-fusion.lbr # Fusion 360 / Eagle (when SnapEDA fetched)-â âââ <MPN>.pdf # datasheet-â âââ stock.json # Mouser stock + lifecycle + variants-â âââ info.json # provenance tags + chipsmith handoff fields (footprint_name, kicad_3d_name, package_kind, pin_count, body_dims_mm, has_ep)-âââ playbooks/ # deep reference â Read on demand (see "Playbook index")-âââ incoming/ # staging for files pulled from desktop+chip-fetcher/+├── src/ # Cargo CLI+│ ├── main.rs · cli.rs · pup.rs · library.rs · import.rs · creds.rs · inventory.rs · serve.rs+│ ├── sources/ti.rs # one source plugin so far+│ └── ui/index.html # viewer+├── library/<MPN>/ # final home for each part's bundle+│ ├── <MPN>.step # rich manufacturer STEP+│ ├── <MPN>.kicad_mod # ground-truth pad data+│ ├── <MPN>.kicad_sym # pin names+│ ├── <MPN>.IntLib # Altium (when SnapEDA fetched)+│ ├── <MPN>-fusion.lbr # Fusion 360 / Eagle (when SnapEDA fetched)+│ ├── <MPN>.pdf # datasheet+│ ├── stock.json # Mouser stock + lifecycle + variants+│ └── info.json # provenance tags + chipsmith handoff fields (footprint_name, kicad_3d_name, package_kind, pin_count, body_dims_mm, has_ep)+├── playbooks/ # deep reference — Read on demand (see "Playbook index")+└── incoming/ # staging for files pulled from desktop ``` ## Commands ```bash-adom-chip-fetcher fetch <MPN> # opens vendor product page â drives the source's flow-adom-chip-fetcher pull <win-path> --mpn # pulls a Windows file via adom-desktop, imports-adom-chip-fetcher import <path> [--mpn] # absorbs a downloaded file/zip into library/<MPN>/-adom-chip-fetcher login <source> # opens a source's login page in the adom-chip-fetcher pup profile-adom-chip-fetcher login all # opens every source as separate tabs-adom-chip-fetcher login list # catalog of known sources-adom-chip-fetcher list # what's in library/ vs missing per slot-adom-chip-fetcher status <MPN> # show step/mod/sym/pdf for one MPN-adom-chip-fetcher app # open the viewer in a Hydrogen webview tab-adom-chip-fetcher serve # run the viewer HTTP server-adom-chip-fetcher install # (re-)deploy SKILL.md + playbooks/+chip-fetcher fetch <MPN> # opens vendor product page → drives the source's flow+chip-fetcher pull <win-path> --mpn # pulls a Windows file via adom-desktop, imports+chip-fetcher import <path> [--mpn] # absorbs a downloaded file/zip into library/<MPN>/+chip-fetcher login <source> # opens a source's login page in the chip-fetcher pup profile+chip-fetcher login all # opens every source as separate tabs+chip-fetcher login list # catalog of known sources+chip-fetcher list # what's in library/ vs missing per slot+chip-fetcher status <MPN> # show step/mod/sym/pdf for one MPN+chip-fetcher app # open the viewer in a Hydrogen webview tab+chip-fetcher serve # run the viewer HTTP server+chip-fetcher install # (re-)deploy SKILL.md + playbooks/ ``` -### Programmatic-rule subcommands â these enforce the skill rules in code+### Programmatic-rule subcommands — these enforce the skill rules in code The AI's job is to call these instead of remembering rules. Each one returns either a clean status or a `HINT:` line telling you what to do@@ -52,7 +52,7 @@ next, so the workflow becomes mechanical: the binary remembers, you ask it. ```bash-adom-chip-fetcher heartbeat <MPN> "<stage>"+chip-fetcher heartbeat <MPN> "<stage>" # Side-effects: creates a stub card on first call, auto-scrolls the # dashboard pup window so the active card is centered + highlighted, # auto-records source attempts in info.json.fetched_via_chain (parses@@ -60,121 +60,121 @@ adom-chip-fetcher heartbeat <MPN> "<stage>" # WARN per missing artifact, prints library-wide WARN with N/Total # incomplete count, and warns when stage phases skip ahead. -adom-chip-fetcher mfr-probe <MPN> [--no-open]+chip-fetcher mfr-probe <MPN> [--no-open] # Detects manufacturer from MPN prefix (table in src/sourcing.rs), # opens their canonical product/search URL in the chip-fetcher-scrape # pup session, records the attempt on the chain. Run BEFORE any- # third-party source â the mfr-first rule is enforced here.+ # third-party source — the mfr-first rule is enforced here. -adom-chip-fetcher next-source <MPN>+chip-fetcher next-source <MPN> # Reads info.json.fetched_via_chain and prints the next ladder step # that hasn't been tried, with a HINT for what command to run. LCSC # is only suggested when every prior step has a recorded failure- # outcome â code refuses to skip ahead.+ # outcome — code refuses to skip ahead. -adom-chip-fetcher sources+chip-fetcher sources # Print the canonical priority order. Single source of truth for # "what's the right ladder?" -adom-chip-fetcher gate [--strict] [--mpn <MPN>]+chip-fetcher gate [--strict] [--mpn <MPN>] # Hard gate. Exit 1 if any card lacks PDF + STEP + .kicad_sym + .kicad_mod # (or also Altium + Fusion in --strict). Prints HINT lines per missing # artifact. **Run this BEFORE reporting "done" on a batch.** Datasheet- # only cards count as incomplete stubs, not done. -adom-chip-fetcher selfcheck # dashboard up + heartbeat fresh + pup session live-adom-chip-fetcher hint # next action based on current heartbeat state+chip-fetcher selfcheck # dashboard up + heartbeat fresh + pup session live+chip-fetcher hint # next action based on current heartbeat state -adom-chip-fetcher project create <name> # create a named project (groups chips for a board/BOM)-adom-chip-fetcher project list # list all projects-adom-chip-fetcher project show <name> # show MPNs in a project-adom-chip-fetcher project add <name> <MPN> # add an MPN to a project-adom-chip-fetcher project remove <name> <MPN> # remove an MPN from a project-adom-chip-fetcher project activate <name> # set the active project (dashboard filters to it)-adom-chip-fetcher project deactivate # clear the active project filter-adom-chip-fetcher project delete <name> # delete a project+chip-fetcher project create <name> # create a named project (groups chips for a board/BOM)+chip-fetcher project list # list all projects+chip-fetcher project show <name> # show MPNs in a project+chip-fetcher project add <name> <MPN> # add an MPN to a project+chip-fetcher project remove <name> <MPN> # remove an MPN from a project+chip-fetcher project activate <name> # set the active project (dashboard filters to it)+chip-fetcher project deactivate # clear the active project filter+chip-fetcher project delete <name> # delete a project -adom-chip-fetcher fetch <MPN> --project <name> # fetch and auto-add to a project+chip-fetcher fetch <MPN> --project <name> # fetch and auto-add to a project ``` Side-effect docs that matter: -- `adom-chip-fetcher import <file>` does **PDF magic-byte validation** (rejects HTML masquerading as PDF â the classic "naked curl returned a 174 KB CDN denial page that looks like 200 OK" footgun) AND a STEP `ISO-10303-21` header check AND KiCad sym/mod sanity-check. Imports that fail magic-byte refuse the file with a clear `ERROR:`.-- `adom-chip-fetcher import` also writes a per-MPN `<MPN>-fetch-log.json` audit trail (timestamp, source path, target name, size, magic-byte status) every time a file lands. Survives across sessions; the AI doesn't have to remember to write provenance.+- `chip-fetcher import <file>` does **PDF magic-byte validation** (rejects HTML masquerading as PDF — the classic "naked curl returned a 174 KB CDN denial page that looks like 200 OK" footgun) AND a STEP `ISO-10303-21` header check AND KiCad sym/mod sanity-check. Imports that fail magic-byte refuse the file with a clear `ERROR:`.+- `chip-fetcher import` also writes a per-MPN `<MPN>-fetch-log.json` audit trail (timestamp, source path, target name, size, magic-byte status) every time a file lands. Survives across sessions; the AI doesn't have to remember to write provenance. -## Critical rules â these always apply+## Critical rules — these always apply -0. **Dashboard is the user's home base.** Before any scraping, open the adom-chip-fetcher viewer as the user's preferred surface â **pup window OR Hydrogen webview, per their preference** (check `user_profile.md` for `chip_fetcher_dashboard_surface`, ask if unknown, save the answer). Laptop-screen users tend to prefer pup; big-monitor users tend to prefer webview. Whichever surface, never navigate it mid-batch. **Scraping prefers the user's REAL signed-in browser via the native-browser bridge** (`nbrowser_*`, the `adom-browser-extension`) â open ONE isolated window (`sessionId: "adom-chip-fetcher"`) and manage every vendor site as a TAB inside it, so you never touch the user's other Chrome/Edge windows. The native browser carries their real vendor logins + captcha human-trust and bypasses bot-walls (**DigiKey hard-blocks pup**). **Fall back to pup `sessionId: "adom-chip-fetcher"`** (cold `profile: "adom-chip-fetcher"`) only when the bridge is absent â and tell the user why native is better + point them to https://wiki.adom.inc/adom/adom-browser-extension. The binary auto-detects and announces the surface; full strategy + per-site playbooks in the **`chip-sourcing`** skill. Scraping stays separate from the dashboard. Heartbeat every stage transition via `/api/activity` so the dashboard reflects live state. **Read [`playbooks/dashboard-flow.md`](playbooks/dashboard-flow.md) before any batch fetch** â it has the dual-surface dashboard pattern, the heartbeat protocol, and the canonical stage labels.+0. **Dashboard is the user's home base.** Before any scraping, open the chip-fetcher viewer as the user's preferred surface — **pup window OR Hydrogen webview, per their preference** (check `user_profile.md` for `chip_fetcher_dashboard_surface`, ask if unknown, save the answer). Laptop-screen users tend to prefer pup; big-monitor users tend to prefer webview. Whichever surface, never navigate it mid-batch. **Scraping prefers the user's REAL signed-in browser via the native-browser bridge** (`nbrowser_*`, the `adom-browser-extension`) — open ONE isolated window (`sessionId: "chip-fetcher"`) and manage every vendor site as a TAB inside it, so you never touch the user's other Chrome/Edge windows. The native browser carries their real vendor logins + captcha human-trust and bypasses bot-walls (**DigiKey hard-blocks pup**). **Fall back to pup `sessionId: "chip-fetcher"`** (cold `profile: "chip-fetcher"`) only when the bridge is absent — and tell the user why native is better + point them to https://wiki.adom.inc/adom/adom-browser-extension. The binary auto-detects and announces the surface; full strategy + per-site playbooks in the **`chip-sourcing`** skill. Scraping stays separate from the dashboard. Heartbeat every stage transition via `/api/activity` so the dashboard reflects live state. **Read [`playbooks/dashboard-flow.md`](playbooks/dashboard-flow.md) before any batch fetch** — it has the dual-surface dashboard pattern, the heartbeat protocol, and the canonical stage labels. 1. **Manufacturer-first.** Stay on `<vendor>.com` and exhaust their CAD/Documentation/Resources/Tools tabs before bouncing to a third-party. Don't go to `3dfindit.com`, `snapmagic.com`, `mouser.componentsearchengine.com`, or any aggregator unless the manufacturer's own page links there explicitly. Read [`playbooks/sourcing-ladder.md`](playbooks/sourcing-ladder.md) for the full ladder + escape rules. 2. **Always ask "do you have a login here?"** before showing a vendor signup form. Most EEs already have SnapEDA/CSE/UL/Mouser/DigiKey/NXP/TI/ST/Microchip accounts. Read [`playbooks/credentials-and-logins.md`](playbooks/credentials-and-logins.md) for the catalog + the credential vault.-3. **NRND/EOL/Last-Time-Buy is a HARD STOP for adom-chip-fetcher's own picks.** If the user explicitly named the MPN, fetch + warn. If adom-chip-fetcher chose it, pick an active replacement instead. Always store `lifecycle` + `lifecycle_warning` in `stock.json`.-4. **Track Mouser stock + pricing as first-class data.** Every chip in `library/<MPN>/` should have `stock.json` with current Mouser snapshot. Multi-package variants go under `variants[]` â don't collapse to the highest-stock one.-4a. **Persist chipsmith handoff fields in `info.json`.** Every fetch MUST write these fields when the source provides them (which is almost always â the upstream library page literally names them):- - `footprint_name` â KiCad library path, e.g. `Package_QFN/QFN-32-1EP_5x5mm_P0.5mm_EP3.7x3.7mm`. Comes from SnapEDA / SnapMagic / Ultra Librarian / Component Search Engine / Mouser CAD / DigiKey CAD download pages â every one of those exposes the canonical KiCad footprint name.- - `kicad_3d_name` â KiCad packages3d STEP path, e.g. `Package_QFN.3dshapes/QFN-32-1EP_5x5mm_P0.5mm_EP3.7x3.7mm`. Same upstream sources.- - `package_kind` â canonical short tag (`SOIC` / `QFN` / `QFP` / `LGA` / `BGA` / `DIP` / `SOT` / `TO` / `USB-C` / etc.) derived from `package_family` via regex normalize.- - `pin_count` â manufacturer datasheet pin-table OR distributor parametric "Number of Terminations" field.- - `body_dims_mm` â from the datasheet mechanical drawing page; nominal `[X, Y, Z]` mm.- - `has_ep` â boolean from the datasheet ("Exposed Pad" / "EPAD" / "Thermal Pad" mention in pin table).- - `expected_pitch_mm` â datasheet mechanical drawing.- These are what `adom-chipsmith` (the chip detection + footprint validator + sign-off app) consumes to drive its pin-detection + measurement-rescale + KiCad stock-library cross-check. The KiCad library is footprint-keyed and 3D-name-keyed â NOT MPN-keyed â so there is no MPNâKiCad-library lookup we can do downstream. **adom-chip-fetcher is the ONLY layer that has access to the upstream page where the footprint name was named.** If adom-chip-fetcher doesn't capture them, no downstream tool can recover them.-5. **adom-chip-fetcher does ALL the clicking.** Never ask the user to click Download / Allow / dismiss a banner. If a click looks impossible, find a way (CDP permission grant, trusted-click via `browser_input_dispatch`, OS-level workaround). Reaching for "user, please click X" is a skill bug. Read [`playbooks/handoff-and-watching.md`](playbooks/handoff-and-watching.md) for the escape valve.+3. **NRND/EOL/Last-Time-Buy is a HARD STOP for chip-fetcher's own picks.** If the user explicitly named the MPN, fetch + warn. If chip-fetcher chose it, pick an active replacement instead. Always store `lifecycle` + `lifecycle_warning` in `stock.json`.+4. **Track Mouser stock + pricing as first-class data.** Every chip in `library/<MPN>/` should have `stock.json` with current Mouser snapshot. Multi-package variants go under `variants[]` — don't collapse to the highest-stock one.+4a. **Persist chipsmith handoff fields in `info.json`.** Every fetch MUST write these fields when the source provides them (which is almost always — the upstream library page literally names them):+ - `footprint_name` — KiCad library path, e.g. `Package_QFN/QFN-32-1EP_5x5mm_P0.5mm_EP3.7x3.7mm`. Comes from SnapEDA / SnapMagic / Ultra Librarian / Component Search Engine / Mouser CAD / DigiKey CAD download pages — every one of those exposes the canonical KiCad footprint name.+ - `kicad_3d_name` — KiCad packages3d STEP path, e.g. `Package_QFN.3dshapes/QFN-32-1EP_5x5mm_P0.5mm_EP3.7x3.7mm`. Same upstream sources.+ - `package_kind` — canonical short tag (`SOIC` / `QFN` / `QFP` / `LGA` / `BGA` / `DIP` / `SOT` / `TO` / `USB-C` / etc.) derived from `package_family` via regex normalize.+ - `pin_count` — manufacturer datasheet pin-table OR distributor parametric "Number of Terminations" field.+ - `body_dims_mm` — from the datasheet mechanical drawing page; nominal `[X, Y, Z]` mm.+ - `has_ep` — boolean from the datasheet ("Exposed Pad" / "EPAD" / "Thermal Pad" mention in pin table).+ - `expected_pitch_mm` — datasheet mechanical drawing.+ These are what `adom-chipsmith` (the chip detection + footprint validator + sign-off app) consumes to drive its pin-detection + measurement-rescale + KiCad stock-library cross-check. The KiCad library is footprint-keyed and 3D-name-keyed — NOT MPN-keyed — so there is no MPN→KiCad-library lookup we can do downstream. **chip-fetcher is the ONLY layer that has access to the upstream page where the footprint name was named.** If chip-fetcher doesn't capture them, no downstream tool can recover them.+5. **chip-fetcher does ALL the clicking.** Never ask the user to click Download / Allow / dismiss a banner. If a click looks impossible, find a way (CDP permission grant, trusted-click via `browser_input_dispatch`, OS-level workaround). Reaching for "user, please click X" is a skill bug. Read [`playbooks/handoff-and-watching.md`](playbooks/handoff-and-watching.md) for the escape valve. 6. **When you hand off a click, ALWAYS watch for the resulting state change.** Never end with "tell me when you're done." Arm a Monitor that polls for the post-click signal (token populates, button enables, file lands) and auto-continues. The user only ever does one click.-7. **EDA-agnostic terminology in user-facing surfaces.** Files on disk are `.kicad_sym` / `.kicad_mod`, but UI labels are "SYMBOL" / "FOOTPRINT" â never "MOD." Adom supports KiCad, Fusion 360, Altium, OrCAD, EAGLE.-8. **Use `profile:"adom-chip-fetcher"` and only that.** Never rotate profile names â that nukes vendor logins. Bust caches with `?v=$(date +%s)`, never with a new profile.-9. **Browse as a real user.** Pre-warm with a referrer chain, add 2â5s random delays, scroll the page, move the mouse. Burst-clicking 5 product pages in 30s gets you flagged. Naked `curl` to a bot-walled CDN returns a 174 KB HTML denial page that looks like 200 OK â always validate `head -c 4 file | grep '%PDF'`.-10. **Auto-accept every cookie banner, every page, every time.** First thing adom-chip-fetcher does on any new page. Walks shadow DOM. Accept All â never Decline / Manage / Save settings.+7. **EDA-agnostic terminology in user-facing surfaces.** Files on disk are `.kicad_sym` / `.kicad_mod`, but UI labels are "SYMBOL" / "FOOTPRINT" — never "MOD." Adom supports KiCad, Fusion 360, Altium, OrCAD, EAGLE.+8. **Use `profile:"chip-fetcher"` and only that.** Never rotate profile names — that nukes vendor logins. Bust caches with `?v=$(date +%s)`, never with a new profile.+9. **Browse as a real user.** Pre-warm with a referrer chain, add 2–5s random delays, scroll the page, move the mouse. Burst-clicking 5 product pages in 30s gets you flagged. Naked `curl` to a bot-walled CDN returns a 174 KB HTML denial page that looks like 200 OK — always validate `head -c 4 file | grep '%PDF'`.+10. **Auto-accept every cookie banner, every page, every time.** First thing chip-fetcher does on any new page. Walks shadow DOM. Accept All — never Decline / Manage / Save settings. 11. **Don't pile up pup tabs.** Reuse the existing tab. Close stale tabs after a download. Only open a new tab when you genuinely need two pages side-by-side.-11b. **NEVER create a pup session with a profile other than `adom-chip-fetcher`.** The user's vendor login cookies (SnapEDA, UL, CSE, TI, ST, Mouser, DigiKey) live in the `adom-chip-fetcher` Chrome profile. Creating `cf-pdf-grab`, `chip-fetcher-dash`, or ANY other profile name starts a FRESH Chrome with NO cookies â silently losing every credential the user entered in previous sessions. This is a critical bug that has already burned the user. ALL adom-chip-fetcher pup work MUST use `profile: "adom-chip-fetcher"`. The `sessionId` can vary (`adom-chip-fetcher`, `chip-fetcher-scrape`) but the `profile` MUST be `adom-chip-fetcher`.-11a. **Always hard-reload the pup dashboard after deploying new code.** After any `cargo build + cp` or version bump that changes `src/ui/index.html`, the pup window is still serving the OLD cached HTML. Run `browser_eval("window.location.reload(true)")` on the dashboard session BEFORE taking screenshots or showing the user. Without this, every "show me" screenshot is stale UI and the user sees the old version â exactly the bug that wastes their time verifying the wrong thing.+11b. **NEVER create a pup session with a profile other than `chip-fetcher`.** The user's vendor login cookies (SnapEDA, UL, CSE, TI, ST, Mouser, DigiKey) live in the `chip-fetcher` Chrome profile. Creating `cf-pdf-grab`, `chip-fetcher-dash`, or ANY other profile name starts a FRESH Chrome with NO cookies — silently losing every credential the user entered in previous sessions. This is a critical bug that has already burned the user. ALL chip-fetcher pup work MUST use `profile: "chip-fetcher"`. The `sessionId` can vary (`chip-fetcher`, `chip-fetcher-scrape`) but the `profile` MUST be `chip-fetcher`.+11a. **Always hard-reload the pup dashboard after deploying new code.** After any `cargo build + cp` or version bump that changes `src/ui/index.html`, the pup window is still serving the OLD cached HTML. Run `browser_eval("window.location.reload(true)")` on the dashboard session BEFORE taking screenshots or showing the user. Without this, every "show me" screenshot is stale UI and the user sees the old version — exactly the bug that wastes their time verifying the wrong thing. 12. **When stuck on a vendor's flow, show the user the product page and let them teach you.** Foreground pup, screenshot, ask one specific question, wait, implement their answer, then capture the pattern back into the right playbook so the next agent benefits.-13. **NEVER claim "done" while stub / empty cards exist on the dashboard.** Run `adom-chip-fetcher gate` before reporting completion â it returns exit 1 if ANY card lacks PDF + STEP + `.kicad_sym` + `.kicad_mod`, and prints `HINT:` lines per missing artifact. Datasheet-only cards = stubs, not done. Every `adom-chip-fetcher heartbeat` ALSO surfaces `WARN: card <MPN> is INCOMPLETE â missing: ...` plus a library-wide `WARN: N/Total cards still incomplete` count â read those WARNs before saying "X of Y done." If the ladder is genuinely exhausted (no STEP exists anywhere on Mfr / SnapMagic / Mouser / DigiKey / Arrow / CSE), explicitly record that in `info.json.fetched_via_chain` and surface "ladder exhausted" to the user â don't silently abandon the card and call it shipped.+13. **NEVER claim "done" while stub / empty cards exist on the dashboard.** Run `chip-fetcher gate` before reporting completion — it returns exit 1 if ANY card lacks PDF + STEP + `.kicad_sym` + `.kicad_mod`, and prints `HINT:` lines per missing artifact. Datasheet-only cards = stubs, not done. Every `chip-fetcher heartbeat` ALSO surfaces `WARN: card <MPN> is INCOMPLETE — missing: ...` plus a library-wide `WARN: N/Total cards still incomplete` count — read those WARNs before saying "X of Y done." If the ladder is genuinely exhausted (no STEP exists anywhere on Mfr / SnapMagic / Mouser / DigiKey / Arrow / CSE), explicitly record that in `info.json.fetched_via_chain` and surface "ladder exhausted" to the user — don't silently abandon the card and call it shipped. ## STEP + Footprint = matched pair (CRITICAL) -When adom-chip-fetcher sources a STEP + footprint bundle (e.g. from SnapMagic), both files are a matched pair. Downstream tools **MUST** use both together -- never pair a adom-chip-fetcher STEP with a tscircuit footprint or vice versa. The `file_provenance` JSON tracks which source each file came from so mismatches can be detected.+When chip-fetcher sources a STEP + footprint bundle (e.g. from SnapMagic), both files are a matched pair. Downstream tools **MUST** use both together -- never pair a chip-fetcher STEP with a tscircuit footprint or vice versa. The `file_provenance` JSON tracks which source each file came from so mismatches can be detected. **Why:** The SnapMagic footprint has pad positions, mounting holes, and courtyard geometry designed to match the SnapMagic STEP model exactly. A tscircuit-exported footprint for the same component has different pad positions. Using the wrong combination causes the 3D model to be misaligned with the pads -- even though each file is individually correct. -**Enforcement:** When `adom-chip-fetcher gate` or `adom-chip-fetcher status` reports a component as complete, the .kicad_mod and .step in `library/<MPN>/` are guaranteed to be from the same source. Any downstream tool (board-building-pipeline, kicad-3d-models) that substitutes a different footprint breaks this guarantee.+**Enforcement:** When `chip-fetcher gate` or `chip-fetcher status` reports a component as complete, the .kicad_mod and .step in `library/<MPN>/` are guaranteed to be from the same source. Any downstream tool (board-building-pipeline, kicad-3d-models) that substitutes a different footprint breaks this guarantee. -## Playbook index â Read on demand+## Playbook index — Read on demand -Each playbook is a focused deep-dive. Read the relevant one when the situation matches. They live at `~/.claude/skills/adom-chip-fetcher/playbooks/<name>.md` after `adom-chip-fetcher install`.+Each playbook is a focused deep-dive. Read the relevant one when the situation matches. They live at `~/.claude/skills/chip-fetcher/playbooks/<name>.md` after `chip-fetcher install`. | Read this | When | |-----------|------|-| [`playbooks/dashboard-flow.md`](playbooks/dashboard-flow.md) | **Read before any batch fetch** â adom-chip-fetcher's UI is the user's home base. Two-pup-session pattern (dashboard + scraping), activity-heartbeat protocol, canonical stage labels (`probing <vendor>.com` / `downloading <format>` / `imported` / `stuck â <reason>`), opening the dashboard window, when NOT to navigate it, antipatterns. |-| [`playbooks/sourcing-ladder.md`](playbooks/sourcing-ladder.md) | Starting a new MPN. Covers the full ladder (manufacturer â SnapEDA â Mouser â DigiKey â UL/CSE), the manufacturer-first ironclad rule, the NRND/EOL hard stop, and the multi-variant `stock.json` schema. |-| [`playbooks/manufacturer-direct.md`](playbooks/manufacturer-direct.md) | You've identified the manufacturer. Covers self-hosting matrix, vendor URL + UL deep-link patterns, Microchip iframe gotcha, NXP product-page detail (BXL + 3D icon + EXTENDED PROFILE), Lattice 30-PDF Documentation tab, manufacturer reference-layout zips â chip CAD, and `info.json` provenance tags. |-| [`playbooks/ultra-librarian.md`](playbooks/ultra-librarian.md) | Any UL flow â embedded captcha or UL Pro logged-in. Three flows: (1) `vendor.ultralibrarian.com/<slug>/embedded` â captcha gauntlet, driven by `cf-ul-fast.sh`; (2) `app.ultralibrarian.com/details/...?uid=...` (logged in) â captcha-free tick-and-click via `cf-ul-pro-batch.sh` (the modern fast-path: sign in once via `adom-chip-fetcher login ultralib`, then no captcha for 30 days); (3) anonymous UL Pro detail pages â fall back to embedded captcha. Also covers the **two-click BUTTON-then-A.export-trigger pattern** that's bug-prone, the canonical-four EDA-format pick, watcher race-safety, and per-package coverage caveats. |-| [`playbooks/snapmagic.md`](playbooks/snapmagic.md) | Manufacturer doesn't host CAD (Nordic, niche connectors, smaller MCUs) and SnapEDA covers it. Covers login (`form.submit()` not button-click), part-page URL, the per-account `preferred_2d_format` AJAX flow, the "Heads Up" multi-section warning, the KiCad V3/V4/V6 sub-picker, full 3-format cycle (KiCad â Altium â Fusion), filename map, and InstaBuild AI-generated parts. |-| [`playbooks/mouser-cse.md`](playbooks/mouser-cse.md) | Manufacturer doesn't host CAD and SnapEDA didn't cover it â Mouser has the ECAD Model + datasheet. Covers Mouser CSE Library Loader, the **HARD RULE that CSE downloads use HTTP Basic Auth** (form-login session cookie is useless for downloads â embed creds inline as `https://user:pass@host/...`), `cf-cse-batch.sh` for batch fetching with inline creds, slide-to-verify escalation, CSE single-login covering all subdomains, and Chrome's "wants to Download multiple files" silent killer. |-| [`playbooks/digikey-models.md`](playbooks/digikey-models.md) | DigiKey's dedicated **Footprints and Models** page at `/en/models/<product-id>` (NOT the product detail page). Three accordion sections: Manufacturer Provided (STEP from mfr CDN), Ultra Librarian (format picker â KiCad v6+), SnapMagic. Two-layer provenance: discovery_source=digikey, content_origin=manufacturer/ul/snapmagic. The mfr STEP is often ONLY surfaced here. Chrome download permission issue + inline-fetch workaround documented. |-| [`playbooks/credentials-and-logins.md`](playbooks/credentials-and-logins.md) | A vendor flow lands on a sign-in / sign-up page. Covers the **two auth schemes** (Credentials button = Basic-Auth URL rewriting for CSE; pup-window sign-in = form-login + cookie for everything else), the **mandatory pup-window-only sign-in walkthrough** (open login in pup, pre-tick "Remember Me" via JS, offer to fill the form for the user â vendor CAD accounts aren't bank logins so just take the password), the "always ask first" rule, known-EE-login catalog, `adom-chip-fetcher login <source>` aliases, save-to-vault pattern, and `user_profile.md` for vendor-form auto-fill. |-| [`playbooks/adom-desktop-api.md`](playbooks/adom-desktop-api.md) | You need to drive pup beyond `browser_navigate` + `browser_eval`. Covers `browser_input_dispatch` (trusted clicks), popup-tab auto-tracking + the "clicks open in new tab" rule, smart-pick + the Vue-Toastification false-positive workaround, `browser_fetch_url` (3 modes) + popup-PDF recipe, `pull_file` streaming, stale-relay gotchas, the cookie-banner auto-accept snippet, and the SACRED `adom-chip-fetcher` profile name. |+| [`playbooks/dashboard-flow.md`](playbooks/dashboard-flow.md) | **Read before any batch fetch** — chip-fetcher's UI is the user's home base. Two-pup-session pattern (dashboard + scraping), activity-heartbeat protocol, canonical stage labels (`probing <vendor>.com` / `downloading <format>` / `imported` / `stuck — <reason>`), opening the dashboard window, when NOT to navigate it, antipatterns. |+| [`playbooks/sourcing-ladder.md`](playbooks/sourcing-ladder.md) | Starting a new MPN. Covers the full ladder (manufacturer → SnapEDA → Mouser → DigiKey → UL/CSE), the manufacturer-first ironclad rule, the NRND/EOL hard stop, and the multi-variant `stock.json` schema. |+| [`playbooks/manufacturer-direct.md`](playbooks/manufacturer-direct.md) | You've identified the manufacturer. Covers self-hosting matrix, vendor URL + UL deep-link patterns, Microchip iframe gotcha, NXP product-page detail (BXL + 3D icon + EXTENDED PROFILE), Lattice 30-PDF Documentation tab, manufacturer reference-layout zips ≠ chip CAD, and `info.json` provenance tags. |+| [`playbooks/ultra-librarian.md`](playbooks/ultra-librarian.md) | Any UL flow — embedded captcha or UL Pro logged-in. Three flows: (1) `vendor.ultralibrarian.com/<slug>/embedded` → captcha gauntlet, driven by `cf-ul-fast.sh`; (2) `app.ultralibrarian.com/details/...?uid=...` (logged in) → captcha-free tick-and-click via `cf-ul-pro-batch.sh` (the modern fast-path: sign in once via `chip-fetcher login ultralib`, then no captcha for 30 days); (3) anonymous UL Pro detail pages → fall back to embedded captcha. Also covers the **two-click BUTTON-then-A.export-trigger pattern** that's bug-prone, the canonical-four EDA-format pick, watcher race-safety, and per-package coverage caveats. |+| [`playbooks/snapmagic.md`](playbooks/snapmagic.md) | Manufacturer doesn't host CAD (Nordic, niche connectors, smaller MCUs) and SnapEDA covers it. Covers login (`form.submit()` not button-click), part-page URL, the per-account `preferred_2d_format` AJAX flow, the "Heads Up" multi-section warning, the KiCad V3/V4/V6 sub-picker, full 3-format cycle (KiCad → Altium → Fusion), filename map, and InstaBuild AI-generated parts. |+| [`playbooks/mouser-cse.md`](playbooks/mouser-cse.md) | Manufacturer doesn't host CAD and SnapEDA didn't cover it — Mouser has the ECAD Model + datasheet. Covers Mouser CSE Library Loader, the **HARD RULE that CSE downloads use HTTP Basic Auth** (form-login session cookie is useless for downloads — embed creds inline as `https://user:pass@host/...`), `cf-cse-batch.sh` for batch fetching with inline creds, slide-to-verify escalation, CSE single-login covering all subdomains, and Chrome's "wants to Download multiple files" silent killer. |+| [`playbooks/digikey-models.md`](playbooks/digikey-models.md) | DigiKey's dedicated **Footprints and Models** page at `/en/models/<product-id>` (NOT the product detail page). Three accordion sections: Manufacturer Provided (STEP from mfr CDN), Ultra Librarian (format picker → KiCad v6+), SnapMagic. Two-layer provenance: discovery_source=digikey, content_origin=manufacturer/ul/snapmagic. The mfr STEP is often ONLY surfaced here. Chrome download permission issue + inline-fetch workaround documented. |+| [`playbooks/credentials-and-logins.md`](playbooks/credentials-and-logins.md) | A vendor flow lands on a sign-in / sign-up page. Covers the **two auth schemes** (Credentials button = Basic-Auth URL rewriting for CSE; pup-window sign-in = form-login + cookie for everything else), the **mandatory pup-window-only sign-in walkthrough** (open login in pup, pre-tick "Remember Me" via JS, offer to fill the form for the user — vendor CAD accounts aren't bank logins so just take the password), the "always ask first" rule, known-EE-login catalog, `chip-fetcher login <source>` aliases, save-to-vault pattern, and `user_profile.md` for vendor-form auto-fill. |+| [`playbooks/adom-desktop-api.md`](playbooks/adom-desktop-api.md) | You need to drive pup beyond `browser_navigate` + `browser_eval`. Covers `browser_input_dispatch` (trusted clicks), popup-tab auto-tracking + the "clicks open in new tab" rule, smart-pick + the Vue-Toastification false-positive workaround, `browser_fetch_url` (3 modes) + popup-PDF recipe, `pull_file` streaming, stale-relay gotchas, the cookie-banner auto-accept snippet, and the SACRED `chip-fetcher` profile name. | | [`playbooks/handoff-and-watching.md`](playbooks/handoff-and-watching.md) | You've decided you can't automate something (captcha, OAuth, OS prompt). Covers when handoff is allowed (escape valve, not routine), the ALWAYS-watch rule, signals to watch for per handoff trigger, the show-product-page-and-teach-me pattern when stuck on a vendor, and EDA-agnostic UI terminology. | -## Adom standard passive size â proactively recommend 0402+## Adom standard passive size — proactively recommend 0402 -**When a user asks "what size LED / resistor / cap / inductor?", default to 0402 and surface the InstaPCB-respin rationale.** Don't recommend 0603 by reflex â that's the traditional EE default rooted in hand-rework friendliness, and Adom's 4-hour respin loop replaces hand-rework entirely. The full rule + paraphrased justification lives in [`electrical-engineering/SKILL.md`](../electrical-engineering/SKILL.md) §4a; read it once per design conversation.+**When a user asks "what size LED / resistor / cap / inductor?", default to 0402 and surface the InstaPCB-respin rationale.** Don't recommend 0603 by reflex — that's the traditional EE default rooted in hand-rework friendliness, and Adom's 4-hour respin loop replaces hand-rework entirely. The full rule + paraphrased justification lives in [`electrical-engineering/SKILL.md`](../electrical-engineering/SKILL.md) §4a; read it once per design conversation. Quick reference (see `solder-jetting/SKILL.md` for the dispense math): -- **0402 = Adom standard.** Solder-jet places it with 250 µm of bridging margin (300 µm dot rule + 550 µm IPC pad gap).+- **0402 = Adom standard.** Solder-jet places it with 250 µm of bridging margin (300 µm dot rule + 550 µm IPC pad gap). - **0603 = override only.** Recommend when user is hand-prototyping outside InstaPCB, has a high-current rail, RF impedance constraint, or explicitly asks.-- **0201 = surface the dispense warning.** Pad gap 250 µm violates the 300 µm inter-pad rule.-- **01005 = out of envelope.** Pads below the 250 µm droplet minimum.+- **0201 = surface the dispense warning.** Pad gap 250 µm violates the 300 µm inter-pad rule.+- **01005 = out of envelope.** Pads below the 250 µm droplet minimum. -For chip packages, pad-pitch ⥠0.4 mm is comfortable; below that surfaces as a DRC warning. Don't recommend a part with finer pitch without flagging it.+For chip packages, pad-pitch ≥ 0.4 mm is comfortable; below that surfaces as a DRC warning. Don't recommend a part with finer pitch without flagging it. ## Sister tools to lean on -- **`adom-parts-search`** (CLI + Hydrogen webview) â unified search across Mouser + DigiKey + JLCPCB with Mouser-preferred routing. Use this **first** when probing whether a chip is even stocked / sourceable, before chasing CAD.-- **`adom-mouser`** / **`adom-digikey`** / **`adom-jlcpcb`** â individual distributor backends. The DigiKey API in particular gives `product_url` / `datasheet_url` on every result â use those to route into adom-chip-fetcher's per-vendor scrapers.-- **`step2glb`** â convert any STEP into a color-preserving GLB for downstream tools (adom-chiplinter, adom-tsci).+- **`adom-parts-search`** (CLI + Hydrogen webview) — unified search across Mouser + DigiKey + JLCPCB with Mouser-preferred routing. Use this **first** when probing whether a chip is even stocked / sourceable, before chasing CAD.+- **`adom-mouser`** / **`adom-digikey`** / **`adom-jlcpcb`** — individual distributor backends. The DigiKey API in particular gives `product_url` / `datasheet_url` on every result — use those to route into chip-fetcher's per-vendor scrapers.+- **`step2glb`** — convert any STEP into a color-preserving GLB for downstream tools (adom-chiplinter, adom-tsci). -When a vendor product page is Cloudflare-blocked from `pup` (DigiKey often is), the distributor APIs above are the side-channel â they return the same `product_url` Cloudflare would gate, plus structured part metadata.+When a vendor product page is Cloudflare-blocked from `pup` (DigiKey often is), the distributor APIs above are the side-channel — they return the same `product_url` Cloudflare would gate, plus structured part metadata. ## Critical gotchas (one-liners) @@ -191,15 +191,15 @@ When a vendor product page is Cloudflare-blocked from `pup` (DigiKey often is), ## Two-pup-window pattern -The user prefers tool dashboards in pup windows, not Hydrogen webview tabs. adom-chip-fetcher uses two concurrent pup windows:+The user prefers tool dashboards in pup windows, not Hydrogen webview tabs. chip-fetcher uses two concurrent pup windows: -- **Dashboard** (`sessionId: "chip-fetcher-dashboard"`, `profile: "adom-chip-fetcher"`) â the live card grid / status view. Open via `adom-chip-fetcher serve` + pup navigate. NEVER navigate this window to a vendor page mid-batch.-- **Scrape** (`sessionId: "chip-fetcher-scrape"`, `profile: "adom-chip-fetcher"`) â where vendor product pages, logins, captchas, and downloads happen. All `browser_navigate` / `browser_eval` / `browser_input_dispatch` scraping calls target this session.+- **Dashboard** (`sessionId: "chip-fetcher-dashboard"`, `profile: "chip-fetcher"`) — the live card grid / status view. Open via `chip-fetcher serve` + pup navigate. NEVER navigate this window to a vendor page mid-batch.+- **Scrape** (`sessionId: "chip-fetcher-scrape"`, `profile: "chip-fetcher"`) — where vendor product pages, logins, captchas, and downloads happen. All `browser_navigate` / `browser_eval` / `browser_input_dispatch` scraping calls target this session. -Both windows share the same `profile: "adom-chip-fetcher"` so cookies and vendor logins persist. Only the `sessionId` differs. This isolation means the user can watch the dashboard update in real time (via `/api/activity` heartbeats) while the scraping window does its work on vendor sites.+Both windows share the same `profile: "chip-fetcher"` so cookies and vendor logins persist. Only the `sessionId` differs. This isolation means the user can watch the dashboard update in real time (via `/api/activity` heartbeats) while the scraping window does its work on vendor sites. ## See also -- `/home/adom/project/adom-chip-fetcher/` â the source repo-- `/home/adom/project/adom-chiplinter/` â adjacent project that consumes adom-chip-fetcher output (pad/leg/tab/pin detector)-- `chip-pads` (planned) â STEP-based pad detector that uses adom-chip-fetcher's library as test cases+- `/home/adom/project/chip-fetcher/` — the source repo+- `/home/adom/project/adom-chiplinter/` — adjacent project that consumes chip-fetcher output (pad/leg/tab/pin detector)+- `chip-pads` (planned) — STEP-based pad detector that uses chip-fetcher's library as test cases
install.sh+1−1@@ -1,4 +1,4 @@ #!/bin/bash set -euo pipefail DIR="$(cd "$(dirname "$0")" && pwd)"; BIN="${HOME}/.local/bin"; mkdir -p "$BIN"-install -m 0755 "$DIR/bin/adom-chip-fetcher" "$BIN/adom-chip-fetcher"; echo "Installed adom-chip-fetcher"+install -m 0755 "$DIR/bin/adom-chip-fetcher" "$BIN/adom-chip-fetcher"; echo "Installed adom-chip-fetcher -> $BIN/adom-chip-fetcher"
selftest.cjsadded+142@@ -0,0 +1,142 @@+// Ralph-loop self-test for the chip-fetcher dashboard. Drives the REAL UI in+// headless Chromium at a phone viewport, exercises the source carat + zoom, and+// asserts + screenshots so regressions are caught here, not on John's phone.+//+// node chip-fetcher/selftest.cjs # full run+// Screenshots → /tmp/selftest/*.png+const puppeteer = require('/home/adom/project/node_modules/puppeteer-core');+const fs = require('fs');++const CHROME = '/home/adom/.cache/ms-playwright/chromium-1217/chrome-linux64/chrome';+const BASE = 'http://127.0.0.1:8786/';+const OUT = '/tmp/selftest';+const MPN = process.argv[2] || 'BMI270';+const PROJ = process.argv[3] || 'Multi-source demo 2 (BMI270)';++const sleep = (ms) => new Promise(r => setTimeout(r, ms));+fs.mkdirSync(OUT, { recursive: true });+const checks = [];+const check = (name, ok, detail = '') => { checks.push({ name, ok, detail }); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); };++(async () => {+ const browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-sandbox', '--disable-gpu'] });+ const page = await browser.newPage();+ await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 2, isMobile: true, hasTouch: true });+ page.on('pageerror', e => console.log('PAGEERROR', e.message));++ const url = `${BASE}?project=${encodeURIComponent(PROJ)}&chip=${encodeURIComponent(MPN)}`;+ await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });+ await page.waitForSelector('.card', { timeout: 15000 });+ await sleep(1200);+ await page.screenshot({ path: `${OUT}/1-card.png` });++ // The deep-linked chip is present + filtered.+ const cards = await page.$$eval('.card', els => els.map(e => e.getAttribute('data-mpn')));+ check('deep-link filters to the chip', cards.length === 1 && cards[0] === MPN, `cards=${JSON.stringify(cards)}`);++ // Footprint carat: list sources + the canonical (current) FP thumbnail must NOT+ // be the ds2sf one (the bug John found).+ const fpInfo = await page.evaluate(() => {+ const grp = [...document.querySelectorAll('.thumb-src-group')].find(g => g.querySelector('.thumb[data-thumb-kind="footprint"]'));+ if (!grp) return { err: 'no footprint group' };+ const chip = grp.querySelector('.src-chip-lab')?.textContent;+ const img = grp.querySelector('.thumb img');+ const opts = [...grp.querySelectorAll('.src-opt')].map(o => o.querySelector('.src-opt-lab').textContent);+ return { chip, imgSrc: img?.src, opts };+ });+ check('FP carat lists multiple sources', (fpInfo.opts || []).length >= 3, `opts=${JSON.stringify(fpInfo.opts)}`);+ check('FP tile defaults to a vendor source (not ds2sf)', /SnapEDA|Ultra|Component/.test(fpInfo.chip || ''), `chip=${fpInfo.chip}`);+ // ds2sf with no pads must NOT appear as a footprint source.+ check('ds2sf not listed as a FP source when it has no footprint', !(fpInfo.opts || []).some(o => /ds2sf/i.test(o)), `opts=${JSON.stringify(fpInfo.opts)}`);++ // "Open in" menu (detail panel) exposes every tool for the chip.+ const tools = await page.$$eval('.openin-row .openbtn', els => els.map(e => e.textContent));+ check('"Open in" lists adom-symbol/footprint/lbr/3D', ['adom-symbol', 'adom-footprint', 'adom-lbr', 'adom-3D'].every(t => tools.includes(t)), `tools=${JSON.stringify(tools)}`);+ check('"Open in" includes chipsmith', tools.includes('chipsmith'));+ check('"Open in" includes ds2sf extraction', tools.includes('ds2sf extraction'));++ // Detail panel is tabbed; Actions is the default (Files must NOT dominate).+ const tabs = await page.evaluate(() => {+ const t = [...document.querySelectorAll('.dtab')].map(x => x.textContent.trim());+ const onTab = document.querySelector('.dtab.on')?.textContent.trim();+ const filesPanelVisible = document.querySelector('.dpanel[data-panel="files"]')?.classList.contains('on');+ return { t, onTab, filesPanelVisible };+ });+ check('detail panel is tabbed (Actions/Details/Files)', tabs.t.some(x => x.startsWith('Actions')) && tabs.t.some(x => x.startsWith('Files')), `tabs=${JSON.stringify(tabs.t)}`);+ check('Actions is the default tab (Files not dominating)', /Actions/.test(tabs.onTab || '') && !tabs.filesPanelVisible, `onTab=${tabs.onTab}`);+ await page.evaluate(() => document.querySelector('.openin-row')?.scrollIntoView());+ await page.screenshot({ path: `${OUT}/4-detail-tabs.png` });++ // The canonical FP thumbnail must actually load (not broken) and not be ds2sf-synth.+ const fpThumb = await page.evaluate(async () => {+ const grp = [...document.querySelectorAll('.thumb-src-group')].find(g => g.querySelector('.thumb[data-thumb-kind="footprint"]'));+ const img = grp.querySelector('.thumb img');+ const ok = img.complete && img.naturalWidth > 0;+ let body = '';+ try { body = await (await fetch(img.src)).text(); } catch {}+ return { ok, hasDs2sf: /ds2sf|SYNTHESI/i.test(body) };+ });+ check('canonical FP thumbnail loads (not broken)', fpThumb.ok);+ check('canonical FP thumbnail is NOT ds2sf-synthesized', !fpThumb.hasDs2sf);++ // Switch the FP source via the carat → label sticks + tile re-renders.+ await page.evaluate(() => {+ const grp = [...document.querySelectorAll('.thumb-src-group')].find(g => g.querySelector('.thumb[data-thumb-kind="footprint"]'));+ grp.querySelector('.src-chip').click();+ });+ await sleep(300);+ const switched = await page.evaluate(() => {+ const pop = [...document.querySelectorAll('.src-popover')].find(p => p.offsetParent !== null);+ const ul = [...pop.querySelectorAll('.src-opt')].find(o => /Ultra/.test(o.textContent));+ ul.click();+ const grp = ul.closest('.thumb-src-group');+ return grp.querySelector('.src-chip-lab').textContent;+ });+ await sleep(400);+ check('picking a source updates the carat label (sticks)', /Ultra/.test(switched), `label=${switched}`);+ await page.screenshot({ path: `${OUT}/2-source-switched.png` });++ // Zoom: tap the zoom button → full-screen overlay with a visible X.+ await page.evaluate(() => { document.querySelector('.card .zoom-btn').click(); });+ await sleep(700);+ const zoom = await page.evaluate(() => {+ const bd = document.querySelector('.card-zoom-backdrop');+ const x = document.querySelector('.card-zoom-x');+ const wrap = document.querySelector('.card-zoomed-wrapper');+ const fills = wrap ? wrap.getBoundingClientRect().width / window.innerWidth : 0;+ return { hasBackdrop: !!bd, fullscreen: bd?.classList.contains('fullscreen'), hasX: !!x && getComputedStyle(x).display !== 'none', widthFrac: fills };+ });+ check('zoom opens an overlay', zoom.hasBackdrop);+ check('zoom is full-screen on phone', zoom.fullscreen && zoom.widthFrac > 0.85, `widthFrac=${zoom.widthFrac.toFixed(2)}`);+ check('zoom has a visible X close button', zoom.hasX);+ // The zoom focus view exposes Open-in + Send-to (reachable on a phone).+ const zoomActions = await page.evaluate(() => ({+ open: [...document.querySelectorAll('.card-zoomed-wrapper .openin-row .openbtn')].map(b => b.textContent),+ send: [...document.querySelectorAll('.card-zoomed-wrapper .sendto-row .sendbtn')].map(b => b.textContent),+ }));+ check('zoom view has Open-in actions', zoomActions.open.includes('adom-symbol') && zoomActions.open.includes('chipsmith'), `open=${JSON.stringify(zoomActions.open)}`);+ check('zoom view has Send-to actions', zoomActions.send.length === 4, `send=${JSON.stringify(zoomActions.send)}`);+ await page.screenshot({ path: `${OUT}/3-zoom.png` });++ // Tapping "Open in adom-symbol" from the zoom actually opens a NEW TAB+ // (window.open) — the phone-correct method.+ const before = (await browser.pages()).length;+ await page.evaluate(() => [...document.querySelectorAll('.card-zoomed-wrapper .openin-row .openbtn')].find(b => b.textContent === 'adom-symbol').click());+ await sleep(2500);+ const after = (await browser.pages()).length;+ check('opening adom-symbol spawns a new browser tab (window.open)', after > before, `pages ${before}→${after}`);+ const toast = await page.evaluate(() => document.getElementById('cf-toast')?.textContent || '');+ check('user is told the open method', /new browser tab|window\.open|Hydrogen/i.test(toast), `toast="${toast.trim()}"`);++ // X closes it.+ await page.evaluate(() => document.querySelector('.card-zoom-x').click());+ await sleep(500);+ const closed = await page.$('.card-zoom-backdrop');+ check('X closes the zoom (back to index)', !closed);++ await browser.close();+ const fails = checks.filter(c => !c.ok);+ console.log(`\n${checks.length - fails.length}/${checks.length} checks passed.`);+ fs.writeFileSync(`${OUT}/result.json`, JSON.stringify(checks, null, 2));+ process.exit(fails.length ? 1 : 0);+})().catch(e => { console.error('SELFTEST ERROR', e); process.exit(2); });
skills/CONTRIBUTING.mdadded+68@@ -0,0 +1,68 @@+# Contributing a source-site skill++You figured out how to navigate a vendor's CAD-download flow — maybe a site+chip-fetcher didn't cover, maybe a new way past a block (the day DigiKey blocked+pup, someone chatted with its assistant and found a path; that knowledge belongs+here). Here's how to get it into the pack so everyone's AI benefits.++## Write the skill++Copy `sites/snapeda/SKILL.md` as a template and fill in the five sections (Reach,+Gate, Download flow, Catch, When it breaks). Keep it to **one site per skill** and+give it real trigger words (the vendor name, "blocked", "captcha", the file types)+so the AI auto-surfaces it at the right moment.++## Two ways to land it — the owner picks per contribution++The pack supports two contribution shapes. Which one is allowed for a given skill+is the **maintainer's call**, made deliberately, trading freshness against trust:++### A. Pull request INTO the pack (vetted — the default)++Open a PR adding `sites/<vendor>/SKILL.md` to this repo. The maintainer reads the+navigation steps, sanity-checks them, and merges. The skill now lives in the+chip-fetcher repo and ships with the pack.++- **Pro:** every step is reviewed before it reaches anyone's AI — the safe default.+- **Con:** updates require another PR; the maintainer is in the loop each time.+- **Use when:** the contributor is new/unknown, or the skill does anything+ sensitive (credential handling, evading a block, executing fetched content).++### B. Linked external skill (trusted — opt-in)++Publish your skill as its **own wiki repo** (you own it, you `adompkg publish`+updates whenever you like). Then PR a one-line **link** into this pack's manifest+instead of the skill body. The pack references your repo; your repo stays yours.++- **Pro:** you keep ownership and can fix your site's flow the hour it changes,+ no maintainer round-trip — exactly what a fast-moving vendor site needs.+- **Con:** the pack now trusts a repo it doesn't control; a later edit isn't+ re-reviewed. So this is **trusted-contributor only**.+- **Use when:** the maintainer knows the contributor and is comfortable letting+ their repo update live.++> The link itself still arrives by PR, so the maintainer always makes the+> conscious decision to trust a given external repo — but once linked, that repo+> self-updates. Default to (A); grant (B) to people you trust. Revoking is just+> dropping the link.++## Security & vetting++Site skills tell an AI to drive a real browser on the user's machine, sometimes+past anti-bot defenses. Treat every contribution as code review:++- No skill should exfiltrate credentials or paste them anywhere but the vendor's+ own login. Creds come from `chip-fetcher creds`, never inline.+- "Getting past a block" means *navigating the site as a human would* (right+ profile, human pacing, the path their own assistant suggested) — **not**+ defeating security controls or solving CAPTCHAs programmatically.+- A linked external skill (path B) inherits the trust you grant its repo. When in+ doubt, require path A.++## Status (for Colby / the wiki)++Path A (PR into the pack) works on the wiki today — it's a normal pull request.+Path B (a pack sub-skill that *links* an externally-owned wiki repo, which then+self-updates) is the piece that needs platform support: a manifest link type the+pack resolves at install time, plus a way for the maintainer to mark a given link+trusted. Tracked in the chip-fetcher Colby feedback.
skills/SKILL.mdadded+84@@ -0,0 +1,84 @@+---+name: chip-fetcher-sites+description: >-+ The chip-fetcher SOURCE-SITE skill pack — one installable skill per vendor+ website that chip-fetcher crawls (SnapEDA, Mouser, DigiKey, Ultra Librarian,+ Arrow, manufacturer sites…). Each sub-skill teaches the AI how to navigate+ THAT site's CAD-download flow through pup: the URL patterns, the login/gate,+ the download button dance, and — crucially — how to get unstuck when a site+ changes its layout or blocks automation. Third parties extend the pack by+ contributing a skill for a site they've figured out. Read this before driving+ chip-fetcher against a vendor site, or when a fetch gets stuck on a site whose+ layout or anti-bot defenses changed. Trigger words: chip-fetcher site, source+ site, vendor navigation, snapeda navigation, digikey blocked, mouser cad,+ ultralibrarian, site skill, crawl site, fetch stuck, anti-bot, captcha,+ download flow, add a site skill, contribute site skill.+---++# chip-fetcher source-site skill pack++chip-fetcher is an **open-source Page** (source public + `adompkg install`-able)+that *also carries a skill pack in the same repo*. The app is the crawler; this+pack is the **per-site navigation knowledge** the AI uses to drive it. They ship+together so the code and the know-how version in lockstep — but the know-how is+the part the community can extend without touching Rust.++## Why per-site skills++Every vendor site has its own CAD-download choreography, and they change it+without notice. SnapEDA gates behind a login; DigiKey periodically tightens+anti-bot and **blocks pup outright**; Mouser buries the 3D model behind a tab;+manufacturer sites (TI, ST, NXP, Nordic…) each have a different "CAD / Symbols &+Footprints" path. Hard-coding all of that in the binary means a site change is a+release. Capturing it as **a skill per site** means:++- the AI reads the current site's skill before navigating, so it knows the exact+ flow instead of guessing;+- when a site changes (or blocks us), **anyone** who figures out the new flow can+ fix it by editing one skill — no fork, no Rust, no release;+- the fix is discoverable: it's published to the wiki and the next person's AI+ picks it up.++The canonical sourcing ladder (Mfr → SnapEDA → Mouser → DigiKey → Arrow → CSE/UL+→ LCSC) still lives in the binary (`chip-fetcher sources`); this pack is the+**how-to-drive-each-rung** layer on top of it.++## Layout++```+skills/+├── SKILL.md # this file — the pack entry point + ladder+├── CONTRIBUTING.md # how 3rd parties add / own a site skill+└── sites/+ ├── snapeda/SKILL.md # the canonical reference flow (login + download)+ ├── digikey/SKILL.md # incl. the "pup is blocked" recovery playbook+ ├── mouser/SKILL.md+ ├── ultralibrarian/SKILL.md+ └── manufacturer/SKILL.md# generic manufacturer-site approach (TI/ST/NXP/Nordic…)+```++Each `sites/<vendor>/SKILL.md` is a complete, installable skill with its own+trigger words, so the AI auto-surfaces the right one when it's about to touch+that vendor — or when a fetch against it gets stuck.++## What a site skill must contain++1. **Reach** — the canonical product-search / product-page URL pattern for an MPN.+2. **Gate** — login / region / cookie wall, and how the persistent pup profile+ gets past it (which profile, what creds key in `chip-fetcher creds`).+3. **Download flow** — the exact button/tab sequence to the CAD bundle, with the+ selectors or visual cues that identify each step.+4. **Catch** — what the downloaded artifact is (zip? IntLib? per-file?), where it+ lands, and how chip-fetcher pulls it off the desktop.+5. **When it breaks** — the failure signatures (block page, captcha, moved button)+ and the recovery playbook. This is the highest-value section — it's why the+ pack exists.++See `sites/snapeda/SKILL.md` for the template filled in.++## Adding a site++Found a site chip-fetcher doesn't know, or a new way past a block? Write a skill+for it and contribute it — `CONTRIBUTING.md` covers the two paths (PR it into the+pack, or own it in your own wiki repo and link it in). Either way your AI, and+everyone else's, gets smarter the next time that site comes up.
skills/sites/digikey/SKILL.mdadded+63@@ -0,0 +1,63 @@+---+name: chip-fetcher-site-digikey+description: >-+ How chip-fetcher navigates DigiKey for a chip's CAD models — and the recovery+ playbook for when DigiKey BLOCKS pup automation (its anti-bot trips+ periodically and serves a challenge/block page instead of the product page).+ Read before a DigiKey fetch, and especially when a DigiKey fetch returns a+ block/challenge instead of the part. Trigger words: digikey, digikey blocked,+ digikey captcha, digikey anti-bot, digikey challenge page, digikey cad,+ digikey step, digikey 3d model, digikey access denied, digikey verify human.+---++# Site: DigiKey++DigiKey carries manufacturer CAD (ECAD model via SnapEDA/SamacSys, 3D STEP) on the+product page. It's a useful rung — **when it lets us in**. DigiKey runs aggressive+bot detection that changes without notice, so the "when it breaks" section here is+the main event.++## Reach+- Search: `https://www.digikey.com/en/products/result?keywords=<MPN>`+- Or product page directly once the DigiKey part number is known.++## Gate+- No login needed for CAD download, but the page is region/cookie-gated and+ bot-screened. Use the persistent pup profile so cookies + a warmed session+ carry over.++## Download flow+1. On the product page, scroll to **Documents & Media** / **CAD Models**.+2. The ECAD model is a SnapEDA/SamacSys embed → may redirect to a partner login;+ the 3D STEP is usually a direct link.+3. Download lands as a `.zip` or `.step` in Downloads; chip-fetcher pulls + unpacks.++## Catch+- Same as the other rungs: pull off the desktop, normalize into `library/<MPN>/`.++## When it breaks — DigiKey blocks pup++**Signature:** instead of the product page you get an "Access Denied",+"verify you are human", a Cloudflare/Akamai interstitial, or an endless spinner.+The page title and a screenshot will show the challenge, not the part.++**Do NOT** try to defeat the challenge programmatically (no CAPTCHA solving, no+header spoofing to look like a different bot). That's the wrong kind of fix and+it's explicitly out of scope (see `../../CONTRIBUTING.md` → Security).++**The right plays, in order:**+1. **Use a warm human profile.** Drive DigiKey in a pup profile the user actually+ browses with — real cookies, real history. A cold automation profile trips the+ screen far more often. Pace actions like a human (no instant clicks).+2. **Ask DigiKey's own assistant.** DigiKey has an on-site chat/AI. Navigating *to+ the model* via the path it hands you often avoids the screened URL entirely.+ (This is exactly how a 3rd party first got past a fresh block — and then+ wrote it up here.)+3. **Skip the rung.** DigiKey is mid-ladder, not the only source. If it's blocking,+ fall to Arrow / CSE-UL rather than fighting it — the part's model is usually+ reachable elsewhere.+4. **Capture what worked and publish it.** If you find a current path through,+ update this section and ship it (`../../CONTRIBUTING.md`). The block changes+ monthly; the freshest skill wins. A fast-moving vendor like this is the textbook+ case for the **linked-external-skill** path so the owner can update it the hour+ it changes.
skills/sites/manufacturer/SKILL.mdadded+47@@ -0,0 +1,47 @@+---+name: chip-fetcher-site-manufacturer+description: >-+ The generic approach for pulling CAD straight from a chip MAKER's own site (TI,+ ST, NXP, Nordic, Microchip, Analog…) — the top rung of the ladder, where the+ STEP is richest and most authoritative. Read before a manufacturer-direct+ fetch, or to add a maker-specific sub-flow. Trigger words: manufacturer cad,+ ti cad download, st cad, nxp cad, nordic cad, microchip cad, analog devices+ cad, manufacturer step, product page cad, maker site.+---++# Site: Manufacturer-direct (generic)++The manufacturer's own product page is the **top rung**: the STEP is the real one,+the symbol/footprint match the datasheet exactly, and there's no third-party+re-draw. Always try here first.++## Reach+- `chip-fetcher sources` + `sourcing.rs` map an MPN prefix → manufacturer + a+ canonical product-search URL. Open that, land on the exact-MPN product page.+- Fallback when the prefix is unknown: a `google.com/search?q=<MPN> CAD STEP site:<maker>.com`+ resolves to the real product page (don't template a `<maker>.com/product/<MPN>`+ guess — those 404 for parts the maker renamed).++## Gate+- Most makers offer CAD without login; some (NXP, ST) gate STEP behind a free+ account or an export-control click-through. Use the persistent profile; creds+ per-maker via `chip-fetcher creds`.++## Download flow+- Find the **CAD / Symbols, Footprints & 3D Models / Design Resources** section on+ the product page. The model is often a partner embed (SnapEDA / Ultra Librarian /+ SamacSys) — if so, hand off to that site's skill. Native STEP downloads land as+ `.step` / `.zip`.++## Catch+- Pull + normalize into `library/<MPN>/`. Manufacturer STEP becomes the canonical+ `<MPN>.step`.++## When it breaks+- **Export-control / login wall** → satisfy it with the per-maker creds, once.+- **Embed instead of native CAD** → follow the embed to the partner site and use+ that site's skill.+- **Per-maker quirks** (TI's "ULTRA Librarian" button, ST's MyST gate, Nordic's+ DeviceTree bundles) deserve their own `sites/<maker>/SKILL.md` — add one when a+ maker's flow is gnarly enough to be worth capturing. `sources/ti.rs` is the code+ counterpart for TI.
skills/sites/mouser/SKILL.mdadded+30@@ -0,0 +1,30 @@+---+name: chip-fetcher-site-mouser+description: >-+ How chip-fetcher pulls CAD + stock/lifecycle from Mouser. Mouser is also the+ authoritative stock/price/lifecycle source (stock.json). Read before a Mouser+ fetch or stock check. Trigger words: mouser, mouser cad, mouser step, mouser+ stock, mouser lifecycle, mouser 3d model, mouser blocked.+---++# Site: Mouser++Mid-ladder for CAD, but the **canonical source for stock + price + lifecycle**+(NRND/EOL) that lands in `stock.json`.++## Reach+- Search: `https://www.mouser.com/c/?q=<MPN>` → product page.++## Gate+- CAD download and the API both want a session/key. Stock/price come via the+ Mouser API (key in `chip-fetcher creds`, used by the inventory path), not pup.++## Download flow+- Product page → **CAD / ECAD Model** (a SnapEDA/SamacSys embed → follow that+ site's skill) and the **3D Model** link. Stock/lifecycle read from the API, not+ the page.++## Catch / When it breaks+- Normalize into `library/<MPN>/`; lifecycle/stock → `stock.json`.+- Embed → hand to the partner site's skill. Block → warm profile, then skip the+ rung (see digikey skill's playbook — same pattern).
skills/sites/nxp/SKILL.mdadded+18@@ -0,0 +1,18 @@+---+name: chip-fetcher-site-nxp+description: >-+ How chip-fetcher pulls CAD from NXP (nxp.com) — the Design Resources / CAD+ section, sometimes behind an export-control click-through. Read before an+ NXP-direct fetch. Trigger words: nxp, nxp.com, nxp cad, nxp step, nxp symbol+ footprint, nxp export control, nxp blocked.+---+# Site: NXP (nxp.com)+Authoritative manufacturer source for NXP MCUs / analog / RF.+## Reach+- `https://www.nxp.com/products/.../<MPN>` or `google.com/search?q=<MPN> nxp.com cad`.+## Gate+- Free account + an **export-control / EULA click-through** on some STEP downloads.+## Download flow+- Product page → **Design Resources → CAD / Symbols & Footprints** (often a SnapEDA/SamacSys embed → hand to that site's skill); native STEP behind the EULA click.+## Catch / When it breaks+- Normalize into `library/<MPN>/`. Satisfy the EULA once via the profile. Embed instead of native CAD → follow the partner site's skill. Publish new flows (`../../CONTRIBUTING.md`).
skills/sites/snapeda/SKILL.mdadded+51@@ -0,0 +1,51 @@+---+name: chip-fetcher-site-snapeda+description: >-+ How chip-fetcher navigates SnapEDA (SnapMagic) to download a chip's CAD bundle+ — symbol, footprint, 3D STEP, and the Fusion/Eagle .lbr + Altium IntLib —+ through a logged-in pup Chrome profile. The canonical template for a+ source-site skill. Read before driving a SnapEDA fetch, or when a SnapEDA fetch+ stalls at the login wall or the download button. Trigger words: snapeda,+ snapmagic, snapeda login, snapeda download, snapeda blocked, snapeda step,+ snapeda symbol footprint, snapeda lbr, snapeda intlib.+---++# Site: SnapEDA (SnapMagic)++SnapEDA is the highest-yield non-manufacturer rung: it has symbol + footprint ++3D + Eagle/Fusion `.lbr` + Altium for most mainstream parts, behind a free login.++## Reach+- Search: `https://www.snapeda.com/search/?q=<MPN>&SearchType=parts`+- Resolve to the part page (first exact-MPN result). The part page URL looks like+ `https://www.snapeda.com/parts/<MPN>/<Manufacturer>/view-part/`.++## Gate+- Requires a logged-in account to download. Use the persistent pup profile+ configured for SnapEDA; credentials come from `chip-fetcher creds` (key+ `snapeda`), entered **only** into SnapEDA's own login form.+- The session cookie persists in the profile for ~weeks; re-login only when the+ download button bounces back to a login modal.++## Download flow+1. On the part page, click **Download** (top-right). A format panel opens.+2. Pick the targets chip-fetcher wants: **Symbol and Footprint** (KiCad), **3D+ Model** (STEP), and the **Eagle/Fusion** library; Altium if offered.+3. SnapEDA emails-gates some downloads — the logged-in session usually skips that;+ if a "check your email" interstitial appears, the profile isn't logged in.+4. The download lands as a `.zip` in the desktop's Downloads.++## Catch+- chip-fetcher pulls the zip off the desktop (`adom-desktop pull_file`), unpacks+ into `library/<MPN>/`, and normalizes filenames (`<MPN>.step`, `.kicad_sym`,+ `.kicad_mod`, `-fusion.lbr`, `.IntLib`).++## When it breaks+- **Bounced to a login modal on Download** → the profile lost its session.+ Re-login with the `snapeda` creds, then retry. Don't paste creds anywhere but+ the SnapEDA login form.+- **"Request this design" instead of Download** → SnapEDA doesn't have this part's+ model yet. Fall to the next ladder rung; don't spin.+- **Moved Download button / new format panel** → SnapEDA reskinned. Screenshot the+ part page, find the new control by its label ("Download", format names), update+ the flow above, and publish the fix (see `../../CONTRIBUTING.md`).
skills/sites/st/SKILL.mdadded+18@@ -0,0 +1,18 @@+---+name: chip-fetcher-site-st+description: >-+ How chip-fetcher pulls CAD from STMicroelectronics (st.com) — the CAD Resources+ tab on a product page, behind a free myST account for some downloads. Read+ before an ST-direct fetch. Trigger words: st, stm, stmicroelectronics, st.com,+ st cad, st step, stm32 cad, myst login, st blocked.+---+# Site: STMicroelectronics (st.com)+Authoritative manufacturer source for STM32 / ST analog & power parts.+## Reach+- `https://www.st.com/en/<family>/<MPN>.html` or `google.com/search?q=<MPN> st.com cad`.+## Gate+- A free **myST** account is required for some CAD/STEP downloads (a click-through login). Use the persistent profile; creds key `st`.+## Download flow+- Product page → **CAD Resources** tab → download the symbol/footprint and STEP. Some land as a `.zip`.+## Catch / When it breaks+- Normalize into `library/<MPN>/`. myST session expires → re-login once. ST gates exports by region/account; if a download 403s, confirm the myST session, then publish any new flow (`../../CONTRIBUTING.md`).
skills/sites/ti/SKILL.mdadded+19@@ -0,0 +1,19 @@+---+name: chip-fetcher-site-ti+description: >-+ How chip-fetcher pulls CAD from Texas Instruments (ti.com) — the "CAD/Symbols,+ Footprints & 3D Models" widget on a product page (an Ultra Librarian embed) and+ the native STEP. Read before a TI-direct fetch. Trigger words: ti, texas+ instruments, ti.com, ti cad, ti step, ti symbol footprint, ti ultra librarian,+ ti.com blocked.+---+# Site: Texas Instruments (ti.com)+Top-rung manufacturer source — TI's STEP is authoritative and its ECAD is an Ultra Librarian embed.+## Reach+- `https://www.ti.com/product/<PARTFAMILY>` → the specific orderable (e.g. nRF… no; TI parts like TPS62840). Or `google.com/search?q=<MPN> ti.com cad`.+## Gate+- No login for STEP; the "CAD/Symbols, Footprints & 3D Models" section hands off to **Ultra Librarian** (free login) for the ECAD model → use the `ultralibrarian` site skill from there.+## Download flow+- Product page → **Design & development → CAD/CAE symbols** → STEP downloads directly; ECAD opens the UL flow.+## Catch / When it breaks+- Normalize into `library/<MPN>/`. If the UL embed changes, see the `ultralibrarian` skill. TI occasionally A/B-tests the product page layout — find the CAD section by its heading, not a fixed selector, and publish the fix (`../../CONTRIBUTING.md`).
skills/sites/ultralibrarian/SKILL.mdadded+27@@ -0,0 +1,27 @@+---+name: chip-fetcher-site-ultralibrarian+description: >-+ How chip-fetcher navigates Ultra Librarian / Component Search Engine (CSE/UL) —+ a late-ladder rung with broad coverage behind a login + format picker. Read+ before a CSE/UL fetch. Trigger words: ultra librarian, ultralibrarian, cse_ul,+ component search engine, samacsys, ul download, ul step, ul blocked.+---++# Site: Ultra Librarian / Component Search Engine (CSE/UL)++Late ladder (`cse_ul`) but wide coverage; many maker + DigiKey/Mouser "ECAD model"+buttons are CSE/UL or SamacSys embeds under the hood.++## Reach+- Search the UL/CSE catalog for `<MPN>`, land on the part, choose the ECAD target.++## Gate+- Free login; pick **KiCad** (symbol+footprint), **STEP**, and Eagle/Fusion ++ Altium when offered. Creds key `cse_ul` via `chip-fetcher creds`.++## Download flow / Catch+- Format picker → download `.zip` → pull + normalize into `library/<MPN>/`.++## When it breaks+- Login bounce → re-login once. Format picker reskinned → screenshot, find the+ KiCad/STEP options by label, update + publish (see `../../CONTRIBUTING.md`).
src/cli.rsadded+1871@@ -0,0 +1,1871 @@+use anyhow::Result;+use clap::{Parser, Subcommand};+use std::path::PathBuf;++use crate::{hints, import, inventory, library, pup, serve, sources, sourcing, thumbnails, validate};++const SKILL_MD: &str = include_str!("../SKILL.md");++// Playbooks — deep reference, loaded on demand by Claude when SKILL.md tells it which one to Read.+// Embedded at compile time so `adom-chip-fetcher install` deploys them alongside SKILL.md without+// needing the source repo on the user's container.+const PLAYBOOKS: &[(&str, &str)] = &[+ ("sourcing-ladder.md", include_str!("../playbooks/sourcing-ladder.md")),+ ("manufacturer-direct.md", include_str!("../playbooks/manufacturer-direct.md")),+ ("ultra-librarian.md", include_str!("../playbooks/ultra-librarian.md")),+ ("snapmagic.md", include_str!("../playbooks/snapmagic.md")),+ ("mouser-cse.md", include_str!("../playbooks/mouser-cse.md")),+ ("digikey-models.md", include_str!("../playbooks/digikey-models.md")),+ ("credentials-and-logins.md", include_str!("../playbooks/credentials-and-logins.md")),+ ("adom-desktop-api.md", include_str!("../playbooks/adom-desktop-api.md")),+ ("handoff-and-watching.md", include_str!("../playbooks/handoff-and-watching.md")),+];++const SESSION: &str = "adom-chip-fetcher";+const PROFILE: &str = "adom-chip-fetcher";++#[derive(Parser)]+#[command(name = "adom-chip-fetcher", version, about = "Download manufacturer STEP/footprint/datasheet bundles via pup")]+struct Cli {+ #[command(subcommand)]+ cmd: Cmd,+}++#[derive(Subcommand)]+enum Cmd {+ /// PLAN a batch: show the user the full queue up front + seed planned-state+ /// dashboard cards, BEFORE any scraping. This is the required first step —+ /// browser-driving verbs (fetch/mfr-probe/scrape) REFUSE to run for an MPN+ /// that isn't in the active plan. That's the guardrail that stops freelancing.+ Plan {+ /// The MPNs to source, in order.+ mpns: Vec<String>,+ /// Name the plan (defaults to "batch"). Becomes the active project.+ #[arg(long, default_value = "batch")]+ name: String,+ },+ /// Scan the native browser's SAVED LOGINS across every profile (Chrome ++ /// Edge), map them to the sourcing ladder, and report which CAD/distributor+ /// vendors you're signed in to + the best profile. adom-chip-fetcher uses this to+ /// self-drive: it auto-switches to the vendor-authed profile while sourcing.+ Logins,+ /// Fetch a part's full CAD bundle (symbol+footprint+STEP) from Component+ /// Search Engine via XHR + basic-auth — no browser download, no prompts, no+ /// questions. ST/TI/NXP/etc. route their EDA models here. First-class ++ /// permanent: works for any user with a CSE login in the credential vault.+ Cse {+ mpn: String,+ /// Manufacturer name for CSE's `mna` param (default: inferred from MPN).+ #[arg(long)]+ mfr: Option<String>,+ },+ /// Set the human "what is this chip" description shown on the card + zoom+ /// view (writes info.json.description). Every sourced chip should have one.+ Describe {+ mpn: String,+ /// The description text (one or two sentences: what the part IS).+ text: String,+ },+ /// Enrich a chip from the Adom parts tools (adom-parts-search → Mouser ++ /// DigiKey + JLCPCB): writes the description, manufacturer, stock.json+ /// (stock + pricing + lifecycle) and lists orderable package variants.+ /// Keys are baked into those tools' own services — NO API key needed.+ Enrich { mpn: String },+ /// Run the MOST authoritative source: extract symbol + footprint from the+ /// manufacturer DATASHEET via adom-ds2sf. GUARANTEES the datasheet is real+ /// (multi-page + pinout) first — refuses a 1-page denial page — then runs+ /// the extraction and renders the ds2sf variant thumbnails.+ Ds2sf { mpn: String },+ /// Fetch one MPN from the best available source+ Fetch {+ mpn: String,+ /// Associate this chip with a project (e.g. "Tamara-LED-Nameplate")+ #[arg(long)]+ project: Option<String>,+ },+ /// Open a source's login page so the user can authenticate once+ Login { source: String },+ /// Show what's already in the library+ List,+ /// Show what's downloaded vs missing for a single MPN+ Status { mpn: String },+ /// Import a downloaded file or zip into the library for an MPN+ Import {+ /// Path to a .zip, .step, .pdf, .kicad_mod, or .kicad_sym+ path: PathBuf,+ /// MPN to file under (default: infer from filename)+ #[arg(long)]+ mpn: Option<String>,+ /// Skip the post-import ds2sf+concur validation step.+ #[arg(long)]+ no_validate: bool,+ /// Where did you FIND the download link? e.g. "digikey" if you+ /// found the link on DigiKey's models page, even though the file+ /// was authored by Ultra Librarian. Chipsmith shows this in+ /// tooltips: "found on DigiKey → made by UL".+ #[arg(long, default_value = "unknown")]+ discovery_source: String,+ /// Who actually MADE the file? e.g. "manufacturer" if the STEP+ /// is from Abracon's own CDN, "ultralibrarian" if UL generated+ /// the KiCad bundle. This is the content attribution.+ #[arg(long, default_value = "unknown")]+ content_origin: String,+ /// URL of the page/download this file came from.+ #[arg(long, default_value = "")]+ source_url: String,+ },+ /// Pull a file from the user's desktop and import it into the library.+ /// e.g. `adom-chip-fetcher pull "C:\\Users\\john\\Downloads\\ul_TPS62840YBGR.zip" --mpn TPS62840YBGR`+ Pull {+ /// Windows/macOS path on the user's desktop+ remote_path: String,+ /// MPN to file under (default: infer from filename)+ #[arg(long)]+ mpn: Option<String>,+ /// Skip the post-pull ds2sf+concur validation step.+ #[arg(long)]+ no_validate: bool,+ /// Where did you FIND the download link?+ #[arg(long, default_value = "unknown")]+ discovery_source: String,+ /// Who actually MADE the file?+ #[arg(long, default_value = "unknown")]+ content_origin: String,+ /// URL of the page/download this file came from.+ #[arg(long, default_value = "")]+ source_url: String,+ },+ /// Deploy SKILL.md to ~/.claude/skills/adom-chip-fetcher/ so Claude knows how to use this tool.+ /// Run after `curl ... | install` to finish setup.+ Install,+ /// Run the viewer HTTP server. Open in any browser at http://localhost:<port>.+ Serve {+ #[arg(long, default_value_t = 8786)]+ port: u16,+ },+ /// Start the viewer + open it in a Hydrogen webview tab.+ App {+ #[arg(long, default_value_t = 8786)]+ port: u16,+ },+ /// Heartbeat the dashboard. Run before every state change so users see+ /// live progress. Stages are short labels: "navigating UL", "captcha+ /// solved", "downloading STEP", "imported", "stuck — needs login", etc.+ Heartbeat {+ mpn: String,+ stage: String,+ },+ /// Verify the rails before a fetch — dashboard reachable, heartbeat+ /// fresh, pup session 'adom-chip-fetcher' open. Fails with HINT lines so the+ /// next step is mechanical, not remembered.+ Selfcheck,+ /// Print a HINT line about what to do next, based on current state.+ /// Useful when an AI agent has lost the thread mid-batch.+ Hint,+ /// Print the canonical sourcing-ladder priority order (Mfr → SnapMagic →+ /// Mouser → DigiKey → Arrow → CSE/UL → LCSC). LCSC is LAST RESORT —+ /// avoid if possible. AI: read this with `adom-chip-fetcher sources` BEFORE+ /// falling back from one source to the next; do NOT skip ahead to LCSC.+ Sources,+ /// Hard gate: scan every chip in library/ and refuse to declare success+ /// while ANY card is incomplete (stub-only or missing core CAD). Returns+ /// non-zero exit when work remains. AI: ALWAYS run `adom-chip-fetcher gate`+ /// before reporting "done" on a batch — it prints exactly which files+ /// each card is missing, so claiming done is mechanical, not remembered.+ Gate {+ /// Strict mode: also require Altium IntLib + Fusion .lbr. Default+ /// mode requires datasheet PDF + STEP + KiCad sym + KiCad mod.+ #[arg(long)]+ strict: bool,+ /// Gate only this MPN (default: every chip in library/).+ #[arg(long)]+ mpn: Option<String>,+ },+ /// Detect manufacturer from MPN and open their canonical product /+ /// search page in pup. AI: run this BEFORE any third-party source.+ /// The mfr-first rule is enforced in code — `adom-chip-fetcher mfr-probe+ /// <MPN>` either opens the right URL or tells you we can't infer the+ /// manufacturer (in which case the AI must do its own probe). Auto-+ /// records the manufacturer-attempt on info.json.fetched_via_chain.+ MfrProbe {+ mpn: String,+ /// Print the URL only, don't open in pup. Useful for scripts.+ #[arg(long)]+ no_open: bool,+ },+ /// Print the next ladder step that hasn't been tried for this MPN,+ /// reading from info.json.fetched_via_chain. The state machine: if+ /// manufacturer hasn't been tried, the next source is "manufacturer".+ /// If LCSC is suggested, the binary verifies the full ladder above it+ /// has been recorded as failed first. AI: run this instead of+ /// remembering the order.+ NextSource {+ mpn: String,+ },+ /// Run ds2sf + concur on a chip directory and walk hints[] to drive+ /// autonomous self-repair. Loops on ds2sf needs_better_pdf candidates,+ /// re-runs ds2sf with stronger model on concur recommendation,+ /// applies update_upstream_metadata patches, marks symbol source as+ /// ds2sf-preferred when UL has stub pin names, and persists final+ /// status to <MPN>-validation-status.json. Surfaces to human cleanly+ /// when out of automated options.+ Validate {+ /// Chip MPN — directory must already exist at library/<MPN>/.+ mpn: String,+ },+ /// Drive the adom-chip-fetcher pup scrape session. Every action auto-+ /// heartbeats the dashboard, always uses `profile: "adom-chip-fetcher"`+ /// (NEVER a different profile — that loses vendor login cookies),+ /// and logs every step to the fetched_via_chain. The AI calls these+ /// instead of raw `adom-desktop browser_*` calls so the dashboard+ /// shows live progress and credentials are never lost.+ Scrape {+ #[command(subcommand)]+ op: ScrapeOp,+ },+ /// Manage stored vendor credentials. Saved to+ /// ~/.config/adom-chip-fetcher/credentials.json on the user's container.+ /// Survives across sessions — the user enters creds ONCE, adom-chip-fetcher+ /// can re-login automatically when cookies expire.+ Creds {+ #[command(subcommand)]+ op: CredsOp,+ },+ /// Print the full completeness ladder for one chip or the whole+ /// library. Shows every level (0-9) with ✓/✗ and what's missing at+ /// each level. The AI reads this output to decide what to do next+ /// instead of guessing.+ Done {+ /// Check only this MPN (default: every chip in library/).+ #[arg(long)]+ mpn: Option<String>,+ },+ /// Sweep every chip's <MPN>.pdf and report which ones are HTML+ /// masquerading as a PDF (Cloudflare denial pages, "click to accept"+ /// interstitials, 404 HTML, etc.). Reads first 5 bytes; rejects+ /// anything not starting with `%PDF-`. With --repair, deletes the+ /// bogus file + records the failure in info.json.fetched_via_chain+ /// so the next refetch knows the previous source returned garbage.+ /// Always run this after a batch fetch to catch the silent footgun+ /// that lands HTML to .pdf when a vendor wall-blocks curl.+ PdfCheck {+ /// Check only this MPN (default: every chip in library/).+ #[arg(long)]+ mpn: Option<String>,+ /// Delete bogus PDFs + record `html_masquerade` outcome in+ /// info.json.fetched_via_chain so next-source advances correctly.+ #[arg(long)]+ repair: bool,+ },+ /// Drive pup to a vendor page that wall-blocks curl, find the real+ /// PDF anchor on the rendered page, fetch it via `fetch().arrayBuffer()`+ /// inside the browser context (cookies + Cloudflare-passed Chrome+ /// fingerprint preserved), base64-transfer the bytes back, magic-byte+ /// validate, and save to `library/<MPN>/<MPN>.pdf`. The escape hatch+ /// for vendors that detect curl and serve HTML to it (Hanrun's Aliyun+ /// CDN, ST CDN, JST product pages, etc.) — proven pattern from the+ /// 2026-05-07 HR911105A recovery.+ PupPdfFetch {+ /// MPN to file the PDF under.+ mpn: String,+ /// Page URL to navigate to first. The PDF anchor is searched on+ /// this rendered page. If both --page-url and --pdf-url are given,+ /// --page-url is the referrer (cookies set), --pdf-url is fetched+ /// directly.+ #[arg(long)]+ page_url: Option<String>,+ /// Direct PDF URL to fetch via `fetch()`. Skips the page-anchor+ /// search step. Recommended when the URL is already known (from+ /// info.json.datasheet_url, a prior chain entry, etc.).+ #[arg(long)]+ pdf_url: Option<String>,+ /// Source slug for the chain ledger (manufacturer / snapmagic /+ /// mouser / digikey / arrow / cse_ul / lcsc). Default:+ /// `manufacturer`.+ #[arg(long, default_value = "manufacturer")]+ source: String,+ /// Pup session id (default: `cf-pdf-grab` — kept separate from+ /// the dashboard `adom-chip-fetcher` and the scrape `adom-chip-fetcher-scrape`+ /// sessions so this can run alongside an existing fetch).+ #[arg(long, default_value = "cf-pdf-grab")]+ session: String,+ },+ /// Re-fetch a specific library file from a specific source. Drives the+ /// vendor's product/CAD page in pup, watches ~/Downloads for the+ /// resulting bundle, and imports it. Used by `adom-chip-fetcher validate`+ /// when concur returns `refetch_library_source` — the loop calls this+ /// as a subprocess so a divergent kicad_sym can be replaced with one+ /// from a different upstream without a human in the loop.+ ///+ /// Sources accepted (canonical slugs): manufacturer, snapmagic, mouser,+ /// digikey, arrow, cse_ul, lcsc.+ /// Formats accepted: kicad_sym, kicad_mod, eagle, step, all.+ Refetch {+ mpn: String,+ /// What kind of file we want refreshed.+ #[arg(long)]+ format: String,+ /// Which ladder step to refetch from.+ #[arg(long)]+ source: String,+ /// Don't open pup; just print the canonical URL we'd drive to.+ /// Used by validate's autonomous loop to record provenance without+ /// stealing the user's screen.+ #[arg(long)]+ url_only: bool,+ /// How long to wait for a download to land in ~/Downloads (seconds).+ #[arg(long, default_value_t = 180)]+ timeout: u64,+ },+ /// Manage the dashboard surface — auto-(re)open it (default), force a+ /// fresh window, or set the surface preference.+ Dashboard {+ #[command(subcommand)]+ op: DashboardOp,+ },+ /// Generate thumbnail images (symbol SVG, footprint SVG, 3D chip PNG)+ /// for one MPN or the whole library. Idempotent: skips artifacts whose+ /// source hasn't changed since the last generation.+ Thumbnails {+ /// Generate for this single MPN (default: every chip in library/)+ #[arg(long)]+ mpn: Option<String>,+ /// Re-render even when the existing thumbnail is newer than its source+ #[arg(long)]+ force: bool,+ },+ /// Manage projects — scope adom-chip-fetcher work to a named project so the+ /// dashboard shows only the chips you care about right now.+ Project {+ #[command(subcommand)]+ op: ProjectOp,+ },+}++#[derive(Subcommand)]+enum ProjectOp {+ /// Create a new project+ Create {+ name: String,+ /// Short description of the project+ #[arg(long, default_value = "")]+ description: String,+ },+ /// List all projects+ List,+ /// Show chips in a project+ Show { name: String },+ /// Add an MPN to a project+ Add { name: String, mpn: String },+ /// Remove an MPN from a project+ Remove { name: String, mpn: String },+ /// Set the active project (filters the dashboard)+ Activate { name: String },+ /// Clear the active project (show full library)+ Deactivate,+ /// Delete a project (does not delete library chips)+ Delete { name: String },+}++#[derive(Subcommand)]+enum DashboardOp {+ /// Make sure the dashboard is up and visible. Idempotent. Pup by default.+ Ensure {+ /// Re-open even if a adom-chip-fetcher pup window is already detected.+ #[arg(long)]+ force: bool,+ },+ /// Set the surface preference (pup | webview). Saved at+ /// ~/.config/adom-chip-fetcher/dashboard.json. Pup is the default since+ /// adom-chip-fetcher needs pup anyway to drive vendor sites.+ SetSurface {+ /// Either "pup" or "webview"+ surface: String,+ },+ /// Print the current surface preference.+ Surface,+}++/// Every pup action the AI drives during a fetch. ALWAYS uses+/// `profile: "adom-chip-fetcher"` — hardcoded, non-overridable. This+/// preserves vendor login cookies across sessions and ensures the+/// dashboard heartbeat fires on every action.+#[derive(Subcommand)]+enum ScrapeOp {+ /// Navigate the scrape pup window to a URL. Auto-heartbeats.+ Navigate {+ mpn: String,+ url: String,+ /// Stage label for the heartbeat (default: "navigating <url>")+ #[arg(long)]+ stage: Option<String>,+ },+ /// Eval JS in the scrape pup window. Auto-heartbeats. Prints the+ /// result to stdout so the AI reads it back.+ Eval {+ mpn: String,+ expr: String,+ },+ /// Screenshot the scrape pup window. Saves to /tmp/ and prints path.+ Screenshot {+ mpn: String,+ },+ /// Open the scrape window (or re-open if dead). Always profile=adom-chip-fetcher.+ Open {+ mpn: String,+ url: String,+ },+}++#[derive(Subcommand)]+enum CredsOp {+ /// List stored credentials (shows host + username, masks password).+ List,+ /// Save a credential. Persists to ~/.config/adom-chip-fetcher/credentials.json.+ Save {+ /// Vendor host slug (snapeda, ultralib, componentsearch, ti, st, etc.)+ host: String,+ username: String,+ password: String,+ },+ /// Delete a stored credential by host.+ Delete { host: String },+}++pub fn run() -> Result<()> {+ let cli = Cli::parse();+ match cli.cmd {+ Cmd::Logins => cmd_logins(),+ Cmd::Cse { mpn, mfr } => cmd_cse(&mpn, mfr.as_deref()),+ Cmd::Describe { mpn, text } => cmd_describe(&mpn, &text),+ Cmd::Enrich { mpn } => cmd_enrich(&mpn),+ Cmd::Ds2sf { mpn } => cmd_ds2sf(&mpn),+ Cmd::Plan { mpns, name } => cmd_plan(&mpns, &name),+ Cmd::Fetch { mpn, project } => {+ if let Some(ref proj) = project {+ let _ = crate::projects::add_mpn(proj, &mpn);+ let _ = crate::projects::set_active(Some(proj));+ println!("project: {proj} (added {mpn})");+ }+ require_planned(&mpn)?;+ fetch(&mpn)+ }+ Cmd::Login { source } => login(&source),+ Cmd::List => list(),+ Cmd::Status { mpn } => status(&mpn),+ Cmd::Import { path, mpn, no_validate, discovery_source, content_origin, source_url } =>+ import_cmd(&path, mpn.as_deref(), no_validate, &discovery_source, &content_origin, &source_url),+ Cmd::Pull { remote_path, mpn, no_validate, discovery_source, content_origin, source_url } =>+ pull_cmd(&remote_path, mpn.as_deref(), no_validate, &discovery_source, &content_origin, &source_url),+ Cmd::Install => install_cmd(),+ Cmd::Serve { port } => serve::run(port),+ Cmd::App { port } => app_cmd(port),+ Cmd::Heartbeat { mpn, stage } => hints::heartbeat(&mpn, &stage),+ Cmd::Selfcheck => hints::selfcheck(),+ Cmd::Hint => hint_cmd(),+ Cmd::Sources => sources_cmd(),+ Cmd::Gate { strict, mpn } => gate_cmd(strict, mpn.as_deref()),+ Cmd::MfrProbe { mpn, no_open } => { require_planned(&mpn)?; mfr_probe_cmd(&mpn, no_open) }+ Cmd::NextSource { mpn } => next_source_cmd(&mpn),+ Cmd::Validate { mpn } => validate_cmd(&mpn),+ Cmd::Refetch { mpn, format, source, url_only, timeout } =>+ refetch_cmd(&mpn, &format, &source, url_only, timeout),+ Cmd::Done { mpn } => done_cmd(mpn.as_deref()),+ Cmd::Scrape { op } => scrape_cmd(op),+ Cmd::Creds { op } => creds_cmd(op),+ Cmd::PdfCheck { mpn, repair } => pdf_check_cmd(mpn.as_deref(), repair),+ Cmd::PupPdfFetch { mpn, page_url, pdf_url, source, session } =>+ pup_pdf_fetch_cmd(&mpn, page_url.as_deref(), pdf_url.as_deref(), &source, &session),+ Cmd::Dashboard { op } => dashboard_cmd(op),+ Cmd::Thumbnails { mpn, force } => thumbnails_cmd(mpn.as_deref(), force),+ Cmd::Project { op } => project_cmd(op),+ }+}++/// First-class CSE source: fetch the full CAD bundle via XHR + basic-auth (no+/// download, no prompt, no questions) and import it. The permanent version of+/// what unblocked the VL53Lx family.+fn cmd_cse(mpn: &str, mfr: Option<&str>) -> Result<()> {+ require_planned(mpn)?;+ let cred = crate::creds::load()?.into_iter()+ .find(|c| c.host.contains("componentsearchengine"))+ .ok_or_else(|| anyhow::anyhow!(+ "no Component Search Engine credential in the vault.\n \+ HINT: sign in to componentsearchengine.com once in your browser, or \+ `adom-chip-fetcher creds save componentsearchengine <user> <pass>`"+ ))?;+ let mfr = mfr.map(String::from)+ .or_else(|| sourcing::guess_mfr(mpn).map(|g| g.manufacturer.to_string()))+ .ok_or_else(|| anyhow::anyhow!("could not infer manufacturer for {mpn}; pass --mfr <name>"))?;+ // Keep the dashboard "working on" bar LIVE for the whole browser fetch —+ // cse_fetch_zip blocks ~40s on the XHR chain, so a single heartbeat would+ // go stale mid-fetch. The ticker re-pulses every 6s until it's stopped.+ let ticker = hints::HeartbeatTicker::start(mpn, &format!("CSE: fetching CAD via your browser ({mfr})"));+ let bytes = pup::cse_fetch_zip(mpn, &mfr, &cred.username, &cred.password)?;+ ticker.stop();+ let zip = std::env::temp_dir().join(format!("cse_{mpn}.zip"));+ std::fs::write(&zip, &bytes)?;+ // Keep the ORIGINAL downloaded archive in the library so the user can+ // inspect the exact bytes we pulled off the internet (folder structure, all+ // EDA formats, the 3D, etc.) — not just the files we extracted from it.+ let orig = library::root().join(mpn).join(format!("{mpn}.cse-bundle.zip"));+ let _ = std::fs::create_dir_all(orig.parent().unwrap());+ let _ = std::fs::write(&orig, &bytes);+ println!("OK: CSE zip {} KB → saved original + importing symbol+footprint+STEP", bytes.len() / 1024);+ let _ = hints::heartbeat(mpn, "importing CSE bundle");+ import_cmd(&zip, Some(mpn), false, "cse", "manufacturer", "")?;+ let _ = hints::heartbeat(mpn, "imported");+ // First-class metadata: pull description + stock + pricing from the Adom+ // parts tools (keys baked in — no API key). Best-effort; never fails the run.+ auto_enrich(mpn);+ // The DATASHEET is the most authoritative source — ALWAYS run ds2sf so every+ // fetched chip gets the independent datasheet extraction. Best-effort: a+ // ds2sf failure doesn't fail the CAD fetch, but the gate WILL flag it (so it+ // can never be silently skipped). Set CHIP_FETCHER_SKIP_DS2SF=1 to opt out.+ if std::env::var("CHIP_FETCHER_SKIP_DS2SF").is_err() {+ if let Err(e) = cmd_ds2sf(mpn) {+ println!("WARN: ds2sf did not complete for {mpn}: {e}");+ }+ }+ warn_if_no_description(mpn);+ Ok(())+}++/// Best-effort enrichment from the Adom parts tools after a fetch. Quietly+/// no-ops if adom-parts-search isn't reachable.+fn auto_enrich(mpn: &str) {+ let _ = hints::heartbeat(mpn, "enriching from adom-parts-search");+ match crate::enrich::enrich(mpn) {+ Ok(r) => {+ if let Some(d) = &r.description {+ println!(" enrich: description set — {}", d.chars().take(70).collect::<String>());+ }+ if r.wrote_stock { println!(" enrich: stock.json written (stock + pricing + lifecycle)"); }+ }+ Err(e) => println!(" enrich: skipped ({e})"),+ }+}++/// Read a chip's info.json.description (empty if none).+fn chip_description(mpn: &str) -> String {+ let p = library::root().join(mpn).join("info.json");+ std::fs::read_to_string(&p).ok()+ .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())+ .and_then(|v| v.get("description").and_then(|d| d.as_str()).map(String::from))+ .unwrap_or_default()+}++/// After sourcing, nudge the AI to set a human description if the chip has none+/// — the card + zoom view show it, and a blank "what is this chip" reads as+/// broken. Self-enforcing, same spirit as the gate/completeness warnings.+fn warn_if_no_description(mpn: &str) {+ if chip_description(mpn).trim().is_empty() {+ println!("HINT: {mpn} has no description — the card/zoom view will show a blank \+ 'what is this chip'. Set one: `adom-chip-fetcher describe {mpn} \"<one-line what it is>\"`");+ }+}++/// Run adom-ds2sf on a chip — the datasheet IS the most authoritative source.+/// Hard-guarantees a real datasheet first, then extracts + renders the variant.+fn cmd_ds2sf(mpn: &str) -> Result<()> {+ let dir = library::root().join(mpn);+ if !dir.exists() { anyhow::bail!("library/{mpn}/ does not exist"); }+ let pdf = dir.join(format!("{mpn}.pdf"));+ // GUARANTEE the datasheet is usable BEFORE spending a Claude extraction on it.+ match crate::import::datasheet_ready(&pdf) {+ Ok(q) => println!("OK: datasheet ready — {} pages, pinout detected", q.pages),+ Err(reason) => {+ hints::print_hint(&format!("{mpn}: datasheet NOT usable for ds2sf — {reason}. \+ Re-fetch a real datasheet (`adom-chip-fetcher pup-pdf-fetch {mpn} --pdf-url <mfr lit URL>`), then re-run."));+ anyhow::bail!("ds2sf needs a real datasheet for {mpn}: {reason}");+ }+ }+ let _ = hints::heartbeat(mpn, "ds2sf: extracting symbol+footprint from datasheet");+ let status = std::process::Command::new("adom-ds2sf")+ .args(["extract"]).arg(&dir).arg("--force").arg("--json-only")+ .status()+ .map_err(|e| anyhow::anyhow!("could not exec adom-ds2sf (install it): {e}"))?;+ if !status.success() {+ let result = std::fs::read_to_string(dir.join(format!("{mpn}-extraction.result.json"))).ok()+ .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());+ let why = result.as_ref().and_then(|r| r.get("summary").and_then(|s| s.as_str())).unwrap_or("see extraction.result.json");+ anyhow::bail!("ds2sf extraction failed for {mpn}: {why}");+ }+ // Render the ds2sf variant thumbnails so the carat + validation surface them.+ auto_render_thumbnails(mpn);+ let _ = hints::heartbeat(mpn, "ds2sf: extracted");+ println!("OK: {mpn} — ds2sf symbol + footprint extracted from the datasheet");+ Ok(())+}++/// Enrich a chip from the Adom parts tools and report what was written.+fn cmd_enrich(mpn: &str) -> Result<()> {+ let r = crate::enrich::enrich(mpn)?;+ match &r.description {+ Some(d) => println!("OK: {mpn} description — {d}"),+ None => println!("WARN: {mpn} — no description found via adom-parts-search"),+ }+ if r.wrote_stock { println!("OK: {mpn} stock.json written (stock + pricing + lifecycle)"); }+ if !r.variants.is_empty() {+ println!("OK: {mpn} orderable variants: {}", r.variants.join(", "));+ }+ Ok(())+}++/// Write the human description into library/<MPN>/info.json (creating the file+/// if needed). Shown on the dashboard card and the zoom focus view.+fn cmd_describe(mpn: &str, text: &str) -> Result<()> {+ let dir = library::root().join(mpn);+ if !dir.exists() {+ anyhow::bail!("library/{mpn}/ does not exist — source the chip first");+ }+ let p = dir.join("info.json");+ let mut info: serde_json::Value = std::fs::read_to_string(&p).ok()+ .and_then(|s| serde_json::from_str(&s).ok())+ .unwrap_or_else(|| serde_json::json!({}));+ info["description"] = serde_json::Value::String(text.to_string());+ std::fs::write(&p, serde_json::to_string_pretty(&info)?)?;+ println!("OK: {mpn} description set ({} chars)", text.len());+ Ok(())+}++/// First-class credential awareness: scan saved logins across every browser+/// profile, map to the ladder, report coverage + the best profile. The sourcing+/// flow auto-switches profiles per vendor using the same data.+fn cmd_logins() -> Result<()> {+ use std::collections::{BTreeMap, BTreeSet};+ let creds = pup::credentials();+ let mut by: BTreeMap<String, BTreeMap<&str, BTreeSet<String>>> = BTreeMap::new();+ for c in creds {+ for (frag, name) in pup::VENDOR_HOSTS {+ if c.host.contains(frag) {+ by.entry(format!("{} · {}", c.browser, c.profile))+ .or_default().entry(*name).or_default()+ .insert(if c.username.is_empty() { "(saved)".into() } else { c.username.clone() });+ }+ }+ }+ if by.is_empty() {+ println!("No saved CAD/distributor logins found in the native browser.");+ println!("HINT: install/enable the Adom browser extension, or sign in to a vendor once.");+ return Ok(());+ }+ let mut ranked: Vec<_> = by.into_iter().collect();+ ranked.sort_by_key(|(_, v)| std::cmp::Reverse(v.len()));+ println!("adom-chip-fetcher — saved CAD/distributor logins (read-only; never passwords):");+ for (prof, vmap) in &ranked {+ println!("\n [{prof}] — {} vendor(s):", vmap.len());+ for (name, users) in vmap {+ println!(" {name:16} {}", users.iter().cloned().collect::<Vec<_>>().join(", "));+ }+ }+ let (best, bv) = &ranked[0];+ println!("\nBEST profile for sourcing: {best} ({} vendors). adom-chip-fetcher auto-switches \+ to the right signed-in profile per vendor as it sources — no questions.", bv.len());++ // Per-BROWSER score: which native browser (Chrome vs Edge) covers the most+ // of the electronics industry across all its profiles.+ let total_vendors = pup::VENDOR_HOSTS.iter().map(|(_, n)| *n).collect::<BTreeSet<_>>().len();+ let mut by_browser: BTreeMap<String, BTreeSet<&str>> = BTreeMap::new();+ for c in creds {+ for (frag, name) in pup::VENDOR_HOSTS {+ if c.host.contains(frag) { by_browser.entry(c.browser.clone()).or_default().insert(*name); }+ }+ }+ let mut bscore: Vec<_> = by_browser.into_iter().collect();+ bscore.sort_by_key(|(_, v)| std::cmp::Reverse(v.len()));+ println!("\n=== Native-browser industry-coverage score ===");+ for (br, v) in &bscore {+ println!(" {:6} {}/{} vendors ({})", br, v.len(), total_vendors, v.iter().cloned().collect::<Vec<_>>().join(", "));+ }+ if let Some((winner, v)) = bscore.first() {+ println!("\nWINNER: drive in **{winner}** — broadest electronics-industry login coverage ({}/{} vendors).", v.len(), total_vendors);+ }+ Ok(())+}++/// Seed the user-visible plan + planned-state cards BEFORE any scraping, so the+/// user sees the whole queue up front. Required first step of every batch.+fn cmd_plan(mpns: &[String], name: &str) -> Result<()> {+ if mpns.is_empty() {+ anyhow::bail!("no MPNs given.\n Usage: adom-chip-fetcher plan <MPN1> <MPN2> ... [--name <plan>]");+ }+ let _ = crate::projects::create_project(name, "adom-chip-fetcher plan");+ crate::projects::set_active(Some(name))?;+ if let Err(e) = hints::dashboard_ensure(false) {+ println!("WARN: could not auto-open the dashboard ({e}); start it with `adom-chip-fetcher serve`");+ }+ println!("PLAN '{name}' — {} chip(s) queued (the user sees these as planned cards now):", mpns.len());+ for (i, mpn) in mpns.iter().enumerate() {+ let _ = crate::projects::add_mpn(name, mpn);+ let _ = hints::heartbeat(mpn, "planned"); // side-effect: seeds the stub card+ println!(" {}. {mpn}", i + 1);+ }+ println!("\nNow source them — manufacturer-first, and every verb heartbeats live to the dashboard:");+ println!(" adom-chip-fetcher mfr-probe <MPN> # required first source (the maker)");+ println!(" adom-chip-fetcher next-source <MPN> # the next ladder step to try");+ println!(" adom-chip-fetcher gate # refuses 'done' while any card is incomplete");+ Ok(())+}++/// PREFLIGHT GUARD — every browser-driving verb (fetch / mfr-probe / scrape)+/// calls this FIRST. It refuses (non-zero exit + HINT) to scrape any MPN the+/// user hasn't seen in an active plan, and refuses if the dashboard isn't live+/// to show working status. This is the programmatic backstop that makes+/// "willy-nilly AI scraping with no insight" structurally impossible — for this+/// agent and for every third party that drives the tool.+fn require_planned(mpn: &str) -> Result<()> {+ let active = crate::projects::get_active().ok_or_else(|| anyhow::anyhow!(+ "REFUSED: no active plan — the user must see the queue before any scraping.\n \+ HINT: adom-chip-fetcher plan {mpn} [more MPNs...] then source."+ ))?;+ if !crate::projects::mpns_for_project(&active).iter().any(|m| m == mpn) {+ anyhow::bail!(+ "REFUSED: {mpn} is not in the active plan '{active}'. Don't scrape chips the user never saw planned.\n \+ HINT: adom-chip-fetcher plan {mpn} --name {active}"+ );+ }+ if !dashboard_reachable() {+ anyhow::bail!(+ "REFUSED: the dashboard isn't reachable on :8786, so the user would get no working-status insight.\n \+ HINT: adom-chip-fetcher serve (then retry) — the dashboard is the user's home base."+ );+ }+ Ok(())+}++fn dashboard_reachable() -> bool {+ use std::net::TcpStream;+ use std::time::Duration;+ "127.0.0.1:8786"+ .parse()+ .ok()+ .and_then(|addr| TcpStream::connect_timeout(&addr, Duration::from_millis(800)).ok())+ .is_some()+}++fn project_cmd(op: ProjectOp) -> Result<()> {+ use crate::projects;+ match op {+ ProjectOp::Create { name, description } => {+ projects::create_project(&name, &description)?;+ println!("OK: created project '{name}'");+ }+ ProjectOp::List => {+ let active = projects::get_active();+ let projs = projects::list_projects();+ if projs.is_empty() {+ println!("no projects. create one with: adom-chip-fetcher project create <name>");+ }+ for p in &projs {+ let marker = if active.as_deref() == Some(&p.name) { " (active)" } else { "" };+ println!(" {} — {} chips{}", p.name, p.mpns.len(), marker);+ }+ }+ ProjectOp::Show { name } => {+ match projects::get_project(&name) {+ Some(p) => {+ println!("project: {}", p.name);+ if !p.description.is_empty() { println!(" {}", p.description); }+ println!(" chips ({}):", p.mpns.len());+ for m in &p.mpns { println!(" {m}"); }+ }+ None => println!("project '{name}' not found"),+ }+ }+ ProjectOp::Add { name, mpn } => {+ projects::add_mpn(&name, &mpn)?;+ println!("OK: added {mpn} to project '{name}'");+ }+ ProjectOp::Remove { name, mpn } => {+ projects::remove_mpn(&name, &mpn)?;+ println!("OK: removed {mpn} from project '{name}'");+ }+ ProjectOp::Activate { name } => {+ projects::set_active(Some(&name))?;+ println!("OK: active project set to '{name}'");+ hints::print_hint("dashboard will now filter to this project's chips");+ }+ ProjectOp::Deactivate => {+ projects::set_active(None)?;+ println!("OK: active project cleared — dashboard shows full library");+ }+ ProjectOp::Delete { name } => {+ projects::delete_project(&name)?;+ println!("OK: deleted project '{name}' (library chips untouched)");+ }+ }+ Ok(())+}++fn thumbnails_cmd(mpn: Option<&str>, force: bool) -> Result<()> {+ if let Some(m) = mpn {+ let _ = hints::heartbeat(m, "rendering thumbnails");+ let r = thumbnails::generate_for(m, force)?;+ print_thumb_report(&r);+ let _ = hints::heartbeat(m, "thumbnails ready");+ } else {+ println!("rendering thumbnails for every chip in library/...");+ let reports = thumbnails::generate_all(force);+ let mut total_gen = 0;+ let mut total_skip = 0;+ let mut total_err = 0;+ for r in &reports {+ print_thumb_report(r);+ total_gen += r.generated.len();+ total_skip += r.skipped.len();+ total_err += r.errors.len();+ }+ println!();+ println!("OK: {} chips processed — {total_gen} generated, {total_skip} skipped (up-to-date), {total_err} errors", reports.len());+ hints::print_hint("open the dashboard to see them: `adom-chip-fetcher dashboard ensure`");+ }+ Ok(())+}++fn print_thumb_report(r: &thumbnails::GenReport) {+ let parts: Vec<String> = r.generated.iter().map(|s| format!("+{s}")).chain(+ r.skipped.iter().map(|s| format!("={s}"))+ ).chain(+ r.errors.iter().map(|(k,_)| format!("!{k}"))+ ).collect();+ println!(" {:<24} {}", r.mpn, parts.join(" "));+ for (k, e) in &r.errors {+ println!(" ERR {k}: {e}");+ }+}++fn dashboard_cmd(op: Cmd_) -> Result<()> { dashboard_dispatch(op) }++// Shim: clap's `#[command(subcommand)]` argument needs the parent type to+// resolve, so we route through this small helper.+type Cmd_ = DashboardOp;++fn dashboard_dispatch(op: DashboardOp) -> Result<()> {+ match op {+ DashboardOp::Ensure { force } => hints::dashboard_ensure(force),+ DashboardOp::SetSurface { surface } => {+ let parsed = match surface.trim().to_lowercase().as_str() {+ "pup" => hints::DashboardSurface::Pup,+ "webview" | "hydrogen" => hints::DashboardSurface::Webview,+ _ => anyhow::bail!("surface must be 'pup' or 'webview'"),+ };+ hints::set_dashboard_surface(parsed)?;+ hints::print_hint("verify with `adom-chip-fetcher dashboard ensure`");+ Ok(())+ }+ DashboardOp::Surface => {+ let s = hints::get_dashboard_surface();+ println!("dashboard surface: {:?}", s);+ hints::print_hint("change with `adom-chip-fetcher dashboard set-surface pup|webview`");+ Ok(())+ }+ }+}++/// Full completeness ladder for one chip or the whole library.+/// 9 levels, each checked from the filesystem. The AI reads this to+/// decide what to do next — no guessing, no "I think it's done."+fn done_cmd(mpn_filter: Option<&str>) -> Result<()> {+ let lib = inventory::scan()?;+ let chips: Vec<_> = lib.chips.into_iter()+ .filter(|c| mpn_filter.is_none_or(|m| c.mpn == m))+ .collect();+ if chips.is_empty() {+ if let Some(m) = mpn_filter { anyhow::bail!("no chip {m} in library"); }+ println!("library is empty."); return Ok(());+ }++ for c in &chips {+ let dir = crate::library::root().join(&c.mpn);+ let has_stock = dir.join("stock.json").exists();+ let has_prov = dir.join(format!("{}-file-provenance.json", c.mpn)).exists();+ let has_val_status = c.validation_status.is_some();+ let val_golden = matches!(c.validation_status.as_deref(), Some("golden") | Some("golden_with_variants"));+ let has_thumbs = c.has_thumb_symbol && c.has_thumb_footprint && c.has_thumb_chip3d;++ let levels: [(u8, &str, bool, &str); 9] = [+ (1, "Datasheet PDF", c.has_pdf, if c.has_pdf { "" } else { "fetch <MPN>.pdf from mfr → SnapMagic → Mouser → DigiKey" }),+ (2, "3D STEP model", c.has_step, if c.has_step { "" } else { "fetch STEP from mfr or DigiKey models page" }),+ (3, "KiCad sym+mod", c.has_sym && c.has_mod, if c.has_sym && c.has_mod { "" } else {+ if !c.has_sym && !c.has_mod { "fetch .kicad_sym + .kicad_mod from UL/SnapMagic/stdlib" }+ else if !c.has_sym { "fetch .kicad_sym" } else { "fetch .kicad_mod" }+ }),+ (4, "Altium library", c.has_altium || c.altium_not_available, if c.has_altium { "" } else if c.altium_not_available { "(not available from any source)" } else { "fetch .IntLib or .SchLib+.PcbLib from SnapMagic/CSE" }),+ (5, "Fusion .lbr", c.has_fusion_lbr || c.fusion_not_available, if c.has_fusion_lbr { "" } else if c.fusion_not_available { "(not available from any source)" } else { "fetch .lbr from SnapMagic/CSE" }),+ (6, "Thumbnails", has_thumbs, if has_thumbs { "" } else { "`adom-chip-fetcher thumbnails --mpn <MPN> --force`" }),+ (7, "Validated", val_golden, if val_golden { "" } else if has_val_status { "re-run: `adom-chip-fetcher validate <MPN>`" } else { "run: `adom-chip-fetcher validate <MPN>` (needs PDF + sym + mod)" }),+ (8, "Mouser stock", has_stock, if has_stock { "" } else { "fetch stock.json from Mouser API or product page" }),+ (9, "File provenance", has_prov, if has_prov { "" } else { "re-import with --discovery-source + --content-origin flags" }),+ ];++ let done_count = levels.iter().filter(|(_, _, ok, _)| *ok).count();+ println!("{} — {}/9 levels complete", c.mpn, done_count);+ for (num, name, ok, fix) in &levels {+ let mark = if *ok { "✓" } else { "✗" };+ let fix_hint = if fix.is_empty() { String::new() } else { format!(" → {fix}") };+ println!(" {mark} {num}. {name}{fix_hint}");+ }+ if done_count < 9 {+ let next = levels.iter().find(|(_, _, ok, _)| !*ok);+ if let Some((_, name, _, fix)) = next {+ println!();+ hints::print_hint(&format!("next step for {}: {} — {}", c.mpn, name, fix));+ }+ }+ println!();+ }++ let total = chips.len();+ let fully = chips.iter().filter(|c| {+ let dir = crate::library::root().join(&c.mpn);+ c.has_pdf && c.has_step && c.has_sym && c.has_mod && (c.has_altium || c.altium_not_available) && (c.has_fusion_lbr || c.fusion_not_available)+ && c.has_thumb_symbol && c.has_thumb_footprint && c.has_thumb_chip3d+ && matches!(c.validation_status.as_deref(), Some("golden") | Some("golden_with_variants"))+ && dir.join("stock.json").exists()+ && dir.join(format!("{}-file-provenance.json", c.mpn)).exists()+ }).count();+ if total > 1 {+ println!("SUMMARY: {fully}/{total} chips at level 9 (fully complete)");+ }+ Ok(())+}++// ---- Hardcoded profile for all pup scraping ----+// The user's vendor login cookies (SnapEDA, UL, CSE, TI, ST, Mouser,+// DigiKey) live in the "adom-chip-fetcher" Chrome profile. Creating ANY+// other profile loses every credential. This constant is the single+// source of truth — every scrape/refetch/pup-pdf-fetch path uses it.+const SCRAPE_PROFILE: &str = "adom-chip-fetcher";+const SCRAPE_SESSION: &str = "adom-chip-fetcher-scrape";++fn scrape_cmd(op: ScrapeOp) -> Result<()> {+ match op {+ ScrapeOp::Open { mpn, url } => {+ require_planned(&mpn)?;+ let _ = hints::heartbeat(&mpn, &format!("scrape: opening {}", &url[..url.len().min(60)]));+ // ONE window: the scrape tab lives beside the dashboard in the+ // adom-chip-fetcher window (native) — no separate scrape window.+ pup::scrape_navigate(&url)?;+ println!("OK: scrape tab open at {url} ({})", pup::surface_label());+ hints::print_hint(&format!("page is loading. Next: `adom-chip-fetcher scrape eval {mpn} '<JS>'` to inspect, or `adom-chip-fetcher scrape screenshot {mpn}`."));+ Ok(())+ }+ ScrapeOp::Navigate { mpn, url, stage } => {+ require_planned(&mpn)?;+ let label = stage.unwrap_or_else(|| format!("navigating {}", &url[..url.len().min(50)]));+ let _ = hints::heartbeat(&mpn, &label);+ pup::scrape_navigate(&url)?;+ println!("OK: navigated to {url}");+ hints::print_hint(&format!("page loaded. Next: eval JS to find download links, or screenshot. Check `adom-chip-fetcher next-source {mpn}` if this source lacks what you need."));+ Ok(())+ }+ ScrapeOp::Eval { mpn, expr } => {+ let short = if expr.len() > 40 { format!("{}…", &expr[..40]) } else { expr.clone() };+ let _ = hints::heartbeat(&mpn, &format!("scrape eval: {short}"));+ let result = pup::scrape_eval(&expr)?;+ println!("{}", serde_json::to_string_pretty(&result)?);+ hints::print_hint(&format!("read the result. For a PDF URL use `adom-chip-fetcher pup-pdf-fetch {mpn} --pdf-url <url>`; for CAD use `adom-chip-fetcher refetch {mpn} --format <fmt> --source <src>`."));+ Ok(())+ }+ ScrapeOp::Screenshot { mpn } => {+ let _ = hints::heartbeat(&mpn, "scrape: taking screenshot");+ let v = pup::scrape_screenshot()?;+ let path = v.get("savedTo").and_then(|p| p.as_str())+ .or_else(|| v.get("output").and_then(|o| o.as_str()))+ .unwrap_or("(captured)");+ println!("OK: screenshot {path}");+ hints::print_hint("look at the screenshot. If a modal blocks, dismiss it with `scrape eval` first.");+ Ok(())+ }+ }+}++fn creds_cmd(op: CredsOp) -> Result<()> {+ match op {+ CredsOp::List => {+ let creds = crate::creds::load()?;+ if creds.is_empty() {+ println!("no stored credentials");+ println!();+ hints::print_hint("save creds with: adom-chip-fetcher creds save <host> <username> <password>");+ hints::print_hint("hosts: snapeda, ultralib, componentsearch, ti, st, microchip, google");+ } else {+ println!("stored credentials ({}):", creds.len());+ for c in &creds {+ let masked = if c.password.len() > 2 {+ format!("{}…{}", &c.password[..1], &c.password[c.password.len()-1..])+ } else { "***".into() };+ println!(" {:<20} user={:<25} pass={}", c.host, c.username, masked);+ }+ }+ Ok(())+ }+ CredsOp::Save { host, username, password } => {+ crate::creds::set(&host, &username, &password)?;+ println!("OK: saved credential for {host} (user={username})");+ println!("stored at ~/.config/adom-chip-fetcher/credentials.json");+ Ok(())+ }+ CredsOp::Delete { host } => {+ crate::creds::delete(&host)?;+ println!("OK: deleted credential for {host}");+ Ok(())+ }+ }+}++/// PDF magic-byte sweep. Walks every `<MPN>.pdf` in library/ and reports+/// which ones don't actually start with `%PDF-`. The bug this catches:+/// a vendor (LCSC, Cloudflare-walled mfr sites, "click to download"+/// interstitial pages) silently returns an HTML body for what looks like+/// a 200 OK PDF response, the off-band download writes those bytes to+/// `<MPN>.pdf`, and downstream tools (ds2sf, chipsmith) then choke on+/// what's not a real PDF. Real example caught 2026-05-07: HR911105A.pdf+/// was 47 KB of <!DOCTYPE html> from an LCSC mirror that 404'd.+///+/// On --repair: deletes the bogus PDF + appends an `html_masquerade`+/// failure entry to info.json.fetched_via_chain so `adom-chip-fetcher+/// next-source` correctly advances to the next ladder step.+fn pdf_check_cmd(mpn_filter: Option<&str>, repair: bool) -> Result<()> {+ let lib = library::root();+ if !lib.exists() {+ anyhow::bail!("library does not exist at {}", lib.display());+ }+ let mut total = 0usize;+ let mut bogus: Vec<(String, usize, String)> = Vec::new(); // (mpn, size, first_80_bytes)+ let mut clean = 0usize;+ let mut missing = 0usize;++ for entry in std::fs::read_dir(&lib)? {+ let entry = entry?;+ let path = entry.path();+ if !path.is_dir() { continue; }+ let mpn = match path.file_name().and_then(|n| n.to_str()) {+ Some(m) => m.to_string(),+ None => continue,+ };+ if let Some(filter) = mpn_filter {+ if mpn != filter { continue; }+ }+ let pdf = path.join(format!("{mpn}.pdf"));+ if !pdf.is_file() { missing += 1; continue; }+ total += 1;++ // Read first 80 bytes — enough for magic + diagnostic preview.+ let mut head = [0u8; 80];+ let read = match std::fs::File::open(&pdf)+ .and_then(|mut f| std::io::Read::read(&mut f, &mut head))+ {+ Ok(n) => n,+ Err(_) => continue,+ };+ let head_slice = &head[..read];++ if head_slice.starts_with(b"%PDF-") {+ clean += 1;+ continue;+ }++ let size = std::fs::metadata(&pdf).map(|m| m.len() as usize).unwrap_or(0);+ let preview: String = head_slice.iter().take(80)+ .map(|b| if b.is_ascii() && !b.is_ascii_control() { *b as char } else { '·' })+ .collect();+ bogus.push((mpn.clone(), size, preview.clone()));++ if repair {+ let _ = std::fs::remove_file(&pdf);+ // Try to record the failure on info.json.fetched_via_chain+ // so next-source knows the prior datasheet attempt was bogus.+ // Best-effort: pick the most recent successful chain entry's+ // source as the failed one (since that's where the file came+ // from), or fall back to "unknown" if chain is empty.+ let chain = sourcing::read_chain(&mpn).unwrap_or_default();+ let suspect_source = chain.iter()+ .rev()+ .find(|e| e.outcome == "OK" || e.outcome.contains("OK") || e.outcome == "lcsc_last_resort")+ .map(|e| e.source.clone());+ if let Some(s) = suspect_source {+ let src_enum = match s.as_str() {+ "manufacturer" => sourcing::Source::Manufacturer,+ "snapmagic" => sourcing::Source::SnapMagic,+ "mouser" => sourcing::Source::Mouser,+ "digikey" => sourcing::Source::DigiKey,+ "arrow" => sourcing::Source::Arrow,+ "cse_ul" | "cse" => sourcing::Source::CseUl,+ "lcsc" => sourcing::Source::Lcsc,+ _ => sourcing::Source::Manufacturer,+ };+ let _ = sourcing::append_chain(&mpn, src_enum, "html_masquerade",+ Some(&format!("PDF was actually HTML — first 80 bytes: {}", preview.chars().take(60).collect::<String>())));+ }+ }+ }++ println!("PDF check: {clean}/{total} real PDFs ({missing} chips have no PDF){}",+ if repair { "; --repair active" } else { "" });+ if !bogus.is_empty() {+ println!();+ println!("Bogus PDFs (HTML masquerading as %PDF-):");+ for (mpn, sz, preview) in &bogus {+ let action = if repair { " → DELETED" } else { "" };+ println!(" ❌ {mpn} ({sz} bytes){action}");+ println!(" first 60 bytes: {}", &preview[..preview.len().min(60)]);+ }+ println!();+ if repair {+ hints::print_hint("bogus PDFs deleted + info.json.fetched_via_chain updated. Run `adom-chip-fetcher next-source <MPN>` to see what to try next.");+ } else {+ hints::print_hint("re-run with --repair to delete bogus PDFs + record html_masquerade in fetched_via_chain. Then refetch from next ladder source.");+ }+ anyhow::bail!("found {} bogus PDF(s)", bogus.len());+ }++ Ok(())+}++/// Drive pup to a vendor page, find the real PDF anchor on the rendered+/// page (cookies + Cloudflare-passed Chrome fingerprint preserved), pull+/// the bytes via `fetch().arrayBuffer()` inside the browser context,+/// base64-transfer them back, magic-byte validate, save to+/// `library/<MPN>/<MPN>.pdf`. The escape hatch for vendors that detect+/// curl and serve HTML to it (Hanrun's Aliyun CDN, ST CDN, JST product+/// pages, etc.).+///+/// Decision matrix:+/// --pdf-url given → fetch that URL directly (most reliable)+/// --page-url given (no pdf-url) → navigate, find first PDF anchor matching MPN+/// neither given → use sourcing::guess_mfr to pick a search URL+fn pup_pdf_fetch_cmd(+ mpn: &str,+ page_url: Option<&str>,+ pdf_url: Option<&str>,+ source: &str,+ session: &str,+) -> Result<()> {+ let dir = library::root().join(mpn);+ if !dir.is_dir() {+ anyhow::bail!("no chip directory at {} — heartbeat or import once first to create it", dir.display());+ }++ let source_enum = match source.to_ascii_lowercase().as_str() {+ "manufacturer" | "mfr" => sourcing::Source::Manufacturer,+ "snapmagic" | "snapeda" => sourcing::Source::SnapMagic,+ "mouser" => sourcing::Source::Mouser,+ "digikey" => sourcing::Source::DigiKey,+ "arrow" => sourcing::Source::Arrow,+ "cse" | "cse_ul" | "ul" | "ultralibrarian" => sourcing::Source::CseUl,+ "lcsc" => sourcing::Source::Lcsc,+ other => anyhow::bail!("unknown source slug {other:?}"),+ };++ let _ = session; // legacy arg; native fetch uses the one adom-chip-fetcher window+ // Resolve the PDF URL: prefer --pdf-url; else open --page-url (or the mfr+ // product page) in the adom-chip-fetcher window's scrape tab and read the+ // datasheet anchor off the rendered page. Everything stays in ONE window.+ let resolved_pdf = if let Some(p) = pdf_url {+ p.to_string()+ } else {+ let nav_url = page_url.map(|s| s.to_string())+ .or_else(|| sourcing::guess_mfr(mpn).map(|g| g.search_url))+ .ok_or_else(|| anyhow::anyhow!("pass --pdf-url or --page-url for {mpn}"))?;+ let _ = hints::heartbeat(mpn, &format!("pup-pdf-fetch from {source}: finding datasheet"));+ pup::scrape_navigate(&nav_url)?;+ std::thread::sleep(std::time::Duration::from_secs(4));+ let r = pup::scrape_eval(+ "(function(){var a=[...document.querySelectorAll('a')].find(function(x){return /\\/resource\\/.*\\.pdf/i.test(x.href)||(/datasheet/i.test(x.textContent)&&/\\.pdf/i.test(x.href))});return a?a.href:''})()"+ )?;+ let url = r.as_str().unwrap_or("").to_string();+ if url.is_empty() {+ let _ = sourcing::append_chain(mpn, source_enum, "pup_no_pdf_link",+ Some(&format!("no datasheet anchor on {nav_url}")));+ anyhow::bail!("no datasheet link found on {nav_url}; pass --pdf-url <direct-url>");+ }+ println!("OK: found datasheet: {url}");+ url+ };++ // Fetch the bytes THROUGH the signed-in browser (real cookies, no download,+ // no native viewer) straight into the library.+ let _ = hints::heartbeat(mpn, &format!("pup-pdf-fetch from {source}: downloading datasheet"));+ let target = dir.join(format!("{mpn}.pdf"));+ let (n, ctype) = pup::fetch_url_to_file(&resolved_pdf, &target)?;+ let head: Vec<u8> = std::fs::read(&target).ok().map(|b| b.into_iter().take(4).collect()).unwrap_or_default();+ if head != b"%PDF" {+ std::fs::remove_file(&target).ok();+ let _ = sourcing::append_chain(mpn, source_enum, "pup_fetch_not_pdf",+ Some(&format!("content-type {ctype}, {n} bytes, head {:?}", String::from_utf8_lossy(&head))));+ anyhow::bail!("fetched bytes are not a PDF (content-type {ctype}, {n} bytes)");+ }+ println!("OK: wrote {} ({} KB, {source})", target.display(), n / 1024);+ let _ = sourcing::append_chain(mpn, source_enum, "OK",+ Some(&format!("datasheet {} KB via fetch_url, %PDF verified", n / 1024)));+ let _ = hints::heartbeat(mpn, "imported");+ Ok(())+}++/// Tiny base64 decoder. Avoids pulling in the `base64` crate for one+/// call site. Spec: RFC 4648 + Standard alphabet, ignores whitespace.+fn base64_decode(s: &str) -> std::result::Result<Vec<u8>, String> {+ const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";+ let mut lookup = [255u8; 256];+ for (i, &c) in ALPHA.iter().enumerate() { lookup[c as usize] = i as u8; }+ let mut out = Vec::with_capacity(s.len() * 3 / 4);+ let bytes: Vec<u8> = s.bytes().filter(|b| !b.is_ascii_whitespace() && *b != b'=').collect();+ let mut i = 0;+ while i + 3 < bytes.len() {+ let (a, b, c, d) = (lookup[bytes[i] as usize], lookup[bytes[i+1] as usize],+ lookup[bytes[i+2] as usize], lookup[bytes[i+3] as usize]);+ if a == 255 || b == 255 || c == 255 || d == 255 {+ return Err(format!("invalid char at byte {i}"));+ }+ out.push((a << 2) | (b >> 4));+ out.push((b << 4) | (c >> 2));+ out.push((c << 6) | d);+ i += 4;+ }+ // Tail bytes (handles 2- and 3-byte residue without padding).+ if i + 1 < bytes.len() {+ let a = lookup[bytes[i] as usize];+ let b = lookup[bytes[i+1] as usize];+ out.push((a << 2) | (b >> 4));+ if i + 2 < bytes.len() {+ let c = lookup[bytes[i+2] as usize];+ out.push((b << 4) | (c >> 2));+ }+ }+ Ok(out)+}++/// Re-fetch driver. Opens the canonical URL for (source, MPN) in the+/// adom-chip-fetcher-scrape pup session, sets up a desktop_watch_files for the+/// expected download, waits up to `timeout` seconds, then imports the+/// resulting file with magic-byte validation. Records every attempt on+/// `info.json.fetched_via_chain` so the audit trail shows which sources+/// were probed in what order.+fn refetch_cmd(mpn: &str, format: &str, source: &str, url_only: bool, timeout: u64) -> Result<()> {+ let dir = library::root().join(mpn);+ if !dir.is_dir() {+ anyhow::bail!("no chip directory at {}", dir.display());+ }++ let source_enum = match source.to_ascii_lowercase().as_str() {+ "manufacturer" | "mfr" => sourcing::Source::Manufacturer,+ "snapmagic" | "snapeda" => sourcing::Source::SnapMagic,+ "mouser" => sourcing::Source::Mouser,+ "digikey" => sourcing::Source::DigiKey,+ "arrow" => sourcing::Source::Arrow,+ "cse" | "cse_ul" | "ul" | "ultralibrarian" => sourcing::Source::CseUl,+ "lcsc" => sourcing::Source::Lcsc,+ other => anyhow::bail!("unknown source slug {other:?}; valid: manufacturer, snapmagic, mouser, digikey, arrow, cse_ul, lcsc"),+ };++ let url = canonical_source_url(mpn, source_enum)?;+ println!("OK: refetch {mpn} format={format} source={source}");+ println!("URL: {url}");++ if url_only {+ // Used by validate.rs::attempt_refetch_library so the autonomous+ // loop can record what would be tried without opening a browser+ // window in the middle of validation. Returns the URL via stdout+ // for the caller; chain entry written below.+ let _ = sourcing::append_chain(mpn, source_enum, "refetch_url_resolved",+ Some(&format!("format={format}, url={url}")));+ return Ok(());+ }++ // Real refetch — open URL, watch downloads, import.+ let _ = hints::heartbeat(mpn, &format!("refetch {format} from {source}: opening pup"));+ let body = serde_json::json!({+ "sessionId": "adom-chip-fetcher-scrape",+ "profile": "adom-chip-fetcher",+ "url": url,+ }).to_string();+ let out = std::process::Command::new("adom-desktop")+ .args(["browser_open_window", &body])+ .output()?;+ if !out.status.success() {+ let _ = sourcing::append_chain(mpn, source_enum, "refetch_failed",+ Some(&format!("could not open pup window for {url}")));+ anyhow::bail!("could not open pup window: {}", String::from_utf8_lossy(&out.stderr));+ }++ println!("watching ~/Downloads for {timeout}s — click the source's download button to land the file");+ let glob = match format {+ "kicad_sym" | "kicad_mod" | "eagle" | "all" => "*.zip",+ "step" => "*.step",+ "pdf" => "*.pdf",+ _ => "*",+ };+ let watch_body = serde_json::json!({+ "path": "Downloads",+ "glob": glob,+ "timeoutMs": timeout * 1000,+ }).to_string();+ let watch_out = std::process::Command::new("adom-desktop")+ .args(["desktop_watch_files", &watch_body])+ .output()?;+ if !watch_out.status.success() {+ let _ = sourcing::append_chain(mpn, source_enum, "refetch_timeout",+ Some(&format!("desktop_watch_files exit non-zero (likely user did not click within {timeout}s)")));+ anyhow::bail!("desktop_watch_files timed out for {url}");+ }++ let watch_v: serde_json::Value = serde_json::from_slice(&watch_out.stdout)?;+ let landed = watch_v.get("files")+ .and_then(|f| f.as_array())+ .and_then(|a| a.first())+ .and_then(|f| f.get("path"))+ .and_then(|p| p.as_str())+ .ok_or_else(|| anyhow::anyhow!("desktop_watch_files returned no file path"))?;++ println!("OK: refetch landed {landed}");+ let pulled = pup::pull_file(landed, dir.parent().unwrap_or(&dir).to_str().unwrap())?;+ let report = import::import_path(&pulled, Some(mpn))?;+ println!("imported into {} (+{} files)", report.dest_dir.display(), report.files_added.len());++ let _ = sourcing::append_chain(mpn, source_enum, "OK",+ Some(&format!("refetched {format} via refetch_cmd, imported {} files", report.files_added.len())));+ Ok(())+}++/// Map (source, MPN) → canonical URL. Manufacturer uses the prefix table+/// from sourcing::guess_mfr (so it's the same URL as `adom-chip-fetcher+/// mfr-probe`); third-party sources have hardcoded search URL templates.+fn canonical_source_url(mpn: &str, source: sourcing::Source) -> Result<String> {+ let enc = mpn.replace(' ', "%20");+ Ok(match source {+ sourcing::Source::Manufacturer => {+ sourcing::guess_mfr(mpn)+ .map(|g| g.search_url)+ .ok_or_else(|| anyhow::anyhow!("could not infer manufacturer for {mpn}; pass an explicit URL via --source mouser/digikey/etc."))?+ }+ sourcing::Source::SnapMagic => format!("https://www.snapeda.com/search/?q={enc}&active=part"),+ sourcing::Source::Mouser => format!("https://www.mouser.com/c/?q={enc}"),+ sourcing::Source::DigiKey => format!("https://www.digikey.com/en/products/result?keywords={enc}"),+ sourcing::Source::Arrow => format!("https://www.arrow.com/en/categories/connectors?q={enc}"),+ sourcing::Source::CseUl => format!("https://componentsearchengine.com/search/{enc}"),+ sourcing::Source::Lcsc => format!("https://www.lcsc.com/search?q={enc}"),+ })+}++/// Validate driver. Resolves MPN → chip directory under library/<MPN>/,+/// runs the ds2sf → concur loop, prints the outcome. Returns non-zero+/// only on hard error or when the loop ends in NeedsHuman /+/// Ds2sfUnrecoverable — so a CI-style caller can `&& adom-chip-fetcher+/// validate <MPN>` and trust the exit code.+fn validate_cmd(mpn: &str) -> Result<()> {+ let dir = library::root().join(mpn);+ if !dir.is_dir() {+ anyhow::bail!("no chip directory at {} — run adom-chip-fetcher first", dir.display());+ }+ let outcome = validate::validate_chip_dir(&dir)?;+ validate::print_outcome(&outcome);+ match outcome {+ validate::ValidationOutcome::Golden { .. } |+ validate::ValidationOutcome::GoldenWithVariants { .. } => Ok(()),+ validate::ValidationOutcome::NeedsHuman { reason, .. } => {+ anyhow::bail!("validation needs human review: {reason}");+ }+ validate::ValidationOutcome::Ds2sfUnrecoverable { summary, .. } => {+ anyhow::bail!("ds2sf unrecoverable: {}", summary.unwrap_or_default());+ }+ }+}++/// Manufacturer-probe. Detects the manufacturer from an MPN prefix and+/// opens their canonical product/search page in the adom-chip-fetcher-scrape+/// pup session. Records the attempt on info.json.fetched_via_chain so+/// next-source knows manufacturer has been tried. AI must run this+/// BEFORE any aggregator — that's the rule, now enforced.+fn mfr_probe_cmd(mpn: &str, no_open: bool) -> Result<()> {+ let guess = sourcing::guess_mfr(mpn);+ match guess {+ Some(g) => {+ println!("OK: detected manufacturer {} for {mpn}", g.manufacturer);+ println!("URL: {}", g.search_url);+ // Record the attempt as a navigation event (no outcome yet — the+ // AI fills in the outcome when it heartbeats success/empty).+ let _ = sourcing::append_chain(mpn, sourcing::Source::Manufacturer,+ "attempted",+ Some(&format!("auto-probe → {}", g.manufacturer)));+ if !no_open {+ // Manufacturer-first source opens as a TAB in the ONE adom-chip-fetcher+ // window (beside the dashboard tab), via the native browser when+ // available so ST/TI/Nordic logins + trust apply.+ match pup::open_site(SESSION, PROFILE, &g.search_url) {+ Ok(_) => println!("OK: opened {} in the adom-chip-fetcher window ({})", g.search_url, pup::surface_label()),+ Err(e) => hints::print_hint(&format!("could not auto-open ({e}) — open the URL above manually")),+ }+ }+ hints::print_hint(&format!("walk the page (Documentation, Tools & Software, CAD icon, Resources). When you decide whether the mfr has the CAD or not, heartbeat with `adom-chip-fetcher heartbeat {mpn} \"manufacturer: <result>\"` so the chain auto-updates."));+ Ok(())+ }+ None => {+ println!("WARN: could not infer manufacturer for {mpn} from prefix table.");+ hints::print_hint(&format!("identify the manufacturer manually (datasheet header, distributor product page) and open <mfr>.com directly. Then heartbeat: `adom-chip-fetcher heartbeat {mpn} \"probing <vendor>.com\"` so the chain records it."));+ Ok(())+ }+ }+}++/// Print the next ladder step that hasn't been tried, with the canonical+/// search URL where applicable. Reads info.json.fetched_via_chain and+/// applies the strict order rule (Mfr → SnapMagic → Mouser → DigiKey →+/// Arrow → CSE/UL → LCSC). LCSC is only suggested when every prior step+/// has a recorded failure outcome.+fn next_source_cmd(mpn: &str) -> Result<()> {+ let chain = sourcing::read_chain(mpn)?;+ println!("Sourcing chain so far for {mpn} ({} entries):", chain.len());+ for e in &chain {+ let note = e.note.as_deref().unwrap_or("");+ println!(" {} → {} {}", e.source, e.outcome, if note.is_empty() { "" } else { "—" });+ if !note.is_empty() { println!(" {note}"); }+ }+ println!();+ match sourcing::next_source(mpn)? {+ Some(s) => {+ println!("NEXT: try {} next.", s.as_slug());+ if matches!(s, sourcing::Source::Manufacturer) {+ hints::print_hint(&format!("`adom-chip-fetcher mfr-probe {mpn}` will detect the manufacturer + open their page in pup."));+ } else if matches!(s, sourcing::Source::Lcsc) {+ hints::print_hint(&format!("LCSC is the LAST step — only fall here when every prior step has a recorded failure. Surface 'no manufacturer CAD found' to the user before pulling LCSC."));+ } else {+ hints::print_hint(&format!("heartbeat `adom-chip-fetcher heartbeat {mpn} \"trying {}\"` before navigating, then heartbeat the outcome (\"empty\" / \"OK\" / \"404\") so next-source advances correctly.", s.as_slug()));+ }+ }+ None => {+ println!("NEXT: ladder fully exhausted for {mpn}. No more steps to try.");+ hints::print_hint("if the chip is still incomplete, surface 'no CAD available anywhere' to the user — don't claim done.");+ }+ }+ Ok(())+}++/// Hard gate. Walks the library, computes each chip's completeness against+/// the sourcing-ladder rule, prints HINT lines per missing artifact, and+/// returns non-zero exit when any card is incomplete. The AI cannot honestly+/// claim "done" without this returning zero — that's the point.+fn gate_cmd(strict: bool, mpn_filter: Option<&str>) -> Result<()> {+ let lib = inventory::scan()?;+ let chips: Vec<_> = lib.chips.into_iter()+ .filter(|c| mpn_filter.is_none_or(|m| c.mpn == m))+ .collect();+ if chips.is_empty() {+ if let Some(m) = mpn_filter {+ anyhow::bail!("no chip in library named {m:?}");+ }+ println!("library is empty.");+ return Ok(());+ }++ let mut incomplete: Vec<(String, Vec<String>)> = Vec::new();+ let mut complete = 0usize;++ for c in &chips {+ let dir = library::root().join(&c.mpn);+ let pdf = dir.join(format!("{}.pdf", c.mpn));+ let mut missing: Vec<String> = Vec::new();+ if !c.has_pdf { missing.push("datasheet.pdf".into()); }+ if !c.has_step { missing.push("STEP".into()); }+ if !c.has_sym { missing.push("KiCad .kicad_sym".into()); }+ if !c.has_mod { missing.push("KiCad .kicad_mod".into()); }+ // PERMANENT GUARANTEE 1 — the datasheet must be REAL (multi-page + pinout),+ // not a 1-page denial page that silently breaks ds2sf.+ if c.has_pdf {+ if let Err(reason) = crate::import::datasheet_ready(&pdf) {+ missing.push(format!("GOOD datasheet — {reason}"));+ }+ }+ // PERMANENT GUARANTEE 2 — ds2sf (the most authoritative source: the+ // manufacturer datasheet extraction) must have RUN and SUCCEEDED.+ let ds_ok = std::fs::read_to_string(dir.join(format!("{}-extraction.result.json", c.mpn))).ok()+ .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())+ .and_then(|r| r.get("status").and_then(|x| x.as_str()).map(|s| s == "ok"))+ .unwrap_or(false);+ if !ds_ok { missing.push("ds2sf extraction (symbol+footprint from the datasheet)".into()); }+ if strict {+ if !c.has_altium { missing.push("Altium (.SchLib+.PcbLib OR .IntLib)".into()); }+ if !c.has_fusion_lbr { missing.push("Fusion .lbr".into()); }+ }+ if missing.is_empty() {+ complete += 1;+ } else {+ incomplete.push((c.mpn.clone(), missing));+ }+ }++ let total = chips.len();+ println!("GATE: {complete}/{total} cards complete{}", if strict { " (STRICT)" } else { "" });+ println!();++ if !incomplete.is_empty() {+ println!("Incomplete cards — DO NOT claim done while these exist:");+ for (mpn, missing) in &incomplete {+ println!(" {mpn}: missing {}", missing.join(", "));+ }+ println!();+ for (mpn, missing) in &incomplete {+ for m in missing {+ hints::print_hint(&format!("{mpn} → fetch {m} (see `adom-chip-fetcher sources` for the ladder, then `adom-chip-fetcher heartbeat {mpn} \"<stage>\"` per stage)"));+ }+ }+ println!();+ hints::print_hint(&format!("re-run `adom-chip-fetcher gate{}` after each round of work — it returns 0 only when every card is complete.",+ if strict { " --strict" } else { "" }));+ anyhow::bail!("gate FAILED: {} of {total} cards incomplete", incomplete.len());+ }++ println!("OK: all {total} cards complete{}.", if strict { " (STRICT)" } else { "" });+ Ok(())+}++fn sources_cmd() -> Result<()> {+ // Single source of truth for the sourcing ladder. Both this command and+ // hints::sourcing_ladder_lines() read from here, so playbook + binary stay+ // in sync. AI consumers can call `adom-chip-fetcher sources` and parse the+ // ordered list — no need to re-read the playbook prose.+ println!("Sourcing-ladder priority (always go in order, never skip):\n");+ for (i, (name, note)) in hints::SOURCE_LADDER.iter().enumerate() {+ println!(" {}. {:<14} {}", i + 1, name, note);+ }+ println!();+ hints::print_hint("LCSC is LAST RESORT — avoid if possible. Surface 'no manufacturer CAD' to user before reaching for LCSC.");+ hints::print_hint("Stock data (stock.json) comes from Mouser regardless of where CAD/PDFs land.");+ Ok(())+}++fn hint_cmd() -> Result<()> {+ match hints::last() {+ None => {+ hints::print_hint("no heartbeat yet — kick off with `adom-chip-fetcher heartbeat <MPN> \"starting fetch\"`");+ hints::print_hint("then `adom-chip-fetcher selfcheck` to verify dashboard + pup session.");+ }+ Some(hb) => {+ let age = hints::age_secs(&hb);+ println!("last heartbeat: {} → {:?} ({}s ago)", hb.mpn, hb.stage, age);+ if age > 60 {+ hints::print_hint(&format!(+ "stale heartbeat — refresh: `adom-chip-fetcher heartbeat {} \"<current stage>\"`",+ hb.mpn+ ));+ } else {+ // If the stage is one of our canonical post-action labels, suggest+ // the next concrete command. Otherwise fall back to a generic prompt+ // so this is never silent — the AI always gets a next step.+ let canonical = matches!(hb.stage.as_str(),+ "fetched" | "imported" | "pulled" | "listed" | "status" | "login");+ if canonical {+ hints::print_next_after(&hb.stage, Some(&hb.mpn));+ } else {+ hints::print_hint(&format!(+ "heartbeat the next stage transition: `adom-chip-fetcher heartbeat {} \"<next stage>\"`",+ hb.mpn+ ));+ hints::print_hint(&format!(+ "after import lands: `adom-chip-fetcher heartbeat {} imported` then `adom-chip-fetcher status {}`",+ hb.mpn, hb.mpn+ ));+ hints::print_hint("if anything is off-rails: `adom-chip-fetcher selfcheck`");+ }+ }+ }+ }+ Ok(())+}++fn app_cmd(port: u16) -> Result<()> {+ use std::sync::mpsc;+ let (tx, rx) = mpsc::channel::<()>();+ let handle = std::thread::spawn(move || {+ // Signal "ready" once we're about to bind. The bind itself is fast.+ let _ = tx.send(());+ let _ = serve::run(port);+ });+ let _ = rx.recv();+ std::thread::sleep(std::time::Duration::from_millis(300));+ serve::open_in_hydrogen(port)?;+ let _ = handle.join();+ Ok(())+}++fn install_cmd() -> Result<()> {+ let home = std::env::var("HOME").map_err(|_| anyhow::anyhow!("HOME not set"))?;+ let skill_dir = std::path::PathBuf::from(home).join(".claude/skills/adom-chip-fetcher");+ std::fs::create_dir_all(&skill_dir)?;+ let skill_path = skill_dir.join("SKILL.md");+ std::fs::write(&skill_path, SKILL_MD)?;+ println!("OK: adom-chip-fetcher SKILL.md deployed to {}", skill_path.display());+ println!(" {} bytes, {} lines", SKILL_MD.len(), SKILL_MD.lines().count());++ // Deploy playbooks/ alongside SKILL.md. Each playbook is a focused deep-dive Claude+ // reads on demand — keeping SKILL.md lean enough to survive context-window compaction.+ let playbooks_dir = skill_dir.join("playbooks");+ std::fs::create_dir_all(&playbooks_dir)?;+ let mut total_bytes: usize = 0;+ let mut total_lines: usize = 0;+ for (name, body) in PLAYBOOKS {+ let pb_path = playbooks_dir.join(name);+ std::fs::write(&pb_path, body)?;+ total_bytes += body.len();+ total_lines += body.lines().count();+ }+ println!(+ "OK: {} playbook(s) deployed to {}",+ PLAYBOOKS.len(),+ playbooks_dir.display()+ );+ println!(" {total_bytes} bytes, {total_lines} lines across all playbooks");++ println!();+ println!("Try `adom-chip-fetcher --help` to see commands.");+ Ok(())+}++fn pull_cmd(remote_path: &str, mpn: Option<&str>, no_validate: bool, discovery_source: &str, content_origin: &str, source_url: &str) -> Result<()> {+ let incoming = library::root().parent().map(|p| p.join("incoming")).unwrap_or_else(|| std::path::PathBuf::from("/tmp"));+ std::fs::create_dir_all(&incoming)?;+ println!("pulling {remote_path} → {}", incoming.display());+ let local = pup::pull_file(remote_path, incoming.to_str().unwrap())?;+ println!("pulled to {}", local.display());+ let prov = if discovery_source != "unknown" || content_origin != "unknown" || !source_url.is_empty() {+ Some(import::FileProvenance { discovery_source: discovery_source.to_string(), content_origin: content_origin.to_string(), url: source_url.to_string() })+ } else { None };+ let report = import::import_path_with_provenance(&local, mpn, prov.as_ref())?;+ println!("imported into {}", report.dest_dir.display());+ for f in &report.files_added { println!(" + {f}"); }+ if let Some(m) = mpn.or_else(|| report.dest_dir.file_name().and_then(|s| s.to_str())) {+ let _ = hints::heartbeat(m, "imported");+ // Validate-then-thumbnail order — ds2sf may patch info.json.package+ // (e.g. VQFN-32 → VQFN-40), and thumbnails should reflect the+ // corrected metadata, not the pre-patch one.+ if !no_validate {+ run_post_import_validate(&report.dest_dir, m);+ }+ auto_render_thumbnails(m);+ hints::print_next_after("pulled", Some(m));+ }+ Ok(())+}++fn import_cmd(path: &std::path::Path, mpn: Option<&str>, no_validate: bool, discovery_source: &str, content_origin: &str, source_url: &str) -> Result<()> {+ let prov = if discovery_source != "unknown" || content_origin != "unknown" || !source_url.is_empty() {+ Some(import::FileProvenance { discovery_source: discovery_source.to_string(), content_origin: content_origin.to_string(), url: source_url.to_string() })+ } else { None };+ let report = import::import_path_with_provenance(path, mpn, prov.as_ref())?;+ println!("imported into {}", report.dest_dir.display());+ for f in &report.files_added {+ println!(" + {f}");+ }+ if report.files_added.is_empty() {+ println!(" (no recognized files found in {})", path.display());+ }+ if let Some(m) = mpn.or_else(|| report.dest_dir.file_name().and_then(|s| s.to_str())) {+ let _ = hints::heartbeat(m, "imported");+ if !no_validate {+ run_post_import_validate(&report.dest_dir, m);+ }+ auto_render_thumbnails(m);+ hints::print_next_after("imported", Some(m));+ }+ Ok(())+}++/// Drive the validation pipeline (ds2sf + concur with autonomous-repair+/// loop) after a fresh file lands. Best-effort — failures here surface as+/// WARN; they don't unwind the import. The `--no-validate` flag bypasses+/// this for cases where the user is dropping individual files mid-fetch.+fn run_post_import_validate(dir: &std::path::Path, mpn: &str) {+ println!();+ println!("running validation (ds2sf + concur) — gold-dataset gate");+ match validate::validate_chip_dir(dir) {+ Ok(outcome) => validate::print_outcome(&outcome),+ Err(e) => println!("WARN: validate failed for {mpn}: {e} — re-run with `adom-chip-fetcher validate {mpn}`"),+ }+}++/// Best-effort thumbnail re-render for one MPN immediately after import.+/// Errors are swallowed (printed as WARN) — the import already succeeded;+/// thumbnails are a UX nicety, not a correctness gate.+fn auto_render_thumbnails(mpn: &str) {+ match thumbnails::generate_for(mpn, false) {+ Ok(r) if !r.generated.is_empty() => {+ println!(" thumbnails: +{}", r.generated.join(" +"));+ let _ = hints::heartbeat(mpn, "thumbnails ready");+ }+ Ok(_) => {} // nothing changed+ Err(e) => println!("WARN: thumbnail render failed for {mpn}: {e}"),+ }+}++fn fetch(mpn: &str) -> Result<()> {+ library::ensure_dir(mpn)?;++ // Rail: dashboard MUST be visible before a fetch starts. Auto-open if not.+ // Best-effort — a failure here doesn't block the fetch (user might be+ // running headless), but they'll see a WARN.+ if let Err(e) = hints::dashboard_ensure(false) {+ println!("WARN: dashboard_ensure failed: {e}");+ }+ let _ = hints::heartbeat(mpn, "starting fetch");++ let url = sources::ti::cad_url(mpn);+ println!("opening {url}");+ pup::open_window(SESSION, PROFILE, &url)?;++ std::thread::sleep(std::time::Duration::from_secs(3));+ let title_v = pup::eval(SESSION, "document.title")?;+ let title = title_v.as_str().unwrap_or("(unknown)");+ println!("page title: {title}");++ println!("\nstep 1: on the TI page, click \"CAD/CAE Symbols & Footprints\" → opens UL with the part loaded");+ println!("step 2: in UL — pick variant, check STEP + KiCAD v6+, set units to mm, click Submit");+ println!("step 3: adom-chip-fetcher will auto-pull the download (up to 5 min wait)");+ println!();++ let local = match wait_for_download() {+ Ok(p) => p,+ Err(e) => {+ eprintln!("auto-pull failed: {e}");+ eprintln!("fallback: drag the zip to adom-chip-fetcher/incoming/ and run `adom-chip-fetcher import`");+ return Err(e);+ }+ };++ println!("pulled to {}", local.display());+ // Derive provenance from the last chain entry. The fetch function+ // currently drives TI/UL, but the chain records what actually happened.+ let last_source = crate::sourcing::last_chain_source(mpn).unwrap_or_else(|| "unknown".to_string());+ let prov = import::FileProvenance {+ discovery_source: last_source.clone(),+ content_origin: last_source,+ url: String::new(),+ };+ let report = import::import_path_with_provenance(&local, Some(mpn), Some(&prov))?;+ println!("imported into {}", report.dest_dir.display());+ for f in &report.files_added { println!(" + {f}"); }+ let _ = hints::heartbeat(mpn, "imported");++ // Validate FIRST, thumbnails SECOND. ds2sf may patch info.json (e.g.+ // package family correction VQFN-32 → VQFN-40), and the thumbnail+ // render reads info.json for orientation hints + label fields — so+ // thumbnails should run against the patched metadata, not the+ // pre-patch one. Closes gap #3 from the integration design doc.+ println!();+ println!("running validation (ds2sf + concur) — this is the agent-to-agent gate downstream tools rely on");+ let dir = library::root().join(mpn);+ match validate::validate_chip_dir(&dir) {+ Ok(outcome) => validate::print_outcome(&outcome),+ Err(e) => println!("WARN: validate failed: {e} — re-run with `adom-chip-fetcher validate {mpn}`"),+ }++ auto_render_thumbnails(mpn);+ hints::print_next_after("fetched", Some(mpn));+ Ok(())+}++/// Block until a fresh `ul_*.zip` lands in the user's Windows Downloads folder,+/// then pull it to the container's `incoming/`. Uses adom-desktop v1.5+'s+/// native `desktop_watch_files` — no shell, no PowerShell, no user-approval+/// dialog. Times out after 5 minutes.+fn wait_for_download() -> Result<std::path::PathBuf> {+ println!("watching desktop ~/Downloads for ul_*.zip (up to 5 min)...");+ let v = pup::desktop_watch_files(+ "%USERPROFILE%\\Downloads",+ "ul_*.zip",+ 300_000, // 5 min in ms+ )?;+ let ok = v.get("ok").and_then(|b| b.as_bool()).unwrap_or(false);+ if !ok {+ let hint = v.get("_hint").and_then(|s| s.as_str()).unwrap_or("");+ anyhow::bail!("desktop_watch_files timed out: {hint}");+ }+ let remote = v+ .get("file")+ .and_then(|f| f.get("path"))+ .and_then(|p| p.as_str())+ .ok_or_else(|| anyhow::anyhow!("desktop_watch_files returned no file.path: {v}"))?+ .to_string();+ println!("desktop has {remote}");+ let incoming = library::root()+ .parent()+ .map(|p| p.join("incoming"))+ .unwrap_or_else(|| std::path::PathBuf::from("/tmp"));+ std::fs::create_dir_all(&incoming)?;+ pup::pull_file(&remote, incoming.to_str().unwrap())+}++const SOURCES: &[(&str, &str, &str)] = &[+ ("google", "Google account (covers all Sign-in-with-Google flows)", "https://accounts.google.com/signin"),+ ("ti", "Texas Instruments", "https://www.ti.com/securemyprofile/login"),+ ("st", "STMicroelectronics", "https://my.st.com/cas/login"),+ ("microchip", "Microchip", "https://www.microchip.com/en-us/my-account-sign-in"),+ ("ultralib", "Ultra Librarian", "https://app.ultralibrarian.com/account/login"),+ ("snapeda", "SnapMagic / SnapEDA", "https://www.snapmagic.com/account/login"),+ ("componentsearch", "Component Search Engine (samacsys)", "https://componentsearchengine.com/account/login"),+];++fn login(source: &str) -> Result<()> {+ if source == "all" {+ println!("opening login pages for all sources as tabs in profile '{PROFILE}'");+ for (name, _, url) in SOURCES {+ println!(" → {name}: {url}");+ // First call ensures the window exists; subsequent calls add tabs+ let _ = pup::open_tab(SESSION, url);+ // Fall back to open_window if the session doesn't exist yet+ pup::open_window(SESSION, PROFILE, url)?;+ }+ println!("\nlog in to each, then 'Stay signed in' / Smart Lock will keep cookies for ~30 days.");+ return Ok(());+ }+ if source == "list" {+ println!("known sources:");+ for (name, desc, url) in SOURCES {+ println!(" {name:<18} {desc}");+ println!(" {:<18} {url}", "");+ }+ return Ok(());+ }+ let entry = SOURCES.iter().find(|(n, _, _)| {+ *n == source+ || (source == "ultralibrarian" && *n == "ultralib")+ || (source == "snapmagic" && *n == "snapeda")+ || (source == "samacsys" && *n == "componentsearch")+ });+ let url = match entry {+ Some((_, _, u)) => *u,+ None => anyhow::bail!(+ "unknown source '{source}' — run `adom-chip-fetcher login list` to see available sources, or `adom-chip-fetcher login all` to open every source"+ ),+ };+ println!("opening {url} for login — cookies will persist in profile '{PROFILE}'");+ pup::open_window(SESSION, PROFILE, url)?;+ hints::print_next_after("login", None);+ Ok(())+}++fn list() -> Result<()> {+ let entries = library::scan()?;+ if entries.is_empty() {+ println!("library is empty ({})", library::root().display());+ hints::print_hint("kick off a first fetch: `adom-chip-fetcher fetch <MPN>`");+ return Ok(());+ }+ println!("{} parts in {}", entries.len(), library::root().display());+ for e in entries {+ println!(" {:<24} step={} mod={} sym={} pdf={}", e.mpn, mark(e.has_step), mark(e.has_mod), mark(e.has_sym), mark(e.has_pdf));+ }+ hints::print_next_after("listed", None);+ Ok(())+}++fn status(mpn: &str) -> Result<()> {+ let e = library::status(mpn)?;+ println!("{:<24} step={} mod={} sym={} pdf={}", e.mpn, mark(e.has_step), mark(e.has_mod), mark(e.has_sym), mark(e.has_pdf));+ hints::print_next_after("status", Some(mpn));+ Ok(())+}++fn mark(present: bool) -> &'static str {+ if present { "✓" } else { "·" }+}
src/creds.rsadded+147@@ -0,0 +1,147 @@+use anyhow::Result;+use serde::{Deserialize, Serialize};+use std::path::PathBuf;++#[derive(Serialize, Deserialize, Clone)]+pub struct Credential {+ pub host: String,+ pub username: String,+ pub password: String,+}++#[derive(Serialize)]+pub struct PublicCred {+ pub host: String,+ pub username: String,+ pub password: String,+}++fn store_path() -> Result<PathBuf> {+ let home = std::env::var("HOME").map_err(|_| anyhow::anyhow!("HOME not set"))?;+ let dir = PathBuf::from(home).join(".config").join("adom-chip-fetcher");+ std::fs::create_dir_all(&dir)?;+ Ok(dir.join("credentials.json"))+}++pub fn load() -> Result<Vec<Credential>> {+ let path = store_path()?;+ if !path.exists() {+ return Ok(Vec::new());+ }+ let body = std::fs::read_to_string(&path)?;+ let creds: Vec<Credential> = serde_json::from_str(&body).unwrap_or_default();+ Ok(creds)+}++fn save(creds: &[Credential]) -> Result<()> {+ let path = store_path()?;+ let body = serde_json::to_string_pretty(creds)?;+ std::fs::write(&path, body)?;+ #[cfg(unix)]+ {+ use std::os::unix::fs::PermissionsExt;+ let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));+ }+ Ok(())+}++pub fn list_public() -> Result<Vec<PublicCred>> {+ Ok(load()?+ .into_iter()+ .map(|c| PublicCred { host: c.host, username: c.username, password: c.password })+ .collect())+}++pub fn set(host: &str, username: &str, password: &str) -> Result<()> {+ let mut creds = load()?;+ creds.retain(|c| c.host != host);+ creds.push(Credential {+ host: host.to_string(),+ username: username.to_string(),+ password: password.to_string(),+ });+ save(&creds)+}++pub fn delete(host: &str) -> Result<bool> {+ let mut creds = load()?;+ let before = creds.len();+ creds.retain(|c| c.host != host);+ let removed = creds.len() != before;+ if removed { save(&creds)?; }+ Ok(removed)+}++/// Match a URL's hostname against stored host patterns. Glob `*` matches any subdomain.+/// Longest non-glob suffix wins.+pub fn match_url(url: &str) -> Result<Option<Credential>> {+ let creds = load()?;+ let host = match url_host(url) {+ Some(h) => h,+ None => return Ok(None),+ };+ let mut best: Option<(usize, Credential)> = None;+ for c in creds {+ if let Some(score) = match_score(&c.host, &host) {+ match &best {+ Some((bs, _)) if *bs >= score => {}+ _ => best = Some((score, c)),+ }+ }+ }+ Ok(best.map(|(_, c)| c))+}++fn url_host(url: &str) -> Option<String> {+ let after_scheme = url.split_once("://").map(|(_, r)| r).unwrap_or(url);+ let host = after_scheme.split(['/', '?', '#']).next()?;+ let host = host.split('@').next_back()?;+ let host = host.split(':').next()?;+ Some(host.to_lowercase())+}++fn match_score(pattern: &str, host: &str) -> Option<usize> {+ let pat = pattern.to_lowercase();+ let h = host.to_lowercase();+ if let Some(suffix) = pat.strip_prefix("*.") {+ if h.ends_with(&format!(".{suffix}")) || h == suffix {+ return Some(suffix.len());+ }+ None+ } else if pat == h {+ Some(pat.len() * 2)+ } else {+ None+ }+}++/// Wrap a URL with basic-auth user:pass@ if a credential matches.+pub fn wrap_url_with_auth(url: &str) -> Result<String> {+ let cred = match match_url(url)? {+ Some(c) => c,+ None => return Ok(url.to_string()),+ };+ let after_scheme = match url.split_once("://") {+ Some(x) => x,+ None => return Ok(url.to_string()),+ };+ let scheme = after_scheme.0;+ let rest = after_scheme.1;+ if rest.contains('@') {+ return Ok(url.to_string());+ }+ let user = url_encode(&cred.username);+ let pass = url_encode(&cred.password);+ Ok(format!("{scheme}://{user}:{pass}@{rest}"))+}++fn url_encode(s: &str) -> String {+ let mut out = String::with_capacity(s.len());+ for b in s.bytes() {+ match b {+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => out.push(b as char),+ _ => out.push_str(&format!("%{:02X}", b)),+ }+ }+ out+}
src/deliver.rsadded+126@@ -0,0 +1,126 @@+//! "Send to" delivery for a fetched chip — push its CAD straight to the user's+//! desktop EDA tools via the adom-desktop bridge. KiCad gets the native+//! `.kicad_sym` + `.kicad_mod`; Fusion 360 gets a true EAGLE `.lbr` (symbol ++//! footprint) built by adom-lbr. Altium / OrCAD have no bridge yet — the `.lbr`+//! is built, but we invite the user to build the bridge and contribute it.++use serde_json::{json, Value};+use std::path::Path;+use std::process::Command;++fn ad(verb: &str, payload: Value) -> Result<Value, String> {+ let out = Command::new("adom-desktop")+ .args([verb, &payload.to_string()])+ .output()+ .map_err(|e| format!("adom-desktop unreachable: {e}"))?;+ let txt = String::from_utf8_lossy(&out.stdout).to_string();+ Ok(serde_json::from_str::<Value>(&txt).unwrap_or_else(|_| json!({ "ok": out.status.success(), "raw": txt })))+}++fn looks_offline(v: &Value) -> bool {+ let s = v.to_string().to_lowercase();+ v.get("bridgeConnected").and_then(|b| b.as_bool()) == Some(false)+ || s.contains("not connected") || s.contains("no client")+ || s.contains("offline") || s.contains("timeout")+}++/// Altium / OrCAD: we generate a real EAGLE `.lbr` and hand it over as a+/// downloadable file with tool-accurate import guidance. Altium imports EAGLE+/// libraries natively (its Import Wizard); OrCAD has no native EAGLE path, so we+/// say so honestly. Either way a desktop *auto-import bridge* would streamline+/// it — that's the contribution CTA.+fn library_export(tool: &str, mpn: &str, lbr_name: &str) -> Value {+ let asset_url = format!("/asset/{mpn}/{lbr_name}");+ let (works, import_hint) = if tool == "Altium" {+ (true, "In Altium: File → Import Wizard → \"EAGLE Projects and Designs\" → select this .lbr → it generates the .SchLib / .PcbLib / .IntLib.")+ } else {+ (false, "OrCAD Capture can't import EAGLE .lbr directly (it uses .olb). For now route it through an EAGLE→OrCAD translator or EDIF; a native OrCAD (.olb) exporter is on the roadmap.")+ };+ json!({+ "ok": false,+ "needsBridge": true,+ "target": tool,+ "assetUrl": asset_url,+ "importHint": import_hint,+ "exportWorks": works,+ "contribute": "https://wiki.adom.inc/adom/adom-desktop",+ "message": format!(+ "Built a real EAGLE .lbr for {tool} (symbol + footprint, lint-passed). {}",+ if works {+ "Download it below and import it — Altium reads EAGLE libraries natively. A one-click Adom Desktop bridge would auto-import it; want to build that? Contribute on the wiki."+ } else {+ "Download it below. A native OrCAD exporter (and an Adom Desktop bridge to auto-import) aren't built yet — want OrCAD in the Adom ecosystem? Contribute on the wiki."+ }+ )+ })+}++/// Deliver chip `mpn` (files in `dir`) to `target`.+pub fn deliver(mpn: &str, dir: &Path, target: &str) -> Value {+ let sym = dir.join(format!("{mpn}.kicad_sym"));+ let md = dir.join(format!("{mpn}.kicad_mod"));+ if !sym.is_file() && !md.is_file() {+ return json!({ "ok": false, "error": format!("no KiCad files for {mpn}") });+ }++ if target == "kicad" {+ let mut steps = Vec::new();+ if sym.is_file() {+ steps.push(ad("send_files", json!({ "filePaths": [sym.to_string_lossy()], "targetApp": "kicad", "destinationFolder": "symbols" })));+ steps.push(ad("kicad_install_library", json!({ "libraryPath": sym.to_string_lossy(), "libraryName": mpn, "libraryType": "symbol" })));+ }+ if md.is_file() {+ steps.push(ad("send_files", json!({ "filePaths": [md.to_string_lossy()], "targetApp": "kicad", "destinationFolder": "footprints" })));+ steps.push(ad("kicad_install_library", json!({ "libraryPath": md.to_string_lossy(), "libraryName": mpn, "libraryType": "footprint" })));+ }+ return summarize("KiCad", "symbol + footprint", &steps);+ }++ let (tool, app, folder) = match target {+ "fusion360" | "fusion" => ("Fusion 360", "fusion360", "fusion"),+ "altium" => ("Altium", "altium", "altium"),+ "orcad" => ("OrCAD", "orcad", "orcad"),+ _ => return json!({ "ok": false, "error": format!("unknown target {target}") }),+ };++ // Build the .lbr (symbol + footprint) via adom-lbr. For Altium/OrCAD write it+ // into the chip's library folder so it's downloadable via /asset/<mpn>/.+ let lbr_name = if app == "altium" || app == "orcad" { format!("{mpn}.lbr") } else { format!("{mpn}.send.lbr") };+ let lbr_path = dir.join(&lbr_name);+ let mut g = Command::new("adom-lbr");+ g.arg("generate");+ if sym.is_file() { g.args(["--sym"]).arg(&sym); }+ if md.is_file() { g.args(["--fp"]).arg(&md); }+ g.args(["--name", mpn]).arg("--output").arg(&lbr_path);+ match g.output() {+ Ok(o) if o.status.success() || lbr_path.is_file() => {}+ Ok(o) => return json!({ "ok": false, "error": "adom-lbr generate failed", "detail": String::from_utf8_lossy(&o.stderr) }),+ Err(e) => return json!({ "ok": false, "error": format!("adom-lbr not available: {e}") }),+ }+ if app == "altium" || app == "orcad" {+ return library_export(tool, mpn, &lbr_name);+ }+ let send = ad("send_files", json!({ "filePaths": [lbr_path.to_string_lossy()], "targetApp": app, "destinationFolder": folder }));+ let win = send.as_ref().ok()+ .and_then(|s| s.get("destinationPaths").and_then(|p| p.as_array()).and_then(|a| a.first()).and_then(|x| x.as_str()).map(String::from))+ .unwrap_or_else(|| lbr_path.to_string_lossy().to_string());+ let mut steps = vec![send];+ if app == "fusion360" {+ steps.push(ad("fusion_open_lbr", json!({ "filePath": win })));+ }+ summarize(tool, "library (.lbr)", &steps)+}++fn summarize(tool: &str, what: &str, results: &[Result<Value, String>]) -> Value {+ for r in results {+ match r {+ Err(e) => return json!({ "ok": false, "target": tool, "error": e, "offline": e.contains("unreachable") }),+ Ok(v) if looks_offline(v) => {+ return json!({ "ok": false, "target": tool, "offline": true,+ "message": format!("File generated, but Adom Desktop isn't connected to {tool}. Open Adom Desktop on your laptop and try again.") });+ }+ _ => {}+ }+ }+ json!({ "ok": true, "target": tool, "message": format!("Delivered {what} to {tool}.") })+}
src/enrich.rsadded+217@@ -0,0 +1,217 @@+//! First-class integration with the Adom parts-data tools.+//!+//! THE LESSON (learned the hard way 2026-06-23): the Adom parts tools+//! — `adom-parts-search` (the umbrella over `adom-mouser` + `adom-digikey` ++//! `adom-jlcpcb`) — keep their API keys baked into their OWN service containers.+//! They ALWAYS work with NO key needed. chip-fetcher must NEVER assume it needs+//! a `MOUSER_API_KEY` of its own; it just shells to `adom-parts-search`.+//!+//! What these tools give us, for free, on every chip:+//! - a human description ("what is this part")+//! - the manufacturer, image, datasheet URL, product URL+//! - stock + pricing + lead time + lifecycle → stock.json+//! - EVERY orderable package variant of a base MPN → the orderable-MPN that+//! CAD sites (CSE / UltraLibrarian) require. This is the permanent fix for+//! the "base ADS1263 → CSE Pick-One, no partID" trap.+//!+//! `adom-parts-search search <MPN>` prints unified JSON to stdout:+//! { query, mouser:{ok,total,hits:[{vendor,vendor_pn,mpn,manufacturer,+//! description,stock,unit_price,price_100,lead_time,image_url,+//! datasheet_url,product_url,flags}]}, digikey:{…}, jlcpcb:{…},+//! recommendation:{winner,reason,mpn,unit_price,stock} }++use anyhow::{Context, Result};+use serde_json::{json, Value};+use std::process::Command;++use crate::library;++const PARTS_SEARCH_BIN: &str = "adom-parts-search";++pub struct EnrichReport {+ pub mpn: String,+ pub description: Option<String>,+ pub wrote_stock: bool,+ pub variants: Vec<String>,+}++/// Run `adom-parts-search search <query>` and return the parsed JSON.+/// Tries the umbrella tool; bubbles up a clear error if it's missing.+pub fn parts_search(query: &str) -> Result<Value> {+ let out = Command::new(PARTS_SEARCH_BIN)+ .arg("search")+ .arg(query)+ .output()+ .with_context(|| format!("running `{PARTS_SEARCH_BIN} search {query}` — is adom-parts-search installed?"))?;+ let stdout = String::from_utf8_lossy(&out.stdout);+ // The CLI prints a one-line "OK: …" summary before the JSON body; find the+ // first '{' and parse from there.+ let start = stdout.find('{').ok_or_else(|| {+ anyhow::anyhow!("adom-parts-search returned no JSON for {query}: {}", stdout.trim())+ })?;+ let v: Value = serde_json::from_str(&stdout[start..])+ .with_context(|| format!("parsing adom-parts-search JSON for {query}"))?;+ Ok(v)+}++/// All hits across Mouser + DigiKey (+ JLCPCB), Mouser first.+fn all_hits(data: &Value) -> Vec<Value> {+ let mut hits = Vec::new();+ for vendor in ["mouser", "digikey", "jlcpcb"] {+ if let Some(h) = data.get(vendor).and_then(|b| b.get("hits")).and_then(|h| h.as_array()) {+ hits.extend(h.iter().cloned());+ }+ }+ hits+}++/// The orderable package variants for a (possibly base) MPN — the exact strings+/// CAD sites need. e.g. ADS1263 → ["ADS1263IPW", "ADS1263IPWR"]. Dedup,+/// preserving Mouser-first order. This is the permanent orderable-MPN resolver.+pub fn resolve_orderable(mpn: &str) -> Vec<String> {+ let data = match parts_search(mpn) {+ Ok(d) => d,+ Err(_) => return vec![],+ };+ let mut seen = std::collections::HashSet::new();+ let mut out = Vec::new();+ for h in all_hits(&data) {+ if let Some(m) = h.get("mpn").and_then(|x| x.as_str()) {+ let m = m.trim().to_string();+ if !m.is_empty() && seen.insert(m.clone()) {+ out.push(m);+ }+ }+ }+ out+}++/// Pick the best hit for `mpn`: exact MPN match first (Mouser preferred via+/// all_hits order), else the first hit.+fn best_hit(data: &Value, mpn: &str) -> Option<Value> {+ let hits = all_hits(data);+ let want = mpn.to_uppercase();+ hits.iter()+ .find(|h| h.get("mpn").and_then(|x| x.as_str()).map(|s| s.to_uppercase() == want).unwrap_or(false))+ .cloned()+ .or_else(|| hits.into_iter().next())+}++/// Clean a distributor description into a human "what is this chip" line.+/// Mouser appends vendor-PN noise like " A 595-ADS1263IPW" and a category+/// prefix "Analog to Digital Converters - "; strip both, collapse whitespace.+fn clean_description(raw: &str) -> String {+ let mut s = raw.trim().to_string();+ // Strip a trailing " A <digits>-<rest>" Mouser vendor-PN tail.+ if let Some(idx) = s.rfind(" A ") {+ let tail = &s[idx + 3..];+ if tail.chars().take(4).filter(|c| c.is_ascii_digit()).count() >= 2+ || tail.contains('-')+ {+ s.truncate(idx);+ }+ }+ // Keep a leading "Category - " prefix as context but drop it if it's the+ // generic ADC/converter family noise that just repeats the part type.+ s = s.trim_end_matches(['-', ' ', '·']).trim().to_string();+ // Collapse internal whitespace.+ s.split_whitespace().collect::<Vec<_>>().join(" ")+}++/// Build a stock.json body from the best hit.+fn stock_body(hit: &Value) -> Value {+ let get = |k: &str| hit.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();+ let stock = hit.get("stock").and_then(|x| x.as_u64()).unwrap_or(0);+ let flags: Vec<String> = hit.get("flags").and_then(|f| f.as_array())+ .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())+ .unwrap_or_default();+ let lifecycle = if flags.iter().any(|f| {+ let f = f.to_uppercase();+ f.contains("NRND") || f.contains("EOL") || f.contains("OBSOLETE") || f.contains("LAST TIME")+ }) { "nrnd_or_eol" } else { "active" };+ let mut tiers = Vec::new();+ let p1 = get("unit_price");+ if !p1.is_empty() { tiers.push(json!({"price": p1, "qty": 1})); }+ let p100 = get("price_100");+ if !p100.is_empty() { tiers.push(json!({"price": p100, "qty": 100})); }+ json!({+ "checked_at": "via adom-parts-search",+ "source": "adom-parts-search",+ get_vendor_key(hit): {+ "vendor_pn": get("vendor_pn"),+ "mpn": get("mpn"),+ "manufacturer": get("manufacturer"),+ "stock": stock,+ "stock_text": format!("{stock} in stock"),+ "lead_time": get("lead_time"),+ "lifecycle": lifecycle,+ "unit_price": p1,+ "price_tiers": tiers,+ "product_url": get("product_url"),+ "image_url": get("image_url"),+ "flags": flags,+ }+ })+}++fn get_vendor_key(hit: &Value) -> String {+ hit.get("vendor").and_then(|x| x.as_str()).unwrap_or("mouser").to_lowercase()+}++/// Enrich a chip from the Adom parts tools: write info.json.description (++/// manufacturer / image / datasheet / product URLs) and stock.json. Best-effort+/// per field; never fails the sourcing run.+pub fn enrich(mpn: &str) -> Result<EnrichReport> {+ let dir = library::root().join(mpn);+ if !dir.exists() {+ anyhow::bail!("library/{mpn}/ does not exist");+ }+ let data = parts_search(mpn)?;+ let mut seen = std::collections::HashSet::new();+ let variants: Vec<String> = all_hits(&data).iter()+ .filter_map(|h| h.get("mpn").and_then(|x| x.as_str()).map(String::from))+ .filter(|m| !m.is_empty() && seen.insert(m.clone()))+ .collect();+ let hit = best_hit(&data, mpn);++ let mut description = None;+ let mut wrote_stock = false;++ if let Some(h) = hit.as_ref() {+ // --- info.json: description + provenance-friendly metadata ---+ let info_path = dir.join("info.json");+ let mut info: Value = std::fs::read_to_string(&info_path).ok()+ .and_then(|s| serde_json::from_str(&s).ok())+ .unwrap_or_else(|| json!({}));+ // Only set the description from the distributor if there isn't already a+ // (likely hand-curated, nicer) one — don't clobber a better description.+ let existing = info.get("description").and_then(|d| d.as_str()).unwrap_or("").trim().to_string();+ if existing.len() < 10 {+ let raw = h.get("description").and_then(|x| x.as_str()).unwrap_or("");+ let cleaned = clean_description(raw);+ if cleaned.len() >= 10 {+ info["description"] = json!(cleaned);+ description = Some(cleaned);+ }+ } else {+ description = Some(existing);+ }+ for (k, src) in [("manufacturer", "manufacturer"), ("image_url", "image_url"),+ ("datasheet_url", "datasheet_url"), ("product_url", "product_url")] {+ if let Some(v) = h.get(src).and_then(|x| x.as_str()) {+ if !v.is_empty() && info.get(k).and_then(|x| x.as_str()).unwrap_or("").is_empty() {+ info[k] = json!(v);+ }+ }+ }+ let _ = std::fs::write(&info_path, serde_json::to_string_pretty(&info).unwrap_or_default());++ // --- stock.json: stock + pricing + lifecycle ---+ let stock = stock_body(h);+ if std::fs::write(dir.join("stock.json"), serde_json::to_string_pretty(&stock).unwrap_or_default()).is_ok() {+ wrote_stock = true;+ }+ }++ Ok(EnrichReport { mpn: mpn.to_string(), description, wrote_stock, variants })+}
src/hints.rsadded+665@@ -0,0 +1,665 @@+// Self-enforcing rails for adom-chip-fetcher: heartbeat the dashboard, check that+// state is healthy before the next pup action, and emit `HINT:` lines that+// the calling AI reads back into context — so the binary itself becomes the+// source of truth for "what to do next" instead of relying on skill prose.++use anyhow::Result;+use serde::{Deserialize, Serialize};+use std::process::Command;+use std::time::{SystemTime, UNIX_EPOCH};++const HEARTBEAT_PATH: &str = "/tmp/adom-chip-fetcher-last-heartbeat.json";+const STALE_AFTER_SECS: u64 = 60;+const DEFAULT_PORT: u16 = 8786;+const DASHBOARD_SURFACE_PATH: &str = ".config/adom-chip-fetcher/dashboard.json";++/// Canonical sourcing-ladder priority. Single source of truth — `adom-chip-fetcher+/// sources` and the playbook prose both read from here. **Always traverse in+/// order; never skip to LCSC.** Direct user rule (2026-05-06): *"after Mouser+/// you should do digikey, and then only after that try LCSC. I really want to+/// avoid LCSC at all costs, but it is in the list if we're desperate."*+pub const SOURCE_LADDER: &[(&str, &str)] = &[+ ("manufacturer", "vendor.com — STEP, datasheet, lifecycle. ALWAYS try first, even when confident."),+ ("snapmagic", "snapeda.com — free CAD bundles, login-gated. Right after manufacturer."),+ ("mouser", "mouser.com — stock + pricing always; sometimes ECAD Models / CSE deep-links."),+ ("digikey", "digikey.com — broader catalog; STEP + datasheet on every product page."),+ ("arrow", "arrow.com — niche distributor."),+ ("cse_ul", "componentsearchengine.com / ultralibrarian.com — only when mfr links there."),+ ("lcsc", "datasheet.lcsc.com / lcsc.com — LAST RESORT. Avoid if possible; tell user we couldn't find it on the manufacturer's site instead."),+];++/// Where the user wants to see the dashboard. Pup is the default — adom-chip-fetcher+/// needs pup anyway to drive vendor sites, so the dashboard tagging along is+/// the lowest-friction path. Hydrogen webview is the alternative for users on+/// big monitors who prefer the workspace-integrated panel.+#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]+pub enum DashboardSurface {+ Pup,+ Webview,+}++impl DashboardSurface {+ fn parse(s: &str) -> Self {+ match s.trim().to_lowercase().as_str() {+ "webview" | "hydrogen" => Self::Webview,+ _ => Self::Pup,+ }+ }+ fn as_str(&self) -> &'static str {+ match self { Self::Pup => "pup", Self::Webview => "webview" }+ }+}++#[derive(Serialize, Deserialize, Clone)]+struct DashboardConfig { surface: String }++fn dashboard_config_path() -> Option<std::path::PathBuf> {+ let home = std::env::var("HOME").ok()?;+ Some(std::path::PathBuf::from(home).join(DASHBOARD_SURFACE_PATH))+}++pub fn get_dashboard_surface() -> DashboardSurface {+ let path = match dashboard_config_path() { Some(p) => p, None => return DashboardSurface::Pup };+ match std::fs::read_to_string(&path) {+ Ok(s) => match serde_json::from_str::<DashboardConfig>(&s) {+ Ok(c) => DashboardSurface::parse(&c.surface),+ Err(_) => DashboardSurface::Pup,+ },+ Err(_) => DashboardSurface::Pup,+ }+}++pub fn set_dashboard_surface(surface: DashboardSurface) -> Result<()> {+ let path = dashboard_config_path()+ .ok_or_else(|| anyhow::anyhow!("HOME not set"))?;+ if let Some(parent) = path.parent() {+ std::fs::create_dir_all(parent)?;+ }+ let cfg = DashboardConfig { surface: surface.as_str().to_string() };+ std::fs::write(&path, serde_json::to_string_pretty(&cfg)?)?;+ println!("OK: dashboard surface set to {:?} ({})", surface, path.display());+ Ok(())+}++#[derive(Serialize, Deserialize, Clone)]+pub struct Heartbeat {+ pub mpn: String,+ pub stage: String,+ pub ts: u64,+}++/// First-heartbeat stub: create an empty `library/<MPN>/info.json` so the+/// dashboard's `inventory::scan` walks find the new chip and render an empty+/// card the user can scroll to while we work. No-op if `info.json` already+/// exists (we don't want to clobber a partial fetch's data). Returns `true`+/// when a new stub was created (i.e. this is the very first heartbeat for+/// this MPN), so the caller can surface the manufacturer-first hint.+fn ensure_stub_card(mpn: &str, stage: &str) -> Result<bool> {+ if mpn.is_empty() || mpn == "ZZZ" {+ return Ok(false);+ }+ let dir = crate::library::root().join(mpn);+ let info_path = dir.join("info.json");+ if info_path.exists() {+ return Ok(false);+ }+ std::fs::create_dir_all(&dir)?;+ let info = serde_json::json!({+ "mpn": mpn,+ "state": "in_progress",+ "stage": stage,+ "started_at": now_secs(),+ });+ std::fs::write(info_path, serde_json::to_string_pretty(&info)?)?;+ Ok(true)+}++fn now_secs() -> u64 {+ SystemTime::now()+ .duration_since(UNIX_EPOCH)+ .map(|d| d.as_secs())+ .unwrap_or(0)+}++pub fn heartbeat(mpn: &str, stage: &str) -> Result<()> {+ let hb = Heartbeat {+ mpn: mpn.to_string(),+ stage: stage.to_string(),+ ts: now_secs(),+ };+ let body = serde_json::to_string(&hb)?;+ let _ = std::fs::write(HEARTBEAT_PATH, &body);++ // Stub-card on first heartbeat: if the chip has no library/<MPN>/ directory+ // yet, create one with an in-progress info.json so the dashboard renders an+ // empty placeholder card immediately. Without this, the user has nothing+ // to scroll to during a fresh fetch — the chip is invisible until the+ // first file lands.+ let was_new = ensure_stub_card(mpn, stage).unwrap_or(false);++ let url = format!("http://127.0.0.1:{DEFAULT_PORT}/api/activity");+ let dashboard_ok = Command::new("curl")+ .args([+ "-fs", "-m", "2", "-X", "POST", &url,+ "-H", "Content-Type: application/json",+ "-d", &body,+ ])+ .output()+ .map(|o| o.status.success())+ .unwrap_or(false);++ println!("OK: heartbeat {mpn} → {stage:?}");+ if !dashboard_ok {+ println!("WARN: dashboard unreachable at {url}. Heartbeat cached locally.");+ print_hint("start the dashboard with `adom-chip-fetcher app` so users can see live progress");+ }++ // Auto-scroll the MPN's dashboard card into view in the adom-chip-fetcher pup+ // session. This is the rule John asked for — the binary enforces "scroll+ // the active card into focus" automatically rather than relying on the AI+ // to remember it on every heartbeat. No-op if pup isn't running.+ match auto_scroll_card(mpn) {+ Ok(true) => println!("OK: scrolled card {mpn} into focus on dashboard"),+ Ok(false) => {} // pup not up, or dashboard surface is webview — silently skip+ Err(e) => println!("WARN: could not scroll card {mpn} into focus: {e}"),+ }++ // First heartbeat for this MPN: print the sourcing-ladder reminder so the+ // AI doesn't skip ahead to LCSC. This is "rules made programmatic" —+ // every fresh fetch sees the canonical order in stdout, not buried in a+ // skill file the AI may not have re-read this turn.+ if was_new {+ println!();+ print_hint(&format!("new stub created for {mpn}. ALWAYS start at the manufacturer's site, then walk the ladder IN ORDER (no skipping). See `adom-chip-fetcher sources` for the full priority list."));+ print_hint("Order: Mfr → SnapMagic → Mouser → DigiKey → Arrow → CSE/UL → LCSC. LCSC is LAST RESORT — surface 'no manufacturer CAD' to user before reaching for it.");+ }++ // Card-completeness check on every heartbeat. The AI must not claim "done"+ // while the active card is missing core CAD. Surface what's missing now,+ // and also count how many other cards in the library are still stubs so+ // the AI can't quietly walk away from a half-finished batch.+ let _ = print_completeness_warnings(mpn);++ // Auto-record source attempts in info.json.fetched_via_chain. The AI+ // doesn't have to remember to write the chain; the binary parses the+ // stage label and logs it. `adom-chip-fetcher next-source <MPN>` then knows+ // what's been tried.+ if let Some((source, outcome)) = crate::sourcing::detect_source_attempt(stage) {+ let added = crate::sourcing::append_chain(mpn, source, &outcome, Some(stage)).unwrap_or(false);+ if added {+ println!("OK: chain {} → {} recorded for {mpn} (run `adom-chip-fetcher next-source {mpn}` to see what's next)", source.as_slug(), outcome);+ }+ }++ // Stage-order state machine. Warn if the AI seems to skip a phase+ // (e.g. heartbeats "imported" without ever heartbeating "downloading").+ let _ = check_stage_order(mpn, stage);+ Ok(())+}++/// A QUIET heartbeat: refresh the dashboard's activity timestamp + stage only.+/// No stdout, no stub creation, no auto-scroll, no chain/stage bookkeeping.+/// This is what the background ticker fires every few seconds so the "working+/// on" bar stays LIVE during a long blocking op — without spamming the log or+/// re-running the heavy per-heartbeat side-effects.+pub fn heartbeat_quiet(mpn: &str, stage: &str) {+ let hb = Heartbeat { mpn: mpn.to_string(), stage: stage.to_string(), ts: now_secs() };+ if let Ok(body) = serde_json::to_string(&hb) {+ let _ = std::fs::write(HEARTBEAT_PATH, &body);+ let url = format!("http://127.0.0.1:{DEFAULT_PORT}/api/activity");+ let _ = Command::new("curl")+ .args(["-fs", "-m", "2", "-X", "POST", &url, "-H", "Content-Type: application/json", "-d", &body])+ .output();+ }+}++/// Keeps the dashboard "working on" bar LIVE during a long blocking call (a 40s+/// browser XHR, a slow import). Emits one loud heartbeat up front, then a quiet+/// one every ~6s on a background thread until stopped (or dropped). Without this+/// the bar goes stale after ACTIVITY_STALE_SECS mid-operation and the user sees+/// no live status — exactly John's complaint.+pub struct HeartbeatTicker {+ stop: std::sync::Arc<std::sync::atomic::AtomicBool>,+ handle: Option<std::thread::JoinHandle<()>>,+}++impl HeartbeatTicker {+ pub fn start(mpn: &str, stage: &str) -> Self {+ use std::sync::atomic::Ordering;+ let _ = heartbeat(mpn, stage); // one loud heartbeat (stub card, scroll, log)+ let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));+ let (mpn, stage, s2) = (mpn.to_string(), stage.to_string(), stop.clone());+ let handle = std::thread::spawn(move || {+ let mut elapsed_ms = 0u64;+ while !s2.load(Ordering::Relaxed) {+ std::thread::sleep(std::time::Duration::from_millis(500));+ elapsed_ms += 500;+ if elapsed_ms >= 6000 {+ elapsed_ms = 0;+ if !s2.load(Ordering::Relaxed) {+ heartbeat_quiet(&mpn, &stage);+ }+ }+ }+ });+ HeartbeatTicker { stop, handle: Some(handle) }+ }++ pub fn stop(mut self) { self.shutdown(); }++ fn shutdown(&mut self) {+ self.stop.store(true, std::sync::atomic::Ordering::Relaxed);+ if let Some(h) = self.handle.take() { let _ = h.join(); }+ }+}++impl Drop for HeartbeatTicker {+ fn drop(&mut self) { self.shutdown(); }+}++/// Track stage-order per MPN in /tmp/adom-chip-fetcher-stage-<MPN>.json. Warn+/// when the AI heartbeats a later phase (e.g. 5=importing) without having+/// hit the prior phase (3=downloading). Doesn't block — just nudges.+fn check_stage_order(mpn: &str, stage: &str) -> Result<()> {+ let phase = match crate::sourcing::stage_phase(stage) {+ Some(p) => p,+ None => return Ok(()),+ };+ let path = std::path::PathBuf::from(format!("/tmp/adom-chip-fetcher-stage-{mpn}.json"));+ // Did we ever record a phase before? Distinguish "no prev recorded"+ // from "prev was phase 0 (queued)" — both are u8 0 but only one is+ // a legitimate prior state.+ let had_prev = path.exists();+ let prev: u8 = if had_prev {+ std::fs::read_to_string(&path)+ .ok()+ .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())+ .and_then(|v| v.get("phase").and_then(|p| p.as_u64()).map(|n| n as u8))+ .unwrap_or(0)+ } else {+ 0+ };++ if had_prev && phase > prev + 1 {+ let phase_names = ["queued", "probing", "found", "downloading", "pulling", "importing", "imported"];+ let prev_name = phase_names.get(prev as usize).copied().unwrap_or("?");+ let cur_name = phase_names.get(phase as usize).copied().unwrap_or("?");+ println!("WARN: stage jumped {prev}→{phase} for {mpn} ({prev_name}→{cur_name}) — skipped intermediate stages. Did the file actually land? Did you heartbeat \"downloading\" / \"pulling\" / \"importing\"?");+ }++ let _ = std::fs::write(&path, serde_json::json!({"phase": phase, "stage": stage}).to_string());+ Ok(())+}++/// Walk the active chip's directory and print explicit `WARN:` + `HINT:` lines+/// for each missing core artifact. Also counts how many OTHER chips in the+/// library are incomplete so the AI sees the batch picture, not just one chip.+/// Pure check; never errors. The deterministic gate is `adom-chip-fetcher gate` —+/// this is the inline version that fires on every heartbeat.+fn print_completeness_warnings(mpn: &str) -> Result<()> {+ let lib = match crate::inventory::scan() {+ Ok(l) => l,+ Err(_) => return Ok(()),+ };+ let active = lib.chips.iter().find(|c| c.mpn == mpn);+ let total = lib.chips.len();+ let incomplete = lib.chips.iter()+ .filter(|c| !(c.has_pdf && c.has_step && c.has_sym && c.has_mod))+ .count();++ if let Some(c) = active {+ let mut missing: Vec<&'static str> = Vec::new();+ if !c.has_pdf { missing.push("datasheet.pdf"); }+ if !c.has_step { missing.push("STEP"); }+ if !c.has_sym { missing.push("KiCad .kicad_sym"); }+ if !c.has_mod { missing.push("KiCad .kicad_mod"); }+ if !missing.is_empty() {+ println!();+ println!("WARN: card {mpn} is INCOMPLETE — missing: {}", missing.join(", "));+ print_hint(&format!("do NOT claim done on {mpn}. Walk the ladder for each missing piece. `adom-chip-fetcher gate` for full status."));+ }+ }+ if incomplete > 0 {+ println!("WARN: {incomplete}/{total} cards in library are still incomplete (run `adom-chip-fetcher gate` to see all).");+ }+ Ok(())+}++/// Scroll the dashboard pup window so the active MPN's card is centered in+/// view, with a brief outline highlight so the user's eye lands on it. Returns+/// `Ok(true)` when the scroll was issued, `Ok(false)` when pup isn't the+/// active surface (no error, no work needed), `Err` only on adom-desktop+/// failure.+fn auto_scroll_card(mpn: &str) -> Result<bool> {+ let safe_mpn = mpn.replace('"', "");+ let expr = format!(+ "(()=>{{const m={:?};const c=document.querySelector(`[data-mpn=\"${{m}}\"]`);\+ if(c){{c.scrollIntoView({{behavior:'smooth',block:'center'}});\+ c.style.outline='3px solid #5cf';\+ setTimeout(()=>c.style.outline='',2500);return 'card';}}\+ const b=document.querySelector('.activity-banner,.heartbeat-banner,[data-activity]');\+ if(b){{b.scrollIntoView({{behavior:'smooth',block:'start'}});return 'banner';}}\+ window.scrollTo({{top:0,behavior:'smooth'}});return 'top';}})()",+ safe_mpn+ );+ let body = serde_json::json!({+ "sessionId": "adom-chip-fetcher-dashboard",+ "expr": expr,+ }).to_string();+ let out = Command::new("adom-desktop")+ .args(["browser_eval", &body])+ .output()?;+ Ok(out.status.success())+}++pub fn last() -> Option<Heartbeat> {+ let s = std::fs::read_to_string(HEARTBEAT_PATH).ok()?;+ serde_json::from_str(&s).ok()+}++pub fn age_secs(hb: &Heartbeat) -> u64 {+ now_secs().saturating_sub(hb.ts)+}++/// Make sure the dashboard is up AND visible to the user. Idempotent —+/// running this every time is the point. Three checks, each with a fix:+/// 1. HTTP server reachable on $DEFAULT_PORT? If not, spawn `adom-chip-fetcher+/// serve` detached.+/// 2. Pup window with sessionId "adom-chip-fetcher-dashboard" pointing at the dashboard?+/// If not, open one (or open Hydrogen tab, per surface preference).+/// 3. (best-effort) Foreground the pup window so the user sees it.+///+/// `force_open` skips check (2)'s "already open" path and re-opens — useful+/// when the user explicitly wants the dashboard front-and-center.+pub fn dashboard_ensure(force_open: bool) -> Result<()> {+ // Step 1 — HTTP server+ if !dashboard_reachable() {+ println!("WARN: dashboard server not running; starting it...");+ let exe = std::env::current_exe()+ .unwrap_or_else(|_| std::path::PathBuf::from("adom-chip-fetcher"));+ // Detach: own session, stdout/stderr to a log file, parent doesn't wait.+ let log = std::fs::OpenOptions::new()+ .create(true).append(true)+ .open("/tmp/cf-dashboard.log")+ .unwrap_or_else(|_| std::fs::File::create("/dev/null").unwrap());+ let log_err = log.try_clone().unwrap();+ let _ = std::process::Command::new(exe)+ .args(["serve", "--port", &DEFAULT_PORT.to_string()])+ .stdout(log)+ .stderr(log_err)+ .stdin(std::process::Stdio::null())+ .spawn()+ .map_err(|e| anyhow::anyhow!("spawn serve: {e}"))?;+ // Give it a moment to bind.+ for _ in 0..20 {+ std::thread::sleep(std::time::Duration::from_millis(150));+ if dashboard_reachable() { break; }+ }+ if dashboard_reachable() {+ println!("OK: dashboard server up on http://127.0.0.1:{DEFAULT_PORT}/");+ } else {+ anyhow::bail!("dashboard server failed to start within 3s — see /tmp/cf-dashboard.log");+ }+ } else {+ println!("OK: dashboard server already running on :{DEFAULT_PORT}");+ }++ // Step 2 — visible surface+ let surface = get_dashboard_surface();+ match surface {+ DashboardSurface::Pup => ensure_pup_dashboard_window(force_open)?,+ DashboardSurface::Webview => ensure_hydrogen_dashboard_tab(force_open)?,+ }+ Ok(())+}++fn dashboard_proxy_url() -> String {+ // The dashboard pup window runs on the user's Windows desktop — it can't+ // reach 127.0.0.1 on the Docker container. Use the Adom cloud proxy URL+ // so the pup browser can access the dashboard through HTTPS.+ if let Ok(host) = std::env::var("ADOM_PROXY_HOST") {+ format!("https://{host}/proxy/{DEFAULT_PORT}/")+ } else if let Ok(slug) = std::env::var("ADOM_CONTAINER_SLUG") {+ format!("https://{slug}.adom.cloud/proxy/{DEFAULT_PORT}/")+ } else if let Ok(tmpl) = std::env::var("VSCODE_PROXY_URI") {+ tmpl.replace("{{port}}", &DEFAULT_PORT.to_string())+ } else {+ format!("http://127.0.0.1:{DEFAULT_PORT}/")+ }+}++fn ensure_pup_dashboard_window(force_open: bool) -> Result<()> {+ // Native browser: the dashboard is TAB 1 of the single "adom-chip-fetcher" window+ // (scrape sites are tabs beside it). Never spawn a second window — that's+ // the "why do I have a native browser AND a pup?" bug.+ if crate::pup::is_native() {+ let url = dashboard_proxy_url();+ if !force_open && crate::pup::session_has_url("adom-chip-fetcher", &format!("/proxy/{DEFAULT_PORT}/")) {+ println!("OK: dashboard is tab 1 of the adom-chip-fetcher window (native browser)");+ return Ok(());+ }+ crate::pup::open_window("adom-chip-fetcher", "adom-chip-fetcher", &url)?;+ println!("OK: opened the adom-chip-fetcher dashboard as tab 1 of your native browser window");+ return Ok(());+ }+ let already_open = pup_session_open("adom-chip-fetcher-dashboard").unwrap_or(false);+ if already_open && !force_open {+ println!("OK: dashboard pup window 'adom-chip-fetcher-dashboard' already open");+ let _ = Command::new("adom-desktop")+ .args(["browser_focus_window", "{\"sessionId\":\"adom-chip-fetcher-dashboard\"}"])+ .output();+ return Ok(());+ }+ let url = dashboard_proxy_url();+ let body = serde_json::json!({+ "sessionId": "adom-chip-fetcher-dashboard",+ "profile": "adom-chip-fetcher",+ "url": url,+ }).to_string();+ let out = Command::new("adom-desktop")+ .args(["browser_open_window", &body])+ .output();+ match out {+ Ok(o) if o.status.success() => {+ println!("OK: opened dashboard in pup at {}", dashboard_proxy_url());+ let _ = Command::new("adom-desktop")+ .args(["browser_focus_window", "{\"sessionId\":\"adom-chip-fetcher-dashboard\"}"])+ .output();+ Ok(())+ }+ Ok(o) => {+ let stdout = String::from_utf8_lossy(&o.stdout).to_string();+ let stderr = String::from_utf8_lossy(&o.stderr).to_string();+ let detail = if stdout.is_empty() { stderr } else { stdout };+ anyhow::bail!("browser_open_window failed: {detail}")+ }+ Err(e) => anyhow::bail!("could not run adom-desktop: {e}"),+ }+}++fn ensure_hydrogen_dashboard_tab(_force_open: bool) -> Result<()> {+ let url = format!("http://127.0.0.1:{DEFAULT_PORT}/");+ // adom-cli is the canonical Hydrogen-tab driver. If it's missing, fall+ // back to pup so the user still sees the dashboard.+ let status = Command::new("adom-cli")+ .args(["workspace", "tab-open", "--url", &url, "--title", "adom-chip-fetcher"])+ .status();+ match status {+ Ok(s) if s.success() => {+ println!("OK: opened dashboard in Hydrogen webview tab");+ Ok(())+ }+ _ => {+ println!("WARN: adom-cli workspace tab-open failed — falling back to pup");+ ensure_pup_dashboard_window(false)+ }+ }+}++fn dashboard_reachable() -> bool {+ let url = format!("http://127.0.0.1:{DEFAULT_PORT}/api/library");+ Command::new("curl")+ .args(["-fs", "-m", "2", "-o", "/dev/null", &url])+ .status()+ .map(|s| s.success())+ .unwrap_or(false)+}++fn pup_session_open(name: &str) -> Option<bool> {+ if list_contains_session(name)? {+ return Some(true);+ }+ // First lookup miss: maybe the bridge dropped + reconnected and lost the+ // session reference even though Chrome is still alive (the v1.5.0 bug).+ // v1.5.1's browser_rescan rebuilds the session map from the actual+ // Chrome targets — call it once before declaring the session gone.+ let rescan = Command::new("adom-desktop")+ .args(["browser_rescan", "{}"])+ .output()+ .ok()?;+ if !rescan.status.success() { return Some(false); }+ let r: serde_json::Value = serde_json::from_slice(&rescan.stdout).ok()?;+ let recovered = r.get("liveSessions")+ .and_then(|l| l.as_array())+ .map(|arr| arr.iter().any(|v| v.as_str() == Some(name)))+ .unwrap_or(false);+ if recovered {+ println!("OK: browser_rescan recovered session {name:?}");+ return Some(true);+ }+ Some(false)+}++fn list_contains_session(name: &str) -> Option<bool> {+ let out = Command::new("adom-desktop")+ .args(["browser_list_windows", "{}"])+ .output()+ .ok()?;+ let s = String::from_utf8_lossy(&out.stdout);+ let compact = format!("\"sessionId\":\"{name}\"");+ let spaced = format!("\"sessionId\": \"{name}\"");+ Some(s.contains(&compact) || s.contains(&spaced))+}++pub fn selfcheck() -> Result<()> {+ // Dashboard rail: auto-(re)open if missing rather than just complaining.+ // The user shouldn't have to remember "is the dashboard up?" — that's+ // the binary's job.+ if let Err(e) = dashboard_ensure(false) {+ println!("WARN: dashboard_ensure: {e}");+ }++ let mut errs: Vec<String> = vec![];+ let mut warns: Vec<String> = vec![];++ if dashboard_reachable() {+ println!("OK: dashboard reachable at http://127.0.0.1:{DEFAULT_PORT}/");+ } else {+ errs.push(format!(+ "dashboard NOT reachable at http://127.0.0.1:{DEFAULT_PORT}/"+ ));+ }++ match last() {+ Some(hb) => {+ let age = age_secs(&hb);+ if age <= STALE_AFTER_SECS {+ println!(+ "OK: heartbeat {age}s old — {} → {:?}",+ hb.mpn, hb.stage+ );+ } else {+ warns.push(format!(+ "heartbeat is {age}s old (stale-after = {STALE_AFTER_SECS}s) — last was {} → {:?}",+ hb.mpn, hb.stage+ ));+ }+ }+ None => warns.push(+ "no heartbeat on file — the dashboard hasn't seen any activity from this session".into(),+ ),+ }++ match pup_session_open("adom-chip-fetcher") {+ Some(true) => println!("OK: pup session 'adom-chip-fetcher' is open"),+ Some(false) => warns.push(+ "pup session 'adom-chip-fetcher' is NOT open — fetches need this exact sessionId".into(),+ ),+ None => warns.push("could not query adom-desktop browser_list_windows".into()),+ }++ if !errs.is_empty() {+ println!();+ for e in &errs {+ println!("ERROR: {e}");+ }+ }+ if !warns.is_empty() {+ println!();+ for w in &warns {+ println!("WARN: {w}");+ }+ }++ println!();+ if errs.is_empty() && warns.is_empty() {+ print_hint("all checks passed — continue with your fetch. Heartbeat each stage transition with `adom-chip-fetcher heartbeat <MPN> \"<stage>\"` before any pup action.");+ } else {+ if !dashboard_reachable() {+ print_hint("start the dashboard FIRST: `adom-chip-fetcher app` (Hydrogen tab) or `adom-chip-fetcher serve` (HTTP only) — every other rule depends on the dashboard being up.");+ }+ match last() {+ None => print_hint("send your first heartbeat: `adom-chip-fetcher heartbeat <MPN> \"starting fetch\"`"),+ Some(hb) if age_secs(&hb) > STALE_AFTER_SECS => print_hint(&format!(+ "heartbeat is stale — refresh with `adom-chip-fetcher heartbeat {} \"<current stage>\"`",+ hb.mpn+ )),+ _ => {}+ }+ if matches!(pup_session_open("adom-chip-fetcher"), Some(false)) {+ print_hint("open the pup window before fetching: navigate to the vendor URL with `sessionId:\"adom-chip-fetcher\", profile:\"adom-chip-fetcher\"`");+ }+ }++ if !errs.is_empty() {+ anyhow::bail!("selfcheck failed with {} error(s)", errs.len());+ }+ Ok(())+}++pub fn print_hint(line: &str) {+ println!("HINT: {line}");+}++/// Emit a "what to do next" line based on current state. Used as the tail of+/// every subcommand's stdout — the AI reads this back into context and the+/// next action becomes mechanical instead of remembered.+pub fn print_next_after(stage: &str, mpn: Option<&str>) {+ match (stage, mpn) {+ ("fetched", Some(m)) => {+ print_hint(&format!("heartbeat the result: `adom-chip-fetcher heartbeat {m} imported`"));+ print_hint(&format!("verify on disk: `adom-chip-fetcher status {m}`"));+ }+ ("imported", Some(m)) => {+ print_hint(&format!("heartbeat: `adom-chip-fetcher heartbeat {m} imported`"));+ print_hint(&format!("dashboard view: `adom-chip-fetcher status {m}`"));+ }+ ("pulled", Some(m)) => {+ print_hint(&format!("heartbeat: `adom-chip-fetcher heartbeat {m} \"pulled to incoming\"`"));+ print_hint("import next: `adom-chip-fetcher import <path> --mpn <MPN>`");+ }+ ("listed", _) => {+ print_hint("inspect one: `adom-chip-fetcher status <MPN>`");+ print_hint("fetch a missing one: `adom-chip-fetcher fetch <MPN>`");+ }+ ("status", Some(m)) => {+ print_hint(&format!("if anything is missing, fetch with `adom-chip-fetcher fetch {m}`"));+ print_hint(&format!("heartbeat any user-visible action with `adom-chip-fetcher heartbeat {m} \"<stage>\"`"));+ }+ ("login", _) => {+ print_hint("after sign-in, heartbeat: `adom-chip-fetcher heartbeat <MPN> \"logged in to <source>\"`");+ print_hint("verify rails: `adom-chip-fetcher selfcheck`");+ }+ _ => {}+ }+}
src/import.rsadded+376@@ -0,0 +1,376 @@+use anyhow::{anyhow, Context, Result};+use std::path::{Path, PathBuf};+use std::process::Command;++use crate::library;++/// Per-file upstream provenance — which ladder source + URL each file+/// came from. Passed into import_path and written to+/// `<MPN>-file-provenance.json` so downstream tools (chipsmith tooltips,+/// wiki chip pages, concur cross-validation notes) can attribute every+/// artifact to its origin without guessing.+#[derive(Clone, Default)]+pub struct FileProvenance {+ pub discovery_source: String, // where did I FIND the link? ("digikey", "mouser", etc.)+ pub content_origin: String, // who actually MADE the file? ("manufacturer", "ultralibrarian", etc.)+ pub url: String, // the download URL the bytes came from+}++pub struct ImportReport {+ pub dest_dir: PathBuf,+ pub files_added: Vec<String>,+}++/// Import with provenance tracking. If `provenance` is Some, every file+/// that lands gets an entry in `<MPN>-file-provenance.json` mapping+/// `"<MPN>.kicad_sym" → { source, url, fetched_at }`. Chipsmith reads+/// this to show "source: SnapMagic" in the footprint-hover tooltip.+pub fn import_path_with_provenance(+ src: &Path,+ mpn_override: Option<&str>,+ provenance: Option<&FileProvenance>,+) -> Result<ImportReport> {+ let report = import_path(src, mpn_override)?;+ if let Some(prov) = provenance {+ for filename in &report.files_added {+ let _ = write_file_provenance(&report.dest_dir, filename, prov);+ }+ // Keep a per-source VARIANT of the symbol/footprint so multiple sources+ // coexist (the provenance carat lists each). Variant filename:+ // <mpn>.<method>.kicad_sym, where method = content_origin (who made it).+ let method = slugify(if !prov.content_origin.is_empty() { &prov.content_origin } else { &prov.discovery_source });+ if !method.is_empty() && method != "ds2sf" {+ if let Some(mpn) = report.dest_dir.file_name().and_then(|n| n.to_str()) {+ for ext in ["kicad_sym", "kicad_mod"] {+ let canon = format!("{mpn}.{ext}");+ if report.files_added.iter().any(|f| f == &canon) {+ let variant = report.dest_dir.join(format!("{mpn}.{method}.{ext}"));+ let _ = std::fs::copy(report.dest_dir.join(&canon), &variant);+ }+ }+ }+ }+ }+ Ok(report)+}++/// Lowercase a source slug to safe filename chars (e.g. "Ultra Librarian" →+/// "ultralibrarian"), so variant filenames are predictable.+fn slugify(s: &str) -> String {+ s.chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_lowercase()).collect()+}++pub fn import_path(src: &Path, mpn_override: Option<&str>) -> Result<ImportReport> {+ if !src.exists() {+ return Err(anyhow!("path does not exist: {}", src.display()));+ }++ let mpn = match mpn_override {+ Some(m) => m.to_string(),+ None => infer_mpn(src)+ .ok_or_else(|| anyhow!("could not infer MPN from path '{}' — pass --mpn", src.display()))?,+ };++ let dest = library::ensure_dir(&mpn)?;+ let mut added = Vec::new();++ if src.is_file() {+ ingest_file(src, &mpn, &dest, &mut added)?;+ } else if src.is_dir() {+ ingest_dir(src, &mpn, &dest, &mut added)?;+ }++ Ok(ImportReport { dest_dir: dest, files_added: added })+}++fn ingest_file(file: &Path, mpn: &str, dest: &Path, added: &mut Vec<String>) -> Result<()> {+ let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();+ match ext.as_str() {+ "zip" => unzip_and_ingest(file, mpn, dest, added),+ "step" | "stp" | "pdf" | "kicad_mod" | "kicad_sym" | "wrl" => {+ // Magic-byte validation. Catches the "naked curl returned a+ // 174 KB HTML denial page that looks like 200 OK" footgun —+ // PDFs are common, STEPs are common, and both have unambiguous+ // magic strings. Refuse the import if the bytes don't match.+ validate_magic_bytes(file, &ext)+ .with_context(|| format!("magic-byte check failed for {}", file.display()))?;++ let target_ext = if ext == "stp" { "step" } else { ext.as_str() };+ let target_name = format!("{mpn}.{target_ext}");+ let target = dest.join(&target_name);+ std::fs::copy(file, &target).with_context(|| format!("copying {} → {}", file.display(), target.display()))?;+ added.push(target_name.clone());+ // Per-MPN fetch-log: append a structured entry every time a+ // file lands. Audit trail without trusting AI memory.+ let _ = append_fetch_log(dest, mpn, &target_name, file);+ Ok(())+ }+ // Fusion 360 + Altium. Inventory checks exact-case filenames+ // (<MPN>.lbr, <MPN>.SchLib, <MPN>.PcbLib, <MPN>.IntLib), so we+ // preserve the canonical case on the way in.+ "lbr" | "schlib" | "pcblib" | "intlib" | "libpkg" => {+ let target_ext = match ext.as_str() {+ "lbr" => "lbr",+ "schlib" => "SchLib",+ "pcblib" => "PcbLib",+ "intlib" => "IntLib",+ "libpkg" => "LibPkg",+ _ => ext.as_str(),+ };+ let target_name = format!("{mpn}.{target_ext}");+ let target = dest.join(&target_name);+ std::fs::copy(file, &target).with_context(|| format!("copying {} → {}", file.display(), target.display()))?;+ added.push(target_name.clone());+ let _ = append_fetch_log(dest, mpn, &target_name, file);+ Ok(())+ }+ _ => Ok(()),+ }+}++fn ingest_dir(dir: &Path, mpn: &str, dest: &Path, added: &mut Vec<String>) -> Result<()> {+ for entry in walk_recursive(dir)? {+ ingest_file(&entry, mpn, dest, added)?;+ }+ Ok(())+}++fn unzip_and_ingest(zip: &Path, mpn: &str, dest: &Path, added: &mut Vec<String>) -> Result<()> {+ let tmp = tempfile_dir()?;+ let status = Command::new("unzip")+ .arg("-q").arg("-o")+ .arg(zip)+ .arg("-d").arg(&tmp)+ .status()+ .context("running unzip — install `unzip` if missing")?;+ if !status.success() {+ return Err(anyhow!("unzip failed for {}", zip.display()));+ }+ ingest_dir(&tmp, mpn, dest, added)?;+ let _ = std::fs::remove_dir_all(&tmp);+ Ok(())+}++fn tempfile_dir() -> Result<PathBuf> {+ let base = std::env::temp_dir().join(format!("adom-chip-fetcher-{}", std::process::id()));+ std::fs::create_dir_all(&base)?;+ Ok(base)+}++fn walk_recursive(dir: &Path) -> Result<Vec<PathBuf>> {+ let mut out = Vec::new();+ let mut stack = vec![dir.to_path_buf()];+ while let Some(d) = stack.pop() {+ for entry in std::fs::read_dir(&d)? {+ let entry = entry?;+ let p = entry.path();+ if p.is_dir() { stack.push(p); }+ else { out.push(p); }+ }+ }+ Ok(out)+}++/// Infer an MPN from the path. Looks at the filename (and parent zip name)+/// for a token that looks like a part number — letters mixed with digits.+fn infer_mpn(path: &Path) -> Option<String> {+ let stem = path.file_stem()?.to_str()?;+ for token in stem.split(|c: char| c == '-' || c == '_' || c == ' ' || c == '.') {+ if looks_like_mpn(token) { return Some(token.to_string()); }+ }+ None+}++/// Append a per-file provenance entry to `<MPN>-file-provenance.json`.+/// Chipsmith reads this to show "source: SnapMagic" in the footprint+/// tooltip. Format:+/// ```json+/// { "ADS1115IDGSR.kicad_sym": { "source": "snapmagic", "url": "...", "fetched_at": 1778... },+/// "ADS1115IDGSR.step": { "source": "manufacturer", "url": "...", "fetched_at": 1778... } }+/// ```+fn write_file_provenance(dest: &Path, filename: &str, prov: &FileProvenance) -> Result<()> {+ use std::time::{SystemTime, UNIX_EPOCH};+ let mpn = dest.file_name().and_then(|n| n.to_str()).unwrap_or("?");+ let path = dest.join(format!("{mpn}-file-provenance.json"));+ let mut map: serde_json::Map<String, serde_json::Value> = if path.exists() {+ let s = std::fs::read_to_string(&path).unwrap_or_default();+ serde_json::from_str(&s).unwrap_or_default()+ } else {+ serde_json::Map::new()+ };+ let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);+ map.insert(filename.to_string(), serde_json::json!({+ "discovery_source": prov.discovery_source,+ "content_origin": prov.content_origin,+ "url": prov.url,+ "fetched_at": now,+ }));+ std::fs::write(&path, serde_json::to_string_pretty(&serde_json::Value::Object(map))?)?;+ Ok(())+}++fn looks_like_mpn(s: &str) -> bool {+ let has_alpha = s.chars().any(|c| c.is_ascii_alphabetic());+ let has_digit = s.chars().any(|c| c.is_ascii_digit());+ let len_ok = s.len() >= 4 && s.len() <= 24;+ has_alpha && has_digit && len_ok+}++/// Datasheet PDF quality — the programmatic guarantee that we have a REAL+/// datasheet, not a 1-page denial/cover page. Shells to pdfinfo (page count) and+/// pdftotext (pinout text). Both ship in the container.+pub struct PdfQuality {+ pub pages: u32,+ pub has_pinout: bool,+}++pub fn pdf_quality(file: &Path) -> PdfQuality {+ let pages = std::process::Command::new("pdfinfo").arg(file).output().ok()+ .and_then(|o| String::from_utf8(o.stdout).ok())+ .and_then(|s| s.lines().find_map(|l| l.strip_prefix("Pages:").map(|n| n.trim().parse::<u32>().ok())))+ .flatten()+ .unwrap_or(0);+ // Pinout signal: pin-table / pin-configuration / common analog pin names.+ let text = std::process::Command::new("pdftotext").arg(file).arg("-").output().ok()+ .and_then(|o| String::from_utf8(o.stdout).ok())+ .unwrap_or_default()+ .to_lowercase();+ let has_pinout = ["pin functions", "pin configuration", "terminal functions",+ "pinout", "pin assignments", "pin descriptions"]+ .iter().any(|k| text.contains(k))+ || text.matches("pin ").count() >= 8;+ PdfQuality { pages, has_pinout }+}++/// Is this chip's datasheet GOOD ENOUGH for ds2sf to extract from? (multi-page ++/// a pinout). Used by the gate + the fetch flow to guarantee a usable datasheet+/// before — and after — running ds2sf. Returns Err(reason) when it isn't.+pub fn datasheet_ready(pdf: &Path) -> std::result::Result<PdfQuality, String> {+ if !pdf.is_file() {+ return Err("no datasheet PDF present".into());+ }+ let q = pdf_quality(pdf);+ if q.pages < 3 {+ return Err(format!("datasheet is only {} page(s) — a CDN cover/denial page, not the real datasheet", q.pages));+ }+ if !q.has_pinout {+ return Err(format!("datasheet ({} pages) has no detectable pinout/pin-table — ds2sf can't extract a symbol from it", q.pages));+ }+ Ok(q)+}++/// Magic-byte validation. PDFs and STEPs both have unambiguous magic+/// strings — checking the first few bytes is the cheapest defense+/// against "naked curl returned a 174 KB Cloudflare denial page that+/// looks like 200 OK." Returns Err if the bytes don't match the claimed+/// extension. KiCad sym/mod and WRL are text-format with looser headers,+/// so we sanity-check just enough to reject HTML.+fn validate_magic_bytes(file: &Path, ext: &str) -> Result<()> {+ use std::io::Read;+ let mut f = std::fs::File::open(file)?;+ let mut buf = [0u8; 64];+ let n = f.read(&mut buf)?;+ let head = &buf[..n];+ let head_lc: String = head.iter().take(32).map(|b| b.to_ascii_lowercase() as char).collect();++ // HTML masquerading as anything is the #1 footgun. Always reject.+ if head_lc.starts_with("<!doctype html") || head_lc.starts_with("<html") || head_lc.contains("<head>") {+ return Err(anyhow!("file looks like HTML (probably a CDN denial / 404 page), not a {ext}: {}", file.display()));+ }++ match ext {+ "pdf" => {+ if !head.starts_with(b"%PDF") {+ return Err(anyhow!("not a real PDF (missing %PDF magic): {}", file.display()));+ }+ // QUALITY GATE (learned the hard way 2026-06-23): %PDF magic alone is+ // NOT enough. A 1-page %PDF is almost always a CDN cover/denial page or+ // a selection guide — NOT the datasheet — and it silently clobbered the+ // real multi-page datasheets, breaking ds2sf. A genuine chip datasheet+ // is many pages. Reject anything < 3 pages outright.+ let q = pdf_quality(file);+ if q.pages > 0 && q.pages < 3 {+ return Err(anyhow!(+ "PDF has only {} page(s) — not a real datasheet (likely a CDN cover/denial/selection page). \+ A genuine chip datasheet is many pages with a pinout. Refusing the import: {}",+ q.pages, file.display()+ ));+ }+ }+ "step" | "stp" => {+ // STEP files start with "ISO-10303-21;" (sometimes preceded by BOM)+ let s: String = head.iter().take(64).map(|b| *b as char).collect();+ if !s.contains("ISO-10303-21") {+ return Err(anyhow!("not a real STEP (missing ISO-10303-21 header): {}", file.display()));+ }+ }+ "kicad_sym" => {+ // KiCad sym is s-expression starting with "(kicad_symbol_lib"+ let s: String = head.iter().take(64).map(|b| *b as char).collect();+ if !s.contains("kicad_symbol_lib") {+ return Err(anyhow!("not a real KiCad symbol library: {}", file.display()));+ }+ }+ "kicad_mod" => {+ let s: String = head.iter().take(64).map(|b| *b as char).collect();+ if !s.contains("footprint") && !s.contains("module") {+ return Err(anyhow!("not a real KiCad footprint: {}", file.display()));+ }+ }+ // VRML and EDA formats — too many vendor variants to magic-check+ // tightly; HTML check above is enough.+ _ => {}+ }+ Ok(())+}++/// Append a structured entry to `<MPN>-fetch-log.json` whenever a real+/// file lands. Audit trail: timestamp, source filename, target name,+/// size, magic-byte check pass. Survives across sessions; the AI doesn't+/// have to remember to write provenance — the binary writes it.+fn append_fetch_log(dest: &Path, mpn: &str, target_name: &str, src: &Path) -> Result<()> {+ use std::time::{SystemTime, UNIX_EPOCH};+ let log_path = dest.join(format!("{mpn}-fetch-log.json"));+ let mut entries: Vec<serde_json::Value> = if log_path.exists() {+ let s = std::fs::read_to_string(&log_path).unwrap_or_default();+ serde_json::from_str(&s).unwrap_or_else(|_| vec![])+ } else {+ vec![]+ };+ let size = std::fs::metadata(src).map(|m| m.len()).unwrap_or(0);+ let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);+ entries.push(serde_json::json!({+ "at": now,+ "target": target_name,+ "src_path": src.display().to_string(),+ "size_bytes": size,+ "magic_ok": true, // we wouldn't reach here if validate_magic_bytes failed+ }));+ let _ = std::fs::write(&log_path, serde_json::to_string_pretty(&entries)?);+ Ok(())+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn infers_mpn_from_typical_ul_filename() {+ let p = Path::new("2099328-Texas-Instruments-TPS62840.zip");+ assert_eq!(infer_mpn(p).as_deref(), Some("TPS62840"));+ }++ #[test]+ fn infers_mpn_from_plain_filename() {+ let p = Path::new("LM358DR.step");+ assert_eq!(infer_mpn(p).as_deref(), Some("LM358DR"));+ }++ #[test]+ fn rejects_non_mpn_tokens() {+ assert!(!looks_like_mpn("Texas"));+ assert!(!looks_like_mpn("2099328"));+ assert!(looks_like_mpn("TPS62840"));+ assert!(looks_like_mpn("LM358"));+ }+}
src/inventory.rsadded+422@@ -0,0 +1,422 @@+use serde::Serialize;+use std::path::Path;++#[derive(Serialize)]+pub struct Chip {+ pub mpn: String,+ pub vendor: String,+ pub package: String,+ pub family: String,+ pub fully_bundled: bool,+ pub has_step: bool,+ pub has_sym: bool, // KiCad .kicad_sym+ pub has_mod: bool, // KiCad .kicad_mod+ pub has_altium_sch: bool, // Altium .SchLib+ pub has_altium_pcb: bool, // Altium .PcbLib+ pub has_altium_intlib: bool, // Altium .IntLib (compiled — SchLib+PcbLib in one)+ pub has_altium: bool, // (sch && pcb) || intlib — derived for the UI+ pub altium_not_available: bool, // info.json says "not_available" (exhausted all sources)+ pub has_fusion_lbr: bool, // Fusion 360 .lbr+ pub fusion_not_available: bool, // info.json says "not_available" (exhausted all sources)+ pub has_pdf: bool,+ pub pdf_count: usize,+ pub has_info: bool,+ pub has_thumb_symbol: bool,+ pub has_thumb_footprint: bool,+ pub has_thumb_chip3d: bool, // Z-up iso (canonical, <MPN>-3d-iso.png)+ pub has_thumb_chip3d_yup: bool, // Y-up iso (<MPN>-3d-iso-yup.png) — ME / Y-up source convention+ /// chip-thumbnailer's auto-inferred source-up axis: "z" or "y" (or "?"+ /// when the manifest is missing). Read from <MPN>-thumbs.json's+ /// thumbnails.threeD.inferredSourceUpAxis.+ pub inferred_up_axis: String,+ /// The orientation the user has chosen for this chip ("z" / "y"). Falls+ /// back to the inferred axis when the user hasn't overridden. Stored in+ /// info.json's chosen_up_axis field so downstream tools (adom-step,+ /// adom-chiplinter, etc.) can pick up the right orientation.+ pub chosen_up_axis: String,+ pub step_source: String,+ pub size_bytes: u64,+ pub files: Vec<String>,+ pub info: serde_json::Value,+ pub stock: serde_json::Value,+ /// ds2sf v0.5.1+ outputs: pixel-trustworthy footprint SVG that+ /// downstream consumers (chipsmith visual diff, sym_create previews,+ /// wiki page hero) use as ground truth. Path is relative to the+ /// chip directory; source slug is "service-kicad-stdlib" (canonical+ /// KiCad render) or "ds2sf-synthesized" (best-effort from extracted+ /// dims; layout reference only). Read from+ /// `<MPN>-extraction.result.json`'s `outputs.footprint_svg` field.+ pub authoritative_footprint_svg: Option<String>,+ pub authoritative_footprint_svg_source: Option<String>,+ /// concur v0.x+ persisted state: golden / golden_with_variants /+ /// divergent / unrecoverable. Read from `<MPN>-validation-status.json`+ /// (which our validate.rs writes after every concur run). Surfaces on+ /// the dashboard so the user sees the gold-dataset state per card.+ pub validation_status: Option<String>,+ pub validation_variants: Vec<String>,+ /// Per-file provenance: which ladder source + URL each library file+ /// came from. Chipsmith reads this for tooltips. Written by+ /// import::import_path_with_provenance at import time.+ pub file_provenance: serde_json::Value,+ /// Provenance for the symbol/footprint thumbnail carat pulldowns: which+ /// method(s) produced an available variant (vendor source + ds2sf).+ pub symbol_sources: Vec<SourceTag>,+ pub footprint_sources: Vec<SourceTag>,+ /// chip-thumbnailer's per-kind failure reasons, read from+ /// `.thumb-errors.json` (e.g. {"sym":"...","fp":"...","3d":"..."}).+ /// Lets the dashboard render a "render failed" tile state with the+ /// reason, instead of a blank tile that's indistinguishable from+ /// "source file absent". Null when no errors file is present.+ pub thumb_errors: serde_json::Value,+}++#[derive(Serialize)]+pub struct Library {+ pub chips: Vec<Chip>,+ pub total_size: u64,+ pub fully_count: usize,+ pub stub_count: usize,+}++fn vendor_from_mpn(mpn: &str) -> &'static str {+ let m = mpn.to_uppercase();+ let prefixes = [+ ("TPS65987", "TI"), ("MCXN", "NXP"), ("ESP32", "Espressif"),+ ("ICE40", "Lattice"), ("R7FA", "Renesas"), ("DA14", "Renesas"),+ ("ATSAM", "Microchip"), ("ATME", "Microchip"), ("ATTI", "Microchip"),+ ("VL5", "ST"), ("STM", "ST"), ("LSM", "ST"),+ ("BME", "Bosch"), ("BMI", "Bosch"), ("BMP", "Bosch"),+ ("PCA9", "NXP"), ("LPC", "NXP"),+ ("AD7", "ADI"), ("AD8", "ADI"), ("LTC", "ADI"), ("MAX", "ADI"),+ ("DMG", "Diodes"), ("DMP", "Diodes"), ("DMN", "Diodes"),+ ("WAGO", "WAGO"), ("NRF", "Nordic"),+ ("TPS", "TI"), ("ADS", "TI"), ("INA", "TI"), ("LM", "TI"),+ ("DRV", "TI"), ("MCF", "TI"), ("BQ", "TI"), ("MSP", "TI"),+ ("VS", "Vishay"), ("SI", "Vishay"), ("CRCW", "Vishay"),+ ];+ for (p, v) in prefixes.iter() {+ if m.starts_with(p) { return v; }+ }+ "?"+}++pub fn scan() -> anyhow::Result<Library> {+ let root = crate::library::root();+ let mut chips = Vec::new();+ let mut total_size = 0u64;+ if !root.exists() {+ return Ok(Library { chips, total_size: 0, fully_count: 0, stub_count: 0 });+ }+ for entry in std::fs::read_dir(&root)? {+ let entry = entry?;+ let path = entry.path();+ if !path.is_dir() { continue; }+ let mpn = entry.file_name().to_string_lossy().to_string();+ let chip = read_chip(&path, &mpn)?;+ total_size += chip.size_bytes;+ chips.push(chip);+ }+ chips.sort_by(|a, b| a.mpn.cmp(&b.mpn));+ let fully_count = chips.iter().filter(|c| c.fully_bundled).count();+ let stub_count = chips.len() - fully_count;+ Ok(Library { chips, total_size, fully_count, stub_count })+}++fn read_chip(dir: &Path, mpn: &str) -> anyhow::Result<Chip> {+ let mut files = Vec::new();+ let mut size = 0u64;+ let mut pdf_count = 0;+ let mut has_pdf = false;+ let mut has_step = false;+ let mut has_sym = false;+ let mut has_mod = false;+ let mut has_altium_sch = false;+ let mut has_altium_pcb = false;+ let mut has_altium_intlib = false;+ let mut has_fusion_lbr = false;+ let mut has_info = false;+ let mut info_value = serde_json::Value::Null;+ let mut stock_value = serde_json::Value::Null;++ for entry in std::fs::read_dir(dir)? {+ let entry = entry?;+ let name = entry.file_name().to_string_lossy().to_string();+ if !entry.path().is_file() { continue; }+ let meta = entry.metadata()?;+ size += meta.len();+ files.push(name.clone());+ if name == format!("{mpn}.step") || name == format!("{mpn}.stp") { has_step = true; }+ if name == format!("{mpn}.kicad_sym") { has_sym = true; }+ if name == format!("{mpn}.kicad_mod") { has_mod = true; }+ if name == format!("{mpn}.SchLib") { has_altium_sch = true; }+ if name == format!("{mpn}.PcbLib") { has_altium_pcb = true; }+ if name == format!("{mpn}.IntLib") { has_altium_intlib = true; }+ if name == format!("{mpn}.lbr") { has_fusion_lbr = true; }+ if name == format!("{mpn}.pdf") || name == format!("{mpn}-datasheet.pdf") { has_pdf = true; }+ if name.ends_with(".pdf") { pdf_count += 1; }+ if name == "info.json" {+ has_info = true;+ if let Ok(s) = std::fs::read_to_string(entry.path()) {+ if let Ok(v) = serde_json::from_str::<serde_json::Value>(&s) {+ info_value = v;+ }+ }+ }+ if name == "stock.json" {+ if let Ok(s) = std::fs::read_to_string(entry.path()) {+ if let Ok(v) = serde_json::from_str::<serde_json::Value>(&s) {+ stock_value = v;+ }+ }+ }+ }+ files.sort();++ let vendor = info_value.get("manufacturer").and_then(|v| v.as_str())+ .map(String::from)+ .unwrap_or_else(|| vendor_from_mpn(mpn).to_string());+ let package = info_value.get("package").and_then(|v| v.as_str())+ .unwrap_or("?").to_string();+ let family = info_value.get("family").and_then(|v| v.as_str())+ .unwrap_or("").to_string();+ let step_source = info_value.get("step_source").and_then(|v| v.as_str())+ .unwrap_or("—").to_string();++ // Thumbnails live alongside the source files using chip-thumbnailer's+ // canonical naming: <MPN>-symbol.svg / <MPN>-footprint.svg / <MPN>-3d-iso.png+ let has_thumb_symbol = dir.join(format!("{mpn}-symbol.svg")).is_file();+ let has_thumb_footprint = dir.join(format!("{mpn}-footprint.svg")).is_file();+ let has_thumb_chip3d = dir.join(format!("{mpn}-3d-iso.png")).is_file();+ let has_thumb_chip3d_yup = dir.join(format!("{mpn}-3d-iso-yup.png")).is_file();++ // Read chip-thumbnailer's manifest for the auto-inferred up axis. Defaults+ // to "z" (STEP canonical) when the manifest is absent.+ let inferred_up_axis = std::fs::read_to_string(dir.join(format!("{mpn}-thumbs.json")))+ .ok()+ .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())+ .and_then(|v| v.get("thumbnails")+ .and_then(|t| t.get("threeD"))+ .and_then(|td| td.get("inferredSourceUpAxis"))+ .and_then(|a| a.as_str())+ .map(|s| s.to_string()))+ .unwrap_or_else(|| "z".to_string());++ // User's chosen axis (if they overrode). Falls back to inferred.+ let chosen_up_axis = info_value.get("chosen_up_axis")+ .and_then(|v| v.as_str())+ .map(String::from)+ .unwrap_or_else(|| inferred_up_axis.clone());++ // Altium — accept either the SchLib+PcbLib pair OR a compiled .IntLib (which+ // contains both internally; it's what Component Search Engine downloads ship).+ let has_altium = (has_altium_sch && has_altium_pcb) || has_altium_intlib;+ let altium_not_available = info_value.get("altium_source")+ .and_then(|v| v.as_str()) == Some("not_available");+ let fusion_not_available = info_value.get("fusion_source")+ .and_then(|v| v.as_str()) == Some("not_available");++ // "Fully bundled" = all 3 EDA tools (KiCad sym+mod, Altium, Fusion lbr) + STEP.+ // Datasheet PDF is intentionally not required — many chips ship with manufacturer+ // datasheets via separate fetch and shouldn't gate the bundled-ness signal.+ let fully_bundled = has_step+ && has_sym && has_mod+ && has_altium+ && has_fusion_lbr;++ // ds2sf v0.5.1+ outputs: pull authoritative footprint SVG path + source+ // from <MPN>-extraction.result.json's `outputs` block. Stable contract+ // fields per the v0.5.1 release. Also relativize to chip dir so the+ // dashboard can serve them directly.+ let ext_result = std::fs::read_to_string(dir.join(format!("{mpn}-extraction.result.json")))+ .ok()+ .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());+ let authoritative_footprint_svg = ext_result.as_ref()+ .and_then(|v| v.get("outputs"))+ .and_then(|o| o.get("footprint_svg"))+ .and_then(|s| s.as_str())+ .map(|p| {+ // Strip the absolute prefix so the dashboard can serve it.+ std::path::Path::new(p)+ .file_name()+ .and_then(|n| n.to_str())+ .map(String::from)+ .unwrap_or_else(|| p.to_string())+ });+ let authoritative_footprint_svg_source = ext_result.as_ref()+ .and_then(|v| v.get("outputs"))+ .and_then(|o| o.get("footprint_svg_source"))+ .and_then(|s| s.as_str())+ .map(String::from);++ // concur validation status: read from <MPN>-validation-status.json+ // (which validate.rs writes after every concur run). Surfaces on the+ // dashboard so the user sees the gold-dataset state per card.+ let validation = std::fs::read_to_string(dir.join(format!("{mpn}-validation-status.json")))+ .ok()+ .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());+ let validation_status = validation.as_ref()+ .and_then(|v| v.get("concur_status"))+ .and_then(|s| s.as_str())+ .map(String::from);+ let validation_variants = validation.as_ref()+ .and_then(|v| v.get("variants"))+ .and_then(|a| a.as_array())+ .map(|arr| arr.iter()+ .filter_map(|e| e.get("kind").and_then(|k| k.as_str()).map(String::from))+ .collect())+ .unwrap_or_default();++ // Per-file provenance map — chipsmith reads this for tooltips.+ let file_provenance = std::fs::read_to_string(dir.join(format!("{mpn}-file-provenance.json")))+ .ok()+ .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())+ .unwrap_or(serde_json::Value::Null);++ // chip-thumbnailer's per-kind failure reasons (.thumb-errors.json). Surfaced+ // so a failed render shows as a distinct tile state, not a silent blank.+ let thumb_errors = std::fs::read_to_string(dir.join(".thumb-errors.json"))+ .ok()+ .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())+ .unwrap_or(serde_json::Value::Null);++ // Per-kind source provenance for the symbol/footprint carat pulldowns: which+ // method produced each available variant. The current (rendered) thumbnail+ // is the canonical .kicad_sym/.kicad_mod; ds2sf is an alternate from the+ // datasheet. The frontend renders these as a ▾ carat like the 3D one.+ let symbol_sources = source_tags(+ &dir,+ has_sym,+ &format!("{mpn}.kicad_sym"),+ "kicad_sym",+ &format!("{mpn}-symbol.extracted.json"),+ &file_provenance,+ &info_value,+ );+ let footprint_sources = source_tags(+ &dir,+ has_mod,+ &format!("{mpn}.kicad_mod"),+ "kicad_mod",+ &format!("{mpn}-footprint.extracted.json"),+ &file_provenance,+ &info_value,+ );++ Ok(Chip {+ mpn: mpn.to_string(),+ vendor, package, family,+ fully_bundled,+ has_step, has_sym, has_mod,+ has_altium_sch, has_altium_pcb, has_altium_intlib, has_altium, altium_not_available,+ has_fusion_lbr, fusion_not_available,+ has_pdf, pdf_count,+ has_info,+ has_thumb_symbol, has_thumb_footprint, has_thumb_chip3d, has_thumb_chip3d_yup,+ inferred_up_axis, chosen_up_axis,+ step_source,+ size_bytes: size,+ files,+ info: info_value,+ stock: stock_value,+ authoritative_footprint_svg,+ authoritative_footprint_svg_source,+ validation_status,+ validation_variants,+ file_provenance,+ thumb_errors,+ symbol_sources,+ footprint_sources,+ })+}++/// A provenance entry for a symbol/footprint thumbnail carat.+#[derive(serde::Serialize)]+pub struct SourceTag {+ pub method: String, // "vendor" | "ds2sf" | "manufacturer" | source slug+ pub label: String, // human label, e.g. "SnapEDA", "ds2sf · datasheet"+ pub current: bool, // is this the variant currently rendered as the thumbnail?+ pub thumb: bool, // does a thumbnail exist for this variant (show it in the carat)?+}++fn source_label(slug: &str) -> String {+ match slug {+ "snapmagic" | "snapeda" => "SnapEDA",+ "ultralibrarian" | "cse_ul" | "ul" => "Ultra Librarian",+ "cse" | "componentsearch" => "Component Search",+ "manufacturer" | "mfr" => "Manufacturer",+ "mouser" => "Mouser",+ "digikey" => "DigiKey",+ "arrow" => "Arrow",+ "lcsc" => "LCSC",+ "kicad_community" => "KiCad community",+ "ds2sf" => "ds2sf · datasheet",+ other => other,+ }+ .to_string()+}++/// Build the source list for a symbol/footprint: the canonical vendor file (with+/// its provenance origin) + the ds2sf datasheet extraction when present.+fn source_tags(+ dir: &std::path::Path,+ has_canonical: bool,+ canonical_file: &str,+ sor_key: &str,+ ds2sf_file: &str,+ provenance: &serde_json::Value,+ info: &serde_json::Value,+) -> Vec<SourceTag> {+ let mpn_prefix = canonical_file.trim_end_matches(&format!(".{sor_key}"));+ let ext_suffix = format!(".{sor_key}");+ let thumb_kind = if sor_key == "kicad_sym" { "symbol" } else { "footprint" };+ let canon_thumb = dir.join(format!("{mpn_prefix}-{thumb_kind}.svg")).is_file();++ // Label by WHERE it was downloaded (discovery_source), NOT who authored it+ // (content_origin). Aggregators are never "the manufacturer"; manufacturer-+ // direct names the site. See provenance::source_label.+ let manufacturer = info.get("manufacturer").and_then(|m| m.as_str()).unwrap_or("");+ let canon_disc = provenance+ .get(canonical_file)+ .and_then(|e| e.get("discovery_source").or_else(|| e.get("content_origin")))+ .and_then(|s| s.as_str())+ .or_else(|| info.get("source_of_record").and_then(|s| s.get(sor_key)).and_then(|s| s.as_str()))+ .unwrap_or("vendor")+ .to_string();++ // chip-fetcher's goal: show EVERY library + format we pulled down, named, so+ // the user can compare them and pick. One carat entry PER (source × format):+ // "Component Search Engine - KiCad Library"+ // "Component Search Engine - Altium Library"+ // "Component Search Engine - Fusion 360 Library"+ // "PDF Datasheet from Texas Instruments (ti.com)"+ let src = crate::provenance::source_label(&canon_disc, manufacturer);+ let has = |f: String| dir.join(f).is_file();+ let mut tags = Vec::new();+ // KiCad library — the canonical render shown on the tile (current source).+ if has_canonical {+ tags.push(SourceTag { method: "kicad".into(), label: format!("{src} - KiCad Library"), current: true, thumb: canon_thumb });+ }+ // Altium library (.SchLib has the symbol/device, .PcbLib the footprint).+ // adom-sfconvert turns it into KiCad sym+mod via KiCad 10 (service-kicad),+ // rendered to its own variant thumbnail — so the carat previews the Altium+ // library's OWN geometry.+ let altium = if sor_key == "kicad_sym" { format!("{mpn_prefix}.SchLib") } else { format!("{mpn_prefix}.PcbLib") };+ if has(altium) {+ let altium_thumb = dir.join(format!("{mpn_prefix}-{thumb_kind}.altium.svg")).is_file();+ tags.push(SourceTag { method: "altium".into(), label: format!("{src} - Altium Library"), current: false, thumb: altium_thumb });+ }+ // Fusion 360 / EAGLE library (.lbr holds both the device + the package).+ // adom-sfconvert turns it into KiCad sym+mod, rendered to its own variant+ // thumbnail — so the carat previews the Fusion library's OWN geometry.+ if has(format!("{mpn_prefix}.lbr")) {+ let fusion_thumb = dir.join(format!("{mpn_prefix}-{thumb_kind}.fusion.svg")).is_file();+ tags.push(SourceTag { method: "fusion".into(), label: format!("{src} - Fusion 360 Library"), current: false, thumb: fusion_thumb });+ }+ // The manufacturer DATASHEET, extracted by ds2sf — the independent check.+ let ds2sf_thumb = dir.join(format!("{mpn_prefix}-{thumb_kind}.ds2sf.svg")).is_file();+ if dir.join(ds2sf_file).is_file() && ds2sf_thumb {+ let mfr = crate::provenance::source_label("manufacturer", manufacturer);+ tags.push(SourceTag { method: "ds2sf".into(), label: format!("PDF Datasheet from {mfr}"), current: !has_canonical, thumb: true });+ }+ tags+}
src/library.rsadded+56@@ -0,0 +1,56 @@+use anyhow::Result;+use std::path::PathBuf;++pub struct Entry {+ pub mpn: String,+ pub has_step: bool,+ pub has_mod: bool,+ pub has_sym: bool,+ pub has_pdf: bool,+}++pub fn root() -> PathBuf {+ let here = std::env::current_exe()+ .ok()+ .and_then(|p| p.parent().map(|p| p.to_path_buf()))+ .unwrap_or_else(|| PathBuf::from("."));+ if let Some(env) = std::env::var_os("CHIP_FETCHER_LIB") {+ return PathBuf::from(env);+ }+ let candidate = PathBuf::from("/home/adom/project/chip-fetcher/library");+ if candidate.exists() {+ return candidate;+ }+ here.join("library")+}++pub fn ensure_dir(mpn: &str) -> Result<PathBuf> {+ let dir = root().join(mpn);+ std::fs::create_dir_all(&dir)?;+ Ok(dir)+}++pub fn status(mpn: &str) -> Result<Entry> {+ let dir = root().join(mpn);+ Ok(Entry {+ mpn: mpn.to_string(),+ has_step: dir.join(format!("{mpn}.step")).exists(),+ has_mod: dir.join(format!("{mpn}.kicad_mod")).exists(),+ has_sym: dir.join(format!("{mpn}.kicad_sym")).exists(),+ has_pdf: dir.join(format!("{mpn}.pdf")).exists(),+ })+}++pub fn scan() -> Result<Vec<Entry>> {+ let root = root();+ if !root.exists() { return Ok(vec![]); }+ let mut out = Vec::new();+ for entry in std::fs::read_dir(&root)? {+ let entry = entry?;+ if !entry.file_type()?.is_dir() { continue; }+ let mpn = entry.file_name().to_string_lossy().into_owned();+ out.push(status(&mpn)?);+ }+ out.sort_by(|a, b| a.mpn.cmp(&b.mpn));+ Ok(out)+}
src/main.rsadded+20@@ -0,0 +1,20 @@+mod cli;+mod creds;+mod provenance;+mod deliver;+mod enrich;+mod hints;+mod import;+mod inventory;+mod library;+mod projects;+mod pup;+mod serve;+mod sources;+mod sourcing;+mod thumbnails;+mod validate;++fn main() -> anyhow::Result<()> {+ cli::run()+}
src/projects.rsadded+112@@ -0,0 +1,112 @@+use anyhow::Result;+use serde::{Deserialize, Serialize};+use std::collections::BTreeMap;+use std::path::PathBuf;+use std::time::{SystemTime, UNIX_EPOCH};++#[derive(Debug, Clone, Serialize, Deserialize, Default)]+pub struct Project {+ pub name: String,+ pub mpns: Vec<String>,+ #[serde(default)]+ pub description: String,+ #[serde(default)]+ pub created: String,+}++#[derive(Debug, Clone, Serialize, Deserialize, Default)]+pub struct ProjectStore {+ pub projects: BTreeMap<String, Project>,+ #[serde(default)]+ pub active: Option<String>,+}++fn now_unix() -> u64 {+ SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)+}++fn store_path() -> PathBuf {+ let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());+ let dir = PathBuf::from(home).join(".config/adom-chip-fetcher");+ let _ = std::fs::create_dir_all(&dir);+ dir.join("projects.json")+}++pub fn load() -> ProjectStore {+ let path = store_path();+ match std::fs::read_to_string(&path) {+ Ok(s) => serde_json::from_str(&s).unwrap_or_default(),+ Err(_) => ProjectStore::default(),+ }+}++pub fn save(store: &ProjectStore) -> Result<()> {+ let path = store_path();+ let json = serde_json::to_string_pretty(store)?;+ std::fs::write(&path, json)?;+ Ok(())+}++pub fn add_mpn(project: &str, mpn: &str) -> Result<()> {+ let mut store = load();+ let proj = store.projects.entry(project.to_string()).or_insert_with(|| Project {+ name: project.to_string(),+ mpns: vec![],+ description: String::new(),+ created: now_unix().to_string(),+ });+ if !proj.mpns.contains(&mpn.to_string()) {+ proj.mpns.push(mpn.to_string());+ }+ save(&store)+}++pub fn remove_mpn(project: &str, mpn: &str) -> Result<()> {+ let mut store = load();+ if let Some(proj) = store.projects.get_mut(project) {+ proj.mpns.retain(|m| m != mpn);+ }+ save(&store)+}++pub fn set_active(project: Option<&str>) -> Result<()> {+ let mut store = load();+ store.active = project.map(|s| s.to_string());+ save(&store)+}++pub fn get_active() -> Option<String> {+ load().active+}++pub fn list_projects() -> Vec<Project> {+ load().projects.values().cloned().collect()+}++pub fn get_project(name: &str) -> Option<Project> {+ load().projects.get(name).cloned()+}++pub fn mpns_for_project(name: &str) -> Vec<String> {+ load().projects.get(name).map(|p| p.mpns.clone()).unwrap_or_default()+}++pub fn create_project(name: &str, description: &str) -> Result<()> {+ let mut store = load();+ store.projects.entry(name.to_string()).or_insert_with(|| Project {+ name: name.to_string(),+ mpns: vec![],+ description: description.to_string(),+ created: now_unix().to_string(),+ });+ save(&store)+}++pub fn delete_project(name: &str) -> Result<()> {+ let mut store = load();+ store.projects.remove(name);+ if store.active.as_deref() == Some(name) {+ store.active = None;+ }+ save(&store)+}
src/provenance.rsadded+73@@ -0,0 +1,73 @@+//! Canonical source-provenance LABELING — the single rule for how every Adom+//! EDA app names where a symbol / footprint / 3D / datasheet actually came from.+//!+//! THE RULE (John, 2026-06-23): label by the DISCOVERY SOURCE — where the file+//! was actually downloaded — NOT by content_origin (who authored the model).+//!+//! - ONLY the maker's OWN website counts as "the manufacturer", and then we+//! NAME the site: "Texas Instruments (ti.com)", "Analog Devices (analog.com)".+//! - The moment the manufacturer hands you off to a 3rd-party library —+//! UltraLibrarian, SnapMagic/SnapEDA, Component Search Engine — that+//! AGGREGATOR is the source. NEVER label it "Manufacturer".+//! - A fuzzy bare "Manufacturer" is misleading and banned.+//!+//! This module is mirrored (same mapping) in adom-symbol, adom-footprint and+//! adom-lbr so the label is identical everywhere. See the `chip-provenance-labeling` skill.++/// The maker's canonical website domain, for "Texas Instruments (ti.com)" labels.+/// Matched case-insensitively on a substring of the manufacturer name.+pub fn mfr_domain(manufacturer: &str) -> Option<&'static str> {+ let m = manufacturer.to_lowercase();+ let table = [+ ("texas instr", "ti.com"), ("analog dev", "analog.com"),+ ("stmicro", "st.com"), ("nxp", "nxp.com"), ("microchip", "microchip.com"),+ ("infineon", "infineon.com"), ("on semi", "onsemi.com"), ("onsemi", "onsemi.com"),+ ("nordic", "nordicsemi.com"), ("espressif", "espressif.com"),+ ("renesas", "renesas.com"), ("diodes inc", "diodes.com"), ("vishay", "vishay.com"),+ ("bosch", "bosch-sensortec.com"), ("maxim", "analog.com"), ("dialog", "renesas.com"),+ ("rohm", "rohm.com"), ("toshiba", "toshiba.com"), ("skyworks", "skyworksinc.com"),+ ("qorvo", "qorvo.com"), ("micron", "micron.com"), ("cypress", "infineon.com"),+ ("silicon lab", "silabs.com"), ("silabs", "silabs.com"), ("lattice", "latticesemi.com"),+ ("winbond", "winbond.com"), ("ti ", "ti.com"),+ ];+ table.iter().find(|(k, _)| m.contains(k)).map(|(_, v)| *v)+}++/// The human label for a source, given the discovery-source slug + the+/// manufacturer name. THIS is the function every app calls.+pub fn source_label(discovery: &str, manufacturer: &str) -> String {+ match discovery.to_lowercase().as_str() {+ // Manufacturer-DIRECT: only when downloaded from the maker's own site.+ "manufacturer" | "mfr" | "vendor" => {+ if let Some(dom) = mfr_domain(manufacturer) {+ if manufacturer.is_empty() { format!("Manufacturer · {dom}") }+ else { format!("{manufacturer} ({dom})") }+ } else if !manufacturer.is_empty() {+ format!("{manufacturer} (direct)")+ } else {+ "Manufacturer (direct site)".to_string()+ }+ }+ // 3rd-party aggregators — NEVER "the manufacturer".+ "cse" | "componentsearch" | "cse_ul" | "samacsys" => "Component Search Engine".into(),+ "snapeda" | "snapmagic" => "SnapEDA".into(),+ "ultralibrarian" | "ul" => "Ultra Librarian".into(),+ "mouser" => "Mouser".into(),+ "digikey" => "DigiKey".into(),+ "arrow" => "Arrow".into(),+ "lcsc" => "LCSC".into(),+ "kicad_community" => "KiCad community".into(),+ other => other.to_string(),+ }+}++/// A short note for the "the manufacturer linked you to it, but it's a 3rd+/// party" middle-ground — appended in tooltips so the user knows the maker+/// pointed here even though the file came off an aggregator.+pub fn linked_from_mfr_note(manufacturer: &str) -> String {+ if manufacturer.is_empty() {+ "the manufacturer's page linked to this 3rd-party library".to_string()+ } else {+ format!("{manufacturer}'s own page links here, but the file is hosted by this 3rd-party library — not downloaded from {manufacturer} directly")+ }+}
src/pup.rsadded+719@@ -0,0 +1,719 @@+//! Browser transport for adom-chip-fetcher.+//!+//! adom-chip-fetcher drives the user's desktop browser to source CAD. There are two+//! transports, and we DEFAULT to the first when it's available:+//!+//! 1. **native-browser** (preferred) — the `adom-browser-extension` bridge+//! (`nbrowser_*` verbs). Drives the user's REAL signed-in Chrome/Edge, so+//! their DigiKey/Mouser/SnapEDA logins, distributor pricing, and captcha+//! human-trust all work. Several sites hard-block Puppeteer (DigiKey is the+//! clearest) but serve the native session normally.+//! 2. **pup** (fallback) — Puppeteer (`browser_*` verbs), a cold profile where+//! we have to recreate auth and can hit bot-walls.+//!+//! ISOLATION RULE (John, 2026-06-23): when using the native browser we open ONE+//! new window for the session and manage every site as a TAB inside it, so we+//! never touch the user's other Chrome/Edge windows. That falls out of the+//! session-keyed pattern: `open_window(session)` makes the dedicated window the+//! first time; every later `open_tab(session, …)` adds a tab to *that* window.++use anyhow::{Context, Result};+use serde_json::{json, Value};+use std::process::Command;+use std::sync::OnceLock;++/// Resolved transport: which verb family to use and which desktop to target.+struct Transport {+ /// Verb prefix: "nbrowser_" (native) or "browser_" (pup).+ prefix: &'static str,+ /// `--target <name>` to disambiguate when 2+ desktops are connected. `None`+ /// lets adom-desktop auto-route (fine for a single desktop).+ target: Option<String>,+ /// True when we're on the user's real signed-in browser.+ native: bool,+}++static TRANSPORT: OnceLock<Transport> = OnceLock::new();++fn transport() -> &'static Transport {+ TRANSPORT.get_or_init(detect_transport)+}++/// True when sourcing runs in the user's real signed-in browser.+pub fn is_native() -> bool {+ transport().native+}++/// Human label for the active surface (for dashboards / logs).+pub fn surface_label() -> String {+ let t = transport();+ if t.native {+ format!("native browser{}", t.target.as_ref().map(|x| format!(" ({x})")).unwrap_or_default())+ } else {+ "pup (cold profile)".to_string()+ }+}++fn detect_transport() -> Transport {+ // Explicit override: CHIP_FETCHER_BROWSER=native|pup+ match std::env::var("CHIP_FETCHER_BROWSER").ok().as_deref() {+ Some("pup") => return Transport { prefix: "browser_", target: None, native: false },+ Some("native") => {+ let target = find_native().unwrap_or(None);+ return Transport { prefix: "nbrowser_", target, native: true };+ }+ _ => {}+ }+ // Auto: prefer the native browser whenever its bridge is live + drivable.+ match find_native() {+ Some(target) => {+ eprintln!(+ "[adom-chip-fetcher] sourcing in an ISOLATED window of your signed-in browser \+ (native-browser bridge{}). Real logins, your distributor pricing, no bot-walls.",+ target.as_ref().map(|t| format!(", {t}")).unwrap_or_default()+ );+ Transport { prefix: "nbrowser_", target, native: true }+ }+ None => {+ eprintln!(+ "[adom-chip-fetcher] native-browser bridge not found — falling back to pup (cold profile). \+ Install the Adom browser extension for your REAL logins + to skip bot-walls that \+ block headless Chrome (DigiKey blocks it outright): \+ https://wiki.adom.inc/adom/adom-browser-extension"+ );+ Transport { prefix: "browser_", target: None, native: false }+ }+ }+}++/// Probe for a live, drivable `native-browser` bridge.+/// Returns `Some(target)` when found (`target=None` ⇒ no explicit `--target`+/// needed), or `None` when absent. The multi-desktop case is handled by reading+/// the `connected[]` list that `bridge_list` returns on `ambiguous_target`, then+/// probing each desktop by name.+fn find_native() -> Option<Option<String>> {+ let (present, connected) = bridge_probe(None);+ if present {+ return Some(None);+ }+ for name in connected {+ if bridge_probe(Some(&name)).0 {+ return Some(Some(name));+ }+ }+ None+}++/// `(native_browser_running, connected_desktop_names)`. The names are non-empty+/// only when the call came back `ambiguous_target` (2+ desktops). `bridge_list`+/// — unlike `ping` — includes `connected[]` in that error, so we read it here.+fn bridge_probe(target: Option<&str>) -> (bool, Vec<String>) {+ let mut cmd = Command::new("adom-desktop");+ if let Some(t) = target {+ cmd.arg("--target").arg(t);+ }+ cmd.arg("bridge_list");+ let v: Value = match cmd.output() {+ Ok(o) => serde_json::from_slice(&o.stdout).unwrap_or(Value::Null),+ _ => return (false, vec![]),+ };+ let present = v+ .get("bridges")+ .and_then(|b| b.as_array())+ .map(|arr| {+ arr.iter().any(|br| {+ br.get("name").and_then(|n| n.as_str()) == Some("native-browser")+ && br.get("paused").and_then(|p| p.as_bool()) != Some(true)+ && (br.get("status").and_then(|s| s.as_str()) == Some("running")+ || br.get("instanceCount").and_then(|c| c.as_u64()).unwrap_or(0) >= 1)+ })+ })+ .unwrap_or(false);+ let connected = connected_from(&v);+ (present, connected)+}++/// Pull the `connected[]` desktop-name list out of an `ambiguous_target` response.+fn connected_from(v: &Value) -> Vec<String> {+ v.get("connected")+ .and_then(|c| c.as_array())+ .map(|arr| arr.iter().filter_map(|x| x.as_str().map(String::from)).collect())+ .unwrap_or_default()+}++// ── public browser ops (transport-agnostic) ─────────────────────────────────++/// Open the session's ISOLATED window at `url` (first call) — every later+/// `open_tab` on the same session lands in this same window. On the native+/// browser we drive the active signed-in profile, so the cold-profile `profile`+/// arg is ignored there.+pub fn open_window(session_id: &str, profile: &str, url: &str) -> Result<Value> {+ let url = crate::creds::wrap_url_with_auth(url).unwrap_or_else(|_| url.to_string());+ let mut args = json!({ "sessionId": session_id, "url": url });+ if !transport().native {+ args["profile"] = json!(profile);+ }+ call_browser("open_window", &args)+}++/// Add a tab to the session's existing isolated window.+pub fn open_tab(session_id: &str, url: &str) -> Result<Value> {+ let url = crate::creds::wrap_url_with_auth(url).unwrap_or_else(|_| url.to_string());+ call_browser("open_tab", &json!({ "sessionId": session_id, "url": url }))+}++/// True when the session already has a live window (≥1 tab). Used to decide+/// whether to add a tab vs. spawn the window.+pub fn session_live(session_id: &str) -> bool {+ let v = match call_browser("list_tabs", &json!({ "sessionId": session_id })) {+ Ok(v) => v,+ Err(_) => return false,+ };+ // native: {output:"{tabs:[…]}"}, pup: {tabs:[…]}+ let tabs = v.get("tabs").cloned().or_else(|| {+ v.get("output")+ .and_then(|o| o.as_str())+ .and_then(|s| serde_json::from_str::<Value>(s).ok())+ .and_then(|p| p.get("tabs").cloned())+ });+ tabs.and_then(|t| t.as_array().map(|a| !a.is_empty())).unwrap_or(false)+}++/// True when any tab in the session's window has a URL containing `needle`.+pub fn session_has_url(session_id: &str, needle: &str) -> bool {+ let v = match call_browser("list_tabs", &json!({ "sessionId": session_id })) {+ Ok(v) => v,+ Err(_) => return false,+ };+ let tabs = v.get("tabs").cloned().or_else(|| {+ v.get("output")+ .and_then(|o| o.as_str())+ .and_then(|s| serde_json::from_str::<Value>(s).ok())+ .and_then(|p| p.get("tabs").cloned())+ });+ tabs.and_then(|t| t.as_array().cloned())+ .map(|arr| {+ arr.iter().any(|tab| {+ tab.get("url").and_then(|u| u.as_str()).map(|u| u.contains(needle)).unwrap_or(false)+ })+ })+ .unwrap_or(false)+}++/// Open `url` in the session's ONE isolated window — as a TAB when the window+/// already exists (e.g. the dashboard is tab 1), else create the window. This+/// is the rule for native browsing: every source lands as a tab beside the+/// dashboard, never in a separate window and never touching the user's others.+pub fn open_site(session_id: &str, profile: &str, url: &str) -> Result<Value> {+ // Native work window: dashboard is ALWAYS tab 1; vendor pages are tabs beside it.+ if is_native() && session_id == WORK_SESSION {+ ensure_work_window()?;+ return open_tab(session_id, url);+ }+ if session_live(session_id) {+ open_tab(session_id, url)+ } else {+ open_window(session_id, profile, url)+ }+}++pub fn eval(session_id: &str, expr: &str) -> Result<Value> {+ let v = call_browser("eval", &json!({ "sessionId": session_id, "expr": expr }))?;+ // native returns {output:{result}}, pup returns {result}; unwrap either.+ if let Some(out) = v.get("output").and_then(|o| o.as_str()) {+ if let Ok(parsed) = serde_json::from_str::<Value>(out) {+ return Ok(parsed.get("result").cloned().unwrap_or(parsed));+ }+ }+ Ok(v.get("result").cloned().unwrap_or(v))+}++/// Close the session's isolated window (cleanup after a run). Best-effort.+pub fn close_window(session_id: &str) -> Result<Value> {+ call_browser("close_window", &json!({ "sessionId": session_id }))+}++// ── credential awareness: adom-chip-fetcher reads the browser's saved-login store ──+// across ALL profiles (Chrome + Edge), maps hosts to the sourcing ladder, and+// auto-switches to the profile that's signed in to the vendor it's about to+// scrape — so the whole flow self-drives without asking the user anything.++#[derive(Clone)]+pub struct Cred {+ pub browser: String,+ pub profile: String,+ pub host: String,+ pub username: String,+}++static CREDS: OnceLock<Vec<Cred>> = OnceLock::new();++/// Every saved login the browser knows about (origins + usernames; never+/// passwords). Cached for the run.+pub fn credentials() -> &'static Vec<Cred> {+ CREDS.get_or_init(|| query_credentials().unwrap_or_default())+}++fn query_credentials() -> Result<Vec<Cred>> {+ let v = call_browser("credentials", &json!({}))?;+ let out = v.get("output").and_then(|o| o.as_str())+ .ok_or_else(|| anyhow::anyhow!("nbrowser_credentials: no output"))?;+ let parsed: Value = serde_json::from_str(out)?;+ let arr = parsed.as_array().cloned()+ .or_else(|| parsed.get("credentials").and_then(|c| c.as_array()).cloned())+ .unwrap_or_default();+ Ok(arr.iter().map(|r| Cred {+ browser: r.get("browser").and_then(|x| x.as_str()).unwrap_or("").to_string(),+ profile: r.get("profile").and_then(|x| x.as_str()).unwrap_or("").to_string(),+ host: r.get("host").and_then(|x| x.as_str()).unwrap_or("").to_lowercase(),+ username: r.get("username").and_then(|x| x.as_str()).unwrap_or("").to_string(),+ }).collect())+}++/// Ladder-vendor host fragments → friendly name.+pub const VENDOR_HOSTS: &[(&str, &str)] = &[+ ("ultralibrarian", "UltraLibrarian"),+ ("snapeda", "SnapEDA"), ("snapmagic", "SnapEDA"),+ ("componentsearchengine", "SamacSys"), ("samacsys", "SamacSys"),+ ("mouser", "Mouser"), ("digikey", "DigiKey"),+ ("st.com", "ST"), ("ti.com", "TI"), ("nordicsemi", "Nordic"),+ ("nxp.com", "NXP"), ("microchip", "Microchip"), ("arrow.com", "Arrow"),+];++/// Hosts whose downloads are authenticated by adom-chip-fetcher's OWN credential+/// vault (an `Authorization: Basic` header on the XHR), NOT by the browser's+/// saved cookie session. For these we must NOT switch the active browser to+/// wherever a saved login happens to live — the vault works in any browser, so+/// we stay on the user's preferred browser (Chrome). This is why John saw Edge:+/// his CSE login is saved in Edge, so the auto-pick dragged the whole run there+/// even though CSE never needed that browser login.+pub const VAULT_AUTH_HOSTS: &[&str] = &["componentsearchengine", "samacsys"];++/// The user's preferred browser to drive (John: "drive this in my Chrome").+/// Profile keys are "<browser>:<email>"; we bias profile selection toward this.+pub const PREFERRED_BROWSER: &str = "chrome";++/// If `url`'s host matches a ladder vendor, return the profile (email) that's+/// signed in to it — preferring the profile with the broadest vendor coverage,+/// so multi-vendor runs stay on one rich profile when possible.+pub fn profile_for_url(url: &str) -> Option<String> {+ let lower = url.to_lowercase();+ // Vault-authed source → never switch browser; stay on the preferred one.+ if VAULT_AUTH_HOSTS.iter().any(|h| lower.contains(*h)) {+ return None;+ }+ let frag = VENDOR_HOSTS.iter().map(|(h, _)| *h).find(|h| lower.contains(*h))?;+ let creds = credentials();+ // count each profile's total ladder-vendor coverage (for tie-breaking)+ use std::collections::HashMap;+ let mut coverage: HashMap<&str, usize> = HashMap::new();+ for c in creds {+ if VENDOR_HOSTS.iter().any(|(h, _)| c.host.contains(*h)) {+ *coverage.entry(c.profile.as_str()).or_default() += 1;+ }+ }+ creds.iter()+ .filter(|c| c.host.contains(frag))+ .max_by_key(|c| coverage.get(c.profile.as_str()).copied().unwrap_or(0))+ .map(|c| c.profile.clone())+}++/// Switch the native browser's active profile for the next nav/fetch.+pub fn use_profile(email: &str) -> Result<Value> {+ call_browser("use_profile", &json!({ "profile": email }))+}++// ── one-window scrape: a dedicated scrape TAB inside the "adom-chip-fetcher" window ──+// In native mode every vendor page is driven in ONE tab of the single+// adom-chip-fetcher window (tab 1 = dashboard, never touched). This keeps scraping in+// the same isolated window instead of spawning a second "adom-chip-fetcher-scrape" one.++const WORK_SESSION: &str = "adom-chip-fetcher";++fn scrape_tab_file() -> std::path::PathBuf {+ let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".into());+ std::path::PathBuf::from(home).join(".config/adom-chip-fetcher/scrape-tab")+}++fn extract_tab_id(v: &Value) -> Option<String> {+ if let Some(out) = v.get("output").and_then(|o| o.as_str()) {+ if let Ok(p) = serde_json::from_str::<Value>(out) {+ if let Some(id) = p.get("tabId").and_then(|x| x.as_str()) {+ return Some(id.to_string());+ }+ }+ }+ v.get("tabId").and_then(|x| x.as_str()).map(String::from)+}++fn work_tab_ids() -> Vec<String> {+ let v = match call_browser("list_tabs", &json!({ "sessionId": WORK_SESSION })) {+ Ok(v) => v,+ Err(_) => return vec![],+ };+ let tabs = v.get("tabs").cloned().or_else(|| {+ v.get("output").and_then(|o| o.as_str())+ .and_then(|s| serde_json::from_str::<Value>(s).ok())+ .and_then(|p| p.get("tabs").cloned())+ });+ tabs.and_then(|t| t.as_array().cloned())+ .map(|arr| arr.iter().filter_map(|t| t.get("tabId").and_then(|x| x.as_str().map(String::from))).collect())+ .unwrap_or_default()+}++fn dashboard_url() -> String {+ if let Ok(host) = std::env::var("ADOM_PROXY_HOST") {+ format!("https://{host}/proxy/8786/")+ } else if let Ok(tmpl) = std::env::var("VSCODE_PROXY_URI") {+ tmpl.replace("{{port}}", "8786")+ } else {+ "http://127.0.0.1:8786/".to_string()+ }+}++/// John's rule: NEVER open the work window without the adom-chip-fetcher dashboard as+/// the FIRST tab. Every path that adds a vendor/scrape tab calls this first, so+/// the window is always created with the dashboard as tab 1.+pub fn ensure_work_window() -> Result<()> {+ if !is_native() {+ return Ok(());+ }+ if session_has_url(WORK_SESSION, "/proxy/8786/") || session_has_url(WORK_SESSION, ":8786/") {+ return Ok(());+ }+ // Drive the user's PREFERRED browser (Chrome). Switch the bridge's active+ // profile to a Chrome profile before opening, so the work window — and every+ // scrape tab in it — lives in Chrome, not whichever browser happens to hold+ // the most vendor logins.+ prefer_chrome_profile();+ // No dashboard tab present → open the window WITH the dashboard as tab 1.+ open_window(WORK_SESSION, WORK_SESSION, &dashboard_url())?;+ Ok(())+}++/// Switch the bridge's active profile to a Chrome profile if one exists, so the+/// work window opens in Chrome (John's stated preference). No-op if the bridge+/// reports no Chrome profile.+pub fn prefer_chrome_profile() {+ let v = match call_browser("profiles", &json!({})) {+ Ok(v) => v,+ Err(_) => return,+ };+ let out = v.get("output").and_then(|o| o.as_str())+ .and_then(|s| serde_json::from_str::<Value>(s).ok())+ .unwrap_or(v);+ let active = out.get("active").and_then(|a| a.as_str()).unwrap_or("");+ if active.starts_with(PREFERRED_BROWSER) {+ return; // already on Chrome+ }+ if let Some(profs) = out.get("profiles").and_then(|p| p.as_array()) {+ if let Some(chrome) = profs.iter().find(|p| {+ p.get("browser").and_then(|b| b.as_str()) == Some(PREFERRED_BROWSER)+ && p.get("live").and_then(|l| l.as_bool()).unwrap_or(true)+ }) {+ if let Some(key) = chrome.get("profile").and_then(|p| p.as_str()) {+ let _ = use_profile(key);+ eprintln!("[adom-chip-fetcher] driving your Chrome profile '{key}'");+ }+ }+ }+}++/// The dedicated scrape tab in the work window — reuse the stored one if it's+/// still alive, else open a fresh tab (beside the dashboard) and remember it.+pub fn ensure_scrape_tab() -> Result<String> {+ ensure_work_window()?; // dashboard is always tab 1 before any scrape tab+ let alive = work_tab_ids();+ if let Ok(stored) = std::fs::read_to_string(scrape_tab_file()) {+ let id = stored.trim().to_string();+ if !id.is_empty() && alive.contains(&id) {+ return Ok(id);+ }+ }+ let v = call_browser("open_tab", &json!({ "sessionId": WORK_SESSION, "url": "about:blank" }))?;+ let id = extract_tab_id(&v).ok_or_else(|| anyhow::anyhow!("no scrape tabId in {v}"))?;+ if let Some(dir) = scrape_tab_file().parent() {+ let _ = std::fs::create_dir_all(dir);+ }+ let _ = std::fs::write(scrape_tab_file(), &id);+ Ok(id)+}++/// Navigate the dedicated scrape tab (native) or the pup scrape window (fallback).+pub fn scrape_navigate(url: &str) -> Result<Value> {+ let url = crate::creds::wrap_url_with_auth(url).unwrap_or_else(|_| url.to_string());+ if is_native() {+ // Self-drive: if this is a known vendor, switch to the profile that's+ // signed in to it before loading the page — no questions asked.+ if let Some(prof) = profile_for_url(&url) {+ if use_profile(&prof).is_ok() {+ eprintln!("[adom-chip-fetcher] using your signed-in profile '{prof}' for this vendor");+ }+ }+ let tab = ensure_scrape_tab()?;+ call_browser("navigate", &json!({ "sessionId": WORK_SESSION, "tabId": tab, "url": url }))+ } else {+ // pup: a separate scrape window is the documented two-window pattern.+ let r = call_browser("navigate", &json!({ "sessionId": "adom-chip-fetcher-scrape", "url": url }));+ if r.is_err() {+ return call_browser("open_window", &json!({ "sessionId": "adom-chip-fetcher-scrape", "profile": "adom-chip-fetcher", "url": url }));+ }+ r+ }+}++pub fn scrape_eval(expr: &str) -> Result<Value> {+ let args = if is_native() {+ let tab = ensure_scrape_tab()?;+ json!({ "sessionId": WORK_SESSION, "tabId": tab, "expr": expr })+ } else {+ json!({ "sessionId": "adom-chip-fetcher-scrape", "expr": expr })+ };+ let v = call_browser("eval", &args)?;+ if let Some(out) = v.get("output").and_then(|o| o.as_str()) {+ if let Ok(parsed) = serde_json::from_str::<Value>(out) {+ return Ok(parsed.get("result").cloned().unwrap_or(parsed));+ }+ }+ Ok(v.get("result").cloned().unwrap_or(v))+}++/// Fetch a URL's BYTES through the user's signed-in browser (real cookies, so+/// login-gated CAD works too) and write them to `dest`. No download, no native+/// viewer, no extra window — the bytes come back base64 in one call. Returns+/// (bytes_written, content_type).+pub fn fetch_url_to_file(url: &str, dest: &std::path::Path) -> Result<(usize, String)> {+ let url = crate::creds::wrap_url_with_auth(url).unwrap_or_else(|_| url.to_string());+ let v = call_browser("fetch_url", &json!({ "sessionId": WORK_SESSION, "url": url }))?;+ let out = v.get("output").and_then(|o| o.as_str())+ .ok_or_else(|| anyhow::anyhow!("fetch_url: no output ({v})"))?;+ let o: Value = serde_json::from_str(out)+ .map_err(|e| anyhow::anyhow!("fetch_url: bad json ({e}): {out:.120}"))?;+ if !o.get("ok").and_then(|b| b.as_bool()).unwrap_or(false) {+ anyhow::bail!("fetch_url failed (HTTP {})", o.get("status").and_then(|s| s.as_i64()).unwrap_or(0));+ }+ let body = o.get("body").and_then(|b| b.as_str()).ok_or_else(|| anyhow::anyhow!("fetch_url: no body"))?;+ let ctype = o.get("contentType").and_then(|c| c.as_str()).unwrap_or("").to_string();+ let bytes = b64_decode(body);+ if let Some(dir) = dest.parent() {+ std::fs::create_dir_all(dir).ok();+ }+ std::fs::write(dest, &bytes)?;+ Ok((bytes.len(), ctype))+}++/// Base64-encode (for the CSE basic-auth Authorization header).+fn b64_encode(data: &[u8]) -> String {+ const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";+ let mut out = String::with_capacity(data.len().div_ceil(3) * 4);+ for ch in data.chunks(3) {+ let b = [ch[0], *ch.get(1).unwrap_or(&0), *ch.get(2).unwrap_or(&0)];+ let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;+ out.push(T[(n >> 18 & 63) as usize] as char);+ out.push(T[(n >> 12 & 63) as usize] as char);+ out.push(if ch.len() > 1 { T[(n >> 6 & 63) as usize] as char } else { '=' });+ out.push(if ch.len() > 2 { T[(n & 63) as usize] as char } else { '=' });+ }+ out+}++/// Fetch a Component Search Engine CAD zip for `mpn` via XHR + basic-auth.+/// XHR (unlike fetch()) accepts an Authorization header AND doesn't trigger a+/// browser download — so this bypasses BOTH the fetch() credential ban and the+/// browser's multiple-download block. The scrape tab is navigated to CSE first+/// (same-origin). Chains entry → partID → zipForm → ga/model.php. Returns the+/// raw zip bytes (`.step` + `.kicad_sym` + `.kicad_mod`).+pub fn cse_fetch_zip(mpn: &str, mfr: &str, user: &str, pass: &str) -> Result<Vec<u8>> {+ // Navigate CLEAN (no inline-credential wrapping) — auth is the XHR header,+ // and an inline-cred URL breaks the page + leaks the password into the URL bar.+ let tab = ensure_scrape_tab()?;+ call_browser("navigate", &json!({+ "sessionId": WORK_SESSION, "tabId": tab,+ "url": "https://mouser.componentsearchengine.com/"+ }))?;+ std::thread::sleep(std::time::Duration::from_secs(4));+ let auth = b64_encode(format!("{user}:{pass}").as_bytes());+ let mpn_js = serde_json::to_string(mpn).unwrap();+ let mfr_js = serde_json::to_string(mfr).unwrap();+ let expr = format!(+ "(function(){{return new Promise(function(res){{var A='Basic {auth}';var MPN={mpn_js};var MFR={mfr_js};\+ function xhr(u,ty,cb){{var x=new XMLHttpRequest();x.open('GET',u,true);x.setRequestHeader('Authorization',A);\+ if(ty)x.responseType=ty;x.onload=function(){{cb(x)}};x.onerror=function(){{res(JSON.stringify({{err:'xhr'}}))}};x.send()}}\+ var base='https://mouser.componentsearchengine.com/';\+ xhr(base+'entry_u_newDesign.php?mna='+encodeURIComponent(MFR)+'&mpn='+encodeURIComponent(MPN)+'&pna=mouser&vrq=multi&fmt=zip&o3=0&lang=en-GB',null,function(a){{\+ var m=(a.responseText.match(/partID=(\\d+)/)||[]);var pid=m[1];if(!pid)return res(JSON.stringify({{err:'no-partID'}}));\+ xhr(base+'preview_newDesign.php?o3=0&partID='+pid+'&ev=0&fmt=zip&pna=Mouser',null,function(b){{\+ var f=new DOMParser().parseFromString(b.responseText,'text/html').getElementById('zipForm');if(!f)return res(JSON.stringify({{err:'no-zipForm'}}));\+ var q=[].slice.call(f.querySelectorAll('input,select')).map(function(i){{return encodeURIComponent(i.name)+'='+encodeURIComponent(i.value)}}).filter(function(s){{return s.length>1}}).join('&');\+ xhr(base+'ga/model.php?'+q,'arraybuffer',function(c){{var u=new Uint8Array(c.response);var s='';\+ for(var i=0;i<u.length;i+=8192)s+=String.fromCharCode.apply(null,u.subarray(i,i+8192));\+ res(JSON.stringify({{head:[u[0],u[1],u[2],u[3]],len:u.length,b64:btoa(s)}}))}})}})}})}})}})()",+ auth = auth, mpn_js = mpn_js, mfr_js = mfr_js+ );+ // Cold CSE calls flake (the entry XHR fires before the session cookie+ // fully settles → spurious "no-partID"). Retry the whole chain a few+ // times, re-warming the page each round, before giving up.+ let mut last_err = String::from("no attempt");+ for attempt in 0..4 {+ if attempt > 0 {+ // re-warm: the homepage navigate primes the session cookie+ let _ = call_browser("navigate", &json!({+ "sessionId": WORK_SESSION, "tabId": tab,+ "url": "https://mouser.componentsearchengine.com/"+ }));+ std::thread::sleep(std::time::Duration::from_secs(3));+ }+ let v = scrape_eval(&expr)?;+ let s = v.as_str().map(String::from).unwrap_or_else(|| v.to_string());+ let r: Value = serde_json::from_str(&s).unwrap_or(v);+ let head: Vec<u64> = r.get("head").and_then(|h| h.as_array())+ .map(|a| a.iter().filter_map(|x| x.as_u64()).collect()).unwrap_or_default();+ if head == [80, 75, 3, 4] {+ let b64 = r.get("b64").and_then(|b| b.as_str())+ .ok_or_else(|| anyhow::anyhow!("CSE: no zip body"))?;+ return Ok(b64_decode(b64));+ }+ last_err = format!("head {head:?}, err {:?}", r.get("err"));+ }+ anyhow::bail!("CSE returned non-zip after 4 attempts ({last_err})")+}++/// Minimal base64 decoder (the binary has no base64 crate; it has b64 encode in+/// main.rs). Skips whitespace/newlines, stops at padding.+fn b64_decode(s: &str) -> Vec<u8> {+ let mut t = [255u8; 256];+ for (i, c) in b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".iter().enumerate() {+ t[*c as usize] = i as u8;+ }+ let mut out = Vec::with_capacity(s.len() / 4 * 3);+ let (mut buf, mut bits) = (0u32, 0u32);+ for &c in s.as_bytes() {+ if c == b'=' { break; }+ let v = t[c as usize];+ if v == 255 { continue; }+ buf = (buf << 6) | v as u32;+ bits += 6;+ if bits >= 8 {+ bits -= 8;+ out.push((buf >> bits) as u8);+ }+ }+ out+}++pub fn scrape_screenshot() -> Result<Value> {+ if is_native() {+ let tab = ensure_scrape_tab()?;+ call_browser("screenshot", &json!({ "sessionId": WORK_SESSION, "tabId": tab }))+ } else {+ call_browser("screenshot", &json!({ "sessionId": "adom-chip-fetcher-scrape" }))+ }+}++/// Pull a file from the user's desktop into a local container directory.+/// Desktop-level verb (not browser-scoped) — same on both transports.+pub fn pull_file(remote_path: &str, save_to: &str) -> Result<std::path::PathBuf> {+ let v = call_desktop("pull_file", &json!({ "filePaths": [remote_path], "saveTo": save_to }))?;+ let local = v["files"][0]["path"]+ .as_str()+ .ok_or_else(|| anyhow::anyhow!("pull_file returned no path: {v}"))?;+ Ok(std::path::PathBuf::from(local))+}++/// Block until a file matching `glob` lands in `path` on the user's desktop.+pub fn desktop_watch_files(path: &str, glob: &str, timeout_ms: u64) -> Result<Value> {+ call_desktop(+ "desktop_watch_files",+ &json!({ "path": path, "glob": glob, "timeoutMs": timeout_ms }),+ )+}++// ── call plumbing ────────────────────────────────────────────────────────────++/// Call a browser verb — prefixes with the active transport's verb family and+/// injects `--target` when one is resolved.+fn call_browser(suffix: &str, args: &Value) -> Result<Value> {+ let t = transport();+ let verb = format!("{}{}", t.prefix, suffix);+ call_with_recovery(&verb, args, t.target.as_deref(), true)+}++/// Call a desktop-level verb (no transport prefix).+fn call_desktop(action: &str, args: &Value) -> Result<Value> {+ call_with_recovery(action, args, transport().target.as_deref(), true)+}++/// Run `adom-desktop [--target T] <action> <args>` and parse the JSON result.+///+/// Recovers from two transient conditions:+/// - `session_not_found` / `session_disconnected`: rescan + retry once.+/// - `ambiguous_target` (2+ desktops, no target passed): discover the native+/// target (or first connected) and retry once with `--target`.+fn call_with_recovery(+ action: &str,+ args: &Value,+ target: Option<&str>,+ allow_recovery: bool,+) -> Result<Value> {+ let mut cmd = Command::new("adom-desktop");+ if let Some(t) = target {+ cmd.arg("--target").arg(t);+ }+ cmd.arg(action).arg(args.to_string());+ let out = cmd+ .output()+ .with_context(|| format!("running adom-desktop {action}"))?;+ if !out.status.success() {+ anyhow::bail!(+ "adom-desktop {action} failed: {}",+ String::from_utf8_lossy(&out.stderr)+ );+ }+ let v: Value = serde_json::from_slice(&out.stdout)+ .with_context(|| format!("parsing adom-desktop {action} stdout"))?;++ if !allow_recovery {+ return Ok(v);+ }++ // Recovery 1: ambiguous target — pick a concrete desktop and retry.+ if target.is_none() && v.get("error").and_then(|e| e.as_str()) == Some("ambiguous_target") {+ let pick = find_native()+ .and_then(|t| t)+ .or_else(|| connected_from(&v).into_iter().next());+ if let Some(name) = pick {+ println!("WARN: adom-desktop {action} ambiguous_target; retrying on --target {name}");+ return call_with_recovery(action, args, Some(&name), false);+ }+ }++ // Recovery 2: session lost — rescan, then retry once if it came back.+ if let Some(code) = v.get("errorCode").and_then(|c| c.as_str()) {+ if code == "session_not_found" || code == "session_disconnected" {+ let sess = args.get("sessionId").and_then(|s| s.as_str()).unwrap_or("");+ println!("WARN: adom-desktop {action} returned {code} for session {sess:?}; running rescan");+ let prefix = transport().prefix;+ let mut rescan = Command::new("adom-desktop");+ if let Some(t) = target {+ rescan.arg("--target").arg(t);+ }+ let rescan = rescan.args([&format!("{prefix}rescan"), "{}"]).output();+ let live = rescan.ok().and_then(|o| {+ if !o.status.success() {+ return None;+ }+ let r: Value = serde_json::from_slice(&o.stdout).ok()?;+ r.get("liveSessions").and_then(|l| l.as_array()).map(|arr| {+ arr.iter().filter_map(|v| v.as_str().map(String::from)).collect::<Vec<_>>()+ })+ });+ let live = live.unwrap_or_default();+ if !sess.is_empty() && live.iter().any(|s| s == sess) {+ println!("OK: rescan recovered session {sess:?}; retrying {action}");+ return call_with_recovery(action, args, target, false);+ }+ println!("WARN: rescan did not recover {sess:?} (live: {live:?})");+ }+ }+ Ok(v)+}
src/serve.rsadded+1222@@ -0,0 +1,1222 @@+use anyhow::{Context, Result};+use std::io::Read;+use std::path::PathBuf;+use std::sync::Mutex;+use tiny_http::{Header, Method, Response, Server};++const INDEX_HTML: &str = include_str!("ui/index.html");++// Single-slot activity state: (raw JSON payload, epoch-seconds it was set).+// Updated via POST /api/activity, read via GET. Used by the viewer to+// highlight the chip currently being worked on. The timestamp lets the+// dashboard distinguish "currently working" from a stale heartbeat that no+// process ever cleared (heartbeats are write-only stage labels with no+// terminal state, so the last one sticks indefinitely without this).+static ACTIVITY: Mutex<Option<(String, u64)>> = Mutex::new(None);++fn now_epoch() -> u64 {+ std::time::SystemTime::now()+ .duration_since(std::time::UNIX_EPOCH)+ .map(|d| d.as_secs())+ .unwrap_or(0)+}++pub fn run(port: u16) -> Result<()> {+ let addr = format!("0.0.0.0:{port}");+ let server = Server::http(&addr).map_err(|e| anyhow::anyhow!("bind {addr}: {e}"))?;+ println!("adom-chip-fetcher viewer listening on http://{addr}");+ println!("(open in Hydrogen via `adom-chip-fetcher app`, or visit in any browser)");++ for mut request in server.incoming_requests() {+ let raw_url = request.url().to_string();+ let no_qs = raw_url.split('?').next().unwrap_or(&raw_url);+ // Strip code-server proxy prefix (e.g. /proxy/8786/) so routes work behind the proxy+ let url = if let Some(rest) = no_qs.strip_prefix(&format!("/proxy/{port}")) {+ if rest.is_empty() { "/".to_string() } else { rest.to_string() }+ } else {+ no_qs.to_string()+ };+ let method = request.method().clone();+ let response = match (&method, url.as_str()) {+ (Method::Get, "/") | (Method::Get, "/index.html") => html_response(INDEX_HTML),+ (Method::Get, "/api/library") => library_json(),+ (Method::Get, "/api/sources") => sources_json(),+ (Method::Get, "/api/validate") => validate_json(),+ (Method::Get, "/api/projects") => projects_json(),+ (Method::Get, "/api/activity") => activity_get(),+ (Method::Post, "/api/activity") => {+ let mut body = String::new();+ let _ = request.as_reader().read_to_string(&mut body);+ activity_set(&body)+ }+ (Method::Get, "/api/credentials") => credentials_list(),+ (Method::Post, "/api/credentials") => {+ let mut body = String::new();+ let _ = request.as_reader().read_to_string(&mut body);+ credentials_set(&body)+ }+ (Method::Delete, path) if path.starts_with("/api/credentials/") => {+ let host = path.trim_start_matches("/api/credentials/");+ credentials_delete(host)+ }+ (Method::Get, path) if path.starts_with("/api/chip/") => {+ let mpn = path.trim_start_matches("/api/chip/");+ chip_json(mpn)+ }+ (Method::Post, "/api/reveal") => {+ let mut body = String::new();+ let _ = request.as_reader().read_to_string(&mut body);+ reveal_in_vscode(&body)+ }+ (Method::Post, "/api/open") => {+ let mut body = String::new();+ let _ = request.as_reader().read_to_string(&mut body);+ open_in_app(&body)+ }+ (Method::Post, "/api/orientation") => {+ let mut body = String::new();+ let _ = request.as_reader().read_to_string(&mut body);+ set_orientation(&body)+ }+ (Method::Post, "/api/deliver") => {+ let mut body = String::new();+ let _ = request.as_reader().read_to_string(&mut body);+ deliver_chip(&body)+ }+ (Method::Get, path) if path.starts_with("/asset/") => {+ serve_asset(path.trim_start_matches("/asset/"))+ }+ (Method::Get, path) if path.starts_with("/thumb/") => {+ serve_thumb(path.trim_start_matches("/thumb/"))+ }+ (Method::Get, path) if path.starts_with("/svg/") => {+ serve_authoritative_svg(path.trim_start_matches("/svg/"))+ }+ (Method::Post, path) if path.starts_with("/api/validate/") => {+ let mpn = path.trim_start_matches("/api/validate/").to_string();+ spawn_subprocess_action("validate", &["validate", &mpn], &mpn)+ }+ (Method::Post, path) if path.starts_with("/api/thumbnails/") => {+ let mpn = path.trim_start_matches("/api/thumbnails/").to_string();+ spawn_subprocess_action("thumbnails", &["thumbnails", "--mpn", &mpn, "--force"], &mpn)+ }+ (Method::Post, path) if path.starts_with("/api/refetch/") => {+ let mpn = path.trim_start_matches("/api/refetch/").to_string();+ let qs = raw_url.split('?').nth(1).unwrap_or("");+ let mut format = String::new();+ let mut source = String::new();+ for p in qs.split('&') {+ if let Some(v) = p.strip_prefix("format=") { format = v.to_string(); }+ if let Some(v) = p.strip_prefix("source=") { source = v.to_string(); }+ }+ if format.is_empty() || source.is_empty() {+ error_response(400, "{\"error\":\"need ?format=X&source=Y\"}".into())+ } else {+ spawn_subprocess_action(+ "refetch",+ &["refetch", &mpn, "--format", &format, "--source", &source],+ &mpn,+ )+ }+ }+ _ => not_found(),+ };+ let _ = request.respond(response);+ }+ Ok(())+}++fn activity_get() -> Response<std::io::Cursor<Vec<u8>>> {+ let entry = ACTIVITY.lock().ok().and_then(|v| v.clone());+ let json = match entry {+ Some((body, ts)) => {+ let age = now_epoch().saturating_sub(ts);+ // Merge a server-computed age into the stored payload so the+ // dashboard can tell "currently working" from a stale heartbeat.+ match serde_json::from_str::<serde_json::Value>(&body) {+ Ok(serde_json::Value::Object(mut m)) => {+ m.insert("_age_seconds".into(), serde_json::json!(age));+ m.insert("_received_at".into(), serde_json::json!(ts));+ serde_json::Value::Object(m).to_string()+ }+ _ => body,+ }+ }+ None => "{}".to_string(),+ };+ json_response(json)+}++fn activity_set(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {+ if serde_json::from_str::<serde_json::Value>(body).is_err() {+ return error_response(400, "{\"error\":\"invalid json\"}".to_string());+ }+ if let Ok(mut slot) = ACTIVITY.lock() {+ *slot = Some((body.to_string(), now_epoch()));+ }+ json_response("{\"ok\":true}".to_string())+}++fn credentials_list() -> Response<std::io::Cursor<Vec<u8>>> {+ match crate::creds::list_public() {+ Ok(creds) => {+ let body = serde_json::json!({"credentials": creds}).to_string();+ json_response(body)+ }+ Err(e) => error_response(500, format!("{{\"error\":{:?}}}", e.to_string())),+ }+}++fn credentials_set(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {+ let parsed: serde_json::Value = match serde_json::from_str(body) {+ Ok(v) => v,+ Err(e) => return error_response(400, format!("{{\"error\":\"invalid json: {e}\"}}")),+ };+ let host = parsed.get("host").and_then(|v| v.as_str()).unwrap_or("");+ let username = parsed.get("username").and_then(|v| v.as_str()).unwrap_or("");+ let password = parsed.get("password").and_then(|v| v.as_str()).unwrap_or("");+ if host.is_empty() || username.is_empty() || password.is_empty() {+ return error_response(400, "{\"error\":\"host, username, password all required\"}".to_string());+ }+ match crate::creds::set(host, username, password) {+ Ok(_) => json_response("{\"ok\":true}".to_string()),+ Err(e) => error_response(500, format!("{{\"error\":{:?}}}", e.to_string())),+ }+}++fn credentials_delete(host: &str) -> Response<std::io::Cursor<Vec<u8>>> {+ let host = urldecode(host);+ match crate::creds::delete(&host) {+ Ok(removed) => json_response(format!("{{\"ok\":true,\"removed\":{removed}}}")),+ Err(e) => error_response(500, format!("{{\"error\":{:?}}}", e.to_string())),+ }+}++// POST /api/open {"mpn":"<X>","kind":"chip3d|symbol|footprint|datasheet"}+// All four open in a NEW PUP TAB at a publicly-routable proxy URL —+// this matters because pup runs on the user's Windows machine, so a+// loopback URL (127.0.0.1) on the container is unreachable from there.+//+// We resolve the proxy URL from $ADOM_PROXY_HOST or $ADOM_CONTAINER_SLUG+// (set by `chip-fetcher dashboard ensure`). When neither is set we fall+// back to 127.0.0.1 so dev-running-on-localhost still works.+/// POST /api/deliver {mpn, target} — send the chip's CAD to a desktop EDA tool.+fn deliver_chip(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {+ let parsed: serde_json::Value = match serde_json::from_str(body) {+ Ok(v) => v,+ Err(e) => return error_response(400, format!("{{\"error\":\"invalid json: {e}\"}}")),+ };+ let mpn = parsed.get("mpn").and_then(|v| v.as_str()).unwrap_or("").trim();+ let target = parsed.get("target").and_then(|v| v.as_str()).unwrap_or("kicad").trim();+ if mpn.is_empty() || mpn.contains('/') || mpn.contains("..") {+ return error_response(400, "{\"error\":\"invalid mpn\"}".to_string());+ }+ let dir = crate::library::root().join(mpn);+ if !dir.exists() {+ return error_response(404, format!("{{\"error\":\"no library/{mpn}/\"}}"));+ }+ json_response(crate::deliver::deliver(mpn, &dir, target).to_string())+}++fn open_in_app(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {+ let parsed: serde_json::Value = match serde_json::from_str(body) {+ Ok(v) => v,+ Err(e) => return error_response(400, format!("{{\"error\":\"invalid json: {e}\"}}")),+ };+ let mpn = parsed.get("mpn").and_then(|v| v.as_str()).unwrap_or("").trim();+ let kind = parsed.get("kind").and_then(|v| v.as_str()).unwrap_or("").trim();+ let source = parsed.get("source").and_then(|v| v.as_str()).unwrap_or("").trim();+ if mpn.is_empty() || mpn.contains('/') || mpn.contains("..") {+ return error_response(400, "{\"error\":\"invalid mpn\"}".to_string());+ }+ let dir = crate::library::root().join(mpn);+ if !dir.exists() {+ return error_response(404, format!("{{\"error\":\"no library/{mpn}/\"}}"));+ }++ // chipsmith opens in its own Hydrogen webview (STEP-native detection ++ // footprint sign-off), loading the chip's whole library dir.+ if kind == "chipsmith" {+ use std::process::Stdio;+ let r = std::process::Command::new("adom-chipsmith")+ .args(["view-library"]).arg(&dir)+ .stdout(Stdio::null()).stderr(Stdio::null()).stdin(Stdio::null())+ .spawn();+ return match r {+ Ok(_) => json_response("{\"ok\":true,\"tool\":\"chipsmith\"}".to_string()),+ Err(e) => error_response(500, format!("{{\"error\":\"adom-chipsmith unavailable: {e}\"}}")),+ };+ }++ let base = pup_visible_base_url();++ let target_url = match kind {+ "chip3d-yup" => {+ // Y-up is for ME / mech-CAD users (Fusion 360 / SolidWorks+ // convention). adom-step uses Z-up internally, so we open the+ // pre-rendered large Y-up PNG full-size in pup rather than+ // wrestling adom-step's pose. Click "Z-up" thumb for the live+ // interactive 3D viewer.+ if !dir.join(format!("{mpn}-3d-iso-yup-lg.png")).exists() {+ return error_response(404, format!("{{\"error\":\"no {mpn}-3d-iso-yup-lg.png — run `chip-thumbnailer scan`\"}}"));+ }+ format!("{base}/thumb/{mpn}/chip3d-yup-lg")+ }+ "chip3d" => {+ let step = dir.join(format!("{mpn}.step"));+ if !step.exists() {+ return error_response(404, format!("{{\"error\":\"no {mpn}.step\"}}"));+ }+ // Real adom-step viewer integration:+ // 1. Ensure server on :8851 (spawn detached if not running).+ // 2. POST the STEP bytes to /api/load so the server switches+ // to this chip — adom-step is single-file-per-server.+ // 3. Open the public proxy URL of port 8851 in a new pup tab.+ ensure_adom_step_server();+ if !load_step_into_adom_step(&step, mpn) {+ println!("WARN: adom-step /api/load failed for {mpn}; falling back to asset URL");+ return open_pup_tab(&format!("{base}/asset/{mpn}/{mpn}.step"), "fallback");+ }+ // Cache-bust the URL so a fresh load happens even if the user already+ // has an adom-step tab open from a previous click.+ let ts = std::time::SystemTime::now()+ .duration_since(std::time::UNIX_EPOCH)+ .map(|d| d.as_millis())+ .unwrap_or(0);+ format!("{}?reload={ts}&title={}", proxy_url_for_port(ADOM_STEP_PORT), urlencode(mpn))+ }+ "datasheet" => {+ let pdf = dir.join(format!("{mpn}.pdf"));+ if !pdf.exists() {+ return error_response(404, format!("{{\"error\":\"no {mpn}.pdf\"}}"));+ }+ ensure_adom_pdf_viewer_server();+ let asset_url = format!("{base}/asset/{mpn}/{mpn}.pdf");+ format!(+ "{}v?src={}&title={}%20datasheet",+ proxy_url_for_port(ADOM_PDF_VIEWER_PORT),+ urlencode(&asset_url),+ urlencode(mpn)+ )+ }+ "symbol" => {+ let sym = dir.join(format!("{mpn}.kicad_sym"));+ if !sym.exists() {+ return error_response(404, format!("{{\"error\":\"no {mpn}.kicad_sym\"}}"));+ }+ // Launch the standalone adom-symbol live app (no gallia, no Node):+ // 1. Ensure `adom-symbol serve` is up on its port.+ // 2. POST the raw .kicad_sym to /load — the app renders it+ // server-side via service-kicad and updates its state.+ // 3. Open the app's public proxy URL in a pup tab.+ ensure_adom_symbol_server();+ // Folder mode: adom-symbol discovers ds2sf + manufacturer + iso and+ // renders the auto-grouped default with a layout toolbar.+ if !load_dir_into_app(&dir, mpn, ADOM_SYMBOL_PORT, source) {+ println!("WARN: adom-symbol /load (dir) failed for {mpn}; falling back to raw SVG");+ return open_pup_tab(&format!("{base}/thumb/{mpn}/symbol"), "fallback-svg");+ }+ format!("{}?reload={}&title={}", proxy_url_for_port(ADOM_SYMBOL_PORT), cache_bust(), urlencode(mpn))+ }+ "footprint" => {+ let md = dir.join(format!("{mpn}.kicad_mod"));+ if !md.exists() {+ return error_response(404, format!("{{\"error\":\"no {mpn}.kicad_mod\"}}"));+ }+ // Launch the standalone adom-footprint live app (no gallia, no+ // Node): ensure `adom-footprint serve`, POST the raw .kicad_mod to+ // /load (app renders via service-kicad), open its proxy URL.+ ensure_adom_footprint_server();+ if !load_kicad_into_app(&md, mpn, ADOM_FOOTPRINT_PORT, "kicadMod") {+ println!("WARN: adom-footprint /load failed for {mpn}; falling back to raw SVG");+ return open_pup_tab(&format!("{base}/thumb/{mpn}/footprint"), "fallback-svg");+ }+ format!("{}?reload={}&title={}", proxy_url_for_port(ADOM_FOOTPRINT_PORT), cache_bust(), urlencode(mpn))+ }+ "lbr" => {+ let sym = dir.join(format!("{mpn}.kicad_sym"));+ let md = dir.join(format!("{mpn}.kicad_mod"));+ if !sym.exists() && !md.exists() {+ return error_response(404, format!("{{\"error\":\"no {mpn}.kicad_sym or .kicad_mod to build an .lbr\"}}"));+ }+ // Launch the standalone adom-lbr live app (no gallia, no Node):+ // 1. Ensure `adom-lbr serve` is up.+ // 2. Generate a true EAGLE .lbr from the chip's symbol+footprint+ // and POST it to /load (the app lints + displays it).+ // 3. Open the app's public proxy URL in a pup tab.+ ensure_adom_lbr_server();+ if !load_lbr_into_app(&dir, mpn, ADOM_LBR_PORT) {+ println!("WARN: adom-lbr generate/load failed for {mpn}");+ return error_response(500, format!("{{\"error\":\"could not build .lbr for {mpn}\"}}"));+ }+ format!("{}?reload={}&title={}", proxy_url_for_port(ADOM_LBR_PORT), cache_bust(), urlencode(mpn))+ }+ _ => return error_response(400, format!("{{\"error\":\"unknown kind {:?} (want chip3d|symbol|footprint|lbr|datasheet)\"}}", kind)),+ };++ // Return the app's public URL and let the CLIENT open it with window.open —+ // that opens a tab in whatever browser the user is actually viewing+ // chip-fetcher in (their phone, a pup window, a Hydrogen webview), which is+ // the only universally-correct surface. (The old server-side+ // `adom-desktop browser_open_tab` always opened on the LAPTOP, so a phone+ // user saw nothing.) `desktopBridge` is reported so the client can name the+ // method and gate Send-to.+ json_response(format!(+ "{{\"ok\":true,\"url\":{:?},\"desktopBridge\":{}}}",+ target_url, desktop_bridge_available()+ ))+}++/// Whether an Adom Desktop client is connected to this container's bridge.+/// Cached briefly (the probe shells out). Used only to label the open method ++/// gate Send-to — the actual app open happens client-side via window.open.+fn desktop_bridge_available() -> bool {+ use std::sync::Mutex;+ use std::time::{Duration, Instant};+ static CACHE: Mutex<Option<(Instant, bool)>> = Mutex::new(None);+ if let Some((t, v)) = *CACHE.lock().unwrap() {+ if t.elapsed() < Duration::from_secs(15) {+ return v;+ }+ }+ let avail = std::process::Command::new("timeout")+ .args(["3", "adom-desktop", "status"])+ .output()+ .ok()+ .map(|o| {+ serde_json::from_str::<serde_json::Value>(&String::from_utf8_lossy(&o.stdout))+ .ok()+ .and_then(|v| v.get("clients").and_then(|c| c.as_u64()))+ .map(|n| n > 0)+ .unwrap_or(false)+ })+ .unwrap_or(false);+ *CACHE.lock().unwrap() = Some((Instant::now(), avail));+ avail+}++/// Fallback open (e.g. raw SVG when the live app load failed): return the URL for+/// the client to window.open — same client-side strategy as the main path.+fn open_pup_tab(url: &str, _label: &str) -> Response<std::io::Cursor<Vec<u8>>> {+ json_response(format!("{{\"ok\":true,\"url\":{:?},\"desktopBridge\":{}}}", url, desktop_bridge_available()))+}++// Dedicated chip-fetcher-owned ports for the spawned helper apps. Using+// non-default ports avoids collisions with any pre-existing adom-step /+// adom-pdf-viewer instances the user may have started for other workflows.+const ADOM_STEP_PORT: u16 = 8857;+const ADOM_PDF_VIEWER_PORT: u16 = 8985;+const ADOM_SYMBOL_PORT: u16 = 8781;+const ADOM_FOOTPRINT_PORT: u16 = 8783;+const ADOM_LBR_PORT: u16 = 8784;++fn port_listening(port: u16) -> bool {+ let url = format!("http://127.0.0.1:{port}/");+ std::process::Command::new("curl")+ .args(["-fs", "-m", "1", "-o", "/dev/null", &url])+ .status()+ .map(|s| s.success())+ .unwrap_or(false)+}++fn ensure_adom_step_server() {+ if port_listening(ADOM_STEP_PORT) { return; }+ // Need a placeholder file to start the server. We'll use any STEP from+ // the library — the next /api/load POST will replace it. If the library+ // is empty, skip; the load call below will surface the error.+ let any_step = std::fs::read_dir(crate::library::root())+ .ok()+ .and_then(|rd| rd.filter_map(|e| e.ok())+ .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))+ .find_map(|e| {+ let mpn = e.file_name().to_string_lossy().to_string();+ let p = e.path().join(format!("{mpn}.step"));+ if p.is_file() { Some(p) } else { None }+ }));+ let Some(any) = any_step else { return; };+ use std::process::Stdio;+ let _ = std::process::Command::new("adom-step")+ .args(["view", "--no-open", "--port", &ADOM_STEP_PORT.to_string()])+ .arg(&any)+ .stdout(Stdio::null())+ .stderr(Stdio::null())+ .stdin(Stdio::null())+ .spawn();+ // Wait briefly for it to come up.+ for _ in 0..30 {+ if port_listening(ADOM_STEP_PORT) { break; }+ std::thread::sleep(std::time::Duration::from_millis(200));+ }+}++fn load_step_into_adom_step(step: &std::path::Path, mpn: &str) -> bool {+ let bytes = match std::fs::read(step) { Ok(b) => b, Err(_) => return false };+ let Some(tmp) = write_tmp_file(&bytes) else { return false };+ // curl -X POST --data-binary @<tmp> -H 'X-Filename: <MPN>.step' http://127.0.0.1:8851/api/load+ let url = format!("http://127.0.0.1:{ADOM_STEP_PORT}/api/load");+ let filename = format!("{mpn}.step");+ let header = format!("X-Filename: {filename}");+ let data_arg = format!("@{}", tmp.display());+ let out = std::process::Command::new("curl")+ .args([+ "-fs", "-m", "60", "-X", "POST",+ "-H", &header,+ "--data-binary", &data_arg,+ &url,+ ])+ .output();+ let _ = std::fs::remove_file(&tmp);+ matches!(out, Ok(o) if o.status.success())+}++fn write_tmp_file(bytes: &[u8]) -> Option<std::path::PathBuf> {+ use std::io::Write;+ let path = std::env::temp_dir().join(format!("cf-load-{}.bin", std::process::id()));+ let mut f = std::fs::File::create(&path).ok()?;+ f.write_all(bytes).ok()?;+ Some(path)+}++/// Generate (or refresh) <MPN>-symbol-viewer.html via the local Node+/// helper. Idempotent at the script level — caller checks file existence+/// first and only invokes this on first click. Returns true on success.+// POST /api/orientation {"mpn":"<X>","axis":"z"|"y"}+// Records the user's choice of up-axis for this chip. Persists to+// library/<MPN>/info.json's chosen_up_axis field. Downstream tools+// (adom-step / adom-chiplinter / chip3d previewers) read this field and+// apply the right rotation when consuming the STEP, so the user only+// has to pick once per chip.+//+// When the user picks the SAME axis as chip-thumbnailer's inferred axis,+// we still record it explicitly — that way "user has confirmed" is+// visible in the data, vs. "still relying on inference."+fn set_orientation(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {+ let parsed: serde_json::Value = match serde_json::from_str(body) {+ Ok(v) => v,+ Err(e) => return error_response(400, format!("{{\"error\":\"invalid json: {e}\"}}")),+ };+ let mpn = parsed.get("mpn").and_then(|v| v.as_str()).unwrap_or("").trim();+ let axis = parsed.get("axis").and_then(|v| v.as_str()).unwrap_or("").trim();+ if mpn.is_empty() || mpn.contains('/') || mpn.contains("..") {+ return error_response(400, "{\"error\":\"invalid mpn\"}".to_string());+ }+ let valid = ["z", "y", "x", "-x", "-y", "-z", "xup", "xdown", "yup", "ydown", "zup", "zdown"];+ if !valid.contains(&axis) {+ return error_response(400, "{\"error\":\"axis must be 'z' or 'y'\"}".to_string());+ }+ let info_path = crate::library::root().join(mpn).join("info.json");+ if !info_path.exists() {+ return error_response(404, format!("{{\"error\":\"no info.json for {mpn}\"}}"));+ }+ let raw = match std::fs::read_to_string(&info_path) {+ Ok(s) => s,+ Err(e) => return error_response(500, format!("{{\"error\":\"read info.json: {e}\"}}")),+ };+ let mut info: serde_json::Value = match serde_json::from_str(&raw) {+ Ok(v) => v,+ Err(e) => return error_response(500, format!("{{\"error\":\"parse info.json: {e}\"}}")),+ };+ info["chosen_up_axis"] = serde_json::Value::String(axis.to_string());+ info["chosen_up_axis_at"] = serde_json::Value::String(+ std::time::SystemTime::now()+ .duration_since(std::time::UNIX_EPOCH)+ .map(|d| d.as_secs().to_string())+ .unwrap_or_default(),+ );+ let pretty = match serde_json::to_string_pretty(&info) {+ Ok(s) => s,+ Err(e) => return error_response(500, format!("{{\"error\":\"serialize: {e}\"}}")),+ };+ if let Err(e) = std::fs::write(&info_path, pretty) {+ return error_response(500, format!("{{\"error\":\"write info.json: {e}\"}}"));+ }+ json_response(format!("{{\"ok\":true,\"mpn\":{mpn:?},\"axis\":{axis:?}}}"))+}++/// Millisecond timestamp for cache-busting an already-open app tab so a fresh+/// /load is reflected even if the user clicked the same kind before.+fn cache_bust() -> u128 {+ std::time::SystemTime::now()+ .duration_since(std::time::UNIX_EPOCH)+ .map(|d| d.as_millis())+ .unwrap_or(0)+}++/// Spawn `adom-symbol serve` on its port if it isn't already listening.+fn ensure_adom_symbol_server() {+ ensure_app_server("adom-symbol", ADOM_SYMBOL_PORT);+}++/// Spawn `adom-footprint serve` on its port if it isn't already listening.+fn ensure_adom_footprint_server() {+ ensure_app_server("adom-footprint", ADOM_FOOTPRINT_PORT);+}++/// Spawn `adom-lbr serve` on its port if it isn't already listening.+fn ensure_adom_lbr_server() {+ ensure_app_server("adom-lbr", ADOM_LBR_PORT);+}++/// Build a true EAGLE `.lbr` for this chip (symbol + footprint when both exist)+/// via the adom-lbr CLI, then POST the XML to adom-lbr's `/load`. Returns false+/// if generation or the load failed.+fn load_lbr_into_app(dir: &std::path::Path, mpn: &str, port: u16) -> bool {+ let sym = dir.join(format!("{mpn}.kicad_sym"));+ let md = dir.join(format!("{mpn}.kicad_mod"));+ let out_lbr = dir.join(format!("{mpn}.lbr"));+ let mut args: Vec<String> = vec!["generate".into()];+ if sym.is_file() {+ args.push("--sym".into());+ args.push(sym.to_string_lossy().to_string());+ }+ if md.is_file() {+ args.push("--fp".into());+ args.push(md.to_string_lossy().to_string());+ }+ if !sym.is_file() && !md.is_file() {+ return false;+ }+ args.push("--name".into());+ args.push(mpn.to_string());+ args.push("--output".into());+ args.push(out_lbr.to_string_lossy().to_string());+ let gen = std::process::Command::new("adom-lbr").args(&args).output();+ // generate exits 2 on lint failure but still writes the file; accept any+ // run that produced the .lbr.+ if !matches!(gen, Ok(_)) || !out_lbr.is_file() {+ return false;+ }+ let content = match std::fs::read_to_string(&out_lbr) {+ Ok(c) => c,+ Err(_) => return false,+ };+ let body = serde_json::json!({ "lbr": content }).to_string();+ let Some(tmp) = write_tmp_file(body.as_bytes()) else { return false };+ let url = format!("http://127.0.0.1:{port}/load");+ let data_arg = format!("@{}", tmp.display());+ let out = std::process::Command::new("curl")+ .args(["-fs", "-m", "30", "-X", "POST", "-H", "Content-Type: application/json", "--data-binary", &data_arg, &url])+ .output();+ let _ = std::fs::remove_file(&tmp);+ matches!(out, Ok(o) if o.status.success())+}++fn ensure_app_server(bin: &str, port: u16) {+ if port_listening(port) {+ return;+ }+ use std::process::Stdio;+ let _ = std::process::Command::new(bin)+ .args(["serve", "--port", &port.to_string()])+ .stdout(Stdio::null())+ .stderr(Stdio::null())+ .stdin(Stdio::null())+ .spawn();+ for _ in 0..30 {+ if port_listening(port) {+ break;+ }+ std::thread::sleep(std::time::Duration::from_millis(200));+ }+}++/// POST a raw KiCad file to a standalone app's `/load` endpoint as JSON+/// `{ "<field>": "<file contents>", "symbolName"/"footprintName": "<mpn>" }`.+/// The app renders it server-side via service-kicad. `field` is "kicadSym" or+/// "kicadMod".+/// POST `{dir, mpn}` to an app's `/load` so it can discover all source files in+/// the chip's library folder (manufacturer .kicad_sym, ds2sf extraction, iso 3D).+fn load_dir_into_app(dir: &std::path::Path, mpn: &str, port: u16, source: &str) -> bool {+ let body = serde_json::json!({ "dir": dir.to_string_lossy(), "mpn": mpn, "source": source }).to_string();+ let Some(tmp) = write_tmp_file(body.as_bytes()) else { return false };+ let url = format!("http://127.0.0.1:{port}/load");+ let data_arg = format!("@{}", tmp.display());+ let out = std::process::Command::new("curl")+ .args(["-fs", "-m", "90", "-X", "POST", "-H", "Content-Type: application/json", "--data-binary", &data_arg, &url])+ .output();+ let _ = std::fs::remove_file(&tmp);+ matches!(out, Ok(o) if o.status.success())+}++fn load_kicad_into_app(path: &std::path::Path, mpn: &str, port: u16, field: &str) -> bool {+ let content = match std::fs::read_to_string(path) {+ Ok(c) => c,+ Err(_) => return false,+ };+ let name_key = if field == "kicadSym" { "symbolName" } else { "footprintName" };+ let body = serde_json::json!({ field: content, name_key: mpn }).to_string();+ let Some(tmp) = write_tmp_file(body.as_bytes()) else { return false };+ let url = format!("http://127.0.0.1:{port}/load");+ let data_arg = format!("@{}", tmp.display());+ let out = std::process::Command::new("curl")+ .args([+ "-fs", "-m", "70", "-X", "POST",+ "-H", "Content-Type: application/json",+ "--data-binary", &data_arg,+ &url,+ ])+ .output();+ let _ = std::fs::remove_file(&tmp);+ matches!(out, Ok(o) if o.status.success())+}++fn ensure_adom_pdf_viewer_server() {+ if port_listening(ADOM_PDF_VIEWER_PORT) { return; }+ use std::process::Stdio;+ let _ = std::process::Command::new("node")+ .args([+ "/home/adom/project/adom-pdf-viewer/server.mjs",+ "--port", &ADOM_PDF_VIEWER_PORT.to_string(),+ ])+ .stdout(Stdio::null())+ .stderr(Stdio::null())+ .stdin(Stdio::null())+ .spawn();+ for _ in 0..30 {+ if port_listening(ADOM_PDF_VIEWER_PORT) { break; }+ std::thread::sleep(std::time::Duration::from_millis(200));+ }+}++/// Resolve the dashboard URL that pup (running on the user's Windows+/// machine) can actually reach. Reads $ADOM_PROXY_HOST or+/// $ADOM_CONTAINER_SLUG, set by `chip-fetcher dashboard ensure`.+fn pup_visible_base_url() -> String {+ proxy_url_for_port(8786).trim_end_matches('/').to_string()+}++/// Same logic as pup_visible_base_url but for an arbitrary port — used so+/// adom-step (8851) and adom-pdf-viewer (8981) are reachable from pup+/// through the same wiki-proxy host pattern.+fn proxy_url_for_port(port: u16) -> String {+ if let Ok(host) = std::env::var("ADOM_PROXY_HOST") {+ return format!("https://{host}/proxy/{port}/");+ }+ if let Ok(slug) = std::env::var("ADOM_CONTAINER_SLUG") {+ return format!("https://{slug}.adom.cloud/proxy/{port}/");+ }+ // Hydrogen / code-server exposes the public proxy template here, e.g.+ // https://<slug>.adom.cloud/proxy/{{port}}/ — substitute the real port.+ if let Ok(tpl) = std::env::var("VSCODE_PROXY_URI") {+ if tpl.contains("{{port}}") {+ return tpl.replace("{{port}}", &port.to_string());+ }+ }+ format!("http://127.0.0.1:{port}/")+}++fn urlencode(s: &str) -> String {+ let mut out = String::new();+ for b in s.bytes() {+ match b {+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' | b':' => {+ out.push(b as char);+ }+ _ => out.push_str(&format!("%{:02X}", b)),+ }+ }+ out+}++// POST /api/reveal {"mpn": "<MPN>"} → reveals library/<MPN>/ in VS Code Explorer.+// Empty mpn (or omitted) reveals the library root. Path is resolved against+// library::root() and rejected if it escapes via .. or contains a slash.+fn reveal_in_vscode(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {+ let parsed: serde_json::Value = match serde_json::from_str(body) {+ Ok(v) => v,+ Err(e) => return error_response(400, format!("{{\"error\":\"invalid json: {e}\"}}")),+ };+ let mpn = parsed.get("mpn").and_then(|v| v.as_str()).unwrap_or("").trim();+ let target = if mpn.is_empty() {+ crate::library::root()+ } else {+ if mpn.contains('/') || mpn.contains("..") || mpn.contains('\\') {+ return error_response(400, "{\"error\":\"invalid mpn\"}".to_string());+ }+ let dir = crate::library::root().join(mpn);+ if !dir.exists() {+ return error_response(404, format!("{{\"error\":\"no library folder for mpn {:?}\"}}", mpn));+ }+ dir+ };+ let path_str = target.to_string_lossy().to_string();+ match std::process::Command::new("adom-vscode")+ .args(["reveal", &path_str])+ .output()+ {+ Ok(out) if out.status.success() => {+ json_response(format!("{{\"ok\":true,\"path\":{:?}}}", path_str))+ }+ Ok(out) => {+ let stderr = String::from_utf8_lossy(&out.stderr).to_string();+ error_response(500, format!("{{\"error\":\"adom-vscode failed\",\"stderr\":{:?}}}", stderr))+ }+ Err(e) => error_response(+ 500,+ format!("{{\"error\":\"adom-vscode not runnable: {}\"}}", e),+ ),+ }+}++fn urldecode(s: &str) -> String {+ let mut out = String::with_capacity(s.len());+ let bytes = s.as_bytes();+ let mut i = 0;+ while i < bytes.len() {+ if bytes[i] == b'%' && i + 2 < bytes.len() {+ if let Ok(b) = u8::from_str_radix(std::str::from_utf8(&bytes[i+1..i+3]).unwrap_or(""), 16) {+ out.push(b as char);+ i += 3;+ continue;+ }+ } else if bytes[i] == b'+' {+ out.push(' ');+ i += 1;+ continue;+ }+ out.push(bytes[i] as char);+ i += 1;+ }+ out+}++fn error_response(code: u16, body: String) -> Response<std::io::Cursor<Vec<u8>>> {+ let h = Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap();+ Response::from_string(body).with_status_code(code).with_header(h)+}++fn html_response(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {+ let h = Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]).unwrap();+ Response::from_string(body.to_string()).with_header(h)+}++fn json_response(body: String) -> Response<std::io::Cursor<Vec<u8>>> {+ let h = Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap();+ Response::from_string(body).with_header(h)+}++/// Brutal self-audit: for every chip (the active project if set), report ✓/✗ for+/// EVERY expected artifact — datasheet, 3D STEP, KiCad symbol+footprint, the card+/// thumbnails (symbol.svg / footprint.svg / 3d-iso.png), the Fusion/EAGLE .lbr,+/// and info.json. A chip is "complete" ONLY if every REQUIRED box is green. This+/// is the tab that stops anyone (human or AI) from calling it done when it isn't.+fn validate_json() -> Response<std::io::Cursor<Vec<u8>>> {+ use serde_json::json;+ let root = crate::library::root();+ let active = crate::projects::get_active();+ let project_mpns: Option<Vec<String>> = active.as_ref().map(|p| crate::projects::mpns_for_project(p));++ let valid_pdf = |p: std::path::PathBuf| -> bool {+ std::fs::File::open(&p).ok().and_then(|mut f| {+ use std::io::Read;+ let mut b = [0u8; 4];+ f.read_exact(&mut b).ok().map(|_| &b == b"%PDF")+ }).unwrap_or(false)+ };+ let valid_step = |p: std::path::PathBuf| -> bool {+ std::fs::read_to_string(&p).ok().map(|s| s.contains("ISO-10303")).unwrap_or(false)+ };+ let nonempty = |p: std::path::PathBuf, min: u64| -> bool {+ std::fs::metadata(&p).map(|m| m.len() >= min).unwrap_or(false)+ };++ let mut chips = Vec::new();+ let (mut total, mut complete) = (0u32, 0u32);+ if let Ok(rd) = std::fs::read_dir(&root) {+ let mut dirs: Vec<_> = rd.filter_map(|e| e.ok()).filter(|e| e.path().is_dir()).collect();+ dirs.sort_by_key(|e| e.file_name());+ for e in dirs {+ let mpn = e.file_name().to_string_lossy().to_string();+ if let Some(ref pm) = project_mpns {+ if !pm.iter().any(|m| m == &mpn) { continue; }+ }+ let d = e.path();+ // REQUIRED — files a complete card MUST have+ let pdf = valid_pdf(d.join(format!("{mpn}.pdf")));+ let step = valid_step(d.join(format!("{mpn}.step")));+ let sym = nonempty(d.join(format!("{mpn}.kicad_sym")), 50);+ let foot = nonempty(d.join(format!("{mpn}.kicad_mod")), 50);+ let sym_svg = nonempty(d.join(format!("{mpn}-symbol.svg")), 500);+ let fp_svg = nonempty(d.join(format!("{mpn}-footprint.svg")), 500);+ let thumb3d = nonempty(d.join(format!("{mpn}-3d-iso.png")), 2000);+ // EXTRA — nice-to-have+ let lbr = nonempty(d.join(format!("{mpn}.lbr")), 50);+ let info = d.join("info.json").exists();+ // Description: the human "what is this chip" line shown on the card ++ // zoom. Tracked so a blank one is impossible to miss in the audit.+ let has_desc = std::fs::read_to_string(d.join("info.json")).ok()+ .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())+ .and_then(|v| v.get("description").and_then(|x| x.as_str()).map(|s| s.trim().len() >= 10))+ .unwrap_or(false);++ // adom-concur cross-source consensus: did the KiCad / EAGLE / Altium+ // / ds2sf variants of this part AGREE? Surface concur's verdict + the+ // per-axis agree/disagree tally so the audit shows consistency, not+ // just file presence.+ let consensus = std::fs::read_to_string(d.join(format!("{mpn}-concur.result.json"))).ok()+ .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())+ .map(|c| {+ let axes = c.get("axes").and_then(|a| a.as_array()).cloned().unwrap_or_default();+ let count = |v: &str| axes.iter().filter(|a| a.get("verdict").and_then(|x| x.as_str()) == Some(v)).count();+ json!({+ "status": c.get("status").and_then(|s| s.as_str()).unwrap_or("?"),+ "sources": c.get("sources_compared").cloned().unwrap_or(json!([])),+ "missing": c.get("sources_missing").cloned().unwrap_or(json!([])),+ "agree": count("agree"), "disagree": count("disagree"), "total_axes": axes.len()+ })+ });++ let required = [pdf, step, sym, foot, sym_svg, fp_svg, thumb3d];+ let all = required.iter().all(|&b| b);+ total += 1;+ if all { complete += 1; }+ chips.push(json!({+ "mpn": mpn,+ "complete": all,+ "required": {+ "datasheet_pdf": pdf, "step_3d": step,+ "kicad_symbol": sym, "kicad_footprint": foot,+ "thumb_symbol": sym_svg, "thumb_footprint": fp_svg, "thumb_3d": thumb3d+ },+ "extra": { "description": has_desc, "fusion_lbr": lbr, "info_json": info },+ "consensus": consensus,+ "sources": per_source_breakdown(&d, &mpn, step)+ }));+ }+ }+ let body = json!({+ "project": active,+ "summary": { "total": total, "complete": complete, "incomplete": total - complete },+ "required_keys": ["datasheet_pdf","step_3d","kicad_symbol","kicad_footprint","thumb_symbol","thumb_footprint","thumb_3d"],+ "extra_keys": ["description","fusion_lbr","info_json"],+ "chips": chips+ }).to_string();+ json_response(body)+}++/// Per-source-library breakdown for the Validation tab's child rows: for each+/// library we downloaded (CSE / UltraLibrarian / SnapEDA / ds2sf), which of+/// symbol / footprint / 3D did we generate from IT. 3D is special: a library+/// that shipped its OWN STEP (`<mpn>.<method>.step`) gets a normal check; one+/// that didn't gets a check with an asterisk (we used the global chip STEP).+fn per_source_breakdown(d: &std::path::Path, mpn: &str, has_global_step: bool) -> serde_json::Value {+ use serde_json::json;+ let has = |f: String| d.join(f).is_file();++ // The DISCOVERY source (where the CAD was found) labels the format lines —+ // CSE / SnapEDA / UltraLibrarian — NOT the content_origin ("manufacturer"),+ // because CSE is an aggregator, not the maker. Read it from file-provenance.+ let prov = std::fs::read_to_string(d.join(format!("{mpn}-file-provenance.json"))).ok()+ .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());+ let disc = prov.as_ref().and_then(|p| p.as_object())+ .and_then(|m| m.values().find_map(|e| e.get("discovery_source").and_then(|s| s.as_str())))+ .unwrap_or("cse");+ // Manufacturer name (for naming the maker's own site when it's truly direct).+ let manufacturer = std::fs::read_to_string(d.join("info.json")).ok()+ .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())+ .and_then(|v| v.get("manufacturer").and_then(|m| m.as_str()).map(String::from))+ .unwrap_or_default();+ // PROVENANCE LABELING RULE (John, 2026-06-23): label by WHERE it was actually+ // downloaded, never the content_origin. ONLY the maker's OWN site counts as+ // "the manufacturer" — and then NAME the site (ti.com / analog.com / st.com).+ // The moment they hand off to UltraLibrarian / SnapMagic / Component Search+ // Engine, that aggregator is the source — NOT the manufacturer.+ let src_label = |disc: &str| crate::provenance::source_label(disc, &manufacturer);+ let src = src_label(disc);++ // Where the GLOBAL chip STEP came from — for the asterisk tooltip on lines+ // whose own library had no 3D ("we swapped in the STEP we got from <X>").+ let step_e = prov.as_ref().and_then(|p| p.get(format!("{mpn}.step")));+ let step_disc = step_e.and_then(|e| e.get("discovery_source")).and_then(|s| s.as_str()).unwrap_or(disc);+ let step_source = src_label(step_disc);+ let step_url = step_e.and_then(|e| e.get("url")).and_then(|s| s.as_str())+ .filter(|s| !s.is_empty()).map(String::from);++ // Download link for a file (relative path under /asset/<mpn>/). UI prepends+ // API_BASE. `original=true` = the raw archive/file we pulled off the internet+ // (for the user to inspect); `false` = a specific file we extracted from it.+ let dl = |name: &str, file: String, original: bool| json!({ "name": name, "file": file, "original": original });+ // The original CSE bundle zip (the exact bytes downloaded) — shared by every+ // EDA-format line, since CSE ships one archive with all formats inside.+ let cse_zip = has(format!("{mpn}.cse-bundle.zip"));++ let mut lines: Vec<serde_json::Value> = Vec::new();++ // 1) Manufacturer DATASHEET → ds2sf extraction is its OWN source. The+ // datasheet has no 3D, so its 3D cell is the global chip STEP (asterisk).+ let ds_tried = has(format!("{mpn}-extraction.result.json"));+ let ds_sym = has(format!("{mpn}-symbol.extracted.json")) || has(format!("{mpn}-symbol.ds2sf.svg"));+ let ds_fp = has(format!("{mpn}-footprint.extracted.json")) || has(format!("{mpn}-footprint.ds2sf.svg"));+ if ds_tried || ds_sym || ds_fp {+ let mut files = vec![];+ // The datasheet PDF IS the original download for this line.+ if has(format!("{mpn}.pdf")) { files.push(dl("datasheet (PDF)", format!("{mpn}.pdf"), true)); }+ // Why is ds2sf red? Surface the extractor's own reason so the ✗ explains itself.+ let note = std::fs::read_to_string(d.join(format!("{mpn}-extraction.result.json"))).ok()+ .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())+ .and_then(|r| {+ let status = r.get("status").and_then(|s| s.as_str()).unwrap_or("");+ let summary = r.get("summary").and_then(|s| s.as_str()).unwrap_or("");+ if status == "ok" { None } else {+ Some(format!("ds2sf could not extract (status: {status}). {summary}"))+ }+ });+ // The datasheet is fetched from the maker's own lit server → name the site.+ lines.push(json!({ "label": format!("PDF Datasheet from {}", src_label("manufacturer")),+ "sym": ds_sym, "fp": ds_fp, "three_d": has_global_step, "three_d_from_lib": false,+ "step_source": step_source, "step_url": step_url, "files": files, "note": note }));+ }++ // 2) One line PER EDA FORMAT in the downloaded bundle — each library has its+ // own symbol + footprint, and (for CSE/UL/SnapEDA bundles) its own STEP.+ // Each line carries download links to the EXACT files we used.+ let three_lib = json!(has_global_step); // CSE/UL/SnapEDA bundles ship the STEP with each format+ let orig_zip = |files: &mut Vec<serde_json::Value>| {+ if cse_zip { files.push(dl("original bundle (.zip)", format!("{mpn}.cse-bundle.zip"), true)); }+ };+ let ki = (has(format!("{mpn}.kicad_sym")), has(format!("{mpn}.kicad_mod")));+ if ki.0 || ki.1 {+ let mut files = vec![];+ if ki.0 { files.push(dl(".kicad_sym", format!("{mpn}.kicad_sym"), false)); }+ if ki.1 { files.push(dl(".kicad_mod", format!("{mpn}.kicad_mod"), false)); }+ if has_global_step { files.push(dl(".step", format!("{mpn}.step"), false)); }+ orig_zip(&mut files);+ lines.push(json!({ "label": format!("{src} - KiCad Library"), "sym": ki.0, "fp": ki.1,+ "three_d": has_global_step, "three_d_from_lib": three_lib, "files": files }));+ }+ let al = (has(format!("{mpn}.SchLib")), has(format!("{mpn}.PcbLib")));+ if al.0 || al.1 {+ let mut files = vec![];+ if al.0 { files.push(dl(".SchLib", format!("{mpn}.SchLib"), false)); }+ if al.1 { files.push(dl(".PcbLib", format!("{mpn}.PcbLib"), false)); }+ if has_global_step { files.push(dl(".step", format!("{mpn}.step"), false)); }+ orig_zip(&mut files);+ lines.push(json!({ "label": format!("{src} - Altium Library"), "sym": al.0, "fp": al.1,+ "three_d": has_global_step, "three_d_from_lib": three_lib, "files": files }));+ }+ // EAGLE/Fusion .lbr is a single file holding BOTH the device (symbol) + package (footprint).+ if has(format!("{mpn}.lbr")) {+ let mut files = vec![dl(".lbr", format!("{mpn}.lbr"), false)];+ if has_global_step { files.push(dl(".step", format!("{mpn}.step"), false)); }+ orig_zip(&mut files);+ lines.push(json!({ "label": format!("{src} - Fusion 360 Library"), "sym": true, "fp": true,+ "three_d": has_global_step, "three_d_from_lib": three_lib, "files": files }));+ }+ json!(lines)+}++/// The CANONICAL sourcing-priority — the single source of truth for "what order+/// chip-fetcher searches the entire internet for electronics data." The+/// dashboard's Sourcing tab renders this verbatim, and the AI must follow it.+/// Three concerns, each with its own ordered ladder. ALWAYS manufacturer-first+/// for CAD; ALWAYS the Adom data tools (keys baked in) for metadata; the manual+/// CAD-crawl skills are the FALLBACK, in order, with LCSC dead last.+fn sources_json() -> Response<std::io::Cursor<Vec<u8>>> {+ use serde_json::json;+ let body = json!({+ "concerns": [+ {+ "concern": "1 · Identity & metadata",+ "what": "What the part IS — description, manufacturer, orderable MPN/package variants, stock, pricing, lead time, lifecycle (active/NRND/EOL).",+ "rule": "ALWAYS query the Adom data tools first — their API keys are baked into their own service containers, so they ALWAYS work with NO key needed. They also resolve the orderable MPN (base ADS1263 → ADS1263IPWR) that CAD sites need.",+ "steps": [+ {"tier": "Adom tool", "name": "adom-parts-search", "detail": "THE umbrella — searches Mouser + DigiKey + JLCPCB IN PARALLEL, returns description, stock, pricing, lead time, lifecycle, image, datasheet URL, and every orderable package variant. Mouser-preferred (40-min drone delivery, Fort Worth).", "cmd": "adom-parts-search search <MPN>"},+ {"tier": "Adom tool", "name": "adom-mouser", "detail": "Single-vendor deep lookup + richest stock/lifecycle. Backend keeps the key server-side (`adom-mouser serve`).", "cmd": "adom-mouser part <MPN>"},+ {"tier": "Adom tool", "name": "adom-digikey", "detail": "DigiKey catalog — broadest coverage, parametric + lifecycle.", "cmd": "adom-digikey part <MPN>"},+ {"tier": "Adom tool", "name": "adom-jlcpcb", "detail": "JLCPCB / LCSC catalog — assembly stock + basic/extended part class.", "cmd": "adom-jlcpcb part <MPN>"}+ ]+ },+ {+ "concern": "2 · Datasheet (PDF)",+ "what": "The manufacturer datasheet.",+ "rule": "Manufacturer's own lit server first; Adom tools give the fallback link.",+ "steps": [+ {"tier": "Manufacturer", "name": "Manufacturer lit server", "detail": "e.g. ti.com/lit/ds/symlink/<part>.pdf, st.com, nxp.com. ALWAYS try first.", "cmd": "chip-fetcher pup-pdf-fetch <MPN> --pdf-url <url>"},+ {"tier": "Adom tool", "name": "adom-parts-search datasheet_url", "detail": "Mouser/DigiKey expose a datasheet link when the mfr URL is unknown.", "cmd": "adom-parts-search search <MPN>"}+ ]+ },+ {+ "concern": "3 · CAD bundle (STEP + symbol + footprint)",+ "what": "The 3D STEP model + KiCad/Altium/Fusion symbol & footprint.",+ "rule": "MANUFACTURER-FIRST, always. Exhaust the maker's own CAD/Resources before any aggregator. Then fall back DOWN this ladder IN ORDER — never skip ahead. LCSC is the LAST resort; surface 'no manufacturer CAD' to the user before reaching for it.",+ "steps": [+ {"tier": "Manufacturer", "name": "Manufacturer site", "detail": "vendor.com CAD / Design Resources tab — the authoritative STEP + footprint. ALWAYS first, even when confident.", "cmd": "chip-fetcher mfr-probe <MPN>"},+ {"tier": "Aggregator skill", "name": "SnapMagic / SnapEDA", "detail": "Free CAD bundles, login-gated. Right after the manufacturer.", "skill": "playbooks/snapmagic.md"},+ {"tier": "Aggregator skill", "name": "Mouser CSE (Component Search Engine / SamacSys)", "detail": "Mouser's ECAD Model / Library Loader. Same-origin XHR + Basic-auth; needs the ORDERABLE MPN (from step 1).", "cmd": "chip-fetcher cse <MPN> --mfr <name>", "skill": "playbooks/mouser-cse.md"},+ {"tier": "Aggregator skill", "name": "DigiKey Footprints & Models", "detail": "DigiKey's /models/<id> page — mfr STEP + Ultra Librarian + SnapMagic.", "skill": "playbooks/digikey-models.md"},+ {"tier": "Aggregator skill", "name": "Arrow", "detail": "Niche distributor CAD.", "skill": "playbooks/sourcing-ladder.md"},+ {"tier": "Aggregator skill", "name": "UltraLibrarian / Component Search Engine", "detail": "Only when the manufacturer links there explicitly. Captcha or UL-Pro login flows.", "skill": "playbooks/ultra-librarian.md"},+ {"tier": "Last resort", "name": "LCSC", "detail": "datasheet.lcsc.com — LAST RESORT. Avoid if possible; tell the user we couldn't find it on the manufacturer's site first.", "skill": "playbooks/sourcing-ladder.md"}+ ]+ }+ ]+ }).to_string();+ json_response(body)+}++fn library_json() -> Response<std::io::Cursor<Vec<u8>>> {+ match crate::inventory::scan() {+ Ok(lib) => json_response(serde_json::to_string(&lib).unwrap_or_default()),+ Err(e) => {+ let h = Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap();+ Response::from_string(format!("{{\"error\":{:?}}}", e.to_string()))+ .with_status_code(500)+ .with_header(h)+ }+ }+}++fn projects_json() -> Response<std::io::Cursor<Vec<u8>>> {+ let store = crate::projects::load();+ json_response(serde_json::to_string(&store).unwrap_or_default())+}++fn chip_json(mpn: &str) -> Response<std::io::Cursor<Vec<u8>>> {+ match crate::inventory::scan() {+ Ok(lib) => match lib.chips.into_iter().find(|c| c.mpn == mpn) {+ Some(c) => json_response(serde_json::to_string(&c).unwrap_or_default()),+ None => not_found(),+ },+ Err(_) => not_found(),+ }+}++fn serve_thumb(rel: &str) -> Response<std::io::Cursor<Vec<u8>>> {+ if rel.contains("..") { return not_found(); }+ let parts: Vec<&str> = rel.splitn(2, '/').collect();+ if parts.len() != 2 { return not_found(); }+ let mpn = parts[0];+ let kind = parts[1];+ // chip-thumbnailer's canonical naming. URL keeps short kinds for nice+ // dashboard hrefs (/thumb/<MPN>/symbol|footprint|chip3d).+ // Per-source variant thumbnails: symbol.<method> / footprint.<method>+ // → <mpn>-symbol.<method>.svg (e.g. symbol.ds2sf, symbol.snapeda).+ if let Some(rest) = kind.strip_prefix("symbol.").or_else(|| kind.strip_prefix("footprint.")) {+ let pre = if kind.starts_with("symbol.") { "symbol" } else { "footprint" };+ if rest.contains('/') || rest.contains("..") { return not_found(); }+ let path = crate::library::root().join(mpn).join(format!("{mpn}-{pre}.{rest}.svg"));+ return match std::fs::read(&path) {+ Ok(b) => Response::from_data(b)+ .with_header(Header::from_bytes(&b"Content-Type"[..], &b"image/svg+xml"[..]).unwrap())+ .with_header(Header::from_bytes(&b"Cache-Control"[..], &b"public, max-age=60"[..]).unwrap()),+ Err(_) => not_found(),+ };+ }+ let (filename, ct) = match kind {+ "symbol" => (format!("{mpn}-symbol.svg"), "image/svg+xml"),+ "footprint" => (format!("{mpn}-footprint.svg"), "image/svg+xml"),+ "chip3d" => (format!("{mpn}-3d-iso.png"), "image/png"),+ "chip3d-yup" => (format!("{mpn}-3d-iso-yup.png"), "image/png"),+ "chip3d-xup" => (format!("{mpn}-3d-iso-xup.png"), "image/png"),+ "chip3d-xdown" => (format!("{mpn}-3d-iso-xdown.png"), "image/png"),+ "chip3d-ydown" => (format!("{mpn}-3d-iso-ydown.png"), "image/png"),+ "chip3d-zdown" => (format!("{mpn}-3d-iso-zdown.png"), "image/png"),+ // Large variants+ "chip3d-lg" => (format!("{mpn}-3d-iso-lg.png"), "image/png"),+ "chip3d-yup-lg" => (format!("{mpn}-3d-iso-yup-lg.png"), "image/png"),+ "chip3d-xup-lg" => (format!("{mpn}-3d-iso-xup-lg.png"), "image/png"),+ "chip3d-xdown-lg" => (format!("{mpn}-3d-iso-xdown-lg.png"), "image/png"),+ "chip3d-ydown-lg" => (format!("{mpn}-3d-iso-ydown-lg.png"), "image/png"),+ "chip3d-zdown-lg" => (format!("{mpn}-3d-iso-zdown-lg.png"), "image/png"),+ _ => return not_found(),+ };+ let path: PathBuf = crate::library::root().join(mpn).join(filename);+ let bytes = match std::fs::read(&path) {+ Ok(b) => b,+ Err(_) => return not_found(),+ };+ let h = Header::from_bytes(&b"Content-Type"[..], ct.as_bytes()).unwrap();+ let cc = Header::from_bytes(&b"Cache-Control"[..], &b"public, max-age=60"[..]).unwrap();+ Response::from_data(bytes).with_header(h).with_header(cc)+}++fn serve_asset(rel: &str) -> Response<std::io::Cursor<Vec<u8>>> {+ if rel.contains("..") { return not_found(); }+ let parts: Vec<&str> = rel.splitn(2, '/').collect();+ if parts.len() != 2 { return not_found(); }+ let path: PathBuf = crate::library::root().join(parts[0]).join(parts[1]);+ let bytes = match std::fs::read(&path) {+ Ok(b) => b,+ Err(_) => return not_found(),+ };+ let ct = guess_content_type(&path);+ let h = Header::from_bytes(&b"Content-Type"[..], ct.as_bytes()).unwrap();+ Response::from_data(bytes).with_header(h)+}++fn guess_content_type(path: &PathBuf) -> &'static str {+ let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();+ match ext.as_str() {+ "pdf" => "application/pdf",+ "step" | "stp" => "application/STEP",+ "json" => "application/json",+ "kicad_mod" | "kicad_sym" => "text/plain; charset=utf-8",+ "html" => "text/html; charset=utf-8",+ "png" => "image/png",+ "jpg" | "jpeg" => "image/jpeg",+ _ => "application/octet-stream",+ }+}++fn not_found() -> Response<std::io::Cursor<Vec<u8>>> {+ Response::from_string("not found").with_status_code(404)+}++/// Serve `<MPN>-footprint.authoritative.svg` from library/<MPN>/. ds2sf+/// v0.5.1+ writes this; the dashboard renders it inline as the canonical+/// 2D footprint reference next to the chip-thumbnailer 3D thumbs.+fn serve_authoritative_svg(mpn: &str) -> Response<std::io::Cursor<Vec<u8>>> {+ if mpn.contains("..") || mpn.contains('/') {+ return not_found();+ }+ let path = crate::library::root().join(mpn).join(format!("{mpn}-footprint.authoritative.svg"));+ match std::fs::read(&path) {+ Ok(bytes) => Response::from_data(bytes)+ .with_header("Content-Type: image/svg+xml".parse::<tiny_http::Header>().unwrap())+ .with_status_code(200),+ Err(_) => not_found(),+ }+}++/// Spawn `chip-fetcher <args>` as a detached subprocess so the dashboard's+/// click returns immediately while the action runs in the background. The+/// dashboard polls `/api/library` to see the new state. Activity heartbeats+/// from the subprocess will surface mid-run progress automatically.+fn spawn_subprocess_action(+ label: &str,+ args: &[&str],+ mpn: &str,+) -> Response<std::io::Cursor<Vec<u8>>> {+ use std::process::{Command, Stdio};+ // Self-invoke by absolute path — name-independent so the rename to+ // adom-chip-fetcher (or any future rename) can't break it.+ let exe = std::env::current_exe().unwrap_or_else(|_| "adom-chip-fetcher".into());+ let mut cmd = Command::new(exe);+ cmd.args(args)+ .stdout(Stdio::null())+ .stderr(Stdio::null())+ .stdin(Stdio::null());+ match cmd.spawn() {+ Ok(_child) => {+ let _ = crate::hints::heartbeat(mpn, &format!("dashboard kicked: {label}"));+ json_response(format!(+ "{{\"ok\":true,\"action\":\"{label}\",\"mpn\":{:?},\"hint\":\"polling /api/library shows new state\"}}",+ mpn+ ))+ }+ Err(e) => error_response(500, format!("{{\"error\":\"could not spawn chip-fetcher {label}: {e}\"}}")),+ }+}++pub fn open_in_hydrogen(port: u16) -> Result<()> {+ let url = format!("http://127.0.0.1:{port}");+ println!("opening {url} in Hydrogen webview");+ let status = std::process::Command::new("adom-cli")+ .args(["workspace", "tab-open", "--url", &url, "--title", "adom-chip-fetcher"])+ .status()+ .context("running adom-cli workspace tab-open")?;+ if !status.success() {+ anyhow::bail!("adom-cli workspace tab-open returned non-zero exit");+ }+ Ok(())+}
src/sources/mod.rsadded+1@@ -0,0 +1 @@+pub mod ti;
src/sources/ti.rsadded+43@@ -0,0 +1,43 @@+pub fn base_part(mpn: &str) -> String {+ let s = mpn.trim_end_matches(|c: char| c == 'R' || c == 'T');+ let chars: Vec<char> = s.chars().collect();+ let n = chars.len();+ let mut i = n;+ while i > 0 && chars[i - 1].is_ascii_digit() { i -= 1; }+ let after_digits = i;+ while i > 0 && chars[i - 1].is_ascii_alphabetic() { i -= 1; }+ let after_alpha = i;+ if after_alpha < after_digits && after_alpha > 0 && chars[after_alpha - 1].is_ascii_digit() {+ chars[..after_alpha].iter().collect()+ } else {+ s.to_string()+ }+}++pub fn product_url(mpn: &str) -> String {+ format!("https://www.ti.com/product/{}", base_part(mpn))+}++/// Land on the TI product page anchored to the CAD section.+/// User clicks "CAD/CAE Symbols & Footprints" → UL opens with the right part pre-loaded.+/// TI.com's part picker is much better than UL's search.+pub fn cad_url(mpn: &str) -> String {+ format!("https://www.ti.com/product/{}#cad-cae-symbols", base_part(mpn))+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn strips_package_and_reel() {+ assert_eq!(base_part("TPS62840DLCR"), "TPS62840");+ assert_eq!(base_part("INA226AIDGSR"), "INA226");+ assert_eq!(base_part("LM358DR"), "LM358");+ assert_eq!(base_part("LM358"), "LM358");+ assert_eq!(base_part("MSP430G2553IPW20"), "MSP430G2553");+ assert_eq!(base_part("DRV8825PWPR"), "DRV8825");+ assert_eq!(base_part("ADS1115IDGSR"), "ADS1115");+ assert_eq!(base_part("ADS1115"), "ADS1115");+ }+}
src/sourcing.rsadded+403@@ -0,0 +1,403 @@+// Sourcing-ladder enforcement, programmatic.+//+// Single source of truth for:+// * the canonical priority order (Mfr → SnapMagic → Mouser → DigiKey →+// Arrow → CSE/UL → LCSC), used by `chip-fetcher sources` and the+// first-heartbeat HINT and the `next-source` subcommand.+// * MPN → manufacturer + canonical product-search URL, used by+// `chip-fetcher mfr-probe`.+// * fetched_via_chain helpers — reading, appending, querying which+// ladder step has been tried for a given MPN.+// * stage-label heuristics — parse a heartbeat stage string for+// source names so the chain auto-updates without the AI writing it.+//+// All of this lives in code (not skill prose) because the agent doesn't+// reliably remember rules. The binary remembers; the agent queries.++use anyhow::Result;+use serde::{Deserialize, Serialize};+use serde_json::Value;+use std::path::PathBuf;++/// One step in the sourcing ladder.+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]+pub enum Source {+ Manufacturer,+ SnapMagic,+ Mouser,+ DigiKey,+ Arrow,+ CseUl,+ Lcsc,+}++impl Source {+ pub fn as_slug(&self) -> &'static str {+ match self {+ Source::Manufacturer => "manufacturer",+ Source::SnapMagic => "snapmagic",+ Source::Mouser => "mouser",+ Source::DigiKey => "digikey",+ Source::Arrow => "arrow",+ Source::CseUl => "cse_ul",+ Source::Lcsc => "lcsc",+ }+ }++ pub fn ladder() -> &'static [Source] {+ &[+ Source::Manufacturer,+ Source::SnapMagic,+ Source::Mouser,+ Source::DigiKey,+ Source::Arrow,+ Source::CseUl,+ Source::Lcsc,+ ]+ }++ pub fn next_after(&self) -> Option<Source> {+ let lad = Source::ladder();+ let idx = lad.iter().position(|s| s == self)?;+ lad.get(idx + 1).copied()+ }+}++// ---------- MPN → manufacturer + search URL ----------++/// What we know (or guess) about a chip's manufacturer from its MPN.+#[derive(Debug, Clone, Serialize)]+pub struct MfrGuess {+ pub manufacturer: &'static str,+ pub search_url: String,+ /// True when the prefix match is unambiguous; false when we fell back+ /// to a generic guess. The CLI surfaces this so the AI knows whether+ /// to trust it or do its own probe.+ pub confident: bool,+}++/// Look up the manufacturer + canonical product-search URL for an MPN.+/// Returns `None` only when we have no signal at all (very rare for+/// real-world EE parts; passive R/C MPNs are the usual gap). Always pairs+/// the manufacturer name with a directly-openable URL — the AI's job is+/// just to navigate there, not to figure out where there is.+pub fn guess_mfr(mpn: &str) -> Option<MfrGuess> {+ let m = mpn.to_ascii_uppercase();++ // Each entry: (prefix, manufacturer, search-URL template with {mpn}+ // placeholder; if the URL contains no placeholder it's a direct search).+ // Order matters — longer prefixes first so e.g. "STM32" matches before+ // "ST" does.+ let table: &[(&str, &str, &str)] = &[+ // MCUs / SoCs+ ("STM32", "STMicroelectronics", "https://www.st.com/en/search.html#q=%MPN%-t=products-page=1"),+ ("LSM", "STMicroelectronics", "https://www.st.com/en/search.html#q=%MPN%-t=products-page=1"),+ ("VL5", "STMicroelectronics", "https://www.st.com/en/search.html#q=%MPN%-t=products-page=1"),+ ("USBLC", "STMicroelectronics", "https://www.st.com/en/search.html#q=%MPN%-t=products-page=1"),+ ("ESP32", "Espressif", "https://www.espressif.com/en/search?keys=%MPN%"),+ ("NRF", "Nordic Semi", "https://www.nordicsemi.com/Search?Keyword=%MPN%"),+ ("ATSAM", "Microchip", "https://www.microchip.com/en-us/search?searchQuery=%MPN%"),+ ("ATME", "Microchip", "https://www.microchip.com/en-us/search?searchQuery=%MPN%"),+ ("ATTI", "Microchip", "https://www.microchip.com/en-us/search?searchQuery=%MPN%"),+ ("PIC", "Microchip", "https://www.microchip.com/en-us/search?searchQuery=%MPN%"),+ ("MCP", "Microchip", "https://www.microchip.com/en-us/search?searchQuery=%MPN%"),+ ("R7FA", "Renesas", "https://www.renesas.com/us/en/search?keywords=%MPN%"),+ ("DA14", "Renesas", "https://www.renesas.com/us/en/search?keywords=%MPN%"),+ ("LPC", "NXP", "https://www.nxp.com/search?q=%MPN%"),+ ("MCXN", "NXP", "https://www.nxp.com/search?q=%MPN%"),+ ("PCA9", "NXP", "https://www.nxp.com/search?q=%MPN%"),+ ("ICE40", "Lattice", "https://www.latticesemi.com/Search?searchTerm=%MPN%"),++ // Power / analog+ ("TPS", "Texas Instruments", "https://www.ti.com/product/%MPN%"),+ ("ADS", "Texas Instruments", "https://www.ti.com/product/%MPN%"),+ ("INA", "Texas Instruments", "https://www.ti.com/product/%MPN%"),+ ("DRV", "Texas Instruments", "https://www.ti.com/product/%MPN%"),+ ("MCF", "Texas Instruments", "https://www.ti.com/product/%MPN%"),+ ("BQ", "Texas Instruments", "https://www.ti.com/product/%MPN%"),+ ("MSP", "Texas Instruments", "https://www.ti.com/product/%MPN%"),+ ("LM", "Texas Instruments", "https://www.ti.com/product/%MPN%"),+ ("LMV", "Texas Instruments", "https://www.ti.com/product/%MPN%"),+ ("AD7", "Analog Devices", "https://www.analog.com/en/search.html?q=%MPN%"),+ ("AD8", "Analog Devices", "https://www.analog.com/en/search.html?q=%MPN%"),+ ("LTC", "Analog Devices", "https://www.analog.com/en/search.html?q=%MPN%"),+ ("MAX", "Analog Devices", "https://www.analog.com/en/search.html?q=%MPN%"),+ ("AP21", "Diodes Inc.", "https://www.diodes.com/search/?q=%MPN%"),+ ("AP22", "Diodes Inc.", "https://www.diodes.com/search/?q=%MPN%"),+ ("DMG", "Diodes Inc.", "https://www.diodes.com/search/?q=%MPN%"),+ ("DMP", "Diodes Inc.", "https://www.diodes.com/search/?q=%MPN%"),+ ("DMN", "Diodes Inc.", "https://www.diodes.com/search/?q=%MPN%"),++ // Sensors+ ("BME", "Bosch Sensortec", "https://www.bosch-sensortec.com/search.html?q=%MPN%"),+ ("BMI", "Bosch Sensortec", "https://www.bosch-sensortec.com/search.html?q=%MPN%"),+ ("BMP", "Bosch Sensortec", "https://www.bosch-sensortec.com/search.html?q=%MPN%"),+ ("BMA", "Bosch Sensortec", "https://www.bosch-sensortec.com/search.html?q=%MPN%"),+ ("BMG", "Bosch Sensortec", "https://www.bosch-sensortec.com/search.html?q=%MPN%"),++ // Connectors+ ("S2B-", "JST", "https://www.jst-mfg.com/product/index.php?series=199"),+ ("S3B-", "JST", "https://www.jst-mfg.com/product/index.php?series=199"),+ ("S4B-", "JST", "https://www.jst-mfg.com/product/index.php?series=199"),+ ("B2B-", "JST", "https://www.jst-mfg.com/product/index.php?series=199"),+ ("B3B-", "JST", "https://www.jst-mfg.com/product/index.php?series=199"),+ ("B4B-", "JST", "https://www.jst-mfg.com/product/index.php?series=199"),+ ("SM", "JST", "https://www.jst-mfg.com/search/?q=%MPN%"),+ ("BM", "JST", "https://www.jst-mfg.com/search/?q=%MPN%"),+ ("USB4", "GCT", "https://gct.co/search?q=%MPN%"),+ ("USB3", "GCT", "https://gct.co/search?q=%MPN%"),+ ("HR9", "HanRun", "https://www.hanrun.com/"), // chinese-only mfr+ ("WAGO", "WAGO", "https://www.wago.com/global/search?text=%MPN%"),++ // Crystals / passives+ ("ABM8", "Abracon", "https://abracon.com/search?searchQuery=%MPN%"),+ ("ABM3", "Abracon", "https://abracon.com/search?searchQuery=%MPN%"),+ ("CRCW", "Vishay", "https://www.vishay.com/search/?q=%MPN%"),+ ("VS", "Vishay", "https://www.vishay.com/search/?q=%MPN%"),+ ("VLM", "Vishay", "https://www.vishay.com/search/?q=%MPN%"),+ ("WSL", "Vishay", "https://www.vishay.com/search/?q=%MPN%"),++ // Switches+ ("TL3", "E-Switch", "https://www.e-switch.com/?s=%MPN%"),+ ];++ for (prefix, mfr, url_tmpl) in table {+ if m.starts_with(prefix) {+ let url = url_tmpl.replace("%MPN%", &urlencoding(mpn));+ return Some(MfrGuess {+ manufacturer: mfr,+ search_url: url,+ confident: true,+ });+ }+ }+ None+}++/// Normalize a chain-source slug. Accepts legacy variants ("cse",+/// "snapeda") and maps to the canonical slug used by Source::as_slug.+/// Without this, an entry with source="cse" doesn't satisfy a check for+/// "cse_ul" and the next-source state machine wrongly thinks cse_ul+/// hasn't been tried.+fn normalize_slug(s: &str) -> String {+ match s.to_ascii_lowercase().as_str() {+ "cse" | "ul" | "cseul" | "ultralibrarian" => "cse_ul".into(),+ "snapeda" => "snapmagic".into(),+ "mfr" => "manufacturer".into(),+ other => other.to_string(),+ }+}++fn urlencoding(s: &str) -> String {+ // Tiny URL-encoder — enough for MPN strings. Avoids a dep.+ let mut out = String::with_capacity(s.len());+ for b in s.bytes() {+ match b {+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {+ out.push(b as char)+ }+ _ => out.push_str(&format!("%{:02X}", b)),+ }+ }+ out+}++// ---------- fetched_via_chain helpers ----------++/// One entry in info.json.fetched_via_chain. Each represents a step on the+/// ladder we tried, with outcome.+#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct ChainEntry {+ pub source: String, // slug from Source::as_slug+ pub outcome: String, // "OK", "empty", "404", "blocked", "ladder_exhausted", etc.+ pub note: Option<String>, // free-form context: "no English DS", "Chinese-only site"+ pub at: u64, // unix seconds+}++/// Read `info.json.fetched_via_chain` for an MPN. Returns empty Vec if no+/// chain has been recorded yet (fresh stub).+pub fn read_chain(mpn: &str) -> Result<Vec<ChainEntry>> {+ let path = info_path(mpn);+ if !path.exists() {+ return Ok(vec![]);+ }+ let s = std::fs::read_to_string(&path)?;+ let v: Value = serde_json::from_str(&s).unwrap_or(Value::Null);+ Ok(parse_chain(&v))+}++fn parse_chain(v: &Value) -> Vec<ChainEntry> {+ let arr = match v.get("fetched_via_chain").and_then(|c| c.as_array()) {+ Some(a) => a,+ None => return vec![],+ };+ arr.iter().filter_map(|e| {+ // Two formats supported: legacy "manufacturer:st.com:OK" string,+ // or the structured object below. Old entries get parsed loosely.+ if let Some(s) = e.as_str() {+ let parts: Vec<&str> = s.splitn(3, ':').collect();+ return Some(ChainEntry {+ source: parts.first().map(|x| x.to_string()).unwrap_or_default(),+ outcome: parts.get(2).map(|x| x.to_string()).unwrap_or_else(|| "?".into()),+ note: parts.get(1).map(|x| x.to_string()),+ at: 0,+ });+ }+ serde_json::from_value::<ChainEntry>(e.clone()).ok()+ }).collect()+}++/// Return the source slug from the last chain entry (most recent attempt).+/// Used by `fetch` to derive provenance for imported files.+pub fn last_chain_source(mpn: &str) -> Option<String> {+ read_chain(mpn).ok()?.last().map(|e| e.source.clone())+}++/// Append an entry to info.json.fetched_via_chain, creating the file if+/// missing. Idempotent on (source, outcome) — won't double-log the same+/// step result. Returns true if a new entry was added.+pub fn append_chain(mpn: &str, source: Source, outcome: &str, note: Option<&str>) -> Result<bool> {+ let path = info_path(mpn);+ if let Some(parent) = path.parent() {+ std::fs::create_dir_all(parent)?;+ }+ let mut info: Value = if path.exists() {+ let s = std::fs::read_to_string(&path)?;+ serde_json::from_str(&s).unwrap_or_else(|_| serde_json::json!({"mpn": mpn}))+ } else {+ serde_json::json!({"mpn": mpn})+ };++ let chain = parse_chain(&info);+ let dup = chain.iter().any(|e| e.source == source.as_slug() && e.outcome == outcome);+ if dup {+ return Ok(false);+ }++ let new_entry = ChainEntry {+ source: source.as_slug().to_string(),+ outcome: outcome.to_string(),+ note: note.map(|s| s.to_string()),+ at: now_secs(),+ };++ let arr = info.get_mut("fetched_via_chain")+ .and_then(|v| v.as_array_mut());+ match arr {+ Some(a) => a.push(serde_json::to_value(&new_entry)?),+ None => {+ info["fetched_via_chain"] = serde_json::Value::Array(+ chain.iter().filter_map(|e| serde_json::to_value(e).ok())+ .chain(std::iter::once(serde_json::to_value(&new_entry)?))+ .collect()+ );+ }+ }++ std::fs::write(&path, serde_json::to_string_pretty(&info)?)?;+ Ok(true)+}++/// Return the next ladder step that hasn't been tried (or has only been+/// tried with a transient outcome). Manufacturer always comes first, even+/// if the AI thinks it knows the part.+pub fn next_source(mpn: &str) -> Result<Option<Source>> {+ let chain = read_chain(mpn)?;+ let tried: std::collections::HashSet<String> = chain.iter()+ // Anything other than the bare "attempted" marker (= "navigated to+ // the page, no outcome yet") counts as tried. Legacy free-form+ // outcomes from older sessions ("Chinese-only site...", "no English+ // DS") still count.+ .filter(|e| e.outcome != "attempted")+ .map(|e| normalize_slug(&e.source))+ .collect();+ for step in Source::ladder() {+ if !tried.contains(step.as_slug()) {+ return Ok(Some(*step));+ }+ }+ Ok(None) // ladder fully exhausted+}++// ---------- stage-label parsing ----------++/// Parse a heartbeat stage string for source-name signals. Returns+/// (source, outcome) if we can confidently extract one. The point is to+/// auto-record the chain on every heartbeat so the AI doesn't have to+/// remember to do it explicitly.+pub fn detect_source_attempt(stage: &str) -> Option<(Source, String)> {+ let s = stage.to_ascii_lowercase();++ // Try to read the source out first.+ let source = if s.contains("manufacturer") || s.contains("mfr ") || s.contains(" mfr,")+ || s.contains("st.com") || s.contains("ti.com") || s.contains("nxp.com")+ || s.contains("nordic") || s.contains("microchip") || s.contains("ti ")+ || s.contains(".com") && s.contains("trying ") && !s.contains("snapeda") && !s.contains("snapmagic")+ && !s.contains("mouser") && !s.contains("digikey") && !s.contains("arrow")+ && !s.contains("lcsc")+ {+ Source::Manufacturer+ } else if s.contains("snapmagic") || s.contains("snapeda") {+ Source::SnapMagic+ } else if s.contains("mouser") {+ Source::Mouser+ } else if s.contains("digikey") {+ Source::DigiKey+ } else if s.contains("arrow") {+ Source::Arrow+ } else if s.contains("cse") || s.contains("ultra librarian") || s.contains("ultralibrarian") {+ Source::CseUl+ } else if s.contains("lcsc") {+ Source::Lcsc+ } else {+ return None;+ };++ // Now infer outcome heuristics.+ let outcome = if s.contains("imported") || s.contains("download ok") || s.contains(" ok ") {+ "OK"+ } else if s.contains("empty") || s.contains("no result") || s.contains("not carried") || s.contains("not_carried") {+ "empty"+ } else if s.contains("404") || s.contains("not found") {+ "404"+ } else if s.contains("blocked") || s.contains("captcha") || s.contains("cdn") {+ "blocked"+ } else if s.contains("trying") || s.contains("probing") || s.contains("driving") || s.contains("falling to") {+ // Just a navigation event, not yet an outcome — don't pollute the chain.+ return None;+ } else {+ "attempted"+ };++ Some((source, outcome.to_string()))+}++// ---------- stage state machine ----------++/// Canonical fetch stages, in expected order. Used to validate stage+/// transitions in heartbeat and warn when the AI reports "imported"+/// without ever heartbeating "downloading".+pub fn stage_phase(stage: &str) -> Option<u8> {+ let s = stage.to_ascii_lowercase();+ if s.contains("queued") || s.contains("starting") { return Some(0); }+ if s.contains("probing") || s.contains("navigating") || s.contains("trying") || s.contains("falling to") || s.contains("driving") { return Some(1); }+ if s.contains("found") || s.contains("identified") { return Some(2); }+ if s.contains("downloading") || s.contains("fetching") || s.contains("polling downloads") { return Some(3); }+ if s.contains("pulling") || s.contains("from desktop") { return Some(4); }+ if s.contains("importing") { return Some(5); }+ if s.contains("imported") || s.contains("complete") || s.contains("done") { return Some(6); }+ None+}++// ---------- internal helpers ----------++fn info_path(mpn: &str) -> PathBuf {+ crate::library::root().join(mpn).join("info.json")+}++fn now_secs() -> u64 {+ std::time::SystemTime::now()+ .duration_since(std::time::UNIX_EPOCH)+ .map(|d| d.as_secs())+ .unwrap_or(0)+}
src/thumbnails.rsadded+377@@ -0,0 +1,377 @@+// Thumbnail generation by shelling to the canonical `chip-thumbnailer` CLI.+//+// chip-thumbnailer is the single source of truth for rendering the three+// per-chip artifacts under library/<MPN>/ — keeping the rendering logic+// (KiCad-CLI invocations, SVG dark theming, OCCT iso-pose render) in one+// place across every Adom tool that needs them.+//+// Output paths (canonical):+// <MPN>-symbol.svg+// <MPN>-footprint.svg+// <MPN>-3d-iso-sm.png (160×120)+// <MPN>-3d-iso.png (320×240) — primary, used by the dashboard+// <MPN>-3d-iso-lg.png (800×600) — used for full-screen pup tab on click+//+// chip-thumbnailer is idempotent: it skips artifacts whose source hasn't+// changed since the last render.++use anyhow::{Context, Result};+use std::process::Command;++use crate::library;++const CHIP_THUMBNAILER_BIN: &str = "adom-chip-thumbnailer";+const PENDING_MARKER: &str = ".thumb-pending";++pub struct GenReport {+ pub mpn: String,+ pub generated: Vec<&'static str>,+ pub skipped: Vec<&'static str>,+ pub errors: Vec<(String, String)>,+}++/// Render thumbnails for a single chip. `force = true` touches the+/// pending-marker first so chip-thumbnailer re-renders even if the output+/// is newer than the source.+pub fn generate_for(mpn: &str, force: bool) -> Result<GenReport> {+ let dir = library::root().join(mpn);+ if !dir.exists() {+ anyhow::bail!("library/{mpn}/ does not exist");+ }+ if force {+ let _ = std::fs::write(dir.join(PENDING_MARKER), b"");+ }++ let out = Command::new(CHIP_THUMBNAILER_BIN)+ .arg("once")+ .arg(mpn)+ .output()+ .context("running chip-thumbnailer once — install with `chip-thumbnailer install`")?;++ let mut report = GenReport {+ mpn: mpn.to_string(),+ generated: Vec::new(),+ skipped: Vec::new(),+ errors: Vec::new(),+ };++ let stdout = String::from_utf8_lossy(&out.stdout);+ let stderr = String::from_utf8_lossy(&out.stderr);++ // Parse chip-thumbnailer's `OK: sym → ...` / `ERROR: 3d → ...` lines so+ // chip-fetcher can show the same per-kind summary it always has.+ for line in stdout.lines().chain(stderr.lines()) {+ let l = strip_ansi(line);+ if let Some(rest) = l.strip_prefix("OK: ") {+ if rest.starts_with("sym ") || rest.starts_with("sym\t") || rest.starts_with("sym→") || rest.starts_with("sym ") {+ report.generated.push("symbol");+ } else if rest.starts_with("fp ") || rest.starts_with("fp\t") || rest.starts_with("fp→") || rest.starts_with("fp ") {+ report.generated.push("footprint");+ } else if rest.starts_with("3d ") || rest.starts_with("3d\t") || rest.starts_with("3d→") || rest.starts_with("3d ") {+ report.generated.push("chip3d");+ }+ } else if let Some(rest) = l.strip_prefix("ERROR: ") {+ // Best-effort kind parsing+ let kind = if rest.starts_with("sym") { "symbol" }+ else if rest.starts_with("fp") { "footprint" }+ else if rest.starts_with("3d") { "chip3d" }+ else { "render" };+ report.errors.push((kind.into(), rest.trim().to_string()));+ }+ }++ if !out.status.success() && report.errors.is_empty() {+ report.errors.push(("setup".into(), format!("chip-thumbnailer exited {:?}: {}", out.status.code(), stderr.trim())));+ }++ // Dedup the kinds in case parsing matched twice+ report.generated.sort_unstable();+ report.generated.dedup();++ // Cross-app: also render the ds2sf source-variant thumbnails (best-effort).+ generate_ds2sf_variants(&dir, mpn);++ Ok(report)+}++/// Render the ds2sf datasheet-extracted symbol/footprint to their own variant+/// thumbnails (`<mpn>-symbol.ds2sf.svg`, `<mpn>-footprint.ds2sf.svg`) so the+/// provenance carat shows a ds2sf option distinct from the vendor download.+/// Best-effort — skipped silently if the tools or the ds2sf extraction are absent.+fn generate_ds2sf_variants(dir: &std::path::Path, mpn: &str) {+ // The canonical SYM/FP tile must show the ACTUAL vendor download (the source+ // the carat labels "SnapEDA"/etc.), not the ds2sf datasheet synthesis.+ // chip-thumbnailer prefers the ds2sf "authoritative" SVG for the footprint+ // (it predates a working service-kicad path), which made the vendor tile show+ // "ds2sf SYNTHESIZED". Re-render the canonical tiles from the real vendor+ // files via adom-symbol/adom-footprint (service-kicad), overwriting that.+ let canon_mod = dir.join(format!("{mpn}.kicad_mod"));+ if canon_mod.is_file() {+ let _ = Command::new("adom-footprint")+ .args(["render", "--file"]).arg(&canon_mod)+ .arg("--out").arg(dir.join(format!("{mpn}-footprint.svg")))+ .output();+ }+ let sym_json = dir.join(format!("{mpn}-symbol.extracted.json"));+ if sym_json.is_file() {+ let out = dir.join(format!("{mpn}-symbol.ds2sf.svg"));+ let _ = Command::new("adom-symbol")+ .args(["render-ds2sf", "--file"])+ .arg(&sym_json).arg("--out").arg(&out)+ .output();+ }+ let fp_json = dir.join(format!("{mpn}-footprint.extracted.json"));+ if fp_json.is_file() {+ let out = dir.join(format!("{mpn}-footprint.ds2sf.svg"));+ let _ = Command::new("adom-footprint")+ .args(["render-ds2sf", "--file"])+ .arg(&fp_json).arg("--out").arg(&out)+ .output();+ }+ // Black-&-white vector OUTLINE icons (OCCT HLR) at the chosen orientation.+ let step = dir.join(format!("{mpn}.step"));+ if step.is_file() {+ let axis = std::fs::read_to_string(dir.join("info.json"))+ .ok()+ .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())+ .and_then(|v| v.get("chosen_up_axis").and_then(|a| a.as_str()).map(String::from))+ .unwrap_or_else(|| "z".to_string());+ let _ = Command::new("python3")+ .arg("/home/adom/project/chip-fetcher/scripts/occt-outline.py")+ .arg(&step).arg(dir).arg(mpn).arg(&axis)+ .output();+ // Transparent-background iso icon (chroma-key the shaded iso for the+ // chosen axis) so the symbol-centre overlay has no ugly box.+ let iso_in = match axis.as_str() {+ "y" | "yup" => format!("{mpn}-3d-iso-yup.png"),+ "x" | "xup" => format!("{mpn}-3d-iso-xup.png"),+ "-x" | "xdown" => format!("{mpn}-3d-iso-xdown.png"),+ "-y" | "ydown" => format!("{mpn}-3d-iso-ydown.png"),+ "-z" | "zdown" => format!("{mpn}-3d-iso-zdown.png"),+ _ => format!("{mpn}-3d-iso.png"),+ };+ let iso_path = dir.join(&iso_in);+ if iso_path.is_file() {+ let _ = Command::new("python3")+ .arg("/home/adom/project/chip-fetcher/scripts/occt-iso.py")+ .arg(&iso_path)+ .arg(dir.join(format!("{mpn}-3d-iso-icon.png")))+ .output();+ }+ // Name-marked 3D: emboss the part number on the chip's top face (real+ // STEP geometry) and render the iso + flat-top outline variants, so the+ // "laser the name on the chip" mode has perspective-correct assets that+ // match what ships to KiCad. Best-effort; skipped if OCCT isn't present.+ generate_named_assets(dir, mpn, &axis);+ }++ // Canonical SYMBOL render — done LAST, AFTER the transparent iso icon exists.+ // John's rule: show adom-symbol's DEFAULT symbol with the iso 3D chip+ // composited into the body. The order matters: rendering the symbol BEFORE+ // the `-3d-iso-icon.png` (transparent) is generated bakes in the shaded+ // `-3d-iso.png` instead — a gray box behind the chip. Generating the icon+ // first (above) guarantees the symbol gets the clean transparent overlay.+ let canon_sym = dir.join(format!("{mpn}.kicad_sym"));+ if canon_sym.is_file() {+ let mut cmd = Command::new("adom-symbol");+ cmd.args(["render", "--file"]).arg(&canon_sym)+ .arg("--out").arg(dir.join(format!("{mpn}-symbol.svg")));+ let iso_icon = dir.join(format!("{mpn}-3d-iso-icon.png"));+ let iso_plain = dir.join(format!("{mpn}-3d-iso.png"));+ if iso_icon.is_file() {+ cmd.arg("--iso").arg(&iso_icon);+ } else if iso_plain.is_file() {+ cmd.arg("--iso").arg(&iso_plain);+ }+ let _ = cmd.output();+ }++ // Foreign-format libraries (EAGLE/Fusion .lbr) → convert to KiCad sym+mod via+ // adom-sfconvert so each format renders its OWN geometry in the carat instead+ // of falling back to the canonical KiCad render. Altium .SchLib/.PcbLib are+ // OLE binary — adom-sfconvert errors honestly there, so no Altium thumb yet.+ convert_foreign_libraries(dir, mpn);++ // Per-source vendor variants kept by import (<mpn>.<method>.kicad_sym/.kicad_mod)+ // → render each to its own thumbnail so the carat shows a preview per source.+ if let Ok(rd) = std::fs::read_dir(dir) {+ for e in rd.flatten() {+ let name = e.file_name().to_string_lossy().to_string();+ for (ext, bin, kind) in [("kicad_sym", "adom-symbol", "symbol"), ("kicad_mod", "adom-footprint", "footprint")] {+ let suffix = format!(".{ext}");+ let prefix = format!("{mpn}.");+ if name == format!("{mpn}.{ext}") || !name.ends_with(&suffix) || !name.starts_with(&prefix) {+ continue;+ }+ let method = &name[prefix.len()..name.len() - suffix.len()];+ if method.is_empty() || method.contains('.') {+ continue;+ }+ let out = dir.join(format!("{mpn}-{kind}.{method}.svg"));+ if !out.is_file() {+ let _ = Command::new(bin).args(["render", "--file"]).arg(e.path()).arg("--out").arg(&out).output();+ }+ }+ }+ }+}++/// Convert foreign-format vendor libraries into KiCad sym+mod so each format+/// renders its OWN geometry. Writes `<mpn>.<method>.kicad_sym/.kicad_mod`, which+/// the per-source variant loop above then renders to `<mpn>-<kind>.<method>.svg`.+/// Idempotent + best-effort: if the converted files already exist, or the+/// converter can't handle the format (Altium OLE binary), we leave it alone.+fn convert_foreign_libraries(dir: &std::path::Path, mpn: &str) {+ // EAGLE / Fusion 360 .lbr → NATIVE via adom-sfconvert (eagle.rs, pure Rust —+ // no service round-trip). Emits export .kicad_sym/.kicad_mod AND the native+ // SVG thumbnails (<mpn>.fusion-symbol.svg / <mpn>.fusion-footprint.svg),+ // which we move to the canonical per-source names so the variant loop skips+ // the service render.+ let lbr = dir.join(format!("{mpn}.lbr"));+ if lbr.is_file() && !dir.join(format!("{mpn}-symbol.fusion.svg")).is_file() {+ let _ = Command::new("adom-sfconvert")+ .arg("convert").arg(&lbr)+ .arg("--out-dir").arg(dir)+ .arg("--name").arg(format!("{mpn}.fusion"))+ .output();+ let _ = std::fs::rename(+ dir.join(format!("{mpn}.fusion-symbol.svg")),+ dir.join(format!("{mpn}-symbol.fusion.svg")),+ );+ let _ = std::fs::rename(+ dir.join(format!("{mpn}.fusion-footprint.svg")),+ dir.join(format!("{mpn}-footprint.fusion.svg")),+ );+ }+ // Altium .SchLib (symbol) + .PcbLib (footprint) → NATIVE via adom-sfconvert+ // (altium-schlib / altium-pcblib, pure Rust — no service round-trip). It+ // emits the export .kicad_sym/.kicad_mod AND the native SVG thumbnail+ // (<mpn>.altium-symbol.svg / <mpn>.altium-footprint.svg). We move those SVGs+ // to chip-fetcher's canonical per-source thumbnail names so the variant+ // render loop below finds them already present and skips the service render.+ let schlib = dir.join(format!("{mpn}.SchLib"));+ if schlib.is_file() && !dir.join(format!("{mpn}-symbol.altium.svg")).is_file() {+ let _ = Command::new("adom-sfconvert")+ .arg("convert").arg(&schlib)+ .arg("--out-dir").arg(dir)+ .arg("--name").arg(format!("{mpn}.altium"))+ .output();+ let _ = std::fs::rename(+ dir.join(format!("{mpn}.altium-symbol.svg")),+ dir.join(format!("{mpn}-symbol.altium.svg")),+ );+ }+ let pcblib = dir.join(format!("{mpn}.PcbLib"));+ if pcblib.is_file() && !dir.join(format!("{mpn}-footprint.altium.svg")).is_file() {+ let _ = Command::new("adom-sfconvert")+ .arg("convert").arg(&pcblib)+ .arg("--out-dir").arg(dir)+ .arg("--name").arg(format!("{mpn}.altium"))+ .output();+ let _ = std::fs::rename(+ dir.join(format!("{mpn}.altium-footprint.svg")),+ dir.join(format!("{mpn}-footprint.altium.svg")),+ );+ }+}++const SCRIPTS: &str = "/home/adom/project/chip-fetcher/scripts";++/// Emboss the part number onto the chip's 3D STEP top face and render the+/// name-marked iso + flat-top outline variants. The mark is real geometry so it+/// renders perspective-correctly AND travels with the STEP to KiCad. The shaded+/// iso uses a shallow raised relief; the line-art outline uses flat top-face+/// geometry (top edge only — no extrusion clutter). Best-effort, idempotent.+fn generate_named_assets(dir: &std::path::Path, mpn: &str, axis_raw: &str) {+ let step = dir.join(format!("{mpn}.step"));+ if !step.is_file() {+ return;+ }+ // The emboss only knows the y-up / z-up top face; normalize, skip exotic axes.+ let up = match axis_raw.to_lowercase().as_str() {+ "y" | "yup" | "+y" => "y",+ "z" | "zup" | "+z" | "" => "z",+ _ => return,+ };+ let icon = dir.join(format!("{mpn}-3d-iso-named-icon.png"));+ if icon.is_file() {+ return; // already generated+ }+ // Optional variant second line (e.g. "QFAA-R7") from info.json, if recorded.+ let variant = std::fs::read_to_string(dir.join("info.json"))+ .ok()+ .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())+ .and_then(|v| v.get("variant_marking").and_then(|a| a.as_str()).map(String::from))+ .unwrap_or_default();++ // 1. Embossed (shallow relief) STEP → for the shaded iso + KiCad delivery.+ let named_step = dir.join(format!("{mpn}-named.step"));+ let mut emboss = Command::new("python3");+ emboss.arg(format!("{SCRIPTS}/occt-name-on-chip.py")).arg(&step).arg(up).arg(mpn).arg(&named_step);+ if !variant.is_empty() { emboss.arg(&variant); }+ if !matches!(emboss.output(), Ok(o) if o.status.success()) {+ return;+ }+ // 2. Render the named STEP's iso (xvfb-backed V3d) → transparent icon.+ let tmp_iso = dir.join(format!("{mpn}-3d-iso-named.png"));+ let _ = Command::new("xvfb-run").args(["-a", "python3"])+ .arg(format!("{SCRIPTS}/render-iso.py")).arg(&named_step).arg(&tmp_iso).arg(axis_raw)+ .output();+ if tmp_iso.is_file() {+ let _ = Command::new("python3").arg(format!("{SCRIPTS}/occt-iso.py")).arg(&tmp_iso).arg(&icon).output();+ }+ // 3. Outline "-named" variants — single-stroke (centerline) part-number drawn+ // on the top face (clean even for long names; no outlined-glyph clutter).+ let mut ol = Command::new("python3");+ ol.arg(format!("{SCRIPTS}/occt-outline.py"))+ .arg(&step).arg(dir).arg(mpn).arg(axis_raw)+ .args(["--suffix", "-named", "--name", mpn]);+ if !variant.is_empty() { ol.args(["--line2"]).arg(&variant); }+ let _ = ol.output();+}++pub fn generate_all(force: bool) -> Vec<GenReport> {+ let root = library::root();+ let mut reports = Vec::new();+ if let Ok(entries) = std::fs::read_dir(&root) {+ for entry in entries.flatten() {+ if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { continue; }+ let mpn = match entry.file_name().to_str() {+ Some(s) => s.to_string(),+ None => continue,+ };+ if mpn.starts_with('.') { continue; }+ match generate_for(&mpn, force) {+ Ok(r) => reports.push(r),+ Err(e) => reports.push(GenReport {+ mpn,+ generated: vec![],+ skipped: vec![],+ errors: vec![("setup".into(), e.to_string())],+ }),+ }+ }+ }+ reports+}++fn strip_ansi(s: &str) -> String {+ // Drop CSI escape sequences (chip-thumbnailer color-codes its output).+ let mut out = String::with_capacity(s.len());+ let bytes = s.as_bytes();+ let mut i = 0;+ while i < bytes.len() {+ if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i+1] == b'[' {+ i += 2;+ while i < bytes.len() {+ let b = bytes[i];+ i += 1;+ if (0x40..=0x7e).contains(&b) { break; }+ }+ } else {+ out.push(bytes[i] as char);+ i += 1;+ }+ }+ out+}
src/ui/index.htmladded+2222@@ -0,0 +1,2222 @@+<!DOCTYPE html>+<html lang="en">+<head>+<meta charset="utf-8">+<title>chip-fetcher viewer</title>+<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%2300b8b0'><rect x='6' y='6' width='12' height='12' rx='1.2'/><rect x='9' y='2' width='2' height='2'/><rect x='13' y='2' width='2' height='2'/><rect x='9' y='20' width='2' height='2'/><rect x='13' y='20' width='2' height='2'/><rect x='2' y='9' width='2' height='2'/><rect x='2' y='13' width='2' height='2'/><rect x='20' y='9' width='2' height='2'/><rect x='20' y='13' width='2' height='2'/></svg>">+<style>+ /* --- Adom canonical font loaders (per adom-app-header §Required @font-face loaders) --- */+ @font-face {+ font-family: 'Familjen Grotesk';+ src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-700-normal.woff2') format('woff2');+ font-weight: 700; font-display: swap;+ }+ @font-face {+ font-family: 'Familjen Grotesk';+ src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-600-normal.woff2') format('woff2');+ font-weight: 600; font-display: swap;+ }+ @font-face {+ font-family: 'Familjen Grotesk';+ src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-500-normal.woff2') format('woff2');+ font-weight: 500; font-display: swap;+ }+ @font-face {+ font-family: 'Satoshi';+ src: url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2');+ font-weight: 400; font-display: swap;+ }+ @font-face {+ font-family: 'Satoshi';+ src: url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2');+ font-weight: 500; font-display: swap;+ }+ @font-face {+ font-family: 'JetBrains Mono';+ src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-400-normal.woff2') format('woff2');+ font-weight: 400; font-display: swap;+ }+ @font-face {+ font-family: 'JetBrains Mono';+ src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-500-normal.woff2') format('woff2');+ font-weight: 500; font-display: swap;+ }+ @font-face {+ font-family: 'JetBrains Mono';+ src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-600-normal.woff2') format('woff2');+ font-weight: 600; font-display: swap;+ }++ :root {+ /* Canonical Adom tokens */+ --bg: #0d1117;+ --surface: #161b22;+ --elevated: #1c2128;+ --border: #30363d;+ --border-muted: #21262d;+ --text: #e6edf3;+ --text2: #7d8590;+ --text3: #484f58;+ --accent: #00b8b0; /* Adom teal — canonical brand primary accent */++ /* chip-fetcher semantic accents */+ --green: #3fb950;+ --amber: #f59e0b;+ --red: #f85149;+ --grey: #4b5563;++ /* legacy aliases (kept for existing rules below) */+ --panel: var(--surface);+ --muted: var(--text2);+ }+ * { box-sizing: border-box; }+ html, body { font: 13px/1.4 'Satoshi', -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }+ body { margin: 0; background: var(--bg); color: var(--text); display: flex; flex-direction: column; min-height: 100vh; }+ /* --- Canonical Adom app-header (single row, 44 px) --- */+ header {+ display: flex;+ align-items: center;+ gap: 12px;+ padding: 0 16px;+ height: 44px;+ border-bottom: 1px solid var(--border-muted);+ background: var(--surface);+ position: relative;+ font-family: 'Familjen Grotesk', sans-serif;+ flex-shrink: 0;+ }+ header .brand {+ display: flex; align-items: center; gap: 8px;+ color: var(--accent);+ font-size: 13px; font-weight: 600;+ letter-spacing: 0.01em;+ cursor: default;+ flex-shrink: 0;+ outline: none;+ }+ header .brand:hover .app-name { text-decoration: underline; text-decoration-color: var(--accent); text-underline-offset: 3px; }+ header .brand svg.logo { width: 16px; height: 16px; flex-shrink: 0; }+ header .brand .app-name { color: var(--accent); }+ header .brand .app-subtitle { color: var(--text2); font-weight: 500; }+ header .brand .sep { color: var(--text3); font-weight: 400; }+ header .vsep {+ width: 1px; height: 18px;+ background: var(--border);+ flex-shrink: 0;+ }+ header .subject {+ display: flex; align-items: center; gap: 8px;+ min-width: 0;+ flex: 1;+ cursor: default;+ }+ header .subject-name {+ color: var(--text);+ font-family: 'Familjen Grotesk', sans-serif;+ font-size: 14px; font-weight: 600;+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;+ }+ header .subject-name.idle { color: var(--text3); font-weight: 500; }+ header .state-pill {+ display: none;+ font-size: 10px; font-weight: 700; letter-spacing: 0.5px;+ padding: 2px 8px;+ border-radius: 10px;+ font-family: 'Familjen Grotesk', sans-serif;+ flex-shrink: 0;+ }+ header .state-pill.show { display: inline-flex; }+ header .state-pill.full { background: rgba(63,185,80,0.18); color: var(--green); }+ header .state-pill.stub { background: rgba(245,158,11,0.20); color: var(--amber); }+ header .state-pill.fetching { background: rgba(0,184,176,0.20); color: var(--accent); animation: pillpulse 1.2s ease-in-out infinite; }+ @keyframes pillpulse { 0%,100% { opacity: 1; } 50% { opacity: 0.55; } }+ header .subject-meta {+ display: flex; align-items: center; gap: 6px;+ color: var(--text2);+ font-family: 'JetBrains Mono', monospace;+ font-size: 11px;+ white-space: nowrap;+ }+ header .subject-meta .sep { color: var(--text3); }+ @media (max-width: 720px) {+ header .subject-meta { display: none; }+ }+ header .actions {+ display: flex; align-items: center; gap: 6px;+ margin-left: auto; flex-shrink: 0;+ }++ /* --- Interactive popover (hover-tooltip you can move into) --- */+ header .hover-popover {+ position: absolute;+ top: calc(100% + 6px);+ left: 16px;+ background: var(--elevated);+ border: 1px solid var(--border);+ border-radius: 8px;+ box-shadow: 0 8px 24px rgba(0,0,0,0.5);+ padding: 12px 14px;+ min-width: 300px;+ max-width: 420px;+ font-family: 'Satoshi', sans-serif;+ font-size: 12px;+ color: var(--text);+ z-index: 99999;+ opacity: 0; transform: translateY(-2px);+ pointer-events: none;+ transition: opacity 120ms ease, transform 120ms ease;+ }+ header .hover-popover.show {+ opacity: 1; transform: none;+ pointer-events: auto;+ }+ header .hover-popover.compact { min-width: 220px; }+ header .hover-popover .popover-title {+ font-family: 'Familjen Grotesk', sans-serif;+ font-size: 14px; font-weight: 700;+ color: var(--text);+ margin-bottom: 8px;+ }+ header .hover-popover .popover-row {+ display: flex; gap: 12px; padding: 3px 0;+ align-items: baseline;+ }+ header .hover-popover .popover-row .k {+ color: var(--text3);+ font-family: 'JetBrains Mono', monospace;+ font-size: 10px; text-transform: lowercase;+ width: 64px; flex-shrink: 0;+ }+ header .hover-popover .popover-row .v {+ color: var(--text);+ font-family: 'Satoshi', sans-serif;+ font-size: 12px;+ flex: 1;+ }+ header .hover-popover .popover-actions {+ margin-top: 10px; padding-top: 10px;+ border-top: 1px solid var(--border-muted);+ display: flex; gap: 12px;+ }+ header .hover-popover .popover-link {+ color: var(--accent);+ text-decoration: none;+ font-family: 'Familjen Grotesk', sans-serif;+ font-size: 12px; font-weight: 600;+ }+ header .hover-popover .popover-link:hover { text-decoration: underline; }++ /* --- Toolbar (filters / search / credentials) — separate row below header --- */+ .toolbar {+ display: flex; align-items: center; gap: 8px;+ padding: 8px 16px;+ border-bottom: 1px solid var(--border-muted);+ background: var(--bg);+ flex-wrap: wrap;+ flex-shrink: 0;+ }+ .toolbar input[type=search],+ .toolbar select,+ .toolbar button {+ background: var(--surface);+ border: 1px solid var(--border);+ color: var(--text);+ padding: 6px 10px;+ border-radius: 6px;+ font: 12px/1.4 'Satoshi', sans-serif;+ }+ .toolbar input[type=search] { min-width: 220px; }+ .toolbar input[type=search]:focus,+ .toolbar select:focus,+ .toolbar button:hover { border-color: var(--accent); outline: none; }+ .toolbar button { cursor: pointer; }+ .toolbar .spacer { margin-left: auto; }+ .modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: none; align-items: center; justify-content: center; z-index: 100; }+ .modal-overlay.open { display: flex; }+ .modal { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 24px; min-width: 480px; max-width: 600px; }+ .modal h3 { margin: 0 0 12px; font-size: 18px; }+ .modal .helper { color: var(--muted); font-size: 12px; margin-bottom: 16px; line-height: 1.5; }+ .modal label { display: block; font-size: 12px; color: var(--muted); margin: 12px 0 4px; }+ .modal input { width: 100%; background: var(--bg); border: 1px solid var(--border); color: var(--text); padding: 8px 10px; border-radius: 4px; font: inherit; }+ .modal .buttons { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }+ .modal .btn-primary { background: var(--accent); border: 1px solid var(--accent); color: white; padding: 8px 16px; border-radius: 4px; cursor: pointer; font: inherit; }+ .modal .btn-secondary { background: transparent; border: 1px solid var(--border); color: var(--text); padding: 8px 16px; border-radius: 4px; cursor: pointer; font: inherit; }+ .creds-list { margin-bottom: 16px; }+ .creds-list .row { display: flex; justify-content: space-between; align-items: center; padding: 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; margin-bottom: 6px; font-size: 13px; }+ .creds-list .row .info { color: var(--muted); font-size: 11px; }+ .creds-list .row code { font-family: "JetBrains Mono", monospace; font-size: 12px; color: var(--text); }+ .creds-list .row button { background: transparent; border: 1px solid var(--border); color: var(--red); padding: 4px 10px; border-radius: 3px; cursor: pointer; font-size: 12px; }+ .msg { font-size: 13px; padding: 8px; border-radius: 4px; margin-top: 12px; }+ .msg.ok { background: rgba(34,197,94,.15); color: var(--green); }+ .msg.err { background: rgba(239,68,68,.15); color: var(--red); }+ main { display: grid; grid-template-columns: 1fr 380px; flex: 1; min-height: 0; }+ .grid { padding: 20px; overflow-y: auto; }+ .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }+ .card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 14px; cursor: pointer; transition: border-color .15s; }+ .card:hover { border-color: var(--accent); }+ .card.selected { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(0,184,176,.25); }+ .card .mpn-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 6px; margin-bottom: 4px; }+ .card .mpn { font-weight: 600; font-size: 15px; word-break: break-all; flex: 1; min-width: 0; }+ .card .reveal-btn { flex: 0 0 auto; background: transparent; border: 1px solid var(--border); color: var(--muted); border-radius: 4px; padding: 3px 5px; cursor: pointer; line-height: 0; display: inline-flex; align-items: center; justify-content: center; opacity: 0; transition: opacity .12s, color .12s, border-color .12s; }+ .card:hover .reveal-btn, .card.selected .reveal-btn { opacity: 1; }+ .card .reveal-btn:hover { color: var(--accent); border-color: var(--accent); }+ .card .meta { color: var(--muted); font-size: 12px; margin-bottom: 8px; }+ /* Thumbnail strip — three conceptual slots: SYMBOL | FOOTPRINT | 3D GROUP.+ The 3D group bundles Z↑ + Y↑ + the ▾ orientation picker because they all represent+ "the STEP file shown from different angles" — distinct from SYM (solved symbol) and+ FP (solved footprint). */+ /* Two-tier thumbnail hierarchy: SYM + FP are the big primary artifacts you+ design with; the 3D up-axis QA + .lbr packaging action are a smaller+ secondary row beneath. */+ .card .thumbs { display: flex; flex-direction: column; gap: 6px; margin: 10px 0; }+ .card .thumb-row { display: grid; gap: 6px; }+ .card .thumb-row.primary { grid-template-columns: 1fr 1fr; }+ .card .thumb-row.secondary { grid-template-columns: 2fr 1fr; align-items: stretch; }+ .card .thumb-3d-group { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; position: relative; border: 1px solid var(--border-muted); border-radius: 6px; padding: 2px; background: rgba(64,75,85,.15); }+ .card .thumb-3d-group .orient-more-btn { position: absolute; bottom: 2px; left: 50%; transform: translateX(-50%); z-index: 5; background: var(--panel); border: 1px solid var(--border); color: var(--muted); border-radius: 3px; padding: 0 6px; font-size: 9px; line-height: 1.5; cursor: pointer; opacity: 0; transition: opacity .12s; }+ .card:hover .thumb-3d-group .orient-more-btn, .card.selected .thumb-3d-group .orient-more-btn { opacity: 1; }+ .card .thumb-3d-group .orient-more-btn:hover { color: var(--accent); border-color: var(--accent); }+ /* Card zoom — animate from the card's grid position to centered,+ so the user tracks which card is zooming. Uses CSS transition on+ top/left/transform with a short ease-out. */+ .card-zoom-backdrop { position: fixed; inset: 0; z-index: 200; background: rgba(0,0,0,0); cursor: pointer; transition: background .25s ease-out; }+ .card-zoom-backdrop.visible { background: rgba(0,0,0,0.70); }+ .card-zoom-backdrop .card-zoomed-wrapper { position: fixed; z-index: 201; pointer-events: auto; cursor: default; transition: top .3s ease-out, left .3s ease-out, transform .3s ease-out; }+ .card-zoom-backdrop .card-zoomed-wrapper .card { transform-origin: top left; border: 2px solid var(--accent); box-shadow: 0 24px 80px rgba(0,0,0,0.7); }+ .card-zoom-backdrop .card .card-actions { opacity: 1 !important; }+ .card-zoom-backdrop .card .orient-more-btn { opacity: 1 !important; }+ .card-zoom-backdrop .card .reveal-btn { opacity: 1 !important; }+ /* Always-visible close button + full-screen scrollable mode (phones). */+ .card-zoom-x { position: fixed; top: 12px; right: 12px; z-index: 210; width: 42px; height: 42px; border-radius: 50%; border: 1px solid var(--border); background: rgba(13,17,23,.92); color: #e6edf3; font-size: 19px; line-height: 1; cursor: pointer; display: grid; place-items: center; box-shadow: 0 6px 22px rgba(0,0,0,.6); }+ .card-zoom-x:hover, .card-zoom-x:active { border-color: var(--accent); color: var(--accent); }+ .card-zoom-backdrop.fullscreen { overflow-y: auto; -webkit-overflow-scrolling: touch; display: flex; align-items: flex-start; justify-content: center; padding: 64px 0 36px; }+ .card-zoom-backdrop.fullscreen .card-zoomed-wrapper { position: relative !important; left: auto !important; top: auto !important; }+ /* Fluid 2-column zoom (desktop): media (thumbnails) on the LEFT, title + meta ++ badges + Open-in/Send-to on the RIGHT — so a zoomed card fills the horizontal+ room and stays short enough to scale up large, instead of a tall narrow strip. */+ .card-zoom-backdrop .card.zoom-fluid { display: grid; grid-template-columns: minmax(320px, 1.25fr) minmax(300px, 1fr); column-gap: 26px; align-items: start; padding: 22px; }+ .card-zoom-backdrop .card.zoom-fluid .zoom-col-media,+ .card-zoom-backdrop .card.zoom-fluid .zoom-col-info { min-width: 0; display: flex; flex-direction: column; gap: 10px; }+ .card-zoom-backdrop .card.zoom-fluid .thumbs { margin: 0; }+ .card-zoom-backdrop .card.zoom-fluid .zoom-actions { margin-top: 2px; }+ /* In the ZOOM, show the FULL chip description (what the part actually is) —+ not the 2-line clamp the grid card uses. John's repeated ask: the zoom must+ explain the chip. Make it prominent and fully readable. */+ .card-zoom-backdrop .card .card-desc {+ -webkit-line-clamp: unset; display: block; overflow: visible;+ font-size: 14px; line-height: 1.5; color: var(--text);+ margin: 6px 0 12px; max-width: 60ch;+ }+ /* Tile background MATCHES the image's own baked-in canvas color, so any+ whitespace introduced by aspect-ratio mismatch (KiCad SVG vs square+ tile) blends seamlessly. Per-kind backgrounds via data-thumb-kind:+ sym/fp → Adom dark canvas #0d1117 (chip-thumbnailer's SVG bg)+ 3d → slate #404B55 (pyrender's iso-pose backdrop) */+ .card .thumb { background: #0d1117; border: 1px solid var(--border-muted); border-radius: 6px; aspect-ratio: 1 / 1; display: flex; align-items: center; justify-content: center; padding: 8%; overflow: hidden; position: relative; transition: transform .15s ease, border-color .15s ease, box-shadow .15s ease; }+ .card .thumb[data-thumb-kind="chip3d"], .card .thumb[data-thumb-kind="chip3d-yup"] { background: #404B55; padding: 4%; }+ .card .thumb.thumb-lbr { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:3px; color:#8aa0b8; background:linear-gradient(150deg,#1b2330,#222c3d); }+ .card .thumb.thumb-lbr svg { width:34%; height:auto; }+ .card .thumb.thumb-lbr:hover { color:#37c8a6; }+ /* Active orientation gets an Adom-teal highlight ring + corner check.+ The OTHER 3D thumb sits dimmer until hover, signalling "click to pick+ this one if the active is wrong". */+ .card .thumb[data-orient-active="true"] { border-color: var(--green); box-shadow: 0 0 0 1px var(--green) inset; }+ .card .thumb[data-orient-inactive="true"] { opacity: 0.55; }+ .card .thumb[data-orient-inactive="true"]:hover { opacity: 1; }+ .card .thumb .orient-check { position: absolute; top: 4px; right: 4px; width: 16px; height: 16px; background: var(--green); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #0d1117; pointer-events: none; }+ .card .thumb .orient-check svg { width: 11px; height: 11px; }+ .card .thumb:hover { border-color: var(--accent); transform: scale(1.06); z-index: 2; box-shadow: 0 6px 18px rgba(0,0,0,0.4), 0 0 0 1px var(--accent); }+ /* Symbol/footprint tiles are non-interactive — no hover lift, no pointer. */+ .card .thumb.thumb-static { cursor: default; }+ .card .thumb.thumb-static:hover { transform: none; box-shadow: none; border-color: var(--border-muted); }+ .card .thumb img { width: 100%; height: 100%; object-fit: contain; display: block; }+ .card .thumb .thumb-label { position: absolute; bottom: 4px; right: 6px; font-size: 9px; color: rgba(230,237,243,0.55); font-family: 'JetBrains Mono', monospace; letter-spacing: .05em; text-shadow: 0 1px 2px rgba(0,0,0,0.6); pointer-events: none; }+ .card .thumb.empty { background: var(--bg); border-style: dashed; padding: 8%; }+ .card .thumb.empty .thumb-placeholder { color: var(--text3); font-size: 10px; font-family: 'JetBrains Mono', monospace; letter-spacing: .05em; }+ /* Render FAILED (chip-thumbnailer errored) — distinct from "not generated"+ so a failure isn't indistinguishable from an absent source file. */+ .card .thumb.failed { background: rgba(220,80,60,.06); border-style: dashed; border-color: rgba(220,80,60,.45); padding: 6%; flex-direction: column; gap: 2px; }+ .card .thumb.failed .thumb-failed { color: #ef7a6a; font-size: 10px; font-family: 'JetBrains Mono', monospace; font-weight: 600; letter-spacing: .05em; }+ .card .thumb.failed .thumb-failed-sub { color: var(--text3); font-size: 8px; font-family: 'JetBrains Mono', monospace; }+ /* Active-card emphasis — when this card is the one being heartbeated. */+ .card.active { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(0,184,176,.35), 0 0 24px rgba(0,184,176,.15); animation: cardpulse 1.4s ease-in-out infinite; }+ @keyframes cardpulse { 0%,100% { box-shadow: 0 0 0 2px rgba(0,184,176,.35), 0 0 24px rgba(0,184,176,.15); } 50% { box-shadow: 0 0 0 2px rgba(0,184,176,.55), 0 0 32px rgba(0,184,176,.30); } }+ .badges { display: flex; gap: 4px; flex-wrap: wrap; align-items: center; }+ .b { font-size: 10px; padding: 2px 6px; border-radius: 3px; font-weight: 600; letter-spacing: .3px; }+ .b.step { background: rgba(34,197,94,.15); color: var(--green); }+ .b.kicad { background: rgba(34,197,94,.15); color: var(--green); }+ .b.altium { background: rgba(34,197,94,.15); color: var(--green); }+ .b.fusion { background: rgba(34,197,94,.15); color: var(--green); }+ .b.partial-eda { background: rgba(245,158,11,.18); color: var(--amber); }+ .b.pdf { background: rgba(0,184,176,.15); color: var(--accent); }+ .b.clickable { cursor: pointer; }+ .b.clickable:hover { filter: brightness(1.25); }+ .b.miss { background: rgba(75,85,99,.25); color: var(--grey); }+ .b.full { background: rgba(34,197,94,.2); color: var(--green); }+ .b.stub { background: rgba(245,158,11,.2); color: var(--amber); }+ .b.src.mfr { background: rgba(34,197,94,.2); color: var(--green); }+ .b.src.fb { background: rgba(245,158,11,.2); color: var(--amber); }+ .b.src.unk { background: rgba(75,85,99,.25); color: var(--grey); }+ .b.lifecycle-unk { background: rgba(75,85,99,.20); color: var(--grey); font-style: italic; }+ .b.stock-yes { background: rgba(34,197,94,.18); color: var(--green); }+ .b.stock-low { background: rgba(245,158,11,.20); color: var(--amber); }+ .b.stock-no { background: rgba(239,68,68,.18); color: var(--red); }+ .b.stock-unk { background: rgba(75,85,99,.20); color: var(--grey); }+ .b.price { background: rgba(0,184,176,.10); color: var(--accent); font-weight: 500; }+ .b.nrnd { background: rgba(239,68,68,.20); color: var(--red); font-weight: 700; letter-spacing: .5px; }+ .b.eol { background: rgba(220,38,38,.30); color: #fca5a5; font-weight: 700; letter-spacing: .5px; }+ .b.new { background: rgba(34,197,94,.18); color: var(--green); font-weight: 600; letter-spacing: .3px; }+ /* concur validation badges */+ .b.val-golden { background: rgba(34,197,94,.25); color: var(--green); font-weight: 700; }+ .b.val-golden-v { background: rgba(34,197,94,.15); color: var(--green); font-weight: 600; }+ .b.val-divergent { background: rgba(245,158,11,.30); color: var(--amber); font-weight: 700; letter-spacing: .3px; }+ .b.val-bad { background: rgba(239,68,68,.25); color: var(--red); font-weight: 700; }+ .b.val-none { background: rgba(75,85,99,.20); color: var(--grey); font-style: italic; }+ /* per-card action buttons (re-validate, re-thumbnails, refetch) */+ .card .card-actions { display: flex; gap: 4px; align-items: center; flex: 0 0 auto; opacity: 0; transition: opacity .12s; }+ .card:hover .card-actions, .card.selected .card-actions { opacity: 1; }+ /* Touch devices have no hover — keep the card actions (incl. the zoom button)+ visible so a tap can reach them. */+ @media (hover: none) { .card .card-actions { opacity: 1; } }+ .card .card-btn { background: transparent; border: 1px solid var(--border); color: var(--muted); border-radius: 4px; padding: 2px 6px; font-size: 12px; cursor: pointer; line-height: 1.4; transition: color .12s, border-color .12s; }+ .card .card-btn:hover { color: var(--accent); border-color: var(--accent); }+ .card .card-btn:disabled { cursor: wait; }+ #cf-toast { position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%) translateY(16px); z-index: 100050; max-width: 90vw; background: rgba(17,22,31,.98); border: 1px solid var(--border2, #2b3645); border-radius: 11px; padding: 11px 16px; font-size: 13px; color: var(--text, #e6edf3); box-shadow: 0 14px 44px rgba(0,0,0,.6); opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s; }+ #cf-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); pointer-events: auto; }+ #cf-toast a { color: var(--accent, #00b8b1); font-weight: 700; margin-left: 6px; }+ .openin-row { display: flex; gap: 6px; flex-wrap: wrap; }+ /* Actions block injected into the zoom focus view. */+ .zoom-actions { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 12px; }+ .zoom-actions .zoom-sec { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 1px; margin: 0 0 6px; }+ .zoom-actions .openin-row, .zoom-actions .sendto-row { margin-bottom: 12px; }+ .openin-row .openbtn { background: var(--panel2, #0f141c); border: 1px solid var(--border); color: var(--text, #e6edf3); border-radius: 8px; padding: 6px 11px; font-size: 12px; font-weight: 600; cursor: pointer; transition: background .12s, border-color .12s, color .12s; }+ .openin-row .openbtn:hover { background: rgba(0,184,177,.16); border-color: var(--accent, #00b8b1); color: var(--accent, #00b8b1); }+ .openin-row .openbtn:disabled { opacity: .55; cursor: wait; }+ .sendto-row { display: flex; gap: 6px; flex-wrap: wrap; }+ .sendto-row .sendbtn { background: var(--panel2, #0f141c); border: 1px solid var(--border); color: var(--text, #e6edf3); border-radius: 8px; padding: 6px 12px; font-size: 12px; font-weight: 600; cursor: pointer; transition: background .12s, border-color .12s, color .12s; }+ .sendto-row .sendbtn:hover { background: var(--accent, #00b8b1); border-color: var(--accent, #00b8b1); color: #04201f; }+ .sendto-row .sendbtn:disabled { opacity: .55; cursor: progress; }+ #bridgeModal { position: fixed; inset: 0; z-index: 100001; display: none; align-items: center; justify-content: center; background: rgba(2,5,10,.62); backdrop-filter: blur(4px); padding: 20px; }+ #bridgeModal.show { display: flex; }+ #bridgeModal .bm-card { max-width: min(94vw,440px); background: rgba(17,22,31,.99); border: 1px solid var(--border); border-radius: 16px; padding: 22px 22px 18px; box-shadow: 0 30px 90px rgba(0,0,0,.7); text-align: center; }+ #bridgeModal .bm-icon { font-size: 34px; margin-bottom: 6px; }+ #bridgeModal .bm-title { font-size: 16px; font-weight: 700; margin-bottom: 8px; }+ #bridgeModal .bm-body { font-size: 13px; line-height: 1.55; color: var(--muted); margin-bottom: 16px; }+ #bridgeModal .bm-actions { display: flex; flex-direction: column; gap: 9px; }+ #bridgeModal .bm-go { display: block; text-decoration: none; background: linear-gradient(180deg,#00b8b1,#009a94); color: #04201f; font-weight: 700; font-size: 13px; padding: 10px; border-radius: 10px; }+ #bridgeModal .bm-hint { font-size: 11.5px; line-height: 1.5; color: var(--text, #cfd6e4); background: rgba(0,184,177,.08); border: 1px solid rgba(0,184,177,.22); border-radius: 9px; padding: 8px 10px; margin-bottom: 14px; text-align: left; }+ #bridgeModal .bm-contribute { display: block; text-decoration: none; color: var(--muted); font-size: 12px; padding: 7px; border: 1px solid var(--border); border-radius: 10px; }+ #bridgeModal .bm-contribute:hover { color: var(--accent); border-color: var(--accent); }+ #bridgeModal .bm-dismiss { background: transparent; border: 1px solid var(--border); color: var(--muted); font-size: 12.5px; padding: 8px; border-radius: 10px; cursor: pointer; }+ .card .refetch-select { font-size: 11px; padding: 1px 4px; width: 28px; background: var(--panel); color: var(--text); border: 1px solid var(--border); color-scheme: dark; overflow: hidden; text-overflow: ellipsis; }+ .card .refetch-select option { background: var(--bg); color: var(--text); padding: 4px 8px; font-family: Satoshi, -apple-system, sans-serif; font-size: 12px; }+ /* authoritative footprint SVG (ds2sf v0.5.1+) */+ .card .auth-svg-thumb { position: relative; margin: 8px 0 4px; padding: 6px; background: #fff; border: 1px solid var(--border); border-radius: 6px; aspect-ratio: 2.4 / 1; display: flex; align-items: center; justify-content: center; overflow: hidden; }+ .card .auth-svg-thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }+ .card .auth-svg-thumb.auth-canonical { border-color: var(--green); box-shadow: 0 0 0 1px rgba(34,197,94,.3) inset; }+ .card .auth-svg-thumb.auth-synth { border-color: var(--amber); box-shadow: 0 0 0 1px rgba(245,158,11,.3) inset; }+ .card .auth-svg-thumb .auth-svg-label { position: absolute; bottom: 2px; right: 4px; font-size: 9px; color: rgba(0,0,0,0.55); font-family: 'JetBrains Mono', monospace; letter-spacing: .05em; pointer-events: none; }+ /* Auto-zoom toggle in toolbar */+ .auto-zoom-toggle { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--muted); cursor: pointer; margin-left: 8px; user-select: none; }+ .auto-zoom-toggle input { accent-color: var(--accent); }+ /* 6-orientation popover (button lives inside .thumb-3d-group) */+ .card .orient-popover { position: absolute; top: 100%; left: 50%; transform: translateX(-50%); z-index: 50; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 8px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); }+ /* symbol/footprint provenance carat — always-visible tappable source chip */+ .card .thumb-src-group { position: relative; display: flex; }+ .card .thumb-src-group .thumb { flex: 1; }+ .card .src-chip { position: absolute; top: 4px; left: 4px; z-index: 4; display: inline-flex; align-items: center; gap: 4px;+ font-size: 8.5px; font-weight: 700; letter-spacing: .02em; color: #d7efed; background: rgba(13,17,23,.82); backdrop-filter: blur(4px);+ border: 1px solid rgba(0,184,177,.45); border-radius: 5px; padding: 2px 5px; cursor: pointer; max-width: 88%;+ transition: border-color .12s, background .12s; -webkit-tap-highlight-color: transparent; }+ .card .src-chip:hover, .card .src-chip:active { border-color: var(--accent); background: rgba(0,184,177,.16); }+ .card .src-chip-lab { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }+ .card .src-chip-car { color: var(--muted); font-size: 8px; }+ .card .src-popover { position: absolute; top: 24px; left: 4px; z-index: 30;+ background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 6px; min-width: 168px;+ display: flex; flex-direction: column; gap: 4px; box-shadow: 0 10px 28px rgba(0,0,0,.55); }+ .card .src-opt { cursor: pointer; display: flex; align-items: center; gap: 8px; border: 1px solid var(--border-muted); border-radius: 6px;+ padding: 5px 7px; font-size: 11px; color: var(--text); transition: border-color .12s, background .12s; white-space: nowrap; }+ .card .src-opt:hover, .card .src-opt:active { border-color: var(--accent); background: rgba(0,184,177,.08); }+ .card .src-opt.chosen { border-color: var(--green); box-shadow: 0 0 0 1px var(--green) inset; }+ .card .src-opt-thumb { width: 30px; height: 30px; object-fit: contain; background: #0d1117; border-radius: 4px; flex: 0 0 30px; }+ .card .src-opt-lab { flex: 1; }+ .card .src-cur { color: var(--green); font-size: 9px; }+ .card .src-dot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 9px; }+ .card .src-dot.vendor { background: #00b8b1; }+ .card .src-dot.ds { background: #d29922; }+ .card .orient-opt { cursor: pointer; border: 1px solid var(--border-muted); border-radius: 6px; padding: 4px; background: #404B55; text-align: center; transition: border-color .12s; }+ .card .orient-opt:hover { border-color: var(--accent); }+ .card .orient-opt.chosen { border-color: var(--green); box-shadow: 0 0 0 1px var(--green) inset; }+ .card .orient-opt img { width: 48px; height: 48px; object-fit: contain; display: block; margin: 0 auto 2px; }+ .card .orient-opt span { font-size: 9px; color: var(--muted); font-family: 'JetBrains Mono', monospace; }+ aside { background: var(--panel); border-left: 1px solid var(--border); padding: 20px; overflow-y: auto; }+ aside.empty { display: flex; align-items: center; justify-content: center; color: var(--muted); }+ /* Tabbed detail panel — keeps the 66-file list from dominating. */+ .dtabs { display: flex; gap: 4px; margin: 14px 0 12px; border-bottom: 1px solid var(--border); }+ .dtab { background: none; border: none; border-bottom: 2px solid transparent; color: var(--muted); font-size: 13px; font-weight: 600; padding: 7px 10px; cursor: pointer; margin-bottom: -1px; display: inline-flex; align-items: center; gap: 5px; }+ .dtab:hover { color: var(--text, #e6edf3); }+ .dtab.on { color: var(--accent, #00b8b1); border-bottom-color: var(--accent, #00b8b1); }+ .dtab .dtab-n { font-size: 10px; background: var(--panel2, #0f141c); border: 1px solid var(--border); border-radius: 8px; padding: 0 5px; color: var(--muted); }+ .dpanel { display: none; }+ .dpanel.on { display: block; }+ .filelist { margin: 0; padding: 0; list-style: none; max-height: calc(100vh - 220px); overflow-y: auto; }+ .filelist li { padding: 3px 0; border-bottom: 1px solid rgba(255,255,255,.04); }+ .filelist a { color: var(--accent); text-decoration: none; font-size: 11.5px; word-break: break-all; }+ aside h2 { margin: 0 0 8px; font-size: 18px; }+ aside .vendor { color: var(--muted); margin-bottom: 16px; }+ aside .section { margin-bottom: 18px; }+ aside .section-title { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px; }+ aside ul { margin: 0; padding-left: 0; list-style: none; }+ aside ul li { padding: 4px 0; border-bottom: 1px solid var(--border); }+ aside ul li:last-child { border-bottom: none; }+ aside a { color: var(--accent); text-decoration: none; }+ aside a:hover { text-decoration: underline; }+ aside pre { background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 8px; font-size: 11px; color: var(--muted); overflow-x: auto; max-height: 300px; }+ aside .fields { display: flex; flex-direction: column; gap: 0; }+ aside .field { display: grid; grid-template-columns: 130px 1fr; gap: 10px; padding: 5px 0; border-bottom: 1px solid var(--border-muted); font-size: 12px; align-items: baseline; }+ aside .field:last-child { border-bottom: none; }+ aside .field-key { color: var(--text2); font-family: 'JetBrains Mono', monospace; font-size: 10px; text-transform: lowercase; letter-spacing: 0.02em; }+ aside .field-val { color: var(--text); font-family: 'Satoshi', sans-serif; word-break: break-word; }+ aside .field-val a { color: var(--accent); }+ aside .field.unknown .field-val { color: var(--text3); font-style: italic; font-family: 'JetBrains Mono', monospace; font-size: 11px; }+ .empty-state { color: var(--muted); padding: 40px; text-align: center; }+ .card .card-desc { color: var(--text2); font-size: 11px; line-height: 1.35; margin: 4px 0 2px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }+ .out-of-filter-note { grid-column: 1 / -1; background: rgba(230,180,80,.10); border: 1px solid rgba(230,180,80,.35); color: var(--text2); border-radius: 6px; padding: 8px 12px; font-size: 12px; display: flex; align-items: center; gap: 10px; }+ .out-of-filter-note b { color: var(--accent); font-family: 'JetBrains Mono', monospace; }+ .out-of-filter-note .ofn-clear { background: var(--accent); color: #fff; border: none; border-radius: 4px; padding: 3px 10px; font-size: 11px; cursor: pointer; margin-left: auto; }+ .out-of-filter-note .ofn-clear:hover { filter: brightness(1.1); }+ .activity { display: none; padding: 10px 24px; background: rgba(0,184,176,.10); border-bottom: 1px solid var(--accent); font-size: 13px; color: var(--text); align-items: center; gap: 12px; }+ .activity.live { display: flex; }+ /* Stale: the last heartbeat is old enough that no work is actually+ happening. Show it muted with the age, instead of a permanent WORKING. */+ .activity.stale { display: flex; background: rgba(120,130,140,.07); border-bottom-color: var(--border); }+ .activity.stale .dot { background: var(--text3); animation: none; }+ .activity.stale .mpn-tag { color: var(--text3); }+ .activity .dot { width: 10px; height: 10px; border-radius: 50%; background: var(--accent); animation: pulse 1.2s ease-in-out infinite; }+ .activity .label { color: var(--muted); }+ .activity .mpn-tag { font-family: "JetBrains Mono", monospace; font-weight: 600; color: var(--accent); }+ .activity .stage { color: var(--muted); }+ @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } }+ .card.active { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(0,184,176,.45); animation: cardpulse 1.2s ease-in-out infinite; position: relative; }+ .card.active::after { content: "WORKING"; position: absolute; top: -8px; right: 8px; background: var(--accent); color: white; font-size: 9px; padding: 2px 6px; border-radius: 3px; letter-spacing: .5px; font-weight: 700; }+ @keyframes cardpulse { 0%,100% { box-shadow: 0 0 0 3px rgba(0,184,176,.45); } 50% { box-shadow: 0 0 0 6px rgba(0,184,176,.20); } }+ /* --- Project tabs --- */+ .project-tabs { display: flex; flex-wrap: wrap; }+ .project-tab {+ font: 500 11px/1 'JetBrains Mono', monospace;+ padding: 4px 10px; border-radius: 4px; cursor: pointer;+ background: var(--surface); border: 1px solid var(--border-muted);+ color: var(--text2); transition: all 0.15s;+ }+ .project-tab:hover { background: var(--elevated); color: var(--text); }+ .project-tab.active { background: var(--accent); color: #fff; border-color: var(--accent); }+ .project-tab .count { opacity: 0.7; margin-left: 4px; }+</style>+</head>+<body>+<header>+ <div class="brand" data-tooltip-target="app-info" tabindex="0">+ <svg class="logo" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">+ <rect x="6" y="6" width="12" height="12" rx="1.2"/>+ <rect x="9" y="2" width="2" height="2"/><rect x="13" y="2" width="2" height="2"/>+ <rect x="9" y="20" width="2" height="2"/><rect x="13" y="20" width="2" height="2"/>+ <rect x="2" y="9" width="2" height="2"/><rect x="2" y="13" width="2" height="2"/>+ <rect x="20" y="9" width="2" height="2"/><rect x="20" y="13" width="2" height="2"/>+ </svg>+ <span class="app-name">adom-chip-fetcher</span>+ <span class="sep">·</span>+ <span class="app-subtitle">manufacturer CAD bundles</span>+ </div>++ <div class="vsep" aria-hidden="true"></div>++ <div class="subject">+ <span class="subject-name idle" id="subjectName">—</span>+ <span class="state-pill" id="subjectPill"></span>+ <span class="subject-meta" id="subjectMeta">+ <span class="sep">·</span><span id="metaFull">0 full</span>+ <span class="sep">·</span><span id="metaStubs">0 stubs</span>+ <span class="sep">·</span><span id="metaSize">0 MB</span>+ </span>+ </div>++ <div class="actions"></div>++ <!-- App-info popover (interactive tooltip — stays open on hover) -->+ <div class="hover-popover" id="app-info" role="dialog" aria-label="App information">+ <div class="popover-title">adom-chip-fetcher</div>+ <div class="popover-row"><span class="k">about</span>+ <span class="v">Download manufacturer-grade chip CAD bundles — STEP 3D + Altium IntLib + Fusion 360 .lbr + KiCad symbol/footprint + datasheet — into a structured library by driving Chrome via pup. Manufacturer-first sourcing ladder, then SnapEDA / Mouser / DigiKey / Ultra Librarian / Component Search Engine.</span></div>+ <div class="popover-row"><span class="k">version</span><span class="v" id="popVersion">v0.1.2</span></div>+ <div class="popover-row"><span class="k">built</span><span class="v" id="popBuilt">2026-05-03</span></div>+ <div class="popover-row"><span class="k">author</span><span class="v">Adom Inc · adom.inc</span></div>+ <div class="popover-actions">+ <a class="popover-link" href="https://wiki-ufypy5dpx93o.adom.cloud/apps/chip-fetcher" target="_blank" rel="noopener">Open wiki ↗</a>+ <a class="popover-link" href="https://github.com/adom-inc/chip-fetcher" target="_blank" rel="noopener">GitHub ↗</a>+ </div>+ </div>+</header>++<div class="toolbar" role="toolbar" aria-label="Library filters">+ <input type="search" id="q" placeholder="search MPN, vendor, package…">+ <select id="vendor"><option value="">all vendors</option></select>+ <select id="status">+ <option value="">all status</option>+ <option value="full">fully-bundled</option>+ <option value="stub">stubs only</option>+ <option value="no-info">missing info.json</option>+ <option value="no-pdf">missing datasheet</option>+ </select>+ <button id="btn-validate" class="spacer" data-tooltip="Full self-audit: a ✓/✗ matrix of EVERY required file for EVERY chip (datasheet, STEP, symbol, footprint, and the 3 card thumbnails). Goes green ONLY when truly complete — so 'done' can't be faked.">✓ Validation</button>+ <button id="btn-sources" data-tooltip="The exact priority order chip-fetcher uses to search the entire internet for electronics data: manufacturer site first, then the Adom data tools (parts-search / Mouser / DigiKey / JLCPCB), then the manual CAD-crawl skills (SnapMagic / CSE / UltraLibrarian), with LCSC dead last.">⛓ Sourcing order</button>+ <button id="btn-creds" data-tooltip="Manage saved credentials for vendor portals (Component Search Engine, etc.).\nUsed to authenticate downloads from SnapEDA / Ultra Librarian / DigiKey when a chip needs a login.">Credentials</button>+ <button id="btn-reveal-lib" data-tooltip="Reveal the chip-fetcher library folder in the VS Code Explorer sidebar.\nOpens ~/project/chip-fetcher/library/ so you can see every chip directory."><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-2px;margin-right:4px"><path d="M3 7.5a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7.5z"/></svg>Library folder</button>+ <label class="auto-zoom-toggle" data-tooltip="When ON, the active chip's card auto-zooms to the foreground whenever the AI moves to a new chip.\nLets you watch live progress at readable size without clicking each card."><input type="checkbox" id="auto-zoom-cb" checked> auto-zoom</label>+</div>++<div class="project-tabs" id="project-tabs" style="display:none;padding:0 16px 6px;gap:6px;align-items:center">+ <span style="font-size:11px;color:var(--text2);margin-right:4px">project:</span>+</div>++<div class="activity" id="activity-bar">+ <span class="dot"></span>+ <span class="label">working on</span>+ <span class="mpn-tag" id="activity-mpn"></span>+ <span class="stage" id="activity-stage"></span>+</div>++<div class="modal-overlay" id="creds-modal">+ <div class="modal">+ <h3>Saved credentials</h3>+ <div class="helper">+ Stored at <code>~/.config/chip-fetcher/credentials.json</code> (chmod 600 — only readable by your container user). chip-fetcher rewrites outbound URLs to <code>https://user:pass@host/…</code> so vendor portals like Component Search Engine never show a basic-auth popup.+ <br><br>+ Passwords are masked by default — click the dots to reveal or hide. The vault is plaintext on disk (vendor-portal creds aren't bank logins) but the UI keeps them off-screen so a casual screenshot doesn't leak them.+ </div>+ <div class="creds-list" id="creds-list"><div class="info">loading…</div></div>++ <h3 style="margin-top:24px">Add / update</h3>+ <label>Host pattern (glob)</label>+ <input id="cred-host" placeholder="*.componentsearchengine.com" value="*.componentsearchengine.com">+ <label>Username</label>+ <input id="cred-user" placeholder="[email protected]">+ <label>Password</label>+ <input id="cred-pass" type="password" placeholder="password">++ <div class="buttons">+ <button class="btn-secondary" id="cred-close">Close</button>+ <button class="btn-primary" id="cred-save">Save credentials</button>+ </div>+ <div id="cred-msg"></div>+ </div>+</div>+<main>+ <div class="grid" id="grid-view">+ <div class="cards" id="cards"><div class="empty-state">loading library…</div></div>+ </div>+ <div id="validation-view" style="display:none;grid-column:1/-1;padding:18px 20px;overflow:auto"></div>+ <div id="sources-view" style="display:none;grid-column:1/-1;padding:18px 24px;overflow:auto"></div>+ <aside class="empty" id="detail">select a chip to see details</aside>+</main>+<script>+let LIB = null;+let SELECTED = null;+let DETAIL_TAB = 'actions'; // which detail-panel tab is open (Actions | Details | Files)+// Per-chip+kind source the user picked in the provenance carat (e.g. "ultralibrarian").+// Keyed "mpn|kind". Persisted so the choice sticks across reloads.+let SRC_SEL = (() => { try { return JSON.parse(localStorage.getItem('cf_src_sel')) || {}; } catch { return {}; } })();+function saveSrcSel() { try { localStorage.setItem('cf_src_sel', JSON.stringify(SRC_SEL)); } catch {} }+// The source method to treat as active for a tile: the user's pick if it's still+// a valid source, else the backend canonical ("current").+function activeMethod(mpn, kind, sources) {+ const key = mpn + '|' + kind;+ const pick = SRC_SEL[key];+ if (pick && sources.some(s => s.method === pick)) return pick;+ const cur = sources.find(s => s.current);+ return cur ? cur.method : (sources[0] && sources[0].method);+}+let ACTIVE_MPN = null;+let ACTIVE_STAGE = '';+let LAST_LIB_HASH = '';+let PROJECTS = null;+let ACTIVE_PROJECT_FILTER = null; // null = show all, string = filter to project++// --- Interactive hover-popover (600 ms show-delay, 220 ms hide-grace) ---+// per adom-app-header §Reference JS + human-ui-patterns §1b (600 ms).+// Hover-intent prevents fly-by triggers; the hide-grace lets the user+// move from the brand block into the popover to click the wiki link.+(function(){+ const SHOW_DELAY = 600; // human-ui-patterns §1b+ const HIDE_GRACE = 220;+ document.querySelectorAll('header [data-tooltip-target]').forEach(trigger => {+ const target = document.getElementById(trigger.dataset.tooltipTarget);+ if (!target) return;+ let showTimer = null, hideTimer = null;+ const show = () => {+ clearTimeout(hideTimer);+ if (target.classList.contains('show')) return;+ clearTimeout(showTimer);+ showTimer = setTimeout(() => target.classList.add('show'), SHOW_DELAY);+ };+ const queueHide = () => {+ clearTimeout(showTimer);+ clearTimeout(hideTimer);+ hideTimer = setTimeout(() => target.classList.remove('show'), HIDE_GRACE);+ };+ trigger.addEventListener('mouseenter', show);+ trigger.addEventListener('mouseleave', queueHide);+ trigger.addEventListener('focus', show);+ trigger.addEventListener('blur', queueHide);+ target.addEventListener('mouseenter', show);+ target.addEventListener('mouseleave', queueHide);+ trigger.addEventListener('click', e => {+ clearTimeout(showTimer);+ target.classList.toggle('show');+ e.stopPropagation();+ });+ document.addEventListener('click', e => {+ if (!target.contains(e.target) && !trigger.contains(e.target)) target.classList.remove('show');+ });+ });+})();++// Update the header subject zone — currently-fetched/selected MPN + state-pill+// + library-level meta counts. Called from pollActivity, load, and select so+// the header reflects "what we're doing right now" continuously.+function updateSubject() {+ const nameEl = document.getElementById('subjectName');+ const pillEl = document.getElementById('subjectPill');+ const focusMpn = ACTIVE_MPN || SELECTED || null;+ if (focusMpn) {+ nameEl.textContent = focusMpn;+ nameEl.classList.remove('idle');+ pillEl.classList.add('show');+ if (ACTIVE_MPN === focusMpn) {+ pillEl.textContent = 'FETCHING';+ pillEl.className = 'state-pill show fetching';+ pillEl.setAttribute('data-tooltip', ACTIVE_STAGE || 'fetching…');+ } else {+ const chip = LIB && LIB.chips.find(c => c.mpn === focusMpn);+ if (chip && chip.fully_bundled) {+ pillEl.textContent = 'FULL';+ pillEl.className = 'state-pill show full';+ pillEl.setAttribute('data-tooltip', 'Fully bundled — STEP 3D + schematic symbol + PCB footprint + datasheet are all present.');+ } else {+ pillEl.textContent = 'STUB';+ pillEl.className = 'state-pill show stub';+ pillEl.setAttribute('data-tooltip', chip ? 'Missing one or more files (STEP / symbol / footprint / datasheet). Use the ⤓ menu to fetch the rest.' : 'unknown');+ }+ }+ } else {+ nameEl.textContent = '—';+ nameEl.classList.add('idle');+ pillEl.classList.remove('show');+ pillEl.removeAttribute('title');+ }+ if (LIB) {+ document.getElementById('metaFull').textContent = `${LIB.fully_count} full`;+ document.getElementById('metaStubs').textContent = `${LIB.stub_count} stubs`;+ document.getElementById('metaSize').textContent = `${(LIB.total_size/1024/1024).toFixed(0)} MB`;+ }+}++// Use relative paths so this works behind /proxy/<port>/ tunnels too.+const API_BASE = (() => {+ const p = location.pathname;+ // strip trailing index.html and trailing slash+ return p.replace(/index\.html$/, '').replace(/\/$/, '');+})();++// A heartbeat older than this many seconds is treated as "not actively+// working" — heartbeats are write-only stage labels with no terminal state,+// so without an age check the last one ("fetching X") sticks forever.+const ACTIVITY_STALE_SECS = 25;++function agoLabel(s) {+ if (s == null) return '';+ if (s < 60) return s + 's ago';+ if (s < 3600) return Math.floor(s / 60) + 'm ago';+ return Math.floor(s / 3600) + 'h ago';+}++// Re-fetch the library and re-render iff the meaningful per-chip state+// changed. Returns true if it re-rendered. Called from pollActivity AND a+// steady timer, so card bodies (files, thumbnails, validation) stay fresh+// even when nothing is actively fetching — previously the library was only+// re-polled while a heartbeat was live, so idle cards went stale until reload.+async function refreshLibraryIfChanged() {+ try {+ const libR = await fetch(API_BASE + '/api/library');+ const newLib = await libR.json();+ const hash = JSON.stringify(newLib.chips.map(c => [c.mpn, c.size_bytes, c.has_step, c.has_sym, c.has_mod, c.pdf_count, c.has_thumb_symbol, c.has_thumb_footprint, c.has_thumb_chip3d, c.validation_status]));+ if (hash !== LAST_LIB_HASH) {+ LIB = newLib;+ LAST_LIB_HASH = hash;+ render();+ updateSubject();+ renderProjectTabs();+ if (ACTIVE_MPN) { scrollActiveCardIntoView(); autoZoomIfEnabled(ACTIVE_MPN); }+ return true;+ }+ } catch (e) { /* transient */ }+ return false;+}++// Poll /api/activity every 1s. Drives the banner + the WORKING card highlight.+async function pollActivity() {+ try {+ const r = await fetch(API_BASE + '/api/activity');+ const a = await r.json();+ const mpn = a && a.mpn ? a.mpn : null;+ const stage = a && a.stage ? a.stage : '';+ const age = (a && typeof a._age_seconds === 'number') ? a._age_seconds : null;+ const bar = document.getElementById('activity-bar');+ const mpnEl = document.getElementById('activity-mpn');+ const stEl = document.getElementById('activity-stage');+ const terminalStage = !stage || stage === 'idle' || stage === 'done' || stage === 'thumbnails ready';+ const stale = age != null && age > ACTIVITY_STALE_SECS;+ const working = mpn && !terminalStage && !stale;++ if (working) {+ bar.classList.add('live');+ bar.classList.remove('stale');+ mpnEl.textContent = mpn;+ stEl.textContent = stage ? '— ' + stage : '';+ const stageChanged = stage !== ACTIVE_STAGE;+ ACTIVE_STAGE = stage;+ if (mpn !== ACTIVE_MPN) {+ ACTIVE_MPN = mpn;+ render();+ updateSubject();+ scrollActiveCardIntoView();+ autoZoomIfEnabled(mpn);+ } else if (stageChanged) {+ updateSubject();+ }+ await refreshLibraryIfChanged();+ } else {+ // Clear the WORKING highlight the moment work stops/goes stale.+ if (ACTIVE_MPN !== null) {+ ACTIVE_MPN = null;+ ACTIVE_STAGE = '';+ render();+ updateSubject();+ }+ // Don't lie with a permanent green WORKING bar. If there's a stale+ // last-heartbeat, show it muted with its age; otherwise hide the bar.+ if (mpn && stale && stage) {+ bar.classList.remove('live');+ bar.classList.add('stale');+ mpnEl.textContent = mpn;+ stEl.textContent = '— idle · last: ' + stage + (age != null ? ' · ' + agoLabel(age) : '');+ } else {+ bar.classList.remove('live', 'stale');+ }+ }+ } catch (e) { /* ignore transient failures */ }+}+setInterval(pollActivity, 1000);+// Keep card bodies live even when idle (no active heartbeat).+setInterval(refreshLibraryIfChanged, 4000);++async function load() {+ const r = await fetch(API_BASE + '/api/library');+ LIB = await r.json();+ updateSubject();+ const vendors = [...new Set(LIB.chips.map(c => c.vendor))].sort();+ const vsel = document.getElementById('vendor');+ for (const v of vendors) {+ const o = document.createElement('option'); o.value = v; o.textContent = v;+ vsel.appendChild(o);+ }+ render();+ loadProjects();+}++async function loadProjects() {+ try {+ const r = await fetch(API_BASE + '/api/projects');+ if (!r.ok) return;+ PROJECTS = await r.json();+ // On first load, honor the server's active project so the dashboard opens+ // already filtered (set via `chip-fetcher project activate <name>`), instead+ // of dumping the full unfiltered library. The user can still tap "All".+ if (!window._projInit) {+ window._projInit = true;+ if (PROJECTS && PROJECTS.active && PROJECTS.projects && PROJECTS.projects[PROJECTS.active]) {+ ACTIVE_PROJECT_FILTER = PROJECTS.active;+ }+ }+ renderProjectTabs();+ applyDeepLink();+ } catch {}+}++// Deep links: ?project=<name>&chip=<MPN>&source=<method>&zoom=1 open the+// dashboard already filtered to the project, the chip selected + scrolled into+// view (with an optional source preselected), and optionally zoomed — so a+// shared link lands exactly on what to look at.+function applyDeepLink() {+ if (window._deepLinkDone || !LIB) return;+ window._deepLinkDone = true;+ const p = new URLSearchParams(location.search);+ const proj = p.get('project'), chip = p.get('chip'), source = p.get('source');+ const zoom = p.get('zoom'); const wantZoom = zoom && zoom !== '0' && zoom !== 'false';+ if (!proj && !chip) return;+ if (proj && PROJECTS && PROJECTS.projects && PROJECTS.projects[proj]) {+ ACTIVE_PROJECT_FILTER = proj; renderProjectTabs();+ }+ if (source && chip) {+ SRC_SEL[chip + '|symbol'] = source; SRC_SEL[chip + '|footprint'] = source; saveSrcSel();+ }+ render();+ if (chip) {+ setTimeout(() => {+ select(chip);+ const card = document.querySelector(`.card[data-mpn="${CSS.escape(chip)}"]`);+ if (card) {+ card.scrollIntoView({ behavior: 'smooth', block: 'center' });+ if (wantZoom) setTimeout(() => zoomCard(card), 380);+ }+ }, 160);+ }+}++function renderProjectTabs() {+ const bar = document.getElementById('project-tabs');+ if (!PROJECTS || !PROJECTS.projects || Object.keys(PROJECTS.projects).length === 0) {+ bar.style.display = 'none';+ return;+ }+ bar.style.display = 'flex';+ const label = bar.querySelector('span');+ bar.innerHTML = '';+ bar.appendChild(label);++ // "All" tab+ const allTab = document.createElement('div');+ allTab.className = 'project-tab' + (ACTIVE_PROJECT_FILTER === null ? ' active' : '');+ allTab.innerHTML = `Library<span class="count">${LIB ? LIB.chips.length : 0}</span>`;+ allTab.onclick = () => { ACTIVE_PROJECT_FILTER = null; renderProjectTabs(); render(); };+ bar.appendChild(allTab);++ // Per-project tabs+ for (const [name, proj] of Object.entries(PROJECTS.projects)) {+ const tab = document.createElement('div');+ tab.className = 'project-tab' + (ACTIVE_PROJECT_FILTER === name ? ' active' : '');+ tab.innerHTML = `${name}<span class="count">${proj.mpns.length}</span>`;+ tab.onclick = () => { ACTIVE_PROJECT_FILTER = name; renderProjectTabs(); render(); };+ bar.appendChild(tab);+ }++ // NOTE: We deliberately do NOT auto-apply the server's persisted "active"+ // project here. That field survives across sessions, so auto-narrowing the+ // grid to it silently hid freshly-fetched chips behind a stale filter from+ // weeks-old, unrelated work. The dashboard always opens on the full Library;+ // the user clicks a project tab to filter. (PROJECTS.active is still shown+ // highlighted if you want to know what the CLI considers active.)+}++function srcTier(src) {+ if (src.startsWith('mfr-direct')) return 'mfr';+ if (src.startsWith('mfr-')) return 'mfr';+ if (src.startsWith('mouser') || src.startsWith('digikey') || src.startsWith('ul-')) return 'fb';+ return 'unk';+}++function srcShort(src) {+ if (src === '—' || !src) return '?';+ return src.replace('-fallback','').replace('mouser-','mouser/').replace('mfr-','');+}++function render() {+ const q = document.getElementById('q').value.toLowerCase();+ const v = document.getElementById('vendor').value;+ const s = document.getElementById('status').value;+ const cards = document.getElementById('cards');+ cards.innerHTML = '';+ const filtered = LIB.chips.filter(c => {+ if (ACTIVE_PROJECT_FILTER && PROJECTS) {+ const proj = PROJECTS.projects[ACTIVE_PROJECT_FILTER];+ if (proj && !proj.mpns.includes(c.mpn)) return false;+ }+ if (q && !(c.mpn + ' ' + c.vendor + ' ' + c.package).toLowerCase().includes(q)) return false;+ if (v && c.vendor !== v) return false;+ if (s === 'full' && !c.fully_bundled) return false;+ if (s === 'stub' && c.fully_bundled) return false;+ if (s === 'no-info' && c.has_info) return false;+ if (s === 'no-pdf' && c.has_pdf) return false;+ return true;+ });+ if (filtered.length === 0) {+ cards.innerHTML = '<div class="empty-state">no chips match filters</div>';+ return;+ }+ // If there's an active chip that isn't in the filtered set, still surface it+ // (so the user always sees what's being worked on) — but make it OBVIOUS+ // it's outside the current filter, with a one-click way to clear, instead of+ // silently injecting a card that contradicts the grid behind it.+ let outOfFilterMpn = null;+ if (ACTIVE_MPN && !filtered.find(c => c.mpn === ACTIVE_MPN)) {+ const activeChip = LIB.chips.find(c => c.mpn === ACTIVE_MPN);+ if (activeChip) { filtered.unshift(activeChip); outOfFilterMpn = ACTIVE_MPN; }+ }+ if (outOfFilterMpn) {+ const where = ACTIVE_PROJECT_FILTER ? ('project “' + ACTIVE_PROJECT_FILTER + '”') : 'the current filter';+ const note = document.createElement('div');+ note.className = 'out-of-filter-note';+ note.innerHTML = `Showing <b>${escapeHtml(outOfFilterMpn)}</b> — not in ${escapeHtml(where)}. <button class="ofn-clear" type="button">Clear filter</button>`;+ note.querySelector('.ofn-clear').onclick = () => {+ ACTIVE_PROJECT_FILTER = null;+ document.getElementById('q').value = '';+ document.getElementById('vendor').value = '';+ document.getElementById('status').value = '';+ renderProjectTabs();+ render();+ };+ cards.appendChild(note);+ }+ for (const c of filtered) {+ const card = document.createElement('div');+ const cls = ['card'];+ if (SELECTED === c.mpn) cls.push('selected');+ if (ACTIVE_MPN === c.mpn) cls.push('active');+ card.className = cls.join(' ');+ card.dataset.mpn = c.mpn;+ const tier = srcTier(c.step_source);+ const m = (c.stock && c.stock.mouser) || null;+ // Always show stock + price badges. "?" when we don't have data.+ let stockBadge = '<span class="b stock-unk" data-tooltip="Stock level unknown.\nMouser stock data hasn\'t been fetched yet for this chip. Run a fetch to populate stock.json with current inventory + pricing.">stock ?</span>';+ let priceBadge = '<span class="b price" data-tooltip="Unit price unknown.\nMouser pricing hasn\'t been fetched yet. Run a fetch to get price breaks from Mouser\'s API.">$?</span>';+ let lifecycleBadge = '<span class="b lifecycle-unk" data-tooltip="Lifecycle status unknown.\nMouser hasn\'t been queried for this chip\'s lifecycle (Active / NRND / EOL / New). Run a fetch to populate.">lifecycle ?</span>';+ if (m) {+ const s = m.stock || 0;+ const cls = s >= 1000 ? 'stock-yes' : s > 0 ? 'stock-low' : 'stock-no';+ const label = s > 0 ? (s >= 1000 ? `${(s/1000).toFixed(s>=10000?0:1)}k in stock` : `${s} in stock`) : 'no stock';+ stockBadge = `<span class="b ${cls}" data-tooltip="Mouser stock: ${s} units.\n${s>=1000?'Well stocked — safe for production quantities.':s>0?'Low stock — check lead time before committing.':'Out of stock at Mouser. Check DigiKey or JLCPCB as alternatives.'}">${label}</span>`;+ priceBadge = m.unit_price+ ? `<span class="b price" data-tooltip="Mouser unit price: ${escapeHtml(m.unit_price)}.\nPrice at qty 1. Click the Mouser link in the chip detail panel for price breaks at higher quantities.">${escapeHtml(m.unit_price)}</span>`+ : '<span class="b price" data-tooltip="Price not available from Mouser for this chip.">$?</span>';+ const lc = (m.lifecycle || '').toLowerCase();+ if (/end of life|obsolete|discontinued|last.?time/.test(lc)) {+ lifecycleBadge = `<span class="b eol" data-tooltip="End of Life (${escapeHtml(m.lifecycle)}).\nThis chip is discontinued. Do NOT use in new designs — it will become unavailable. chip-fetcher will suggest active replacements if available.">EOL — DON'T USE</span>`;+ } else if (/not recommended|nrnd|phased.?out|mature/.test(lc)) {+ lifecycleBadge = `<span class="b nrnd" data-tooltip="Not Recommended for New Designs (${escapeHtml(m.lifecycle)}).\nStill available but the manufacturer is phasing it out. Use an active replacement for new designs; OK for existing boards.">NRND — AVOID FOR NEW DESIGNS</span>`;+ } else if (/new product|new release/.test(lc)) {+ lifecycleBadge = `<span class="b new" data-tooltip="New product (${escapeHtml(m.lifecycle)}).\nRecently released by the manufacturer. Stock may be limited initially but supply is ramping.">NEW</span>`;+ } else if (lc) {+ lifecycleBadge = '';+ } else {+ lifecycleBadge = '<span class="b lifecycle-unk" data-tooltip="Lifecycle field empty in Mouser\'s response.\nThe chip is listed but Mouser didn\'t report Active/NRND/EOL status. Usually means Active.">lifecycle ?</span>';+ }+ }+ const pkgDisplay = (c.package && c.package !== '?' && c.package !== '—') ? c.package : '?';+ const sizeDisplay = c.size_bytes > 0 ? `${(c.size_bytes/1024/1024).toFixed(1)} MB` : '? MB';+ // Per-EDA-tool badges. Each tool gets one badge:+ // green = both symbol + footprint present+ // amber = partial (e.g. only schematic, or only footprint)+ // grey = missing entirely (shows "?")+ const ked = badgeForEda('KICAD', c.has_sym, c.has_mod, c.info, c.file_provenance, c.mpn);+ const alt = badgeForEda('ALTIUM', c.has_altium_sch, c.has_altium_pcb, c.info, c.file_provenance, c.mpn);+ const fus = badgeForEda('FUSION', c.has_fusion_lbr, c.has_fusion_lbr, c.info, c.file_provenance, c.mpn); // Fusion ships sym+mod in one .lbr+ // Validation badge (concur status). Read straight from inventory.rs's+ // validation_status field which we read from <MPN>-validation-status.json.+ let valBadge = '';+ if (c.validation_status === 'golden') {+ valBadge = `<span class="b val-golden" data-tooltip="Golden dataset — every source (ds2sf from datasheet, KiCad UL bundle, EAGLE, Altium) agrees on pin count, pad geometry, pin names, and package family. Safe for downstream use.">✓ GOLDEN</span>`;+ } else if (c.validation_status === 'golden_with_variants') {+ const vs = (c.validation_variants || []).map(v => v.replace(/_/g, ' ')).join(', ');+ valBadge = `<span class="b val-golden-v" data-tooltip="Golden with explainable variants.\nSources disagree on some axes, but every disagreement is benign:\n${escapeHtml(vs)}\nSafe for downstream — the variant list tells tools which source to prefer per axis.">✓ GOLDEN+${(c.validation_variants||[]).length}v</span>`;+ } else if (c.validation_status === 'divergent') {+ valBadge = `<span class="b val-divergent" data-tooltip="Divergent — at least one source disagrees and the disagreement is NOT explained by a known variant.\nClick ⟲ to re-validate, or open the concur result.json for the specific conflict axis + suggested refetch sources.">⚠ DIVERGENT</span>`;+ } else if (c.validation_status === 'unrecoverable') {+ valBadge = `<span class="b val-bad" data-tooltip="Unrecoverable — concur could not parse enough sources to run the comparison.\nCheck the chip directory for missing or corrupt files.">✗ FAIL</span>`;+ } else {+ valBadge = `<span class="b val-none" data-tooltip="Not yet validated.\nClick ⟲ to run ds2sf (extracts symbol + footprint from the datasheet PDF) then concur (cross-checks against KiCad/EAGLE/Altium). Takes 30-60s per chip.">— UNVAL</span>`;+ }+ // NOTE: the datasheet-derived ("authoritative") footprint is NOT a separate+ // tile anymore — it lives as the `ds2sf · datasheet` source on the FP tile's+ // provenance carat (one tap to compare against the vendor footprint). A big+ // white SVG tile here was redundant with the FP tile and broke dark mode.+ card.innerHTML = `+ <div class="mpn-row">+ <div class="mpn">${escapeHtml(c.mpn)}</div>+ <div class="card-actions">+ <button class="card-btn" data-tooltip="Re-validate this chip.\nRuns ds2sf (extracts symbol + footprint from datasheet PDF) then concur (cross-checks against KiCad/EAGLE/Altium libs). Updates the validation badge on this card when done." data-action="validate" data-mpn="${escapeHtml(c.mpn)}" aria-label="Re-validate">⟲</button>+ <button class="card-btn" data-tooltip="Re-render thumbnails.\nRegenerates the symbol SVG, footprint SVG, and all 6 orientation 3D PNGs from the current library files. Useful after importing a new STEP or .kicad_sym." data-action="thumbnails" data-mpn="${escapeHtml(c.mpn)}" aria-label="Re-render thumbnails">🖼</button>+ <select class="card-btn refetch-select" data-tooltip="Re-fetch a specific file from a different source.\nPick what you need and where to get it. Opens the source's product page in a browser window and watches for the download." data-mpn="${escapeHtml(c.mpn)}" aria-label="Refetch from source">+ <option value="">⤓ re-fetch…</option>+ <option value="manufacturer|kicad_sym">Manufacturer → Schematic symbol</option>+ <option value="manufacturer|kicad_mod">Manufacturer → PCB footprint</option>+ <option value="snapmagic|kicad_sym">SnapMagic → Schematic symbol</option>+ <option value="snapmagic|kicad_mod">SnapMagic → PCB footprint</option>+ <option value="mouser|kicad_sym">Mouser → Schematic symbol</option>+ <option value="digikey|kicad_sym">DigiKey → Schematic symbol</option>+ <option value="cse_ul|kicad_sym">Ultra Librarian → Schematic symbol</option>+ <option value="manufacturer|step">Manufacturer → 3D STEP model</option>+ <option value="manufacturer|pdf">Manufacturer → Datasheet PDF</option>+ </select>+ <button class="reveal-btn" data-tooltip="Open this chip's folder in VS Code Explorer.\nReveals library/${escapeHtml(c.mpn)}/ in the sidebar so you can see every file (STEP, KiCad sym/mod, datasheets, info.json, provenance logs)." data-reveal-mpn="${escapeHtml(c.mpn)}" aria-label="Reveal ${escapeHtml(c.mpn)} in VS Code Explorer"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7.5a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7.5z"/></svg></button>+ <button class="card-btn zoom-btn" data-tooltip="Zoom this chip card into a centered overlay for close inspection (tap the backdrop to close). On a phone this is the way to enlarge a card — double-tap is reserved by the browser for page zoom." data-zoom-mpn="${escapeHtml(c.mpn)}" aria-label="Zoom ${escapeHtml(c.mpn)} card"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="M21 21l-3.5-3.5M11 8v6M8 11h6"/></svg></button>+ </div>+ </div>+ <div class="meta">${escapeHtml(c.vendor || '?')} · ${escapeHtml(pkgDisplay)} · ${sizeDisplay}</div>+ ${(c.info && (c.info.summary || c.info.description)) ? `<div class="card-desc" data-tooltip="${escapeHtml(c.info.summary || c.info.description)}">${escapeHtml(c.info.summary || c.info.description)}</div>` : ''}+ <div class="thumbs">+ <!-- Primary row: the two artifacts you design with — biggest. -->+ <div class="thumb-row primary">+ ${renderThumb(c.mpn, 'symbol', c.has_thumb_symbol, 'SYM', null, thumbErr(c, 'sym'), c.symbol_sources)}+ ${renderThumb(c.mpn, 'footprint', c.has_thumb_footprint, 'FP', null, thumbErr(c, 'fp'), c.footprint_sources)}+ </div>+ <!-- Secondary row: 3D up-axis QA + the .lbr packaging action — smaller. -->+ <div class="thumb-row secondary">+ <div class="thumb-3d-group">+ ${renderThumb(c.mpn, 'chip3d', c.has_thumb_chip3d, 'Z↑', { upAxis: 'z', chosen: c.chosen_up_axis, inferred: c.inferred_up_axis }, thumbErr(c, '3d'))}+ ${renderThumb(c.mpn, 'chip3d-yup', c.has_thumb_chip3d_yup, 'Y↑', { upAxis: 'y', chosen: c.chosen_up_axis, inferred: c.inferred_up_axis }, thumbErr(c, '3d'))}+ <button class="orient-more-btn" data-tooltip="Pick from all 6 face-up orientations.\nZ↑ and Y↑ cover 98% of chips. For unusual STEP files (WAGO connectors, through-hole parts), try X↑, X↓, Y↓, or Z↓. The chosen orientation is saved to info.json for downstream tools." data-mpn="${escapeHtml(c.mpn)}">▾</button>+ <div class="orient-popover" data-mpn="${escapeHtml(c.mpn)}" style="display:none">+ ${['xup','xdown','ydown','zdown'].map(o => {+ const kind = 'chip3d-' + o;+ const label = o.replace('up','↑').replace('down','↓').toUpperCase().replace('X','X ').replace('Y','Y ').replace('Z','Z ');+ const url = API_BASE + '/thumb/' + encodeURIComponent(c.mpn) + '/' + kind + '?t=' + Date.now();+ const isChosen = c.chosen_up_axis === o;+ return `<div class="orient-opt${isChosen?' chosen':''}" data-mpn="${escapeHtml(c.mpn)}" data-axis="${o}" data-tooltip="Mark the ${label} 3D rendering as the correct up-axis for this chip.">+ <img src="${url}" onerror="this.parentElement.style.display='none'" />+ <span>${label}</span>+ </div>`;+ }).join('')}+ </div>+ </div>+ ${(c.has_sym || c.has_mod) ? `<div class="thumb thumb-lbr" data-tooltip="Build a true EAGLE .lbr (symbol + footprint) for ${escapeHtml(c.mpn)} and open it in the live adom-lbr app — lints it for Fusion 360 / Altium / OrCAD, then one-click Send to your desktop EDA tool." data-thumb-mpn="${escapeHtml(c.mpn)}" data-thumb-kind="lbr"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H9a1.5 1.5 0 0 1 1.5 1.5V20a1.5 1.5 0 0 0-1.5-1.5H5.5A1.5 1.5 0 0 1 4 17z"/><path d="M20 5.5A1.5 1.5 0 0 0 18.5 4H15a1.5 1.5 0 0 0-1.5 1.5V20a1.5 1.5 0 0 1 1.5-1.5h3.5a1.5 1.5 0 0 0 1.5-1.5z"/></svg><span class="thumb-label">.lbr</span></div>` : ''}+ </div>+ </div>+ <div class="badges">+ <span class="b ${c.fully_bundled?'full':'stub'}" data-tooltip="${c.fully_bundled?'Fully bundled — has STEP + KiCad symbol + KiCad footprint + Altium + Fusion .lbr.\nThis chip is ready for all downstream tools (chipsmith, InstaPCB, molecule creation).':'Stub — missing one or more core CAD files (STEP, KiCad sym/mod, Altium, Fusion).\nClick ⟲ to validate, or use the ⤓ dropdown to refetch missing files from a specific source.'}">${c.fully_bundled?'FULL':'STUB'}</span>+ ${valBadge}+ ${lifecycleBadge}+ ${stockBadge}+ ${priceBadge}+ <span class="b ${c.has_step?'step':'miss'}" data-tooltip="${c.has_step?'3D STEP model present.\nUsed by chipsmith for pin detection + footprint validation, and by chip-thumbnailer for the 3D orientation renders you see above.' + (c.step_source ? '\nSource: ' + c.step_source : '') + provenanceSummary(c.info, c.file_provenance, c.mpn):'3D STEP model missing.\nNeeded for chipsmith validation + 3D thumbnails.' + provenanceSummary(c.info, c.file_provenance, c.mpn)}">STEP${c.has_step?'':' ?'}</span>+ ${ked}+ ${alt}+ ${fus}+ <span class="b ${c.pdf_count>0?'pdf':'miss'}${c.has_pdf ? ' clickable' : ''}" data-tooltip="${c.pdf_count>0?c.pdf_count+' datasheet PDF'+(c.pdf_count>1?'s':'')+' in the chip directory.\nClick to open in the PDF viewer.':'No datasheet PDF yet.\nUse ⤓ → Manufacturer → Datasheet PDF to fetch it.'}" ${c.has_pdf ? `data-thumb-mpn="${escapeHtml(c.mpn)}" data-thumb-kind="datasheet"` : ''}>${c.pdf_count>0 ? c.pdf_count : '?'} PDF${c.pdf_count===1?'':'s'}</span>+ <span class="b src ${tier}" data-tooltip="STEP file source: ${escapeHtml(c.step_source)}.\n${c.step_source==='—'?'No STEP source recorded yet.':'This is where the 3D model came from. The sourcing ladder (Mfr → SnapMagic → Mouser → DigiKey → Arrow → CSE/UL → LCSC) determines priority.'}">${escapeHtml(srcShort(c.step_source))}</span>+ </div>+ `;+ card.onclick = (ev) => {+ const rb = ev.target.closest('.reveal-btn');+ if (rb) { ev.stopPropagation(); revealFolder(rb.dataset.revealMpn); return; }+ // Per-card action buttons (validate / thumbnails) — POST to the+ // matching /api/<action>/<MPN> endpoint, the server spawns+ // chip-fetcher as a subprocess. Dashboard auto-refreshes from+ // /api/library polling.+ const ab = ev.target.closest('.card-btn[data-action]');+ if (ab) {+ ev.stopPropagation();+ const action = ab.dataset.action;+ const mpn = ab.dataset.mpn;+ ab.disabled = true;+ ab.style.opacity = '0.5';+ fetch(API_BASE + '/api/' + action + '/' + encodeURIComponent(mpn), { method: 'POST' })+ .then(r => r.json())+ .then(j => {+ console.log('[' + action + '] kicked:', j);+ // The subprocess heartbeats; the dashboard's activity poller+ // will pick that up + render the WORKING badge.+ })+ .catch(e => console.error('[' + action + '] failed:', e))+ .finally(() => {+ // Leave the button disabled briefly so the user gets visual+ // confirmation; re-enable after 3s. The actual work continues+ // in the background.+ setTimeout(() => { ab.disabled = false; ab.style.opacity = ''; }, 3000);+ });+ return;+ }+ const tb = ev.target.closest('.thumb[data-thumb-kind]');+ if (tb) {+ ev.stopPropagation();+ const flipTo = tb.dataset.orientFlip;+ if (flipTo) { setOrientation(tb.dataset.thumbMpn, flipTo); return; }+ // Symbol/footprint tiles are NOT clickable to open the app — that+ // conflicted with the source-carat menu sitting on them. Use the carat to+ // switch source and the "Open in" buttons to launch. 3D/datasheet still open.+ if (tb.dataset.thumbKind === 'symbol' || tb.dataset.thumbKind === 'footprint') return;+ openThumb(tb.dataset.thumbMpn, tb.dataset.thumbKind);+ return;+ }+ // Single-click SELECTS the chip → fills the right-hand detail panel+ // (the "select a chip to see details" pane). Double-click zooms the+ // card into a centered overlay for close inspection.+ select(c.mpn);+ };+ card.ondblclick = (ev) => {+ if (ev.target.closest('.card-btn, .reveal-btn, .thumb, .refetch-select, .orient-more-btn, .orient-popover')) return;+ zoomCard(card);+ };+ // Tap-to-zoom (touch-friendly — phones reserve double-tap for page zoom).+ const zb = card.querySelector('.zoom-btn');+ if (zb) zb.onclick = (ev) => { ev.stopPropagation(); zoomCard(card); };+ // Orient-more popover toggle + pick+ const orientBtn = card.querySelector('.orient-more-btn');+ const orientPop = card.querySelector('.orient-popover');+ if (orientBtn && orientPop) {+ orientBtn.onclick = (ev) => {+ ev.stopPropagation();+ orientPop.style.display = orientPop.style.display === 'none' ? 'grid' : 'none';+ };+ orientPop.querySelectorAll('.orient-opt').forEach(opt => {+ opt.onclick = (ev) => {+ ev.stopPropagation();+ const axis = opt.dataset.axis;+ const mpn = opt.dataset.mpn;+ orientPop.style.display = 'none';+ setOrientation(mpn, axis);+ };+ });+ }++ // Refetch dropdown — separate handler because <select> needs change+ // event, not click.+ const refSel = card.querySelector('.refetch-select');+ if (refSel) {+ refSel.onchange = (ev) => {+ ev.stopPropagation();+ const v = ev.target.value;+ if (!v) return;+ const [source, format] = v.split('|');+ const mpn = ev.target.dataset.mpn;+ ev.target.value = ''; // reset selector+ fetch(API_BASE + '/api/refetch/' + encodeURIComponent(mpn) ++ '?format=' + encodeURIComponent(format) + '&source=' + encodeURIComponent(source),+ { method: 'POST' })+ .then(r => r.json())+ .then(j => console.log('[refetch] kicked:', j))+ .catch(e => console.error('[refetch] failed:', e));+ };+ refSel.onclick = (ev) => ev.stopPropagation();+ }+ cards.appendChild(card);+ }+}++function autoZoomIfEnabled(mpn) {+ const cb = document.getElementById('auto-zoom-cb');+ if (!cb || !cb.checked) return;+ // Dismiss any existing zoom+ const existing = document.querySelector('.card-zoom-backdrop');+ if (existing) existing.remove();+ // Find the card and zoom it+ const card = document.querySelector(`[data-mpn="${CSS.escape(mpn)}"]`);+ if (card) {+ // Small delay so the render settles first+ setTimeout(() => zoomCard(card), 100);+ }+}++function zoomCard(cardEl) {+ // PROGRAMMATIC ANTI-STACK GUARD. pollActivity (1s), the library refresh (4s),+ // auto-zoom, and manual clicks can all call zoomCard within the same frame —+ // autoZoomIfEnabled's remove-then-setTimeout(100) loses the race when two+ // callers both pass the "none open" check before either timeout fires, so two+ // backdrops get created and stack. The only robust fix is to dedup HERE, at+ // the moment of creation: there is ever only ONE .card-zoom-backdrop. If one+ // is already open for this same chip, do nothing; otherwise replace it.+ const alreadyOpen = document.querySelector('.card-zoom-backdrop');+ if (alreadyOpen) {+ if (alreadyOpen.dataset.mpn === cardEl.dataset.mpn) return;+ alreadyOpen.remove();+ }+ // Animate FROM the card's grid position TO centered on screen so+ // the user can track which card is zooming. The clone starts at+ // the card's exact rect (scale 1) and transitions to the final+ // centered + scaled position.+ const rect = cardEl.getBoundingClientRect();+ // Phone: fill the browser width (no 1.9 cap) and allow vertical scroll.+ const phone = matchMedia('(hover: none)').matches || window.innerWidth < 760;+ // The FINAL scale is computed AFTER the clone is in the DOM, from its REAL+ // height (which includes the injected Open-in/Send-to actions + status banner).+ // Computing it here from the original card height clipped those off the bottom.+ const backdrop = document.createElement('div');+ backdrop.className = 'card-zoom-backdrop';+ backdrop.dataset.mpn = cardEl.dataset.mpn || '';+ const wrapper = document.createElement('div');+ wrapper.className = 'card-zoomed-wrapper';+ const clone = cardEl.cloneNode(true);+ clone.style.width = rect.width + 'px';+ clone.style.transformOrigin = 'top left';+ // Inject Open-in + Send-to into the zoom focus view so the chip's actions are+ // reachable here (especially on a phone, where the side panel is cramped).+ // The .openbtn/.sendbtn handlers are delegated on document, so they just work.+ const zchip = (LIB && LIB.chips || []).find(x => x.mpn === cardEl.dataset.mpn);+ if (zchip) {+ const open = [+ zchip.has_sym && ['symbol', 'adom-symbol'],+ zchip.has_mod && ['footprint', 'adom-footprint'],+ (zchip.has_sym || zchip.has_mod) && ['lbr', 'adom-lbr'],+ zchip.has_step && ['chip3d', 'adom-3D'],+ zchip.has_step && ['chipsmith', 'chipsmith'],+ (zchip.symbol_sources || []).some(s => s.method === 'ds2sf') && ['ds2sf', 'ds2sf extraction'],+ ].filter(Boolean);+ const sec = document.createElement('div');+ sec.className = 'zoom-actions';+ sec.innerHTML =+ `<div class="zoom-sec">Open in</div><div class="openin-row" data-mpn="${escapeHtml(zchip.mpn)}">`+ + open.map(([k, l]) => `<button class="openbtn" data-kind="${k}">${l}</button>`).join('')+ + `</div><div class="zoom-sec">Send to</div><div class="sendto-row" data-mpn="${escapeHtml(zchip.mpn)}">`+ + [['kicad', 'KiCad'], ['fusion360', 'Fusion'], ['altium', 'Altium'], ['orcad', 'OrCAD']]+ .map(([t, l]) => `<button class="sendbtn" data-target="${t}">${l}</button>`).join('')+ + `</div>`;+ clone.appendChild(sec);+ }+ // Fluid 2-column layout (desktop): move the media (.thumbs) into a left column+ // and everything else into a right column, so the zoom uses the horizontal room+ // and is short enough to scale up big. Children stay descendants of the clone,+ // so the click re-wiring below still finds them.+ if (!phone) {+ const media = document.createElement('div'); media.className = 'zoom-col-media';+ const info = document.createElement('div'); info.className = 'zoom-col-info';+ Array.from(clone.children).forEach(ch => {+ (ch.classList.contains('thumbs') ? media : info).appendChild(ch);+ });+ clone.appendChild(media);+ clone.appendChild(info);+ clone.classList.add('zoom-fluid');+ clone.style.width = Math.min(900, Math.round(window.innerWidth * 0.82)) + 'px';+ }+ // Start at the card's grid position (scale 1)+ wrapper.style.left = rect.left + 'px';+ wrapper.style.top = rect.top + 'px';+ clone.style.transform = 'scale(1)';+ // Show a "working on" banner ONLY while THIS chip is actively being+ // fetched. ACTIVE_MPN/ACTIVE_STAGE are maintained by pollActivity and are+ // null when idle/stale — so an idle chip shows no banner at all (it used to+ // inject an empty "working on" box, which read as broken).+ const zoomMpn = cardEl.dataset.mpn;+ function syncZoomStatus() {+ const working = ACTIVE_MPN === zoomMpn && ACTIVE_STAGE;+ // The status banner floats on the BACKDROP (fixed, top-center) — NOT inside+ // the card. Injecting it into the card reflowed + rescaled the whole layout+ // every time it appeared/disappeared, which read as "wacky". As an overlay+ // pill it never touches the card's geometry; the liked layout stays put.+ let bar = backdrop.querySelector('.zoom-status');+ if (working) {+ if (!bar) {+ bar = document.createElement('div');+ bar.className = 'zoom-status';+ bar.style.cssText = 'position:fixed;top:18px;left:50%;transform:translateX(-50%);z-index:20;background:rgba(0,184,176,.18);border:1px solid var(--accent);border-radius:999px;padding:8px 18px;font-size:13px;font-weight:600;color:var(--accent);font-family:Satoshi,sans-serif;box-shadow:0 6px 22px rgba(0,0,0,.45);backdrop-filter:blur(6px);white-space:nowrap;';+ backdrop.appendChild(bar);+ }+ bar.textContent = '● working on — ' + ACTIVE_STAGE;+ } else if (bar) {+ bar.remove();+ }+ }+ syncZoomStatus();+ const statusPoll = setInterval(syncZoomStatus, 1500);+ wrapper.appendChild(clone);+ backdrop.appendChild(wrapper);+ // Dismiss with reverse animation (declared before use by the X button).+ function dismiss() {+ clearInterval(statusPoll);+ backdrop.classList.remove('visible');+ if (!phone) { wrapper.style.left = rect.left + 'px'; wrapper.style.top = rect.top + 'px'; clone.style.transform = 'scale(1)'; }+ setTimeout(() => backdrop.remove(), 300);+ document.removeEventListener('keydown', escHandler);+ }+ // Always-visible close (X) so it's obvious how to get back to the index.+ const xbtn = document.createElement('button');+ xbtn.className = 'card-zoom-x'; xbtn.setAttribute('aria-label', 'Close'); xbtn.textContent = '✕';+ xbtn.onclick = (e) => { e.stopPropagation(); dismiss(); };+ backdrop.appendChild(xbtn);+ document.body.appendChild(backdrop);+ // Fit the WHOLE card — including the injected Open-in/Send-to actions + any+ // status banner — on screen with ONE zoom factor: the largest scale that fits+ // BOTH the width and the card's REAL height. So it fills as much of the room as+ // it can, is always fully visible, and NEVER scrolls or clips. (Measure the+ // clone's natural height now that it's in the DOM at scale 1.)+ // Fit the card to the viewport. CRITICAL: measure the clone's size AFTER the+ // 2-column fluid layout + thumbnails have settled. Measuring synchronously+ // here reads the TALL single-column height (before the grid reflows to wide-+ // and-short), so the scale comes out too small and the card ends up a cramped+ // little box floating in empty space — exactly the "weird at this zoom" bug.+ function fitZoom() {+ const naturalW = clone.offsetWidth || rect.width;+ const naturalH = clone.offsetHeight || rect.height;+ const scale = Math.min(+ (window.innerWidth * (phone ? 0.96 : 0.94)) / naturalW,+ (window.innerHeight * 0.94) / naturalH+ );+ const scaledW = naturalW * scale, scaledH = naturalH * scale;+ wrapper.style.left = Math.max(8, (window.innerWidth - scaledW) / 2) + 'px';+ wrapper.style.top = Math.max(8, (window.innerHeight - scaledH) / 2) + 'px';+ clone.style.transition = 'transform .3s ease-out';+ clone.style.transform = `scale(${scale})`;+ }+ backdrop.classList.add('visible');+ // Double-rAF so the fluid 2-col layout has reflowed before we measure…+ requestAnimationFrame(() => requestAnimationFrame(fitZoom));+ // …and re-fit when a late-loading thumbnail changes the card's height.+ clone.querySelectorAll('img').forEach(img => {+ if (!img.complete) img.addEventListener('load', fitZoom, { once: true });+ });+ // Re-fit on browser zoom / window resize so it never goes weird at non-100%.+ const onResize = () => fitZoom();+ window.addEventListener('resize', onResize);+ backdrop.addEventListener('remove', () => window.removeEventListener('resize', onResize));+ backdrop.onclick = (ev) => { if (ev.target === backdrop) dismiss(); };+ const escHandler = (ev) => { if (ev.key === 'Escape') dismiss(); };+ document.addEventListener('keydown', escHandler);+ // Re-wire ALL interactive elements inside the zoom clone.+ // The clone is a dead DOM copy — no event listeners survived.+ // Every clickable thing must be re-attached here.++ // 1. Thumbnail clicks: open viewer OR flip orientation+ clone.querySelectorAll('.thumb[data-thumb-kind]').forEach(tb => {+ tb.onclick = (ev) => {+ ev.stopPropagation();+ const flipTo = tb.dataset.orientFlip;+ if (flipTo) { setOrientation(tb.dataset.thumbMpn, flipTo); dismiss(); return; }+ openThumb(tb.dataset.thumbMpn, tb.dataset.thumbKind);+ };+ tb.style.cursor = 'pointer';+ });++ // 2. Orient-more popover (▾ button + option tiles)+ const orientBtn = clone.querySelector('.orient-more-btn');+ const orientPop = clone.querySelector('.orient-popover');+ if (orientBtn && orientPop) {+ orientBtn.onclick = (ev) => {+ ev.stopPropagation();+ orientPop.style.display = orientPop.style.display === 'none' ? 'grid' : 'none';+ };+ clone.querySelectorAll('.orient-opt').forEach(opt => {+ opt.onclick = (ev) => {+ ev.stopPropagation();+ setOrientation(opt.dataset.mpn, opt.dataset.axis);+ dismiss();+ };+ });+ }++ // 3. Action buttons (⟲ validate, 🖼 thumbnails)+ clone.querySelectorAll('.card-btn[data-action]').forEach(ab => {+ ab.onclick = (ev) => {+ ev.stopPropagation();+ fetch(API_BASE + '/api/' + ab.dataset.action + '/' + encodeURIComponent(ab.dataset.mpn), { method: 'POST' });+ };+ });++ // 4. Refetch dropdown+ const refSel = clone.querySelector('.refetch-select');+ if (refSel) {+ refSel.onchange = (ev) => {+ ev.stopPropagation();+ const v = ev.target.value;+ if (!v) return;+ const [source, format] = v.split('|');+ const mpn = ev.target.dataset.mpn;+ ev.target.value = '';+ fetch(API_BASE + '/api/refetch/' + encodeURIComponent(mpn) ++ '?format=' + encodeURIComponent(format) + '&source=' + encodeURIComponent(source),+ { method: 'POST' });+ };+ refSel.onclick = (ev) => ev.stopPropagation();+ }++ // 5. Reveal folder button+ clone.querySelectorAll('.reveal-btn[data-reveal-mpn]').forEach(rb => {+ rb.onclick = (ev) => { ev.stopPropagation(); revealFolder(rb.dataset.revealMpn); };+ });++ // 6. PDF badge click (open datasheet)+ clone.querySelectorAll('[data-thumb-mpn][data-thumb-kind="datasheet"]').forEach(pdf => {+ pdf.onclick = (ev) => { ev.stopPropagation(); openThumb(pdf.dataset.thumbMpn, 'datasheet'); };+ pdf.style.cursor = 'pointer';+ });+}++function escapeHtml(s) {+ return String(s).replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[m]);+}++// One thumbnail tile. Clickable when the source file is present —+// click dispatches to the right Adom app (3D → adom-step, PDF →+// adom-pdf-viewer, sym/fp → full-size SVG in a pup tab). Empty slot+// shows a dashed placeholder so the user can see what's coming.+// Returns the chip-thumbnailer failure reason for a thumbnail kind, or null.+// Keys in .thumb-errors.json are "sym" / "fp" / "3d".+function thumbErr(c, key) {+ const e = c && c.thumb_errors;+ return (e && typeof e === 'object' && e[key]) ? String(e[key]) : null;+}++function renderThumb(mpn, kind, present, label, orient, errMsg, sources) {+ if (!present) {+ // Distinguish a FAILED render (chip-thumbnailer errored) from one that was+ // never attempted — a blank tile next to a green KICAD/STEP chip otherwise+ // reads as "no file", hiding a real generation failure.+ if (errMsg) {+ return `<div class="thumb failed" data-tooltip="${escapeHtml(label)} render failed: ${escapeHtml(errMsg)}\nClick the ⟲ / 🖼 card buttons to re-validate or re-render."><span class="thumb-failed">⚠ ${escapeHtml(label)}</span><span class="thumb-failed-sub">render failed</span></div>`;+ }+ return `<div class="thumb empty" data-tooltip="${escapeHtml(label)} thumbnail not yet generated.\nUse the 🖼 card button to render it from the current library files."><span class="thumb-placeholder">${escapeHtml(label)}</span></div>`;+ }+ // Show the ACTIVE source's thumbnail on the main tile: the canonical render+ // for the current source, or the `.<method>` variant when the user picked one.+ let methodSuffix = '';+ if (sources && sources.length) {+ const am = activeMethod(mpn, kind, sources);+ const amS = sources.find(s => s.method === am);+ if (amS && !amS.current && amS.thumb) methodSuffix = '.' + am;+ }+ const url = API_BASE + '/thumb/' + encodeURIComponent(mpn) + '/' + kind + methodSuffix;+ const src = url + '?t=' + Date.now();++ // Orientation-picker affordances on the two 3D tiles. When `orient` is+ // passed, decorate active vs inactive: active gets a green border + corner+ // check, inactive dims to 55% until hover with a "click to mark this+ // orientation as correct" hint in the tooltip.+ let orientAttrs = '';+ let orientCheck = '';+ let titleSuffix = '';+ if (orient && (kind === 'chip3d' || kind === 'chip3d-yup')) {+ const isActive = orient.chosen === orient.upAxis;+ if (isActive) {+ orientAttrs = ' data-orient-active="true"';+ orientCheck = `<span class="orient-check" aria-label="active orientation"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></span>`;+ const inferredMatches = orient.inferred === orient.upAxis;+ titleSuffix = inferredMatches+ ? ` · active (auto-detected ${orient.inferred.toUpperCase()}-up)`+ : ` · active (you picked, overriding inferred ${orient.inferred.toUpperCase()}-up)`;+ } else {+ orientAttrs = ` data-orient-inactive="true" data-orient-flip="${orient.upAxis}"`;+ titleSuffix = ` · click to mark this ${orient.upAxis.toUpperCase()}-up rendering as correct`;+ }+ }++ const openTip = {+ symbol: `Open ${mpn} in the live adom-symbol app — interactive schematic symbol rendered via service-kicad (no local KiCad).`,+ footprint: `Open ${mpn} in the live adom-footprint app — interactive PCB footprint rendered via service-kicad (no local KiCad).`,+ chip3d: `Open ${mpn} in the adom-step 3D viewer — orbit the real STEP model.`,+ 'chip3d-yup': `Open the Y-up 3D render of ${mpn} full-size (Fusion 360 / SolidWorks convention).`,+ datasheet: `Open the ${mpn} datasheet PDF in the viewer.`,+ }[kind] || `Open ${mpn} ${label} in Adom.`;+ // If a per-source variant thumbnail is missing/failed, fall back to the+ // canonical render (no broken image). Only one fallback hop.+ const canonUrl = API_BASE + '/thumb/' + encodeURIComponent(mpn) + '/' + kind;+ const onerr = methodSuffix+ ? `onerror="if(!this.dataset.fb){this.dataset.fb=1;this.src='${canonUrl}?t='+Date.now()}else{this.style.visibility='hidden'}"`+ : `onerror="this.style.visibility='hidden'"`;+ // Symbol/footprint tiles are NON-interactive (no open-on-click, no hover+ // tooltip) so they don't fight the source-carat menu sitting on them.+ const noHover = (kind === 'symbol' || kind === 'footprint');+ const tipAttr = noHover ? '' : ` data-tooltip="${escapeHtml(openTip + titleSuffix)}"`;+ const thumbHtml = `<div class="thumb${noHover ? ' thumb-static' : ''}"${tipAttr} data-thumb-mpn="${escapeHtml(mpn)}" data-thumb-kind="${kind}"${orientAttrs}>+ <img src="${src}" alt="${escapeHtml(mpn)} ${escapeHtml(label)}" loading="lazy" ${onerr}>+ <span class="thumb-label">${escapeHtml(label)}</span>+ ${orientCheck}+ </div>`;+ // Symbol/footprint provenance carat: an always-visible, tappable source chip+ // (works on touch) + a popover listing every method that produced a variant.+ if ((kind === 'symbol' || kind === 'footprint') && sources && sources.length) {+ return `<div class="thumb-src-group">${thumbHtml}${srcCarat(mpn, kind, sources)}</div>`;+ }+ return thumbHtml;+}++// Always-visible tappable source chip + popover of all source methods.+function srcCarat(mpn, kind, sources) {+ if (!sources.length) return '';+ const active = activeMethod(mpn, kind, sources);+ const cur = sources.find(s => s.method === active) || sources.find(s => s.current) || sources[0];+ const multi = sources.length > 1;+ const opts = sources.map(s => {+ const tsrc = s.thumb ? `${API_BASE}/thumb/${encodeURIComponent(mpn)}/${kind}${s.current ? '' : '.' + s.method}?t=${Date.now()}` : '';+ const sel = s.method === active;+ return `<div class="src-opt${sel ? ' chosen' : ''}" data-mpn="${escapeHtml(mpn)}" data-kind="${kind}" data-method="${escapeHtml(s.method)}" data-canonical="${s.current ? '1' : '0'}"+ data-tooltip="${escapeHtml(s.label)} — ${sel ? 'the variant shown now.' : 'tap to make this the source shown on the tile + open it in adom-' + kind + '.'}">+ ${s.thumb ? `<img class="src-opt-thumb" src="${tsrc}" loading="lazy" onerror="this.outerHTML='<span class=\\'src-dot ${s.method === 'ds2sf' ? 'ds' : 'vendor'}\\'></span>'">` : `<span class="src-dot ${s.method === 'ds2sf' ? 'ds' : 'vendor'}"></span>`}+ <span class="src-opt-lab">${escapeHtml(s.label)}</span>${sel ? '<span class="src-cur">●</span>' : ''}+ </div>`;+ }).join('');+ return `<button class="src-chip" data-mpn="${escapeHtml(mpn)}" data-kind="${kind}"+ data-tooltip="Source of this ${kind}: ${escapeHtml(cur.label)}.${multi ? ' ' + sources.length + ' methods — tap to switch.' : ' Tap for details.'}">+ <span class="src-dot ${cur.method === 'ds2sf' ? 'ds' : 'vendor'}"></span><span class="src-chip-lab">${escapeHtml(cur.label)}</span>${multi ? '<span class="src-chip-car">▾</span>' : ''}+ </button>+ <div class="src-popover" data-mpn="${escapeHtml(mpn)}" style="display:none">${opts}</div>`;+}++async function setOrientation(mpn, axis) {+ try {+ const r = await fetch(API_BASE + '/api/orientation', {+ method: 'POST',+ headers: {'Content-Type': 'application/json'},+ body: JSON.stringify({mpn, axis}),+ });+ const j = await r.json().catch(() => ({}));+ if (!r.ok || !j.ok) {+ alert('Could not set orientation: ' + (j.error || ('HTTP ' + r.status)));+ return;+ }+ const libR = await fetch(API_BASE + '/api/library');+ LIB = await libR.json();+ LAST_LIB_HASH = '';+ render();+ } catch (e) {+ alert('Could not reach /api/orientation: ' + e.message);+ }+}++// Bring the active card into view. Called whenever the heartbeat MPN+// changes — cards take more vertical real estate now that thumbnails+// land below the meta row, so they scroll off screen easily during a+// long batch. Only scroll if the card is genuinely off-screen so we+// don't fight the user's manual scroll while they're inspecting.+function scrollActiveCardIntoView() {+ const card = document.querySelector('.card.active') || (ACTIVE_MPN && document.querySelector(`.card[data-mpn="${CSS.escape(ACTIVE_MPN)}"]`));+ if (!card) return;+ const rect = card.getBoundingClientRect();+ const fullyVisible = rect.top >= 0 && rect.bottom <= window.innerHeight;+ if (fullyVisible) return;+ card.scrollIntoView({behavior: 'smooth', block: 'center'});+}++// Provenance carat: toggle the popover, or open a source variant.+document.addEventListener('click', (ev) => {+ const chip = ev.target.closest('.src-chip');+ if (chip) {+ ev.stopPropagation(); ev.preventDefault();+ const pop = chip.parentElement.querySelector('.src-popover');+ document.querySelectorAll('.src-popover').forEach(p => { if (p !== pop) p.style.display = 'none'; });+ if (pop) pop.style.display = pop.style.display === 'none' ? 'flex' : 'none';+ return;+ }+ const opt = ev.target.closest('.src-opt');+ if (opt) {+ ev.stopPropagation();+ const mpn = opt.dataset.mpn, kind = opt.dataset.kind, method = opt.dataset.method;+ const canonical = opt.dataset.canonical === '1';+ // Persist the pick so the carat + tile show this source (sticks across reloads).+ SRC_SEL[mpn + '|' + kind] = method; saveSrcSel();+ // Update the DOM in place: chip label, chosen highlight, main tile thumbnail.+ const group = opt.closest('.thumb-src-group');+ if (group) {+ const label = opt.querySelector('.src-opt-lab').textContent;+ const dotCls = method === 'ds2sf' ? 'ds' : 'vendor';+ const chipLab = group.querySelector('.src-chip-lab'); if (chipLab) chipLab.textContent = label;+ const chipDot = group.querySelector('.src-chip .src-dot'); if (chipDot) chipDot.className = 'src-dot ' + dotCls;+ group.querySelectorAll('.src-opt').forEach(o => {+ const on = o === opt;+ o.classList.toggle('chosen', on);+ let bullet = o.querySelector('.src-cur');+ if (on && !bullet) { bullet = document.createElement('span'); bullet.className = 'src-cur'; bullet.textContent = '●'; o.appendChild(bullet); }+ else if (!on && bullet) bullet.remove();+ });+ const img = group.querySelector('.thumb img');+ if (img) img.src = API_BASE + '/thumb/' + encodeURIComponent(mpn) + '/' + kind + (canonical ? '' : '.' + method) + '?t=' + Date.now();+ }+ const pop = opt.closest('.src-popover'); if (pop) pop.style.display = 'none';+ // Switching the source ONLY swaps the tile thumbnail — it must NOT open the+ // app (clicking the menu kept launching adom-symbol). Use the explicit+ // "Open in" buttons to launch an app.+ return;+ }+ document.querySelectorAll('.src-popover').forEach(p => { if (!p.contains(ev.target)) p.style.display = 'none'; });+});++// Lightweight toast (bottom-center). Optional href makes it a tappable link.+let CF_TOAST_T = null;+function cfToast(msg, href) {+ let t = document.getElementById('cf-toast');+ if (!t) { t = document.createElement('div'); t.id = 'cf-toast'; document.body.appendChild(t); }+ t.innerHTML = href ? `${msg} <a href="${href}" target="_blank" rel="noopener">open ↗</a>` : msg;+ t.classList.add('show'); clearTimeout(CF_TOAST_T);+ CF_TOAST_T = setTimeout(() => t.classList.remove('show'), href ? 8000 : 4200);+}++// The viewing context, for choosing + NAMING the open method.+function viewContext() {+ const embedded = window.self !== window.top; // a Hydrogen webview is an iframe+ const phone = matchMedia('(hover: none)').matches || innerWidth < 760;+ return { embedded, phone };+}++// Open a chip in an Adom app. Opens in the user's CURRENT browser via+// window.open (works on a phone, in pup, or a Hydrogen webview) — the only+// universally-correct surface — and tells the user exactly how it opened.+async function openThumb(mpn, kind, source) {+ const APP = { symbol: 'adom-symbol', footprint: 'adom-footprint', lbr: 'adom-lbr',+ chip3d: 'adom-3D', 'chip3d-yup': 'adom-3D', datasheet: 'the datasheet', chipsmith: 'chipsmith', ds2sf: 'adom-symbol (ds2sf)' };+ const app = APP[kind] || kind;+ // Pre-open a blank tab INSIDE the click gesture so the browser doesn't block+ // it; navigate it once the server has the chip loaded. (chipsmith has no URL —+ // it opens its own Hydrogen webview server-side — so don't pre-open for it.)+ const tab = kind === 'chipsmith' ? null : window.open('', '_blank');+ try {+ const r = await fetch(API_BASE + '/api/open', {+ method: 'POST', headers: { 'Content-Type': 'application/json' },+ body: JSON.stringify({ mpn, kind, source }),+ });+ const j = await r.json().catch(() => ({}));+ if (!r.ok || !j.ok) { if (tab) tab.close(); cfToast(`⚠ Couldn't open ${app}: ${j.error || ('HTTP ' + r.status)}`); return; }+ if (kind === 'chipsmith') {+ // chipsmith opens its own Hydrogen webview (on the machine running Adom+ // Desktop / Hydrogen) — not a browser tab, so it won't appear on a phone.+ cfToast(`Opened ${app} as a Hydrogen webview${j.desktopBridge ? ' on your Adom Desktop' : ' (needs Hydrogen — not visible on a phone)'}.`);+ return;+ }+ const { embedded, phone } = viewContext();+ const where = phone ? 'a new browser tab on your phone' : embedded ? 'a new tab from the embedded view' : 'a new browser tab';+ if (tab && j.url) { tab.location.href = j.url; cfToast(`Opened ${app} in ${where} (window.open).`); }+ else if (j.url) { cfToast(`${app} is ready — pop-up was blocked, tap to open:`, j.url); }+ } catch (e) { if (tab) tab.close(); cfToast(`⚠ Couldn't reach the server: ${e.message}`); }+}++// Build per-file provenance lines from file_provenance JSON.+// Returns e.g. ".step from snapmagic\n.kicad_mod from snapmagic"+function fileProvenanceLines(fileProv, mpn) {+ if (!fileProv || typeof fileProv !== 'object') return '';+ var lines = [];+ for (var filename in fileProv) {+ var entry = fileProv[filename];+ if (!entry) continue;+ var ext = filename.replace(mpn + '.', '.').replace(mpn, '');+ var src = entry.discovery_source || entry.content_origin || '?';+ var origin = entry.content_origin || '';+ if (origin && origin !== src) src += ' (made by ' + origin + ')';+ lines.push(ext + ' from ' + src);+ }+ return lines.length ? '\nProvenance:\n' + lines.join('\n') : '';+}++// Summarize the sourcing chain from info.json into a one-line provenance string.+// Fallback when per-file provenance isn't available.+function provenanceSummary(info, fileProv, mpn) {+ // Prefer per-file provenance when available+ var perFile = fileProvenanceLines(fileProv, mpn);+ if (perFile) return perFile;+ // Fall back to chain summary+ if (!info || !info.fetched_via_chain || !info.fetched_via_chain.length) return '';+ var sources = info.fetched_via_chain.map(function(e) {+ var s = e.source || '?';+ var o = e.outcome || '';+ if (o === 'attempted' || o === 'failed') s += ' (tried)';+ if (o === 'success') s += ' (got it)';+ return s;+ });+ return '\nSourced via: ' + sources.join(' > ');+}++// One badge per EDA tool: green if both halves present, amber if partial,+// grey "?" if missing. Fusion uses one .lbr containing both sym+mod, so+// callers pass the same flag twice for that tool.+function badgeForEda(label, hasSym, hasMod, info, fileProv, mpn) {+ const fullName = {KICAD:'KiCad',ALTIUM:'Altium',FUSION:'Fusion 360'}[label]||label;+ var prov = provenanceSummary(info, fileProv, mpn);+ if (hasSym && hasMod) {+ return `<span class="b ${label.toLowerCase()}" data-tooltip="${fullName} schematic symbol + PCB footprint both present.\nThis chip has a complete ${fullName} library — ready for import into your EDA tool.${prov}">${label}</span>`;+ }+ if (hasSym || hasMod) {+ const has = hasSym ? 'schematic symbol' : 'PCB footprint';+ const miss = hasSym ? 'PCB footprint' : 'schematic symbol';+ return `<span class="b partial-eda" data-tooltip="${fullName}: has ${has} but missing ${miss}.\nPartial library — use the refetch button to get the missing piece.${prov}">${label} ${hasSym?'sym':'fp'}</span>`;+ }+ return `<span class="b miss" data-tooltip="${fullName} schematic symbol + PCB footprint both missing.\nNo ${fullName} library files fetched yet.${prov}">${label} ?</span>`;+}++async function select(mpn) {+ SELECTED = mpn;+ updateSubject();+ document.querySelectorAll('.card').forEach(c => c.classList.toggle('selected', c.dataset.mpn === mpn));+ const r = await fetch(API_BASE + '/api/chip/' + encodeURIComponent(mpn));+ if (!r.ok) {+ document.getElementById('detail').innerHTML = '<div class="empty-state">failed to load</div>';+ return;+ }+ const c = await r.json();+ const aside = document.getElementById('detail');+ aside.classList.remove('empty');++ // Render every canonical info.json field. "?" placeholder when unknown so+ // the user sees explicitly what we don't have, not a missing row.+ const CANONICAL = [+ ['manufacturer', 'Manufacturer'],+ ['package', 'Package'],+ ['family', 'Family'],+ ['description', 'Description'],+ ['step_source', 'STEP source'],+ ['step_authoring', 'STEP authoring'],+ ['step_url', 'STEP URL'],+ ['step_note', 'STEP note'],+ ['datasheet_source','Datasheet source'],+ ['datasheet_url', 'Datasheet URL'],+ ['datasheet_note', 'Datasheet note'],+ ['sym_source', 'Symbol source'],+ ['sym_url', 'Symbol URL'],+ ['mod_source', 'Footprint source'],+ ['mod_url', 'Footprint URL'],+ ['ul_deeplink_source', 'UL deep-link source'],+ ['ul_deeplink_url', 'UL deep-link URL'],+ ['ul_deeplink_note', 'UL deep-link note'],+ ['ul_zip', 'UL zip'],+ ['fetched_at', 'Fetched at'],+ ['verified_by', 'Verified by'],+ ];+ const info = c.info || {};+ const fieldRows = CANONICAL.map(([k, label]) => {+ let v = info[k];+ if (v === undefined || v === null || v === '') v = '?';+ const isQ = (v === '?');+ const isUrl = typeof v === 'string' && /^https?:\/\//.test(v);+ const valHtml = isUrl+ ? `<a href="${escapeHtml(v)}" target="_blank" rel="noopener">${escapeHtml(v)}</a>`+ : escapeHtml(String(v));+ return `<div class="field ${isQ?'unknown':''}">+ <span class="field-key">${escapeHtml(label)}</span>+ <span class="field-val">${valHtml}</span>+ </div>`;+ }).join('');++ const tab = DETAIL_TAB || 'actions';+ aside.innerHTML = `+ <h2>${escapeHtml(c.mpn)}</h2>+ <div class="vendor">${escapeHtml(c.vendor || '?')} · ${escapeHtml(c.package || '?')}${c.family && c.family !== '?' ? ' · ' + escapeHtml(c.family) : ''}</div>++ <div class="dtabs" role="tablist">+ <button class="dtab${tab==='actions'?' on':''}" data-tab="actions">Actions</button>+ <button class="dtab${tab==='details'?' on':''}" data-tab="details">Details</button>+ <button class="dtab${tab==='files'?' on':''}" data-tab="files">Files <span class="dtab-n">${c.files.length}</span></button>+ </div>++ <div class="dpanel${tab==='actions'?' on':''}" data-panel="actions">+ <div class="section">+ <div class="section-title">Open in</div>+ <div class="openin-row" data-mpn="${escapeHtml(c.mpn)}">+ ${c.has_sym ? `<button class="openbtn" data-kind="symbol" data-tooltip="Open ${escapeHtml(c.mpn)}'s schematic symbol in the live adom-symbol app.">adom-symbol</button>` : ''}+ ${c.has_mod ? `<button class="openbtn" data-kind="footprint" data-tooltip="Open ${escapeHtml(c.mpn)}'s PCB footprint in the live adom-footprint app (layers + InstaPCB solder-paste dots).">adom-footprint</button>` : ''}+ ${(c.has_sym || c.has_mod) ? `<button class="openbtn" data-kind="lbr" data-tooltip="Build + open a true EAGLE .lbr (symbol + footprint) in adom-lbr.">adom-lbr</button>` : ''}+ ${c.has_step ? `<button class="openbtn" data-kind="chip3d" data-tooltip="Orbit the real 3D STEP model in the adom-step viewer.">adom-3D</button>` : ''}+ ${c.has_step ? `<button class="openbtn" data-kind="chipsmith" data-tooltip="Open in chipsmith — STEP-native pin/footprint detection + sign-off.">chipsmith</button>` : ''}+ ${(c.symbol_sources||[]).some(s=>s.method==='ds2sf') ? `<button class="openbtn" data-kind="ds2sf" data-tooltip="View ds2sf's datasheet-extracted symbol (opens adom-symbol on the ds2sf source).">ds2sf extraction</button>` : ''}+ </div>+ </div>+ <div class="section">+ <div class="section-title">Send to</div>+ <div class="sendto-row" data-mpn="${escapeHtml(c.mpn)}">+ <button class="sendbtn" data-target="kicad" data-tooltip="Install the native KiCad symbol + footprint into your KiCad libraries via Adom Desktop.">KiCad</button>+ <button class="sendbtn" data-target="fusion360" data-tooltip="Build a true EAGLE .lbr and open it in Fusion 360 Electronics.">Fusion</button>+ <button class="sendbtn" data-target="altium" data-tooltip="Build the EAGLE .lbr + import steps (native Altium exporter on the roadmap).">Altium</button>+ <button class="sendbtn" data-target="orcad" data-tooltip="Build the EAGLE .lbr (native OrCAD exporter on the roadmap).">OrCAD</button>+ </div>+ </div>+ <div class="section">+ <div class="section-title">Status</div>+ <div class="badges">+ <span class="b ${c.fully_bundled?'full':'stub'}">${c.fully_bundled?'FULL':'STUB'}</span>+ <span class="b ${c.has_step?'step':'miss'}">STEP${c.has_step?'':' ?'}</span>+ ${badgeForEda('KICAD', c.has_sym, c.has_mod, c.info, c.file_provenance, c.mpn)}+ ${badgeForEda('ALTIUM', c.has_altium, c.has_altium, c.info, c.file_provenance, c.mpn)}+ ${badgeForEda('FUSION', c.has_fusion_lbr, c.has_fusion_lbr, c.info, c.file_provenance, c.mpn)}+ <span class="b ${c.pdf_count>0?'pdf':'miss'}">${c.pdf_count>0 ? c.pdf_count : '?'} PDF${c.pdf_count===1?'':'s'}</span>+ <span class="b src ${srcTier(c.step_source)}">source: ${escapeHtml(srcShort(c.step_source))}</span>+ </div>+ </div>+ </div>++ <div class="dpanel${tab==='details'?' on':''}" data-panel="details">+ <div class="fields">${fieldRows}</div>+ </div>++ <div class="dpanel${tab==='files'?' on':''}" data-panel="files">+ <ul class="filelist">+ ${c.files.map(f => {+ const url = API_BASE + '/asset/' + encodeURIComponent(c.mpn) + '/' + encodeURIComponent(f);+ return `<li><a href="${url}" target="_blank">${escapeHtml(f)}</a></li>`;+ }).join('')}+ </ul>+ </div>+ `;+ aside.querySelectorAll('.dtab').forEach(t => t.onclick = () => {+ DETAIL_TAB = t.dataset.tab;+ aside.querySelectorAll('.dtab').forEach(x => x.classList.toggle('on', x === t));+ aside.querySelectorAll('.dpanel').forEach(p => p.classList.toggle('on', p.dataset.panel === DETAIL_TAB));+ });+}++document.getElementById('q').addEventListener('input', render);+document.getElementById('vendor').addEventListener('change', render);+document.getElementById('status').addEventListener('change', render);++// ---------------- open a chip in a specific Adom tool ----------------+// Delegated on document so it works in the detail panel AND the zoom overlay.+document.addEventListener('click', (ev) => {+ const b = ev.target.closest('.openbtn');+ if (!b) return;+ const mpn = b.closest('.openin-row').dataset.mpn, kind = b.dataset.kind;+ const prev = b.textContent; b.disabled = true; b.textContent = 'Opening…';+ setTimeout(() => { b.disabled = false; b.textContent = prev; }, 2500);+ // ds2sf has no viewer of its own — open its extracted symbol on the ds2sf source.+ if (kind === 'ds2sf') openThumb(mpn, 'symbol', 'ds2sf');+ else openThumb(mpn, kind);+});++// ---------------- send to desktop EDA tools ----------------+// Delegated on document so it works in the detail panel AND the zoom overlay.+document.addEventListener('click', async (ev) => {+ const b = ev.target.closest('.sendbtn');+ if (!b) return;+ const row = b.closest('.sendto-row');+ const mpn = row.dataset.mpn, target = b.dataset.target, label = b.textContent;+ const all = [...row.querySelectorAll('.sendbtn')];+ all.forEach(x => x.disabled = true);+ const prev = b.textContent; b.textContent = 'Sending…';+ try {+ const r = await fetch(API_BASE + '/api/deliver', {+ method: 'POST', headers: {'Content-Type': 'application/json'},+ body: JSON.stringify({mpn, target}),+ });+ const j = await r.json().catch(() => ({}));+ if (j.needsBridge) showBridgeModal(j);+ else if (j.ok) b.textContent = '✓ ' + label;+ else alert((j.message || j.error || 'Could not send to ' + label));+ } catch (e) { alert('Send failed: ' + e.message); }+ finally { setTimeout(() => { all.forEach(x => x.disabled = false); b.textContent = prev; }, 2500); }+});+function showBridgeModal(j) {+ let m = document.getElementById('bridgeModal');+ if (!m) { m = document.createElement('div'); m.id = 'bridgeModal'; document.body.appendChild(m); }+ const dl = j.assetUrl+ ? `<a class="bm-go" href="${API_BASE + j.assetUrl}" download>⬇ Download the .lbr</a>` : '';+ const hint = j.importHint ? `<div class="bm-hint">${j.importHint}</div>` : '';+ const icon = j.exportWorks ? '📦' : '🌉';+ m.innerHTML = '<div class="bm-card"><div class="bm-icon">' + icon + '</div>'+ + '<div class="bm-title">' + (j.exportWorks ? (j.target + ' library export') : ('Build the ' + (j.target || '') + ' bridge')) + '</div>'+ + '<div class="bm-body">' + (j.message || '') + '</div>'+ + hint+ + '<div class="bm-actions">' + dl+ + '<a class="bm-contribute" href="' + (j.contribute || 'https://wiki.adom.inc') + '" target="_blank" rel="noopener">Build the auto-import bridge — contribute on the wiki →</a>'+ + '<button class="bm-dismiss">Close</button></div></div>';+ m.classList.add('show');+ m.querySelector('.bm-dismiss').onclick = () => m.classList.remove('show');+ m.onclick = e => { if (e.target === m) m.classList.remove('show'); };+}++// ---------------- reveal folder in VS Code Explorer ----------------+async function revealFolder(mpn) {+ try {+ const r = await fetch(API_BASE + '/api/reveal', {+ method: 'POST',+ headers: {'Content-Type': 'application/json'},+ body: JSON.stringify({mpn: mpn || ''}),+ });+ const j = await r.json().catch(() => ({}));+ if (!r.ok || !j.ok) {+ console.warn('reveal failed', j);+ alert('Could not reveal folder: ' + (j.error || j.stderr || ('HTTP ' + r.status)));+ }+ } catch (e) {+ console.warn('reveal request failed', e);+ alert('Could not reach /api/reveal: ' + e.message);+ }+}+document.getElementById('btn-reveal-lib').addEventListener('click', () => revealFolder(''));++// ---------------- VALIDATION tab — brutal self-audit ----------------+const REQ = [+ ['datasheet_pdf','Datasheet'], ['step_3d','STEP 3D'],+ ['kicad_symbol','Symbol'], ['kicad_footprint','Footprint'],+ ['thumb_symbol','Sym thumb'], ['thumb_footprint','FP thumb'], ['thumb_3d','3D thumb'],+];+let validationOn = false, validationTimer = null;+function setValidationView(on) {+ validationOn = on;+ document.getElementById('grid-view').style.display = on ? 'none' : '';+ document.getElementById('validation-view').style.display = on ? 'block' : 'none';+ document.getElementById('btn-validate').style.background = on ? 'var(--accent)' : '';+ document.getElementById('btn-validate').style.color = on ? '#fff' : '';+ if (on) { renderValidation(); validationTimer = setInterval(renderValidation, 3000); }+ else if (validationTimer) { clearInterval(validationTimer); validationTimer = null; }+}+async function renderValidation() {+ const host = document.getElementById('validation-view');+ let data;+ try { data = await (await fetch(API_BASE + '/api/validate?v=' + Date.now())).json(); }+ catch (e) { host.innerHTML = '<div class="empty-state">validation API error</div>'; return; }+ const s = data.summary || {};+ const allGreen = s.total > 0 && s.incomplete === 0;+ const cell = (ok) => `<td style="text-align:center;font-size:16px;font-weight:700;color:${ok ? 'var(--green,#34d17e)' : '#ff5252'}">${ok ? '✓' : '✗'}</td>`;+ // adom-concur cross-source consensus cell: did KiCad / EAGLE / Altium / ds2sf agree?+ const consensusCell = (con) => {+ if (!con) return `<td style="text-align:center;font-size:11px;color:var(--muted)">— not run</td>`;+ const golden = /golden/i.test(con.status || '');+ const col = golden ? 'var(--green,#34d17e)' : '#ff5252';+ const label = golden ? (con.status === 'golden' ? 'GOLDEN' : 'GOLDEN*') : (con.status || '?').toUpperCase();+ const srcs = (con.sources || []).join(' · ') || '—';+ return `<td style="text-align:center;white-space:nowrap" data-tooltip="adom-concur cross-source consensus.\nCompared: ${srcs}\nMissing: ${(con.missing||[]).join(', ')||'none'}\n${con.agree} axes agree, ${con.disagree} disagree of ${con.total_axes}. GOLDEN* = agree except a naming-convention variant.">+ <span style="font-weight:700;color:${col};font-size:13px">${label}</span>+ <span style="font-size:10px;color:var(--muted);display:block">${con.agree}✓/${con.disagree}✗ · ${(con.sources||[]).length} src</span></td>`;+ };+ // Per-source child row: which sym / fp / 3d we generated from THIS library.+ // 3D with an asterisk = the library shipped no STEP, so we used the global+ // chip 3D (hover explains). Normal check = the library had its own 3D.+ const miniChk = (ok, why) => `<span${(!ok && why) ? ` data-tooltip="${escapeHtml(why)}" style="cursor:help;` : ` style="`}color:${ok?'var(--green,#34d17e)':'#ff5252'};font-weight:700;font-size:13px">${ok?'✓':'✗'}</span>`;+ const chk3d = (s) => {+ if (!s.three_d) return miniChk(false);+ if (s.three_d_from_lib) return miniChk(true);+ // No 3D in this library → we swapped in the global chip STEP. Name where it came from.+ const where = s.step_source || 'another source';+ const tip = `there was no 3D chip in this library — so we swapped in the STEP we downloaded from ${where}${s.step_url ? ' (' + s.step_url + ')' : ''}`;+ return `<span data-tooltip="${escapeHtml(tip)}" style="color:var(--green,#34d17e);font-weight:700;font-size:13px;cursor:help">✓<span style="color:var(--accent)">*</span></span>`;+ };+ // Download links. ORIGINAL (the raw file/archive downloaded off the internet)+ // is styled as a solid accent pill ⭳; EXTRACTED files are outline pills ⬇.+ const dlLinks = (c, s) => (s.files || []).map(f => {+ const url = API_BASE + '/asset/' + encodeURIComponent(c.mpn) + '/' + encodeURIComponent(f.file);+ const tip = f.original+ ? 'ORIGINAL file exactly as downloaded from the internet — inspect it yourself: ' + f.file+ : 'a specific file we extracted from the download: ' + f.file;+ const style = f.original+ ? 'color:#06251f;background:var(--accent);font-weight:700;border:1px solid var(--accent);'+ : 'color:var(--accent);border:1px solid rgba(0,184,177,.3);';+ return `<a href="${url}" download="${escapeHtml(f.file)}" data-tooltip="${escapeHtml(tip)}" style="${style}font-size:10.5px;text-decoration:none;border-radius:4px;padding:1px 7px;margin-left:6px;white-space:nowrap">${f.original?'⭳':'⬇'} ${escapeHtml(f.name)}</a>`;+ }).join('');+ const childRows = (c) => (c.sources || []).map(s => `+ <tr style="background:rgba(0,184,176,.035)">+ <td colspan="11" style="padding:4px 0 4px 30px;border-top:1px solid rgba(255,255,255,.04)">+ <span style="color:var(--accent);font-size:11px">↳</span>+ <span style="color:var(--text2);font-size:11.5px;font-weight:600;margin:0 20px 0 6px;display:inline-block;min-width:215px">${s.label}</span>+ <span style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px">sym</span> ${miniChk(s.sym, s.note)}+ <span style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-left:18px">fp</span> ${miniChk(s.fp, s.note)}+ <span style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-left:18px">3d chip</span> ${chk3d(s)}+ <span style="margin-left:22px;display:inline-flex;align-items:center">${dlLinks(c, s)}</span>+ </td>+ </tr>`).join('');+ const rows = (data.chips || []).map(c => {+ const reqCells = REQ.map(([k]) => cell(!!(c.required && c.required[k]))).join('');+ const nm = c.complete ? 'var(--green,#34d17e)' : '#ff5252';+ return `<tr>+ <td style="font-family:'JetBrains Mono',monospace;font-size:12px;white-space:nowrap;padding-right:14px;color:var(--text)">${c.mpn}</td>+ ${reqCells}+ <td style="text-align:center">${cell(!!(c.extra && c.extra.fusion_lbr)).replace('<td','<span').replace('</td>','</span>')}</td>+ ${consensusCell(c.consensus)}+ <td style="text-align:center;font-weight:700;color:${nm};white-space:nowrap">${c.complete ? '✓ COMPLETE' : '✗ INCOMPLETE'}</td>+ </tr>${childRows(c)}`;+ }).join('');+ const head = REQ.map(([,l]) => `<th style="font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);padding:0 4px">${l}</th>`).join('');+ host.innerHTML = `+ <div style="display:flex;align-items:center;gap:14px;margin-bottom:14px">+ <div style="font-size:18px;font-weight:700;color:var(--text)">Validation${data.project ? ' — ' + data.project : ''}</div>+ <div style="font-size:13px;font-weight:700;padding:5px 12px;border-radius:6px;background:${allGreen ? 'rgba(52,209,126,.18)' : 'rgba(255,82,82,.16)'};color:${allGreen ? 'var(--green,#34d17e)' : '#ff5252'};border:1px solid ${allGreen ? 'rgba(52,209,126,.5)' : 'rgba(255,82,82,.45)'}">+ ${s.complete || 0} / ${s.total || 0} fully complete${allGreen ? ' — ALL GREEN ✓' : ' — ' + (s.incomplete || 0) + ' INCOMPLETE'}+ </div>+ <div style="font-size:11px;color:var(--muted)">required: datasheet + STEP + symbol + footprint + 3 thumbnails. Auto-refreshes.</div>+ </div>+ <table style="border-collapse:collapse;width:100%">+ <thead><tr>+ <th style="text-align:left;font-size:11px;color:var(--muted)">MPN</th>${head}+ <th style="font-size:10px;color:var(--muted)">.lbr</th>+ <th style="font-size:10px;color:var(--muted)" data-tooltip="adom-concur — cross-source consensus: do the KiCad / EAGLE / Altium / ds2sf variants of this part agree?">CONSENSUS<br><span style="font-size:8px;color:var(--accent)">adom-concur</span></th>+ <th style="font-size:11px;color:var(--muted)">VERDICT</th>+ </tr></thead>+ <tbody>${rows || '<tr><td colspan="10" style="color:var(--muted);padding:14px">no chips in scope</td></tr>'}</tbody>+ </table>`;+}+document.getElementById('btn-validate').addEventListener('click', () => { if (sourcesOn) setSourcesView(false); setValidationView(!validationOn); });++// ---------------- Sourcing priority tab ----------------+// The exact, code-backed order chip-fetcher searches the internet for data.+// Rendered straight from /api/sources so it can NEVER drift from real behavior.+let sourcesOn = false;+const TIER_STYLE = {+ 'Manufacturer': { bg:'rgba(0,184,176,.16)', fg:'#00e6dc', bd:'rgba(0,184,176,.5)' },+ 'Adom tool': { bg:'rgba(140,107,247,.18)', fg:'#b9a4ff', bd:'rgba(140,107,247,.55)' },+ 'Aggregator skill':{ bg:'rgba(100,171,255,.15)', fg:'#8cc2ff', bd:'rgba(100,171,255,.5)' },+ 'Last resort': { bg:'rgba(255,82,82,.14)', fg:'#ff7676', bd:'rgba(255,82,82,.45)' },+};+function setSourcesView(on) {+ sourcesOn = on;+ if (on && validationOn) setValidationView(false);+ document.getElementById('grid-view').style.display = on ? 'none' : '';+ document.getElementById('sources-view').style.display = on ? 'block' : 'none';+ document.getElementById('btn-sources').style.background = on ? 'var(--accent)' : '';+ document.getElementById('btn-sources').style.color = on ? '#fff' : '';+ if (on) renderSources();+}+async function renderSources() {+ const host = document.getElementById('sources-view');+ let data;+ try { data = await (await fetch(API_BASE + '/api/sources?v=' + Date.now())).json(); }+ catch (e) { host.innerHTML = '<div class="empty-state">sources API error</div>'; return; }+ const badge = (tier) => {+ const s = TIER_STYLE[tier] || { bg:'rgba(255,255,255,.06)', fg:'var(--muted)', bd:'var(--border)' };+ return `<span style="font-size:9.5px;text-transform:uppercase;letter-spacing:.6px;font-weight:700;padding:2px 7px;border-radius:5px;background:${s.bg};color:${s.fg};border:1px solid ${s.bd};white-space:nowrap">${tier}</span>`;+ };+ const concerns = (data.concerns || []).map(c => {+ const steps = (c.steps || []).map((st, i) => `+ <div style="display:flex;gap:12px;align-items:flex-start;padding:9px 0;border-top:1px solid var(--border)">+ <div style="font-family:'JetBrains Mono',monospace;font-size:13px;font-weight:700;color:var(--accent);min-width:22px;text-align:right">${i + 1}.</div>+ <div style="min-width:118px;display:flex;flex-direction:column;gap:5px">${badge(st.tier)}</div>+ <div style="flex:1;min-width:0">+ <div style="font-size:14px;font-weight:700;color:var(--text)">${st.name}</div>+ <div style="font-size:12.5px;color:var(--text2);line-height:1.45;margin-top:2px">${st.detail || ''}</div>+ ${st.cmd ? `<code style="display:inline-block;margin-top:5px;font-size:11.5px;color:var(--accent);background:rgba(0,184,176,.07);border:1px solid rgba(0,184,176,.2);border-radius:5px;padding:2px 7px">${st.cmd}</code>` : ''}+ ${st.skill ? `<span style="font-size:10.5px;color:var(--muted);margin-left:6px">↳ ${st.skill}</span>` : ''}+ </div>+ </div>`).join('');+ return `+ <div style="margin-bottom:26px;background:var(--card,rgba(255,255,255,.02));border:1px solid var(--border);border-radius:12px;padding:16px 18px">+ <div style="font-size:16px;font-weight:800;color:var(--text)">${c.concern}</div>+ <div style="font-size:12.5px;color:var(--text2);margin:4px 0 6px">${c.what || ''}</div>+ <div style="font-size:12px;color:var(--accent);background:rgba(0,184,176,.07);border-left:3px solid var(--accent);padding:6px 10px;border-radius:0 6px 6px 0;margin-bottom:6px">▸ ${c.rule || ''}</div>+ ${steps}+ </div>`;+ }).join('');+ host.innerHTML = `+ <div style="max-width:920px">+ <div style="font-size:20px;font-weight:800;color:var(--text);margin-bottom:4px">Sourcing priority — how chip-fetcher searches the internet</div>+ <div style="font-size:12.5px;color:var(--muted);margin-bottom:18px">The real, code-backed order (served from <code>/api/sources</code>). Three concerns, each searched top-to-bottom. <b style="color:var(--text2)">Manufacturer-first for CAD; Adom data tools (keys baked in, always work) for metadata; manual-crawl skills are the fallback; LCSC is dead last.</b></div>+ ${concerns}+ </div>`;+}+document.getElementById('btn-sources').addEventListener('click', () => setSourcesView(!sourcesOn));++// ---------------- credential vault ----------------+const credsModal = document.getElementById('creds-modal');+const credsMsg = document.getElementById('cred-msg');+document.getElementById('btn-creds').addEventListener('click', () => {+ credsModal.classList.add('open');+ credsMsg.textContent = '';+ loadCreds();+});+document.getElementById('cred-close').addEventListener('click', () => credsModal.classList.remove('open'));+credsModal.addEventListener('click', (e) => { if (e.target === credsModal) credsModal.classList.remove('open'); });++async function loadCreds() {+ const list = document.getElementById('creds-list');+ list.innerHTML = '<div class="info">loading…</div>';+ try {+ const r = await fetch(API_BASE + '/api/credentials');+ if (!r.ok) {+ const err = await r.text();+ list.innerHTML = '<div class="info">credential vault unavailable: ' + escapeHtml(err) + '</div>';+ return;+ }+ const d = await r.json();+ const creds = d.credentials || [];+ if (creds.length === 0) {+ list.innerHTML = '<div class="info">no credentials saved yet</div>';+ return;+ }+ list.innerHTML = creds.map(c =>+ `<div class="row"><div><code>${escapeHtml(c.host)}</code><div class="info">${escapeHtml(c.username)} · <span class="pw-mask" data-pw="${escapeHtml(c.password || '')}" data-tooltip="Click to reveal / hide this saved password." style="cursor:pointer; opacity:.7; user-select:none;">••••••••</span></div></div>` ++ `<button data-host="${escapeHtml(c.host)}">delete</button></div>`+ ).join('');+ list.querySelectorAll('button').forEach(btn => {+ btn.onclick = () => deleteCred(btn.dataset.host);+ });+ list.querySelectorAll('.pw-mask').forEach(span => {+ span.onclick = () => {+ const masked = span.textContent === '••••••••';+ span.textContent = masked ? span.dataset.pw : '••••••••';+ span.setAttribute('data-tooltip', masked ? 'Click to hide this password.' : 'Click to reveal this password.');+ span.style.userSelect = masked ? 'text' : 'none';+ };+ });+ } catch (e) {+ list.innerHTML = '<div class="info">load error: ' + escapeHtml(e.message) + '</div>';+ }+}++async function deleteCred(host) {+ if (!confirm('Delete credentials for ' + host + '?')) return;+ const r = await fetch(API_BASE + '/api/credentials/' + encodeURIComponent(host), {method: 'DELETE'});+ showMsg(r.ok ? 'deleted' : 'delete failed', r.ok);+ loadCreds();+}++document.getElementById('cred-save').addEventListener('click', async () => {+ const host = document.getElementById('cred-host').value.trim();+ const username = document.getElementById('cred-user').value.trim();+ const password = document.getElementById('cred-pass').value;+ if (!host || !username || !password) {+ showMsg('host, username, password all required', false);+ return;+ }+ showMsg('saving…', true);+ try {+ const r = await fetch(API_BASE + '/api/credentials', {+ method: 'POST',+ headers: {'Content-Type': 'application/json'},+ body: JSON.stringify({host, username, password})+ });+ if (r.ok) {+ showMsg('credentials saved', true);+ document.getElementById('cred-pass').value = '';+ loadCreds();+ } else {+ const err = await r.text();+ showMsg('save failed: ' + err, false);+ }+ } catch (e) {+ showMsg('save error: ' + e.message, false);+ }+});++function showMsg(text, ok) {+ credsMsg.className = 'msg ' + (ok ? 'ok' : 'err');+ credsMsg.textContent = text;+}++load().catch(e => {+ document.getElementById('cards').innerHTML = '<div class="empty-state">load error: ' + e.message + '</div>';+});+// ---- Tooltip system per human-ui-patterns skill ----+// Body-appended position:fixed div at z-index 99999, 600ms delay,+// auto-flip when near viewport edge. Every interactive element uses+// data-tooltip="..." for descriptive, newbie-friendly help text.+(function initTooltips() {+ const tip = document.createElement('div');+ tip.style.cssText = 'position:fixed;z-index:99999;background:#1a1f2e;color:#e6edf3;border:1px solid #3d4450;border-radius:6px;padding:6px 10px;font-size:12px;line-height:1.4;max-width:min(320px,calc(100vw - 40px));pointer-events:none;opacity:0;transition:opacity .15s;white-space:pre-wrap;font-family:Satoshi,-apple-system,BlinkMacSystemFont,sans-serif;box-shadow:0 4px 16px rgba(0,0,0,0.5)';+ document.body.appendChild(tip);+ let timer = null;+ // Touch-primary devices (phones) emulate mouseover on tap but never fire+ // mouseout, so hover-tooltips stick and cover content. Disable them there;+ // touch users tap elements directly. Also dismiss on any tap/scroll.+ const TOUCH = window.matchMedia && window.matchMedia('(hover: none)').matches;+ const hide = () => { clearTimeout(timer); tip.style.opacity = '0'; };+ document.addEventListener('touchstart', hide, { passive: true });+ window.addEventListener('scroll', hide, { passive: true, capture: true });+ document.addEventListener('mouseover', (ev) => {+ if (TOUCH) return;+ const el = ev.target.closest('[data-tooltip]');+ if (!el) return;+ clearTimeout(timer);+ timer = setTimeout(() => {+ tip.textContent = el.dataset.tooltip;+ tip.style.opacity = '1';+ const r = el.getBoundingClientRect();+ let left = r.left;+ tip.style.left = '0'; tip.style.top = '0'; // reset for measurement+ const tw = tip.offsetWidth, th = tip.offsetHeight;+ // Prefer ABOVE the element — these chips/badges/pulldowns sit at the top+ // of their card, so a tooltip below covers the thumbnail. Flip below only+ // when there genuinely isn't room above (element near the viewport top).+ let top = r.top - th - 6;+ if (top < 8) top = r.bottom + 6;+ if (left + tw > window.innerWidth - 8) left = window.innerWidth - tw - 8;+ if (left < 8) left = 8;+ tip.style.left = left + 'px';+ tip.style.top = top + 'px';+ }, 600);+ });+ document.addEventListener('mouseout', (ev) => {+ const el = ev.target.closest('[data-tooltip]');+ if (!el) return;+ clearTimeout(timer);+ tip.style.opacity = '0';+ });+})();+</script>+</body>+</html>
src/validate.rsadded+662@@ -0,0 +1,662 @@+// Validate a chip directory by running ds2sf + concur in sequence and+// walking their hints[] arrays to drive autonomous self-repair.+//+// Both tools are agent-to-agent: each emits a stable+// <MPN>-{extraction,concur}.result.json with `status`, `exit_code`,+// `hints[]`, and (for concur) `variants[]`. We mirror those types via+// serde tagged-union deserialization and `match` on the action / kind.+//+// Read both contracts before touching this file:+// ~/.claude/skills/adom-ds2sf/SKILL.md+// ~/.claude/skills/adom-concur/SKILL.md++use anyhow::{anyhow, Context, Result};+use serde::Deserialize;+use std::collections::HashSet;+use std::path::{Path, PathBuf};+use std::process::Command;++// ---------- ds2sf result.json types ----------++#[derive(Debug, Deserialize)]+pub struct Ds2sfResult {+ pub schema_version: String,+ pub status: String, // "ok" | "needs_better_pdf" | "unrecoverable"+ pub exit_code: i32,+ pub mpn: String,+ #[serde(default)]+ pub manufacturer: Option<String>,+ pub datasheet_used: PathBuf,+ #[serde(default)]+ pub datasheet_sha256: Option<String>,+ #[serde(default)]+ pub summary: Option<String>,+ #[serde(default)]+ pub review_required: Vec<serde_json::Value>,+ #[serde(default)]+ pub hints: Vec<Ds2sfHint>,+ #[serde(default)]+ pub outputs: Option<Ds2sfOutputs>,+}++#[derive(Debug, Deserialize)]+pub struct Ds2sfOutputs {+ #[serde(default)]+ pub symbol_json: Option<PathBuf>,+ #[serde(default)]+ pub footprint_json: Option<PathBuf>,+ #[serde(default)]+ pub provenance_json: Option<PathBuf>,+}++#[derive(Debug, Deserialize)]+#[serde(tag = "action", rename_all = "snake_case")]+pub enum Ds2sfHint {+ TryAlternatePdf {+ #[serde(default)]+ reason: Option<String>,+ #[serde(default)]+ candidates: Vec<PathBuf>,+ },+ UpdateUpstreamMetadata {+ file: PathBuf,+ key: String,+ #[serde(default)]+ current: Option<serde_json::Value>,+ extracted: serde_json::Value,+ },+ UseDatasheetExtractedFootprint {+ #[serde(default)]+ package: Option<String>,+ },+ HumanReview {+ #[serde(default)]+ reason: Option<String>,+ #[serde(default)]+ notes: Option<String>,+ },+ // Catch-all so a future hint kind doesn't make deserialize fail.+ #[serde(other)]+ Unknown,+}++// ---------- concur result.json types ----------++#[derive(Debug, Deserialize)]+pub struct ConcurResult {+ pub schema_version: String,+ pub status: String, // "golden" | "golden_with_variants" | "divergent" | "unrecoverable"+ pub exit_code: i32,+ pub mpn: String,+ #[serde(default)]+ pub summary: Option<String>,+ #[serde(default)]+ pub sources_compared: Vec<String>,+ #[serde(default)]+ pub sources_missing: Vec<String>,+ #[serde(default)]+ pub axes: Vec<serde_json::Value>,+ #[serde(default)]+ pub hints: Vec<ConcurHint>,+ #[serde(default)]+ pub variants: Vec<ConcurVariant>,+}++#[derive(Debug, Deserialize)]+#[serde(tag = "action", rename_all = "snake_case")]+pub enum ConcurHint {+ RefetchLibrarySource {+ format: String, // "kicad_sym" | "kicad_mod" | "eagle"+ #[serde(default)]+ suspect: Option<String>,+ #[serde(default)]+ notes: Option<String>,+ #[serde(default)]+ suggested_sources: Vec<String>,+ #[serde(default)]+ expected_resolution: Option<String>,+ },+ UlSymbolHasStubNames {+ source: String,+ #[serde(default)]+ pin_count: Option<u32>,+ #[serde(default)]+ suggestion: Option<String>,+ #[serde(default)]+ chip_fetcher_action: Option<String>,+ },+ RerunDs2sfWithStrongerModel {+ #[serde(default)]+ reason: Option<String>,+ #[serde(default)]+ suspect_field: Option<String>,+ #[serde(default)]+ suggested_model: Option<String>,+ },+ AcceptWithCaveats {+ #[serde(default)]+ caveats: Vec<String>,+ },+ HumanReview {+ #[serde(default)]+ reason: Option<String>,+ #[serde(default)]+ notes: Option<String>,+ },+ SourceParseFailed {+ format: String,+ #[serde(default)]+ path: Option<PathBuf>,+ #[serde(default)]+ error: Option<String>,+ },+ #[serde(other)]+ Unknown,+}++#[derive(Debug, Deserialize, Clone)]+pub struct ConcurVariant {+ pub kind: String, // tagged-string per contract+ #[serde(flatten)]+ pub extra: std::collections::BTreeMap<String, serde_json::Value>,+}++// ---------- public outcome ----------++#[derive(Debug)]+pub enum ValidationOutcome {+ /// concur reported `golden` — every source agrees exactly.+ Golden { mpn: String },+ /// concur reported `golden_with_variants` — disagreements all explained+ /// by variant kinds. Variants persisted to `<MPN>-validation-status.json`.+ GoldenWithVariants {+ mpn: String,+ variants: Vec<ConcurVariant>,+ used_ds2sf_preferred_symbol: bool,+ },+ /// We exhausted automated repairs (or hit a step that requires human+ /// judgment). Final state of result.json saved alongside the chip.+ NeedsHuman {+ mpn: String,+ reason: String,+ last_concur_status: Option<String>,+ last_ds2sf_status: Option<String>,+ },+ /// ds2sf came back with `unrecoverable` exit code 3.+ Ds2sfUnrecoverable { mpn: String, summary: Option<String> },+}++// ---------- driver ----------++const MAX_DS2SF_PDF_TRIES: usize = 6;+const MAX_CONCUR_REPAIRS: usize = 5;++pub fn validate_chip_dir(dir: &Path) -> Result<ValidationOutcome> {+ let mpn = dir.file_name()+ .and_then(|n| n.to_str())+ .ok_or_else(|| anyhow!("chip dir has no basename: {}", dir.display()))?+ .to_string();++ println!("OK: validate {} — running ds2sf …", mpn);+ let mut tried_pdfs: HashSet<PathBuf> = HashSet::new();++ let mut ds2sf = run_ds2sf(dir, None)?;+ tried_pdfs.insert(ds2sf.datasheet_used.clone());++ // Phase 1 — needs_better_pdf loop+ let mut tries = 0usize;+ while ds2sf.status == "needs_better_pdf" {+ tries += 1;+ if tries > MAX_DS2SF_PDF_TRIES {+ return Ok(ValidationOutcome::NeedsHuman {+ mpn,+ reason: format!("ds2sf needs_better_pdf after {tries} attempts; out of candidates"),+ last_concur_status: None,+ last_ds2sf_status: Some("needs_better_pdf".into()),+ });+ }+ let next = ds2sf.hints.iter().find_map(|h| match h {+ Ds2sfHint::TryAlternatePdf { candidates, .. } => candidates.iter().find(|c| !tried_pdfs.contains(*c)).cloned(),+ _ => None,+ });+ let Some(next) = next else {+ return Ok(ValidationOutcome::NeedsHuman {+ mpn,+ reason: "ds2sf needs_better_pdf — no untried candidate PDFs in hints[]".into(),+ last_concur_status: None,+ last_ds2sf_status: Some("needs_better_pdf".into()),+ });+ };+ println!("OK: ds2sf wants alternate PDF → trying {}", next.display());+ tried_pdfs.insert(next.clone());+ ds2sf = run_ds2sf(dir, Some(&next))?;+ }++ if ds2sf.status == "unrecoverable" {+ return Ok(ValidationOutcome::Ds2sfUnrecoverable {+ mpn,+ summary: ds2sf.summary,+ });+ }+ if ds2sf.status != "ok" {+ return Ok(ValidationOutcome::NeedsHuman {+ mpn,+ reason: format!("ds2sf returned unexpected status {:?}", ds2sf.status),+ last_concur_status: None,+ last_ds2sf_status: Some(ds2sf.status),+ });+ }++ // Apply update_upstream_metadata hints from ds2sf — these are+ // info.json corrections (e.g. wrong package family detected).+ apply_ds2sf_metadata_patches(&ds2sf);++ // Phase 2 — concur loop+ println!("OK: ds2sf complete; running concur …");+ let mut tried_repairs: HashSet<String> = HashSet::new();+ let mut used_ds2sf_preferred_symbol = false;+ let mut last_concur_status = String::new();+ let mut last_concur: Option<ConcurResult> = None;++ for round in 1..=MAX_CONCUR_REPAIRS {+ let r = run_concur(dir)?;+ last_concur_status = r.status.clone();++ match r.status.as_str() {+ "golden" | "golden_with_variants" => {+ // ul_symbol_has_stub_names is a "do not refetch" hint —+ // mark the symbol source as ds2sf-preferred so downstream+ // sym_create authors a fresh symbol from ds2sf JSON.+ for h in &r.hints {+ if let ConcurHint::UlSymbolHasStubNames { .. } = h {+ if let Err(e) = mark_symbol_source_ds2sf_preferred(dir) {+ eprintln!("WARN: could not mark {mpn} symbol source ds2sf-preferred: {e}");+ } else {+ used_ds2sf_preferred_symbol = true;+ }+ }+ }+ let _ = persist_validation_status(dir, &r);+ if r.status == "golden" {+ return Ok(ValidationOutcome::Golden { mpn });+ } else {+ return Ok(ValidationOutcome::GoldenWithVariants {+ mpn,+ variants: r.variants.clone(),+ used_ds2sf_preferred_symbol,+ });+ }+ }+ "divergent" => {+ // Walk hints in order; first action that's not been tried+ // wins. Stop iterating once we acted (or once we determined+ // no autonomous repair is available).+ let mut acted = false;+ for h in &r.hints {+ match h {+ ConcurHint::RerunDs2sfWithStrongerModel { suggested_model, reason, .. } => {+ let key = "ds2sf_rerun".to_string();+ if tried_repairs.insert(key) {+ let model = suggested_model.clone().unwrap_or_else(|| "claude-opus-4-7".into());+ println!("OK: concur round {round} — re-running ds2sf with model {model} (reason: {})",+ reason.as_deref().unwrap_or("?"));+ ds2sf = run_ds2sf_with_model(dir, &model)?;+ acted = true;+ break;+ }+ }+ ConcurHint::RefetchLibrarySource { format, suggested_sources, notes, suspect, .. } => {+ // Try the first untried (format, source) pair.+ // If the refetch surface returns "not implemented"+ // (current state — chip-fetcher's per-source refetch+ // CLI isn't wired yet), record ALL suggested_sources+ // in info.json.fetched_via_chain so the human has+ // the priority-ordered list, then surface gracefully.+ let key = format!("refetch_attempted|{format}");+ if !tried_repairs.contains(&key) {+ let pick = suggested_sources.iter().next().cloned();+ if let Some(ref src) = pick {+ println!("OK: concur round {round} — refetch_library_source {format} from {src} (suspect: {}, {})",+ suspect.as_deref().unwrap_or("?"),+ notes.as_deref().unwrap_or("no notes"));+ match attempt_refetch_library(dir, format, src) {+ Ok(()) => { acted = true; tried_repairs.insert(key); break; }+ Err(e) => {+ eprintln!("WARN: refetch {format}/{src} failed: {e}");+ // Record the full priority-ordered list to the chain+ // for later manual follow-up, and stop trying refetches+ // for this format — every hint here is variant of "fix+ // the kicad_sym," and they all fail the same way.+ for s in suggested_sources {+ let _ = attempt_refetch_library(dir, format, s); // appends to chain, returns Err+ }+ tried_repairs.insert(key);+ // Don't break — let the OUTER for loop pick a different+ // hint kind (e.g. UlSymbolHasStubNames) if present.+ }+ }+ }+ }+ }+ ConcurHint::UlSymbolHasStubNames { .. } => {+ // Even on divergent, this hint means "don't+ // refetch the .kicad_sym, prefer ds2sf." Mark+ // and treat as soft repair (not a refetch).+ let key = "ds2sf_preferred_symbol".to_string();+ if tried_repairs.insert(key) {+ if let Err(e) = mark_symbol_source_ds2sf_preferred(dir) {+ eprintln!("WARN: could not mark ds2sf-preferred: {e}");+ } else {+ println!("OK: concur round {round} — marked symbol source ds2sf-preferred (UL stub names)");+ used_ds2sf_preferred_symbol = true;+ acted = true;+ }+ break;+ }+ }+ _ => { /* AcceptWithCaveats / HumanReview / SourceParseFailed / Unknown — no autonomous action */ }+ }+ }+ last_concur = Some(r);+ if !acted {+ let lc = last_concur.unwrap();+ let _ = persist_validation_status(dir, &lc);+ return Ok(ValidationOutcome::NeedsHuman {+ mpn,+ reason: human_reason_from_hints(&lc),+ last_concur_status: Some(last_concur_status),+ last_ds2sf_status: Some("ok".into()),+ });+ }+ }+ "unrecoverable" => {+ let _ = persist_validation_status(dir, &r);+ return Ok(ValidationOutcome::NeedsHuman {+ mpn,+ reason: r.summary.clone().unwrap_or_else(|| "concur unrecoverable".into()),+ last_concur_status: Some(last_concur_status),+ last_ds2sf_status: Some("ok".into()),+ });+ }+ other => {+ return Ok(ValidationOutcome::NeedsHuman {+ mpn,+ reason: format!("concur returned unexpected status {other:?}"),+ last_concur_status: Some(last_concur_status),+ last_ds2sf_status: Some("ok".into()),+ });+ }+ }+ }++ Ok(ValidationOutcome::NeedsHuman {+ mpn,+ reason: format!("concur still divergent after {MAX_CONCUR_REPAIRS} repair rounds"),+ last_concur_status: Some(last_concur_status),+ last_ds2sf_status: Some("ok".into()),+ })+}++// ---------- subprocess helpers ----------++fn run_ds2sf(chip_dir: &Path, pdf_override: Option<&Path>) -> Result<Ds2sfResult> {+ let mut cmd = Command::new("adom-ds2sf");+ cmd.arg("extract").arg(chip_dir).arg("--force").arg("--json-only");+ if let Some(p) = pdf_override {+ cmd.arg("--pdf").arg(p);+ }+ let _ = cmd.status().with_context(|| "running ds2sf extract")?;+ read_ds2sf_result(chip_dir)+}++fn run_ds2sf_with_model(chip_dir: &Path, model: &str) -> Result<Ds2sfResult> {+ let mut cmd = Command::new("adom-ds2sf");+ cmd.arg("extract").arg(chip_dir)+ .arg("--force").arg("--json-only")+ .arg("--model").arg(model);+ let _ = cmd.status().with_context(|| "running ds2sf extract --model")?;+ read_ds2sf_result(chip_dir)+}++fn read_ds2sf_result(chip_dir: &Path) -> Result<Ds2sfResult> {+ let mpn = chip_dir.file_name().and_then(|n| n.to_str())+ .ok_or_else(|| anyhow!("bad chip dir"))?;+ let path = chip_dir.join(format!("{mpn}-extraction.result.json"));+ let s = std::fs::read_to_string(&path)+ .with_context(|| format!("reading {}", path.display()))?;+ let r: Ds2sfResult = serde_json::from_str(&s)+ .with_context(|| format!("parsing ds2sf result.json: {}", path.display()))?;+ Ok(r)+}++fn run_concur(chip_dir: &Path) -> Result<ConcurResult> {+ let mut cmd = Command::new("adom-concur");+ cmd.arg("check").arg(chip_dir).arg("--json-only");+ let _ = cmd.status().with_context(|| "running concur check")?;+ read_concur_result(chip_dir)+}++fn read_concur_result(chip_dir: &Path) -> Result<ConcurResult> {+ let mpn = chip_dir.file_name().and_then(|n| n.to_str())+ .ok_or_else(|| anyhow!("bad chip dir"))?;+ let path = chip_dir.join(format!("{mpn}-concur.result.json"));+ let s = std::fs::read_to_string(&path)+ .with_context(|| format!("reading {}", path.display()))?;+ let r: ConcurResult = serde_json::from_str(&s)+ .with_context(|| format!("parsing concur result.json: {}", path.display()))?;+ Ok(r)+}++// ---------- side-effects ----------++/// Apply ds2sf's update_upstream_metadata hints — patch info.json fields+/// where ds2sf detected the chip-fetcher hint was wrong (e.g. wrong+/// package family). Best-effort; won't unwind a partial patch.+fn apply_ds2sf_metadata_patches(r: &Ds2sfResult) {+ for h in &r.hints {+ if let Ds2sfHint::UpdateUpstreamMetadata { file, key, extracted, .. } = h {+ if let Err(e) = patch_json_key(file, key, extracted) {+ eprintln!("WARN: could not apply update_upstream_metadata to {} (key={key}): {e}", file.display());+ } else {+ println!("OK: patched {} → {key} := {}", file.display(), extracted);+ }+ }+ }+}++fn patch_json_key(path: &Path, key: &str, value: &serde_json::Value) -> Result<()> {+ let s = std::fs::read_to_string(path)+ .with_context(|| format!("reading {}", path.display()))?;+ let mut v: serde_json::Value = serde_json::from_str(&s)+ .with_context(|| format!("parsing {}", path.display()))?;+ if let Some(obj) = v.as_object_mut() {+ obj.insert(key.to_string(), value.clone());+ } else {+ return Err(anyhow!("{} is not a JSON object", path.display()));+ }+ std::fs::write(path, serde_json::to_string_pretty(&v)?)?;+ Ok(())+}++/// Mark the chip dir's symbol source as `ds2sf-preferred` so downstream+/// `sym_create` authors a fresh symbol from ds2sf's extracted JSON+/// rather than the UL stub-named .kicad_sym.+fn mark_symbol_source_ds2sf_preferred(chip_dir: &Path) -> Result<()> {+ let info = chip_dir.join("info.json");+ let s = std::fs::read_to_string(&info)+ .with_context(|| format!("reading {}", info.display()))?;+ let mut v: serde_json::Value = serde_json::from_str(&s)?;+ if let Some(obj) = v.as_object_mut() {+ obj.insert("symbol_source".to_string(), serde_json::Value::String("ds2sf-preferred".into()));+ obj.insert("symbol_source_reason".to_string(), serde_json::Value::String(+ "concur reported ul_symbol_has_stub_names; .kicad_sym pin names are stubs (the UL bundle did not carry real labels). Downstream sym_create should author from <MPN>-symbol.extracted.json instead.".into()+ ));+ }+ std::fs::write(&info, serde_json::to_string_pretty(&v)?)?;+ Ok(())+}++/// Write `<MPN>-validation-status.json` so downstream tools can read+/// the final state without re-running the whole pipeline.+fn persist_validation_status(chip_dir: &Path, r: &ConcurResult) -> Result<()> {+ let mpn = chip_dir.file_name().and_then(|n| n.to_str())+ .ok_or_else(|| anyhow!("bad chip dir"))?;+ let path = chip_dir.join(format!("{mpn}-validation-status.json"));+ let body = serde_json::json!({+ "schema_version": "1",+ "validated_at": std::time::SystemTime::now()+ .duration_since(std::time::UNIX_EPOCH)+ .map(|d| d.as_secs()).unwrap_or(0),+ "concur_status": r.status,+ "sources_compared": r.sources_compared,+ "sources_missing": r.sources_missing,+ "variants": r.variants.iter().map(|v| {+ let mut m = serde_json::Map::new();+ m.insert("kind".into(), serde_json::Value::String(v.kind.clone()));+ for (k, val) in &v.extra { m.insert(k.clone(), val.clone()); }+ serde_json::Value::Object(m)+ }).collect::<Vec<_>>(),+ "summary": r.summary,+ });+ std::fs::write(&path, serde_json::to_string_pretty(&body)?)?;+ println!("OK: validation status persisted to {}", path.display());+ Ok(())+}++/// Refetch a specific library file from a specific source by shelling to+/// `chip-fetcher refetch`. `concur`'s `suggested_sources` is the+/// priority-ordered list; the validate loop walks it and calls this+/// function for the first untried entry. Returns Ok(()) when refetch+/// landed a new file, Err otherwise — the loop then re-runs concur and+/// expects the suspect axis to flip verdict.+///+/// Source slug normalization: maps third-party variants ("snapeda",+/// "Manufacturer site: foo (longer note)") to canonical Source enum+/// before invoking the subprocess. concur's suggested_sources entries+/// often carry verbose context after a colon ("Manufacturer site: …+/// search the part number on the manufacturer's website…"), so we+/// extract just the leading slug for routing.+fn attempt_refetch_library(chip_dir: &Path, format: &str, source: &str) -> Result<()> {+ let mpn = chip_dir.file_name().and_then(|n| n.to_str())+ .ok_or_else(|| anyhow!("bad chip dir"))?;++ // concur emits sources as either bare slugs ("Manufacturer", "Ultra+ // Librarian", "SnapMagic / SnapEDA") or as longer descriptive strings+ // ("Manufacturer site: foo (search the part number…)"). Extract just+ // the routing slug so we can call refetch with a known source.+ let lower = source.to_ascii_lowercase();+ let source_slug = if lower.contains("manufacturer") || lower.contains("mfr") || lower.contains("vendor") {+ "manufacturer"+ } else if lower.contains("snapmagic") || lower.contains("snapeda") {+ "snapmagic"+ } else if lower.contains("mouser") {+ "mouser"+ } else if lower.contains("digikey") {+ "digikey"+ } else if lower.contains("arrow") {+ "arrow"+ } else if lower.contains("ultra librarian") || lower.contains("ultralibrarian") || lower.contains("componentsearchengine") || lower.contains("cse") {+ "cse_ul"+ } else if lower.contains("lcsc") {+ "lcsc"+ } else {+ let _ = crate::sourcing::append_chain(mpn, crate::sourcing::Source::Manufacturer,+ "refetch_unknown_source",+ Some(&format!("concur suggested {source:?} which doesn't map to a known ladder slug")));+ return Err(anyhow!("could not normalize source slug from {source:?}"));+ };++ println!("OK: invoking `adom-chip-fetcher refetch {mpn} --format {format} --source {source_slug}` (180s download window)");+ // Self-invoke by absolute path — name-independent across renames.+ let exe = std::env::current_exe().unwrap_or_else(|_| "adom-chip-fetcher".into());+ let status = std::process::Command::new(exe)+ .args(["refetch", mpn,+ "--format", format,+ "--source", source_slug])+ .status()+ .map_err(|e| anyhow!("could not exec adom-chip-fetcher refetch: {e}"))?;++ if status.success() {+ // refetch_cmd already appended an OK chain entry on success.+ Ok(())+ } else {+ // refetch_cmd appended a failure entry; we just propagate.+ Err(anyhow!("chip-fetcher refetch exited non-zero (timeout, user didn't click, or import failed) — see info.json.fetched_via_chain"))+ }+}++fn human_reason_from_hints(r: &ConcurResult) -> String {+ // Concise + deduped summary. Many divergent hints repeat the same+ // refetch suggestion (e.g. one per pin disagreement). Collapse to+ // unique action lines + a count of pin-level conflicts.+ let mut refetch_targets: std::collections::BTreeSet<(String, String)> =+ std::collections::BTreeSet::new(); // (format, top-source)+ let mut human_reasons: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();+ let mut parse_failures: Vec<(String, String)> = Vec::new();+ let mut refetch_count = 0usize;++ for h in &r.hints {+ match h {+ ConcurHint::RefetchLibrarySource { format, suggested_sources, .. } => {+ refetch_count += 1;+ if let Some(top) = suggested_sources.iter().next() {+ // Use just the leading slug, not the long verbose note.+ let trimmed = top.split(':').next().unwrap_or(top).trim().to_string();+ refetch_targets.insert((format.clone(), trimmed));+ }+ }+ ConcurHint::HumanReview { reason, .. } => {+ human_reasons.insert(reason.clone().unwrap_or_else(|| "?".into()));+ }+ ConcurHint::SourceParseFailed { format, error, .. } => {+ parse_failures.push((format.clone(), error.clone().unwrap_or_else(|| "?".into())));+ }+ _ => {}+ }+ }++ let mut parts: Vec<String> = Vec::new();+ if refetch_count > 0 {+ parts.push(format!("{refetch_count} divergent axis hint(s) → refetch needed: {}",+ refetch_targets.iter()+ .map(|(f, s)| format!("{f}@{s}"))+ .collect::<Vec<_>>().join(", ")));+ }+ for r in human_reasons { parts.push(format!("human_review:{r}")); }+ for (f, e) in parse_failures { parts.push(format!("parse_failed:{f}({e})")); }++ if parts.is_empty() {+ r.summary.clone().unwrap_or_else(|| "concur divergent — no actionable hints".into())+ } else {+ parts.join("; ")+ }+}++/// Pretty-print a ValidationOutcome for the CLI surface.+pub fn print_outcome(out: &ValidationOutcome) {+ match out {+ ValidationOutcome::Golden { mpn } => {+ println!("OK: {mpn} → GOLDEN (every source agrees)");+ }+ ValidationOutcome::GoldenWithVariants { mpn, variants, used_ds2sf_preferred_symbol } => {+ println!("OK: {mpn} → GOLDEN_WITH_VARIANTS ({} variant{})",+ variants.len(), if variants.len() == 1 { "" } else { "s" });+ for v in variants {+ println!(" variant: {}", v.kind);+ }+ if *used_ds2sf_preferred_symbol {+ println!(" note: symbol_source = ds2sf-preferred (UL bundle had stub pin names)");+ }+ }+ ValidationOutcome::NeedsHuman { mpn, reason, last_concur_status, last_ds2sf_status } => {+ println!("WARN: {mpn} → NEEDS_HUMAN");+ println!(" reason: {reason}");+ if let Some(s) = last_ds2sf_status { println!(" last ds2sf status: {s}"); }+ if let Some(s) = last_concur_status { println!(" last concur status: {s}"); }+ }+ ValidationOutcome::Ds2sfUnrecoverable { mpn, summary } => {+ println!("ERROR: {mpn} → DS2SF_UNRECOVERABLE");+ if let Some(s) = summary { println!(" {s}"); }+ }+ }+}
tool-map.htmladded+197@@ -0,0 +1,197 @@+<!doctype html>+<html lang="en">+<head>+<meta charset="utf-8">+<meta name="viewport" content="width=device-width, initial-scale=1">+<title>chip-fetcher — Tool Map</title>+<style>+ :root{+ --bg:#0a0e14; --panel:#0d1117; --ink:#e6edf3; --muted:#8b949e;+ --local:#34d17e; --local-bg:rgba(52,209,126,.08); --local-bd:rgba(52,209,126,.35);+ --remote:#ff9a4a; --remote-bg:rgba(255,154,74,.09); --remote-bd:rgba(255,154,74,.40);+ --desktop:#8c6bf7;--desktop-bg:rgba(140,107,247,.10);--desktop-bd:rgba(140,107,247,.40);+ --lib:#64abff; --lib-bg:rgba(100,171,255,.09); --lib-bd:rgba(100,171,255,.38);+ --hub:#00e6dc; --hub-bg:rgba(0,230,220,.10); --hub-bd:rgba(0,230,220,.45);+ --new:#ffd23f;+ }+ *{box-sizing:border-box}+ body{margin:0;background:+ radial-gradient(1200px 700px at 85% -10%,rgba(0,184,177,.10),transparent 60%),+ radial-gradient(900px 600px at -5% 110%,rgba(140,107,247,.10),transparent 55%),+ linear-gradient(155deg,#0a0e14,#0d1117 45%,#071318);+ color:var(--ink);font-family:'Familjen Grotesk',system-ui,-apple-system,Segoe UI,sans-serif;+ -webkit-font-smoothing:antialiased;padding:24px 16px 60px}+ h1{font-size:26px;margin:0 0 2px;letter-spacing:-.5px}+ h1 span{background:linear-gradient(100deg,#00e6dc,#39b8ff 60%,#8c6bf7);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}+ .sub{color:var(--muted);font-size:14px;margin:0 0 18px}+ .wrap{max-width:1100px;margin:0 auto}++ /* legend */+ .legend{display:flex;flex-wrap:wrap;gap:10px;margin:0 0 22px}+ .lg{display:flex;align-items:center;gap:7px;font-size:12.5px;color:var(--muted);+ border:1px solid #222a33;border-radius:999px;padding:5px 11px;background:#0e141c}+ .dot{width:11px;height:11px;border-radius:3px;flex:none}++ /* tiers */+ .tier{margin:0 0 14px}+ .tier-h{display:flex;align-items:baseline;gap:8px;margin:0 0 8px}+ .tier-h b{font-size:13px;letter-spacing:.06em;text-transform:uppercase;color:var(--ink)}+ .tier-h em{font-style:normal;color:var(--muted);font-size:12px}+ .row{display:flex;flex-wrap:wrap;gap:10px}++ .node{border-radius:12px;padding:10px 12px;min-width:150px;flex:1 1 150px;max-width:230px;+ border:1px solid #222a33;background:#0e141c;position:relative}+ .node .nm{font-weight:700;font-size:14px;display:flex;align-items:center;gap:6px}+ .node .dsc{color:var(--muted);font-size:11.5px;margin-top:3px;line-height:1.35}+ .node .calls{margin-top:7px;font-size:11px;color:#aeb8c4}+ .node .calls b{color:var(--remote)}+ .tag{font-size:9.5px;font-weight:700;letter-spacing:.04em;padding:2px 6px;border-radius:5px;text-transform:uppercase}+ .t-local{color:var(--local);background:var(--local-bg);border:1px solid var(--local-bd)}+ .t-remote{color:var(--remote);background:var(--remote-bg);border:1px solid var(--remote-bd)}+ .t-desktop{color:var(--desktop);background:var(--desktop-bg);border:1px solid var(--desktop-bd)}+ .t-lib{color:var(--lib);background:var(--lib-bg);border:1px solid var(--lib-bd)}+ .new-pill{font-size:9px;font-weight:800;color:#0a0e14;background:var(--new);border-radius:5px;padding:1px 5px;letter-spacing:.03em}++ .local{border-color:var(--local-bd)}+ .remote{border-color:var(--remote-bd);background:var(--remote-bg)}+ .desktop{border-color:var(--desktop-bd);background:var(--desktop-bg)}+ .lib{border-color:var(--lib-bd);background:var(--lib-bg)}++ .hub{border:1.5px solid var(--hub-bd);background:var(--hub-bg);max-width:none;flex-basis:100%}+ .hub .nm{font-size:18px;color:var(--hub)}++ .arrow{text-align:center;color:#3a4452;font-size:18px;margin:2px 0;line-height:1}++ /* explainer cards */+ .ex{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:26px 0 6px}+ @media(max-width:680px){.ex{grid-template-columns:1fr}}+ .card{border:1px solid #222a33;border-radius:14px;padding:14px 16px;background:#0e141c}+ .card h3{margin:0 0 6px;font-size:15px;display:flex;align-items:center;gap:8px}+ .card p{margin:0 0 8px;font-size:13px;color:#c4cdd8;line-height:1.5}+ .card .k{font-size:12px;color:var(--muted)}+ code{font-family:'JetBrains Mono',ui-monospace,monospace;font-size:11.5px;background:#11181f;border:1px solid #1c242d;padding:1px 5px;border-radius:5px;color:#cfe}++ .count{display:inline-flex;align-items:center;justify-content:center;min-width:22px;height:22px;+ padding:0 6px;border-radius:7px;background:var(--hub-bg);border:1px solid var(--hub-bd);color:var(--hub);font-weight:800;font-size:13px}+ .note{font-size:12px;color:var(--muted);margin-top:14px;line-height:1.5}+</style>+</head>+<body>+<div class="wrap">+ <h1><span>chip-fetcher</span> — tool map</h1>+ <p class="sub">What runs <em>in your container</em> vs. what we <em>call over the network</em>. Generated 2026-06-24 · updated for native Altium + EAGLE.</p>++ <div class="legend">+ <span class="lg"><span class="dot" style="background:var(--hub)"></span>orchestrator</span>+ <span class="lg"><span class="dot" style="background:var(--local)"></span>local CLI — runs in your container</span>+ <span class="lg"><span class="dot" style="background:var(--lib)"></span>local library (Rust crate, not a separate app)</span>+ <span class="lg"><span class="dot" style="background:var(--remote)"></span>remote service container — we call it over HTTPS</span>+ <span class="lg"><span class="dot" style="background:var(--desktop)"></span>your laptop — reached via adom-desktop bridge</span>+ </div>++ <!-- HUB -->+ <div class="tier">+ <div class="row">+ <div class="node hub">+ <div class="nm">⬢ adom-chip-fetcher</div>+ <div class="dsc">The orchestrator. Pulls every CAD format off the internet, converts + renders each, and shows them side-by-side so you can pick what to export.</div>+ </div>+ </div>+ </div>+ <div class="arrow">▼ shells out to (all local CLIs) ▼</div>++ <!-- CONVERSION + RENDER -->+ <div class="tier">+ <div class="tier-h"><b>Convert & render</b><em>turn each downloaded format into geometry + thumbnails</em></div>+ <div class="row">+ <div class="node local"><div class="nm">adom-sfconvert <span class="new-pill">NEW</span></div><div class="dsc">EAGLE/Fusion .lbr → KiCad AND Altium → KiCad — <b>fully native, pure Rust</b>.</div><div class="calls">native libs: <b style="color:var(--lib)">altium-schlib</b> + <b style="color:var(--lib)">altium-pcblib</b> + eagle — <b style="color:var(--local)">no service</b></div></div>+ <div class="node local"><div class="nm">adom-symbol</div><div class="dsc">Renders the schematic symbol (with 3D chip embossed).</div><div class="calls">→ <b>service-kicad</b></div></div>+ <div class="node local"><div class="nm">adom-footprint</div><div class="dsc">Renders the PCB footprint SVG.</div><div class="calls">→ <b>service-kicad</b></div></div>+ <div class="node local"><div class="nm">adom-ds2sf</div><div class="dsc">Extracts sym+footprint from the datasheet PDF.</div><div class="calls">local (uses <code>claude</code> + pdf tools)</div></div>+ <div class="node local"><div class="nm">adom-concur</div><div class="dsc">Scores whether all libs + datasheet AGREE (now incl. Altium symbol).</div><div class="calls">uses libs <b style="color:var(--lib)">altium-pcblib</b> + <b style="color:var(--lib)">altium-schlib</b></div></div>+ <div class="node local"><div class="nm">adom-step</div><div class="dsc">Handles the 3D STEP model.</div><div class="calls">→ <b>step2glb</b></div></div>+ <div class="node local"><div class="nm">adom-lbr</div><div class="dsc">Side-by-side sym/fp/3D embeddable.</div><div class="calls">→ <b>wiki</b></div></div>+ <div class="node local"><div class="nm">adom-chip-thumbnailer</div><div class="dsc">Bakes the card thumbnails (sym/fp/3D icons).</div><div class="calls">local (OCCT + xvfb)</div></div>+ <div class="node local"><div class="nm">adom-chipsmith</div><div class="dsc">Validates the 3D chip against its footprint.</div><div class="calls">→ <b>service-kicad</b>, <b>step2glb</b></div></div>+ </div>+ </div>++ <!-- LIBS -->+ <div class="tier">+ <div class="tier-h"><b>Local libraries</b><em>Rust crates compiled in — not separate apps</em></div>+ <div class="row">+ <div class="node lib"><div class="nm">altium-pcblib <span class="tag t-lib">lib</span></div><div class="dsc"><b>The Altium reader you built.</b> Parses .PcbLib/.IntLib binary → KiCad pad JSON + SVG. 100% local, no network.</div></div>+ <div class="node lib"><div class="nm">altium-schlib <span class="new-pill">NEW</span> <span class="tag t-lib">lib</span></div><div class="dsc"><b>Native Altium SYMBOL reader</b> — parses .SchLib binary → KiCad pin JSON + SVG. 100% local, validated 28/28 vs KiCad.</div></div>+ <div class="node lib"><div class="nm">eagle parser <span class="tag t-lib">lib</span></div><div class="dsc">Inside adom-sfconvert. Parses EAGLE/Fusion .lbr XML → KiCad. 100% local.</div></div>+ </div>+ </div>++ <!-- SOURCING -->+ <div class="tier">+ <div class="tier-h"><b>Sourcing</b><em>find the part & its data — each has a baked-in API key in its own service</em></div>+ <div class="row">+ <div class="node local"><div class="nm">adom-parts-search</div><div class="dsc">The umbrella search.</div><div class="calls">→ <b>mouser</b>, <b>digikey</b>, <b>jlcpcb</b></div></div>+ <div class="node local"><div class="nm">adom-mouser</div><div class="calls">→ <b>mouser service</b></div></div>+ <div class="node local"><div class="nm">adom-digikey</div><div class="calls">→ <b>digikey service</b></div></div>+ <div class="node local"><div class="nm">adom-jlcpcb</div><div class="calls">→ <b>jlcpcb service</b></div></div>+ </div>+ </div>++ <!-- BRIDGE -->+ <div class="tier">+ <div class="tier-h"><b>Bridge & infra</b><em>reach your laptop / the platform</em></div>+ <div class="row">+ <div class="node local"><div class="nm">adom-desktop</div><div class="dsc">Bridge to your real laptop.</div><div class="calls">→ <b style="color:var(--desktop)">your laptop</b></div></div>+ <div class="node local"><div class="nm">adom-cli</div><div class="dsc">Adom platform (containers, wiki, carbon).</div></div>+ <div class="node local"><div class="nm">adom-vscode</div><div class="dsc">Reveal files in the editor.</div></div>+ </div>+ </div>++ <div class="arrow">▼ the heavy lifting lives OFF your container ▼</div>++ <!-- REMOTE SERVICES -->+ <div class="tier">+ <div class="tier-h"><b>Remote service containers</b><em>shared, always-on, big installs you never put on your own container</em></div>+ <div class="row">+ <div class="node remote"><div class="nm">🌐 service-kicad <span class="tag t-remote">remote</span></div><div class="dsc"><b>KiCad 10</b> (~2 GB) headless. DRC/ERC, SVG/Gerber/STEP/GLB export. (Altium import is now native — see altium-schlib.)</div><div class="k" style="font-size:11px;color:var(--muted);margin-top:5px">kicad-rk5ue5pcfemi.adom.cloud</div></div>+ <div class="node remote"><div class="nm">🌐 step2glb <span class="tag t-remote">remote</span></div><div class="dsc">STEP → GLB 3D conversion (OCCT).</div><div class="k" style="font-size:11px;color:var(--muted);margin-top:5px">step2glb-…adom.cloud</div></div>+ <div class="node remote"><div class="nm">🌐 mouser / digikey / jlcpcb <span class="tag t-remote">remote ×3</span></div><div class="dsc">One service each, with the vendor API key baked in — so you never need a key.</div><div class="k" style="font-size:11px;color:var(--muted);margin-top:5px">mouser-… · digikey-… · jlcpcb-…</div></div>+ <div class="node remote"><div class="nm">🌐 wiki <span class="tag t-remote">remote</span></div><div class="dsc">Publish & install pages/skills.</div><div class="k" style="font-size:11px;color:var(--muted);margin-top:5px">wiki.adom.inc</div></div>+ </div>+ </div>++ <!-- DESKTOP -->+ <div class="tier">+ <div class="tier-h"><b>Your laptop</b><em>via the adom-desktop bridge — not a cloud service</em></div>+ <div class="row">+ <div class="node desktop"><div class="nm">🖥 real Chrome <span class="tag t-desktop">laptop</span></div><div class="dsc">Native-browser / pup automation for vendor downloads (your real logins).</div></div>+ <div class="node desktop"><div class="nm">🖥 KiCad / Fusion <span class="tag t-desktop">laptop</span></div><div class="dsc">Open the finished library in your actual EDA app.</div></div>+ </div>+ </div>++ <!-- EXPLAINERS -->+ <div class="ex">+ <div class="card">+ <h3>🟢 What's a “local CLI”?</h3>+ <p>A small command that's <b>installed in your cloud container</b> (in <code>~/.local/bin</code>) and runs right there. Fast, no network, nothing big to install. Example: <code>adom-sfconvert</code>, <code>adom-symbol</code>.</p>+ <p class="k">These are tiny Rust binaries (a few MB). They do the orchestration, parsing, and file-shuffling.</p>+ </div>+ <div class="card">+ <h3>🟠 What's a “remote service container”?</h3>+ <p>A <b>shared, always-on container we call over HTTPS</b>. It holds the heavy software (KiCad is ~2 GB) so it lives in <b>one</b> place instead of on every user's container.</p>+ <p class="k">When a local CLI needs KiCad, it POSTs a file to <code>service-kicad</code> and gets the result back. <b>Nothing ever installs KiCad on your container.</b></p>+ </div>+ </div>++ <div class="card" style="margin-top:14px">+ <h3><span class="count">15</span> apps chip-fetcher depends on</h3>+ <p>adom-sfconvert <span class="new-pill">NEW</span> · adom-symbol · adom-footprint · adom-ds2sf · adom-concur · adom-step · adom-lbr · adom-chip-thumbnailer · adom-chipsmith · adom-parts-search · adom-mouser · adom-digikey · adom-jlcpcb · adom-desktop · adom-cli (+ adom-vscode for editor reveal).</p>+ <p class="k">All 15 are <b style="color:var(--local)">local CLIs</b>. Between them they reach only <b style="color:var(--remote)">6 remote services</b> (service-kicad, step2glb, mouser, digikey, jlcpcb, wiki) and your laptop. The 2 GB KiCad never touches your container.</p>+ </div>++ <div class="note">+ <b>On Altium specifically:</b> <b style="color:var(--lib)">altium-pcblib</b> reads Altium footprints and <b style="color:var(--lib)">altium-schlib</b> reads Altium symbols (both native/local). <code>adom-altium</code> is an empty stub. The other Altium pages on the wiki — <code>adom-altium-library-importer</code>, <code>altium-part-importer</code>, <code>adom-desktop-altium-bridge</code> (all barrett-land) — go the <b>opposite</b> direction: they write parts <i>into</i> Altium Designer on the desktop. Reading Altium <b>symbols</b> (.SchLib) is now <b>native too</b> via <code>altium-schlib</code> — so as of this thread <b>nothing foreign touches service-kicad</b>; Altium + EAGLE convert entirely in your container.+ </div>+</div>+</body>+</html>
uninstall.sh+2−1@@ -1,2 +1,3 @@ #!/bin/bash-rm -f "${HOME}/.local/bin/adom-chip-fetcher"; echo "Removed adom-chip-fetcher"+set -euo pipefail+rm -f "${HOME}/.local/bin/chip-fetcher"; echo "Uninstalled chip-fetcher"