← Commit history
Cargo.tomladded+21
@@ -0,0 +1,21 @@+[package]+name = "chip-fetcher"+version = "1.2.23"+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 = "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
SKILL.mdadded+205
@@ -0,0 +1,205 @@+---+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.+---++# chip-fetcher++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++```+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+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++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+next, so the workflow becomes mechanical: the binary remembers, you+ask it.++```bash+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+  # the stage label for "Mouser empty" / "SnapMagic OK" patterns), prints+  # WARN per missing artifact, prints library-wide WARN with N/Total+  # incomplete count, and warns when stage phases skip ahead.++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.++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.++chip-fetcher sources+  # Print the canonical priority order. Single source of truth for+  # "what's the right ladder?"++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.++chip-fetcher selfcheck       # dashboard up + heartbeat fresh + pup session live+chip-fetcher hint            # next action based on current heartbeat state++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++chip-fetcher fetch <MPN> --project <name>  # fetch and auto-add to a project+```++Side-effect docs that matter:++- `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++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 always runs in pup `sessionId: "chip-fetcher"`** (separate from the dashboard) because trusted-click + captcha + vendor logins only work there. 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 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:"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 `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 `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 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 `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++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** — 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++**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).+- **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.++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 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.++## Critical gotchas (one-liners)++1. **UL download tokens are SINGLE-USE.** Re-fetching via `browser_eval` + `fetch()` will not work. Pull from user's `~/Downloads`.+2. **UL ships PACKAGE-named files inside the zip**, not MPN-named. Always pass `--mpn <MPN>` to import.+3. **PowerShell quoting via cmd.exe is terrible.** Use `-EncodedCommand` (UTF-16LE base64).+4. **`adom-desktop` shell timeout is 120s default.** Long-running shell commands get killed. Chunk into shorter calls.+5. **`chrome-error://chromewebdata/` after a Download click is NORMAL.** Chrome handles the binary as a download, can't render it as HTML. Check the Downloads folder; the download usually succeeded.+6. **CSE filename pattern is `LIB_<MPN>.zip`** (vs UL's `ul_<MPN>.zip`). Watcher regex: `^(ul_|LIB_).*\.zip$`.+7. **CSE STEP files are mfr-authored, FreeCAD-exported with named PRODUCTs.** Trust them as mfr-grade.+8. **Don't `.click()` on icon-only buttons that fire `loadPartDiv()` or similar.** Use `MouseEvent` dispatch with proper `clientX`/`clientY` from `getBoundingClientRect()`.+9. **Pup profiles don't inherit from main Chrome.** First login per service is mandatory; cookies persist after.+10. **Anti-automation flags already set** in pup launch (`--enable-automation=false`, `--disable-blink-features=AutomationControlled`). reCAPTCHA still requires a human or AI-vision click.++## Two-pup-window pattern++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: "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: "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/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
bin/chip-fetcheradded
⋯ 1 unchanged line ⋯
install.shadded+4
@@ -0,0 +1,4 @@+#!/bin/bash+set -euo pipefail+DIR="$(cd "$(dirname "$0")" && pwd)"; BIN="${HOME}/.local/bin"; mkdir -p "$BIN"+install -m 0755 "$DIR/bin/chip-fetcher" "$BIN/chip-fetcher"; echo "Installed chip-fetcher"
package.jsonadded+27
@@ -0,0 +1,27 @@+{+  "slug": "chip-fetcher",+  "version": "1.0.0",+  "type": "app",+  "description": "Your whole parts library \u2014 manufacturer-grade chip CAD (symbol, footprint, 3D) one tap from your EDA tool.",+  "dependencies": {},+  "tags": [+    "eda",+    "kicad",+    "parts",+    "components",+    "pcb",+    "cli"+  ],+  "hero": {+    "type": "billboard",+    "headline": "Chip Fetcher",+    "subhead": "Your whole parts library \u2014 one tap from your EDA tool.",+    "screenshot": "screenshots/shot.png",+    "accent": "#00b8b1"+  },+  "scripts": {+    "install": "./install.sh",+    "uninstall": "./uninstall.sh"+  },+  "needs_sudo": false+}\ No newline at end of file
page.json+24−31
@@ -1,39 +1,32 @@ {-  "schema_version": 1,   "slug": "chip-fetcher",-  "type": "app",   "version": "1.0.0",-  "title": "Chip Fetcher",-  "brief": "Download manufacturer-grade chip CAD bundles (STEP + KiCad sym/footprint + Fusion .lbr + datasheet) into a structured library, and open or ship each part to your EDA tool.",-  "readme": "# chip-fetcher\n\n**Download manufacturer-grade chip CAD bundles — 3D STEP + KiCad symbol & footprint + Fusion `.lbr` + datasheet — into a structured library, and open or ship each part straight to your EDA tool.**\n\nchip-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 KiCad (rendering goes through the shared `service-kicad`).\n\n## The dashboard\n\nEvery fetched chip is a card with live thumbnails:\n\n- **SYM / FP** tiles — the schematic symbol and PCB footprint, each with a **▾ source carat**: when a part was pulled from more than one vendor (SnapEDA · Ultra Librarian · Component Search · ds2sf-from-datasheet), tap to switch and the tile re-renders to that source. Pick the one you trust.\n- **3D** tiles — Z↑ / Y↑ orientation previews of the real STEP model (and a ▾ for all six face-up axes).\n- **.lbr** tile — builds a true EAGLE library on demand.\n- **Projects** — pin a working set of parts behind a filter chip.\n\n### Open in / Send to\n\nSelect a chip and the detail panel (tabbed: **Actions · Details · Files**) gives you:\n\n- **Open in** — `adom-symbol`, `adom-footprint`, `adom-lbr`, `adom-3D`, `chipsmith`, `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.\n- **Send to** — KiCad (native symbol + footprint), Fusion 360 (real `.lbr`), Altium / OrCAD (`.lbr` download + import steps; native exporters on the roadmap).\n\n## The source-site skill pack\n\nThe per-vendor navigation knowledge lives **in this repo** as an extensible skill pack (`skills/`): one skill per site (SnapEDA, DigiKey, Mouser, Ultra Librarian, manufacturer-direct), each documenting how to reach the CAD download — *and how to recover when a site changes or blocks automation*. Third parties contribute new site skills by PR, or by linking their own self-updating skill repo. See `skills/CONTRIBUTING.md`.\n\n## Part of the Adom EDA family\n\nchip-fetcher is the hub; it hands parts to the standalone apps it ships alongside:\n\n- **adom-symbol** — interactive schematic symbol, with the chip's 3D model marked on it.\n- **adom-footprint** — PCB footprint with a layers HUD and the **InstaPCB 300 µm solder-paste dispensing dots**.\n- **adom-lbr** — EAGLE `.lbr` generator + Fusion-360 linter.\n- **ds2sf** — datasheet → symbol + footprint extraction.\n\n## Run it\n\n```bash\nchip-fetcher serve            # the live dashboard (AI-drivable: /api/library, /api/open, …)\nchip-fetcher fetch <MPN>      # source a part into the library\nchip-fetcher sources          # print the canonical sourcing ladder\n```\n\n## Contribute\n\nMIT, contributions welcome. The highest-leverage place to help is the **source-site skill pack** — if you've figured out how to navigate a vendor's site (or get past a fresh block), add a site skill and open a PR. Code lives at `/home/adom/project/chip-fetcher`.\n",-  "install": {-    "binary_name": "chip-fetcher",-    "install_dir": "",-    "install_hint": "",-    "version_cmd": ""+  "type": "app",+  "description": "Your whole parts library — manufacturer-grade chip CAD (symbol, footprint, 3D) one tap from your EDA tool.",+  "dependencies": {},+  "tags": [+    "eda",+    "kicad",+    "parts",+    "components",+    "pcb",+    "cli"+  ],+  "hero": {+    "type": "billboard",+    "headline": "Chip Fetcher",+    "subhead": "Your whole parts library — one tap from your EDA tool.",+    "screenshot": "screenshots/shot.png",+    "accent": "#00b8b1"+  },+  "scripts": {+    "install": "./install.sh",+    "uninstall": "./uninstall.sh"   },+  "needs_sudo": false,+  "visibility": "public",   "author": {-    "id": "6738b00a4dc87b8adbb0a5a3",     "name": "John Lauer",     "email": "[email protected]"-  },-  "tags": [],-  "license": "proprietary",-  "visibility": {-    "public": true-  },-  "hero": {-    "type": "billboard",-    "headline": "chip-fetcher",-    "subhead": "Your whole parts library — manufacturer-grade chip CAD, one tap from your EDA tool.",-    "screenshot": "docs/library.png",-    "accent": "#00b8b0"-  },-  "sample_prompts": [],-  "discovery_triggers": [],-  "discovery_pitch": null,-  "metadata": {},-  "created_at": "2026-06-21T21:33:53.734Z",-  "updated_at": "2026-06-21T21:33:53.734Z",-  "skills": []+  } }\ No newline at end of file
screenshots/shot.pngadded
⋯ 1 unchanged line ⋯
scripts/__pycache__/hershey_simplex.cpython-312.pycadded
⋯ 1 unchanged line ⋯
scripts/adom-desktop-chrome-permissions-prompt.mdadded+137
@@ -0,0 +1,137 @@+# adom-desktop feature request: auto-grant `automatic_downloads` on every pup `browser_open_window`++Paste into Claude on the desktop.++## TL;DR — the simple, primary ask++**Bake `automatic_downloads: granted` for ALL origins into every `browser_open_window` call by default.** That's the single fix that solves 95% of the chip-fetcher pain. Use Chromium's CDP `Browser.grantPermissions` at session creation:++```js+// In adom-desktop's puppeteer-side session bootstrap, right after browser launch:+await browser._client().send('Browser.grantPermissions', {+  permissions: ['automaticDownloads', 'notifications', 'clipboardReadWrite']+  // origin omitted = applies to all origins+});+```++This means: chip-fetcher (and every other pup-driven flow) never has to deal with the "<site> wants to Download multiple files / Allow / Block" dialog. The user clicks once, multiple downloads in sequence Just Work, and the agent doesn't need to know the dialog ever existed.++Direct user request: *"can you just always give that always allow for multiple downloads anytime you open a pup window for chip-fetcher so we never have that issue in the future?"*++Add an opt-out via `browser_open_window` arg if a session ever needs strict mode:++```bash+adom-desktop browser_open_window '{+  "sessionId":"some-strict-session",+  "url":"...",+  "strictPermissions": true+}'+```++Default behavior (no arg or `strictPermissions: false`): permissive. That's what 100% of chip-fetcher's flows want.++---++The rest of this doc is a longer-form spec covering other dialog types and detection APIs — useful if you want a more complete browser-permissions feature, but the **TL;DR fix above unblocks chip-fetcher today**.++## Background++## Background++`browser_input_dispatch` (v1.4.7+) lets the agent fire trusted clicks at page-level DOM elements. But Chromium has a layer ABOVE the rendered page — its own browser-chrome permission dialogs — that isn't reachable via `Input.dispatchMouseEvent` because those clicks resolve in DOM coords against the page, not against the Chromium UI shell.++Common dialogs that the agent CAN'T see or click today:++- **"<site> wants to Download multiple files / Allow / Block"** — fires on the second-or-later download per origin per session. Verified blocking on `componentsearchengine.com` and `snapeda.com` flows when the agent fires multiple format-downloads in sequence.+- **"<site> wants to: Show notifications / Use your camera / Use your microphone"** — vendor sign-up flows sometimes trigger.+- **"Open in <Application>?"** — when a download triggers an OS-level handoff (e.g. .step files associated with a CAD tool).++The user has no programmatic way to know these dialogs appeared, and the agent can't dismiss them. Today: the user has to look at the pup window, click Allow, then tell the agent to retry.++## What I want++### Item 1 — `browser_list_chrome_dialogs`++Surface Chromium's dialog state. Use Puppeteer's `page._client().on('Page.javascriptDialogOpening', ...)` and `Browser.cdpSession` listeners for browser-level prompts.++```bash+adom-desktop browser_list_chrome_dialogs '{"sessionId":"chip-fetcher"}'+# returns:+{+  "dialogs": [+    {+      "type": "permission",+      "permission": "download_multiple_files",+      "origin": "https://www.snapeda.com",+      "message": "snapeda.com wants to Download multiple files",+      "options": ["Allow", "Block"],+      "dialogId": "perm-1"+    }+  ]+}+```++### Item 2 — `browser_dismiss_chrome_dialog`++```bash+adom-desktop browser_dismiss_chrome_dialog '{+  "sessionId":"chip-fetcher",+  "dialogId":"perm-1",+  "action":"Allow"+}'+# returns: {ok: true, dismissed: "perm-1"}+```++Permissive by default for the chip-fetcher use case (`Allow`); the chip-fetcher SKILL.md already documents that we want multi-download permission auto-accepted on every CSE/SnapEDA/UL flow.++### Item 3 — `browser_set_default_permission`++Pre-grant permissions per-origin so the dialog never fires:++```bash+adom-desktop browser_set_default_permission '{+  "sessionId":"chip-fetcher",+  "origin":"https://www.snapeda.com",+  "permission":"automatic_downloads",+  "value":"granted"+}'+```++Maps to Chromium's `Browser.setPermission` CDP method. This is the cleanest fix — set it once during `browser_open_window` and never see the dialog again.++### Suggested defaults baked into the bridge++When a session is created, auto-grant on common origins:++```json+[+  "componentsearchengine.com",+  "*.componentsearchengine.com",+  "vendor.ultralibrarian.com",+  "snapeda.com",+  "www.snapeda.com",+  "www.mouser.com",+  "www.digikey.com",+  "www.ti.com",+  "www.nordicsemi.com",+  "www.nxp.com",+  "www.st.com"+]+```++with `automatic_downloads: granted`. Add a `disable_default_permissions: true` arg for `browser_open_window` if a session needs strict mode.++## Verification++After ship, this WAGO/CSE/SnapEDA recipe should run without any user interaction at the multi-file Allow step:++```bash+adom-desktop browser_open_window '{"sessionId":"x","profile":"x","url":"https://www.snapeda.com/parts/<MPN>/.../view-part/"}'+# (no manual Allow click required even on the second per-part-format download)+adom-desktop browser_input_dispatch '{"sessionId":"x","type":"click","selector":"#download_step_model"}'+sleep 3+adom-desktop browser_input_dispatch '{"sessionId":"x","type":"click","selector":"#download_kicad"}'+# Both downloads land without the Allow dialog ever firing+```++— sent from john's container running chip-fetcher v0.1.x against adom-desktop v1.4.11
scripts/adom-desktop-credential-vault-prompt.mdadded+78
@@ -0,0 +1,78 @@+# adom-desktop credential vault for HTTP Basic Auth popups++## Problem++When `pup` navigates to a subdomain that issues an HTTP **Basic Auth** challenge (e.g. `nxp.componentsearchengine.com`, `mouser.componentsearchengine.com`, NXP design portals, some IBM/HP/Renesas docs), Chrome shows a system-level popup that lives **outside the page DOM**. It can't be interacted with via `browser_eval`. The user has to manually type the same email + password every time. Real Adom-user feedback: *"typing into these popup windows is heinous."*++## Why we can't just `browser_eval` past it++The popup is a Chrome native dialog (HTML lives in browser chrome, not the rendered page). Its DOM is inaccessible from the controlled page context. Puppeteer **does** offer `page.authenticate({username, password})` which intercepts basic-auth challenges before they show — but `pup` doesn't currently expose that API.++## Desired behavior++Add a credential vault to `pup` that:++1. Stores `{host_pattern → {username, password}}` mappings in an encrypted local file (`<adom-desktop>/plugins/puppeteer/credentials.enc`, AES-256 with a key derived from the user's machine ID + a one-time passphrase).+2. Calls `page.authenticate(creds)` on every new page in a session, with creds matched by URL hostname.+3. Exposes new CLI commands so users / Claude can manage entries:+   - `adom-desktop credential_set '{"host":"*.componentsearchengine.com","username":"[email protected]","password":"..."}'`+   - `adom-desktop credential_list` — returns list of host patterns + usernames (NEVER passwords)+   - `adom-desktop credential_delete '{"host":"..."}'`+4. Returns `password_required` when no entry matches and a basic-auth challenge fires, so chip-fetcher can prompt the user once and then store.++## Wire format++```jsonc+// adom-desktop credential_set '<json>'+{+  "host": "*.componentsearchengine.com",   // glob, matched against new-page URL host+  "username": "...",+  "password": "..."                         // never echoed back via list/get+}+```++```jsonc+// adom-desktop credential_list+{+  "credentials": [+    { "host": "*.componentsearchengine.com", "username": "[email protected]" }+    // password omitted+  ]+}+```++## Files to look at++- `src-tauri/plugins/puppeteer/index.js` — `getOrLaunchBrowser()` is where new pages are created. Hook `page.on('targetcreated')` (or wrap `addTabToSession`) to inject `page.authenticate(creds)` when host matches.+- New file: `src-tauri/plugins/puppeteer/credential_vault.js` — encrypted storage, glob host matching, lookup helper.+- `cli/src/commands.rs` — add `credential_set` / `credential_list` / `credential_delete` subcommands routed through the existing relay.++## Encryption++Use **OS keychain / DPAPI** (Windows: `keytar` npm or `windows-credential-manager`; macOS: `security add-internet-password`; Linux: libsecret) so the user's OS-level lock screen protects the secrets. Don't reinvent encryption.++## Acceptance test++```bash+# 1. Set creds for CSE family+adom-desktop credential_set '{"host":"*.componentsearchengine.com","username":"[email protected]","password":"<paste>"}'++# 2. Navigate to nxp.componentsearchengine.com — basic-auth popup MUST NOT appear; page just loads+adom-desktop browser_navigate '{"sessionId":"chip-fetcher","url":"https://nxp.componentsearchengine.com/preview_newDesign.php?..."}'+adom-desktop browser_eval '{"sessionId":"chip-fetcher","expr":"document.title"}'  # should be SamacSys Part Preview, not "Sign in"++# 3. List shows username but NEVER password+adom-desktop credential_list  # → [{host:"*.componentsearchengine.com", username:"[email protected]"}]+```++## Bonus: import from existing browser password manager++After the basic plumbing, expose:+```+adom-desktop credential_import_chrome+```+which reads the user's primary Chrome profile's `Login Data` SQLite (DPAPI-decrypted on Windows), shows them which entries to import, and stores the picked ones. One paste, never type again.++## Why this matters++The chip-fetcher skill instructs harvesting CAD across 5+ vendors (TI / ST / ADI / NXP / Microchip / etc.), most of which route through Component Search Engine. Each subdomain has its own basic-auth challenge. Without a credential vault, the user types their CSE password 10× per session. This is the highest-impact UX win in chip-fetcher right now and is the natural companion to the `pull_file` streaming fix.
scripts/adom-desktop-pullfile-speed-prompt.mdadded+56
@@ -0,0 +1,56 @@+# Speed up adom-desktop pull_file for large binaries++## Problem++`pull_file` currently base64-encodes the entire file in memory and embeds it inside a single JSON WebSocket message. For files > ~10 MB, the message takes 30–120 seconds to transfer over the WS relay (base64 inflates by 33%, ndjson serialization isn't pipelined, and the relay buffers the whole frame). For 75 MB files (e.g. NXP reference manuals) it routinely times out at the Docker side. Real Adom-user reports: "why is this so slow?"++## Files to look at++- `src-tauri/src/file_handler.rs` — `handle_pull_files()` reads bytes, base64s, packs into a single `pull_files_result` WS message+- `src-tauri/src/protocol.rs` — `pull_files_result()` constructs the WS frame+- `src-tauri/src/ws_client.rs` — sends the frame+- CLI side: `cli/src/commands.rs` — `pull_file()` command, currently uses `desktop_command(...)` with default timeout (30 s)++## Desired behavior++1. **Stream large files in fixed-size chunks** (1 MB binary frames) over a dedicated WS message type `pull_files_chunk` instead of one giant base64 JSON payload.+2. **Use binary WebSocket frames** (no base64) — `tokio-tungstenite` supports `Message::Binary(Vec<u8>)` natively; on the Docker CLI side, `tungstenite` does too. Saves the 33% inflation + JSON parse overhead.+3. **Send progress events** so Docker can show "12/74 MB" instead of hanging silent.+4. **Bump CLI default timeout to a function of file size**: `30 + (size_mb * 0.5)` seconds, capped at 600.++## Wire format proposal++```+// File header (text JSON)+{"type":"pull_file_start","requestId":"...","name":"foo.pdf","size":78912580,"chunks":76}++// N binary chunks, each with this 12-byte prefix:+//   [u32 BE: requestId-hash][u32 BE: chunk_index][u32 BE: chunk_size]+// followed by the raw bytes (no base64, no JSON)++// Completion+{"type":"pull_file_done","requestId":"...","sha256":"..."}+```++CLI streams chunks to disk as they arrive; final SHA256 verified once `done` is received.++## Backwards compat++Keep the old `pull_files` text-frame path working for files < 5 MB (the common case; small CAD bundles, datasheets). Add a new `pull_files_streamed` command for the large-file path; CLI auto-picks based on `stat`-reported size.++## Acceptance test++```bash+# Pull a 75 MB PDF — should complete in < 15 s, not 60++time adom-desktop pull_file '{"filePaths":["C:\\Users\\john\\Downloads\\MCXNP184M150F70RM.pdf"],"saveTo":"/tmp"}'+```++Target: 75 MB in 15 s (5 MB/s effective throughput minimum). Current measured: timeout at 30 s with no completion.++## Why this matters++`pull_file` is the canonical desktop→container transfer used by chip-fetcher (NXP/TI/ST/etc. CAD bundles), datasheet harvesting, log forwarding, and screenshot pulls. Any tool that needs the user's local Downloads folder → container hits this. Sub-10MB users don't notice; anyone grabbing reference manuals (commonly 50–100 MB) does. This is on the chip-fetcher skill's "must fix" list because the skill recommends harvesting all design-help PDFs per chip — which means hitting RM/SRM > 50 MB regularly.++## Bonus: HTTP fallback path++Even simpler fix if streaming WS frames is too invasive: have adom-desktop spawn a one-shot localhost HTTP server (random port, single-use signed token), respond to the pull_file request with `{"http_url": "http://desktop-ip:port/<token>/<file>"}`, and let Docker `curl` it directly over the user's LAN. WebSockets stay for control messages; bulk data goes over HTTP/1.1 chunked transfer where it belongs.
scripts/adom-desktop-trusted-input-prompt.mdadded+241
@@ -0,0 +1,241 @@+# adom-desktop feature request: trusted-input dispatch + popup-tab tracking++Paste this whole document into Claude on your desktop (where the `adom-desktop` Rust source lives) and ask it to implement the two features below.++---++## Context++`adom-desktop` is the Rust CLI + WebSocket relay (default `/usr/local/bin/adom-desktop`) that lets container-side agents drive a real Chromium ("pup") on the user's laptop. Source layout:++- `Cargo.toml` + `src/` — main Rust binary (CLI + relay)+- `plugins/puppeteer/` — Node side that actually drives Chromium via puppeteer-core+- Existing browser commands: `browser_open_window`, `browser_open_tab`, `browser_navigate`, `browser_eval`, `browser_screenshot`, `browser_list_tabs`, `browser_list_windows`, `browser_close_tab`, `browser_close_window`, `browser_focus_window`, `browser_raise_os_window`, `browser_alert_window`, `browser_reload`, `browser_record_*`, `browser_switch_tab`++I'm using adom-desktop from inside an Adom container to run a tool called `chip-fetcher` that scrapes manufacturer / distributor sites for chip CAD bundles + datasheets. I just hit two gaps that block whole classes of vendor sites. Fixing them unblocks a lot.++---++## Feature 1 — `browser_input_dispatch` (trusted click / key / move)++### The gap++Page-side `dispatchEvent(new MouseEvent("click", {...}))` produces events with `isTrusted: false`. Modern frameworks (React, Vue, Svelte) and most anti-automation vendor JS gate handlers on `event.isTrusted === true`. So my synthetic clicks fire to the DOM but the registered click handler **never runs**.++Repro that bit me today: WAGO's "Generate Datasheet" button. Their bundled JS does:++```js+submit = async () => {+  const url = priintGridUrl + language;+  const body = new URLSearchParams({ALL_DATA: "true", language, productCode});+  formSubmit({url, method: "POST", target: "_blank"}, body);+};+```++That handler is wrapped behind an `isTrusted` check by Vue/the framework. My `MouseEvent`, `PointerEvent`, focus-then-Enter — all silently no-op. I had to read the bundle, extract the API, and replay the POST manually via `fetch()` with `credentials:"include"`. That works, but it's brittle (bundle hashes change every deploy) and uses no end-user UX.++### The fix++Expose Puppeteer's native CDP input dispatch. Puppeteer already wraps it as `page.mouse.click(x, y)`, `page.keyboard.type(text)`, `page.keyboard.press(key)`, `page.mouse.move(x, y)`. These produce events with `isTrusted: true` because they go through `Input.dispatchMouseEvent` / `Input.dispatchKeyEvent` at the Chromium browser level, not from page JS.++Suggested CLI surface:++```bash+# Click at coordinates+adom-desktop browser_input_dispatch '{+  "sessionId":"chip-fetcher",+  "type":"click",+  "x":860,"y":803+}'++# Click via selector (resolve coords from selector's bounding rect, then dispatch)+adom-desktop browser_input_dispatch '{+  "sessionId":"chip-fetcher",+  "type":"click",+  "selector":"button.wg-button--primary"+}'++# Type into a focused input+adom-desktop browser_input_dispatch '{+  "sessionId":"chip-fetcher",+  "type":"type",+  "text":"DMG2305UX-7"+}'++# Press a single key+adom-desktop browser_input_dispatch '{+  "sessionId":"chip-fetcher",+  "type":"key",+  "key":"Enter"+}'++# Mouse move (for human-like trajectories before clicks)+adom-desktop browser_input_dispatch '{+  "sessionId":"chip-fetcher",+  "type":"move",+  "x":400,"y":500+}'+```++### Implementation hints (Node side)++In `plugins/puppeteer/`:++```js+case "browser_input_dispatch": {+  const { sessionId, type, x, y, selector, text, key } = args;+  const page = sessions[sessionId].activePage;++  if (type === "click") {+    if (selector) {+      const handle = await page.$(selector);+      if (!handle) return { error: `selector not found: ${selector}` };+      await handle.click(); // puppeteer's .click() uses Input.dispatchMouseEvent under the hood+      return { ok: true };+    }+    await page.mouse.click(x, y);+    return { ok: true };+  }+  if (type === "type") {+    await page.keyboard.type(text);+    return { ok: true };+  }+  if (type === "key") {+    await page.keyboard.press(key);+    return { ok: true };+  }+  if (type === "move") {+    await page.mouse.move(x, y);+    return { ok: true };+  }+  return { error: `unknown type: ${type}` };+}+```++### Acceptance test++After implementation, this should fire the WAGO submit handler and the popup tab actually opens:++```bash+# Open the WAGO product page+adom-desktop browser_open_window '{+  "sessionId":"test-trusted",+  "profile":"test-trusted",+  "url":"https://www.wago.com/global/p/2601-3105"+}'++# Wait for page to load, then open the modal+sleep 6+adom-desktop browser_eval '{+  "sessionId":"test-trusted",+  "expr":"Array.from(document.querySelectorAll(\"button, a\")).find(e => /^generate datasheet$/i.test((e.textContent||\"\").trim())).click()"+}'++# After modal opens, dispatch a TRUSTED click on the green "Generate data sheet" button+sleep 3+adom-desktop browser_input_dispatch '{+  "sessionId":"test-trusted",+  "type":"click",+  "selector":"button.wg-button--primary"+}'++# Within ~5 seconds, browser_list_tabs should show a NEW tab on wago.priintcloud.com+sleep 5+adom-desktop browser_list_tabs '{"sessionId":"test-trusted"}'+# Expected: tabCount: 2, second tab url contains "priintcloud.com/datasheets/2601-3105"+```++If browser_list_tabs still shows tabCount: 1 after the trusted click, **see Feature 2** — the popup is opening but isn't being tracked.++---++## Feature 2 — auto-track popup tabs in `browser_list_tabs`++### The gap++`browser_list_tabs` only returns tabs that the agent created via `browser_open_tab`. Tabs that the page itself spawns (via `window.open()`, `target="_blank"` form submits, `<a target="_blank">` clicks) are real Chromium targets but invisible to the pup tab list. Result: even if a click does open a new tab, my container-side scraper has no way to find it, switch to it, or eval against it.++### The fix++Listen for `Target.targetCreated` and `Target.targetDestroyed` events from Chromium and keep an internal table of all top-level page targets per session, keyed by tabId. Include them all in `browser_list_tabs` output, with a flag indicating provenance.++Suggested response shape:++```json+{+  "sessionId": "chip-fetcher",+  "activeTabId": "tab-1",+  "count": 2,+  "tabs": [+    {+      "tabId": "tab-1",+      "url": "https://www.wago.com/global/p/2601-3105",+      "title": "PCB terminal block (2601-3105) | WAGO",+      "active": true,+      "opener": "user"+    },+    {+      "tabId": "tab-popup-1",+      "url": "https://wago.priintcloud.com/datasheets/2601-3105/en/abc...",+      "title": "Datasheet - WAGO 2601-3105",+      "active": false,+      "opener": "popup",+      "openerTabId": "tab-1"+    }+  ]+}+```++### Implementation hints (Node side)++In `plugins/puppeteer/sessions/<sessionId>.js`:++```js+// After creating the browser:+browser.on("targetcreated", async (target) => {+  if (target.type() !== "page") return;+  const page = await target.page();+  if (!page) return;+  const tabId = `tab-popup-${++this.popupCounter}`;+  this.tabs.set(tabId, {+    page,+    url: page.url(),+    opener: target.opener() ? "popup" : "user",+    openerTabId: target.opener() ? this.findTabIdForTarget(target.opener()) : null,+  });+});++browser.on("targetdestroyed", (target) => {+  // Remove from this.tabs+});++// list_tabs response builds from this.tabs.values()+```++### Acceptance test++```bash+# After Feature 1 lands, the trusted click on WAGO's Generate button+# should produce a NEW tab visible to browser_list_tabs:++adom-desktop browser_list_tabs '{"sessionId":"chip-fetcher"}'+# Expected: tabCount: 2, with tab-popup-1 entry showing priintcloud.com URL+```++Then I can `browser_eval` against the popup tab's tabId, fetch the PDF binary via in-page `fetch()`, base64 it back, and save — without any API-replay reverse-engineering.++---++## Suggested file/PR structure++- `Cargo.toml` — bump version+- `src/cli.rs` (or wherever the dispatch table lives) — register `browser_input_dispatch` action+- `plugins/puppeteer/index.js` (or per-session module) — implement the input handler + target-tracking listeners+- `plugins/puppeteer/types.d.ts` — extend the tab response shape with `opener` / `openerTabId`+- One integration test per feature (the acceptance tests above are good shapes for those)++A single PR with both features is fine — they go together (popup tracking is most useful when you can produce trusted clicks that spawn popups in the first place).++Once it ships, please bump the version + remind me to upgrade my container's binary so the chip-fetcher skill picks up the new commands.++— sent from john's container at john-galliaapril-… running chip-fetcher v0.1.x
scripts/adom-desktop-v1410-followups-prompt.mdadded+189
@@ -0,0 +1,189 @@+# adom-desktop v1.4.10 follow-ups — two remaining v1.4.9 issues++Paste into Claude on the desktop. v1.4.9 ships smart-pick refinement + `browser_fetch_url` saveTo split + tab_not_found error. Verified on container side:++- ✅ **`browser_fetch_url` saveTo:container** works perfectly (TI INA226 datasheet, 1.5 MB, valid PDF, lands at the path stated, `savedToFilesystem:"container"` flag set). 🎉+- ⚠️ **modal-scoped smart-pick** has a false-positive on Vue-Toastification toast containers+- ❌ **`tab_not_found` error** is not actually implemented — `browser_eval` still silently falls back to the active tab++Concrete repros for both below.++---++## Item 1 — modal detection false-positives on Vue-Toastification (and similar empty toast containers)++### The bug++WAGO's product page (`https://www.wago.com/global/p/2601-3105`) ships Vue-Toastification. Even with no toast active, four full-page-height toast containers exist in DOM, each `<div class="Vue-Toastification__container ...">` with `position:fixed; z-index:9999; height:100vh`. They cover >25% viewport, beat the v1.4.9 modal-detection heuristic, and become the "modal root."++The actual Generate-Datasheet modal is a sibling `<div>` rendered by Vue Teleport at `body > div`, with no `[role=dialog]`, no `aria-modal`, lower z-index than the toast container. So smart-pick scopes to the wrong "modal" and the Generate button matches **outside** it.++```bash+adom-desktop browser_input_dispatch '{+  "sessionId":"chip-fetcher",+  "type":"click",+  "selector":"button.wg-button--primary"+}'+# → matchedCount: 3+#   modalDetected: true+#   insideModal: false+#   pickStrategy: "visible-text-largest-no-modal-match"+#   clickedText: "Add to shopping cart"   ← wrong; Generate is in the real modal+```++DOM proof:++```js+// Run in pup eval against an open WAGO modal+JSON.stringify({+  fixedOver25pct: Array.from(document.querySelectorAll('*'))+    .filter(el => {+      const cs = getComputedStyle(el);+      if (!['fixed','absolute'].includes(cs.position)) return false;+      const r = el.getBoundingClientRect();+      return r.width * r.height / (innerWidth * innerHeight) > 0.25;+    })+    .map(el => ({+      tag: el.tagName,+      cls: (el.className || '').toString().slice(0,40),+      z: getComputedStyle(el).zIndex,+      hasInteractiveChild: !!el.querySelector('button, input, a, [role=button]'),+      childTextLen: (el.textContent || '').trim().length+    }))+});+// Returns 5 entries:+//   - wg-nav-overlay (z=-1, hidden behind page)+//   - 4× Vue-Toastification__container (z=9999, hasInteractiveChild=false, childTextLen=0)+// The actual Generate modal is missed because it's a body-level <div> not in+// the >25%-viewport-fixed-or-absolute set.+```++### What I want++The detection heuristic should require the candidate to **contain at least one visible interactive element with non-empty text**. Empty toast containers fail that filter. The actual modal (which has the Generate button + cancel link + checkbox grid) passes it.++```js+// In pickElementForInputDispatch's modal-root detection:+const isPlausibleModal = (el) => {+  // Must contain at least one visible button/link/input with non-empty text+  const interactives = Array.from(el.querySelectorAll('button, a, input, [role=button]'));+  return interactives.some(child => {+    const cs = getComputedStyle(child);+    if (cs.display === 'none' || cs.visibility === 'hidden') return false;+    if ((child.textContent || '').trim().length === 0 && child.tagName !== 'INPUT') return false;+    return true;+  });+};+const candidates = fixedOver25pct.filter(isPlausibleModal);+```++This change should be small — fixedOver25pct list is already computed; just add the post-filter.++Add a `modalRootInfo` debug field to the response so callers can see what got picked (or didn't):++```json+{+  "modalDetected": true,+  "modalRoot": { "tag": "DIV", "cls": "datasheet-modal", "z": "1000" },+  "insideModal": true,+  "pickStrategy": "modal-scoped-largest"+}+```++### Acceptance test++After fix, the WAGO recipe with `selector:"button.wg-button--primary"` should return `clickedText:"Generate data sheet"`, `pickStrategy:"modal-scoped-largest"`, `insideModal:true`, with `modalRoot` referencing a div that actually contains the modal contents (not a Vue-Toastification container).++### Workaround for now++Same as before v1.4.9: compute the button's bounding rect via `browser_eval` and pass `{x, y}` coords. Defeats the smart-pick value-add for modals.++---++## Item 2 — `tab_not_found` error path is not implemented (regression / missing implementation)++### The bug++The v1.4.9 release notes promised:++> When you pass tabId:"tab-2" but the popup has auto-closed, you no longer silently get the active tab's response.++But it still does:++```bash+adom-desktop browser_eval '{+  "sessionId":"chip-fetcher",+  "tabId":"tab-999",+  "expr":"location.href"+}'+```++```json+{+  "ok": true,+  "result": "https://www.wago.com/global/p/2601-3105",+  "sessionId": "chip-fetcher",+  "tabId": "tab-1"+}+```++`tab-999` doesn't exist (only `tab-1` is open). Eval routed to `tab-1` and reported `tabId:"tab-1"` in the response. No `errorCode`, no `currentTabs`, no `_hint`. Same silent-fallback as v1.4.7.++The bridge-side code path that was supposed to land in v1.4.9 didn't (or got reverted, or didn't make it into the build). v1.4.10 should ship it.++### What I want++The promised v1.4.9 behavior:++```json+{+  "ok": false,+  "error": "Tab \"tab-999\" not found in session \"chip-fetcher\".",+  "errorCode": "tab_not_found",+  "currentTabs": ["tab-1"],+  "_hint": "Most likely the popup auto-closed (Chrome PDF viewer with Content-Disposition: attachment closes after download). Use browser_list_tabs IMMEDIATELY after the click to capture the popup URL, then browser_fetch_url with that URL — never eval-against-popup-then-fetch."+}+```++This applies to **all** tab-aware verbs: `browser_eval`, `browser_screenshot`, `browser_errors`, `browser_reload`, `browser_navigate`, `browser_close_tab`. Each should hard-error on missing tabId rather than fall back to active.++### Acceptance test++```bash+# After fix+adom-desktop browser_eval '{"sessionId":"x","tabId":"tab-bogus","expr":"1"}'+# → {"ok":false,"errorCode":"tab_not_found",...}+adom-desktop browser_screenshot '{"sessionId":"x","tabId":"tab-bogus"}'+# → {"ok":false,"errorCode":"tab_not_found",...}+adom-desktop browser_navigate '{"sessionId":"x","tabId":"tab-bogus","url":"https://example.com"}'+# → {"ok":false,"errorCode":"tab_not_found",...}+```++### Workaround for now++Always check `browser_list_tabs` for the tab's existence before eval-ing against it. Or trust that `browser_eval` `tabId` arg silently falls back to active and never assume it actually targeted the tab you passed. Both are bad — please ship v1.4.9's promised behavior in v1.4.10.++---++## Suggested PR shape++- **Item 1** — `pickElementForInputDispatch.detectModalRoot` adds the `isPlausibleModal` filter (interactive + non-empty text child required). Returns `modalRoot` debug field. Test: WAGO scenario should land on the real modal.+- **Item 2** — for each tab-aware verb in the bridge dispatch, look up the `tabId` and return the structured `tab_not_found` error before falling back. Add the listed `_hint`. Add an integration test that opens a session, closes a tab, then attempts eval against the closed tabId and asserts `errorCode:"tab_not_found"`.++Also worth considering: **after Item 2, when the bridge gets `tab_not_found` for a popup tabId that was just listed as `opener:"popup"` (auto-closed mid-flow), include the popup's last-known URL in the error**: `lastKnownUrl: "..."`, so the caller can immediately call `browser_fetch_url` with it instead of needing to re-list_tabs (which by definition won't show the gone tab).++---++## Container-side update flow++```bash+curl -L -o /tmp/cli-fresh \+  https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-desktop/adom-desktop-linux+sudo install -m 755 /tmp/cli-fresh /usr/local/bin/adom-desktop+adom-desktop --version+# Need 1.4.10 or newer+```++Plus the `taskkill //f //im node.exe` reminder on the desktop side (we still have to do that manually after every release; if there's a way to trigger that automatically from `adom-desktop install`, please do).++— sent from john's container running chip-fetcher v0.1.x against adom-desktop v1.4.9
scripts/adom-desktop-v1411-followup-prompt.mdadded+120
@@ -0,0 +1,120 @@+# adom-desktop v1.4.11 follow-up — `tab_not_found` structured fields missing++Paste into Claude on the desktop. v1.4.10 shipped the WAGO Vue-Toastification fix (verified working — chip-fetcher's own creds modal `modal-overlay.open` is correctly detected as `modalRoot:{id:creds-modal,cls:modal-overlay open,z:100}` with `pickStrategy:"modal-scoped-largest"`, real Generate-Datasheet flow's modal-detection misfire was a different test-setup issue, not a heuristic bug). 🎉++But the `tab_not_found` error structure is incomplete.++---++## The bug++v1.4.10 release notes promise this shape:++```json+{+  "ok": false,+  "errorCode": "tab_not_found",+  "error": "Tab \"tab-999\" not found in session \"chip-fetcher\".",+  "currentTabs": ["tab-1"],+  "lastKnownUrl": "https://...",+  "opener": "popup",+  "openerTabId": "tab-1",+  "closedMsAgo": 1842,+  "_hint": "This tab closed 2s ago at URL ... Call browser_fetch_url with that URL ..."+}+```++What v1.4.10 actually returns:++```bash+adom-desktop browser_eval '{"sessionId":"chip-fetcher","tabId":"tab-999","expr":"document.title"}'+# Exit 1+# {+#   "error": "Tab \"tab-999\" not found in session \"chip-fetcher\"."+# }+```++Only the `error` string. No `ok:false`, no `errorCode`, no `currentTabs`, no `_hint`, no ring-buffer data (`lastKnownUrl` / `opener` / `openerTabId` / `closedMsAgo`). The improvement over v1.4.9 silent fallback is that the call now errors at all — but the structured fields the release notes promised aren't there for callers to act on.++---++## What I want++Restore the full structured shape:++```json+{+  "ok": false,+  "errorCode": "tab_not_found",+  "error": "Tab \"tab-999\" not found in session \"chip-fetcher\".",+  "currentTabs": ["tab-1"],+  "_hint": "Most likely the popup auto-closed (Chrome PDF viewer with Content-Disposition: attachment closes after download). Use browser_list_tabs IMMEDIATELY after the click to capture the popup URL, then browser_fetch_url with that URL.",+  // ring-buffer fields when applicable+  "lastKnownUrl": "https://wago.priintcloud.com/datasheets/2601-3105/en",+  "opener": "popup",+  "openerTabId": "tab-1",+  "closedMsAgo": 1842+}+```++Critical for chip-fetcher: when the popup tab auto-closes between the `list_tabs` call (which captured its URL) and a follow-up `browser_eval tabId:popup-tabId`, having `lastKnownUrl` in the error gives me the URL to feed to `browser_fetch_url` without having to re-trigger the click that spawned the popup.++For tabs that never existed (like my `tab-999` test), only `currentTabs` + `_hint` are relevant; ring-buffer fields are null/absent. That's fine.++### Acceptance test++```bash+# Tab that never existed+adom-desktop browser_eval '{"sessionId":"chip-fetcher","tabId":"tab-999","expr":"1"}'+# Expected:+# {+#   "ok": false,+#   "errorCode": "tab_not_found",+#   "error": "Tab \"tab-999\" not found in session \"chip-fetcher\".",+#   "currentTabs": ["tab-1"],+#   "_hint": "..."+# }+```++```bash+# Tab that just closed (open a popup, close it, eval against its tabId)+adom-desktop browser_open_tab '{"sessionId":"chip-fetcher","url":"https://example.com"}'+# returns tabId="tab-2"+adom-desktop browser_close_tab '{"sessionId":"chip-fetcher","tabId":"tab-2"}'+adom-desktop browser_eval '{"sessionId":"chip-fetcher","tabId":"tab-2","expr":"1"}'+# Expected:+# {+#   "ok": false,+#   "errorCode": "tab_not_found",+#   "error": "Tab \"tab-2\" not found in session \"chip-fetcher\".",+#   "currentTabs": ["tab-1"],+#   "lastKnownUrl": "https://example.com/",+#   "opener": "user",+#   "openerTabId": null,+#   "closedMsAgo": 28,+#   "_hint": "This tab closed 28ms ago at URL https://example.com/. Call browser_fetch_url with that URL ..."+# }+```++---++## Suggested PR shape++In the relay or CLI dispatch wrapper that produces the `tab_not_found` error, the response constructor seems to only set the `error` field. Expand it to include `ok:false`, `errorCode`, `currentTabs` (always), and ring-buffer-derived fields when the bogus tabId matches a recently-closed entry.++The ring buffer is already populated per the release notes — the data exists, it's just not getting into the response payload. Probably a single function that's missing field copies.++---++## Container-side update flow++```bash+curl -L -o /tmp/cli-fresh \+  https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-desktop/adom-desktop-linux+sudo install -m 755 /tmp/cli-fresh /usr/local/bin/adom-desktop+adom-desktop --version+# Need 1.4.11 or newer+# Plus the taskkill //f //im node.exe reminder on Windows.+```++— sent from john's container running chip-fetcher v0.1.x against adom-desktop v1.4.10
scripts/adom-desktop-v148-followups-prompt.mdadded+166
@@ -0,0 +1,166 @@+# adom-desktop v1.4.7 follow-ups — three small fixes++Paste this into Claude on the desktop where the `adom-desktop` Rust + plugins/puppeteer source lives. Three loosely-related items I hit while integrating the v1.4.7 features into chip-fetcher today.++---++## Background++I'm running `adom-desktop` from inside an Adom container. Just verified v1.4.7's two new features end-to-end via the WAGO datasheet recipe:++- `browser_input_dispatch type:"click"` with `{x, y}` coords ✅ (trusted click, `isTrusted=true`, framework handler fires)+- `browser_list_tabs` showing `opener:"popup"` for `target=_blank` form-submit popups ✅++These work. Three small refinements would have saved me ~20 min of debugging:++---++## Item 1 — `browser_input_dispatch` with `selector:` should disambiguate or error on ambiguity++### What bit me++WAGO's modal has a `Generate data sheet` button at the bottom of the modal. The button's class is `wg-button wg-button--primary`. I called:++```bash+adom-desktop browser_input_dispatch '{+  "sessionId":"chip-fetcher",+  "type":"click",+  "selector":"button.wg-button--primary"+}'+```++The relay returned `{"ok":true, "dispatched":"click"}`. I assumed the click landed on the Generate button. It didn't — the modal stayed up. After a `browser_eval` to inspect, the page actually had **four** `button.wg-button--primary` elements:++1. The cookie/consent dismiss button (54x54, no text, top-right corner)+2. "Add to shopping cart" (visible)+3. "Add to shopping cart" (`display:none` duplicate)+4. "Generate data sheet" (the one I wanted, at y≈779)++Puppeteer's `page.click(selector)` resolves to the **first match in document order**, so the cookie X button got clicked. The relay reported success — because, mechanically, *a* trusted click was dispatched — but it wasn't the click I wanted.++### What I want++Either of these would have saved the debug round:++**Option A — error on ambiguity by default:**++```json+{"ok":false,"error":"selector matched 4 elements; pass exactNth or use coords","matched":4}+```++With an opt-in `firstMatch:true` arg if the caller knows the first match is correct.++**Option B — pick smartly (preferred):**++Filter the matched set to **visible elements with non-zero text content** before picking, and tie-break by largest visual area or by being inside the topmost stacking context (modals/dialogs). This is what a human would do — "click the visible Generate button," not "click the first DOM-order match." Most call sites benefit; ambiguity is usually the cookie-banner-still-up + button-of-the-same-class case I hit.++Either way, return the matched count + the rect of the element actually clicked so the caller can sanity-check:++```json+{+  "ok": true,+  "dispatched": "click",+  "matchedCount": 4,+  "clickedRect": {"x":890, "y":803, "w":193, "h":48},+  "clickedText": "Generate data sheet"+}+```++### Workaround in the meantime++I'm computing rects via `browser_eval` and passing `{x, y}` coords to `browser_input_dispatch` instead of selectors. Works fine but verbose; selectors should "just work" for the obvious case.++---++## Item 2 — fetching a PDF from a Chrome PDF-viewer popup tab returns the viewer HTML, not the binary++### What bit me++WAGO's "Generate Datasheet" form-submit POSTs to `wago.priintcloud.com/datasheets/<MPN>/<lang>` with `target="_blank"`. The response is `Content-Type: application/pdf`. Chrome opens it as a popup tab, which v1.4.7's `browser_list_tabs` correctly tracks as `opener:"popup"`.++But when I do this against the popup tab to grab the PDF binary:++```bash+adom-desktop browser_eval '{+  "sessionId":"chip-fetcher",+  "tabId":"tab-2",+  "expr":"(async()=>{const r=await fetch(location.href,{credentials:\"include\"}); const ab=await r.arrayBuffer(); /* base64-encode and return */})()",+  "awaitPromise":true+}'+```++…I get a **194 KB stub HTML wrapper** that starts with newlines, not the multi-MB PDF binary. Confirmed: `document.contentType` reports `application/pdf` but the in-page fetch is being intercepted by Chrome's PDF Viewer Plugin shell rather than hitting the network.++The PDF binary IS reachable via direct POST replay (I did that as a workaround — read the bundled JS, extracted the priintcloud endpoint + form params, ran the POST inside a regular tab via `fetch()` and got 3.8 MB of real PDF). But that defeats the purpose of the popup feature — the whole point of the popup was to avoid the bundle-reading reverse-engineering.++### What I want++A way to grab the popup tab's actual served bytes without hitting the PDF viewer wrapper. Options:++**Option A — `browser_save_tab_as <path>`:** Pup uses puppeteer's `page.pdf()` for HTML pages, or for `application/pdf` tabs, simply re-issues the original request via the network layer with the tab's session cookies and saves the response body. Returns `{ok, savedTo, bytes, contentType}`.++```bash+adom-desktop browser_save_tab_as '{+  "sessionId":"chip-fetcher",+  "tabId":"tab-2",+  "saveTo":"/path/in/container/wago.pdf"+}'+```++**Option B — `browser_fetch_url <url>`:** Generic "fetch this URL with the active session's cookies and return base64 binary." Caller passes the popup's URL (read via `browser_eval` `tabId:"tab-2", expr:"location.href"`). Implementation: puppeteer-side `page.context.request.get(url, {headers:{...}})` returns the body without going through the rendered tab.++```bash+adom-desktop browser_fetch_url '{+  "sessionId":"chip-fetcher",+  "url":"https://wago.priintcloud.com/datasheets/2601-3105/en/abc?attempt=1&signature=...",+  "saveTo":"/tmp/wago.pdf"+}'+# returns {ok:true, bytes:3798336, contentType:"application/pdf"}+```++Option B is more general (also handles the case where the click doesn't open a popup but just downloads). Option A is more focused on "I have this popup tab and want its bytes." Either solves the WAGO case cleanly.++**Either way: include `Content-Type` + size in the result so the caller can validate before writing to disk.**++### Workaround++For now I'm reading the JS bundle, finding the underlying API the form submit hits, and replaying the POST directly. Brittle (bundle hashes change every deploy) but works.++---++## Item 3 — document `node.exe` in the bridge-stale-after-upgrade list++### What bit me++After v1.4.6 → v1.4.7 desktop upgrade, the relay rejected `browser_input_dispatch` with `"Unknown command"` even though the new Linux CLI binary's `strings` output had the command and `--version` was correct. Restarting `adom-desktop serve` on the container side didn't help — the relay was fine; the forwarded-to puppeteer bridge (`node.exe` on Windows) was stale, still loaded the old `plugins/puppeteer/server.js` from before the upgrade.++The fix was `taskkill //f //im node.exe` on the desktop. That's documented in CLAUDE.md for the **KiCad / Fusion bridges (`python.exe`)** but `node.exe` (puppeteer) wasn't mentioned in that list.++### What I want++Add `node.exe` to whatever auto-graceful-restart logic / docs the python.exe bridges use. If there's an installer post-step that does `taskkill` on stale bridges, include `node.exe` there. If it's documented in CLAUDE.md / README, mention `node.exe` next to `python.exe`.++A user-facing message in the relay would also help: when the relay sees the bridge return `"Unknown command"` for a command that's in the relay's known-commands table, it could automatically respond:++```json+{+  "ok": false,+  "error": "puppeteer bridge appears stale (returned Unknown command for a known action)",+  "hint": "On Windows: taskkill /f /im node.exe; the bridge will auto-respawn on next browser_* call",+  "stale_bridge_suspected": true+}+```++That self-diagnostic + hint would have saved most of the debug time.++---++## Suggested PR shape++- **Item 1** — add visibility-filter + ambiguity-error to `browser_input_dispatch`'s selector path in `plugins/puppeteer/server.js`. Return `matchedCount` + `clickedRect` + `clickedText` in the response so callers can verify.+- **Item 2** — add `browser_fetch_url` action that uses puppeteer's `BrowserContext.request` (or `page.context().request`) to fetch arbitrary URLs with the session's cookies, bypassing PDF-viewer interception. Optional `saveTo` for direct disk write; otherwise base64 in the response.+- **Item 3** — add `node.exe` to the bridge-respawn docs + ideally the relay-side stale-bridge self-diagnostic hint.++Once shipped, please bump the version + remind me to refresh `/usr/local/bin/adom-desktop` from the wiki + `taskkill //f //im node.exe` on first run.++— sent from john's container running chip-fetcher v0.1.x
scripts/adom-desktop-v149-followups-prompt.mdadded+216
@@ -0,0 +1,216 @@+# adom-desktop v1.4.9 follow-ups — three bugs found integrating v1.4.8++Paste into Claude on the desktop. v1.4.8 ships `browser_input_dispatch` smart-pick + `browser_fetch_url` + stale-bridge self-diagnostic — thank you. Three concrete issues hit on the first real integration today.++---++## Item 1 — smart-pick should weight modal/dialog stacking context above raw area++### The bug++WAGO's "Generate Datasheet" modal opens. Inside the modal, the green submit button has class `wg-button wg-button--primary` and text "Generate data sheet" with rect 193×48 at y=804. **On the same page (behind the modal),** there's "Add to shopping cart" with class `wg-button wg-button--primary` rect 201×48 at y=727. Four matches total for `button.wg-button--primary` (cookie X dismiss + Add-to-cart + hidden duplicate + Generate).++v1.4.8 smart-pick filters to visible+text and tiebreaks by largest area. Result:++```json+{+  "matchedCount": 4,+  "chosenIndex": 1,+  "clickedText": "Add to shopping cart",+  "pickStrategy": "visible-text-largest"+}+```++Add-to-cart's 9648 px² beats Generate's 9264 px² by ~4%. **It picked the button BEHIND the open modal.** A user looking at the page sees the modal as the only interactive surface; smart-pick should agree.++### What I want++When multiple visible+text matches exist, **prefer elements inside the topmost stacking context** (open `<dialog>`, `[role="dialog"]`, `[aria-modal="true"]`, or any element with `position:fixed`/`absolute` + high z-index that has body-blocking overlay siblings). Tiebreak by area only inside that set.++Detection heuristic (matches what a human sees):++```js+function topmostModalRoot() {+  // 1. Open <dialog> elements+  const openDialog = Array.from(document.querySelectorAll('dialog[open]')).pop();+  if (openDialog) return openDialog;++  // 2. role=dialog / aria-modal=true+  const ariaModal = Array.from(document.querySelectorAll('[role="dialog"][aria-modal="true"], [aria-modal="true"]')).pop();+  if (ariaModal) return ariaModal;++  // 3. Highest z-index visible fixed/absolute element covering >25% of viewport+  // (cookie-banner-style modals + custom-modal libraries that don't use ARIA)+  const candidates = Array.from(document.querySelectorAll('*'))+    .filter(el => {+      const cs = getComputedStyle(el);+      if (!['fixed','absolute'].includes(cs.position)) return false;+      const r = el.getBoundingClientRect();+      const viewportArea = innerWidth * innerHeight;+      return (r.width * r.height) / viewportArea > 0.25;+    })+    .sort((a,b) => parseInt(getComputedStyle(b).zIndex || '0') - parseInt(getComputedStyle(a).zIndex || '0'));+  return candidates[0] || null;+}+```++If `topmostModalRoot()` is non-null, restrict the smart-pick candidate set to elements inside it. Add a `pickStrategy` value: `"modal-scoped-largest"` so callers can see what happened.++Acceptance:++```bash+# After WAGO modal opens+adom-desktop browser_input_dispatch '{"sessionId":"x","type":"click","selector":"button.wg-button--primary"}'+# Expected: clickedText="Generate data sheet", pickStrategy="modal-scoped-largest"+```++### Workaround for now++I'm computing `getBoundingClientRect()` for the specific button via `browser_eval` and passing `{x, y}` coords. Works but verbose; selector should "just work" when there's an open modal.++---++## Item 2 — `browser_fetch_url` `saveTo:` path semantics are ambiguous + file doesn't actually land++### The bug++I called:++```bash+adom-desktop browser_fetch_url '{+  "sessionId":"chip-fetcher",+  "tabId":"tab-2",+  "url":"https://wago.priintcloud.com/datasheets/2601-3105/en",+  "saveTo":"/tmp/wago-via-fetch-url.pdf"+}'+```++Response:++```json+{+  "ok": true,+  "status": 200,+  "bytes": 199514,+  "contentType": "text/html;charset=UTF-8",+  "savedTo": "/tmp/wago-via-fetch-url.pdf"+}+```++But:++- **`/tmp/wago-via-fetch-url.pdf` doesn't exist on the container** (`ls /tmp/wago-via-fetch-url.pdf` → No such file).+- **`C:\Users\john\AppData\Local\Temp\wago-via-fetch-url.pdf` doesn't exist on the desktop either.**++So `savedTo` reported a path that no file is at. (Separately, `bytes:199514, contentType:text/html` means the URL returned HTML for that GET, not a PDF — that's a different issue, see Item 3.)++### What I want++Three things, any one of which fixes the immediate confusion:++**A. Document `saveTo`'s filesystem.** Container side or desktop side? If desktop, what's the working directory (Windows username path resolution)? Is `~` expanded? Are non-existent parent dirs created?++**B. If desktop-side, integrate with `pull_file` automatically.** Either:+  - `saveTo` is treated as a desktop path AND `bringBack:true` opt-in re-pulls into the container at the same path, OR+  - Add a separate `containerSaveTo:` arg that's container-filesystem-relative — relay writes to a temp on desktop, pull_files back, drops at `containerSaveTo`.++**C. Default to no-saveTo → return `bodyBase64` in the response.** I'd prefer this for files <50 MB. Verifiable in one call. Caller decides where to write.++```json+{+  "ok": true,+  "status": 200,+  "bytes": 199514,+  "contentType": "text/html;charset=UTF-8",+  "bodyBase64": "PHA+SGVsbG8...",+  "savedTo": null+}+```++If `saveTo` is provided AND it's container-side, write directly. If `saveTo` is provided AND it's desktop-side, write there + return `desktopSavedTo` (NOT `savedTo`) so the field name matches semantics.++### Workaround for now++Drop `saveTo` and use base64-via-eval, like before browser_fetch_url shipped — defeats the purpose of the new verb.++---++## Item 3 — `browser_eval tabId:` silently falls back to active tab when popup is gone++### The bug++WAGO's Generate flow opens a popup tab. When I read `location.href` from the popup via `browser_eval tabId:"tab-2"`:++```bash+adom-desktop browser_eval '{+  "sessionId":"chip-fetcher",+  "tabId":"tab-2",+  "expr":"location.href"+}'+# Returns the parent tab's URL — NOT tab-2's URL+```++The expected `wago.priintcloud.com/datasheets/...` URL came back as `www.wago.com/global/p/2601-3105` — that's tab-1, not tab-2. Then `browser_list_tabs` showed only tab-1 (no tab-2). So tab-2 had auto-closed (Chrome PDF viewer or download race) between the popup-detection list_tabs call and my eval — but eval didn't error, it silently fell back to the active tab.++That's a footgun: the caller thinks they're reading from the popup, but they're actually reading from wherever-the-active-tab-happens-to-be. PDF popups especially are short-lived (Chrome opens, viewer renders, optionally closes if `Content-Disposition: attachment`).++### What I want++When `tabId` is passed and that tab no longer exists:++```json+{+  "ok": false,+  "error": "tabId 'tab-2' no longer exists in session 'chip-fetcher'",+  "currentTabs": ["tab-1"],+  "hint": "Popup may have auto-closed (Chrome PDF viewer with Content-Disposition: attachment closes after download). Use browser_fetch_url with the popup URL captured from list_tabs INSTEAD of eval against the popup's tabId."+}+```++The hint is gold for chip-fetcher: the right pattern with PDF popups is **read the URL from list_tabs immediately** (before it auto-closes), then `browser_fetch_url` with that URL — never eval-against-popup-then-fetch.++### Worked end-to-end recipe (after Items 1-3 ship)++```bash+# 1. Open product page + auto-accept cookies + open modal+adom-desktop browser_navigate '{"sessionId":"x","url":"https://www.wago.com/global/p/2601-3105"}'+sleep 6+adom-desktop browser_eval '{"sessionId":"x","expr":"<auto-accept-cookies-snippet>"}'+sleep 2+adom-desktop browser_eval '{"sessionId":"x","expr":"document.querySelector(\"button:has-text(\\\"Generate Datasheet\\\")\").click()"}'+sleep 3++# 2. Trusted click on modal submit (Item 1: smart-pick now scopes to modal)+adom-desktop browser_input_dispatch '{+  "sessionId":"x","type":"click","selector":"button.wg-button--primary"+}'+# {clickedText:"Generate data sheet", pickStrategy:"modal-scoped-largest"}++sleep 5++# 3. Read popup URL FROM list_tabs (don't eval against the popup's tabId)+POPUP_URL=$(adom-desktop browser_list_tabs '{"sessionId":"x"}' \+  | jq -r '.tabs[] | select(.opener=="popup") | .url' | head -1)++# 4. Fetch via browser_fetch_url (Item 2: bodyBase64 by default, or saveTo to container)+adom-desktop browser_fetch_url '{+  "sessionId":"x",+  "url":"'$POPUP_URL'",+  "containerSaveTo":"/home/adom/project/chip-fetcher/library/WAGO-2601-3105/datasheet.pdf"+}'+# {ok:true, bytes:3798336, contentType:"application/pdf", savedTo:"/home/adom/.../datasheet.pdf"}+```++That's chef's-kiss-clean. Items 1-3 unlock it.++---++## Suggested PR shape++- **Item 1** — `pickElementForInputDispatch()` adds modal-context detection before area-tiebreak. New `pickStrategy:"modal-scoped-largest"` value. Test: WAGO modal scenario.+- **Item 2** — clarify `saveTo` semantics + add `containerSaveTo` (or rename existing `saveTo` to `desktopSaveTo` + add new `containerSaveTo`) + return `bodyBase64` when neither is set. Update the relay → bridge IPC to pipe binary through to whichever side. Validation: response `savedTo` field must point at a path where a file actually exists.+- **Item 3** — `browser_eval` errors on missing tabId instead of silent active-tab fallback. Add `currentTabs` to error response.++Once shipped: bump + tag + wiki publish + remind me to refresh `/usr/local/bin/adom-desktop` and `taskkill //f //im node.exe` per the v1.4.7 lesson.++— sent from john's container running chip-fetcher v0.1.x against adom-desktop v1.4.8
scripts/cf-autowatch.shadded+82
@@ -0,0 +1,82 @@+#!/bin/bash+# Watch the chip-fetcher pup window for CAPTCHA solve, click Submit, then poll+# the user's Downloads folder for the resulting ul_*.zip and pull+import it.+#+# Usage: cf-autowatch.sh <MPN>+#+# Race-safety: only one watcher can run at a time (PID lock at /tmp/cf-autowatch.lock).+# Tab-safety: confirms the pup tab URL still references this MPN before clicking Submit.++set -u+MPN="${1:?usage: cf-autowatch.sh <MPN>}"+LOCK=/tmp/cf-autowatch.lock+TIMEOUT=300++# Acquire lock — refuse if another watcher is still running on a live PID+if [ -e "$LOCK" ]; then+  OLD_PID=$(cat "$LOCK" 2>/dev/null || echo "")+  if [ -n "$OLD_PID" ] && kill -0 "$OLD_PID" 2>/dev/null; then+    echo "ERROR: another watcher (pid $OLD_PID) is running. Stop it first."+    exit 2+  fi+  rm -f "$LOCK"+fi+echo $$ > "$LOCK"+trap 'rm -f "$LOCK"' EXIT++# Loose base part for URL matching (e.g. MCF8316A1RRYR → MCF8316A)+MPN_BASE=$(echo "$MPN" | sed -E 's/(R|T)$//; s/[A-Z]+[0-9]*$//')+[ -z "$MPN_BASE" ] && MPN_BASE="$MPN"++START=$(date +%s)+echo "[$(date +%H:%M:%S)] watching CAPTCHA for ${MPN} (base: ${MPN_BASE})..."++# Step 1: poll CAPTCHA until solved AND the tab still shows our MPN+while [ $(($(date +%s) - START)) -lt $TIMEOUT ]; do+  STATE=$(adom-desktop browser_eval "{\"sessionId\":\"chip-fetcher\",\"expr\":\"JSON.stringify({token: document.getElementById('g-recaptcha-response')?.value || '', url: location.href})\"}" 2>/dev/null \+    | python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('result','{}'))" 2>/dev/null)+  TOKEN_LEN=$(echo "$STATE" | python3 -c "import sys,json;print(len(json.loads(sys.stdin.read()).get('token','')))" 2>/dev/null)+  URL=$(echo "$STATE" | python3 -c "import sys,json;print(json.loads(sys.stdin.read()).get('url',''))" 2>/dev/null)++  if [ "${TOKEN_LEN:-0}" -gt 50 ]; then+    # Tab-safety: URL must still reference our MPN base (case-insensitive)+    if echo "$URL" | grep -qiE "$MPN_BASE|$MPN"; then+      echo "[$(date +%H:%M:%S)] CAPTCHA solved (token len $TOKEN_LEN), URL matches → click Submit"+      adom-desktop browser_eval '{"sessionId":"chip-fetcher","expr":"(()=>{const l=document.getElementById(\"SubmitLink\"); if(!l) return \"no link\"; l.classList.remove(\"disabled\"); l.click(); return \"clicked\"})()"}' >/dev/null+      break+    else+      echo "[$(date +%H:%M:%S)] CAPTCHA solved but tab is at '$URL' — not our MPN ($MPN_BASE), aborting"+      exit 3+    fi+  fi+  sleep 2+done++# Step 2: watch Downloads for new download matching our MPN base.+# Match both UL (ul_*.zip) and Component Search Engine (LIB_*.zip) filename prefixes.+# desktop_watch_files only supports one glob at a time, so we poll the union via+# desktop_list_files (with mtime filter) on a short cadence.+echo "[$(date +%H:%M:%S)] watching Downloads for {ul_,LIB_}*.zip matching ${MPN_BASE}..."+SINCE=$(($(date +%s) - 30))+PULL_START=$(date +%s)+while [ $(($(date +%s) - PULL_START)) -lt 60 ]; do+  for GLOB in "ul_*${MPN_BASE}*.zip" "LIB_*${MPN_BASE}*.zip"; do+    LIST=$(adom-desktop desktop_list_files "{\"path\":\"%USERPROFILE%\\\\Downloads\",\"glob\":\"$GLOB\",\"modifiedSince\":$SINCE}" 2>/dev/null)+    REMOTE=$(echo "$LIST" | python3 -c "+import sys, json+try:+    d = json.load(sys.stdin)+    f = (d.get('data', {}).get('files') or d.get('files') or [])+    if f: print(f[0].get('path',''))+except: pass+")+    if [ -n "$REMOTE" ]; then+      echo "[$(date +%H:%M:%S)] desktop has $REMOTE"+      /home/adom/project/chip-fetcher/target/release/chip-fetcher pull "$REMOTE" --mpn "$MPN" 2>&1 | head -10+      exit 0+    fi+  done+  sleep 2+done+echo "TIMEOUT waiting for download"+exit 1
scripts/cf-cse-batch.shadded+102
@@ -0,0 +1,102 @@+#!/bin/bash+# cf-cse-batch.sh — fetch one MPN's CAD bundle via Mouser CSE with inline-credential+# auth. Bypasses the chrome-error / silent-fail issue caused by the CSE Basic-Auth+# popup being invisible to browser_eval.+#+# Usage:+#   ./cf-cse-batch.sh <MPN> "<Manufacturer Name>"+#+# Output: pulls LIB_<part-variant>.zip into chip-fetcher/incoming/, then distributes+# .kicad_sym / .kicad_mod / .SchLib / .PcbLib / .IntLib / .lbr into library/<MPN>/.++set -u+MPN="${1:?MPN required}"+MFR="${2:?manufacturer required}"+SESSION="chip-fetcher"++# Pull creds + urlencode (write JSON to a tmp file to avoid bash quote-mangling of the python script)+CRED_JSON=$(mktemp)+curl -s http://127.0.0.1:8786/api/credentials > "$CRED_JSON"+CRED=$(python3 - "$CRED_JSON" <<'PYEOF'+import sys, json, urllib.parse+d = json.load(open(sys.argv[1]))+c = next((x for x in d['credentials'] if 'componentsearchengine' in x['host']), None)+if c:+    u = urllib.parse.quote(c['username'], safe='')+    p = urllib.parse.quote(c['password'], safe='')+    print(f"{u}:{p}")+PYEOF+)+rm -f "$CRED_JSON"+[[ -z "$CRED" ]] && { echo "ERROR: no CSE creds in vault"; exit 1; }++MFR_URL=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$MFR', safe=''))")+MPN_URL=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$MPN', safe=''))")++# Step 1: Navigate to CSE search with inline creds+SEARCH_URL="https://${CRED}@mouser.componentsearchengine.com/entry_u_newDesign.php?mna=${MFR_URL}&mpn=${MPN_URL}&pna=mouser&vrq=multi&fmt=zip&o3=0&logo=&lang=en-GB&epm=0"+adom-desktop browser_navigate "{\"sessionId\":\"$SESSION\",\"url\":\"$SEARCH_URL\"}" >/dev/null 2>&1+sleep 3++# Step 2: Find first variant (skipping header row) + click to preview+FIRST_PART=$(adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"(()=>{const rows=Array.from(document.querySelectorAll('tr')); for(const r of rows){const td=r.querySelector('td'); if(td && /^[A-Za-z0-9]/.test(td.textContent.trim())) return td.textContent.trim();} return '';})()\"}" 2>/dev/null \+  | python3 -c "import sys,json; print(json.load(sys.stdin).get('result','').strip())")+if [[ -z "$FIRST_PART" ]]; then+  echo "ERROR: no variants found for $MPN ($MFR) on Mouser CSE"+  exit 2+fi++# Click first DATA row's Download button (filter out header row)+adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"(()=>{const rows=Array.from(document.querySelectorAll('tr')); for(const r of rows){const td=r.querySelector('td'); if(td && /^[A-Za-z0-9]/.test(td.textContent.trim())){const btn=r.querySelector('a, button'); if(btn){btn.click(); return 'clicked';}}} return 'no btn';})()\"}" >/dev/null 2>&1+sleep 3++# Step 3: Extract tok + partID from preview's hidden form+META=$(adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"(()=>{const f=document.getElementById('zipForm'); if(!f) return null; const get=n=>f.querySelector('input[name='+n+']')?.value; return JSON.stringify({partID:get('partID'),tok:get('tok')});})()\"}" 2>/dev/null \+  | python3 -c "import sys,json; print(json.load(sys.stdin).get('result',''))")+PART_ID=$(echo "$META" | python3 -c "import sys,json; print(json.loads(sys.stdin.read() or '{}').get('partID',''))" 2>/dev/null)+TOK=$(echo "$META"     | python3 -c "import sys,json; print(json.loads(sys.stdin.read() or '{}').get('tok',''))"     2>/dev/null)+if [[ -z "$PART_ID" || -z "$TOK" ]]; then+  echo "ERROR: $MPN ($MFR) — preview form missing partID/tok (got '$META')"+  exit 3+fi+echo "  partID=$PART_ID tok=$TOK ($FIRST_PART)"++# Step 4: Mark a unix-second checkpoint BEFORE triggering the download, then+# use desktop_pull_glob (adom-desktop v1.5+) to wait for the matching file+# AND pull it in one shot. No PowerShell, no shell_execute, no user prompt.+BEFORE=$(date +%s)++DL="https://${CRED}@mouser.componentsearchengine.com/ga/model.php?partID=${PART_ID}&partner=Mouser&tok=${TOK}&emb=1&lang=en-GB&pna=Mouser"+adom-desktop browser_navigate "{\"sessionId\":\"$SESSION\",\"url\":\"$DL\"}" >/dev/null 2>&1++cd /home/adom/project/chip-fetcher+PULL=$(adom-desktop desktop_pull_glob "{\"path\":\"%USERPROFILE%\\\\Downloads\",\"glob\":\"LIB_*.zip\",\"since\":$BEFORE,\"wait\":true,\"timeoutMs\":60000,\"saveTo\":\"$(pwd)/incoming/\"}" 2>/dev/null)+ZIP=$(echo "$PULL" | python3 -c "import sys,json; d=json.load(sys.stdin); f=(d.get('files') or [{}])[0]; print(f.get('path',''))")+NEW=$(basename "$ZIP")+if [[ -z "$ZIP" || ! -f "$ZIP" ]]; then+  echo "ERROR: no LIB_*.zip appeared in Downloads after $BEFORE — $PULL"+  exit 4+fi+echo "  download: $NEW"++# Step 5: relativize the zip path so the rest of the script works.+ZIP="incoming/$NEW"+[[ ! -f "$ZIP" ]] && { echo "ERROR: pulled file missing at $ZIP"; exit 5; }++# Step 6: Distribute the format files we don't get from chip-fetcher's import+EXTRACT=$(mktemp -d)+unzip -q -o "$ZIP" -d "$EXTRACT"+LIB="library/$MPN"+mkdir -p "$LIB"+ADDED=""+SCH=$(find "$EXTRACT" -name "*.SchLib" | head -1)+PCB=$(find "$EXTRACT" -name "*.PcbLib" | head -1)+INT=$(find "$EXTRACT" -name "*.IntLib" | head -1)+LBR=$(find "$EXTRACT" -path "*/EAGLE/*.lbr" -o -path "*/SOLIDWORKS*/*.lbr" | head -1)+[[ -n "$SCH" ]] && cp "$SCH" "$LIB/$MPN.SchLib" && ADDED="$ADDED SchLib"+[[ -n "$PCB" ]] && cp "$PCB" "$LIB/$MPN.PcbLib" && ADDED="$ADDED PcbLib"+[[ -n "$INT" ]] && cp "$INT" "$LIB/$MPN.IntLib" && ADDED="$ADDED IntLib"+[[ -n "$LBR" ]] && cp "$LBR" "$LIB/$MPN.lbr"    && ADDED="$ADDED lbr"+rm -rf "$EXTRACT"+echo "  distributed:$ADDED"+echo "OK: $MPN"
scripts/cf-drive.shadded+49
@@ -0,0 +1,49 @@+#!/bin/bash+# Drive a chip's manufacturer page → Ultra Librarian deep-link → set form (STEP, KiCAD v6+, mm, T&C).+#+# Usage: cf-drive.sh <MPN> [vendor]+#   vendor (optional): ti | st | adi | nxp | microchip | renesas+#                      Default: ti.+#+# After this returns, run cf-autowatch.sh <MPN> & to auto-pull the download.++set -u+MPN="${1:?usage: cf-drive.sh <MPN> [vendor]}"+VENDOR="${2:-ti}"+echo "=== driving $MPN (vendor: $VENDOR) ==="++case "$VENDOR" in+  ti)         PROD_URL="https://www.ti.com/product/$MPN" ;;+  st)         PROD_URL="https://www.st.com/en/imaging-and-photonics-solutions/${MPN,,}.html" ;;  # ST URL varies by category+  adi)        PROD_URL="https://www.analog.com/en/products/${MPN,,}.html" ;;+  nxp)        PROD_URL="https://www.nxp.com/products/$MPN" ;;+  microchip)  PROD_URL="https://www.microchip.com/en-us/product/$MPN" ;;+  renesas)    PROD_URL="https://www.renesas.com/us/en/products/$MPN" ;;+  *) echo "FAIL: unknown vendor $VENDOR (try: ti st adi nxp microchip renesas)"; exit 1 ;;+esac++adom-desktop browser_navigate "{\"sessionId\":\"chip-fetcher\",\"url\":\"$PROD_URL\"}" >/dev/null+sleep 6++# Find any "Ultra Librarian" link on the page (vendors all use the same vendor.ultralibrarian.com pattern,+# but the path is per-vendor: /TI/, /stmicro/, /adi/, etc.).+UL_URL=$(adom-desktop browser_eval '{"sessionId":"chip-fetcher","expr":"(() => { const a = Array.from(document.querySelectorAll(\"a\")).find(x => x.href && /ultralibrarian\\.com\\/.*\\/embedded/i.test(x.href)); return a ? a.href : \"NONE\"; })()"}' 2>/dev/null \+  | python3 -c "import sys,json; print(json.load(sys.stdin)['result'])" 2>/dev/null)++if [ "$UL_URL" = "NONE" ] || [ -z "$UL_URL" ]; then+  echo "FAIL: no UL link on $VENDOR page for $MPN ($PROD_URL)"+  exit 1+fi+echo "UL link: $UL_URL"++adom-desktop browser_navigate "{\"sessionId\":\"chip-fetcher\",\"url\":\"$UL_URL\"}" >/dev/null+sleep 6+adom-desktop browser_eval '{"sessionId":"chip-fetcher","expr":"(() => { const choose = Array.from(document.querySelectorAll(\"a, button\")).find(b => /choose.*cad/i.test(b.textContent)); if (choose) choose.click(); })()"}' >/dev/null+sleep 3++# Set the form using LABEL-based selection (not ID). TI uses MfrThreeDModel, ADI uses ThreeDModel,+# others may use other IDs — but the visible label "STEP" / "KiCAD v6+" is consistent across vendors.+adom-desktop browser_eval '{"sessionId":"chip-fetcher","expr":"(() => { const cbs = Array.from(document.querySelectorAll(\"input[type=checkbox]\")); const byLabel = re => cbs.find(c => re.test((c.parentElement?.textContent || \"\").trim().split(\"\\n\")[0])); const step = byLabel(/^STEP$/i); const kicad = byLabel(/^KiCAD v6\\+/i) || byLabel(/^KiCAD v6$/i); const tc = byLabel(/agree.*Terms|Terms.*Conditions/i); if (step && !step.checked) step.click(); if (kicad && !kicad.checked) kicad.click(); if (tc && !tc.checked) tc.click(); const sels = Array.from(document.querySelectorAll(\"select\")).filter(s => Array.from(s.options).some(o => /mm|metric/i.test(o.textContent))); for (const s of sels) { const m = Array.from(s.options).find(o => /metric|mm/i.test(o.textContent)); if (m) { s.value = m.value; s.dispatchEvent(new Event(\"change\",{bubbles:true})); } } return JSON.stringify({step: step?.checked, kicad: kicad?.checked, tc: tc?.checked, units: sels[0]?.value}); })()"}' 2>&1 | head -5++adom-desktop browser_raise_os_window '{"sessionId":"chip-fetcher"}' >/dev/null+echo "form ready — click CAPTCHA on pup window"
scripts/cf-mouser-backfill.shadded+112
@@ -0,0 +1,112 @@+#!/bin/bash+# cf-mouser-backfill.sh <MPN>+# Universal fallback: queries Mouser API for the part, finds the canonical product URL+# (or any variant with a datasheet_url), navigates pup to the Mouser product page,+# scrapes ALL PDF links (Mouser hosts vendor docs on its own CDN — bypasses Cloudflare),+# and curls each into library/<MPN>/.+#+# Mouser CDN PDFs are at https://www.mouser.com/datasheet/<…>.pdf and serve plainly.++set -u+MPN="${1:?usage: cf-mouser-backfill.sh <MPN>}"+LIB_DIR="/home/adom/project/chip-fetcher/library/$MPN"+mkdir -p "$LIB_DIR"++MOUSER_API="${MOUSER_API:-https://mouser-xr8t4f5d01bt.adom.cloud}"++echo "=== Mouser backfill: $MPN ==="++# Find a Mouser product URL for this MPN (prefer the variant with a datasheet_url)+PRODUCT_URL=$(adom-mouser search "$MPN" --api "$MOUSER_API" 2>/dev/null \+  | python3 -c "+import sys, json+raw = sys.stdin.read()+try:+    js = raw[raw.index('{'):]+    d = json.loads(js)+except Exception:+    print(''); sys.exit(0)+comps = d.get('components', [])+# Prefer one with a datasheet_url+with_ds = [c for c in comps if c.get('datasheet_url')]+if with_ds:+    print(with_ds[0].get('product_url',''))+elif comps:+    print(comps[0].get('product_url',''))+")++if [ -z "$PRODUCT_URL" ]; then+  echo "  SKIP: no Mouser hit for $MPN"+  exit 0+fi+echo "  product: $PRODUCT_URL"++# Navigate pup to the Mouser product page+adom-desktop browser_navigate "{\"sessionId\":\"chip-fetcher\",\"url\":\"$PRODUCT_URL\"}" >/dev/null 2>&1+sleep 6++# Scrape PDF links — Mouser hosts datasheets at /datasheet/N/... and many vendor docs are linked from the page+PDF_JSON=$(adom-desktop browser_eval '{"sessionId":"chip-fetcher","expr":"(()=>{const links=Array.from(document.querySelectorAll(\"a\")).filter(a=>/\\.pdf(\\?|$|#)/i.test(a.href||\"\"));const seen=new Set();const out=[];for(const a of links){const h=a.href;if(seen.has(h))continue;seen.add(h);const rowText=(a.closest(\"tr,li,article,section,div\")?.textContent||a.textContent||\"\").trim().replace(/\\s+/g,\" \").slice(0,80);out.push(h+\"|\"+rowText);}return JSON.stringify(out);})()"}' 2>/dev/null \+  | python3 -c "import sys,json; print(json.load(sys.stdin).get('result',''))" 2>/dev/null)++if [ -z "$PDF_JSON" ] || [ "$PDF_JSON" = "[]" ]; then+  echo "  SKIP: no PDF links on Mouser page"+  exit 0+fi++echo "$PDF_JSON" | python3 -c "+import sys, json+items = json.load(sys.stdin)+for entry in items:+    parts = entry.split('|', 1)+    if len(parts) == 2:+        print(parts[0] + '\t' + parts[1])+    else:+        print(parts[0] + '\t')+" > /tmp/cf-mouser-pdfs.txt++DOC_COUNT=$(wc -l < /tmp/cf-mouser-pdfs.txt)+echo "  found $DOC_COUNT unique PDF links"++COUNT=0+SKIPPED=0+FAILED=0+while IFS=$'\t' read -r PDF_URL TITLE; do+  [ -z "$PDF_URL" ] && continue++  if [ -n "$TITLE" ]; then+    SLUG=$(echo "$TITLE" | sed -E 's/[^a-zA-Z0-9-]+/-/g; s/^-+|-+$//g; s/--+/-/g' | cut -c1-40 | tr 'A-Z' 'a-z')+  fi+  if [ -z "${SLUG:-}" ]; then+    SLUG=$(basename "$PDF_URL" .pdf | tr 'A-Z' 'a-z' | cut -c1-40)+  fi+  OUT="$LIB_DIR/$MPN-$SLUG.pdf"+  if [ -f "$OUT" ]; then+    SKIPPED=$((SKIPPED + 1)); continue+  fi++  HEAD=$(curl -sLI -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/130.0" "$PDF_URL" 2>/dev/null)+  CT=$(echo "$HEAD" | grep -i "^content-type:" | tail -1 | tr -d '\r' | awk '{print $2}')+  CL=$(echo "$HEAD" | grep -i "^content-length:" | tail -1 | tr -d '\r' | awk '{print $2}')++  if echo "$CT" | grep -qi "pdf"; then+    if [ -n "$CL" ] && [ "$CL" -gt 26214400 ]; then+      echo "  skip large: $SLUG"; SKIPPED=$((SKIPPED + 1)); continue+    fi+    if curl -sL -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/130.0" -o "$OUT" "$PDF_URL" 2>/dev/null; then+      SIZE=$(stat -c%s "$OUT" 2>/dev/null || echo 0)+      if [ "$SIZE" -gt 1000 ] && head -c 4 "$OUT" | grep -q "%PDF"; then+        echo "  + $MPN-$SLUG.pdf ($((SIZE/1024))KB)"+        COUNT=$((COUNT + 1))+      else+        rm -f "$OUT"; FAILED=$((FAILED + 1))+      fi+    else+      FAILED=$((FAILED + 1))+    fi+  else+    SKIPPED=$((SKIPPED + 1))+  fi+done < /tmp/cf-mouser-pdfs.txt++echo "  pulled=$COUNT skipped=$SKIPPED failed=$FAILED"
scripts/cf-mouser-pdf-pull.shadded+93
@@ -0,0 +1,93 @@+#!/bin/bash+# cf-mouser-pdf-pull.sh <MPN>+# Use pup's in-page fetch() to download Mouser-hosted PDFs (Akamai blocks naked curl).+# Walks each variant returned by the Mouser API; for each datasheet_url, fetches+# binary via browser_eval and decodes base64 into the library.+set -u+MPN="${1:?usage: cf-mouser-pdf-pull.sh <MPN>}"+LIB_DIR="/home/adom/project/chip-fetcher/library/$MPN"+mkdir -p "$LIB_DIR"+MOUSER_API="${MOUSER_API:-https://mouser-xr8t4f5d01bt.adom.cloud}"++echo "=== Mouser-PDF-pull: $MPN ==="++# Get all unique PDF URLs from Mouser API for this MPN's variants+URLS=$(adom-mouser search "$MPN" --api "$MOUSER_API" 2>/dev/null \+  | python3 -c "+import sys, json+raw=sys.stdin.read()+try:+    js=raw[raw.index('{'):]; d=json.loads(js)+except Exception:+    sys.exit(0)+seen=set()+for c in d.get('components', []):+    u=c.get('datasheet_url')+    if u and '.pdf' in u and u not in seen:+        seen.add(u); print(u)+")++if [ -z "$URLS" ]; then+  echo "  no datasheet URLs from API"+  # Fallback: also navigate to product page + scrape+  PRODUCT_URL=$(adom-mouser search "$MPN" --api "$MOUSER_API" 2>/dev/null \+    | python3 -c "+import sys, json+raw=sys.stdin.read()+try:+    js=raw[raw.index('{'):]; d=json.loads(js)+except Exception:+    sys.exit(0)+comps=d.get('components',[])+print(comps[0].get('product_url','') if comps else '')")+  if [ -z "$PRODUCT_URL" ]; then echo "  SKIP: no Mouser hit"; exit 0; fi+  adom-desktop browser_navigate "{\"sessionId\":\"chip-fetcher\",\"url\":\"$PRODUCT_URL\"}" >/dev/null 2>&1+  sleep 6+  URLS=$(adom-desktop browser_eval '{"sessionId":"chip-fetcher","expr":"JSON.stringify(Array.from(new Set(Array.from(document.querySelectorAll(\"a\")).map(a=>a.href).filter(h=>/\\.pdf(\\?|$|#)/i.test(h)))))"}' 2>/dev/null \+    | python3 -c "import sys,json; r=json.load(sys.stdin).get('result',''); print('\n'.join(json.loads(r)) if r and r.startswith('[') else '')" 2>/dev/null)+fi++if [ -z "$URLS" ]; then+  echo "  SKIP: no PDFs found via API or page scrape"+  exit 0+fi++# First, navigate pup to mouser.com to establish Akamai session cookies+adom-desktop browser_navigate '{"sessionId":"chip-fetcher","url":"https://www.mouser.com/"}' >/dev/null 2>&1+sleep 3++COUNT=0+FAILED=0+echo "$URLS" | while read URL; do+  [ -z "$URL" ] && continue+  SLUG=$(basename "$URL" .pdf | tr 'A-Z' 'a-z' | sed -E 's/[^a-z0-9-]+/-/g; s/^-+|-+$//g; s/--+/-/g' | cut -c1-40)+  OUT="$LIB_DIR/$MPN-$SLUG.pdf"+  if [ -f "$OUT" ]; then echo "  exists: $SLUG"; continue; fi++  # Fetch PDF via in-page XHR, base64-encode, return through browser_eval+  EXPR=$(python3 -c "+import json+url='$URL'+print(json.dumps(f'(async()=>{{const r=await fetch({json.dumps(url)},{{credentials:\"include\"}});if(!r.ok)return\"FAIL_\"+r.status;const ab=await r.arrayBuffer();const b=new Uint8Array(ab);let s=\"\";for(let i=0;i<b.length;i++)s+=String.fromCharCode(b[i]);return\"OK_\"+btoa(s);}})()'))+")++  # Use browser_eval with awaitPromise:true if supported; else await in expr+  RESULT=$(adom-desktop browser_eval "{\"sessionId\":\"chip-fetcher\",\"expr\":$EXPR,\"awaitPromise\":true}" 2>/dev/null \+    | python3 -c "import sys,json; r=json.load(sys.stdin).get('result',''); print(r if isinstance(r,str) else '')" 2>/dev/null)++  if [[ "$RESULT" == OK_* ]]; then+    B64="${RESULT#OK_}"+    echo "$B64" | base64 -d > "$OUT" 2>/dev/null+    SIZE=$(stat -c%s "$OUT" 2>/dev/null || echo 0)+    if [ "$SIZE" -gt 1000 ] && head -c 4 "$OUT" | grep -q "%PDF"; then+      echo "  + $MPN-$SLUG.pdf ($((SIZE/1024))KB)"+      COUNT=$((COUNT + 1))+    else+      rm -f "$OUT"; echo "  fail decode: $SLUG (size=$SIZE)"; FAILED=$((FAILED + 1))+    fi+  else+    echo "  fail fetch: $SLUG ($RESULT)"; FAILED=$((FAILED + 1))+  fi+done++echo "  done: pulled=$COUNT failed=$FAILED"
scripts/cf-pup-backfill.shadded+93
@@ -0,0 +1,93 @@+#!/bin/bash+# cf-pup-backfill.sh <MPN> <product-page-URL>+# "Browse as a real user" PDF backfill: navigates pup Chrome to the product page,+# auto-accepts the cookie banner (incl. shadow-DOM), scrapes all .pdf anchors,+# then pulls each via in-page fetch() with credentials so vendor anti-bot+# checks (Akamai/Cloudflare/etc.) pass with the real Chrome session.+#+# Every saved file is verified as %PDF magic before keeping. HTML denial pages+# never make it onto disk.++set -u+MPN="${1:?usage: cf-pup-backfill.sh <MPN> <URL>}"+URL="${2:?usage: cf-pup-backfill.sh <MPN> <URL>}"+LIB_DIR="/home/adom/project/chip-fetcher/library/$MPN"+mkdir -p "$LIB_DIR"++echo "=== pup-backfill: $MPN ==="+echo "  page: $URL"++adom-desktop browser_navigate "{\"sessionId\":\"chip-fetcher\",\"url\":\"$URL\"}" >/dev/null 2>&1+sleep 6++# Auto-accept cookies (DOM + shadow DOM)+adom-desktop browser_eval '{"sessionId":"chip-fetcher","expr":"(()=>{const tries=(root)=>{const btns=Array.from(root.querySelectorAll(\"button, a\"));const re=/^(accept all|accept all cookies|accept|i agree|allow all|ok)$/i;const m=btns.find(b=>re.test((b.textContent||\"\").trim()));if(m){m.click();return true;}return false;};if(tries(document))return\"accepted\";const seen=new Set();const walk=(root)=>{for(const el of root.querySelectorAll(\"*\")){if(el.shadowRoot&&!seen.has(el)){seen.add(el);if(tries(el.shadowRoot))return true;if(walk(el.shadowRoot))return true;}}return false;};return walk(document)?\"accepted-in-shadow\":\"no-banner\";})()"}' 2>/dev/null >/dev/null+sleep 2++# Verify not on a 404 / denial page+TITLE=$(adom-desktop browser_eval '{"sessionId":"chip-fetcher","expr":"document.title.slice(0,80)"}' 2>/dev/null \+  | python3 -c "import sys,json; print(json.load(sys.stdin).get('result',''))" 2>/dev/null)+if echo "$TITLE" | grep -qi "404\|access.*denied\|not found"; then+  echo "  SKIP: $TITLE"+  exit 0+fi++# Scrape PDF anchors+PDF_JSON=$(adom-desktop browser_eval '{"sessionId":"chip-fetcher","expr":"(()=>{const links=Array.from(document.querySelectorAll(\"a\")).filter(a=>/\\.pdf(\\?|$|#)/i.test(a.href||\"\"));const seen=new Set();const out=[];for(const a of links){const h=a.href;if(seen.has(h))continue;seen.add(h);const rowText=(a.closest(\"tr,li,article,section,div\")?.textContent||a.textContent||\"\").trim().replace(/\\s+/g,\" \").slice(0,80);out.push(h+\"|\"+rowText);}return JSON.stringify(out);})()"}' 2>/dev/null \+  | python3 -c "import sys,json; print(json.load(sys.stdin).get('result',''))" 2>/dev/null)++if [ -z "$PDF_JSON" ] || [ "$PDF_JSON" = "[]" ]; then+  echo "  no PDF anchors visible on rendered page"+  exit 0+fi++echo "$PDF_JSON" | python3 -c "+import sys, json+items = json.load(sys.stdin)+for entry in items:+    parts = entry.split('|', 1)+    if len(parts) == 2: print(parts[0] + '\t' + parts[1])+    else: print(parts[0] + '\t')+" > /tmp/cf-pup-pdfs.txt++DOC_COUNT=$(wc -l < /tmp/cf-pup-pdfs.txt)+echo "  found $DOC_COUNT PDF links"++COUNT=0+SKIPPED=0+FAILED=0+while IFS=$'\t' read -r PDF_URL TITLE; do+  [ -z "$PDF_URL" ] && continue+  if [ -n "$TITLE" ]; then+    SLUG=$(echo "$TITLE" | sed -E 's/[^a-zA-Z0-9-]+/-/g; s/^-+|-+$//g; s/--+/-/g' | cut -c1-40 | tr 'A-Z' 'a-z')+  fi+  if [ -z "${SLUG:-}" ]; then+    SLUG=$(basename "$PDF_URL" .pdf | tr 'A-Z' 'a-z' | cut -c1-40)+  fi+  OUT="$LIB_DIR/$MPN-$SLUG.pdf"+  if [ -f "$OUT" ]; then SKIPPED=$((SKIPPED + 1)); continue; fi++  # In-page fetch with credentials. Returns OK_<base64> or FAIL_<status>.+  EXPR=$(python3 -c "+import json+url = '$PDF_URL'+print(json.dumps(f'(async()=>{{try{{const r=await fetch({json.dumps(url)},{{credentials:\"include\"}});if(!r.ok)return\"FAIL_\"+r.status;const ab=await r.arrayBuffer();const b=new Uint8Array(ab);let s=\"\";for(let i=0;i<b.length;i++)s+=String.fromCharCode(b[i]);return\"OK_\"+btoa(s);}}catch(e){{return\"ERR_\"+e.message;}}}})()'))+")+  RESULT=$(adom-desktop browser_eval "{\"sessionId\":\"chip-fetcher\",\"expr\":$EXPR,\"awaitPromise\":true}" 2>/dev/null \+    | python3 -c "import sys,json; r=json.load(sys.stdin).get('result',''); print(r if isinstance(r,str) else '')" 2>/dev/null)++  if [[ "$RESULT" == OK_* ]]; then+    echo "${RESULT#OK_}" | base64 -d > "$OUT" 2>/dev/null+    SIZE=$(stat -c%s "$OUT" 2>/dev/null || echo 0)+    if [ "$SIZE" -gt 1000 ] && head -c 4 "$OUT" | grep -q "%PDF"; then+      echo "  + $MPN-$SLUG.pdf ($((SIZE/1024))KB)"+      COUNT=$((COUNT + 1))+    else+      rm -f "$OUT"; echo "  fail not-pdf: $SLUG"; FAILED=$((FAILED + 1))+    fi+  else+    echo "  fail $SLUG: $RESULT"; FAILED=$((FAILED + 1))+  fi+done < /tmp/cf-pup-pdfs.txt++echo "  pulled=$COUNT skipped=$SKIPPED failed=$FAILED"
scripts/cf-ti-backfill.shadded+114
@@ -0,0 +1,114 @@+#!/bin/bash+# cf-ti-backfill.sh <MPN>+# Open the TI product page in pup, scrape the "Technical documentation" section+# for /lit/pdf/<id> URLs, curl each into library/<MPN>/<MPN>-<id>.pdf.+# Skips files >25MB and dupes already on disk.++set -u+MPN="${1:?usage: cf-ti-backfill.sh <MPN>}"+LIB_DIR="/home/adom/project/chip-fetcher/library/$MPN"+mkdir -p "$LIB_DIR"++echo "=== TI backfill: $MPN ==="++# TI product pages use the BASE part number (no package suffix).+# Progressive truncation: try as-is, then strip 1 char at a time until 200 or 4 chars left.+BASE=""+TRY="$MPN"+while [ ${#TRY} -ge 4 ]; do+  CODE=$(curl -sL -A "Mozilla/5.0" -o /dev/null -w "%{http_code}" "https://www.ti.com/product/$TRY")+  if [ "$CODE" = "200" ]; then+    BASE="$TRY"+    break+  fi+  TRY="${TRY%?}"+done+if [ -z "$BASE" ]; then+  echo "  SKIP: no TI page found for any prefix of $MPN"+  exit 0+fi+echo "  base part: $BASE"++# Open product page — reuse the active tab (don't pile up tabs).+adom-desktop browser_navigate "{\"sessionId\":\"chip-fetcher\",\"url\":\"https://www.ti.com/product/$BASE\"}" >/dev/null 2>&1+sleep 5++# Verify we landed on a real product page (not 404)+TITLE=$(adom-desktop browser_eval '{"sessionId":"chip-fetcher","expr":"document.title.slice(0,80)"}' 2>/dev/null \+  | python3 -c "import sys,json; print(json.load(sys.stdin).get('result',''))" 2>/dev/null)+if echo "$TITLE" | grep -qi "404\|not found\|error"; then+  echo "  SKIP: TI 404 for $MPN ($TITLE)"+  exit 0+fi++# Scrape /lit/pdf/<id> URLs from the Technical Documentation section+LIT_JSON=$(adom-desktop browser_eval '{"sessionId":"chip-fetcher","expr":"(()=>{const hRe=new RegExp(\"technical documentation\",\"i\"); const heads=Array.from(document.querySelectorAll(\"h1,h2,h3,h4\")); const hd=heads.find(h=>hRe.test(h.textContent||\"\")); if(!hd) return \"NO\"; const sec=hd.closest(\"section,div\"); const re=new RegExp(\"^https://www.ti.com/lit/pdf/[a-zA-Z0-9_-]+$\"); const links=Array.from(sec.querySelectorAll(\"a\")).filter(a=>re.test(a.href)); const seen=new Set(); const out=[]; for(const a of links){const id=a.href.split(\"/\").pop(); if(seen.has(id)) continue; seen.add(id); const rowText=(a.closest(\"tr,li,article,div\")?.textContent||a.textContent||\"\").trim().replace(/\\s+/g,\" \").slice(0,80); out.push(id+\"|\"+rowText);} return JSON.stringify(out);})()"}' 2>/dev/null \+  | python3 -c "import sys,json; print(json.load(sys.stdin).get('result',''))" 2>/dev/null)++if [ -z "$LIT_JSON" ] || [ "$LIT_JSON" = "NO" ]; then+  echo "  SKIP: no Technical Documentation section found"+  exit 0+fi++# Parse JSON list (each entry: "<docid>|<title>")+echo "$LIT_JSON" | python3 -c "+import sys, json+items = json.load(sys.stdin)+for entry in items:+    parts = entry.split('|', 1)+    if len(parts) == 2:+        print(parts[0] + '\t' + parts[1])+    else:+        print(parts[0] + '\t')+" > /tmp/cf-ti-doc-list.txt++DOC_COUNT=$(wc -l < /tmp/cf-ti-doc-list.txt)+echo "  found $DOC_COUNT unique docs"++# Curl each into library/<MPN>/+COUNT=0+SKIPPED=0+FAILED=0+while IFS=$'\t' read -r DOCID TITLE; do+  [ -z "$DOCID" ] && continue+  # Sanitize title slug for filename+  SLUG=$(echo "$TITLE" | sed -E 's/[^a-zA-Z0-9-]+/-/g; s/^-+|-+$//g; s/--+/-/g' | cut -c1-40 | tr 'A-Z' 'a-z')+  [ -z "$SLUG" ] && SLUG="$DOCID"+  OUT="$LIB_DIR/$MPN-$SLUG.pdf"++  # Skip if already exists+  if [ -f "$OUT" ]; then+    SKIPPED=$((SKIPPED + 1))+    continue+  fi++  URL="https://www.ti.com/lit/pdf/$DOCID"+  # HEAD first to check size + type+  HEAD=$(curl -sLI -A "Mozilla/5.0" "$URL" 2>/dev/null)+  CT=$(echo "$HEAD" | grep -i "^content-type:" | tail -1 | tr -d '\r' | awk '{print $2}')+  CL=$(echo "$HEAD" | grep -i "^content-length:" | tail -1 | tr -d '\r' | awk '{print $2}')++  if echo "$CT" | grep -q "pdf"; then+    if [ -n "$CL" ] && [ "$CL" -gt 26214400 ]; then+      echo "  skip $DOCID ($((CL/1024/1024))MB > 25MB cap)"+      SKIPPED=$((SKIPPED + 1))+      continue+    fi+    if curl -sL -A "Mozilla/5.0" -o "$OUT" "$URL" 2>/dev/null; then+      SIZE=$(stat -c%s "$OUT" 2>/dev/null || echo 0)+      if [ "$SIZE" -gt 1000 ]; then+        echo "  + $MPN-$SLUG.pdf ($((SIZE/1024))KB)"+        COUNT=$((COUNT + 1))+      else+        rm -f "$OUT"+        FAILED=$((FAILED + 1))+      fi+    else+      FAILED=$((FAILED + 1))+    fi+  else+    SKIPPED=$((SKIPPED + 1))+  fi+done < /tmp/cf-ti-doc-list.txt++echo "  pulled=$COUNT skipped=$SKIPPED failed=$FAILED"
scripts/cf-ul-fast.shadded+375
@@ -0,0 +1,375 @@+#!/bin/bash+# cf-ul-fast.sh — fast two-phase UL fetch with AI-vision handoff+#+# Phase 1 (pre-captcha):  navigate → preset form → trusted-click I'm-not-a-robot+#                         → screenshot iframe + crop if image-grid escalates+#                         → output JSON: silent_pass OR image_grid info+#+# AI does vision step (only LLM round-trip).+#+# Phase 2 (post-captcha): click tiles → click Verify → atomic-eval Submit+#                         → poll Downloads → pull → distribute to family MPNs+#                         → output JSON: done with file paths+#+# This collapses the chained-LLM-call problem (~30s of AI latency between+# tile-pick and Submit-fire) into TWO calls with one vision step in between.+# All non-vision work runs in tight bash with sub-second internal latency.+#+# Usage:+#   ./cf-ul-fast.sh --phase pre-captcha --url '<UL-URL>'+#   ./cf-ul-fast.sh --phase post-captcha --tiles 4,9 \+#       --mpns VLMS1500,VLMTG1500,VLMB1500,VLMO1500,VLMY1500+#+# State between phases lives at /tmp/cf-ul-fast-state.json.++set -u+SESSION="${CF_SESSION:-chip-fetcher}"+ACTIVITY="http://127.0.0.1:8786/api/activity"+STATE_FILE="/tmp/cf-ul-fast-state.json"++PHASE=""+URL=""+TILES=""+MPNS=""+GRID="3x3"  # 3x3 (9 tiles) or 4x4 (16 tiles) — UL rotates these to throw off AI; AI sees grid in screenshot and passes to script+ROUND=1++while [[ $# -gt 0 ]]; do+  case "$1" in+    --phase) PHASE="$2"; shift 2 ;;+    --url) URL="$2"; shift 2 ;;+    --tiles) TILES="$2"; shift 2 ;;+    --mpns) MPNS="$2"; shift 2 ;;+    --grid) GRID="$2"; shift 2 ;;+    --round) ROUND="$2"; shift 2 ;;+    *) echo "{\"ok\":false,\"error\":\"unknown arg: $1\"}"; exit 1 ;;+  esac+done++# Grid geometry (derived from iframe rect at click time, not hardcoded).+# bframe internal layout (% of iframe inner dims):+#   - header (blue prompt bar): ~14% of height (top)+#   - tile grid: middle, full width minus ~3% side margins+#   - footer (refresh/audio/info + VERIFY): ~17% of height (bottom)+HEADER_PCT=0.14+FOOTER_PCT=0.17+SIDE_PCT=0.03+if [[ "$GRID" == "4x4" ]]; then+  TILE_COLS=4; TILE_ROWS=4+else+  TILE_COLS=3; TILE_ROWS=3+fi++post_act()  { curl -s -X POST "$ACTIVITY" -H 'Content-Type: application/json' -d "$1" >/dev/null 2>&1 || true; }+clear_act() { curl -s -X POST "$ACTIVITY" -H 'Content-Type: application/json' -d '{}' >/dev/null 2>&1 || true; }++# ===== PHASE 1: pre-captcha =====+if [[ "$PHASE" == "pre-captcha" ]]; then+  [[ -z "$URL" ]] && { echo '{"ok":false,"error":"--url required for pre-captcha phase"}'; exit 1; }++  post_act '{"mpn":"_ul","stage":"navigating UL"}'+  adom-desktop browser_navigate "{\"sessionId\":\"$SESSION\",\"url\":\"$URL\"}" >/dev/null 2>&1+  sleep 5++  # Optionally click "Choose CAD Formats & Download" — exists on vendor.ultralibrarian.com+  # embedded URLs but NOT on app.ultralibrarian.com part-detail pages where the format+  # selectors are inline. Skip the click if the button isn't on the page.+  post_act '{"mpn":"_ul","stage":"opening Choose CAD Formats (if present)"}'+  RECT=$(adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"(()=>{const a=Array.from(document.querySelectorAll('a, button')).find(e=>/choose cad/i.test((e.textContent||'').trim())); if(!a)return null; const r=a.getBoundingClientRect(); return JSON.stringify({x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)});})()\"}" 2>/dev/null \+    | python3 -c "import sys,json; d=json.load(sys.stdin); r=json.loads(d.get('result','{}') or '{}'); print(r.get('x',0), r.get('y',0))" 2>/dev/null)+  CX=$(echo "$RECT" | awk '{print $1}')+  CY=$(echo "$RECT" | awk '{print $2}')+  if [[ -n "$CX" && "$CX" != "0" ]]; then+    adom-desktop browser_input_dispatch "{\"sessionId\":\"$SESSION\",\"type\":\"click\",\"x\":$CX,\"y\":$CY}" >/dev/null 2>&1+    sleep 3+  fi+  # On UL Pro pages, format selectors may be in collapsible category panels — expand them+  # so the format-checkbox preset finds them.+  adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"(()=>{const cats=Array.from(document.querySelectorAll('a, button, [role=button], div')).filter(e=>{const t=(e.textContent||'').trim(); return /^(3D CAD Model|KiCAD|Altium|Autodesk|Fusion)\\s*[▶▼►▾]?\$/i.test(t);}); for(const c of cats){c.click();} return cats.length+' cats expanded';})()\"}" >/dev/null 2>&1+  sleep 1++  # Preset form (canonical four + T&C + mm)+  post_act '{"mpn":"_ul","stage":"presetting form (STEP + KiCad v6+ + Altium + Fusion 360 + mm + T&C)"}'+  adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"(()=>{const cbs=Array.from(document.querySelectorAll('input[type=checkbox]')); const want=[/^STEP\$/i,/kicad v6/i,/^altium designer/i,/^fusion.?360/i,/agree|terms/i]; let n=0; for(const re of want){const c=cbs.find(c=>re.test((c.parentElement?.textContent||c.closest('label')?.textContent||'').trim())); if(c&&!c.checked){c.click();n++;}} const sels=Array.from(document.querySelectorAll('select')); const u=sels.find(s=>Array.from(s.options).some(o=>/metric|mm/i.test(o.textContent))); if(u){const m=Array.from(u.options).find(o=>/metric|mm/i.test(o.textContent)); u.value=m.value; u.dispatchEvent(new Event('change',{bubbles:true}));} return n+' set';})()\"}" >/dev/null 2>&1+  sleep 1++  # Install JS hooks: MutationObserver auto-submit + DOM cache. Once token populates,+  # Submit fires within one event-loop tick (no 200ms polling latency).+  post_act '{"mpn":"_ul","stage":"installing auto-submit hook"}'+  adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"(()=>{if(window.__cf)return 'already';window.__cf={submitted:false,submittedAt:0,tokenLen:0};const ta=document.querySelector('[name=g-recaptcha-response]');if(!ta)return 'no_token_textarea';const findSubmit=()=>Array.from(document.querySelectorAll('a, button, input[type=submit]')).find(e=>/^submit\$/i.test((e.textContent||e.value||'').trim()));const fire=()=>{const t=ta.value||'';if(t.length<=20||window.__cf.submitted)return;window.__cf.tokenLen=t.length;const btn=findSubmit();if(btn){btn.click();window.__cf.submitted=true;window.__cf.submittedAt=Date.now();}};const obs=new MutationObserver(fire);obs.observe(ta,{attributes:true,attributeFilter:['value'],characterData:true,subtree:true,childList:true});window.__cf.obs=obs;window.__cf.poll=setInterval(fire,100);return 'installed';})()\"}" >/dev/null 2>&1++  # Mark a unix-second checkpoint BEFORE the captcha. desktop_watch_files+  # (adom-desktop v1.5+) accepts unix seconds for `since` so we only see+  # files written AFTER this checkpoint — no shell, no PowerShell.+  post_act '{"mpn":"_ul","stage":"marking pre-captcha checkpoint"}'+  BEFORE_TS=$(date +%s)+  echo "$BEFORE_TS" > /tmp/cf-ul-fast-before.txt++  # Trigger captcha — two paths:+  #   (a) vendor.ultralibrarian.com/<mfr>/embedded — anchor iframe "I'm not a robot"+  #   (b) app.ultralibrarian.com/details/...     — "Download Now" button (invisible reCAPTCHA)+  # Try (a) first; fall back to (b).+  post_act '{"mpn":"_ul","stage":"triggering captcha (anchor or Download Now)"}'+  IFR=$(adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"(()=>{const f=document.querySelector('iframe[src*=anchor]') || document.querySelector('iframe[src*=recaptcha]'); if(!f)return null; const r=f.getBoundingClientRect(); if(r.width<10||r.height<10)return null; return JSON.stringify({x:r.x|0,y:r.y|0});})()\"}" 2>/dev/null \+    | python3 -c "import sys,json; d=json.load(sys.stdin); r=json.loads(d.get('result','{}') or '{}'); print(r.get('x',0), r.get('y',0))" 2>/dev/null)+  IX=$(echo $IFR | awk '{print $1}')+  IY=$(echo $IFR | awk '{print $2}')+  if [[ -n "$IX" && "$IX" != "0" ]]; then+    # Path A — visible anchor checkbox (vendor.ultralibrarian.com embedded)+    IX=$((IX + 30))+    IY=$((IY + 40))+    adom-desktop browser_input_dispatch "{\"sessionId\":\"$SESSION\",\"type\":\"move\",\"x\":$((IX-200)),\"y\":$((IY-100)),\"steps\":5}" >/dev/null 2>&1+    sleep 0.4+    adom-desktop browser_input_dispatch "{\"sessionId\":\"$SESSION\",\"type\":\"move\",\"x\":$IX,\"y\":$IY,\"steps\":15}" >/dev/null 2>&1+    sleep 0.3+    adom-desktop browser_input_dispatch "{\"sessionId\":\"$SESSION\",\"type\":\"click\",\"x\":$IX,\"y\":$IY}" >/dev/null 2>&1+    sleep 4+  else+    # Path B — UL Pro page; click "Download Now" button which triggers invisible reCAPTCHA+    post_act '{"mpn":"_ul","stage":"clicking Download Now (UL Pro flow)"}'+    adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"(()=>{const b=Array.from(document.querySelectorAll('button, a')).find(e=>/^Download\\s*Now\$/i.test((e.textContent||e.value||'').trim())); if(!b) return 'no btn'; b.click(); return 'clicked';})()\"}" >/dev/null 2>&1+    sleep 5+  fi++  # Read state+  STATE=$(adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"(()=>{const t=(document.querySelector('[name=g-recaptcha-response]')?.value||'').length; const bf=document.querySelector('iframe[src*=bframe]'); const r=bf?bf.getBoundingClientRect():null; const visible=r&&r.y>0&&r.y<2000; return JSON.stringify({tokenLen:t,bframe:r?{x:r.x|0,y:r.y|0,w:r.width|0,h:r.height|0}:null,visible:visible,dpr:window.devicePixelRatio});})()\"}" 2>/dev/null \+    | python3 -c "import sys,json; print(json.load(sys.stdin).get('result',''))")++  TOKLEN=$(echo "$STATE" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('tokenLen',0))")++  if [[ "$TOKLEN" -gt 20 ]]; then+    # Silent pass — Submit immediately, but defer to phase 2 (so AI sees the structured output)+    cat > "$STATE_FILE" <<EOF+{"phase1_completed_at":"$(date -u +%FT%TZ)","silent_pass":true,"url":"$URL"}+EOF+    cat <<EOF+{"ok":true,"stage":"silent_pass","_hint":"reCAPTCHA passed silently. Run: cf-ul-fast.sh --phase post-captcha --mpns <comma-list>"}+EOF+    exit 0+  fi++  # Image grid escalated — screenshot + crop+  post_act '{"mpn":"_ul","stage":"image grid escalated — cropping for AI vision"}'+  adom-desktop browser_screenshot "{\"sessionId\":\"$SESSION\",\"path\":\"/tmp/cf-ul-fullpage.png\",\"maxWidth\":2000}" >/dev/null 2>&1+  # browser_screenshot writes to its own conduit-screenshots dir; find latest+  LATEST=$(ls -1t /tmp/conduit-screenshots/screenshot-*.png 2>/dev/null | head -1)+  cp "$LATEST" /tmp/cf-ul-fullpage.png++  # Crop bframe region using state from above+  python3 << PYEOF > /tmp/cf-ul-bframe-meta.json+import json+from PIL import Image+state = json.loads("""$STATE""")+bf = state.get('bframe') or {}+dpr = state.get('dpr', 1.5)+img = Image.open('/tmp/cf-ul-fullpage.png')+sw, sh = img.size+x = int(bf.get('x',0) * dpr)+y = int(bf.get('y',0) * dpr)+w = int(bf.get('w',0) * dpr)+h = int(bf.get('h',0) * dpr)+crop = img.crop((x, y, min(x+w, sw), min(y+h, sh)))+crop.save('/tmp/cf-ul-bframe-crop.png')+crop_2x = crop.resize((crop.size[0]*2, crop.size[1]*2))+crop_2x.save('/tmp/cf-ul-bframe-crop-2x.png')+print(json.dumps({+  "iframe_png": "/tmp/cf-ul-bframe-crop.png",+  "iframe_png_2x": "/tmp/cf-ul-bframe-crop-2x.png",+  "size": list(crop.size),+  "size_2x": list(crop_2x.size),+  "iframe_css_rect": bf,+  "dpr": dpr+}))+PYEOF+  META=$(cat /tmp/cf-ul-bframe-meta.json)++  # Try to extract the prompt + grid size from the iframe DOM (best-effort — bframe is cross-origin)+  # Most reCAPTCHA prompts surface as "Select all images with X" — for now we leave grid detection to AI vision.++  # Save state for phase 2+  cat > "$STATE_FILE" <<EOF+{"phase1_completed_at":"$(date -u +%FT%TZ)","silent_pass":false,"url":"$URL","iframe_meta":$META}+EOF++  cat <<EOF+{"ok":true,"stage":"image_grid","round":1,"iframe_png":"/tmp/cf-ul-bframe-crop.png","iframe_png_2x":"/tmp/cf-ul-bframe-crop-2x.png","_hint":"Read iframe_png_2x. UL rotates captcha types — note (a) grid size (3x3 = 9 tiles, 4x4 = 16 tiles), (b) prompt text in the blue header, (c) whether it says 'Select all images with X' (one-shot) vs 'Click verify once there are none left' (iterative; may need multiple rounds). Then call: cf-ul-fast.sh --phase post-captcha --tiles <list> --mpns <list> --grid <3x3-or-4x4>. Tiles numbered left-to-right, top-to-bottom (1-9 for 3x3, 1-16 for 4x4)."}+EOF+  exit 0++# ===== PHASE 2: post-captcha =====+elif [[ "$PHASE" == "post-captcha" ]]; then+  [[ -z "$MPNS" ]] && { echo '{"ok":false,"error":"--mpns required for post-captcha phase"}'; exit 1; }++  if [[ ! -f "$STATE_FILE" ]]; then+    echo "{\"ok\":false,\"error\":\"no $STATE_FILE — run --phase pre-captcha first\"}"+    exit 1+  fi++  SILENT=$(python3 -c "import json; print(json.load(open('$STATE_FILE')).get('silent_pass',False))")+  IFRAME_RECT=$(python3 -c "import json; m=json.load(open('$STATE_FILE')).get('iframe_meta',{}).get('iframe_css_rect',{}); print(m.get('x',0),m.get('y',0),m.get('w',0),m.get('h',0))")+  read IX IY IW IH <<< "$IFRAME_RECT"++  if [[ "$SILENT" == "False" || "$SILENT" == "false" ]]; then+    if [[ -z "$TILES" ]]; then+      echo '{"ok":false,"error":"image_grid was active in phase 1 — --tiles is required"}'+      exit 1+    fi++    # Click each tile — coords derived from CURRENT iframe rect (not hardcoded)+    post_act "{\"mpn\":\"_ul\",\"stage\":\"AI vision round $ROUND: clicking tiles $TILES ($GRID)\"}"+    # Compute grid geometry from iframe rect (CSS pixels)+    GRID_X0=$(python3 -c "print(int($IX + $IW * $SIDE_PCT))")+    GRID_Y0=$(python3 -c "print(int($IY + $IH * $HEADER_PCT))")+    GRID_W=$(python3 -c "print(int($IW * (1 - 2 * $SIDE_PCT)))")+    GRID_H=$(python3 -c "print(int($IH * (1 - $HEADER_PCT - $FOOTER_PCT)))")+    TW=$(python3 -c "print($GRID_W / $TILE_COLS)")+    TH=$(python3 -c "print($GRID_H / $TILE_ROWS)")+    IFS=',' read -ra TILE_ARR <<< "$TILES"+    for T in "${TILE_ARR[@]}"; do+      ROW=$(( (T - 1) / TILE_COLS ))+      COL=$(( (T - 1) % TILE_COLS ))+      X=$(python3 -c "print(int($GRID_X0 + ($COL + 0.5) * $TW))")+      Y=$(python3 -c "print(int($GRID_Y0 + ($ROW + 0.5) * $TH))")+      adom-desktop browser_input_dispatch "{\"sessionId\":\"$SESSION\",\"type\":\"click\",\"x\":$X,\"y\":$Y}" >/dev/null 2>&1 &+    done+    wait+    sleep 0.2++    # Click Verify — bottom-right of iframe footer.+    # Footer is the last $FOOTER_PCT of iframe height; VERIFY button sits ~75px in from right edge, ~40px above bottom (CSS).+    VX=$((IX + IW - 75))+    VY=$((IY + IH - 40))+    post_act "{\"mpn\":\"_ul\",\"stage\":\"clicking Verify\"}"+    adom-desktop browser_input_dispatch "{\"sessionId\":\"$SESSION\",\"type\":\"click\",\"x\":$VX,\"y\":$VY}" >/dev/null 2>&1+    sleep 0.5++    # Detect iterative-captcha escalation: bframe still visible + no token = new round needed+    ROUND2=$(adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"(()=>{const t=(document.querySelector('[name=g-recaptcha-response]')?.value||'').length; const bf=document.querySelector('iframe[src*=bframe]'); const r=bf?bf.getBoundingClientRect():null; const visible=r&&r.y>0&&r.y<2000; return JSON.stringify({tokenLen:t,bframe:r?{x:r.x|0,y:r.y|0,w:r.width|0,h:r.height|0}:null,visible:visible});})()\"}" 2>/dev/null \+      | python3 -c "import sys,json; print(json.load(sys.stdin).get('result',''))")+    R2_TOK=$(echo "$ROUND2" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('tokenLen',0))")+    R2_VIS=$(echo "$ROUND2" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('visible',False))")+    if [[ "$R2_TOK" == "0" && ( "$R2_VIS" == "True" || "$R2_VIS" == "true" ) ]]; then+      # Iterative captcha — new tiles appeared. Re-screenshot + output for AI vision again.+      post_act '{"mpn":"_ul","stage":"iterative captcha — round 2 escalation, re-screenshotting"}'+      adom-desktop browser_screenshot "{\"sessionId\":\"$SESSION\",\"path\":\"/tmp/cf-ul-fullpage.png\",\"maxWidth\":2000}" >/dev/null 2>&1+      LATEST=$(ls -1t /tmp/conduit-screenshots/screenshot-*.png 2>/dev/null | head -1)+      cp "$LATEST" /tmp/cf-ul-fullpage.png+      python3 << PYEOF >/dev/null+import json+from PIL import Image+state = json.loads("""$ROUND2""")+bf = state.get('bframe') or {}+dpr = 1.5  # pup default+img = Image.open('/tmp/cf-ul-fullpage.png')+sw, sh = img.size+x = int(bf.get('x',0) * dpr); y = int(bf.get('y',0) * dpr)+w = int(bf.get('w',0) * dpr); h = int(bf.get('h',0) * dpr)+crop = img.crop((x, y, min(x+w, sw), min(y+h, sh)))+crop.save('/tmp/cf-ul-bframe-crop.png')+crop.resize((crop.size[0]*2, crop.size[1]*2)).save('/tmp/cf-ul-bframe-crop-2x.png')+PYEOF+      NEXT_ROUND=$((ROUND + 1))+      cat <<EOF+{"ok":true,"stage":"image_grid_round_${NEXT_ROUND}","round":${NEXT_ROUND},"iframe_png":"/tmp/cf-ul-bframe-crop.png","iframe_png_2x":"/tmp/cf-ul-bframe-crop-2x.png","_hint":"Iterative captcha round ${NEXT_ROUND}. UL rotates approaches — re-read iframe_png_2x carefully (grid may be 3x3 or 4x4, prompt may differ from round 1, type may switch between 'select all' and 'click verify once there are none left'). Call --phase post-captcha --tiles <new-list> --mpns <same-list> --grid <3x3-or-4x4> --round ${NEXT_ROUND}. State preserved."}+EOF+      exit 0+    fi+  fi++  # Atomic-eval poll for token + auto-Submit (90s budget, 200ms cadence — TIGHT)+  post_act '{"mpn":"_ul","stage":"atomic-eval token watcher (Submit fires on token populate)"}'+  SUBMITTED=""+  for i in $(seq 1 450); do+    sleep 0.2+    RES=$(adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"(()=>{const t=(document.querySelector('[name=g-recaptcha-response]')?.value||''); if(t.length>20){const a=Array.from(document.querySelectorAll('a, button, input[type=submit]')).find(e=>/^submit\$/i.test((e.textContent||e.value||'').trim())); if(a){a.click();return 'SUBMITTED_'+a.tagName;} return 'NO_BTN';} return 'WAIT';})()\"}" 2>/dev/null \+      | python3 -c "import sys,json; print(json.load(sys.stdin).get('result',''))")+    case "$RES" in+      SUBMITTED*) SUBMITTED="$RES"; break ;;+      NO_BTN) echo "{\"ok\":false,\"error\":\"token populated but Submit btn not found\",\"attempt\":$i}"; exit 1 ;;+    esac+  done++  if [[ -z "$SUBMITTED" ]]; then+    echo '{"ok":false,"error":"timeout waiting for token — captcha may have failed or token expired"}'+    exit 1+  fi++  # Watch Downloads for new ul_*.zip and pull it in one shot — no shell,+  # no PowerShell, no user-approval dialog.+  post_act '{"mpn":"_ul","stage":"watching Downloads for ul_*.zip"}'+  BEFORE_TS=$(cat /tmp/cf-ul-fast-before.txt)+  PULL=$(adom-desktop desktop_pull_glob "{\"path\":\"%USERPROFILE%\\\\Downloads\",\"glob\":\"ul_*.zip\",\"since\":$BEFORE_TS,\"wait\":true,\"timeoutMs\":60000,\"saveTo\":\"/home/adom/project/chip-fetcher/incoming/\"}" 2>/dev/null)+  ZIP_PATH=$(echo "$PULL" | python3 -c "import sys,json; d=json.load(sys.stdin); f=(d.get('files') or [{}])[0]; print(f.get('path',''))")+  if [[ -z "$ZIP_PATH" || ! -f "$ZIP_PATH" ]]; then+    echo "{\"ok\":false,\"error\":\"timeout — no new ul_*.zip after Submit\",\"detail\":$(echo "$PULL" | python3 -c 'import sys,json; print(json.dumps(json.load(sys.stdin)))')}"+    exit 1+  fi+  ZIP_NAME=$(basename "$ZIP_PATH")++  # Extract once to a tmp dir+  EXTRACT_DIR=$(mktemp -d)+  unzip -q -o "$ZIP_PATH" -d "$EXTRACT_DIR"++  # Distribute KiCad + Altium + Fusion + STEP to each MPN's library dir (family-distribution)+  post_act '{"mpn":"_ul","stage":"distributing KiCad+Altium+Fusion+STEP to family MPNs"}'+  IFS=',' read -ra MPN_ARR <<< "$MPNS"+  DISTRIBUTED=""+  for MPN in "${MPN_ARR[@]}"; do+    LIB="library/$MPN"+    mkdir -p "$LIB"+    # KiCad: .kicad_sym + .kicad_mod (under KiCADv6/ in UL zip)+    SYM=$(find "$EXTRACT_DIR" -name "*.kicad_sym" | head -1)+    MOD=$(find "$EXTRACT_DIR" -name "*.kicad_mod" | head -1)+    # Altium: .SchLib + .PcbLib (under Altium/<MPN>/ in UL zip)+    ALT_SCH=$(find "$EXTRACT_DIR" -name "*.SchLib" | head -1)+    ALT_PCB=$(find "$EXTRACT_DIR" -name "*.PcbLib" | head -1)+    # Fusion 360: .lbr (under Fusion360/ in UL zip)+    FUS_LBR=$(find "$EXTRACT_DIR" -name "*.lbr" | head -1)+    # STEP (top-level OR inside Fusion360/ / Altium/)+    STEP_F=$(find "$EXTRACT_DIR" \( -name "*.step" -o -name "*.stp" \) | head -1)+    [[ -n "$SYM"     ]] && cp "$SYM"     "$LIB/$MPN.kicad_sym"+    [[ -n "$MOD"     ]] && cp "$MOD"     "$LIB/$MPN.kicad_mod"+    [[ -n "$ALT_SCH" ]] && cp "$ALT_SCH" "$LIB/$MPN.SchLib"+    [[ -n "$ALT_PCB" ]] && cp "$ALT_PCB" "$LIB/$MPN.PcbLib"+    [[ -n "$FUS_LBR" ]] && cp "$FUS_LBR" "$LIB/$MPN.lbr"+    # Don't overwrite mfr-direct STEP if already imported+    if [[ -n "$STEP_F" && ! -f "$LIB/$MPN.step" ]]; then+      cp "$STEP_F" "$LIB/$MPN.step"+    fi+    DISTRIBUTED="$DISTRIBUTED \"$MPN\","+    post_act "{\"mpn\":\"$MPN\",\"stage\":\"KiCad+Altium+Fusion imported (mfr-via-UL)\"}"+  done+  DISTRIBUTED="[${DISTRIBUTED%,}]"++  # Update info.json per MPN with mfr-via-UL provenance for sym/mod+  for MPN in "${MPN_ARR[@]}"; do+    INFO="library/$MPN/info.json"+    if [[ -f "$INFO" ]]; then+      python3 -c "+import json+p = '$INFO'+d = json.load(open(p))+d['sym_source'] = 'mfr-via-UL'+d['sym_url'] = '$URL'+d['mod_source'] = 'mfr-via-UL'+d['mod_url'] = '$URL'+d['ul_zip'] = '$ZIP_NAME'+json.dump(d, open(p,'w'), indent=2)+"+    fi+  done++  rm -rf "$EXTRACT_DIR"+  clear_act++  cat <<EOF+{"ok":true,"stage":"done","ul_zip":"$NEW","distributed_to":$DISTRIBUTED,"_hint":"sym+mod imported to library/<MPN>/. Refresh chip-fetcher dashboard to see the chips promoted from stub to bundled."}+EOF+  exit 0++else+  cat <<EOF+{"ok":false,"error":"--phase must be 'pre-captcha' or 'post-captcha'","_hint":"Phase 1: cf-ul-fast.sh --phase pre-captcha --url <UL-URL>. Phase 2: cf-ul-fast.sh --phase post-captcha --tiles 4,9 --mpns A,B,C"}+EOF+  exit 1+fi
scripts/cf-ul-fetch.shadded+91
@@ -0,0 +1,91 @@+#!/bin/bash+# cf-ul-fetch.sh <MPN> <UL-deep-link>+#+# Drive the Ultra Librarian "Choose CAD Formats & Download" flow with the+# proven fast-path pattern. Goes through reCAPTCHA, lets the AI/user solve+# any image grids, and submits the moment the token populates — no+# round-trip between detect and Submit.+#+# Use the UL deep-link from the manufacturer's product page (e.g. on TI:+# scrape `vendor.ultralibrarian.com/.../embedded/?gpn=...&package=...&sid=...`).+# Do NOT guess `?vdrPN=` — that often routes to the wrong part.+#+# Example:+#   bash cf-ul-fetch.sh TPS25751SRSMR \+#     'https://vendor.ultralibrarian.com/TI/embedded/?gpn=TPS25751&package=RSM&pin=32&sid=lit2ddb1769f694931cc606e8&c=1'++set -u+MPN="${1:?usage: cf-ul-fetch.sh <MPN> <UL-URL>}"+UL_URL="${2:?usage: cf-ul-fetch.sh <MPN> <UL-URL>}"+SESSION="${SESSION:-chip-fetcher}"+ACTIVITY="http://127.0.0.1:8786/api/activity"++post_act()  { curl -s -X POST "$ACTIVITY" -H 'Content-Type: application/json' -d "{\"mpn\":\"$MPN\",\"stage\":\"$1\"}" >/dev/null; }+clear_act() { curl -s -X POST "$ACTIVITY" -H 'Content-Type: application/json' -d '{}' >/dev/null; }++post_act "navigating UL embedded"+adom-desktop browser_navigate "{\"sessionId\":\"$SESSION\",\"url\":\"$UL_URL\"}" >/dev/null 2>&1+sleep 6++# Verify part is correct (UL silently routes to wrong parts when gpn doesn't match)+TITLE=$(adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"document.body.textContent.replace(/\\\\s+/g,' ').slice(0,200)\"}" 2>&1 \+  | python3 -c "import sys,json; print(json.load(sys.stdin).get('result',''))")+echo "  loaded: $TITLE"+case "${TITLE,,}" in+  *"$(echo $MPN | tr '[:upper:]' '[:lower:]' | head -c 8)"*) echo "  ✓ part match" ;;+  *) echo "  ⚠ part mismatch — UL may have routed to a different chip; check the UL URL"; exit 1 ;;+esac++post_act "opening Choose CAD Formats"+RECT=$(adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"(()=>{const el=Array.from(document.querySelectorAll('a')).find(a=>/choose cad/i.test(a.textContent)); if(!el)return null; const r=el.getBoundingClientRect(); return JSON.stringify({x:Math.round(r.left+r.width/2),y:Math.round(r.top+r.height/2)});})()\"}" 2>&1 \+  | python3 -c "import sys,json; print(json.load(sys.stdin).get('result',''))")+X=$(echo "$RECT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('x',0))" 2>/dev/null)+Y=$(echo "$RECT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('y',0))" 2>/dev/null)+adom-desktop browser_input_dispatch "{\"sessionId\":\"$SESSION\",\"type\":\"click\",\"x\":$X,\"y\":$Y}" >/dev/null 2>&1+sleep 3++post_act "pre-setting form (STEP+KiCAD v6+ +Altium+Fusion360+mm+T&C)"+adom-desktop browser_eval '{"sessionId":"'$SESSION'","expr":"(()=>{const cbs=Array.from(document.querySelectorAll(\"input[type=checkbox]\")); const want=[/^STEP$/i,/kicad v6/i,/^altium designer/i,/^fusion.?360/i,/agree|terms/i]; for(const re of want){const c=cbs.find(c=>re.test((c.parentElement?.textContent||c.closest(\"label\")?.textContent||\"\").trim())); if(c&&!c.checked) c.click();} const sels=Array.from(document.querySelectorAll(\"select\")); const u=sels.find(s=>Array.from(s.options).some(o=>/metric|mm/i.test(o.textContent))); if(u){const m=Array.from(u.options).find(o=>/metric|mm/i.test(o.textContent)); u.value=m.value;u.dispatchEvent(new Event(\"change\",{bubbles:true}));} return cbs.filter(c=>c.checked).length+\" checked\";})()"}' >/dev/null 2>&1++# Mark a unix-second checkpoint BEFORE the captcha. desktop_pull_glob below+# uses this as `since`, so only files written AFTER the checkpoint match.+BEFORE_TS=$(date +%s)++post_act "clicking reCAPTCHA — solve image grid in pup window if it appears"+RRECT=$(adom-desktop browser_eval '{"sessionId":"'$SESSION'","expr":"(()=>{const f=document.querySelector(\"iframe[src*=recaptcha]\"); if(!f)return null; const r=f.getBoundingClientRect(); return JSON.stringify({x:r.x|0,y:r.y|0});})()"}' 2>&1 \+  | python3 -c "import sys,json; print(json.load(sys.stdin).get('result',''))")+IX=$(echo "$RRECT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('x',0)+30)")+IY=$(echo "$RRECT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('y',0)+40)")+adom-desktop browser_input_dispatch "{\"sessionId\":\"$SESSION\",\"type\":\"click\",\"x\":$IX,\"y\":$IY}" >/dev/null 2>&1++# === THE WINNING PATTERN ===+# Atomic eval: in ONE browser_eval call, check token and click Submit if good.+# Eliminates the round-trip between "token detected" and "Submit clicked"+# that was costing us the captcha lifetime in earlier attempts.+post_act "watching for token → atomic submit"+for i in $(seq 1 90); do+  sleep 1+  RES=$(adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"(()=>{const t=(document.querySelector('[name=g-recaptcha-response]')?.value||''); if(t.length>20){const a=Array.from(document.querySelectorAll('a, button, input[type=submit]')).find(e=>/^submit\$/i.test((e.textContent||e.value||'').trim())); if(a){a.click();return 'SUBMITTED_'+a.tagName;} return 'NO_BTN';} return 'WAIT';})()\"}" 2>/dev/null \+    | python3 -c "import sys,json; print(json.load(sys.stdin).get('result',''))")+  case "$RES" in+    SUBMITTED*) echo "  $RES on attempt $i"; break ;;+  esac+done++post_act "watching Downloads for ul_*.zip"+cd /home/adom/project/chip-fetcher+PULL=$(adom-desktop desktop_pull_glob "{\"path\":\"%USERPROFILE%\\\\Downloads\",\"glob\":\"ul_*.zip\",\"since\":$BEFORE_TS,\"wait\":true,\"timeoutMs\":60000,\"saveTo\":\"$(pwd)/incoming/\"}" 2>/dev/null)+ZIP_PATH=$(echo "$PULL" | python3 -c "import sys,json; d=json.load(sys.stdin); f=(d.get('files') or [{}])[0]; print(f.get('path',''))")+if [ -z "$ZIP_PATH" ] || [ ! -f "$ZIP_PATH" ]; then+  post_act "TIMEOUT — no zip landed"+  echo "  TIMEOUT: no ul_*.zip in Downloads — $PULL"+  clear_act+  exit 1+fi++echo "  FOUND: $ZIP_PATH"+post_act "importing"+./target/release/chip-fetcher import "$ZIP_PATH" --mpn "$MPN" 2>&1 | tail -10+post_act "imported"+sleep 2+clear_act
scripts/cf-ul-pro-batch.shadded+103
@@ -0,0 +1,103 @@+#!/bin/bash+# cf-ul-pro-batch.sh — UL Pro batch fetcher for ONE MPN.+# Requires: chip-fetcher pup session signed in to app.ultralibrarian.com+# (cookie persists; verify via `chip-fetcher login ultralib` if not).+#+# Usage:+#   ./cf-ul-pro-batch.sh <MPN>+#+# Posts activity updates to /api/activity throughout so the dashboard+# shows real-time progress.++set -u+MPN="${1:?MPN required}"+SESSION="chip-fetcher"+ACTIVITY="http://127.0.0.1:8786/api/activity"+CF=/home/adom/project/chip-fetcher++post_activity() {+  curl -s -X POST "$ACTIVITY" -H "Content-Type: application/json" \+    -d "{\"mpn\":\"$MPN\",\"stage\":\"$1\"}" > /dev/null+}++clear_activity() {+  curl -s -X POST "$ACTIVITY" -H "Content-Type: application/json" -d '{}' > /dev/null+}++post_activity "searching UL Pro"++# Step 1: Search UL Pro for the MPN, get part-page URL+adom-desktop browser_navigate "{\"sessionId\":\"$SESSION\",\"tabId\":\"tab-1\",\"url\":\"https://app.ultralibrarian.com/search?queryText=${MPN}\"}" >/dev/null 2>&1+sleep 5+URL=$(adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"tabId\":\"tab-1\",\"expr\":\"document.querySelector('a[href*=details]')?.href || ''\"}" 2>/dev/null \+  | python3 -c "import sys,json; print(json.load(sys.stdin).get('result','').strip())")+if [[ -z "$URL" ]]; then+  post_activity "ERROR: not found on UL Pro"; sleep 2; clear_activity+  echo "ERROR: $MPN not found on UL Pro"; exit 1+fi+echo "  url: $URL"++# Step 2: Navigate to part page+post_activity "loading part page"+adom-desktop browser_navigate "{\"sessionId\":\"$SESSION\",\"tabId\":\"tab-1\",\"url\":\"$URL\"}" >/dev/null 2>&1+sleep 4++# Step 3a: Click the BUTTON "Download Now" first to open the format picker.+# (UL Pro's flow: BUTTON expands picker → user ticks formats → A.export-trigger submits.)+post_activity "opening format picker"+adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"tabId\":\"tab-1\",\"expr\":\"(()=>{var btn=Array.from(document.querySelectorAll('button')).find(b=>b.textContent.trim()==='Download Now' && b.offsetParent!==null); if(btn){btn.click(); return 'opened';} return 'no-button';})()\"}" >/dev/null 2>&1+sleep 2++# Step 3b: Tick Altium Designer + Fusion360 PCB + any consent checkboxes (now in the open picker)+post_activity "selecting Altium + Fusion formats"+TICKED=$(adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"tabId\":\"tab-1\",\"expr\":\"(()=>{var labels=['Altium Designer','Fusion360 PCB']; var ticked=[]; document.querySelectorAll('input[type=checkbox]').forEach(cb=>{var lbl=cb.labels?.[0]?.textContent.trim()||''; if(labels.indexOf(lbl)>=0||/consent/i.test(cb.name||'')){cb.checked=true; cb.dispatchEvent(new Event('change',{bubbles:true})); ticked.push(lbl||cb.name);}}); return ticked.length;})()\"}" 2>/dev/null \+  | python3 -c "import sys,json; print(json.load(sys.stdin).get('result',''))")+echo "  ticked: $TICKED checkboxes"+sleep 1++# Step 4: Mark timestamp BEFORE the click, then click the visible+# A.export-trigger Download Now anchor (actual submit). Uses unix seconds —+# adom-desktop v1.5+ accepts that for `since`.+BEFORE=$(date +%s)++post_activity "submitting download request"+CLICK=$(adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"tabId\":\"tab-1\",\"expr\":\"(()=>{var btn=Array.from(document.querySelectorAll('a.export-trigger')).find(b=>b.textContent.trim()==='Download Now' && b.offsetParent!==null); if(!btn) btn=Array.from(document.querySelectorAll('button')).find(b=>b.textContent.trim()==='Download Now' && b.offsetParent!==null); if(!btn) return 'no-btn'; btn.click(); return btn.tagName;})()\"}" 2>/dev/null \+  | python3 -c "import sys,json; print(json.load(sys.stdin).get('result',''))")+if [[ "$CLICK" == "no-btn" || -z "$CLICK" ]]; then+  post_activity "ERROR: submit button not found"; sleep 2; clear_activity+  echo "ERROR: A.export-trigger Download Now not found"; exit 2+fi+echo "  submitted via: $CLICK"++# Step 5+6: Watch Downloads for new ul_*.zip and pull it in one shot via+# the new desktop_pull_glob — no PowerShell, no shell_execute, no user prompt.+post_activity "downloading from UL Pro"+PULL=$(adom-desktop desktop_pull_glob "{\"path\":\"%USERPROFILE%\\\\Downloads\",\"glob\":\"ul_*.zip\",\"since\":$BEFORE,\"wait\":true,\"timeoutMs\":120000,\"saveTo\":\"$CF/incoming/\"}" 2>/dev/null)+ZIP=$(echo "$PULL" | python3 -c "import sys,json; d=json.load(sys.stdin); f=(d.get('files') or [{}])[0]; print(f.get('path',''))")+NEW=$(basename "$ZIP")+if [[ -z "$ZIP" || ! -f "$ZIP" ]]; then+  post_activity "ERROR: download never appeared"; sleep 2; clear_activity+  echo "ERROR: no zip in Downloads after 2 min — $PULL"; exit 3+fi+echo "  download: $NEW"++# Step 7: Distribute Altium + Fusion files+post_activity "distributing Altium + Fusion"+EXTRACT=$(mktemp -d)+unzip -q -o "$ZIP" -d "$EXTRACT"+LIB="$CF/library/$MPN"+mkdir -p "$LIB"+ADDED=""+SCH=$(find "$EXTRACT" -name "*.SchLib" | head -1)+PCB=$(find "$EXTRACT" -name "*.PcbLib" | head -1)+LBR=$(find "$EXTRACT" -name "*.lbr" | head -1)+[[ -n "$SCH" ]] && cp "$SCH" "$LIB/$MPN.SchLib" && ADDED="$ADDED SchLib"+[[ -n "$PCB" ]] && cp "$PCB" "$LIB/$MPN.PcbLib" && ADDED="$ADDED PcbLib"+[[ -n "$LBR" ]] && cp "$LBR" "$LIB/$MPN.lbr"    && ADDED="$ADDED lbr"+rm -rf "$EXTRACT"+echo "  distributed:$ADDED"++post_activity "done"+sleep 1+clear_activity+echo "OK $MPN"
scripts/cf-ul-pro.shadded+22
@@ -0,0 +1,22 @@+#!/bin/bash+# cf-ul-pro.sh — UL Pro (app.ultralibrarian.com) batch helper.+# Searches for an MPN, navigates to first result, then runs cf-ul-fast.sh phase-1+# which now auto-detects UL Pro vs vendor-embedded flow.+#+# Usage:+#   ./cf-ul-pro.sh <MPN>     # outputs the part-page URL ready for phase-1++set -u+MPN="${1:?MPN required}"+SESSION="chip-fetcher"++adom-desktop browser_navigate "{\"sessionId\":\"$SESSION\",\"url\":\"https://app.ultralibrarian.com/search?queryText=${MPN}\"}" >/dev/null 2>&1+sleep 5++URL=$(adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"document.querySelector('a[href*=details]')?.href || ''\"}" 2>/dev/null \+  | python3 -c "import sys,json; print(json.load(sys.stdin).get('result','').strip())")+if [[ -z "$URL" ]]; then+  echo "ERROR: no UL Pro result for $MPN"+  exit 1+fi+echo "$URL"
scripts/cf-vendor-backfill.shadded+95
@@ -0,0 +1,95 @@+#!/bin/bash+# cf-vendor-backfill.sh <MPN> <product-page-URL>+# Generic per-vendor PDF backfill: navigates pup to a product page, scrapes all+# anchor links pointing at .pdf, and curls each into library/<MPN>/.+#+# Reuses the active chip-fetcher tab (browser_navigate, not browser_open_tab).++set -u+MPN="${1:?usage: cf-vendor-backfill.sh <MPN> <URL>}"+URL="${2:?usage: cf-vendor-backfill.sh <MPN> <URL>}"++LIB_DIR="/home/adom/project/chip-fetcher/library/$MPN"+mkdir -p "$LIB_DIR"++echo "=== vendor backfill: $MPN ==="+echo "  product page: $URL"++adom-desktop browser_navigate "{\"sessionId\":\"chip-fetcher\",\"url\":\"$URL\"}" >/dev/null 2>&1+sleep 6++# Verify not 404+TITLE=$(adom-desktop browser_eval '{"sessionId":"chip-fetcher","expr":"document.title.slice(0,80)"}' 2>/dev/null \+  | python3 -c "import sys,json; print(json.load(sys.stdin).get('result',''))" 2>/dev/null)+if echo "$TITLE" | grep -qi "404\|not found\|page not"; then+  echo "  SKIP: 404 ($TITLE)"+  exit 0+fi++# Scrape all PDF anchor hrefs+PDF_JSON=$(adom-desktop browser_eval '{"sessionId":"chip-fetcher","expr":"(()=>{const links=Array.from(document.querySelectorAll(\"a\")).filter(a=>/\\.pdf(\\?|$|#)/i.test(a.href||\"\"));const seen=new Set();const out=[];for(const a of links){const h=a.href;if(seen.has(h))continue;seen.add(h);const rowText=(a.closest(\"tr,li,article,section,div\")?.textContent||a.textContent||\"\").trim().replace(/\\s+/g,\" \").slice(0,80);out.push(h+\"|\"+rowText);}return JSON.stringify(out);})()"}' 2>/dev/null \+  | python3 -c "import sys,json; print(json.load(sys.stdin).get('result',''))" 2>/dev/null)++if [ -z "$PDF_JSON" ] || [ "$PDF_JSON" = "[]" ]; then+  echo "  SKIP: no PDF links on page"+  exit 0+fi++echo "$PDF_JSON" | python3 -c "+import sys, json+items = json.load(sys.stdin)+for entry in items:+    parts = entry.split('|', 1)+    if len(parts) == 2:+        print(parts[0] + '\t' + parts[1])+    else:+        print(parts[0] + '\t')+" > /tmp/cf-vendor-pdfs.txt++DOC_COUNT=$(wc -l < /tmp/cf-vendor-pdfs.txt)+echo "  found $DOC_COUNT unique PDF links"++COUNT=0+SKIPPED=0+FAILED=0+while IFS=$'\t' read -r PDF_URL TITLE; do+  [ -z "$PDF_URL" ] && continue++  # Build slug from title; fall back to filename+  if [ -n "$TITLE" ]; then+    SLUG=$(echo "$TITLE" | sed -E 's/[^a-zA-Z0-9-]+/-/g; s/^-+|-+$//g; s/--+/-/g' | cut -c1-40 | tr 'A-Z' 'a-z')+  fi+  if [ -z "${SLUG:-}" ]; then+    SLUG=$(basename "$PDF_URL" .pdf | tr 'A-Z' 'a-z' | cut -c1-40)+  fi+  OUT="$LIB_DIR/$MPN-$SLUG.pdf"+  if [ -f "$OUT" ]; then+    SKIPPED=$((SKIPPED + 1)); continue+  fi++  HEAD=$(curl -sLI -A "Mozilla/5.0" "$PDF_URL" 2>/dev/null)+  CT=$(echo "$HEAD" | grep -i "^content-type:" | tail -1 | tr -d '\r' | awk '{print $2}')+  CL=$(echo "$HEAD" | grep -i "^content-length:" | tail -1 | tr -d '\r' | awk '{print $2}')++  if echo "$CT" | grep -qi "pdf"; then+    if [ -n "$CL" ] && [ "$CL" -gt 26214400 ]; then+      echo "  skip large: $SLUG ($((CL/1024/1024))MB)"+      SKIPPED=$((SKIPPED + 1)); continue+    fi+    if curl -sL -A "Mozilla/5.0" -o "$OUT" "$PDF_URL" 2>/dev/null; then+      SIZE=$(stat -c%s "$OUT" 2>/dev/null || echo 0)+      if [ "$SIZE" -gt 1000 ]; then+        echo "  + $MPN-$SLUG.pdf ($((SIZE/1024))KB)"+        COUNT=$((COUNT + 1))+      else+        rm -f "$OUT"; FAILED=$((FAILED + 1))+      fi+    else+      FAILED=$((FAILED + 1))+    fi+  else+    SKIPPED=$((SKIPPED + 1))+  fi+done < /tmp/cf-vendor-pdfs.txt++echo "  pulled=$COUNT skipped=$SKIPPED failed=$FAILED"
scripts/gen-footprint-viewer.mjsadded+84
@@ -0,0 +1,84 @@+#!/usr/bin/env node+/**+ * gen-footprint-viewer.mjs — generate <MPN>-footprint-viewer.html (FpView)+ * for one chip. Mirrors gen-symbol-viewer.mjs's pattern:+ *   1. Pre-render the .kicad_mod → SVG via local kicad-cli (using a shim+ *      .pretty/ dir, since kicad-cli demands one for fp export).+ *   2. Pass that SVG content directly to gallia's generateFootprintViewer+ *      via the new svgContent option, bypassing the broken HTTP route.+ *   3. Write <MPN>-footprint-viewer.html into the chip's library dir.+ *+ * Usage:+ *   node gen-footprint-viewer.mjs <library-dir>/<MPN>+ */++import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync, copyFileSync, rmSync } from 'fs';+import { tmpdir } from 'os';+import { execFileSync } from 'child_process';+import { join, basename, resolve } from 'path';++const arg = process.argv[2];+if (!arg) {+  console.error('usage: gen-footprint-viewer.mjs <library-dir>/<MPN>');+  process.exit(2);+}++const dir = resolve(arg);+const mpn = basename(dir);+const fpPath = join(dir, `${mpn}.kicad_mod`);+if (!existsSync(fpPath)) {+  console.error(`ERROR: ${fpPath} not found`);+  process.exit(1);+}++// ── kicad-cli fp export svg requires a .pretty/ dir input ──+const tmp = mkdtempSync(join(tmpdir(), 'cf-fpview-'));+const pretty = join(tmp, `${mpn}.pretty`);+mkdirSync(pretty, { recursive: true });+copyFileSync(fpPath, join(pretty, `${mpn}.kicad_mod`));++try {+  execFileSync('kicad-cli', [+    'fp', 'export', 'svg',+    '--layers', 'F.Cu,F.SilkS,F.Mask,F.Fab,Edge.Cuts',+    '--output', tmp,+    pretty,+  ], { stdio: 'pipe' });+} catch (e) {+  console.error('ERROR: kicad-cli fp export svg failed:', e.stderr?.toString() || e.message);+  rmSync(tmp, { recursive: true, force: true });+  process.exit(1);+}++const svgFile = readdirSync(tmp).find(f => f.endsWith('.svg'));+if (!svgFile) {+  console.error('ERROR: kicad-cli produced no svg in', tmp);+  rmSync(tmp, { recursive: true, force: true });+  process.exit(1);+}+const svgContent = readFileSync(join(tmp, svgFile), 'utf-8');+rmSync(tmp, { recursive: true, force: true });++// ── Build FpView HTML via gallia's generateFootprintViewer ──+const { generateFootprintViewer } = await import(+  '/home/adom/project/gallia/viewer/kicad-footprint-viewer.js'+);++const infoPath = join(dir, 'info.json');+let info = {};+if (existsSync(infoPath)) {+  try { info = JSON.parse(readFileSync(infoPath, 'utf-8')); } catch {}+}++const html = await generateFootprintViewer(fpPath, mpn, {+  padDescriptions: {},+  symbolPinMap: {},+  datasheetUrl: info.datasheet_url && info.datasheet_url !== '?' ? info.datasheet_url : '',+  manufacturer: info.manufacturer && info.manufacturer !== '?' ? info.manufacturer : '',+  partName: mpn,+  svgContent,+});++const outPath = join(dir, `${mpn}-footprint-viewer.html`);+writeFileSync(outPath, html);+console.log(`OK: ${outPath} (${html.length} bytes)`);
scripts/gen-symbol-viewer.mjsadded+78
@@ -0,0 +1,78 @@+#!/usr/bin/env node+/**+ * gen-symbol-viewer.mjs — generate <MPN>-symbol-viewer.html for one chip.+ *+ * Bypasses symbol-creator's HTTP service entirely. Pre-renders the symbol+ * SVG via LOCAL kicad-cli (gallia's HTTP route doesn't accept arbitrary+ * file uploads) and passes it directly to generateBrandedViewer via the+ * `svgContent` option. This sidesteps:+ *   - Port collisions (no localhost service required)+ *   - Stale env state on background symbol-creator processes+ *   - service-kicad's `/sym/export/svg` POST route not being implemented+ *     for arbitrary kicad_sym uploads (only `<library>/<name>` GETs work)+ *+ * Usage:+ *   node gen-symbol-viewer.mjs <library-dir>/<MPN>+ *+ * Writes <MPN>-symbol-viewer.html into the same dir.+ */++import { existsSync, mkdtempSync, readFileSync, readdirSync, writeFileSync, rmSync } from 'fs';+import { tmpdir } from 'os';+import { execFileSync } from 'child_process';+import { join, basename, resolve } from 'path';++const arg = process.argv[2];+if (!arg) {+  console.error('usage: gen-symbol-viewer.mjs <library-dir>/<MPN>');+  process.exit(2);+}++const dir = resolve(arg);+const mpn = basename(dir);+const symPath = join(dir, `${mpn}.kicad_sym`);+if (!existsSync(symPath)) {+  console.error(`ERROR: ${symPath} not found`);+  process.exit(1);+}++// ── Local SVG render via kicad-cli — same approach chip-thumbnailer uses ──+const tmp = mkdtempSync(join(tmpdir(), 'cf-symview-'));+try {+  execFileSync('kicad-cli', ['sym', 'export', 'svg', '--output', tmp, symPath], { stdio: 'pipe' });+} catch (e) {+  console.error('ERROR: kicad-cli sym export svg failed:', e.stderr?.toString() || e.message);+  rmSync(tmp, { recursive: true, force: true });+  process.exit(1);+}+const svgFile = readdirSync(tmp).find(f => f.endsWith('.svg'));+if (!svgFile) {+  console.error('ERROR: kicad-cli produced no svg in', tmp);+  rmSync(tmp, { recursive: true, force: true });+  process.exit(1);+}+const svgContent = readFileSync(join(tmp, svgFile), 'utf-8');+rmSync(tmp, { recursive: true, force: true });++// ── Build viewer HTML via gallia's generateBrandedViewer ──+const { generateBrandedViewer } = await import(+  '/home/adom/project/gallia/symbol-creator/lib/viewer-gen.js'+);++const infoPath = join(dir, 'info.json');+let info = {};+if (existsSync(infoPath)) {+  try { info = JSON.parse(readFileSync(infoPath, 'utf-8')); } catch {}+}++const html = await generateBrandedViewer(symPath, mpn, {+  pinDescriptions: {},+  groupDescriptions: {},+  datasheetUrl: info.datasheet_url && info.datasheet_url !== '?' ? info.datasheet_url : '',+  manufacturer: info.manufacturer && info.manufacturer !== '?' ? info.manufacturer : '',+  svgContent,+});++const outPath = join(dir, `${mpn}-symbol-viewer.html`);+writeFileSync(outPath, html);+console.log(`OK: ${outPath} (${html.length} bytes)`);
scripts/hershey_simplex.pyadded+3
@@ -0,0 +1,3 @@+# Hershey simplex single-stroke font (from @jscad/modeling, public domain Paul Bourke)+# {ascii: [width, x,y, ... None=pen-up ...]}, y-up, baseline y=0+FONT = {'height': 14, 32: [16], 33: [10, 5, 21, 5, 7, None, 5, 2, 4, 1, 5, 0, 6, 1, 5, 2], 34: [16, 4, 21, 4, 14, None, 12, 21, 12, 14], 35: [21, 11, 25, 4, -7, None, 17, 25, 10, -7, None, 4, 12, 18, 12, None, 3, 6, 17, 6], 36: [20, 8, 25, 8, -4, None, 12, 25, 12, -4, None, 17, 18, 15, 20, 12, 21, 8, 21, 5, 20, 3, 18, 3, 16, 4, 14, 5, 13, 7, 12, 13, 10, 15, 9, 16, 8, 17, 6, 17, 3, 15, 1, 12, 0, 8, 0, 5, 1, 3, 3], 37: [24, 21, 21, 3, 0, None, 8, 21, 10, 19, 10, 17, 9, 15, 7, 14, 5, 14, 3, 16, 3, 18, 4, 20, 6, 21, 8, 21, 10, 20, 13, 19, 16, 19, 19, 20, 21, 21, None, 17, 7, 15, 6, 14, 4, 14, 2, 16, 0, 18, 0, 20, 1, 21, 3, 21, 5, 19, 7, 17, 7], 38: [26, 23, 12, 23, 13, 22, 14, 21, 14, 20, 13, 19, 11, 17, 6, 15, 3, 13, 1, 11, 0, 7, 0, 5, 1, 4, 2, 3, 4, 3, 6, 4, 8, 5, 9, 12, 13, 13, 14, 14, 16, 14, 18, 13, 20, 11, 21, 9, 20, 8, 18, 8, 16, 9, 13, 11, 10, 16, 3, 18, 1, 20, 0, 22, 0, 23, 1, 23, 2], 39: [10, 5, 19, 4, 20, 5, 21, 6, 20, 6, 18, 5, 16, 4, 15], 40: [14, 11, 25, 9, 23, 7, 20, 5, 16, 4, 11, 4, 7, 5, 2, 7, -2, 9, -5, 11, -7], 41: [14, 3, 25, 5, 23, 7, 20, 9, 16, 10, 11, 10, 7, 9, 2, 7, -2, 5, -5, 3, -7], 42: [16, 8, 21, 8, 9, None, 3, 18, 13, 12, None, 13, 18, 3, 12], 43: [26, 13, 18, 13, 0, None, 4, 9, 22, 9], 44: [10, 6, 1, 5, 0, 4, 1, 5, 2, 6, 1, 6, -1, 5, -3, 4, -4], 45: [26, 4, 9, 22, 9], 46: [10, 5, 2, 4, 1, 5, 0, 6, 1, 5, 2], 47: [22, 20, 25, 2, -7], 48: [20, 9, 21, 6, 20, 4, 17, 3, 12, 3, 9, 4, 4, 6, 1, 9, 0, 11, 0, 14, 1, 16, 4, 17, 9, 17, 12, 16, 17, 14, 20, 11, 21, 9, 21], 49: [20, 6, 17, 8, 18, 11, 21, 11, 0], 50: [20, 4, 16, 4, 17, 5, 19, 6, 20, 8, 21, 12, 21, 14, 20, 15, 19, 16, 17, 16, 15, 15, 13, 13, 10, 3, 0, 17, 0], 51: [20, 5, 21, 16, 21, 10, 13, 13, 13, 15, 12, 16, 11, 17, 8, 17, 6, 16, 3, 14, 1, 11, 0, 8, 0, 5, 1, 4, 2, 3, 4], 52: [20, 13, 21, 3, 7, 18, 7, None, 13, 21, 13, 0], 53: [20, 15, 21, 5, 21, 4, 12, 5, 13, 8, 14, 11, 14, 14, 13, 16, 11, 17, 8, 17, 6, 16, 3, 14, 1, 11, 0, 8, 0, 5, 1, 4, 2, 3, 4], 54: [20, 16, 18, 15, 20, 12, 21, 10, 21, 7, 20, 5, 17, 4, 12, 4, 7, 5, 3, 7, 1, 10, 0, 11, 0, 14, 1, 16, 3, 17, 6, 17, 7, 16, 10, 14, 12, 11, 13, 10, 13, 7, 12, 5, 10, 4, 7], 55: [20, 17, 21, 7, 0, None, 3, 21, 17, 21], 56: [20, 8, 21, 5, 20, 4, 18, 4, 16, 5, 14, 7, 13, 11, 12, 14, 11, 16, 9, 17, 7, 17, 4, 16, 2, 15, 1, 12, 0, 8, 0, 5, 1, 4, 2, 3, 4, 3, 7, 4, 9, 6, 11, 9, 12, 13, 13, 15, 14, 16, 16, 16, 18, 15, 20, 12, 21, 8, 21], 57: [20, 16, 14, 15, 11, 13, 9, 10, 8, 9, 8, 6, 9, 4, 11, 3, 14, 3, 15, 4, 18, 6, 20, 9, 21, 10, 21, 13, 20, 15, 18, 16, 14, 16, 9, 15, 4, 13, 1, 10, 0, 8, 0, 5, 1, 4, 3], 58: [10, 5, 14, 4, 13, 5, 12, 6, 13, 5, 14, None, 5, 2, 4, 1, 5, 0, 6, 1, 5, 2], 59: [10, 5, 14, 4, 13, 5, 12, 6, 13, 5, 14, None, 6, 1, 5, 0, 4, 1, 5, 2, 6, 1, 6, -1, 5, -3, 4, -4], 60: [24, 20, 18, 4, 9, 20, 0], 61: [26, 4, 12, 22, 12, None, 4, 6, 22, 6], 62: [24, 4, 18, 20, 9, 4, 0], 63: [18, 3, 16, 3, 17, 4, 19, 5, 20, 7, 21, 11, 21, 13, 20, 14, 19, 15, 17, 15, 15, 14, 13, 13, 12, 9, 10, 9, 7, None, 9, 2, 8, 1, 9, 0, 10, 1, 9, 2], 64: [27, 18, 13, 17, 15, 15, 16, 12, 16, 10, 15, 9, 14, 8, 11, 8, 8, 9, 6, 11, 5, 14, 5, 16, 6, 17, 8, None, 12, 16, 10, 14, 9, 11, 9, 8, 10, 6, 11, 5, None, 18, 16, 17, 8, 17, 6, 19, 5, 21, 5, 23, 7, 24, 10, 24, 12, 23, 15, 22, 17, 20, 19, 18, 20, 15, 21, 12, 21, 9, 20, 7, 19, 5, 17, 4, 15, 3, 12, 3, 9, 4, 6, 5, 4, 7, 2, 9, 1, 12, 0, 15, 0, 18, 1, 20, 2, 21, 3, None, 19, 16, 18, 8, 18, 6, 19, 5], 65: [18, 9, 21, 1, 0, None, 9, 21, 17, 0, None, 4, 7, 14, 7], 66: [21, 4, 21, 4, 0, None, 4, 21, 13, 21, 16, 20, 17, 19, 18, 17, 18, 15, 17, 13, 16, 12, 13, 11, None, 4, 11, 13, 11, 16, 10, 17, 9, 18, 7, 18, 4, 17, 2, 16, 1, 13, 0, 4, 0], 67: [21, 18, 16, 17, 18, 15, 20, 13, 21, 9, 21, 7, 20, 5, 18, 4, 16, 3, 13, 3, 8, 4, 5, 5, 3, 7, 1, 9, 0, 13, 0, 15, 1, 17, 3, 18, 5], 68: [21, 4, 21, 4, 0, None, 4, 21, 11, 21, 14, 20, 16, 18, 17, 16, 18, 13, 18, 8, 17, 5, 16, 3, 14, 1, 11, 0, 4, 0], 69: [19, 4, 21, 4, 0, None, 4, 21, 17, 21, None, 4, 11, 12, 11, None, 4, 0, 17, 0], 70: [18, 4, 21, 4, 0, None, 4, 21, 17, 21, None, 4, 11, 12, 11], 71: [21, 18, 16, 17, 18, 15, 20, 13, 21, 9, 21, 7, 20, 5, 18, 4, 16, 3, 13, 3, 8, 4, 5, 5, 3, 7, 1, 9, 0, 13, 0, 15, 1, 17, 3, 18, 5, 18, 8, None, 13, 8, 18, 8], 72: [22, 4, 21, 4, 0, None, 18, 21, 18, 0, None, 4, 11, 18, 11], 73: [8, 4, 21, 4, 0], 74: [16, 12, 21, 12, 5, 11, 2, 10, 1, 8, 0, 6, 0, 4, 1, 3, 2, 2, 5, 2, 7], 75: [21, 4, 21, 4, 0, None, 18, 21, 4, 7, None, 9, 12, 18, 0], 76: [17, 4, 21, 4, 0, None, 4, 0, 16, 0], 77: [24, 4, 21, 4, 0, None, 4, 21, 12, 0, None, 20, 21, 12, 0, None, 20, 21, 20, 0], 78: [22, 4, 21, 4, 0, None, 4, 21, 18, 0, None, 18, 21, 18, 0], 79: [22, 9, 21, 7, 20, 5, 18, 4, 16, 3, 13, 3, 8, 4, 5, 5, 3, 7, 1, 9, 0, 13, 0, 15, 1, 17, 3, 18, 5, 19, 8, 19, 13, 18, 16, 17, 18, 15, 20, 13, 21, 9, 21], 80: [21, 4, 21, 4, 0, None, 4, 21, 13, 21, 16, 20, 17, 19, 18, 17, 18, 14, 17, 12, 16, 11, 13, 10, 4, 10], 81: [22, 9, 21, 7, 20, 5, 18, 4, 16, 3, 13, 3, 8, 4, 5, 5, 3, 7, 1, 9, 0, 13, 0, 15, 1, 17, 3, 18, 5, 19, 8, 19, 13, 18, 16, 17, 18, 15, 20, 13, 21, 9, 21, None, 12, 4, 18, -2], 82: [21, 4, 21, 4, 0, None, 4, 21, 13, 21, 16, 20, 17, 19, 18, 17, 18, 15, 17, 13, 16, 12, 13, 11, 4, 11, None, 11, 11, 18, 0], 83: [20, 17, 18, 15, 20, 12, 21, 8, 21, 5, 20, 3, 18, 3, 16, 4, 14, 5, 13, 7, 12, 13, 10, 15, 9, 16, 8, 17, 6, 17, 3, 15, 1, 12, 0, 8, 0, 5, 1, 3, 3], 84: [16, 8, 21, 8, 0, None, 1, 21, 15, 21], 85: [22, 4, 21, 4, 6, 5, 3, 7, 1, 10, 0, 12, 0, 15, 1, 17, 3, 18, 6, 18, 21], 86: [18, 1, 21, 9, 0, None, 17, 21, 9, 0], 87: [24, 2, 21, 7, 0, None, 12, 21, 7, 0, None, 12, 21, 17, 0, None, 22, 21, 17, 0], 88: [20, 3, 21, 17, 0, None, 17, 21, 3, 0], 89: [18, 1, 21, 9, 11, 9, 0, None, 17, 21, 9, 11], 90: [20, 17, 21, 3, 0, None, 3, 21, 17, 21, None, 3, 0, 17, 0], 91: [14, 4, 25, 4, -7, None, 5, 25, 5, -7, None, 4, 25, 11, 25, None, 4, -7, 11, -7], 92: [14, 0, 21, 14, -3], 93: [14, 9, 25, 9, -7, None, 10, 25, 10, -7, None, 3, 25, 10, 25, None, 3, -7, 10, -7], 94: [16, 6, 15, 8, 18, 10, 15, None, 3, 12, 8, 17, 13, 12, None, 8, 17, 8, 0], 95: [16, 0, -2, 16, -2], 96: [10, 6, 21, 5, 20, 4, 18, 4, 16, 5, 15, 6, 16, 5, 17], 97: [19, 15, 14, 15, 0, None, 15, 11, 13, 13, 11, 14, 8, 14, 6, 13, 4, 11, 3, 8, 3, 6, 4, 3, 6, 1, 8, 0, 11, 0, 13, 1, 15, 3], 98: [19, 4, 21, 4, 0, None, 4, 11, 6, 13, 8, 14, 11, 14, 13, 13, 15, 11, 16, 8, 16, 6, 15, 3, 13, 1, 11, 0, 8, 0, 6, 1, 4, 3], 99: [18, 15, 11, 13, 13, 11, 14, 8, 14, 6, 13, 4, 11, 3, 8, 3, 6, 4, 3, 6, 1, 8, 0, 11, 0, 13, 1, 15, 3], 100: [19, 15, 21, 15, 0, None, 15, 11, 13, 13, 11, 14, 8, 14, 6, 13, 4, 11, 3, 8, 3, 6, 4, 3, 6, 1, 8, 0, 11, 0, 13, 1, 15, 3], 101: [18, 3, 8, 15, 8, 15, 10, 14, 12, 13, 13, 11, 14, 8, 14, 6, 13, 4, 11, 3, 8, 3, 6, 4, 3, 6, 1, 8, 0, 11, 0, 13, 1, 15, 3], 102: [12, 10, 21, 8, 21, 6, 20, 5, 17, 5, 0, None, 2, 14, 9, 14], 103: [19, 15, 14, 15, -2, 14, -5, 13, -6, 11, -7, 8, -7, 6, -6, None, 15, 11, 13, 13, 11, 14, 8, 14, 6, 13, 4, 11, 3, 8, 3, 6, 4, 3, 6, 1, 8, 0, 11, 0, 13, 1, 15, 3], 104: [19, 4, 21, 4, 0, None, 4, 10, 7, 13, 9, 14, 12, 14, 14, 13, 15, 10, 15, 0], 105: [8, 3, 21, 4, 20, 5, 21, 4, 22, 3, 21, None, 4, 14, 4, 0], 106: [10, 5, 21, 6, 20, 7, 21, 6, 22, 5, 21, None, 6, 14, 6, -3, 5, -6, 3, -7, 1, -7], 107: [17, 4, 21, 4, 0, None, 14, 14, 4, 4, None, 8, 8, 15, 0], 108: [8, 4, 21, 4, 0], 109: [30, 4, 14, 4, 0, None, 4, 10, 7, 13, 9, 14, 12, 14, 14, 13, 15, 10, 15, 0, None, 15, 10, 18, 13, 20, 14, 23, 14, 25, 13, 26, 10, 26, 0], 110: [19, 4, 14, 4, 0, None, 4, 10, 7, 13, 9, 14, 12, 14, 14, 13, 15, 10, 15, 0], 111: [19, 8, 14, 6, 13, 4, 11, 3, 8, 3, 6, 4, 3, 6, 1, 8, 0, 11, 0, 13, 1, 15, 3, 16, 6, 16, 8, 15, 11, 13, 13, 11, 14, 8, 14], 112: [19, 4, 14, 4, -7, None, 4, 11, 6, 13, 8, 14, 11, 14, 13, 13, 15, 11, 16, 8, 16, 6, 15, 3, 13, 1, 11, 0, 8, 0, 6, 1, 4, 3], 113: [19, 15, 14, 15, -7, None, 15, 11, 13, 13, 11, 14, 8, 14, 6, 13, 4, 11, 3, 8, 3, 6, 4, 3, 6, 1, 8, 0, 11, 0, 13, 1, 15, 3], 114: [13, 4, 14, 4, 0, None, 4, 8, 5, 11, 7, 13, 9, 14, 12, 14], 115: [17, 14, 11, 13, 13, 10, 14, 7, 14, 4, 13, 3, 11, 4, 9, 6, 8, 11, 7, 13, 6, 14, 4, 14, 3, 13, 1, 10, 0, 7, 0, 4, 1, 3, 3], 116: [12, 5, 21, 5, 4, 6, 1, 8, 0, 10, 0, None, 2, 14, 9, 14], 117: [19, 4, 14, 4, 4, 5, 1, 7, 0, 10, 0, 12, 1, 15, 4, None, 15, 14, 15, 0], 118: [16, 2, 14, 8, 0, None, 14, 14, 8, 0], 119: [22, 3, 14, 7, 0, None, 11, 14, 7, 0, None, 11, 14, 15, 0, None, 19, 14, 15, 0], 120: [17, 3, 14, 14, 0, None, 14, 14, 3, 0], 121: [16, 2, 14, 8, 0, None, 14, 14, 8, 0, 6, -4, 4, -6, 2, -7, 1, -7], 122: [17, 14, 14, 3, 0, None, 3, 14, 14, 14, None, 3, 0, 14, 0], 123: [14, 9, 25, 7, 24, 6, 23, 5, 21, 5, 19, 6, 17, 7, 16, 8, 14, 8, 12, 6, 10, None, 7, 24, 6, 22, 6, 20, 7, 18, 8, 17, 9, 15, 9, 13, 8, 11, 4, 9, 8, 7, 9, 5, 9, 3, 8, 1, 7, 0, 6, -2, 6, -4, 7, -6, None, 6, 8, 8, 6, 8, 4, 7, 2, 6, 1, 5, -1, 5, -3, 6, -5, 7, -6, 9, -7], 124: [8, 4, 25, 4, -7], 125: [14, 5, 25, 7, 24, 8, 23, 9, 21, 9, 19, 8, 17, 7, 16, 6, 14, 6, 12, 8, 10, None, 7, 24, 8, 22, 8, 20, 7, 18, 6, 17, 5, 15, 5, 13, 6, 11, 10, 9, 6, 7, 5, 5, 5, 3, 6, 1, 7, 0, 8, -2, 8, -4, 7, -6, None, 8, 8, 6, 6, 6, 4, 7, 2, 8, 1, 9, -1, 9, -3, 8, -5, 7, -6, 5, -7], 126: [24, 3, 6, 3, 8, 4, 11, 6, 12, 8, 12, 10, 11, 14, 8, 16, 7, 18, 7, 20, 8, 21, 10, None, 3, 8, 4, 10, 6, 11, 8, 11, 10, 10, 14, 7, 16, 6, 18, 6, 20, 7, 21, 10, 21, 12]}
scripts/occt-iso.pyadded+31
@@ -0,0 +1,31 @@+#!/usr/bin/env python3+"""Make a TRANSPARENT-background version of a chip's shaded iso PNG so it can be+overlaid on any canvas (e.g. the symbol centre) without an ugly box.++Usage:  occt-iso.py <iso.png> <out.png>++chip-thumbnailer renders the iso on a uniform slate background; we sample that+background colour from the corners and feather it to transparent (distance-based+alpha), which keeps clean anti-aliased chip edges. Requires numpy + Pillow.+"""+import sys+import numpy as np+from PIL import Image++def main():+    src, out = sys.argv[1], sys.argv[2]+    img = np.asarray(Image.open(src).convert("RGB"), dtype=np.float64)+    h, w, _ = img.shape+    # Background colour = median of the four corners (robust to a stray pixel).+    corners = np.array([img[2, 2], img[2, w-3], img[h-3, 2], img[h-3, w-3]])+    bg = np.median(corners, axis=0)+    dist = np.sqrt(((img - bg) ** 2).sum(axis=2))+    # Feather: dist<=T0 → transparent, dist>=T1 → opaque.+    T0, T1 = 16.0, 44.0+    alpha = np.clip((dist - T0) / (T1 - T0), 0.0, 1.0)+    rgba = np.dstack([img, alpha * 255.0]).astype(np.uint8)+    Image.fromarray(rgba, "RGBA").save(out)+    print(f"OK: transparent iso → {out}")++if __name__ == "__main__":+    main()
scripts/occt-name-on-chip.pyadded+173
@@ -0,0 +1,173 @@+#!/usr/bin/env python3+"""Emboss a chip's part number onto the TOP face of its 3D STEP model — real+BRep geometry, so it renders correctly in the isometric thumbnail AND travels+with the STEP into KiCad and every downstream tool.++We don't guess where "up" is — chip-fetcher already records the chip's chosen+up-axis (y or z). The top face is the max-coordinate plane on that axis; the+text lies flat on it, fitted to the face's bounding box, raised as a shallow+relief (laser-mark style).++  occt-name-on-chip.py <in.step> <up_axis: y|z> <name> <out.step> [line2]++`line2` is optional — e.g. a variant/extended suffix — embossed as a smaller+second line below the main name (variants are a first-class future option).+"""+import sys, glob++from OCP.STEPControl import STEPControl_Reader, STEPControl_Writer, STEPControl_StepModelType+from OCP.IFSelect import IFSelect_RetDone+from OCP.Bnd import Bnd_Box+from OCP.BRepBndLib import BRepBndLib+from OCP.StdPrs import StdPrs_BRepFont, StdPrs_BRepTextBuilder+from OCP.NCollection import NCollection_Utf8String+from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism+from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform+from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse+from OCP.gp import gp_Vec, gp_Trsf, gp_Ax1, gp_Pnt, gp_Dir+from OCP.TopoDS import TopoDS_Compound+from OCP.BRep import BRep_Builder++FONT = (glob.glob('/usr/share/fonts/**/DejaVuSansMono-Bold.ttf', recursive=True)+        or glob.glob('/usr/share/fonts/**/DejaVuSans-Bold.ttf', recursive=True))[0]+++def read_step(path):+    r = STEPControl_Reader()+    if r.ReadFile(path) != IFSelect_RetDone:+        raise SystemExit(f"ERROR: cannot read {path}")+    r.TransferRoots()+    return r.OneShape()+++def bbox(shape):+    b = Bnd_Box()+    BRepBndLib.Add_s(shape, b)+    return b.Get()  # xmin,ymin,zmin,xmax,ymax,zmax+++def text_shape(s, size):+    """Flat text in the XY plane, baseline at origin, growing +X. Returns+    (shape, width, height)."""+    font = StdPrs_BRepFont(NCollection_Utf8String(FONT), float(size))+    sh = StdPrs_BRepTextBuilder().Perform(font, NCollection_Utf8String(s))+    xmin, ymin, zmin, xmax, ymax, zmax = bbox(sh)+    return sh, (xmax - xmin), (ymax - ymin), xmin, ymin+++def fit_size(text, avail_w, avail_h):+    """Pick a font size so `text` fits avail_w × avail_h."""+    ref = 10.0+    _, w0, h0, _, _ = text_shape(text, ref)+    if w0 <= 0 or h0 <= 0:+        return ref+    return max(0.2, min(ref * avail_w / w0, ref * avail_h / h0))+++def translate(shape, dx, dy, dz):+    t = gp_Trsf(); t.SetTranslation(gp_Vec(dx, dy, dz))+    return BRepBuilderAPI_Transform(shape, t, True).Shape()+++def rotate_x(shape, deg):+    t = gp_Trsf(); t.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(1, 0, 0)), deg * 3.141592653589793 / 180.0)+    return BRepBuilderAPI_Transform(shape, t, True).Shape()+++def rotate_about(shape, px, py, pz, dx, dy, dz, deg):+    t = gp_Trsf(); t.SetRotation(gp_Ax1(gp_Pnt(px, py, pz), gp_Dir(dx, dy, dz)), deg * 3.141592653589793 / 180.0)+    return BRepBuilderAPI_Transform(shape, t, True).Shape()+++def main():+    args = [a for a in sys.argv[1:] if a != "--flat"]+    flat = "--flat" in sys.argv  # outline mode: top-face geometry only, no relief+    in_step, up, name, out_step = args[0:4]+    line2 = args[4] if len(args) > 4 else ""+    up = up.lower().strip()+    if up not in ("y", "z"):+        up = "z"++    chip = read_step(in_step)+    xmin, ymin, zmin, xmax, ymax, zmax = bbox(chip)+    # In-plane axes (width along X always; depth is the other horizontal axis),+    # and the "up" coordinate of the top face.+    if up == "z":+        width, depth, top = (xmax - xmin), (ymax - ymin), zmax+        cx, cyz = (xmin + xmax) / 2, (ymin + ymax) / 2+        thickness = zmax - zmin+    else:  # y-up+        width, depth, top = (xmax - xmin), (zmax - zmin), ymax+        cx, cyz = (xmin + xmax) / 2, (zmin + zmax) / 2+        thickness = ymax - ymin+    # Laser-mark relief: a shallow SURFACE mark, not deep engraving — ~1-2% of+    # the chip's thickness. Kept thin on purpose so (a) the shaded iso reads like+    # a real laser mark and (b) the HLR outline shows essentially just the top+    # edge of the text (the sub-pixel side walls don't clutter the line art).+    relief = min(0.04, max(0.012, thickness * 0.018))++    lines = [name] + ([line2] if line2 else [])+    n = len(lines)+    margin_w, margin_d = width * 0.82, depth * 0.78+    # second (variant) line gets ~70% the height of the main line+    weights = [1.0, 0.7][:n]+    wsum = sum(weights)+    gap = margin_d * 0.12++    # Build, size and stack the lines (centered block in the XY plane).+    builder = BRep_Builder()+    comp = TopoDS_Compound(); builder.MakeCompound(comp)+    total_h = sum((margin_d - gap * (n - 1)) * (wt / wsum) for wt in weights) + gap * (n - 1)+    y_cursor = total_h / 2.0+    for line, wt in zip(lines, weights):+        line_h = (margin_d - gap * (n - 1)) * (wt / wsum)+        size = fit_size(line, margin_w, line_h)+        sh, w, h, bx, by = text_shape(line, size)+        # center this line horizontally; place its vertical center at y_cursor-line_h/2+        cx_text = bx + w / 2.0+        cy_text = by + h / 2.0+        target_y = y_cursor - line_h / 2.0+        sh = translate(sh, -cx_text, target_y - cy_text, 0)+        builder.Add(comp, sh)+        y_cursor -= (line_h + gap)++    # FLAT mode (for the line-art outline): keep the text as flat top-face+    # geometry floating a hair above the top so HLR draws ONLY the top edge of+    # each glyph — no extrusion side walls cluttering the wireframe.+    # SOLID mode (for the shaded iso): extrude a shallow relief so the mark reads+    # as raised when lit.+    lift = 0.005+    if flat:+        body = comp+    else:+        body = BRepPrimAPI_MakePrism(comp, gp_Vec(0, 0, relief)).Shape()++    # Orient onto the top face and translate to the face center.+    if up == "z":+        placed = translate(body, cx, cyz, top + (lift if flat else -relief * 0.25))+    else:+        # Rotate local +Z normal → +Y (top face for y-up), move to the top, then+        # spin 180° about the vertical (Y) axis so the glyph fronts face the iso+        # camera and the part number reads left-to-right (a proper rotation —+        # mirroring would invert the solid and break the boolean).+        placed = rotate_x(body, -90)+        placed = translate(placed, cx, top + (lift if flat else -relief * 0.25), cyz)+        placed = rotate_about(placed, cx, top, cyz, 0, 1, 0, 180)++    if flat:+        # Don't boolean a 2D face into a solid; emit chip + text faces as a compound.+        out = TopoDS_Compound(); builder.MakeCompound(out)+        builder.Add(out, chip); builder.Add(out, placed)+    else:+        out = BRepAlgoAPI_Fuse(chip, placed).Shape()++    w = STEPControl_Writer()+    w.Transfer(out, STEPControl_StepModelType.STEPControl_AsIs)+    if w.Write(out_step) != IFSelect_RetDone:+        raise SystemExit("ERROR: STEP write failed")+    kind = "flat-marked" if flat else "embossed"+    print(f"OK: {kind} '{name}'" + (f" + '{line2}'" if line2 else "") + f" ({up}-up) → {out_step}")+++if __name__ == "__main__":+    main()
scripts/occt-outline.pyadded+217
@@ -0,0 +1,217 @@+#!/usr/bin/env python3+"""Generate black-and-white vector OUTLINE icons of a chip STEP via OCCT HLR+(hidden-line removal), at the user's chosen orientation.++Usage:  occt-outline.py <step> <outdir> <mpn> [axis]++Writes <mpn>-3d-outline-<style>.svg for each style (thin/bold/blueprint/teal/dark),+rendered at the same camera as chip-thumbnailer's iso pose for <axis>+(z/asIs, y/yUp, x/xUp, -y/yDown, -x/xDown, -z/zDown). Requires cadquery-ocp (OCP).+"""+import sys, os, math+from OCP.STEPControl import STEPControl_Reader+from OCP.HLRBRep import HLRBRep_Algo, HLRBRep_HLRToShape+from OCP.HLRAlgo import HLRAlgo_Projector+from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2, gp_Ax3, gp_Trsf+from OCP.Bnd import Bnd_Box+from OCP.BRepBndLib import BRepBndLib+from OCP.TopExp import TopExp_Explorer+from OCP.TopAbs import TopAbs_EDGE+from OCP.TopoDS import TopoDS+from OCP.BRepAdaptor import BRepAdaptor_Curve+from OCP.GeomAbs import GeomAbs_Line+from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform++try:+    from hershey_simplex import FONT as HERSHEY+except Exception:+    import os as _os+    sys.path.insert(0, _os.path.dirname(_os.path.abspath(__file__)))+    from hershey_simplex import FONT as HERSHEY++# Single-stroke text: one line of Hershey glyphs → list of 2D polylines (font+# units; x advances, baseline y=0, left-origin). Returns (polylines, width).+def _hershey_line(text):+    polys = []+    cx = 0.0+    for ch in text:+        g = HERSHEY.get(ord(ch)) or HERSHEY.get(63)  # '?' fallback+        w = g[0]+        coords = g[1:]+        cur = []+        i = 0+        while i < len(coords):+            if coords[i] is None:+                if len(cur) >= 2:+                    polys.append(cur)+                cur = []+                i += 1+                continue+            cur.append((cx + coords[i], coords[i + 1]))+            i += 2+        if len(cur) >= 2:+            polys.append(cur)+        cx += w+    return polys, cx++CAP = 21.0  # Hershey cap height in font units++# chip-thumbnailer's _UP_AXIS_VIEW (proj, up) per orientation.+AXIS_VIEW = {+    "asis": ((1, 1, 1), (0, 0, 1)),+    "zdown": ((1, -1, -1), (0, 0, -1)),+    "yup":  ((1, 1, -1), (0, 1, 0)),+    "ydown": ((1, -1, 1), (0, -1, 0)),+    "xup":  ((1, 1, -1), (1, 0, 0)),+    "xdown": ((-1, 1, 1), (-1, 0, 0)),+}+ALIAS = {"z": "asis", "zup": "asis", "": "asis", "asis": "asis",+         "y": "yup", "yup": "yup", "-y": "ydown", "ydown": "ydown",+         "x": "xup", "xup": "xup", "-x": "xdown", "xdown": "xdown",+         "-z": "zdown", "zdown": "zdown"}++def nrm(v): n = math.sqrt(sum(c*c for c in v)) or 1; return tuple(c/n for c in v)+def dot(a, b): return sum(x*y for x, y in zip(a, b))+def cross(a, b): return (a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0])++def view_trsf(proj, up):+    d = nrm((-proj[0], -proj[1], -proj[2]))+    u = nrm((up[0]-dot(up, d)*d[0], up[1]-dot(up, d)*d[1], up[2]-dot(up, d)*d[2]))+    r = nrm(cross(d, u))+    ax3 = gp_Ax3(gp_Pnt(0, 0, 0), gp_Dir(-d[0], -d[1], -d[2]), gp_Dir(*r))+    t = gp_Trsf(); t.SetTransformation(ax3)+    return t++def to_view(shape, proj, up):+    return BRepBuilderAPI_Transform(shape, view_trsf(proj, up), True).Shape()++# Build single-stroke name text, placed flat on the chip's TOP face (per up-axis)+# and projected through the SAME view transform as the chip → 2D polylines in the+# chip's projected space. `lines` is a list of strings (line 2 = variant suffix).+def name_polylines_2d(orig_shape, up, lines, proj, vproj):+    b = Bnd_Box(); BRepBndLib.Add_s(orig_shape, b)+    xmin, ymin, zmin, xmax, ymax, zmax = b.Get()+    if up == "y":+        width, depth, top = (xmax - xmin), (zmax - zmin), ymax+        cu, cv = (xmin + xmax) / 2, (zmin + zmax) / 2+    else:  # z-up / as-is+        width, depth, top = (xmax - xmin), (ymax - ymin), zmax+        cu, cv = (xmin + xmax) / 2, (ymin + ymax) / 2+    lines = [l for l in lines if l]+    n = len(lines) or 1+    # Fit: widest line spans ~74% of the face width; all lines stack within ~46%+    # of the depth. Scale = font-units → mm.+    raw = [(_hershey_line(s)) for s in lines]+    maxw = max((w for _, w in raw), default=1) or 1+    line_gap = CAP * 0.5+    block_h = n * CAP + (n - 1) * line_gap+    sc = min(width * 0.74 / maxw, depth * 0.46 / block_h)+    t = view_trsf(proj, vproj)+    out = []+    # stack lines top→down (first line highest)+    y_cursor = (block_h / 2.0)  # in font units, centered block+    for polys, w in raw:+        # center this line horizontally; baseline so the line's cap-center sits at y_cursor-CAP/2+        x_off = -w / 2.0+        y_base = (y_cursor - CAP)  # baseline of this line (font units)+        for poly in polys:+            pts3 = []+            for (fx, fy) in poly:+                lx = (fx + x_off) * sc+                ly = (fy + y_base) * sc+                if up == "y":+                    P = gp_Pnt(cu - lx, top + 0.002, cv + ly)+                else:+                    P = gp_Pnt(cu + lx, cv + ly, top + 0.002)+                P.Transform(t)+                pts3.append((P.X(), P.Y()))+            if len(pts3) >= 2:+                out.append(pts3)+        y_cursor -= (CAP + line_gap)+    return out++def polylines(comp, n=22):+    out = []+    if comp is None or comp.IsNull(): return out+    exp = TopExp_Explorer(comp, TopAbs_EDGE)+    while exp.More():+        try:+            ad = BRepAdaptor_Curve(TopoDS.Edge_s(exp.Current()))+            a, b = ad.FirstParameter(), ad.LastParameter()+            steps = 1 if ad.GetType() == GeomAbs_Line else n+            pts = [(ad.Value(a+(b-a)*i/steps).X(), ad.Value(a+(b-a)*i/steps).Y()) for i in range(steps+1)]+            if len(pts) >= 2: out.append(pts)+        except Exception: pass+        exp.Next()+    return out++def hlr(shape):+    ax2 = gp_Ax2(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1), gp_Dir(1, 0, 0))+    a = HLRBRep_Algo(); a.Add(shape); a.Projector(HLRAlgo_Projector(ax2)); a.Update(); a.Hide()+    hs = HLRBRep_HLRToShape(a)+    def g(nm):+        try: return getattr(hs, nm)()+        except Exception: return None+    return {"vis": polylines(g("VCompound")), "hid": polylines(g("HCompound"))}++def svg(groups, vb, size=320, pad=0.10, bg=None):+    minx, miny, maxx, maxy = vb; w = (maxx-minx) or 1; h = (maxy-miny) or 1+    s = size*(1-2*pad)/max(w, h)+    ox = size*pad - minx*s + (size*(1-2*pad)-w*s)/2+    oy = size*pad + maxy*s + (size*(1-2*pad)-h*s)/2+    def P(poly): return " ".join(f"{ox+x*s:.2f},{oy-y*s:.2f}" for x, y in poly)+    body = f'<rect width="{size}" height="{size}" fill="{bg}"/>' if bg else ''+    for polys, st in groups:+        for poly in polys: body += f'<polyline points="{P(poly)}" {st}/>'+    return f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {size} {size}">{body}</svg>'++def main():+    # Optional --suffix <s> appends to the output filenames (e.g. "-named") so a+    # name-marked variant can sit beside the plain outline.+    argv = sys.argv[1:]+    suffix = ""+    name = ""+    line2 = ""+    for flag, setter in (("--suffix", "suffix"), ("--name", "name"), ("--line2", "line2")):+        if flag in argv:+            i = argv.index(flag); val = argv[i + 1]; del argv[i:i + 2]+            if setter == "suffix": suffix = val+            elif setter == "name": name = val+            else: line2 = val+    step, outdir, mpn = argv[0], argv[1], argv[2]+    axis = ALIAS.get((argv[3] if len(argv) > 3 else "").lower(), "asis")+    proj, up = AXIS_VIEW[axis]+    r = STEPControl_Reader(); r.ReadFile(step); r.TransferRoots()+    orig = r.OneShape()+    shape = to_view(orig, proj, up)+    H = hlr(shape)+    # Single-stroke part-number on the top face (centerline text, not outlined+    # glyphs — reads cleanly even for long names).+    nm = name_polylines_2d(orig, "y" if axis == "yup" else "z", [name, line2], proj, up) if name else []+    xs = [x for g in (H["vis"], H["hid"]) for poly in g for x, y in poly]+    ys = [y for g in (H["vis"], H["hid"]) for poly in g for x, y in poly]+    vb = (min(xs), min(ys), max(xs), max(ys)) if xs else (0, 0, 1, 1)+    J = 'stroke-linejoin="round" stroke-linecap="round"'+    # (visible-edge stroke, name stroke). Name is a touch thinner so the+    # centerline text stays crisp against the chip's silhouette.+    styles = {+        "thin":  (("#0d1117", "1.1", "#0d1117", "0.9"), None),+        "bold":  (("#0d1117", "2.4", "#0d1117", "1.6"), None),+        "teal":  (("#00e6dc", "1.7", "#00e6dc", "1.2"), None),+        "dark":  (("#e6edf3", "1.5", "#e6edf3", "1.1"), None),+    }+    for k, ((ec, ew, nc, nw), bg) in styles.items():+        gs = [(H["vis"], f'fill="none" stroke="{ec}" stroke-width="{ew}" {J}')]+        if nm:+            gs.append((nm, f'fill="none" stroke="{nc}" stroke-width="{nw}" {J}'))+        open(os.path.join(outdir, f"{mpn}-3d-outline-{k}{suffix}.svg"), "w").write(svg(gs, vb, bg=bg))+    # blueprint = WHITE visible lines + faint blue dashed hidden lines + white name.+    bp = [(H["hid"], 'fill="none" stroke="#7fa8d8" stroke-width="0.6" stroke-dasharray="3,2"'),+          (H["vis"], f'fill="none" stroke="#e6edf3" stroke-width="1.3" {J}')]+    if nm:+        bp.append((nm, f'fill="none" stroke="#e6edf3" stroke-width="1.0" {J}'))+    open(os.path.join(outdir, f"{mpn}-3d-outline-blueprint{suffix}.svg"), "w").write(svg(bp, vb, bg=None))+    print(f"OK: wrote 5 outline styles for {mpn} at axis={axis} (vis edges={len(H['vis'])}, name strokes={len(nm)})")++if __name__ == "__main__":+    main()
scripts/render-iso.pyadded+26
@@ -0,0 +1,26 @@+#!/usr/bin/env python3+"""Render a STEP to an isometric PNG at a given up-axis, reusing the canonical+V3d offscreen renderer in service-step2glb. Thin wrapper so chip-fetcher can+render an arbitrary STEP (e.g. the name-embossed one) the same way the dashboard+thumbnails are produced.++  render-iso.py <in.step> <out.png> <up_axis> [size]++Run under xvfb-run (the V3d renderer needs an X display).+"""+import sys++sys.path.insert(0, "/home/adom/project/service-step2glb/api")+from step_to_thumbnail import render_thumbnail  # noqa: E402+++def main():+    step, out = sys.argv[1], sys.argv[2]+    axis = sys.argv[3] if len(sys.argv) > 3 else "z"+    size = int(sys.argv[4]) if len(sys.argv) > 4 else 600+    render_thumbnail(step, out, pose="iso", up_axis=axis, width=size, height=size)+    print(f"OK: iso → {out}")+++if __name__ == "__main__":+    main()
src/cli.rsadded+1635
@@ -0,0 +1,1635 @@+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 `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 = "chip-fetcher";+const PROFILE: &str = "chip-fetcher";++#[derive(Parser)]+#[command(name = "chip-fetcher", version, about = "Download manufacturer STEP/footprint/datasheet bundles via pup")]+struct Cli {+    #[command(subcommand)]+    cmd: Cmd,+}++#[derive(Subcommand)]+enum Cmd {+    /// 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. `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/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 '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 `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 `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 — `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 chip-fetcher pup scrape session. Every action auto-+    /// heartbeats the dashboard, always uses `profile: "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/chip-fetcher/credentials.json on the user's container.+    /// Survives across sessions — the user enters creds ONCE, 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 `chip-fetcher` and the scrape `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 `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 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 chip-fetcher pup window is already detected.+        #[arg(long)]+        force: bool,+    },+    /// Set the surface preference (pup | webview). Saved at+    /// ~/.config/chip-fetcher/dashboard.json. Pup is the default since+    /// 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: "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=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/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::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})");+            }+            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 } => 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),+    }+}++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: 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: `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 `chip-fetcher dashboard ensure`");+            Ok(())+        }+        DashboardOp::Surface => {+            let s = hints::get_dashboard_surface();+            println!("dashboard surface: {:?}", s);+            hints::print_hint("change with `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 { "`chip-fetcher thumbnails --mpn <MPN> --force`" }),+            (7, "Validated",        val_golden,     if val_golden { "" } else if has_val_status { "re-run: `chip-fetcher validate <MPN>`" } else { "run: `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 "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 = "chip-fetcher";+const SCRAPE_SESSION: &str = "chip-fetcher-scrape";++fn scrape_cmd(op: ScrapeOp) -> Result<()> {+    match op {+        ScrapeOp::Open { mpn, url } => {+            let _ = hints::heartbeat(&mpn, &format!("scrape: opening {}", &url[..url.len().min(60)]));+            let body = serde_json::json!({+                "sessionId": SCRAPE_SESSION,+                "profile": SCRAPE_PROFILE,+                "url": url,+            }).to_string();+            let out = std::process::Command::new("adom-desktop")+                .args(["browser_open_window", &body])+                .output()?;+            if !out.status.success() {+                let nav = serde_json::json!({"sessionId": SCRAPE_SESSION, "url": url}).to_string();+                let _ = std::process::Command::new("adom-desktop")+                    .args(["browser_navigate", &nav])+                    .output()?;+            }+            println!("OK: scrape window open at {url} (profile={SCRAPE_PROFILE})");+            hints::print_hint(&format!("page is loading. Next: `chip-fetcher scrape eval {mpn} '<JS to find what you need>'` to inspect the page, or `chip-fetcher scrape screenshot {mpn}` to see what the user sees."));+            Ok(())+        }+        ScrapeOp::Navigate { mpn, url, stage } => {+            let label = stage.unwrap_or_else(|| format!("navigating {}", &url[..url.len().min(50)]));+            let _ = hints::heartbeat(&mpn, &label);+            let body = serde_json::json!({"sessionId": SCRAPE_SESSION, "url": url}).to_string();+            let out = std::process::Command::new("adom-desktop")+                .args(["browser_navigate", &body])+                .output()?;+            if !out.status.success() {+                anyhow::bail!("browser_navigate failed");+            }+            println!("OK: navigated to {url}");+            hints::print_hint(&format!("page loaded. Next: eval JS to find download buttons/links, or screenshot to see the page. Remember: check `chip-fetcher next-source {mpn}` if this source doesn't have 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 body = serde_json::json!({"sessionId": SCRAPE_SESSION, "expr": expr}).to_string();+            let out = std::process::Command::new("adom-desktop")+                .args(["browser_eval", &body])+                .output()?;+            let v: serde_json::Value = serde_json::from_slice(&out.stdout)+                .unwrap_or(serde_json::Value::Null);+            let result = v.get("result").cloned().unwrap_or(serde_json::Value::Null);+            println!("{}", serde_json::to_string_pretty(&result)?);+            hints::print_hint(&format!("read the result above. If you found a download URL, use `chip-fetcher pup-pdf-fetch {mpn} --pdf-url <url>` for PDFs or `chip-fetcher refetch {mpn} --format <fmt> --source <src>` for CAD bundles."));+            Ok(())+        }+        ScrapeOp::Screenshot { mpn } => {+            let _ = hints::heartbeat(&mpn, "scrape: taking screenshot");+            let body = serde_json::json!({"sessionId": SCRAPE_SESSION}).to_string();+            let out = std::process::Command::new("adom-desktop")+                .args(["browser_screenshot", &body])+                .output()?;+            let v: serde_json::Value = serde_json::from_slice(&out.stdout)+                .unwrap_or(serde_json::Value::Null);+            let path = v.get("savedTo").and_then(|p| p.as_str()).unwrap_or("?");+            println!("OK: screenshot at {path}");+            hints::print_hint("look at the screenshot to see what the user sees. If a modal or interstitial is blocking, dismiss it with `scrape eval` before proceeding.");+            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: 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/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 `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 `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:?}"),+    };++    // Pick the navigation URL: prefer page_url, then a known datasheet_url+    // from info.json, then fall through to mfr-probe.+    let nav_url = if let Some(p) = page_url {+        p.to_string()+    } else if pdf_url.is_some() {+        // We have a direct PDF URL but no page; navigate to the chip's+        // mfr search page anyway so cookies + Referer are realistic+        // (some CDNs check Referer host).+        sourcing::guess_mfr(mpn)+            .map(|g| g.search_url)+            .unwrap_or_else(|| pdf_url.unwrap().to_string())+    } else {+        sourcing::guess_mfr(mpn)+            .map(|g| g.search_url)+            .ok_or_else(|| anyhow::anyhow!("no --page-url, no --pdf-url, and could not infer manufacturer for {mpn}; pass --page-url <url>"))?+    };++    let _ = hints::heartbeat(mpn, &format!("pup-pdf-fetch from {source}: opening pup"));++    // Open or focus the pup session.+    let open_body = serde_json::json!({+        "sessionId": session,+        "profile": session,+        "url": nav_url,+    }).to_string();+    let out = std::process::Command::new("adom-desktop")+        .args(["browser_open_window", &open_body])+        .output()?;+    if !out.status.success() {+        // Already-open sessions return error — try a navigate instead.+        let nav_body = serde_json::json!({+            "sessionId": session,+            "url": nav_url,+        }).to_string();+        let _ = std::process::Command::new("adom-desktop")+            .args(["browser_navigate", &nav_body])+            .output()?;+    }+    println!("OK: pup at {nav_url}");+    std::thread::sleep(std::time::Duration::from_secs(4));++    // If --pdf-url not given, search the rendered page for one matching the MPN.+    let resolved_pdf = if let Some(p) = pdf_url {+        p.to_string()+    } else {+        let find_expr = format!(+            "(()=>{{const m={:?};const aa=Array.from(document.querySelectorAll('a'));\+             const matches=aa.filter(a=>{{const u=(a.href||'').toLowerCase();const t=(a.textContent||'').toLowerCase();\+             return u.includes('.pdf') || /datasheet|spec|文件|drawing/.test(t)}});\+             const best=matches.find(a=>(a.href+a.textContent).toLowerCase().includes(m.toLowerCase()))||matches[0];\+             return best?best.href:''}})()",+            mpn+        );+        let find_body = serde_json::json!({"sessionId": session, "expr": find_expr}).to_string();+        let find_out = std::process::Command::new("adom-desktop")+            .args(["browser_eval", &find_body])+            .output()?;+        let find_v: serde_json::Value = serde_json::from_slice(&find_out.stdout)?;+        let url = find_v.get("result").and_then(|r| r.as_str()).unwrap_or("").to_string();+        if url.is_empty() {+            let _ = sourcing::append_chain(mpn, source_enum, "pup_no_pdf_link",+                Some(&format!("page {nav_url} had no anchor with .pdf or 'datasheet'/'文件' text matching {mpn}")));+            anyhow::bail!("could not find a PDF anchor on {nav_url} matching {mpn}; pass --pdf-url <direct-url>");+        }+        println!("OK: found PDF anchor: {url}");+        url+    };++    // Fetch the PDF bytes inside the browser context (carries cookies ++    // proper Referer), arrayBuffer → base64 → return via stdout.+    let fetch_expr = format!(+        "(async()=>{{const r=await fetch({:?},{{credentials:'include'}});\+         if(!r.ok)return 'http '+r.status;\+         const b=await r.arrayBuffer();const u8=new Uint8Array(b);let s='';\+         for(let i=0;i<u8.length;i+=8192){{s+=String.fromCharCode.apply(null,u8.subarray(i,i+8192))}}\+         return btoa(s)}})()",+        resolved_pdf+    );+    let fetch_body = serde_json::json!({"sessionId": session, "expr": fetch_expr}).to_string();+    let fetch_out = std::process::Command::new("adom-desktop")+        .args(["browser_eval", &fetch_body])+        .output()?;+    let fetch_v: serde_json::Value = serde_json::from_slice(&fetch_out.stdout)?;+    let b64 = fetch_v.get("result").and_then(|r| r.as_str()).unwrap_or("");++    if b64.is_empty() || !b64.starts_with("JVBERi") {+        // JVBERi is base64('%PDF') — a real PDF will start with this.+        let preview = if b64.len() > 80 { &b64[..80] } else { b64 };+        let _ = sourcing::append_chain(mpn, source_enum, "pup_fetch_not_pdf",+            Some(&format!("fetch returned non-PDF bytes (b64 prefix: {})", preview)));+        anyhow::bail!("fetched bytes are not a PDF (base64 prefix: {})", preview);+    }++    // Base64 → bytes → magic-byte recheck → write.+    let pdf_bytes = base64_decode(b64)+        .map_err(|e| anyhow::anyhow!("base64 decode failed: {e}"))?;+    if !pdf_bytes.starts_with(b"%PDF") {+        anyhow::bail!("decoded bytes don't start with %PDF (decoder integrity issue)");+    }+    let target = dir.join(format!("{mpn}.pdf"));+    std::fs::write(&target, &pdf_bytes)?;+    println!("OK: wrote {} ({} KB, {})", target.display(), pdf_bytes.len() / 1024, source);++    let _ = sourcing::append_chain(mpn, source_enum, "OK",+        Some(&format!("pup-pdf-fetch via {} → {} ({} KB), %PDF magic verified", session, resolved_pdf, pdf_bytes.len() / 1024)));+    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+/// 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": "chip-fetcher-scrape",+        "profile": "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 `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 `&& 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 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 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 {+                let body = serde_json::json!({+                    "sessionId": "chip-fetcher-scrape",+                    "profile": "chip-fetcher",+                    "url": g.search_url,+                }).to_string();+                let out = std::process::Command::new("adom-desktop")+                    .args(["browser_open_window", &body])+                    .output();+                match out {+                    Ok(o) if o.status.success() => println!("OK: opened {} in chip-fetcher-scrape pup session", g.search_url),+                    _ => hints::print_hint("could not auto-open via adom-desktop — 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 `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: `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!("`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 `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<&'static str>)> = Vec::new();+    let mut complete = 0usize;++    for c in &chips {+        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 strict {+            if !c.has_altium     { missing.push("Altium (.SchLib+.PcbLib OR .IntLib)"); }+            if !c.has_fusion_lbr { missing.push("Fusion .lbr"); }+        }+        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 `chip-fetcher sources` for the ladder, then `chip-fetcher heartbeat {mpn} \"<stage>\"` per stage)"));+            }+        }+        println!();+        hints::print_hint(&format!("re-run `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 `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 `chip-fetcher heartbeat <MPN> \"starting fetch\"`");+            hints::print_hint("then `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: `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: `chip-fetcher heartbeat {} \"<next stage>\"`",+                        hb.mpn+                    ));+                    hints::print_hint(&format!(+                        "after import lands: `chip-fetcher heartbeat {} imported` then `chip-fetcher status {}`",+                        hb.mpn, hb.mpn+                    ));+                    hints::print_hint("if anything is off-rails: `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/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: 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 `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 `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: 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 chip-fetcher/incoming/ and run `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 `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 `chip-fetcher login list` to see available sources, or `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: `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("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/hints.rsadded+592
@@ -0,0 +1,592 @@+// Self-enforcing rails for 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/chip-fetcher-last-heartbeat.json";+const STALE_AFTER_SECS: u64 = 60;+const DEFAULT_PORT: u16 = 8786;+const DASHBOARD_SURFACE_PATH: &str = ".config/chip-fetcher/dashboard.json";++/// Canonical sourcing-ladder priority. Single source of truth — `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 — 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 `chip-fetcher app` so users can see live progress");+    }++    // Auto-scroll the MPN's dashboard card into view in the 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 `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. `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 `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(())+}++/// Track stage-order per MPN in /tmp/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/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 `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. `chip-fetcher gate` for full status."));+        }+    }+    if incomplete > 0 {+        println!("WARN: {incomplete}/{total} cards in library are still incomplete (run `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": "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 `chip-fetcher+///      serve` detached.+///   2. Pup window with sessionId "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("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<()> {+    let already_open = pup_session_open("chip-fetcher-dashboard").unwrap_or(false);+    if already_open && !force_open {+        println!("OK: dashboard pup window 'chip-fetcher-dashboard' already open");+        let _ = Command::new("adom-desktop")+            .args(["browser_focus_window", "{\"sessionId\":\"chip-fetcher-dashboard\"}"])+            .output();+        return Ok(());+    }+    let url = dashboard_proxy_url();+    let body = serde_json::json!({+        "sessionId": "chip-fetcher-dashboard",+        "profile": "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\":\"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", "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("chip-fetcher") {+        Some(true) => println!("OK: pup session 'chip-fetcher' is open"),+        Some(false) => warns.push(+            "pup session '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 `chip-fetcher heartbeat <MPN> \"<stage>\"` before any pup action.");+    } else {+        if !dashboard_reachable() {+            print_hint("start the dashboard FIRST: `chip-fetcher app` (Hydrogen tab) or `chip-fetcher serve` (HTTP only) — every other rule depends on the dashboard being up.");+        }+        match last() {+            None => print_hint("send your first heartbeat: `chip-fetcher heartbeat <MPN> \"starting fetch\"`"),+            Some(hb) if age_secs(&hb) > STALE_AFTER_SECS => print_hint(&format!(+                "heartbeat is stale — refresh with `chip-fetcher heartbeat {} \"<current stage>\"`",+                hb.mpn+            )),+            _ => {}+        }+        if matches!(pup_session_open("chip-fetcher"), Some(false)) {+            print_hint("open the pup window before fetching: navigate to the vendor URL with `sessionId:\"chip-fetcher\", profile:\"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: `chip-fetcher heartbeat {m} imported`"));+            print_hint(&format!("verify on disk: `chip-fetcher status {m}`"));+        }+        ("imported", Some(m)) => {+            print_hint(&format!("heartbeat: `chip-fetcher heartbeat {m} imported`"));+            print_hint(&format!("dashboard view: `chip-fetcher status {m}`"));+        }+        ("pulled", Some(m)) => {+            print_hint(&format!("heartbeat: `chip-fetcher heartbeat {m} \"pulled to incoming\"`"));+            print_hint("import next: `chip-fetcher import <path> --mpn <MPN>`");+        }+        ("listed", _) => {+            print_hint("inspect one: `chip-fetcher status <MPN>`");+            print_hint("fetch a missing one: `chip-fetcher fetch <MPN>`");+        }+        ("status", Some(m)) => {+            print_hint(&format!("if anything is missing, fetch with `chip-fetcher fetch {m}`"));+            print_hint(&format!("heartbeat any user-visible action with `chip-fetcher heartbeat {m} \"<stage>\"`"));+        }+        ("login", _) => {+            print_hint("after sign-in, heartbeat: `chip-fetcher heartbeat <MPN> \"logged in to <source>\"`");+            print_hint("verify rails: `chip-fetcher selfcheck`");+        }+        _ => {}+    }+}
src/import.rsadded+320
@@ -0,0 +1,320 @@+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!("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+}++/// 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()));+            }+        }+        "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+413
@@ -0,0 +1,413 @@+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();++    let mut tags = Vec::new();+    if has_canonical {+        // origin = content_origin (who made it) → discovery_source (where found)+        // → info.json source_of_record → "vendor".+        let origin = provenance+            .get(canonical_file)+            .and_then(|e| e.get("content_origin").or_else(|| e.get("discovery_source")))+            .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");+        tags.push(SourceTag { method: origin.to_string(), label: source_label(origin), current: true, thumb: canon_thumb });+    }+    // ds2sf datasheet extraction — only list it when it actually produced a+    // renderable result (<mpn>-{symbol|footprint}.ds2sf.svg). ds2sf often gets a+    // symbol but no footprint (no land-pattern table in the datasheet → no pads),+    // and listing an empty ds2sf footprint source is confusing.+    let ds2sf_thumb = dir.join(format!("{mpn_prefix}-{thumb_kind}.ds2sf.svg")).is_file();+    if dir.join(ds2sf_file).is_file() && ds2sf_thumb {+        tags.push(SourceTag { method: "ds2sf".into(), label: source_label("ds2sf"), current: !has_canonical, thumb: true });+    }+    // Per-source vendor variants kept by import: <mpn>.<method>.kicad_sym/.kicad_mod.+    if let Ok(rd) = std::fs::read_dir(dir) {+        for e in rd.flatten() {+            let name = e.file_name().to_string_lossy().to_string();+            if name == canonical_file || !name.ends_with(&ext_suffix) || !name.starts_with(&format!("{mpn_prefix}.")) {+                continue;+            }+            let method = &name[mpn_prefix.len() + 1..name.len() - ext_suffix.len()];+            if method.is_empty() || method.contains('.') || method == "extracted" {+                continue;+            }+            if tags.iter().any(|t| t.method == method) {+                continue;+            }+            let t = dir.join(format!("{mpn_prefix}-{thumb_kind}.{method}.svg")).is_file();+            tags.push(SourceTag { method: method.to_string(), label: source_label(method), current: false, thumb: t });+        }+    }+    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+18
@@ -0,0 +1,18 @@+mod cli;+mod creds;+mod deliver;+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/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/pup.rsadded+114
@@ -0,0 +1,114 @@+use anyhow::{Context, Result};+use serde_json::{json, Value};+use std::process::Command;++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());+    call("browser_open_window", &json!({+        "sessionId": session_id,+        "profile": profile,+        "url": url,+    }))+}++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,+    }))+}++/// Pull a file from the user's desktop into a local container directory.+/// Wraps `adom-desktop pull_file`. Returns the local path of the saved file.+pub fn pull_file(remote_path: &str, save_to: &str) -> Result<std::path::PathBuf> {+    let v = call("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,+/// then return the parsed JSON result. Uses adom-desktop v1.5+'s native+/// `desktop_watch_files` — no shell, no user approval. `since` defaults to+/// "now" inside the bridge, so call this BEFORE the click that triggers the+/// download.+pub fn desktop_watch_files(path: &str, glob: &str, timeout_ms: u64) -> Result<Value> {+    call("desktop_watch_files", &json!({+        "path": path,+        "glob": glob,+        "timeoutMs": timeout_ms,+    }))+}++pub fn eval(session_id: &str, expr: &str) -> Result<Value> {+    let v = call("browser_eval", &json!({+        "sessionId": session_id,+        "expr": expr,+    }))?;+    Ok(v.get("result").cloned().unwrap_or(v))+}++fn call(action: &str, args: &Value) -> Result<Value> {+    call_with_recovery(action, args, true)+}++/// Run `adom-desktop <action> <args>` and parse the JSON result.+///+/// If adom-desktop v1.5.1+ reports `errorCode: session_not_found` or+/// `session_disconnected` AND `allow_recovery` is true, we transparently:+///   1. Call `browser_rescan` (which walks every Chrome target and rebuilds+///      the bridge's session map by parsing `(session: X)` page-title tags).+///   2. If the target session reappears, retry the original call ONCE.+///   3. If still missing, return the original error so the caller can decide+///      whether to spawn a fresh window.+///+/// This recovery path was added because pre-v1.5.1 the bridge would lose+/// sessions on a transient WebSocket drop and silently retarget unrelated+/// windows. v1.5.1 surfaces a clear error code instead — and we use it.+fn call_with_recovery(action: &str, args: &Value, allow_recovery: bool) -> Result<Value> {+    let out = Command::new("adom-desktop")+        .arg(action)+        .arg(args.to_string())+        .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"))?;++    // Recovery recipe per adom-desktop v1.5.1: errorCode tells us when the+    // bridge lost the session. Try one rescan + retry before giving up.+    if allow_recovery {+        if let Some(code) = v.get("errorCode").and_then(|c| c.as_str()) {+            if code == "session_not_found" || code == "session_disconnected" {+                let target = args.get("sessionId").and_then(|s| s.as_str()).unwrap_or("");+                println!("WARN: adom-desktop {action} returned {code} for session {target:?}; running browser_rescan");+                let rescan = Command::new("adom-desktop")+                    .args(["browser_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 !target.is_empty() && live.iter().any(|s| s == target) {+                    println!("OK: rescan recovered session {target:?} (live now: {live:?}); retrying {action}");+                    return call_with_recovery(action, args, false);+                }+                println!("WARN: rescan did not recover {target:?} (live: {live:?}); returning original error");+            }+        }+    }+    Ok(v)+}
src/serve.rsadded+958
@@ -0,0 +1,958 @@+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!("chip-fetcher viewer listening on http://{addr}");+    println!("(open in Hydrogen via `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/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)+}++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};+    let mut cmd = Command::new("chip-fetcher");+    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", "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/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+298
@@ -0,0 +1,298 @@+// 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 = "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_sym = dir.join(format!("{mpn}.kicad_sym"));+    if canon_sym.is_file() {+        let _ = Command::new("adom-symbol")+            .args(["render", "--file"]).arg(&canon_sym)+            .arg("--out").arg(dir.join(format!("{mpn}-symbol.svg")))+            .output();+    }+    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);+    }++    // 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();+                }+            }+        }+    }+}++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/validate.rsadded+660
@@ -0,0 +1,660 @@+// 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/ds2sf/SKILL.md+//   ~/.claude/skills/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("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("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("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 `chip-fetcher refetch {mpn} --format {format} --source {source_slug}` (180s download window)");+    let status = std::process::Command::new("chip-fetcher")+        .args(["refetch", mpn,+               "--format", format,+               "--source", source_slug])+        .status()+        .map_err(|e| anyhow!("could not exec 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}"); }+        }+    }+}
uninstall.shadded+3
@@ -0,0 +1,3 @@+#!/bin/bash+set -euo pipefail+rm -f "${HOME}/.local/bin/chip-fetcher"; echo "Uninstalled chip-fetcher"