app
Chip Fetcher (cf)
Public Made by Adomby adom
Crawls the web on your behalf to gather everything about the chips you care about: datasheets, symbols, footprints, 3D models, stock and pricing, straight from the manufacturer first, into your EDA tool. Say "cf" to your AI.
← Commit history
Upload full chip-fetcher source (MIT) for Barrett / adom org
37 files changed
+5216
playbooks/adom-desktop-api.md+355playbooks/credentials-and-logins.md+309playbooks/dashboard-flow.md+219playbooks/digikey-models.md+74playbooks/handoff-and-watching.md+123playbooks/manufacturer-direct.md+244playbooks/mouser-cse.md+149playbooks/snapmagic.md+146playbooks/sourcing-ladder.md+136playbooks/ultra-librarian.md+315scripts/adom-desktop-chrome-permissions-prompt.md+137scripts/adom-desktop-credential-vault-prompt.md+78scripts/adom-desktop-pullfile-speed-prompt.md+56scripts/adom-desktop-trusted-input-prompt.md+241scripts/adom-desktop-v1410-followups-prompt.md+189scripts/adom-desktop-v1411-followup-prompt.md+120scripts/adom-desktop-v148-followups-prompt.md+166scripts/adom-desktop-v149-followups-prompt.md+216scripts/cf-autowatch.sh+82scripts/cf-cse-batch.sh+102scripts/cf-drive.sh+49scripts/cf-mouser-backfill.sh+112scripts/cf-mouser-pdf-pull.sh+93scripts/cf-pup-backfill.sh+93scripts/cf-ti-backfill.sh+114scripts/cf-ul-fast.sh+375scripts/cf-ul-fetch.sh+91scripts/cf-ul-pro-batch.sh+103scripts/cf-ul-pro.sh+22scripts/cf-vendor-backfill.sh+95scripts/gen-footprint-viewer.mjs+84scripts/gen-symbol-viewer.mjs+78scripts/hershey_simplex.py+3scripts/occt-iso.py+31scripts/occt-name-on-chip.py+173scripts/occt-outline.py+217scripts/render-iso.py+26playbooks/adom-desktop-api.mdadded+355@@ -0,0 +1,355 @@+# adom-desktop API patterns (chip-fetcher playbook)++Trusted clicks, popup auto-tracking, smart-pick, browser_fetch_url, pull_file. Read this when you need to drive a pup browser tab beyond the basic `browser_navigate` + `browser_eval` couplet.++## Trusted click via `browser_input_dispatch` (adom-desktop ≥ v1.4.7)++When a click "lands but does nothing" — that's almost always because the framework / vendor JS gates the handler on `event.isTrusted`, and page-side `dispatchEvent(new MouseEvent(...))` is `isTrusted: false`. **`browser_input_dispatch` routes through Chromium CDP `Input.dispatchMouseEvent` / `dispatchKeyEvent`** — events have `isTrusted: true`, so framework handlers (React/Vue/Svelte) and most vendor anti-automation gates actually fire.++```bash+# Click by selector (preferred — uses element bounding rect + scrollIntoView)+adom-desktop browser_input_dispatch '{+ "sessionId":"chip-fetcher",+ "type":"click",+ "selector":"button.wg-button--primary"+}'++# Click by viewport coords+adom-desktop browser_input_dispatch '{+ "sessionId":"chip-fetcher",+ "type":"click",+ "x":860, "y":803+}'++# Type into an input+adom-desktop browser_input_dispatch '{+ "sessionId":"chip-fetcher",+ "type":"type",+ "text":"DMG2305UX-7",+ "selector":"input[name=search]"+}'++# Press a key+adom-desktop browser_input_dispatch '{+ "sessionId":"chip-fetcher",+ "type":"key",+ "key":"Enter"+}'++# Mouse move (human-like trajectory before a click)+adom-desktop browser_input_dispatch '{+ "sessionId":"chip-fetcher",+ "type":"move",+ "x":400, "y":500, "steps":10+}'+```++Optional fields: `tabId` (defaults to active tab), `button` (`left`|`right`|`middle`), `clickCount` (2 for double-click), `delay` (ms between mousedown/mouseup or between keystrokes).++**Use `browser_input_dispatch` when:**+- Vendor "Generate" / "Download" / "Submit" buttons no-op on `browser_eval` clicks+- Login forms behind framework gates+- Modal dismissal needs `key:"Escape"` and DOM-level dispatch fails+- Any time you'd say "I clicked it but nothing happened"++`browser_eval`-side click is still fine for buttons that DO fire (most CSE Download Model buttons, simple modal openers). `browser_input_dispatch` is the upgrade path when those silently no-op.++## Popup tabs auto-track in `browser_list_tabs` (adom-desktop ≥ v1.4.7)++Tabs the page itself spawns (`window.open()`, `target="_blank"` form submits, `<a target="_blank">` clicks) used to be invisible. Now they auto-track:++```bash+adom-desktop browser_list_tabs '{"sessionId":"chip-fetcher"}'+# → tabs: [+# { tabId:"tab-1", opener:"user", openerTabId:null, url:"..." },+# { tabId:"tab-2", opener:"popup", openerTabId:"tab-1", url:"..." }+# ]+```++`opener:"user"` = chip-fetcher opened it. `opener:"popup"` = the page spawned it; `openerTabId` names the tab that triggered the popup.++Popups don't auto-focus — use `browser_switch_tab` to make one active before screenshots, or pass `tabId` to `browser_eval` / `browser_screenshot` to target it without switching.++## Clicks may open the result in a NEW TAB++**Every "Download Datasheet" / "Generate" / "Open PDF" / "View Document" click on a vendor site can spawn a new browser tab.** Common sites where this fires: WAGO ("Generate Datasheet" → PDF in new tab), Microchip, ST, Renesas, ADI, even some TI app-note rows.++**The rule, applied after every click that "should" produce a download/PDF/CAD file:**++1. **Wait ~2-5s** for the popup to fully load (it may be a redirect chain).+2. **`browser_list_tabs`** — look for a new entry with `opener:"popup"`. The `openerTabId` confirms which tab triggered it.+3. **`browser_eval` (or `browser_screenshot`) with `tabId` set to the popup's `tabId`** — interact with the popup directly without switching the active tab.+4. **For a PDF popup**: grab `location.href` from the popup, then use `browser_fetch_url` (Mode A or B). Do NOT use in-page `fetch(location.href)` on a Chrome PDF Viewer popup — it returns the PDF Viewer HTML wrapper (~200 KB stub), not the binary.+5. **`browser_close_tab` with the popup's `tabId`** to clean up after.++This also applies to "Buy" → distributor checkout, "View 3D" → embedded viewer in new tab, "Open Application Note" → PDF in new tab.++Direct user pushback: *"wago opens the datasheet in a new tab, so you could just see that tab and then save it locally"* + *"add that to the skill that this could happen on any website."*++## Smart-pick (adom-desktop ≥ v1.4.9)++When a selector matches multiple elements, the relay no longer silently picks document-order first. It filters to visible+text matches, detects open modals, and prefers candidates **inside** the topmost modal (with area tiebreak).++Always check the response fields:++```json+{+ "ok": true,+ "matchedCount": 4,+ "chosenIndex": 3,+ "clickedRect": {"x":890,"y":803,"w":193,"h":48},+ "clickedText": "Generate data sheet",+ "pickStrategy": "modal-scoped-largest",+ "modalDetected": true,+ "insideModal": true+}+```++`pickStrategy` values to act on:+- `only-match` / `first-match` — single match, or you opted out via `firstMatch:true`+- `modal-scoped-largest` — modal detected, picked inside it (preferred)+- `visible-text-largest` — no modal, area-tiebreak among visible+text+- `visible-text-largest-no-modal-match` — modal detected but selector hit nothing inside it. **Either your selector is wrong, OR modal-detection misfired (Vue-Toastification false-positive bug).**+- `visible-largest` — no text matches, fell back to area+- `fallback-first-document-order` — nothing visible at all++If `pickStrategy` is `visible-text-largest-no-modal-match` and you can SEE the button visually, refine the selector OR pass `{x, y}` coords directly. Always sanity-check `clickedText` against expectation before assuming success.++### Vue-Toastification false-positive workaround++Pages using Vue-Toastification (WAGO and others) ship empty `<div class="Vue-Toastification__container">` elements at `position:fixed; z-index:9999; height:100vh`. Smart-pick's >25%-viewport heuristic sees them as "modal." Result on WAGO: real Generate-Datasheet modal is missed, smart-pick reports `modalDetected:true insideModal:false pickStrategy:"visible-text-largest-no-modal-match"`, and falls back to area-tiebreak among page-level matches → clicks "Add to shopping cart" instead of "Generate data sheet."++**Workaround:** when `pickStrategy === "visible-text-largest-no-modal-match"` AND `modalDetected === true` AND the user can clearly see the button on screen, fall back to coords:++```bash+RECT=$(adom-desktop browser_eval "{\"sessionId\":\"$S\",\"expr\":\"(()=>{const b=document.querySelector('YOUR-SELECTOR'); if(!b)return null; const r=b.getBoundingClientRect(); return JSON.stringify({x:Math.round(r.left+r.width/2),y:Math.round(r.top+r.height/2)});})()\"}" | jq -r '.result')+adom-desktop browser_input_dispatch "{\"sessionId\":\"$S\",\"type\":\"click\",\"x\":$(echo $RECT | jq .x),\"y\":$(echo $RECT | jq .y)}"+```++Modal-detection `isPlausibleModal` filter is fixed in v1.4.11 — empty containers correctly skipped.++## `browser_fetch_url` — pull bytes through the session (adom-desktop ≥ v1.4.9)++When a popup tab serves `application/pdf`, in-page `fetch(location.href)` returns Chrome's PDF Viewer HTML wrapper, NOT the binary. `browser_fetch_url` re-issues the request through the bridge's session context, bypassing the page render layer. **This is the canonical way to grab popup-PDF bytes.**++Three modes:++```bash+# Mode A — container-side write (preferred for chip-fetcher's library/)+adom-desktop browser_fetch_url '{+ "sessionId":"chip-fetcher",+ "tabId":"tab-2",+ "url":"https://wago.priintcloud.com/datasheets/2601-3105/en",+ "saveTo":"/home/adom/project/chip-fetcher/library/WAGO-2601-3105/datasheet.pdf"+}'+# → {ok:true, savedTo:"/home/.../datasheet.pdf", savedToFilesystem:"container",+# bytes:3798336, contentType:"application/pdf", status:200}++# Mode B — desktop-side write (drop a CAD bundle in user's Downloads)+adom-desktop browser_fetch_url '{+ "sessionId":"chip-fetcher",+ "tabId":"tab-2",+ "url":"https://...",+ "desktopSaveTo":"C:\\Users\\john\\Downloads\\bundle.zip"+}'++# Mode C — neither saveTo nor desktopSaveTo → bodyBase64 in response+# (caller decides where to write; useful for files <50 MB)+```++### Combined recipe — vendor "Generate Datasheet" → popup → grab PDF++```bash+SESSION="chip-fetcher"+URL="https://www.wago.com/global/p/2601-3105"+OUT="/home/adom/project/chip-fetcher/library/WAGO-2601-3105/datasheet.pdf"++# 1. Open product page+adom-desktop browser_navigate "{\"sessionId\":\"$SESSION\",\"url\":\"$URL\"}"+sleep 6++# 2. Auto-accept cookies+adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"<auto-accept-cookies-snippet>\"}"+sleep 2++# 3. Open the modal (eval-click is fine for non-trusted modal openers)+adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"Array.from(document.querySelectorAll('button, a')).find(e => /^generate datasheet$/i.test((e.textContent||'').trim())).click()\"}"+sleep 3++# 4. TRUSTED click on modal submit+adom-desktop browser_input_dispatch "{\"sessionId\":\"$SESSION\",\"type\":\"click\",\"selector\":\"button.wg-button--primary\"}"+sleep 5++# 5. Capture popup URL FROM list_tabs IMMEDIATELY (popup tabs auto-close)+LIST=$(adom-desktop browser_list_tabs "{\"sessionId\":\"$SESSION\"}")+POPUP_URL=$(echo "$LIST" | jq -r '.tabs[] | select(.opener=="popup") | .url' | head -1)+POPUP_TAB=$(echo "$LIST" | jq -r '.tabs[] | select(.opener=="popup") | .tabId' | head -1)++# 6. Fetch PDF bytes through the session, save container-side+adom-desktop browser_fetch_url "{+ \"sessionId\":\"$SESSION\",+ \"tabId\":\"$POPUP_TAB\",+ \"url\":\"$POPUP_URL\",+ \"saveTo\":\"$OUT\"+}"+```++### `tab_not_found` recovery (v1.4.11+)++When the Chrome PDF Viewer popup auto-closes between your `browser_list_tabs` and a follow-up `browser_eval tabId:popupTabId`, the structured error returns `lastKnownUrl` directly:++```json+{+ "ok": false,+ "errorCode": "tab_not_found",+ "lastKnownUrl": "https://example.com/",+ "openerTabId": "tab-1",+ "_hint": "...Call browser_fetch_url with that URL (and the opener tabId for cookies) to get its bytes — do NOT try to eval against the closed tabId."+}+```++```bash+# Canonical popup-PDF recovery pattern+RESP=$(adom-desktop browser_eval "{\"sessionId\":\"X\",\"tabId\":\"$POPUP\",\"expr\":\"1\"}" 2>&1 || true)+if echo "$RESP" | jq -e '.errorCode == "tab_not_found"' >/dev/null; then+ URL=$(echo "$RESP" | jq -r '.lastKnownUrl')+ OPENER=$(echo "$RESP" | jq -r '.openerTabId')+ adom-desktop browser_fetch_url "{\"sessionId\":\"X\",\"tabId\":\"$OPENER\",\"url\":\"$URL\",\"saveTo\":\"…\"}"+fi+```++## adom-desktop pull_file (streaming, v1.4.3+)++`pull_file` streams files in 1 MiB binary WS frames straight to disk — no base64 inflation, no JSON-per-chunk overhead, incremental SHA256 verification, 600 s per-file timeout. **Works for files of any size we've seen** (75 MB NXP Reference Manuals complete in ~12 s).++```bash+adom-desktop pull_file '{+ "filePaths": ["C:/Users/<USER>/Downloads/<file>"],+ "saveTo": "/home/adom/project/chip-fetcher/incoming"+}'+# → {"success": true, "files": [{"name": "...", "path": "/home/adom/...", "size": ..., "sha256": "...", "chunks": ...}]}+```++If `chunks` is missing from the response, you're hitting an old non-streaming path — see "Stale relay" below.++## Stale-relay gotchas++### `adom-desktop serve` (container-side)++The `adom-desktop serve` process holds its own argv[0] binary in memory; on-disk upgrades don't reload it. Symptom: `Unknown action: send_and_wait_streamed` from the relay's HTTP API even though the on-disk binary is current. Fix:++```bash+pkill -f "adom-desktop serve"+sleep 1+nohup /usr/local/bin/adom-desktop serve > /tmp/adom-desktop-serve.log 2>&1 &+disown+adom-desktop --version+```++### Stale Puppeteer bridge (`node.exe` on Windows desktop)++adom-desktop's Windows-side puppeteer bridge (`node.exe`) is auto-spawned at first browser command and stays resident. Upgrading the desktop app reinstalls `plugins/puppeteer/server.js` on disk, but the existing `node.exe` keeps its old code loaded. Symptom: relay rejects new commands with `"Unknown command: <name>"` even though the binary's `--version` reports the new version.++```bash+ssh user@host "taskkill //f //im node.exe"+# then send any browser command to auto-respawn+adom-desktop browser_status '{"sessionId":"chip-fetcher"}'+```++**Verification command after any adom-desktop upgrade:**+```bash+adom-desktop browser_input_dispatch '{"sessionId":"x","type":"click","x":1,"y":1}'+# Expected: {"ok":true,"dispatched":"click",…} or session-not-found+# WRONG: "Unknown command: browser_input_dispatch" — bridge is stale, kill node.exe.+```++## Cookie-banner auto-accept (run after every navigation)++The first thing chip-fetcher does on any new page is dismiss the cookie banner with "Accept All" — without being told, without asking the user, without thinking about it. Cookie banners block clicks (z-index gate) and break programmatic navigation.++```js+// snippet to use via browser_eval after every navigation+(()=>{+ 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";+ // Walk shadow roots — Usercentrics et al. use them+ const hostsAttempted = new Set();+ const walk = (root) => {+ for (const el of root.querySelectorAll("*")) {+ if (el.shadowRoot && !hostsAttempted.has(el)) {+ hostsAttempted.add(el);+ if (tries(el.shadowRoot)) return true;+ if (walk(el.shadowRoot)) return true;+ }+ }+ return false;+ };+ return walk(document) ? "accepted-in-shadow" : "no-banner";+})()+```++Direct user quote: *"you're supposed to always accept their stupid cookie dialogs automatically"*. Never click "Reject", "Decline", "Manage preferences", or "Save settings" — those are slower and the user has explicitly ruled out anything but Accept All.++## Comprehensive page killer (cookies + surveys + modals)++Run this AFTER every `browser_navigate`, AND a second time after a 2-second delay (some popups load after first paint):++```js+const acceptRegex = /^(accept all|accept|allow all|i accept|got it|agree|ok|continue|yes)$/i;+const declineRegex = /^(not today|no thanks|maybe later|decline|close|dismiss|not now|no, thanks|cancel|skip|later)$/i;+Array.from(document.querySelectorAll("button, a, [role=button]"))+ .filter(b => {+ const t = b.textContent.trim();+ return acceptRegex.test(t) || declineRegex.test(t);+ })+ .forEach(b => b.click());+// Click close X icons (cookie banners, modals, popups)+document.querySelectorAll("[aria-label*=close i], [class*=banner-close], [class*=modal-close], [class*=popup-close], #onetrust-close-btn-container button, #onetrust-accept-btn-handler, #surveyModal .close, [id*=survey] [class*=close]").forEach(b => b.click());+// Hide any survivors+document.querySelectorAll("[id*=survey], [class*=survey-modal], [class*=popup-modal], [aria-modal=true]:not([id^=mvOverlay])").forEach(el => el.style.display = "none");+document.body.style.overflow = "";+```++NXP, ST, ADI, and Microchip all have multiple modal overlays that compound: cookie banner + privacy banner + survey + chat widget.++## Pup profile name is SACRED++**Use `profile:"chip-fetcher"` and only `profile:"chip-fetcher"`.** Never `chip-fetcher-2`, `chip-fetcher-temp`, `cf-bust`, or any other variant. Each pup profile is an isolated Chrome user-data directory at `<adom-desktop>/plugins/puppeteer/profiles/<name>/` — switching names dumps you into a fresh profile with no cookies, no signed-in vendor accounts, no work-Chrome sync, no CSE login, nothing.++**Do not rotate profiles to bust Chrome's HTML cache.** That destroys vendor logins. Bust the cache the right way:+- Add a `?v=$(date +%s)` query string when reloading via `location.reload()` or `browser_navigate`.+- Run `chip-fetcher serve` on a different port and reopen — the proxy treats it as a new origin.+- Send `Cache-Control: no-store` headers from the chip-fetcher viewer's HTTP server.++If you've already broken the profile (vendor logins gone, Google account avatar missing in upper-right) the only recovery is the user re-signs into the work Chrome profile. Treat profile-rotation regressions as a P0 skill bug.++## Don't pile up pup tabs++**Reuse the existing tab. Don't open a new tab for every navigation.** Use `browser_navigate` (or `location.href = …` via `browser_eval`) when moving between part pages, search pages, or vendor sites. Only call `browser_open_tab` when you genuinely need two pages side-by-side. After a download fires and the zip is auto-pulled, close stale tabs with `browser_close_tab`.++Direct user quote: *"never open too many tabs cuz i'm seeing way too many"*. Tab clutter is also a perf hit — every chip-fetcher tab carries cookies, cached JS, and an open Cloudflare check, and after ~10 the pup window starts thrashing.++## adom-desktop commands you'll actually use++```bash+# Drive a browser tab+adom-desktop browser_navigate '{"sessionId":"chip-fetcher","url":"..."}'+adom-desktop browser_eval '{"sessionId":"chip-fetcher","expr":"..."}'+adom-desktop browser_screenshot '{"sessionId":"chip-fetcher","maxWidth":900}'+adom-desktop browser_raise_os_window '{"sessionId":"chip-fetcher"}'++# Run shell on user's desktop (requires approval — first time per session)+adom-desktop shell_execute '{"command":"<cmd>","timeoutSeconds":30}'++# Pull a file from the user's desktop into the container+adom-desktop pull_file '{+ "filePaths": ["C:\\Users\\<USER>\\Downloads\\<file>"],+ "saveTo": "/home/adom/project/chip-fetcher/incoming"+}'+```
playbooks/credentials-and-logins.mdadded+309@@ -0,0 +1,309 @@+# Credentials and logins (chip-fetcher playbook)++Login source catalog, credential vault, the "always ask first" rule. Read this when a vendor flow lands on a sign-in/sign-up page.++## ALWAYS ask "do you have a login here?" before showing a signup form++**Almost every EE already has logins for the standard CAD/distributor sites** — SnapEDA / SnapMagic, Component Search Engine, Ultra Librarian, Mouser, DigiKey, NXP, TI my.ti.com, ST, Microchip, Renesas, Bosch Sensortec, Nordic DevZone, etc. When a vendor download flow lands on "Sign in or Create Account," **the default move is to ASK first**, not to start filling a sign-up form with profile data.++The wrong sequence (verified painful 2026-05-03):+1. Land on SnapEDA signup form+2. Pre-fill email/username/company from `user_profile.md`+3. Ask user for password++The right sequence:+1. Land on SnapEDA signup form+2. **Click "Sign in" / "Already have an account?" first** — that opens the existing-account login.+3. Ask the user: *"SnapEDA login required. Do you already have an account? If yes, sign in in the pup window; if no, want me to sign you up with [email protected] + jlauer + Adom Industries, Inc.?"*+4. If they have an account → wait for the sign-in click + watch for the post-login redirect.+5. If they don't → only THEN go through signup with `user_profile` data.++Direct user pushback: *"why the fuck wouldn't you ask me if i have a login already … almost all EE's are going to have all of these logins."*++## ⚠ Two auth schemes, two different tools — don't conflate them++The chip-fetcher dashboard has a **Credentials** button AND a pup-login flow. They solve different auth schemes; picking the wrong one wastes the user's time.++| Tool | What it stores | Mechanism | When it works |+|------|----------------|-----------|---------------|+| **Credentials button** (`/api/credentials`, `~/.config/chip-fetcher/credentials.json`) | host + username + password | chip-fetcher rewrites outbound URLs to `https://user:pass@host/…` so the browser satisfies HTTP Basic Auth before any dialog can appear | **Basic-Auth-protected endpoints only** — primarily `*.componentsearchengine.com` (Mouser CSE, NXP CSE, etc.). Useless on form-login sites. |+| **Pup-window sign-in** (`adom-desktop browser_navigate` + user clicks Login) | session cookie (lives in pup profile dir) | Real form submission inside the chip-fetcher pup browser; vendor sets `Set-Cookie`; cookie persists | **Form-login sites** — UL Pro, SnapEDA/SnapMagic, my.ti.com, NXP MyNXP, Microchip, ST, Renesas, etc. These sites don't honor Basic Auth headers at all. |++**Common mistake:** treating UL Pro / SnapEDA / TI as "give me your password and I'll save it in the Credentials vault." That vault only does URL rewriting — it cannot drive a captcha + email + password form, and the receiving site ignores the Basic Auth header entirely. The login has to happen in pup so the session cookie lands in the pup profile.++## ⚠ HARD RULE — pup-window sign-in must happen IN THE PUP WINDOW, never in the user's host browser++**The cookie has to land in the chip-fetcher pup profile.** If the user signs in via Edge/Chrome on the host OS, that cookie is invisible to chip-fetcher and every subsequent `cf-ul-fast.sh` / `cf-ul-pro.sh` / `cf-cse-batch.sh` run still hits the captcha wall or the Basic-Auth dialog.++The wrong sequence (verified painful 2026-05-04):+1. UL captcha grind defeats AI-vision speed.+2. Tell the user: *"want to give me UL Pro credentials?"*+3. User says: *"how do I get that?"*+4. I describe the signup process abstractly — user opens **Edge** to sign up.+5. Cookie ends up in Edge. Useless. Captcha wall still there.++The right sequence:+1. Hit a vendor sign-in wall.+2. **Open the login URL in the chip-fetcher pup session immediately**:+ ```bash+ adom-desktop browser_navigate '{"sessionId":"chip-fetcher","profile":"chip-fetcher","url":"https://app.ultralibrarian.com/account/login"}'+ adom-desktop browser_focus_window '{"sessionId":"chip-fetcher"}'+ ```+ Equivalent shorthand: `chip-fetcher login <alias>` (alias table below).+3. Tell the user: *"Sign in / register **in the pup window** — that's where the cookie needs to land."*+4. Watch for the post-login redirect, then optionally save creds to the OS keychain (see "adom-desktop OS keychain vault" below) so future containers don't need a fresh sign-in.++**Never** tell the user to "go to https://… and sign in" without specifying the pup window. The instruction has to be: *"I've opened it in pup; sign in there."*++Direct user pushback (2026-05-04): *"but why aren't you taking me, and future adom users, through this in pup then if the goal is to see the cookie? i'm in edge browser ready to sign in, but you'll never see that."* And then: *"can you make sure you handle this this way for future adom users and walk them through this process?"*++## Walkthrough — taking a user through pup-window sign-in (copy-pasteable)++When you decide a vendor needs a logged-in session (form-login site like UL Pro, SnapEDA, my.ti.com, NXP MyNXP, Microchip, ST), follow this procedure verbatim. Every step has a reason — don't skip steps to "save the user time."++### 0. Confirm it's the right tool++Before you reach for pup-login, check the auth scheme:++- **HTTP Basic Auth** (CSE family, some niche vendor APIs) → use the **Credentials button** in the dashboard / `/api/credentials`. NOT pup-login.+- **Form login + cookie** (everything else in the catalog above) → pup-login.++If you're unsure, hit the download URL with `curl -I` — a `WWW-Authenticate: Basic` header means Basic Auth; a 302 to a `/login` or `/signin` page means form login.++### 1. Open the login page in the chip-fetcher pup session++```bash+# Replace <login-url> with the right entry from the alias table or known-EE-login catalog.+adom-desktop browser_navigate '{"sessionId":"chip-fetcher","profile":"chip-fetcher","url":"<login-url>"}'+adom-desktop browser_focus_window '{"sessionId":"chip-fetcher"}'+```++Or the shorthand (covers the vendors in the alias table):++```bash+chip-fetcher login <alias> # ultralib, snapeda, componentsearch, ti, st, nxp, microchip+```++Always pass `"profile":"chip-fetcher"` to `browser_navigate` — this is the cookie jar `cf-ul-fast.sh`, `cf-ul-pro.sh`, and `cf-cse-batch.sh` all read from.++### 2. Screenshot to confirm the page rendered++```bash+adom-desktop browser_screenshot '{"sessionId":"chip-fetcher","fullPage":false}'+```++Read the screenshot file. Confirm you see the email/password form, not a captcha-only landing or an error page. If the page is blank or redirected somewhere unexpected, **don't** ask the user to type a password into a half-broken state — fix the navigation first.++### 2b. Pre-tick the "Remember Me" / "Stay signed in" checkbox BEFORE the user types++**This step is mandatory, not optional.** The user shouldn't have to think about whether to check the box — pre-tick it for them so the cookie lands persistent (typically 30 days) instead of session-only (lost on pup process restart / container rebuild).++```bash+adom-desktop browser_eval '{"sessionId":"chip-fetcher","tabId":"tab-1","expr":"document.querySelectorAll(\"input[type=checkbox]\").forEach(cb=>{cb.checked=true; cb.dispatchEvent(new Event(\"change\",{bubbles:true}));})"}'+```++Naive but works on every form-login page in the catalog (UL Pro "Remember My Login", SnapEDA "Remember Me", TI "Stay signed in", etc.). On pages with multiple checkboxes (newsletter opt-in, ToS), pre-ticking all of them is fine — the user can untick if they care, and the persistent-session box gets the right value either way.++If the checkbox is custom-rendered (e.g., a styled `<button role="checkbox">`), the generic `input[type=checkbox]` selector misses it. Inspect via:++```bash+adom-desktop browser_eval '{"sessionId":"chip-fetcher","expr":"Array.from(document.querySelectorAll(\"label\")).filter(l=>/remember|stay signed|keep.*log/i.test(l.textContent)).map(l=>({text:l.textContent.trim().slice(0,40), html:l.outerHTML.slice(0,200)}))"}'+```++…and adjust the selector accordingly.++Direct user pushback (2026-05-04): *"why didn't you do that for me?"* — the user shouldn't have had to ask whether to check the box. Pre-tick by default.++### 3. Tell the user — verbatim shape, then OFFER TO FILL FOR THEM++The default move is to **offer to fill the form for them.** These are vendor CAD-download accounts (UL Pro, SnapEDA, CSE, my.ti.com, NXP MyNXP, Microchip, ST). They are not bank logins. The threat model is "if this leaks, the user emails support and resets their password." Treating them as sacred wastes the user's time on a fake security ritual — direct user pushback (2026-05-04): *"stop freaking out about passwords that are literally just for stupid websites that let you download a cad file. the electronics industry acts like they're a bank with these passwords. but they're not banks."*++Use this message shape (adapt the vendor name + URL):++> "Opened the **Ultra Librarian Pro** login in the chip-fetcher pup window with **Remember My Login** pre-ticked. If you've got an account, paste the email + password here and I'll fill it in for you. If you don't have one, click **Register** in the pup window."++If the user pastes credentials, **just fill the form and submit.** Don't lecture about chat-as-secret-store — that's adversarial in the wrong direction for vendor CAD accounts. (Real exception: for actual high-stakes credentials — bank, GitHub, internal infra, payment processor — keep the keychain ritual.)++If the user prefers to type creds themselves, that's also fine — they sign in in the pup window (which you've already pre-ticked), and you watch for the post-login redirect.++### 4. While you wait++Don't disappear. Useful work to do while the user signs in:++- Make sure `adom-desktop credential_set` will accept the host pattern you'll save under (e.g., `*.ultralibrarian.com`).+- Pre-build the smoke-test command — the exact `cf-ul-fast.sh <MPN>` or equivalent you'll run after sign-in succeeds, on a part you know previously hit a captcha.+- If the vendor is one with a `chip-fetcher login <alias>` shorthand but the alias table is missing it, add it.++### 5. Detect sign-in success++Don't ask the user "are you signed in?" — check yourself:++```bash+# Most form-login sites redirect away from /login on success. Check the current URL:+adom-desktop browser_eval '{"sessionId":"chip-fetcher","expr":"location.href"}' \+ | python3 -c "import sys,json; print(json.load(sys.stdin).get('result',''))"++# Or screenshot + visually check for the user's avatar / email in the upper-right.+adom-desktop browser_screenshot '{"sessionId":"chip-fetcher","fullPage":false}'+```++If `location.href` still contains `/login` or `/signin` after a reasonable wait, the user hasn't completed yet (or hit an error). Don't move on prematurely.++### 6. Save to chip-fetcher's Credentials vault (default) or OS keychain (for genuinely sensitive creds)++After sign-in succeeds, save the cred so a fresh container doesn't restart from scratch.++**For vendor CAD accounts (UL Pro, SnapEDA, CSE, my.ti.com, etc.) — the default — save to chip-fetcher's vault:**++```bash+curl -s -X POST http://127.0.0.1:8786/api/credentials \+ -H "Content-Type: application/json" \+ -d '{"host":"app.ultralibrarian.com","username":"<email>","password":"<password>"}'+```++This populates the **Credentials** button in the dashboard. The vault is plaintext at `~/.config/chip-fetcher/credentials.json` (chmod 600). Why this is fine for vendor CAD accounts: the file is only readable by the container user, and the threat model for these accounts is "if leaked, user resets password and moves on." Same threat model as the user pasting them in chat in the first place.++**For genuinely sensitive creds (bank, GitHub, payment processor, internal infra) — different rule:** use `adom-desktop credential_set` (OS keychain) and have the user run it themselves so the password never crosses chat or shell args. Those creds shouldn't be in chip-fetcher's plaintext vault at all.++The split: **vendor CAD = chip-fetcher vault, plaintext, no big deal.** **Real secrets = OS keychain, never in chat.**++### 7. Smoke-test the original failing fetch++Re-run the script that triggered this whole flow on a known-failing MPN:++```bash+./chip-fetcher/scripts/cf-ul-fast.sh <MPN> # or cf-cse-batch.sh, etc.+```++Confirm it now succeeds without the captcha wall / Basic-Auth dialog. If it still fails, the cookie didn't land — check pup profile dir for the `Cookies` SQLite, and check `--profile` flag on the script.++### What to NEVER do in this flow++- **Never** print the URL and walk away. The user opens Edge.+- **Never** save passwords directly in skill files or memory (the chip-fetcher vault and the OS keychain are the two homes for storage; chat is for transit only).+- **Never** assume the cookie auto-syncs between the host browser and pup. They are separate cookie jars.+- **Never** confuse this with the **Credentials button** flow when the vendor needs a session cookie (that vault only does Basic-Auth URL rewriting).+- **Never** treat vendor CAD-download accounts (UL, SnapEDA, CSE, my.ti.com, NXP MyNXP, ST, Microchip) like bank logins. The user finds the security theater more annoying than the leakage risk. Just take the password and fill the form.++## Known-EE-login catalog++Every site here is one almost every embedded engineer already has credentials for, and asking is faster than guessing:++| Site | Login URL | Common reason for login |+|------|-----------|-------------------------|+| SnapEDA / SnapMagic | https://www.snapeda.com/account/login/ | Free CAD downloads |+| Component Search Engine | https://componentsearchengine.com/account/login | CSE library loader |+| Ultra Librarian | https://www.ultralibrarian.com/account/login | UL library loader (some flows) |+| Mouser | https://www.mouser.com/MyMouser/SignIn | Order tracking, sample requests |+| DigiKey | https://www.digikey.com/MyDigiKey/Login | Order tracking, BOM tools |+| my.ti.com (Texas Instruments) | https://www.ti.com/securelogin/ | Sample requests, secured datasheets |+| NXP MyNXP | https://www.nxp.com/webapp/sign-in | Restricted AppNote PDFs |+| ST my.st.com | https://my.st.com/auth/login | Restricted AppNotes, eval boards |+| Microchip MyMicrochip | https://www.microchip.com/en-us/my-account-sign-in | KiCad/Fusion CAD downloads |+| Renesas | https://www.renesas.com/myaccount/login | Restricted CAD/AppNotes |+| Bosch Sensortec | https://www.bosch-sensortec.com/my-account/login/ | Eval kit ordering |+| Nordic DevZone | https://devzone.nordicsemi.com/auth/login | Forum + some restricted downloads |+| Espressif | https://www.espressif.com/en/login | Module datasheets, mass-prod kits |++When you hit any of these sign-in walls, ASK before building forms.++## chip-fetcher's known-source aliases++| name | URL | Notes |+|-------------------|-----------------------------------------------------------|-------|+| `google` | `https://accounts.google.com/signin` | propagates to all "Sign in with Google" buttons |+| `ti` | `https://www.ti.com/securemyprofile/login` | TI funnels CAD through UL anyway |+| `st` | `https://my.st.com/cas/login` | ST self-hosts some STEPs |+| `microchip` | `https://www.microchip.com/en-us/my-account-sign-in` | OAuth popup; Google sign-in works for myMicrochip |+| `nxp` | `https://www.nxp.com/security/login` | **Required for Reference Manuals + HW Design Guides + Board User Manuals.** Datasheet, errata, app notes, factsheet are open. |+| `ultralib` | `https://app.ultralibrarian.com/account/login` | **email/password or LinkedIn only — no Google** |+| `snapeda` | `https://www.snapmagic.com/account/login` | SnapMagic (rebranded SnapEDA) |+| `componentsearch` | `https://componentsearchengine.com/signin` | samacsys; **single login covers all `<mfr>.componentsearchengine.com` subdomains** |++`chip-fetcher login <name>` opens the login page in the chip-fetcher pup profile. `chip-fetcher login all` opens them all as separate tabs. `chip-fetcher login list` prints this catalog.++## ⚠ HARD RULE — CSE downloads use HTTP Basic Auth, not the form-login session++**Component Search Engine (`mouser.componentsearchengine.com`, `nxp.componentsearchengine.com`, etc.) requires HTTP Basic Auth on its download endpoints (`/ga/model.php`, `/preview_newDesign.php` redirects). The `/signin` form login gives you a session cookie that is USELESS for downloads — those endpoints challenge for Basic Auth separately.**++Symptoms when you forget this (verified painful 2026-05-04):++- Form login at `componentsearchengine.com/signin` succeeds — page redirects to `/learn-more`, "Log Out" link appears, you "look" signed in.+- You navigate to `mouser.componentsearchengine.com/preview_newDesign.php?...` — the preview page renders.+- You click "Download CAD Models" — the page lands on `chrome-error://chromewebdata/`, no file in Downloads.+- A native Chrome HTTP-Basic-Auth dialog is blocking the page **but is NOT in the DOM**. `browser_eval` cannot see it. `document.querySelector("[role=dialog]")` returns null. Your automation thinks the click did nothing.+- You waste 30 minutes troubleshooting and the user gets to ask, *"i gave you my credentials for CSE and you never typed them in?"*++**The fix — three layers, in order of reliability:**++### 1. Inline credentials in the URL (universal — works in all navigation paths)++```bash+USER=$(python3 -c "import urllib.parse; print(urllib.parse.quote('<your-cse-email>', safe=''))")+PASS=$(python3 -c "import urllib.parse; print(urllib.parse.quote('<password from credential vault>', safe=''))")+URL="https://${USER}:${PASS}@mouser.componentsearchengine.com/preview_newDesign.php?o3=0&partID=8659&ev=0&fmt=zip&pna=Mouser"+adom-desktop browser_navigate "{\"sessionId\":\"chip-fetcher\",\"url\":\"$URL\"}"+```++Always urlencode the username (`@` → `%40`) and password. This is the lowest-failure-mode pattern — Chrome handles the credentials before any dialog can appear, and it works regardless of pup version, profile state, or which pup tool you're using (`browser_navigate`, `browser_open_window`, etc.). **Use this by default for any CSE click that triggers a download.**++### 2. adom-desktop OS keychain vault (only fires on `page.goto`)++`adom-desktop credential_set` stores creds in the OS keychain (Windows DPAPI / macOS Keychain / Linux libsecret). The vault hook fires before `page.goto` so the basic-auth popup is satisfied automatically — but **only for page-level navigations**. A click-triggered download URL that opens in the same tab can bypass the hook depending on how Chrome routes the request, and that's exactly when the hidden popup catches you.++**One-time setup per user:**++```bash+# Ask user for CSE creds, then:+adom-desktop credential_set '{+ "host": "*.componentsearchengine.com",+ "username": "<their email>",+ "password": "<their password>"+}'++# Audit (passwords NEVER returned)+adom-desktop credential_list+# → [{"host":"*.componentsearchengine.com","username":"..."}]+```++**Glob matching:** `*.example.com` matches any subdomain; longest-non-glob-suffix wins. Set once per user → applies to nxp.cse, mouser.cse, ti.cse, etc.++For genuinely high-stakes secrets (banking, internal infra, payment processors), have the user run `credential_set` themselves so the password never crosses chat or shell args. For vendor CAD-download accounts (CSE, UL, SnapEDA, etc.), the user pasting the password in chat is fine — those creds end up in the chip-fetcher vault anyway, and the threat model is "if leaked, reset the password and move on."++### 3. chip-fetcher's `/api/credentials` vault (read-only mirror, used for inline-URL building)++chip-fetcher stores a non-OS-keychain copy of CSE credentials at `~/.config/chip-fetcher/credentials.json` (chmod 600) and exposes them at `http://127.0.0.1:8786/api/credentials` for the dashboard's "manage credentials" UI. **This vault is plaintext-readable** — perfect for building inline-URL credentials in scripts, NOT a substitute for the OS-keychain auto-inject.++```bash+# Pull both vaults' state and use the chip-fetcher one to build inline URLs:+CRED=$(curl -s http://127.0.0.1:8786/api/credentials | python3 -c '+import sys,json,urllib.parse+d=json.load(sys.stdin)+c=next((x for x in d["credentials"] if "componentsearchengine" in x["host"]), None)+if c: print(f"{urllib.parse.quote(c[\"username\"],safe=\"\")}:{urllib.parse.quote(c[\"password\"],safe=\"\")}")+')+URL="https://${CRED}@mouser.componentsearchengine.com/..."+```++**Sync the two vaults** the first time you set up CSE on a new container — write to both `chip-fetcher`'s and `adom-desktop`'s. They serve different layers and the failure modes are independent.++## User profile memory for forms++A `user` memory exists at `~/.claude/projects/-home-adom-project/memory/user_profile.md` — the user's company info (Adom Industries Inc., Fort Worth TX, etc.) for auto-filling NXP/TI/ST extended-profile and registration forms.++The pattern: read the memory when a vendor form needs filling, set field values, dispatch `input` + `change` + `blur` events on each field (some forms only enable submit after these fire), then click submit. **Never store passwords in memory** — those go in the credential vault.++## Don't click the Chrome profile avatar++**Don't click the Chrome profile avatar (top-right of browser chrome) in pup windows.** It opens chrome:// account-switcher UI that pup can't host — the session dies. All auth happens on web pages, never via the chrome avatar. If the session does die, cookies persist in the profile dir, so just relaunch with the same `profile: "chip-fetcher"` and you're still logged in.++## Diagnosing "am I signed in?" before assuming the worst++When the user reports automation detection is firing, **don't immediately conclude the profile is broken**. Check first by navigating to `https://accounts.google.com/` and looking at the upper-right avatar — if the user's account name + photo are visible, the Google sign-in is intact and the detection is about something else entirely (Chrome-for-Testing fingerprint, missing extensions, burst-request behavior).++When detection fires:+1. Verify Google sign-in is intact (don't lose it).+2. Apply the "look like a slow, distracted human" rules (referrer chain, random delays, scroll, mouse-move).+3. If still blocked, ask the user to slide the CAPTCHA — the missing browsing-history reputation can't be faked.++Past mistake: misdiagnosed automation detection as profile-loss and asked the user to re-sign-in unnecessarily.
playbooks/dashboard-flow.mdadded+219@@ -0,0 +1,219 @@+# Dashboard flow — chip-fetcher's UI is the user's home base (HARD RULE)++**Read this first.** chip-fetcher is not just a CLI that scrapes the web — it's a dashboard that the user keeps open as their home base while the agent does the scraping in a separate window. The dashboard is the single place the user looks to understand what's planned, what's in flight, and what's done. The skill below is mandatory for every batch fetch.++## The hard rule++**Two separate surfaces. The dashboard is user-preference; the scraping window is always pup.**++| Surface | Where it lives | User-preferred? | Why |+|---------|----------------|-----------------|-----|+| **Dashboard** (home base) | EITHER pup `sessionId: "chip-fetcher-dashboard"` OR Hydrogen webview tab named "chip-fetcher" | **YES — ask the user (or check user-profile memory)** | Just renders HTML + polls `/api/activity`. No automation needed. |+| **Scraping window** | pup `sessionId: "chip-fetcher"` | **NO — always pup** | Needs `browser_input_dispatch` for trusted clicks, captcha solving, vendor-login cookies on the `chip-fetcher` profile, download capture from `~/Downloads`. None of that works in a Hydrogen webview. |++### Dashboard placement — figure out the user's preference++Adom users split based on screen real estate:+- **Laptop-screen users (e.g. John)** prefer pup as the dashboard — the pup window can be moved to a second monitor, alt-tabbed, or sized to taste outside VS Code's chrome.+- **Big-monitor users (e.g. Colby)** prefer Hydrogen webview — keeps everything inside VS Code, splits cleanly alongside the editor in a workspace pane.++**Be smart about this detail:**+1. Check `~/.claude/projects/-home-adom-project/memory/user_profile.md` for a `chip_fetcher_dashboard_surface` preference (`pup` or `webview`)+2. If absent, ASK the user once: *"Where do you want the chip-fetcher dashboard — pup window or Hydrogen webview tab?"* and save the answer for future sessions+3. Default fallback if you can't ask: pup, since it works on any screen size++Either way, the **scraping work always runs in pup** — the dashboard surface is decoupled from the automation surface.++### How to open the dashboard in either surface++**Pup version:**+```bash+DASHBOARD_URL="${VSCODE_PROXY_URI/\{\{port\}\}/8786}"+adom-desktop browser_open_window "{+ \"sessionId\":\"chip-fetcher-dashboard\",+ \"profile\":\"chip-fetcher\",+ \"url\":\"$DASHBOARD_URL\"+}"+adom-desktop browser_raise_os_window '{"sessionId":"chip-fetcher-dashboard"}'+```++**Hydrogen webview version:**+```bash+# Find a non-VS-Code pane (per the "never cover VS Code" memory rule)+# Then split + add a webview tab+VSCODE_PANEL=$(adom-cli hydrogen workspace tabs | jq -r '.tabs[] | select(.name=="Visual Studio Code") | .panelId')+WEBVIEW_TYPE="adom/c1d2e3f4-aaaa-4000-a000-00000000000f"+DASHBOARD_URL="http://127.0.0.1:8786/"+adom-cli hydrogen workspace split \+ --panel-id "$VSCODE_PANEL" \+ --direction horizontal \+ --panel-type "$WEBVIEW_TYPE" \+ --display-name "chip-fetcher" \+ --position after \+ --ratio 0.4 \+ --initial-state "{\"url\":\"$DASHBOARD_URL\",\"displayName\":\"chip-fetcher\"}"+```++Both surfaces poll the same `/api/activity` endpoint, so heartbeats reach the user regardless of which surface they picked.++**Why two distinct things:**+- The user can always glance at the dashboard while the agent navigates. They don't lose context when the agent visits SnapMagic or solves a captcha.+- The dashboard surface NEVER gets navigated away from. It stays on the chip-fetcher viewer URL. The scraping window does the work.+- If the agent gets stuck, the dashboard surfaces it on the relevant chip's card — the user knows which chip needs help without reading chat.++## The canonical batch flow++```+User asks: "fetch these 6 LEDs"+ ↓+Stage 0: PLAN+ ├ Tell user the MPN list as a numbered plan+ ├ chip-fetcher plan <MPN1> <MPN2> ... (seeds dashboard with planned-state stub cards)+ └ Open dashboard in chip-fetcher-dashboard session if not already open+ ↓+Stage 1: PROBE manufacturer site (per chip, in chip-fetcher session)+ ├ post_act <MPN> "probing <vendor>.com"+ ├ Walk product page for CAD/Documentation/Tools links+ └ post_act <MPN> "found UL deep-link" OR "no mfr-direct CAD"+ ↓+Stage 2: FETCH (per chip, in chip-fetcher session)+ ├ post_act <MPN> "downloading <format>"+ ├ Drive UL form / SnapEDA modal / Mouser CSE Library Loader+ └ post_act <MPN> "captcha — user help needed" if stuck+ ↓+Stage 3: PULL + IMPORT (per chip)+ ├ post_act <MPN> "pulling from desktop"+ ├ chip-fetcher pull <path> --mpn <MPN>+ └ post_act <MPN> "imported"+ ↓+Stage 4: CLEAR+ └ post_act <MPN> "" (or DELETE) to clear the live activity badge+```++## The activity-heartbeat protocol++**Always use the CLI: `chip-fetcher heartbeat <MPN> "<stage>"`** — it does five things on every call so the AI doesn't have to remember them:++1. POSTs the heartbeat to `/api/activity` so the dashboard renders the live stage.+2. **Auto-creates a stub `library/<MPN>/info.json`** the first time a new MPN heartbeats — the dashboard renders an empty placeholder card so the user has something to scroll to even before any files land.+3. **Auto-scrolls the dashboard pup window** so the active MPN's card is centered in view with a brief outline highlight (`#5cf`). No more "I'm working on this chip but the user is parked on a different row of the grid."+4. **Prints the canonical sourcing-ladder HINT on the very first heartbeat per MPN** (Mfr → SnapMagic → Mouser → DigiKey → Arrow → CSE/UL → LCSC). The AI sees this on stdout instead of having to re-read the skill.+5. **Prints a card-completeness `WARN:` on every heartbeat** listing what files the active card is still missing (PDF / STEP / `.kicad_sym` / `.kicad_mod`), plus a library-wide `WARN: N/Total cards still incomplete` count. The AI cannot quietly walk away from a half-finished batch — the count is in stdout on every heartbeat.++The legacy raw-curl flow still works as a fallback for shell scripts that don't have the binary on PATH:++```bash+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+}+```++…but it does NOT trigger any of the above side-effects. Always prefer the CLI.++The dashboard polls `/api/activity` and renders the latest stage on the chip's card. The user's card shows: live stage, history of recent stages, and any "stuck" flag.++## The completion gate — `chip-fetcher gate`++Before reporting "X of Y done" or "batch complete" or shipping anything based on the library, run `chip-fetcher gate`. It walks every chip and exits 1 if ANY card lacks PDF + STEP + `.kicad_sym` + `.kicad_mod` (default mode) or also Altium + Fusion (`--strict`). For each incomplete card it prints `HINT:` lines naming the exact missing artifacts. The deterministic check replaces "did I remember to check completeness?" — the binary remembers for you.++Datasheet-only cards count as **incomplete stubs**, not done. If the ladder is genuinely exhausted (no STEP exists anywhere on Mfr / SnapMagic / Mouser / DigiKey / Arrow / CSE), record that in `info.json.fetched_via_chain` so the gate sees it as a documented dead-end — but ONLY after walking the full ladder, not as a shortcut.++**Heartbeat MUST fire on:**+- Every page navigation in the scraping session (`navigating <vendor>.com`)+- Every form-fill / button-click that's part of a multi-step flow (`pre-setting form`, `clicking reCAPTCHA`, `polling Downloads`)+- Every state change that affects "is this chip done yet?" (`downloading kicad zip`, `pulling from desktop`, `imported`)+- Every stuck state (`captcha — user help needed`, `mfr lacks CAD — needs SnapEDA approval`)++**Heartbeat must NOT lie about state.** "imported" means the file is in `library/<MPN>/`. "downloading" means the download fired and we're polling Downloads. Don't post "imported" before the file has actually landed — the user trusts the dashboard to be ground truth.++## Canonical stage labels++Use these exact strings so the dashboard can color/group consistently:++| Stage label | When | Color |+|-------------|------|-------|+| `planned` | Initial state when `chip-fetcher plan` seeds the card | grey |+| `probing <vendor>.com` | Scraping window navigated to manufacturer page | blue |+| `found UL deep-link` / `found mfr STEP` / `found CSE link` | mfr-direct CAD path identified | blue |+| `no mfr-direct CAD — falling back to <source>` | manufacturer doesn't host; await user approval | amber |+| `downloading <format>` | Format selected, click fired | blue |+| `captcha — user help needed` | Captcha escalated; pup raised to user | amber |+| `polling Downloads` | Watching ~/Downloads for the zip to land | blue |+| `pulling from desktop` | `adom-desktop pull_file` in flight | blue |+| `importing` | `chip-fetcher import` running | blue |+| `imported` | Files in `library/<MPN>/`, ready to verify | green |+| `done — verified` | step + sym + mod + pdf all present in library | green |+| `stuck — <one-line reason>` | Genuine blocker; user needs to step in | red |++## Opening the dashboard pup window++```bash+# 1. Make sure the viewer is running+ps aux | grep "[c]hip-fetcher serve" >/dev/null || \+ nohup /usr/local/bin/chip-fetcher serve --port 8786 > /tmp/cf-serve.log 2>&1 & disown++# 2. Get the public URL for pup (proxy through the container's adom.cloud slug)+DASHBOARD_URL="${VSCODE_PROXY_URI/\{\{port\}\}/8786}" # env var inside the container++# 3. Open in pup session "chip-fetcher-dashboard" (NEVER navigate this session away)+adom-desktop browser_open_window "{+ \"sessionId\":\"chip-fetcher-dashboard\",+ \"profile\":\"chip-fetcher\",+ \"url\":\"$DASHBOARD_URL\"+}"++# 4. Raise it so the user can see+adom-desktop browser_raise_os_window '{"sessionId":"chip-fetcher-dashboard"}'+```++## The scraping session++```bash+# Use sessionId "chip-fetcher" (NOT "chip-fetcher-dashboard") for all scraping.+# Navigate, login, click, captcha — all in this window.+adom-desktop browser_navigate '{"sessionId":"chip-fetcher","url":"https://www.vishay.com/..."}'+adom-desktop browser_eval '{"sessionId":"chip-fetcher","expr":"..."}'+```++**Never** call `browser_navigate` against `chip-fetcher-dashboard`. That window stays on the dashboard URL for the entire batch. If the user does navigate it (clicks a link in the viewer), let them — but don't drive it programmatically.++## Plan-loading (current state of code)++⚠ **Code gap (as of chip-fetcher 0.1.2):** there is no `chip-fetcher plan <MPN>...` command yet. The viewer auto-discovers chips by scanning `library/<MPN>/` directories at server start, so a chip doesn't appear on the dashboard until it has at least one file imported.++**Until that command lands**, the canonical flow is:+1. List the planned MPNs to the user textually as a numbered batch (so they see the queue before any scraping fires)+2. Start the heartbeat the moment the chip moves into `probing <vendor>.com` (this auto-creates the chip's card via the activity API's side-effect of writing to `library/<MPN>/.activity` or similar)+3. Do not pretend "planned" cards exist on the dashboard if they don't yet++Code work to add proper plan-loading is tracked separately in chip-fetcher's TODO; keep this playbook authoritative for what the flow SHOULD look like once that lands.++## When the dashboard window dies / closes++If the user accidentally closes the dashboard window:+1. Don't reopen it silently — that's confusing if they meant to close it.+2. Surface it: *"I noticed the chip-fetcher dashboard window closed. Want me to reopen it so you can see batch progress?"* and wait for yes.+3. If they say yes, reopen with `chip-fetcher-dashboard` session and resume heartbeats.++The activity history is server-side (in `chip-fetcher serve` memory) so reopening shows current state instantly.++## Common antipatterns (don't do these)++| Antipattern | Why it's wrong |+|-------------|----------------|+| Single `sessionId: "chip-fetcher"` for both dashboard AND scraping | Navigating to vishay.com covers the dashboard; user loses context |+| Heartbeats only on stuck/done | User can't see live progress mid-flow; they think the agent froze |+| Navigating the dashboard window mid-batch | Dashboard is the user's anchor; never overwrite it |+| Posting `imported` before the file actually lands in `library/<MPN>/` | Lies on the dashboard erode user trust |+| Skipping plan-listing because "I'll just fetch and they'll see results" | User doesn't know what's coming or how long it'll take |+| Using a different profile for the dashboard session | Cookies / vendor logins won't be shared |++## Cross-references++- [`manufacturer-direct.md`](manufacturer-direct.md) — every stage in the per-vendor flow should heartbeat+- [`ultra-librarian.md`](ultra-librarian.md) — `cf-ul-fetch.sh` already implements this protocol; reference implementation+- [`snapmagic.md`](snapmagic.md) — heartbeat the format-cycling stages (kicad → altium → fusion)+- [`mouser-cse.md`](mouser-cse.md) — heartbeat the slide-CAPTCHA escalation+- [`handoff-and-watching.md`](handoff-and-watching.md) — when a stage hits "user help needed", post the heartbeat AND raise the scraping window
playbooks/digikey-models.mdadded+74@@ -0,0 +1,74 @@+# DigiKey CAD Models page (chip-fetcher playbook)++DigiKey hosts a dedicated **Footprints and Models** page per part at `digikey.com/en/models/<product-id>`. This is separate from the product detail page (`/en/products/detail/...`) and is where CAD downloads live. The product detail page has datasheets but NOT the CAD format picker.++## URL pattern++```+https://www.digikey.com/en/models/<product-id>+```++The `<product-id>` is the numeric ID from the product detail URL (e.g. `2637915` from `/en/products/detail/abracon-llc/ABM8G-24-000MHZ-B4Y-T3/2637915`).++## What the page shows++Three expandable accordion sections, each from a different upstream:++| Section | What it provides | Two-layer provenance |+|---|---|---|+| **Manufacturer Provided** | STEP file (`.stp` or `.STEP.zip`) hosted on the mfr's CDN | discovery_source=digikey, content_origin=manufacturer |+| **Ultra Librarian** | Symbol + footprint + 3D in multiple EDA formats (KiCad v5, v6+, Altium, Eagle, OrCAD, PADS, etc.) | discovery_source=digikey, content_origin=ultralibrarian |+| **SnapMagic** | Symbol + footprint + 3D, same format range | discovery_source=digikey, content_origin=snapmagic |++**Key insight:** the Manufacturer Provided STEP is often the ONLY place the mfr's own 3D model is surfaced outside the mfr's website. Abracon, for example, hosts their STEP on an Aliyun CDN (`abracon.com/Support/STEP/...`) — DigiKey links directly to it. This makes DigiKey's models page a critical stop in the ladder, not just a fallback.++## Download flow++1. Click the accordion section (e.g. "Ultra Librarian >") to expand it.+2. The section shows Symbol / Footprint / 3D Model previews.+3. Click the red **"Select Download Format"** button at the bottom.+4. A modal appears: **"Choose Download Format"** with radio buttons for every supported EDA format (3D CAD: IGES v5.3, STEP, STL; 2D Model: Altium, KiCad v5, KiCad v6+, Eagle, OrCAD, PADS, etc.).+5. Select **KiCad v6+** (or the target format).+6. Click **Download**.+7. A **DOWNLOAD AGREEMENT** modal MAY appear — it requires scrolling the agreement text to the bottom before the acceptance mechanism activates. Check for a checkbox inside the agreement modal and check it.+8. The download lands in `~/Downloads` as a zip.++## Chrome download permission issue++**As of 2026-05-08:** scripted clicks (`browser_eval → btn.click()`) are silently blocked by Chrome. The download triggers (analytics fire, agreement modal appears) but no file lands. This is a Chrome security policy for non-user-gesture downloads.++**Current workaround:** use `fetch().arrayBuffer()` + base64 transfer inside the browser context for the Manufacturer Provided STEP (direct URL is visible on the page). For UL/SnapMagic format-picked downloads, the download URL is dynamically generated and not fetchable inline.++**Permanent fix:** adom-desktop needs `Page.setDownloadBehavior({behavior:'allow'})` as a default on every pup page — see the adom-desktop prompt for "full capability mode."++## Manufacturer Provided STEP — proven inline-fetch pattern++The STEP link is a direct `<a href="https://mfr-cdn.com/.../Part.STEP.zip">` on the page. Even when Chrome blocks the download, the bytes are fetchable via:++```javascript+const r = await fetch("https://abracon.com/Support/STEP/Resonators/ABM8G.STEP.zip", {credentials:"omit"});+const b = await r.arrayBuffer();+// base64 encode + transfer back to container+```++This is the same pattern as `chip-fetcher pup-pdf-fetch`. The URL is visible in the page's `<a>` tags — scrape eval to find it.++## Two-layer provenance++When importing files from DigiKey's models page, ALWAYS record both layers:++```bash+chip-fetcher import /tmp/ABM8G.STEP.zip --mpn ABM8G-24.000MHZ-B4Y-T3 \+ --discovery-source digikey \+ --content-origin manufacturer \+ --source-url "https://abracon.com/Support/STEP/Resonators/ABM8G.STEP.zip"+```++Chipsmith reads this and shows: "found on DigiKey → made by Abracon (manufacturer)".++## When to use this playbook++- After the manufacturer's own site doesn't host CAD (Abracon, many crystal/passive mfrs)+- When SnapMagic's direct download fails ("Oh Snap!" error, Chrome blocking)+- When Ultra Librarian's login is broken+- DigiKey's models page often has ALL THREE sources in one place — check it before fighting individual vendor sites
playbooks/handoff-and-watching.mdadded+123@@ -0,0 +1,123 @@+# Handoff + watching (chip-fetcher playbook)++When the agent has to hand off a click to the user. Read this when you've decided you can't automate something — captcha, OAuth, OS-level prompt, etc.++## The escape valve, NOT the routine++**chip-fetcher does ALL the clicking.** Never ask the Adom user to click Download, Allow, dismiss a dialog, dismiss a cookie banner, or operate any browser UI on their behalf. chip-fetcher exists so the user *doesn't* have to do that work — once they've signed in (one time, per service), every subsequent click is the agent's job.++If a click looks impossible (Chrome permission dialog, OS-level prompt), the answer is **find a way** — pre-grant the permission via CDP, set a Chrome startup flag, screenshot-and-OS-click via desktop automation, or change the URL flow to skip the gate entirely. Reaching for "user, please click X" is a skill bug, not a workaround.++Direct user quote: *"do it yourself … you always have to do all the work, not the adom user."*++If the agent has genuinely tried every automated path and still cannot click something, fall back to surfacing the obstacle clearly (screenshot + one-line description) — but treat that as a failure mode worth fixing in the skill, not a routine handoff.++User clarification: *"if there are instances where you truly need the human's input, you have to stop and ask for their help."* That is the escape valve, not a routine fallback.++## When handing off a click, ALWAYS watch for the resulting state change++**Never end a user-handoff with "tell me when you're done" / "say done when you've clicked it" / "let me know once it's checked."** That's a skill bug. The agent's job after a handoff is to **immediately set up a watcher** that detects the state change and auto-continues — the user shouldn't have to think about coordination, just click the thing.++**The pattern:**++1. Foreground the pup window with `browser_raise_os_window`.+2. Tell the user **what** to click in one short sentence (e.g., *"please click the reCAPTCHA 'I'm not a robot' checkbox"*).+3. **Immediately arm a Monitor** that polls for the post-click signal — typically a DOM state change (button enabled / checkbox checked / specific class added) or a network signal (zip lands in Downloads, popup tab appears, API response).+4. When the Monitor fires, auto-continue the flow. The user only ever did one click.++**Wrong (asking for verbal confirmation):**+> "Click the reCAPTCHA, then tell me 'done' and I'll click Submit."++**Right (watching):**+> "Click the reCAPTCHA. I'm watching the page state — will auto-Submit when the checkbox goes green."++```bash+# After user-handoff: poll for reCAPTCHA passed state+adom-desktop browser_eval "{\"sessionId\":\"$S\",\"expr\":\"!!(document.querySelector('[name=g-recaptcha-response]')?.value)\"}"+# returns true when user has passed → agent fires Submit+```++## Examples of state changes worth watching for++| Handoff trigger | Signal to watch for |+|----------------|---------------------|+| reCAPTCHA "I'm not a robot" | `[name=g-recaptcha-response]`'s value populates (non-empty string) |+| Sign-in / OAuth | URL changes back to the post-login redirect, OR a logged-in indicator (avatar) appears |+| Slide CAPTCHA | Same-origin redirect away from the verification page, OR a session cookie gets set |+| Multi-file download Allow dialog | Next download click actually lands a file in Downloads (the Allow only shows once per origin) |+| User picks a CAD format | Submit button becomes enabled / loses `disabled` attribute |+| User confirms an OS-level prompt | Request-following signal (file appears, page navigates, etc.) |++**Implementation hint** — Monitor with a poll loop:++```bash+adom-desktop browser_eval '...recaptcha-passed-check...' \+ | jq -r '.result' \+ | grep -q true && echo "PASSED"+```++Wrap that in a Monitor `until ... do sleep 2; done` and the agent knows the moment the user clicks.++Direct user quote: *"you're always supposed to watch. why did you not do that this time? is it not in the skill?"*++## When stuck on a vendor — show user + let them teach you++chip-fetcher is a community-built, recursively-learning skill. When the agent hits a dead end on a CAD/datasheet fetch:++1. **Open the product page in pup foreground** (`browser_raise_os_window`).+2. **Take a screenshot** so the user can see exactly what page state the agent landed on.+3. **Ask the user one short question:** *"I can't find a CAD download path on this page — can you spot it? Click the right button or describe what you see."*+4. **Wait for the user's hint** (they'll often say "click the CAE icon," "click the Documents tab," "the dimensional drawing has a STEP link," etc.).+5. **Implement the path the user described.**+6. **Capture the new pattern into this skill** with a vendor-specific section so the next agent doing the same vendor's part finds it automatically. Cite the user's verbatim hint as the rationale.++Examples accumulated this way:+- **WAGO Generate Datasheet popup auto-closes** — *"wago opens the datasheet in a new tab, so you could just see that tab and then save it locally"* → added "clicks may open in new tab" rule+- **Mouser sidebar has both Datasheet AND ECAD** — *"remember that mouser has these"* → added Mouser-as-first-class playbook+- **WAGO has a CAE icon that opens UL embedded** — *"if you click the CAE button it pulls up the wago UL download which has cad"* → added vendor.ultralibrarian.com/Wago/Embedded/?vdrPN=<MPN>+- **Cookie banners block clicks via z-index** — *"you're supposed to always accept their stupid cookie dialogs automatically"* → cookie-walker rule+- **Slide-CAPTCHAs can't be scripted** — *"are you able to drive that?"* → user-handoff escape valve+- **Profile-name churn destroys auth** — *"why did you not remember that so my profile loads again?"* → profile-name-is-sacred rule++Direct user quote on the meta-rule: *"our users can help build out this skill as they need specific parts. so you should always show them the product page when you think you're at a dead end and let them help you figure out how to get the cad just like i have several times. then figure it out from their feedback and add to the skill for all other users. we can then make them almost be like a recursively learning loop from everyone."*++**Operational template** when the agent hits a dead end:++```bash+# 1. Foreground the page+adom-desktop browser_raise_os_window '{"sessionId":"chip-fetcher"}'++# 2. Screenshot for the chat+adom-desktop browser_screenshot '{"sessionId":"chip-fetcher","maxWidth":1500}'++# 3. Update activity badge so the dashboard shows "stuck — needs user"+curl -X POST http://127.0.0.1:8786/api/activity -d '{"mpn":"<MPN>","stage":"stuck — need user help"}'++# 4. Tell the user what you tried and ask one specific question.+# GOOD: "WAGO product page has no .pdf anchor. The Generate Datasheet+# modal opens but I can't find the CAD link. What do I click?"+# BAD: "I couldn't find anything, what now?"++# 5. After the user answers, drive their suggested path AND+# update SKILL.md (or the relevant playbook) with the new vendor-specific pattern.+```++## EDA-agnostic terminology in user-facing UI++**Adom is EDA-agnostic — chip-fetcher must support KiCad, Fusion 360 Electronics, Altium, OrCAD, EAGLE, and others.** Files on disk can be `.kicad_sym` / `.kicad_mod` (since KiCad's open format is the de-facto interchange), but **never expose those filenames as labels in the viewer or in user-facing CLI output**. Translate to neutral EDA terminology:++| under-the-hood file | user-facing label |+|--------------------|--------------------|+| `*.kicad_sym` | "SYMBOL" |+| `*.kicad_mod` | "FOOTPRINT" |+| `*.step` | "STEP" *(industry-standard, no translation needed)* |+| `*.pdf` | "PDF" or "DOCS" *(both are vendor-neutral)* |++Direct user quote: *"don't refer to stuff as MOD if what you mean by that is a footprint … we want adom to be applicable to all EDA software like kicad, fusion 360, altium, orcad, etc. so we don't want to go too heavy on any kicad references. its fine that we're using mod files under the hood, but we don't have to reveal that."*++Apply this rule to:+- Card badges in the viewer+- Filter labels ("missing footprint" not "missing MOD")+- CLI output (`chip-fetcher status` should print "footprint" not "mod")+- Status messages and tooltips+- Skill documentation when describing user-visible behavior
playbooks/manufacturer-direct.mdadded+244@@ -0,0 +1,244 @@+# Manufacturer-direct (chip-fetcher playbook)++Per-vendor patterns for finding CAD + datasheets directly on the manufacturer's site. Read this when you've identified the manufacturer for an MPN and need to drive their specific page layout.++**All scraping in this playbook must run in pup `sessionId: "chip-fetcher"` (NOT the dashboard session) and must heartbeat every stage transition via `/api/activity` per [`dashboard-flow.md`](dashboard-flow.md).** Canonical stage labels: `probing <vendor>.com` → `found mfr-direct CAD` (or `no mfr-direct CAD — falling back to <source>`) → `downloading <format>` → `pulling from desktop` → `imported`.++## Confidence is not a substitute for the mfr probe++The rule isn't "check the manufacturer when uncertain." It's "always START at the manufacturer's site, even when 100% confident." Recurring violation pattern: AI recalls a known part family from training → queries Mouser to verify stock → recommends "want me to fetch this from SnapMagic?" — without ever opening `<mfr>.com`. **That is a direct skill-rule violation, even when the part really does exist on SnapMagic.**++Past direct user pushback (4+ repeats across sessions):+- *"i keep telling you to not go beyond the manufacturers website."*+- *"why do you keep not using the preference order i'm asking for?"*+- *"are you sure you couldn't navigate the lattice site? i feel like you give up too fast."*+- *"does renesas say to use 3dfindit.com? cuz if not, i don't like the idea."*++Some manufacturers self-host CAD that's better than the aggregators (Würth's UL-direct CAE icon, NXP's BXL bundles). Some have UL deep-links that are still mfr-direct because the link is on the mfr's page. Some don't host CAD at all and SnapMagic is the correct fallback. **You don't know which until you actually visit `<mfr>.com`.** 30 seconds of opening the page often changes the recommendation; pay the cost.++**Operational rule:** when a user asks for any chip / LED / connector / passive — even one you're 100% sure about — your FIRST action is `browser_open_window` on the manufacturer's product page. Walk Documentation, Tools & Software, CAD icon, Resources, Hardware Files. Scrape `<img alt>` for icon-only buttons. Only AFTER that probe is it OK to fall to SnapMagic / Mouser / DigiKey, and even then REPORT the mfr's state to the user before bouncing. Mouser stock-API queries can run in parallel as a side-channel, but they don't substitute for the mfr CAD probe.++## Self-hosting matrix++| Vendor | Self-hosts STEP | Self-hosts symbol/footprint | Datasheet hosting |+|-----------|-----------------|-------------------------------|-------------------|+| TI | no (UL only) | no (UL only) | direct, open URL |+| ST | no (UL only) | no (UL only) | in-page fetch (CDN blocks curl) |+| ADI | no (UL only) | no (UL only) | in-page fetch |+| **NXP** | no | **YES — BXL format** (mfr-direct) | direct PDF |+| Microchip | no (UL gated) | no (UL gated) | Microchip CDN |+| Renesas | no | partcommunity (separate flow) | direct PDF |+| Bosch | NO CAD AT ALL | NO CAD AT ALL | direct PDF |+| Syntiant | NO CAD AT ALL | NO CAD AT ALL | NDA-gated |+| **Espressif** | **YES via Mouser CSE Library Loader.** Both bare chips (ESP32-S3FN8) and modules (ESP32-S3-WROOM-1-N4) have full STEP+sym+footprint via mouser.componentsearchengine.com. | YES (same path) | **Open URLs at `www.espressif.com/sites/default/files/documentation/<mpn>_<doctype>_en.pdf`** — datasheet + TRM are huge (10–15 MB) and curl-able from container. |++For NXP specifically: BXL files are Accelerated Designs format — convert to KiCad via their Library Loader (Windows app) or treat as opaque mfr-authored asset.++## Vendor URL & UL deep-link patterns++Each manufacturer has its own product-page URL pattern, but most eventually link to a `vendor.ultralibrarian.com/<vendor-slug>/embedded` URL with a session-tied query string.++| Vendor | Product page URL | UL deep-link |+|---------------|-----------------------------------------------------------|-------------------------------------------------------------|+| **TI** | `ti.com/product/<MPN>` | `vendor.ultralibrarian.com/TI/embedded/?gpn=<MPN>&...` |+| **ST** | `st.com/en/<category>/<mpn>.html` | `vendor.ultralibrarian.com/stmicro/embedded?rpn=<MPN>&...` |+| **ADI** | `analog.com/en/products/<mpn>.html` | `vendor.ultralibrarian.com/adi/embedded?vdrPN=<MPN>` |+| **NXP** | `nxp.com/products/<MPN>` *(self-hosts BXL — no UL)* | n/a — direct `.bxl.zip` download |+| **Microchip** | `microchip.com/en-us/product/<mpn>` *(login-gated)* | `vendor.ultralibrarian.com/microchip?vdrpn=<MPN>` *(in iframe)* |+| **Renesas** | `renesas.com/en/products/<mpn>` | n/a — Renesas routes through CADENAS PartCommunity |+| **WAGO** | `wago.com/global/p/<MPN>` | `vendor.ultralibrarian.com/Wago/Embedded/?vdrPN=<MPN>` *(via "CAE" icon)* |++Deferred: **Renesas** (CADENAS PartCommunity has its own auth flow), **Bosch / TDK / Allegro / etc.** (no CAD on their sites; need UL login).++## TI — open everything++Datasheets are trivial — `curl https://www.ti.com/lit/gpn/<base-mpn>` returns the PDF directly. No CAPTCHA, no login. Batch a whole list in one shell loop. Use `base-mpn` (e.g. `tps62840`, not `tps62840dlcr`) — that's the lowercase chip-family slug.++## Microchip iframe gotcha++Microchip embeds the UL form in a **cross-origin iframe** at `vendor.ultralibrarian.com/microchip?vdrpn=<MPN>`. `browser_eval` from the parent (microchip.com) page **cannot reach into the iframe** (CSP). Workaround: navigate the entire pup tab to the iframe URL directly. Then the form is at top level and the standard form-driver works.++```bash+adom-desktop browser_navigate '{"sessionId":"chip-fetcher","url":"https://vendor.ultralibrarian.com/microchip?vdrpn=ATSAMD51J20A-AUT"}'+```++Also: Microchip CAD is gated behind myMicrochip login. First-time use, OAuth popup opens — sign in with Google (uses the shared pup-profile Google session) or email/password.++## NXP product-page detail (don't quit early!)++NXP product pages have **icon-only CAD buttons** next to the "Data Sheet" pill that are easy to miss in a text-based DOM scrape:++1. **Symbol/Footprint icon** (small green tile, schematic-looking) — links to NXP's `/downloads/en/symbols-footprints/<MPN>-<package>-BXL.zip` (mfr-direct, no auth)+2. **3D icon** (small green tile labeled "3D") — fires `loadPartDiv('NXP','<MPN>','nxp',...)` which renders a **componentsearchengine** (samacsys) overlay div directly on the NXP page. The 3D STEP file lives behind that widget.++To find them programmatically:+```js+// Symbol/footprint URL (direct):+const ecad = document.getElementById('ecad_cta'); // <a href="javascript:loadPartDiv(...)">+const iconSrc = ecad?.querySelector('img')?.src; // https://nxp.componentsearchengine.com/icon.php?...++// Trigger samacsys widget:+loadPartDiv('NXP','<MPN>','nxp',1,'zip',0,'','en-GB',0,'',0);+// Look for #mvOverlayDiv / #modalContainer / #mvBackdrop after this fires.+```++**Lesson learned the hard way:** when a vendor page seems to have "no CAD links," look for icon-only buttons via `<img>` `alt`/`src` attrs and parent `<a href="javascript:...">` triggers, not just text search. NXP and others bury the CAD entry behind a tiny styled icon.++**Don't navigate away from the vendor page** to chase guesses. The links you need are usually on the original page, just hidden behind an icon, accordion, or async-loaded section. Scroll, click tabs, expand dropdowns, inspect images.++### NXP login + EXTENDED PROFILE gotcha++- **NXP** open without auth: datasheet (`/docs/en/data-sheet/<id>.pdf`), block diagram (`/assets/block-diagram/...`), errata (`/docs/en/errata/...`), factsheet (`/docs/en/fact-sheet/...`), application notes (`/docs/en/application-note/AN<id>.pdf`), symbol/footprint BXL (`/downloads/en/symbols-footprints/<MPN>-<package>-BXL.zip`).+- **NXP** requires login: Reference Manual, Security RM, Hardware Design Guide, FRDM/EVK board user manuals — accessed via `/webapp/Download?colCode=<id>` which redirects to `login.nxp.com` (Azure B2C). Sign in with the user's NXP account, then re-fetch.+- **NXP requires an EXTENDED PROFILE** (Tell-us-about-yourself, business title, job description, company primary business, address) before unlocking Reference Manuals + Hardware Design Guides + Board User Manuals. Detect the redirect to `/webapp-signup/updateUser?userType=EXTENDED&mandatory=YES&TARGETURL=...` after a `colCode=` Download attempt and direct the user to fill that form (one-time per account). After submission, the original `colCode=` URL serves the PDF.++## Vishay — manufacturer-direct CAD via Design Tools page (verified 2026-05-03)++Vishay self-hosts 3D models as `.zip` files on their **Design Tools** page — full mfr-direct, no UL/CSE/SnapMagic bounce required. **This is the cleanest provenance tier.** Discovered by John pointing me at the page after I missed it on first walk.++### Design Tools URL pattern++```+https://www.vishay.com/en/how/design-support-tools/?category=<NN>+```++Filter UI on the page lets you set:+- **Design Tool Type** = `3D Model` (or `Symbol/Footprint`, `SPICE Model`, etc.)+- **Product Category** = `LEDs`, `Resistors`, `Capacitors`, `Diodes`, `Optoelectronics`, etc. (drives `?category=`)++The result is a sortable table with rows like:+| Design Tool Type | Description | Product Category | Share |+|------------------|-------------|------------------|-------|+| 3D Model | SMD 0402 ChipLED (*.zip) | LEDs | 🔗 |+| 3D Model | ChipLED Highbright 0603 VLMB1300 (*.zip) | LEDs | 🔗 |++Each row's `<a href="*.zip">` is a direct download — `https://www.vishay.com/docs/<doc-id>/<filename>.zip`.++### Family-level CAD (Vishay's strategy)++**Vishay publishes ONE zip per package family, not per MPN.** Example: `vlmx15xx.zip` covers the entire VLMx15xx 0402 ChipLED family — Super Red (VLMS1500), True Green (VLMTG1500), Blue (VLMB1500), White (VLMW1500), Soft Orange (VLMO1500), Yellow (VLMY1500), and others all share the same physical SMD 0402 body, so one STEP file applies to all of them. Per-part differences (color, wavelength, Vf) come from the datasheet, not the 3D model.++**Importer implication:** when fetching from Vishay's family-level zip, distribute the extracted STEP to EACH MPN's `library/<MPN>/<MPN>.step` directory. Tag `info.json` with:+```json+{+ "step_source": "mfr-direct",+ "step_authoring": "mfr-uploaded",+ "step_url": "https://www.vishay.com/docs/<doc-id>/<family>.zip",+ "step_note": "Family-level package body — covers all <FAMILY>xxxx parts. Color/wavelength/Vf differs per part; consult datasheet."+}+```++This pattern likely applies to other passive makers too (Würth, Murata, Yageo) — assume family-level until proven otherwise.++### Vishay naming conventions for indicator LEDs++| Code | Color |+|------|-------|+| `VLMS` | Super Red |+| `VLMB` | Blue |+| `VLMG` | Yellow Green |+| `VLMTG` | True Green |+| `VLMO` | Soft Orange (Vishay's amber-equivalent) |+| `VLMY` | Yellow |+| `VLMW` | White |++Series digits indicate package size:+- `1500` = SMD 0402 ChipLED Standard family+- `1300` = 0603 family+- `1400` = larger SMD or different brightness tier++**Distributor packaging suffix vs MPN:** the bare manufacturer MPN is `VLMS1500`. The `-GS08` suffix on Mouser/DigiKey listings is the **distributor's packaging code** (tape and reel options), NOT part of Vishay's MPN. Use the bare MPN as the library directory name. Mouser stock-API queries can use either form (`VLMS1500` works; `VLMS1500-GS08` resolves to specific reel SKU).++### What Vishay does NOT host++The Design Tools page filtered to "Symbol/Footprint" for LEDs returns very limited results — Vishay does not appear to publish KiCad/Altium symbols + footprints for indicator LEDs as cleanly as the 3D models. **Confirm by visiting individual product detail pages too** (sometimes vendors publish sym/mod separately on the part page rather than in the design-tools index).++If Vishay's part page also lacks sym/mod, the canonical fallback is:++1. **Mouser product page → "ECAD Model" link** (routes into `mouser.componentsearchengine.com/...` and serves a `LIB_<MPN>.zip` with `.step` + `.kicad_sym` + `.kicad_mod`).+2. The Mouser CSE bundle's STEP is mfr-uploaded too, but **Vishay's direct STEP is more accurate** — keep Vishay's STEP, take only the sym/mod from the CSE zip.+3. This is the "mfr-direct STEP + distributor-CSE sym/mod" hybrid pattern — tag `info.json`'s `sym_source` and `mod_source` separately from `step_source`:++```json+{+ "step_source": "mfr-direct", "step_url": "https://www.vishay.com/docs/.../...zip",+ "sym_source": "mouser-ecad-model", "sym_url": "https://mouser.componentsearchengine.com/...",+ "mod_source": "mouser-ecad-model", "mod_url": "https://mouser.componentsearchengine.com/..."+}+```++Direct user direction during the verifying session: *"if you fail [on Vishay sym/mod], then it would seem you should try to then find that product on mouser and use their symbol/footprint and we'll join the two up. i love that you found the step though cuz i want the most accurate part 3d files."*++**Translation:** mfr-direct STEP is non-negotiable for accuracy. Sym/mod can come from the Mouser-CSE fallback because they're geometric (pad placement is a geometric fact, not a wavelength curve) and CSE's mfr-uploaded copies are good enough.++---++## Lattice / iCE40 family — Documentation tab has 30+ PDFs++iCE40 UltraPlus has a Documentation tab with 30+ PDFs (datasheet, hardware checklist, design guidelines, programming guide, all the IP usage guides, package diagrams, PCB layout, solder reflow, thermal management) — all at `https://www.latticesemi.com/view_document?document_id=<N>`. None of these are in the `<chip-page>/links` scrape — you have to click the Documentation tab to see them all listed.++User pushback on bailing too quickly: *"are you sure you couldn't navigate the lattice site? i feel like you give up too fast."* **Always click the Documentation tab and scrape ALL `view_document` URLs you find.**++## Sources have rich scene graphs (TI verified)++Confirmed across 8 TI parts grabbed via UL — UL ships **manufacturer-grade B-Rep STEPs with named PRODUCTs**:++| MPN | Package | PRODUCTs (named) | Faces | Cyl | Sph |+|---|---|---|---|---|---|+| TPS62840YBGR | DSBGA-6 | `product` | 15 | 1 | 6 |+| INA226AIDGSR | VSSOP-10 | `BODY-SO`, `LEAD-SO`, `PIN1-ID`, `DGS0010A_ASM` | 64 | 24 | 8 |+| LM358APE4 | PDIP-8 | `BODY_P0008A`, `LEAD_P0008A`, `LD_LHALF`, `LD_RHALF`, `ASM` | 90 | 30 | 8 |+| DRV8316RRGFR | VQFN-40 | `FRAME`, `BODY`, `QFN_ASM` | 635 | 160 | 0 |+| MCF8316A1RRYR | VQFN-40 | `FRAME`, `BODY`, `QFN_ASM` | 635 | 160 | 0 |++These are exactly the named-scene-graph signals adom-chiplinter Mode 0 needs.++## Manufacturer "reference-layout" zips ≠ standalone chip CAD++When a manufacturer publishes a `<MPN>-reference-layout-<ver>.zip`, it's a full board-design bundle, NOT a standalone chip CAD. Verified on Nordic's nRF52840-CKAA portal: the 18 MB zip contains:++- `Altium Designer files/` — `.IntLib`, `.PcbDoc`, `.PrjPCB`, `.SchDoc` (6 board variants)+- `Bill of Materials/` — per-variant `.xls` files+- `Production files/` — Gerbers, drill (`.DRL`)+- `Schematic and Layout pdf files/` — board-level PDFs++**Where it goes:** keep in `library/<MPN>/reference-layout/<...preserve subdirs...>` as a separate artifact bucket. Don't try to extract STEP/sym/mod from the Altium files automatically. Surface it in the viewer as "Reference layout: <N> BOMs · <N> Altium variants · <N> Gerbers · <N> PDFs."++**This bundle does NOT fill chip-fetcher's `<MPN>.step` / `<MPN>.kicad_sym` / `<MPN>.kicad_mod` slots.** Mark the chip as still stub-on-CAD in the viewer when only reference-layout exists.++## When stuck on a vendor: show user + let them teach you++chip-fetcher is community-built and recursively-learning. When the agent hits a dead end:++1. **Open the product page in pup foreground** (`browser_raise_os_window`).+2. **Take a screenshot** so the user can see what page state the agent landed on.+3. **Ask one short question:** *"I can't find a CAD download path on this page — can you spot it? Click the right button or describe what you see."*+4. **Wait for the user's hint.**+5. **Implement the path the user described.**+6. **Capture the new pattern back into this skill** with a vendor-specific section so the next agent doing the same vendor's part finds it automatically.++Direct user quote: *"our users can help build out this skill as they need specific parts. so you should always show them the product page when you think you're at a dead end and let them help you figure out how to get the cad just like i have several times. then figure it out from their feedback and add to the skill."*++Failure modes pushed back on:+- Went to 3dfindit.com because Renesas didn't surface CAD on the chip page → user: *"does renesas say to use 3dfindit.com? cuz if not, i don't like the idea of using it."*+- Jumped to Mouser CSE for the iCE40UP5K STEP without exhausting Lattice → user: *"are you sure you couldn't navigate the lattice site? i feel like you give up too fast."*+- Tried to use SnapMagic for BME688 → user: *"those other services like snapmagic don't have good 3d files."*++## Grab ALL design-help PDFs while you're on a vendor page++Datasheet alone isn't enough for an EE: also pull errata, hardware design guides, app notes, reference designs, anti-counterfeit guides, package mechanical drawings, IBIS models. EEs need the complete doc set. **Save each as `library/<MPN>/<MPN>-<doc-type>.pdf`** (e.g. `MCXN947VNLT-errata.pdf`, `MCXN947VNLT-hwguide.pdf`, `MCXN947VNLT-appnote-spi.pdf`). Don't pop in to grab just the STEP and leave — every page visit is an opportunity to harvest the full design package.++## Provenance tag in info.json++Always tag the source so downstream tools know the trust level:++```json+{+ "step_source": "mfr-direct" | "mfr-via-UL" | "mfr-via-CSE" | "mouser-ecad-model" | "mouser-cse-fallback" | "snapmagic" | "ul-main-search",+ "step_authoring": "mfr-uploaded" | "third-party",+ "verified_by": "FILE_NAME header / SamacSys preview / etc.",+ "source_url": "<the URL the file came from>"+}+```++Trust order: `mfr-direct` > `mfr-via-UL` > `mfr-via-CSE` > `mouser-ecad-model` > `mouser-cse-fallback` > `snapmagic` > `ul-main-search`. Anything tagged `"snapmagic"` or `"third-party"` gets demoted by adom-chiplinter's test-corpus weighting.
playbooks/mouser-cse.mdadded+149@@ -0,0 +1,149 @@+# Mouser + Component Search Engine (chip-fetcher playbook)++Mouser is a first-class playbook (NOT a fallback tier) — it covers the long tail of chips without dedicated mfr CAD pages. Read this when manufacturer-direct doesn't host CAD, OR when you need stock + pricing + ECAD in one go.++**Heartbeat every stage** per [`dashboard-flow.md`](dashboard-flow.md). The slide-CAPTCHA escalation is a particularly important moment to heartbeat: post `mouser slide-CAPTCHA — user help needed` so the dashboard surfaces the stuck state, then raise the scraping window with `browser_raise_os_window` and let the user drag the slider. Don't silently wait — the user should see on the dashboard that we're stuck on Mouser.++## Mouser CSE Library Loader — the universal fallback (don't miss this)++**Critical pattern:** when a manufacturer has no CSE portal of their own (`<mfr>.componentsearchengine.com` returns `DNS_PROBE_FINISHED_NXDOMAIN`) AND no UL deep-link AND nothing on the mfr's own site, **check Mouser CSE**.++Pattern: `https://mouser.componentsearchengine.com/entry_u_newDesign.php?mna=<MFR>&mpn=<MPN>&pna=mouser&vrq=multi&fmt=zip&o3=0&lang=en-GB`++Mouser proxies CSE for any chip in their catalog, not just chips whose manufacturer paid for a dedicated CSE portal. It hits the same SamacSys library backend, returns the same mfr-uploaded STEP/symbol/footprint, with the same single CSE login.++### ⚠ TWO traps that make `entry_u_newDesign.php` return NO partID (both verified 2026-06-23, TI ADCs)++The entry endpoint returns one of three pages. Only one has a `partID`:+- **"SamacSys Part Preview"** (len ~50KB) → contains `partID=<N>`. ✅ This is what you want.+- **"SamacSys Part Selector" / "Pick One"** (len ~8–21KB) → a disambiguation page, **no partID**. ❌++Two independent causes push you onto the no-partID pages:++1. **Use the ORDERABLE MPN, not the base part.** `mpn=ADS1263` → "Pick One" (the base part has many package variants, so CSE can't resolve one). `mpn=ADS1263IPWR` (orderable, with package suffix) → "Part Preview", partID 755745. **Always pass the orderable MPN with its package/reel suffix.** If you only have the base MPN, a distributor parametric lookup (`adom-mouser`/`adom-digikey`) gives the orderable variant, or try the common suffix (TSSOP `…IPWR`, QFN `…IRTER`).++2. **Do NOT append `&logo=&epm=0` to the entry URL.** Those two extra params silently flip CSE into the "Part Selector" page (no partID) **even for a valid orderable MPN**. Use the minimal param set above. This cost an afternoon of "no-partID" debugging — the bug was a malformed URL, not the chip or the browser.++### `chip-fetcher cse <MPN> --mfr "<Manufacturer>"` — the automated path (use this first)++Does the whole CSE chain in code: navigate clean → entry (orderable MPN) → partID → preview → zipForm → `ga/model.php` → import STEP+symbol+footprint. Retries the cold-call flake and keeps the dashboard "working on" bar live via a heartbeat ticker. Prefer it over hand-driving URLs.++### How the download works — same-origin XHR + Basic-auth header (the breakthrough)++CSE downloads are HTTP Basic Auth. Inline-cred URLs (`https://user:pass@host/…`) are REJECTED by `fetch()`, and a plain navigate pops Chrome's invisible Basic-auth dialog. **The robust technique: a same-origin `XMLHttpRequest` (from a tab already on `mouser.componentsearchengine.com`) with `xhr.setRequestHeader('Authorization','Basic '+btoa(user+':'+pass))`.** XHR (unlike fetch) accepts the Authorization header AND pulls the bytes into JS as an arraybuffer — so the zip never hits Chrome's download policy (which silently blocks the 2nd+ download). This is what `cse_fetch_zip` in `pup.rs` does. **Vault-auth works in ANY browser** — the auth is the XHR header from chip-fetcher's vault, not a browser cookie, so CSE runs fine in the user's Chrome even if their saved CSE login lives in Edge.++**Real example:** Espressif has no CSE portal. Probing `espressif.componentsearchengine.com` gave NXDOMAIN. UL portal `vendor.ultralibrarian.com/espressif/embedded` gave "No Access is defined." But Mouser's product page for ESP32-S3FN8 had an "ECAD Model" block linking to `mouser.componentsearchengine.com/...` — and that path served a complete bundle. Same trick worked for the ESP32-S3-WROOM-1-N4 module.++## ⚠ HARD RULE — CSE downloads need credentials embedded in the URL++**Before you click any "Download CAD Models" button on `*.componentsearchengine.com`, embed credentials inline.** The `/signin` form-login session cookie is USELESS for downloads — those endpoints issue a separate HTTP Basic Auth challenge that pops a native Chrome dialog **invisible to `browser_eval`**. Symptom: page lands on `chrome-error://chromewebdata/`, no file in Downloads, nothing in DOM to indicate why. (Verified painful 2026-05-04.)++```bash+# Pull creds from chip-fetcher's vault, urlencode, prepend to URL:+CRED=$(curl -s http://127.0.0.1:8786/api/credentials | python3 -c '+import sys,json,urllib.parse+d=json.load(sys.stdin)+c=next((x for x in d["credentials"] if "componentsearchengine" in x["host"]), None)+if c: print(f"{urllib.parse.quote(c[\"username\"],safe=\"\")}:{urllib.parse.quote(c[\"password\"],safe=\"\")}")+')+URL="https://${CRED}@mouser.componentsearchengine.com/preview_newDesign.php?o3=0&partID=<ID>&ev=0&fmt=zip&pna=Mouser"+adom-desktop browser_navigate "{\"sessionId\":\"chip-fetcher\",\"url\":\"$URL\"}"+```++Always urlencode the username (`@` → `%40`) and password — special chars are common in CSE passwords. **Use this pattern by default for every CSE click that triggers a download.** Full diagnostic, OS-keychain alternative, and the two-vault sync requirement: see [`credentials-and-logins.md`](credentials-and-logins.md) § HARD RULE — CSE downloads use HTTP Basic Auth.++**Discovery flow when you can't find CAD on a vendor's site:**+1. Use `adom-mouser part <MPN>` to get the Mouser product URL.+2. Navigate pup to that Mouser page.+3. Look for "ECAD Model" / "Library Loader" link/text on the product page.+4. Follow the CSE URL into `mouser.componentsearchengine.com/...` directly.+5. Drive the standard CSE Download flow (same as NXP CSE).++## Mouser — first-class playbook++**Mouser is the most important external resource chip-fetcher has after the manufacturer's own site.** It hosts the datasheet AND the ECAD bundle for nearly every chip in their catalog — and for the long tail of chips whose manufacturers don't publish CAD (Vishay passives, niche connectors, smaller-vendor SoCs), Mouser is the *only* source.++Direct user quote: *"mouser is a key resource here so we have to get into the skill how to work with their website."*++**What's on every Mouser product page (right sidebar):**++- **`Datasheet:`** block — a `<MPN> Datasheet (PDF)` link. Either Mouser-CDN-hosted (`https://www.mouser.com/datasheet/<…>.pdf`) or a redirect to the manufacturer's CDN. Behind Akamai's bot wall, so use the in-page `fetch()` pattern, not naked curl.+- **`ECAD Model:`** block — `PCB Symbol, Footprint & 3D Model` link. Routes into `mouser.componentsearchengine.com/...` and serves a `LIB_<MPN>.zip` with `.step` + `.kicad_sym` + `.kicad_mod`.+- Extra reference docs (app notes, training PDFs) listed as additional `<a href="*.pdf">` rows further down — scrape them all.++## Operating manual — Mouser++1. **Always go through pup Chrome** — never naked curl. Their CDN serves a 174 KB HTML denial page (`"Access to this page has been denied because we believe you are using automation tools"`) for any non-browser request.+2. **Use the auto-cookie-accept helper** on every navigation — Mouser's cookie banner blocks downstream clicks via z-index.+3. **Use in-page `fetch()` with `{credentials:"include"}`** to pull PDFs (Chrome session cookies + JA3 fingerprint pass Akamai). Decode base64 result back into the library. `cf-mouser-pdf-pull.sh` is the reference implementation.+4. **For ECAD bundles, drive the full CSE Download flow inside the pup window.** Mouser-hosted CSE shares the same single-sign-on cookie as `componentsearchengine.com` proper, so once the user is signed in there, every Mouser ECAD link works without re-auth.+5. **Mouser's Search API (`adom-mouser`) gives you the canonical product URL + `datasheet_url` field for every variant.** Walk all variants — the version with the populated `datasheet_url` is sometimes a different package SKU than the MPN you searched for.++## Mouser's slide-to-verify CAPTCHA++After ~5–10 automated requests in a session, Mouser shows a slide-to-verify CAPTCHA. **Stop and ask the user to drag the slider.** Use `browser_raise_os_window` + `browser_screenshot` to surface the obstacle, then say: *"Mouser hit me with a slide-CAPTCHA — please drag the slider on the pup window to clear it."* Resume the flow once the CAPTCHA clears.++**DO NOT pivot to a different source for ECAD bundles** — there is no other source for those. Direct user pushback: *"that was a good test case … you can't just abandon the website if that's the only way to get the step file and symbol/footprint and the datasheet."*++**Cooldown:** Mouser's verification token persists for 30+ minutes after the user clears the slider. Batch your Mouser fetches during that window so the user only ever has to slide once per session.++**Critical caveat:** Mouser's CDN is Akamai-protected and rejects naked `curl` (returns 200 OK with a "Access to this page has been denied" HTML page). To pull a Mouser-hosted PDF you must either (a) navigate pup Chrome there and trigger a download (file lands in `~/Downloads`, then `chip-fetcher pull`), or (b) use in-page `fetch()` via `browser_eval` with `credentials:"include"` — Chrome's session cookies pass Akamai's bot check and the response can be base64-encoded back through `awaitPromise:true`.++## When the slider hits — escalation order++1. **Try `browser_input_dispatch type:"move"` along the slider track followed by `type:"click"` at the right edge.** Trusted CDP input has `isTrusted: true` and may pass GeeTest's basic acceleration check; some Mouser slider variants accept it.+2. **Pivot to vendor-direct ONLY if Mouser is fungible for this chip's docs.** TI lit IDs, ST `/resource/`, NXP `/docs/` — there are non-Mouser paths for documentation. Not for ECAD bundles.+3. **For ECAD bundles, there is no fungible path.** Hand off via `desktop_open_url` to the user's native browser (real Chrome with browsing-history reputation, extensions, real input). They slide once, the CAPTCHA token persists 30+ minutes, then the rest of the session works through pup again.+4. **Don't silently abandon a website if it's the only path.**++## CSE auth — single login covers all `<mfr>.componentsearchengine.com` subdomains++Sign in once at `https://componentsearchengine.com/signin` with the user's email + password, and the cookie covers every per-vendor subdomain (nxp, mouser, etc.). On first visit to a subdomain, an HTTP **Basic Auth** popup may appear once — it accepts the same CSE creds. After that, downloads on subdomain pages work.++The CSE STEP files we get for NXP/Mouser CSE are **mfr-authored, FreeCAD-exported** with named PRODUCTs (`ASSEMBLY`, `Body`, `ThermalPin`, `PinsArrayLR`, `PinsArrayTB`) — same authoring pipeline as TI's Fusion-electronics exports. So CSE-distributed STEPs match UL-distributed STEPs in scene-graph quality.++**Filename pattern: CSE downloads land as `LIB_<MPN>.zip` (vs UL's `ul_<MPN>.zip`).** The `cf-autowatch.sh` script's filename match needs to handle both — regex is `^(ul_|LIB_).*\.zip$`.++## Chrome's "wants to Download multiple files" SILENT KILLER++When CSE fires a download, Chrome detects multiple downloads (symbol + footprint + 3D + datasheet often arrive as separate transfers) and shows an Allow/Block dialog the first time per origin per session. **Programmatic `.click()` and `MouseEvent` dispatch CANNOT click Allow** — it's a Chromium browser-chrome dialog, not a page element.++Symptom: download button "works" (no error, page doesn't change), but `~/Downloads` stays empty for minutes.++**Cure: pre-grant the `automatic_downloads` permission to `*.componentsearchengine.com` BEFORE opening the page.** Two known approaches:+- (a) use Chrome DevTools Protocol `Browser.grantPermissions` via pup before the first navigation+- (b) launch the pup profile with `--auto-open-devtools-for-tabs` plus a setup script that flips the permission++**Do NOT ask the user to click Allow** — see "chip-fetcher does ALL the clicking" rule. Cloudflare/CSE reject curl-with-cookies attempts (HTTP 403 → CF anti-bot challenge → redirect to `/register`), so Chrome inside pup is the only viable path. Once the permission is granted at profile-load, all CSE clicks in that session run unattended.++(`chip-fetcher/scripts/adom-desktop-chrome-permissions-prompt.md` is the open feature request to bake `automatic_downloads: granted` into every pup `browser_open_window` by default.)++## Anti-detection / browse as a real user++Even when you go through pup Chrome, sites still profile *behavior*. Burst-clicking 5 product pages in 30 seconds with no scrolling, no mouse-movement, no referrer chain looks like a bot regardless of TLS fingerprint. The remedies, applied always:++1. **Pre-warm with a referrer chain.** Don't deep-link straight to a product page. Visit `<vendor>.com/` first, wait 3 seconds, then click into a search → results → product. Anti-bot heuristics weight `Referer` chains heavily.+2. **Add 2–5 second random delays between navigations** (`sleep $((2 + RANDOM % 4))`).+3. **Scroll the page after load.** Even one `window.scrollTo({top:600,behavior:'smooth'})` then a pause then `scrollTo({top:1400})` shifts behavioral profile out of bot territory and triggers lazy-loaded content.+4. **Move the mouse.** A few `dispatchEvent(new MouseEvent('mousemove', {clientX:..., clientY:..., bubbles:true}))` calls between clicks help.+5. **Don't fetch the same domain in tight bursts.** When backfilling 14 chips, space them ≥4 seconds apart and reload one tab — don't open 14 tabs at once.+6. **Use a single persistent profile (`chip-fetcher`)** so cookies, fingerprint, and visit history accumulate.+7. **Detection escape valve:** if a site still 403s despite all of the above, slow down and ask the user to slide whatever CAPTCHA appears.++## Always browse as a real user++**Every PDF fetch goes through pup Chrome with a real session, not naked `curl`.** Vendor CDNs (Mouser/Akamai, Cloudflare, ST/Akamai, Nordic/Cloudflare, NXP/Cloudflare) actively profile traffic for "automation tools" — TLS fingerprint, header order, missing `sec-ch-ua`/`sec-fetch-*`, no JS execution, no cookies, no referrer chain.++**The rule: every fetch flows through pup.** Use one of these in priority order:++1. **In-page `fetch()` via `browser_eval`** with `{credentials:"include"}` and `awaitPromise:true`, base64-encoding the response body back through the JSON channel.+2. **Programmatic `<a>` click in the rendered page** — Chrome handles the download natively, file lands in `~/Downloads`, then `chip-fetcher pull <path>`.+3. **Naked `curl` ONLY for vendor-doc CDNs that are confirmed not behind a bot wall** — TI's `ti.com/lit/pdf/<id>` is one of the few that works. **Always validate by checking the first 4 bytes:**+ ```bash+ head -c 4 "$OUT" | grep -q "%PDF" || { echo "not a real PDF"; rm -f "$OUT"; }+ ```+ If the magic check fails, the vendor served a denial page. Re-route through pup.++**Never write a backfill script that does `curl ... > out.pdf` without the `%PDF` magic-byte gate.** A 174KB HTML denial page mis-stored as a `.pdf` is worse than a missing file.++Direct user pushback: *"make sure we never get errors like this. meaning always browse as a real user would."*
playbooks/snapmagic.mdadded+146@@ -0,0 +1,146 @@+# SnapEDA / SnapMagic (chip-fetcher playbook)++Verified end-to-end 2026-05-03 on nRF52840-CKAA-R7 — KiCad + Altium + Fusion in one session. Read this when the manufacturer doesn't host CAD directly (Nordic, niche connectors, passives, smaller-vendor MCUs).++**SnapEDA is a TIER 2 fallback — only use it AFTER confirming the manufacturer doesn't host CAD on their own site.** When falling back, post a `no mfr-direct CAD — falling back to SnapEDA` heartbeat so the user sees on the dashboard that we're leaving manufacturer-direct (per [`dashboard-flow.md`](dashboard-flow.md)). All format-cycling stages (kicad → altium → fusion) get their own heartbeats: `setting preferred format altium`, `clicking download symbol+footprint`, `dismissing heads-up`, `pulling altium IntLib`, etc.++## What it is++**SnapEDA is `snapeda.com` (host) but rebranded to "SnapMagic Search" in the UI.** Free CAD downloads (symbol + footprint + STEP + 3D + Altium/KiCad/Eagle/OrCAD/PADS/TARGET/Allegro) for millions of parts. **Account required for downloads.** Most embedded engineers already have one — ASK first per the credentials playbook.++**Where SnapEDA shines:** parts that aren't in CSE/UL — Nordic (no UL deep-link), niche connectors, passives, smaller-vendor MCUs, anything where the manufacturer doesn't publish CAD directly.++## Login flow (verified working)++```bash+# 1. Navigate to login page+adom-desktop browser_navigate '{"sessionId":"chip-fetcher","url":"https://www.snapeda.com/account/login/"}'+sleep 5++# 2. Fill credentials + form.submit() (NOT button click — the "Log In" looks like an <a> but+# button-click doesn't fire the real handler; only form.submit() works)+adom-desktop browser_eval '{"sessionId":"chip-fetcher","expr":"(()=>{const u=document.querySelector(\"input[name=username]\"); const p=document.querySelector(\"input[type=password]\"); const proto=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,\"value\"); proto.set.call(u,\"<email>\"); proto.set.call(p,\"<password>\"); [\"input\",\"change\",\"blur\"].forEach(t=>{u.dispatchEvent(new Event(t,{bubbles:true})); p.dispatchEvent(new Event(t,{bubbles:true}));}); u.closest(\"form\").submit(); return \"submitted\";})()"}'++# 3. Wait for redirect away from /account/login/ — typically lands on /home/+```++**Critical: SnapEDA's "Log in" button does NOT submit the form via click.** Use `form.submit()` directly. Verified painful pattern when button-click wasted ~30 seconds before figuring this out.++## Search + part-page flow++URL pattern: `https://www.snapeda.com/parts/<MPN>/<Manufacturer>/view-part/`. The Manufacturer slug is URL-encoded (`Nordic%20Semiconductors` for Nordic parts).++To search: `https://www.snapeda.com/search/?q=<MPN>` returns a list of matching variants.++## Download flow — KEY INSIGHT: format is gated by user account preference++The part page has three tabs: **"Symbol and Footprint"**, **"3D Model"**, **"Alternates"**. The "Symbol and Footprint" tab has TWO orange buttons side by side: full bundle (`Download Symbol and Footprint`) and footprint-only (`Download Footprint`).++**3D Model (STEP)** — direct, no format picker:+```bash+adom-desktop browser_eval '{"sessionId":"chip-fetcher","expr":"document.getElementById(\"download_step_model\").click()"}'+# Lands as <MPN>--3DModel-STEP-<id>.STEP. Always raw STEP regardless of preferred-format.+```++**Symbol + Footprint** is gated by a **per-account "Preferred ECAD format"** at `https://www.snapeda.com/account/email/`. Two settings drive the orange-button behavior:++1. **Preferred ECAD format** (radio `preferred_2d_format`): one of `altium_native`, `kicad_modv6`, `kicad_modv4`, `kicad_modv3`, `eagle`, `fusion_360`, `orcad_v17`, `orcad_v16`, `pads`, `diptrace`, `designspark`, `cr_8000`, `circuit_studio`, `ecadstar`, `easypc`, `express_pcb_plus`.+2. **Direct download toggle** (ON = skip the format-picker modal and go straight to the preferred format)++When toggle is OFF (the default), clicking the orange "Download Symbol and Footprint" opens a `Choose Download Format` modal showing ALL formats, with the preferred at the top. When toggle is ON, the modal is skipped — except for KiCad which still asks "V3 / V4 / V6 & later" version variant.++## Switching format programmatically++Clicking the radio auto-saves via AJAX (no submit button needed). Verified by header text updating to "Direct download - Preferred format: Altium":++```bash+# Set Altium as preferred (auto-saves on click)+adom-desktop browser_navigate '{"sessionId":"chip-fetcher","url":"https://www.snapeda.com/account/email/"}'+sleep 4+adom-desktop browser_eval '{"sessionId":"chip-fetcher","expr":"document.querySelector(\"input[name=preferred_2d_format][value=altium_native]\").click()"}'+# Then go back to part page — orange button now serves Altium.+```++**Always restore the preferred format to whatever the user had originally** (typically `kicad_modv6`) at the end of the session — don't leave the account changed.++## The "Heads Up" warning++For parts with multiple symbol sections, a small modal interrupts the download with *"This part contains multiple symbol sections. View the sections to see all pinouts."* Click the orange "Ok, download this part" button to proceed:++```bash+adom-desktop browser_eval '{"sessionId":"chip-fetcher","expr":"(()=>{const b=Array.from(document.querySelectorAll(\"a, button\")).find(e=>e.offsetParent!==null && /ok.*download this part/i.test((e.textContent||\"\").trim())); if(b){b.click(); return \"clicked\";} return \"no heads-up modal\";})()"}'+```++This is benign — just a "did you know there are multiple sections" notification. Always click OK.++## Full proven 3-format sequence++```bash+PART_URL="https://www.snapeda.com/parts/<MPN>/<Manufacturer>/view-part/"++fetch_format() {+ local FORMAT="$1" # altium_native | kicad_modv6 | fusion_360 | eagle | etc.+ # 1. Set preference (auto-saves)+ adom-desktop browser_navigate '{"sessionId":"chip-fetcher","url":"https://www.snapeda.com/account/email/"}' >/dev/null+ sleep 4+ adom-desktop browser_eval "{\"sessionId\":\"chip-fetcher\",\"expr\":\"document.querySelector('input[name=preferred_2d_format][value=$FORMAT]').click()\"}" >/dev/null+ sleep 1+ # 2. Back to part page+ adom-desktop browser_navigate "{\"sessionId\":\"chip-fetcher\",\"url\":\"$PART_URL\"}" >/dev/null+ sleep 5+ # 3. Click orange Download (use coords from canvas-main-btn rect, typically near (548, 704))+ adom-desktop browser_eval '{"sessionId":"chip-fetcher","expr":"(()=>{const b=Array.from(document.querySelectorAll(\"a.canvas-main-btn, a.modal-trigger\")).find(e=>e.offsetParent!==null && /download symbol and footprint/i.test((e.textContent||\"\").trim())); if(!b)return\"no btn\"; const r=b.getBoundingClientRect(); return JSON.stringify({x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)});})()"}'+ # ...trusted-click those coords...+ # 4. Modal opens: click the format button by visible text+ # 5. KiCad-only: pick V6 & Later. All formats: dismiss "Heads Up" → click "Ok, download this part"+ # 6. Watch Downloads for new file+}++fetch_format kicad_modv6 # → <MPN>.zip (contains .kicad_sym + .kicad_mod)+fetch_format altium_native # → <MPN>.IntLib (raw IntLib, NOT zipped)+fetch_format fusion_360 # → <MPN>.lbr (Eagle .lbr — Fusion eats Eagle natively)+```++## Verified filenames in user Downloads++No MPN-Format suffix, just `<MPN>` + native extension:++| Format radio | Filename in Downloads | What's inside |+|--------------|----------------------|---------------|+| `kicad_modv6` (after V6 & Later picker) | `<MPN>.zip` | `<MPN>.kicad_sym` + `<MPN>.kicad_mod` |+| `kicad_modv4`, `kicad_modv3` | `<MPN>.zip` | older-format KiCad files |+| `altium_native` | `<MPN>.IntLib` | binary Altium Integrated Library, not zipped |+| `fusion_360` | `<MPN>.lbr` | Eagle .lbr (Fusion 360 imports Eagle) |+| `eagle` | `<MPN>.lbr` | identical content to Fusion in many cases |+| `orcad_v17` / `orcad_v16` | `<MPN>.zip` | OrCAD/Allegro library |++**Repeat-download collisions:** if the user already has `<MPN>.lbr` from a prior Fusion fetch, a fresh Eagle fetch lands as `<MPN> (1).lbr`. Watch Downloads diff, not absolute filename.++## Importing into chip-fetcher library++```bash+# KiCad zip → auto-extracts to .kicad_sym + .kicad_mod+chip-fetcher pull "C:\\Users\\<user>\\Downloads\\<MPN>.zip" --mpn <MPN>++# Altium IntLib + Fusion .lbr — pull stages them in incoming/ but doesn't auto-import.+# Manually copy into library/ with explicit names so the viewer can find them:+chip-fetcher pull "C:\\Users\\<user>\\Downloads\\<MPN>.IntLib" --mpn <MPN>+cp incoming/<MPN>.IntLib library/<MPN>/<MPN>.IntLib++chip-fetcher pull "C:\\Users\\<user>\\Downloads\\<MPN>.lbr" --mpn <MPN>+cp "incoming/<MPN>.lbr" library/<MPN>/<MPN>-fusion.lbr # disambiguate from Eagle if both fetched+```++**(TODO for chip-fetcher importer: handle `.IntLib` and `.lbr` natively — current importer only knows about KiCad zips and STEP files, leaves Altium/Fusion in incoming/.)**++## InstaBuild AI-generated parts++Some parts on SnapMagic are AI-generated rather than vetted by the SnapMagic editorial team. Banner reads *"You've created this part using our AI Model Generator. This part is only visible to you."* The download flow is identical, but the symbol/footprint quality should be inspected before committing to the design. The InstaBuild edit URL is `https://instabuild.snapmagic.com/?unipart_id=<id>&token=<token>` (linked from the banner).++## Account profile cache++If `user_profile.md` (personal memory) has a `Site logins` section listing SnapEDA's email, pre-fill it; otherwise ASK. Never store passwords — pre-fill email + ask user to type password once. Pup persists session via cookies after login (`chip-fetcher` profile name).++**Login URL:** `https://www.snapeda.com/account/login/`++Direct user reaction when this flow landed the STEP for nRF52840-CKAA: *"all adom users will be thrilled!"*
playbooks/sourcing-ladder.mdadded+136@@ -0,0 +1,136 @@+# Sourcing ladder (chip-fetcher playbook)++The defining rule for where chip-fetcher looks for CAD + datasheets, in priority order. Read this when starting any new MPN fetch, or when debugging why the agent went to the wrong source.++## The ladder, in priority order++1. **Manufacturer's own site** (`ti.com`, `nordicsemi.com`, `nxp.com`, `st.com`, `analog.com`, `microchip.com`, `bosch-sensortec.com`, `wago.com`, `vishay.com`, `diodes.com`, `renesas.com`, `espressif.com`, `latticesemi.com`). Datasheet, app notes, errata, lifecycle status, and a "CAD" / "Tools & Software" link if they partner with UL/CSE.+2. **SnapEDA / SnapMagic** (`snapeda.com`) — free CAD downloads (symbol + footprint + STEP + 3D + Altium/KiCAD/Eagle/OrCAD/etc.) for millions of parts. **Login required (most EEs already have one).** Comprehensive coverage especially for chips that aren't on UL/CSE — including Nordic, niche connectors, passives, etc. Should come right after manufacturer-direct, before chasing CSE-direct or UL-guess URLs.+3. **Mouser** (`adom-mouser search "<MPN>"`) — stock + pricing always, plus Mouser product page can surface UL/CSE deep-links.+4. **DigiKey** (`adom-digikey search "<MPN>"`) — broader catalog, niche industrial parts.+5. **Arrow** (`arrow.com`) — niche distributor.+6. **CSE direct** / **UL direct** — only when the manufacturer routes you there from their own page.+7. **LCSC** (`lcsc.com`, `datasheet.lcsc.com`) — **LAST RESORT, AVOID IF POSSIBLE.** LCSC redistributes manufacturer datasheets and EasyEDA-imported CAD, but quality is uneven and we lose the manufacturer's own provenance. Only fall here when steps 1–6 all returned nothing usable, and even then prefer to surface "we couldn't find this on the manufacturer's site" to the user instead of silently grabbing from LCSC.++Direct user quote (2026-05-06): *"when you're falling back to mouser for the search, after that 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."*++**Stock data is independent of the ladder:** always pull `stock.json` from the Mouser API alongside, regardless of where CAD/PDFs come from.++## Why manufacturer first (the ironclad rule)++- Vendor docs are authoritative (latest revision, full set of app notes).+- Manufacturer pages link to UL/SnapEDA deep-links specifically for their parts — find them by following the "CAD" link FROM the vendor page, never by guessing URLs.+- Errata + lifecycle status from the vendor is authoritative.++Direct user quotes:+- *"i keep telling you to not go beyond the manufacturers website."*+- *"look at the manufacturer web pages then. i want you always doing that first anyway before looking at 3rd party sites like mouser/digikey."*+- *"the whole point here is we want to get files, especially step files, from the manufacturer. so you can't cheat. only fallback to places like mouser when you're desperate."*++**🔒 IRONCLAD RULE: never leave the manufacturer's own website without explicit user approval.**++**This applies EVEN WHEN you "already know" the part.** When the agent has high prior confidence about a part (e.g. "LTST-C190KRKT is a Lite-On 0603 red LED, SnapMagic has it"), the cognitive shortcut is to skip the mfr-site probe and jump to the known-source. **Don't.** The rule isn't "check the mfr site when uncertain" — it's "always START at the mfr site, even when confident, before recommending or fetching from anywhere else." Confidence about the part identity is not a substitute for confirming the manufacturer's own site state.++The failure pattern that repeatedly trips this: user asks "do we have indicator LEDs?" → agent recalls a known part family from training → queries Mouser for stock validation → recommends fetching from SnapMagic — without ever opening `<mfr>.com`. That's a direct skill-rule violation. Direct user pushback (recurring): *"why do you keep not using the preference order i'm asking for?"*++The right reflex: open the manufacturer's product page first, even if it takes 30 seconds. Lite-On has `liteonopto.com`, Würth has `we-online.com`, etc. Walk the page, look for CAD/Datasheet/3D Model links. ONLY after exhausting the mfr's site is it OK to talk about SnapMagic / Mouser-CSE / UL fallbacks.++What this means in practice:+1. **Stay on `<vendor>.com` only.** Don't bounce to `3dfindit.com`, `partcommunity.com`, `mouser.componentsearchengine.com`, `vendor.ultralibrarian.com`, `snapeda.com`, `componentsearchengine.com`, or any third-party site **unless the manufacturer's own page links there explicitly** (e.g. TI/ST/ADI/Microchip embedding a `vendor.ultralibrarian.com/<slug>/embedded` URL counts as mfr-endorsed; navigating to UL via Google search does NOT).+2. **If you can't find CAD on the manufacturer's site, REPORT that to the user.** Don't go hunting on aggregators silently. The user will tell you what to do.+3. **Mfr-uploaded ≠ mfr-direct.** SamacSys hosts mfr-uploaded models, but the discovery / distribution channel is still third-party. Always check the mfr's site FIRST, exhaustively, even if you "know" it's going to fail.+4. **Native non-KiCad formats are still mfr-direct.** Altium `.IntLib`, OrCAD `.olb`/`.dml`, EAGLE `.lbr`, Renesas `.dml`, NXP `.bxl` — all of these qualify as mfr-authored. Pull them and convert to KiCad later; don't reject them because they're not in our preferred format.+5. **Exhaust the manufacturer's site before reporting "not available."** Walk every tab (Design Resources, Documentation, Tools & Software, Software & Drivers, Documents & Downloads, Hardware Files), expand every "Toggle" / accordion, click every icon-only button, scrape every `<img>` `alt`/`src`, follow every PDF/zip link, navigate to every Package / Pinout / Mechanical Drawing subcategory.++## Where SnapEDA shines (Tier 2)++When the manufacturer doesn't publish CAD directly (Nordic for example — `nordicsemi.com` doesn't expose chip CAD, only firmware SDK + reference-board Altium files), SnapEDA is the next viable free source. It's free with a login, and **most embedded engineers already have a SnapEDA account**.++Direct user pushback when chip-fetcher missed SnapEDA on Nordic's nRF52840-CKAA dead-end: *"why haven't you tried that before?"* Answer: should have. Adding to the ladder permanently.++## NRND / EOL / Last-Time-Buy is a hard stop for chip-fetcher's own picks++When a manufacturer page (or distributor lifecycle field) says any of these, treat it as a hard signal that the part is on its way out of the market:++- "Not Recommended for New Designs" / "NRND"+- "End of Life" / "EOL" / "Obsolete"+- "Last Time Buy" / "LTB" / "Last Order"+- DigiKey/Mouser `lifecycle` field set to anything except `active` / "" / "Active"+- Bosch/ST "Phased out" / "Mature"+- TI "Lifecycle: NRND"+- Mouser "Discontinued"+- Vendor pages with the explicit warning banner at the top++**The rule:**++1. **If the user explicitly asked for that exact MPN** → fetch it AND surface the lifecycle warning prominently. *"Heads up — TI has TPS40190EVM marked NRND, expect availability to drop within 2–3 years; do you still want it or should I find an active equivalent?"* Pause and let them decide. Their design might already be locked to that specific part for a reason (existing PCB, qual'd component, BOM compatibility).+2. **If chip-fetcher was choosing the part on its own** (default library picks, "popular" suggestions, batch fills, "show me a good op-amp" requests) → **DO NOT add it to the library.** Pick an active replacement. NRND parts will be unbuyable for InstaPCB users in 1–3 years; baking them into Adom's default library means real boards won't be manufacturable at scale.+3. **Always include `lifecycle` + `lifecycle_warning` in the stored `stock.json`** so downstream tools (InstaPCB, BOM checker) can flag these without re-scraping.++Direct user quote: *"if the page says 'not recommended for new designs' that you take that seriously. let the user know if they asked you specifically to get that part, but if you were just thinking that part was a good idea for some reason, it's not a good idea."*++**Detection sources** (check all three):++```bash+# 1. Manufacturer page banners+adom-desktop browser_eval '{"sessionId":"x","expr":"document.body.textContent.match(/not recommended for new designs|nrnd|end of life|obsolete|last time buy|phased out/gi)?.slice(0,3)"}'++# 2. Mouser API+adom-mouser search "<MPN>" | jq '.components[0] | {lifecycle, stock_text, lead_time}'+# lifecycle != "" and != "Active" → flag++# 3. DigiKey API+adom-digikey search "<MPN>" | jq '.components[0].lifecycle'+```++**Viewer badge:** when lifecycle is non-active, render a red "NRND" / "EOL" badge next to the stock badge so it's immediately visible during library browsing.++## Track Mouser stock + pricing as first-class data++chip-fetcher isn't just a CAD library — it's the source-of-truth for whether each chip can actually be put into an InstaPCB build. Every chip in `library/<MPN>/` should have current Mouser stock + price metadata cached alongside the CAD bundle.++**stock.json schema:**++```json+{+ "checked_at": "2026-05-03T01:15:00Z",+ "primary_variant": "TPS25751DREFR",+ "primary_reason": "highest_stock",+ "variants": [+ {+ "mpn": "TPS25751DREFR",+ "mouser_pn": "595-TPS25751DREFR",+ "stock": 3276,+ "unit_price": "$2.99",+ "package": "DREF",+ "lifecycle": "Active",+ "lead_time": "...",+ "product_url": "...",+ "datasheet_url": "..."+ }+ ],+ "mouser": {+ "mouser_pn": "...",+ "in_stock": 2995,+ "expected_dates": [{"qty": 2995, "date": "2026-09-04"}],+ "min_order": 1, "multiple": 1, "lead_time_weeks": 20,+ "lifecycle": "active",+ "price_breaks": [{"qty": 1, "unit_price_usd": 4.14}],+ "product_url": "...",+ "rohs": true,+ "datasheet_url": "..."+ }+}+```++Direct user quote: *"we need to know stuff is in stock at mouser so we can build the instapcb for the user, so you should be tracking that data too in chip-fetcher cuz it's really important data."*++## Multi-package variant tracking++When `adom-mouser search "<MPN>"` returns more than one variant of the chip, save **every variant** to `stock.json` under `variants[]` — not just the best/first one. Downstream tools (InstaPCB BOM picker, schematic-to-board flow, alternate-part suggester) need to know the user's choices, especially when packages differ.++Worked example: TPS25751 has both TPS25751DREFR (3276 @ $2.99, package: DREF) and TPS25751SRSMR (1425 @ $2.66, package: SRSM/RSM). Storing only the highest-stock variant hides that the user can pick a different package for layout reasons.++**Discovery:** any time the Mouser `components[]` array length > 1 AND the MPNs differ in suffix (different package), treat as multi-variant. If MPNs are identical (just differ in `mouser_pn`), it's the same chip from different inventory pools — collapse those.++Direct user quote: *"when you see multiple variants like that from mouser, you have to track that to let the user know the choices later downstream."*
playbooks/ultra-librarian.mdadded+315@@ -0,0 +1,315 @@+# Ultra Librarian (chip-fetcher playbook)++UL deep-link patterns, form quirks, captcha handling, atomic-submit pattern, watcher race-safety. Read this when the manufacturer's product page links to a `vendor.ultralibrarian.com/<slug>/embedded` URL.++## TL;DR — three flows, pick by URL host++| URL pattern | Flow | Captcha? | Driver script | Auth |+|---|---|---|---|---|+| `vendor.ultralibrarian.com/<vendor>/embedded` | **Embedded captcha-iframe** (the public, no-login route from manufacturer product pages) | reCAPTCHA gauntlet, iterative escalation | `cf-ul-fast.sh --phase pre-captcha/post-captcha` | none required, but anonymous |+| `app.ultralibrarian.com/details/<uuid>/<vendor>/<MPN>` | **UL Pro tick-and-click** (logged-in user; URL contains `?uid=…`) | **none** for logged-in users | `cf-ul-pro-batch.sh <MPN>` | UL Pro form-login, cookie in pup profile |+| `app.ultralibrarian.com/details/<uuid>/...` (no `?uid=`) | UL Pro detail, anonymous | yes — falls back to embedded captcha | `cf-ul-fast.sh` (after page transition) | none |++**Best practice 2026-05-04:** sign in to UL Pro once via `chip-fetcher login ultralib` (see [`credentials-and-logins.md`](credentials-and-logins.md)). Then `cf-ul-pro-batch.sh` drives every subsequent fetch with zero captcha — no iterative-grid AI-vision burn, no token-timeout race. Captcha-driven `cf-ul-fast.sh` is now a fallback for parts UL Pro doesn't carry, or for anonymous flows.++## UL Pro logged-in tick-and-click flow (cf-ul-pro-batch.sh)++When the chip-fetcher pup profile has a live `app.ultralibrarian.com` cookie (`Remember My Login` was ticked at sign-in time → 30-day persistent session), UL Pro detail pages serve a **direct download form** instead of a captcha-iframe.++### The exact two-click pattern (this is the bug-prone part)++There are TWO elements on the page both labeled "Download Now":+- **`<button class="btn btn-dark...">`** — opens the format-picker panel. Clicking this does NOT trigger a download.+- **`<a class="export-trigger">`** — actually submits the picked formats and triggers the download. Initially **hidden** (collapsed under the closed picker); only `offsetParent !== null` after the BUTTON has been clicked.++Calling the script in the right order:++1. **Click the BUTTON** to open the format picker. URL gets `?open=exports`.+2. **Tick the format checkboxes** (Altium Designer, Fusion360 PCB, KiCAD v6+, etc.) — these are now visible in the open picker.+3. **Click the A.export-trigger** to submit. UL Pro server-side processes the format conversion; the "Starting Your Download" modal appears.++A single-click pattern (just clicking whichever Download Now is visible first) **fails silently** — the BUTTON click opens the picker but the download never fires because the submit element wasn't clicked. Verified painful 2026-05-04: 6/8 chips timed out waiting for a download that was never submitted.++### Full flow++1. Search: `https://app.ultralibrarian.com/search?queryText=<MPN>` → first `a[href*=details]` is the part page.+2. Navigate to part page → URL becomes `https://app.ultralibrarian.com/details/<uuid>/<vendor>/<MPN>?uid=<userId>`. The `?uid=` confirms authenticated session — if absent, the cookie didn't take and you'll get re-routed through embedded captcha.+3. Click `<button>Download Now</button>` (visible BUTTON) — opens picker.+4. Tick checkboxes by **label text**: `Altium Designer`, `Fusion360 PCB`, `KiCAD v6+`, `STEP`, etc. The checkboxes share `name="exports"` and have an integer value identifying the format. There are ~40 export formats; pick what you need.+5. Optionally tick any consent checkbox the manufacturer requires (TI: `name="consent-TIInfoShare"`). **Verified not actually required for the download to succeed** — UL Pro proceeds without it. But ticking it satisfies the form's UI completeness check.+6. Click `<a class="export-trigger">Download Now</a>` (visible A) — submits.+7. UL Pro shows a "Starting Your Download" modal — server-side format conversion takes 2-15 seconds typically (claims "up to 2 minutes").+8. Poll `~/Downloads/` (or Windows Downloads via `adom-desktop shell_execute powershell ...`) for `ul_<MPN>.zip` newer than the click timestamp.+9. Pull via `chip-fetcher pull <Windows-path> --mpn <MPN>` to copy zip into `incoming/`.+10. Extract `*.SchLib`, `*.PcbLib`, `*.lbr` from the zip and copy to `library/<MPN>/<MPN>.{SchLib,PcbLib,lbr}`. The chip-fetcher `pull` step already imports `.kicad_sym`, `.kicad_mod`, `.step` — the manual distribute step is just for Altium + Fusion files (chip-fetcher's importer doesn't handle them yet).++```bash+./scripts/cf-ul-pro-batch.sh BQ25798RQMR+# Posts activity to /api/activity at every stage so the dashboard reflects progress.+# Output: url, picker opened, ticked N, submitted via A, download <name>, distributed SchLib PcbLib lbr, OK <MPN>+```++### Coverage caveats — UL Pro doesn't carry every package variant++UL Pro is per-package, not per-MPN. Common gaps (verified 2026-05-04):++- TI ADS131M04**IPBSR** (TQFP-32 PBS package) — UL has IPWR/IPWT (TSSOP-32 PW package) but **not** the PBS variant.+- TI MCF8316A1RRYR — not on UL Pro at all (very new part).++**Don't ship a wrong-package Altium file.** When the search returns a different package suffix, mark it in `info.json` (`altium: "no UL Pro coverage for <package>"`) and try the manufacturer's product page (TI's "EDA Symbols and Footprints" link sometimes routes to a different UL flow that has more packages). For non-coverage parts, accept partial state — KiCad-only is better than KiCad + wrong-package Altium.++## Use the fast two-phase captcha script for the embedded flow — DO NOT drive UL step-by-step from chat++**`chip-fetcher/scripts/cf-ul-fast.sh`** is the canonical UL driver. It collapses the chained-LLM-call problem (each chat-side `browser_eval` adds ~2-5s of latency, easily eating the captcha's 2-minute token budget) into TWO calls with one AI vision step in between. Tight bash cadences (200ms token-poll, parallel tile dispatch, 0.5s post-Verify wait) match what a Rust implementation could do; the bottleneck post-vision is the `adom-desktop` HTTP relay, not the script.++```bash+# Phase 1 (one shell call): navigate → preset form → trusted-click I'm-not-a-robot+# → if image grid: screenshot iframe + crop with DPR scaling+./scripts/cf-ul-fast.sh --phase pre-captcha --url '<UL-URL>'+# Returns JSON: { stage:"image_grid", iframe_png:"/tmp/...", round:1, _hint:"..." }+# OR: { stage:"silent_pass" }++# AI does ONE vision step: read iframe_png_2x, identify (a) grid size, (b) prompt, (c) tiles.++# Phase 2 (one shell call): click tiles → click Verify → check round-N escalation+# → atomic-eval Submit on token → poll Downloads+# → pull → distribute to family MPNs+./scripts/cf-ul-fast.sh --phase post-captcha --tiles 4,9 --mpns A,B,C \+ --grid 3x3 --round 1+# Returns: { stage:"done", ul_zip:..., distributed_to:["A","B","C"] }+# OR (iterative captcha): { stage:"image_grid_round_2", iframe_png:..., _hint:"..." }+# → AI does another vision pass, calls phase 2 again with --round 2.+```++The script handles family-distribution: when one UL zip covers multiple MPNs (common for indicator passives where the package body is shared), pass `--mpns A,B,C` and the script copies sym/mod into each `library/<MPN>/`.++## UL rotates captcha approaches — adapt, don't hardcode++UL deliberately varies captcha to throw off AI vision:++- **Grid size**: 3×3 (9 tiles, 1.5× larger per tile) vs 4×4 (16 tiles, denser). AI MUST detect from screenshot and pass `--grid 3x3` or `--grid 4x4` to phase 2.+- **Type**: "Select all images with X" (one-shot — click matching tiles, click Verify, done) vs "Click verify once there are none left" (iterative — click matches, then NEW images appear in clicked tiles, may need multiple rounds).+- **Subject difficulty**: easy (cars, fire hydrants, traffic lights) vs harder (boats, bicycles, chimneys, palm trees) — AI may need to be aggressive about ambiguous tiles.+- **Image noise overlay**: some replacement tiles in iterative mode have heavy noise — usually means "newly-shown after a click; classify based on what's visible through the noise."++`cf-ul-fast.sh --phase post-captcha` detects iterative escalation automatically (post-Verify check: bframe still visible AND token still empty → re-screenshot → output `image_grid_round_N+1` for AI to do another vision pass). AI loops until token populates or captcha hard-fails.++## /compact your conversation BEFORE driving UL++A captcha token has a ~120-second life from the I'm-not-a-robot click. AI vision response time scales with conversation context size. **If your context is large (200K+ tokens), each vision turn can take 8-15s** — that compounds across iterative captcha rounds and burns the token budget.++**Before any non-trivial UL fetch, recommend the user run `/compact`** to trim context. Smaller context = faster vision responses = the captcha pass succeeds before the token expires.++**Heartbeat protocol is mandatory** — see [`dashboard-flow.md`](dashboard-flow.md). `cf-ul-fast.sh` calls `post_act` automatically at every stage so the dashboard reflects live progress.++## DO NOT drive UL with separate `browser_eval` chat-side calls++Anti-pattern (the slow path):++```+[chat] browser_navigate UL URL+[chat] browser_eval — open Choose CAD Formats+[chat] browser_eval — preset form+[chat] browser_input_dispatch — click captcha+[chat] browser_screenshot — see image grid+[chat] AI vision — pick tiles+[chat] browser_input_dispatch — click tile 1+[chat] browser_input_dispatch — click tile 2+[chat] browser_input_dispatch — click Verify+[chat] browser_eval — poll for token (×N)+[chat] browser_eval — Submit+[chat] shell_execute — poll Downloads+[chat] chip-fetcher pull+```++Each line carries 2-5s of LLM/network latency. By the time Submit fires, the token is dead.++**The right path: `cf-ul-fast.sh --phase pre-captcha`, AI vision, `cf-ul-fast.sh --phase post-captcha`. Two CLI calls, one vision step.**++## Auto-driving pattern (the win)++**`chip-fetcher/scripts/cf-ul-fetch.sh <MPN> <UL-deep-link>`** is the canonical end-to-end driver. It implements the atomic-submit pattern verified working across TI/ST/ADI/WAGO/Microchip flows.++`chip-fetcher/scripts/cf-drive.sh <MPN>` opens the TI product page, finds the embedded UL link (carries session-tied URL pre-loaded with right gpn + package + pin), navigates to it, clicks "Choose CAD Formats & Download", then sets the form via JS:++```js+document.getElementById("MfrThreeDModel").click(); // STEP+document.getElementById("KiCADv6").click(); // KiCAD v6++document.getElementById("TermsAndConditions").click();+const sel = document.querySelectorAll("select").find(s => /Metric/i.test(s.options[1].text));+sel.value = "2-2"; sel.dispatchEvent(new Event("change",{bubbles:true})); // mm+```++`chip-fetcher/scripts/cf-autowatch.sh <MPN>` (background) does two things:+1. Polls `g-recaptcha-response` token every 2s. When length > 50, the user has solved CAPTCHA. **Verifies the active tab URL still matches the MPN's base part** (race-safety) before auto-removing `disabled` from `#SubmitLink` and clicking it.+2. Polls user's `~/Downloads` every 2s. When a `ul_*.zip` whose filename matches the MPN base appears, calls `adom-desktop pull_file` + `chip-fetcher import --mpn <MPN>`.++A PID lock at `/tmp/cf-autowatch.lock` prevents two watchers running simultaneously.++The user only has to click **one CAPTCHA per part**. Everything else is automated.++### Typical batch flow++```bash+cd /home/adom/project/chip-fetcher+./scripts/cf-drive.sh <MPN>+./scripts/cf-autowatch.sh <MPN> &+adom-desktop browser_raise_os_window '{"sessionId":"chip-fetcher"}'+# → user clicks CAPTCHA on the pup window (one click)+# → watcher auto-clicks Submit, polls Downloads, pulls + imports+```++While the watcher's running, `curl https://www.ti.com/lit/gpn/<base-mpn>` to grab the datasheet PDF in parallel — that endpoint is open, no auth.++## Vendor-specific UL form quirks++Different UL portals use different checkbox IDs:++| Portal | STEP checkbox ID | KiCAD v6+ ID |+|------------|-------------------|--------------|+| TI | `MfrThreeDModel` | `KiCADv6` |+| Microchip | `MfrThreeDModel` | `KiCADv6` |+| ST | `MfrThreeDModel` | `KiCADv6` |+| ADI | `ThreeDModel` | `KiCADv6` |++**Don't rely on IDs** — use the visible label "STEP" / "KiCAD v6+" with `byLabel(re => /^STEP$/i)` lookup. The cf-drive.sh script already does this.++## ON UL DOWNLOADS — grab the popular EDA formats + STEP++**On every UL download form, check the popular-EDA format checkboxes — not just KiCAD.** The user's current project may target one EDA tool, but Adom adds support for new EDA tools over time, and re-fetching every board for a different EDA tool would mean another reCAPTCHA toll per part. **UL also caps free downloads at ~5 format selections per submission**, so picking smartly matters — going for "ALL formats" doesn't work.++**The canonical four-pick that covers >95% of user EDA tooling:**++- ☑ **STEP** (3D model — always, every download)+- ☑ **KiCAD v6+**+- ☑ **Altium Designer**+- ☑ **Fusion 360 PCB / Eagle**+- ☑ **T&C** checkbox (mandatory)++Direct user direction: *"i think the names i gave you are the right choice for now. we don't need ALL formats of other EDA's. just the popular ones … fusion 360 and altium and kicad."*++If the user later expands the supported EDA list (OrCAD, DesignSpark, Mentor, Pulsonix, Quadcept, TARGET, Zuken, CADSTAR, eCadstar — each is a separate UL checkbox), add to this list, but stay within UL's per-submission cap. For chips that need an EDA format outside the canonical four, do a second reCAPTCHA-gated fetch later (worth the toll).++**Footprint Units:** always set to **Metric (mm)** when the dropdown exists. Most EDA tools internally use mm; importing as mil and converting is a precision-loss tax.++**STEP picker:** if the page shows a "Cadenas 3D Model" dropdown (WAGO-style UL forms), pick `STEP AP214` (universal compatibility) or `STEP AP242` (newer, with PMI). If STEP is a checkbox (TI-style UL forms), just check it.++```bash+# Driver helper: pick the canonical four + T&C (no overflow)+adom-desktop browser_eval '{"sessionId":"chip-fetcher","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();} return \"done\";})()"}'+```++If UL silently unchecks one (the "max 5" enforcement), the canonical four already fits — but if a vendor adds extra mandatory checkboxes (e.g. WAGO had Cadenas 3D Model dropdown which counts toward the cap), drop one of the EDA formats (typically Eagle/Fusion last) to stay under.++## reCAPTCHA v2 auto-click — try first, fall back to user only on image-grid++With `browser_input_dispatch` (adom-desktop ≥ v1.4.7), the agent CAN dispatch a trusted CDP click on the reCAPTCHA "I'm not a robot" checkbox iframe — **isTrusted is no longer the limiter**. What stops a clean silent pass is **Google's session-reputation scoring**: pup's Chrome-for-Testing has no browsing-history, no Google account session, no consistent IP usage pattern, so reCAPTCHA escalates to the image-grid challenge for low-reputation sessions.++**The empirical pattern (verified 2026-05-03 on TI UL):**++1. Move cursor toward the checkbox via `browser_input_dispatch type:"move"` with intermediate steps (helps fingerprint look human).+2. Click the checkbox at iframe `(left+30, top+40)` — that's where "I'm not a robot" sits inside the iframe.+3. Wait 3–5s.+4. Read `[name=g-recaptcha-response]`'s value.+ - Non-empty + `>20` chars → **silent pass, proceed to Submit**.+ - Empty + an image-challenge iframe (`iframe[src*=bframe]`) appears → **escalation, hand off to user**.++```bash+# 1. Find iframe coords+RECT=$(adom-desktop browser_eval '{"sessionId":"X","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});})()"}' \+ | jq -r '.result')++# 2. Move + click (humanlike approach)+IX=$(($(echo $RECT | jq .x) + 30))+IY=$(($(echo $RECT | jq .y) + 40))+adom-desktop browser_input_dispatch "{\"sessionId\":\"X\",\"type\":\"move\",\"x\":$((IX-200)),\"y\":$((IY-100)),\"steps\":5}"+sleep 0.5+adom-desktop browser_input_dispatch "{\"sessionId\":\"X\",\"type\":\"move\",\"x\":$IX,\"y\":$IY,\"steps\":15}"+sleep 0.4+adom-desktop browser_input_dispatch "{\"sessionId\":\"X\",\"type\":\"click\",\"x\":$IX,\"y\":$IY}"+sleep 4++# 3. Detect outcome: passed vs escalated+RESULT=$(adom-desktop browser_eval '{"sessionId":"X","expr":"JSON.stringify({tokenLen:(document.querySelector(\"[name=g-recaptcha-response]\")?.value||\"\").length, challenge:!!document.querySelector(\"iframe[src*=bframe]\")})"}')+# tokenLen >= 20 → passed, agent continues+# tokenLen == 0 && challenge true → image grid, AI vision can try, else user+```++**If escalated to image grid, AI vision CAN often solve it.** Verified working 2026-05-03 on TI UL: solved a 4×4 "traffic lights" grid + 3×3 "motorcycles" grid in two passes, token populated cleanly. Technique:++1. **Screenshot the iframe region.** The bframe iframe's bounding rect is exposed via `document.querySelector("iframe[src*=bframe]").getBoundingClientRect()`. Crop to `(rect.x * dpr, rect.y * dpr, rect.w * dpr, rect.h * dpr)` where `dpr = window.devicePixelRatio` (typically 1.5).+2. **Resize the crop 2× via ImageMagick** (`convert input -resize 200% out.png`).+3. **Read the prompt + grid.** "Select all squares with X" = 4×4 grid (16 tiles, click each square containing any pixel). "Select all images with X" = 3×3 grid (9 tiles, only the primary subject).+4. **Compute tile centers in browser CSS coords.** For 4×4 grid in iframe (273,127,400,580): grid area top≈237 left≈298, tile size ≈89×105 → tile (r,c) center = `(298 + (c-0.5)*89, 237 + (r-0.5)*105)`. For 3×3 grid: tile size ≈118×140.+5. **Click each correct tile via `browser_input_dispatch type:click`** with ~600ms between clicks (humanlike pacing).+6. **Click Verify** at iframe-bottom-right.+7. **Watch `[name=g-recaptcha-response]`** — token populating with >20 chars = passed.++**Token expires within ~2 minutes — Submit IMMEDIATELY after token populates.** Do NOT re-set form, do NOT add `sleep 8` between checks; that latency burns through the captcha lifetime and you'll have to solve it again.++## CRITICAL pattern — ATOMIC token-check + Submit-click in one `browser_eval`++**The winning pattern for UL captcha submits, verified end-to-end on TPS25751 2026-05-03.** Every prior attempt failed because the bash-side polling was `eval → check → eval` with 200–500ms round-trip between detection and Submit, which was enough for UL's backend to re-validate and reject the captcha token.++**Fix: do the check AND the click in a single `browser_eval`.**++```js+(() => {+ 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';+})()+```++Bash polls this one eval at 1-second cadence — when it returns `SUBMITTED_*` you know Submit fired in the same browser tick the token was detected. **Net round-trip latency from token-populate to Submit-click: ~0 ms.**++**Other prerequisites that go with the atomic eval:**++1. **Pre-set the form BEFORE the captcha** (STEP + KiCAD v6+ + Altium + Fusion 360 + mm + T&C all in one eval). Zero work between token-detect and Submit.+2. **UL's Submit is an `<a>` tag**, not `<button>` — selector must scan `'a, button, input[type=submit]'`.+3. **Cache the Downloads-before snapshot BEFORE clicking reCAPTCHA**, not after Submit — else you race the download and miss the new file.+4. **Use the manufacturer's session-tied UL deep-link**, not `?vdrPN=` guesses. TI's product page has a `CAD` link that includes `gpn=<MPN>&package=<PKG>&pin=<N>&sid=<token>`. Without `sid`, UL silently routes to a wrong/related part. Verified: `?vdrPN=tps65987d` routed to `F280041RSHSR` (totally different chip).+5. **Verify part identity after navigation** — read `document.body.textContent` and confirm the MPN appears.++The full winning recipe lives in `chip-fetcher/scripts/cf-ul-fetch.sh`. Direct user reaction when this pattern landed the bundle on first attempt: *"you did it!!!!!! put that in the skill and/or make a script to be ready next time."*++## Even tighter timing for AI-vision-solved image grids++Verified 2026-05-03: even when AI vision passes a multi-stage image-grid challenge (4×4 → 3×3 → 4×4 → 3×3, ~3 challenges deep), **the cumulative time spent on screenshot + ImageMagick crop + Read + click cycles can exceed the 2-minute token window** by the time the FINAL Submit posts to the vendor backend.++**Mitigation when going AI-vision-solve route:**++1. **Pre-set the form completely** before triggering reCAPTCHA. STEP, KiCAD v6+, Altium, Fusion 360, Metric (mm), T&C — all set BEFORE you click "I'm not a robot."+2. **Keep ALL between-click latency under 1s.** No `sleep 4` for screenshots — use `sleep 0.6` between tile clicks.+3. **Skip screenshots after Verify.** Read `[name=g-recaptcha-response]` directly — that's the only signal that matters. If `.length > 20` → fire Submit in the SAME bash chain.+4. **Accept that 3+ challenge stages probably exceed the budget.** Plan for a redo, but expect the user to need to step in if Google keeps escalating. Direct user pushback on slow re-tries: *"why are you going so slow again? it'll timeout!!!"*++**The hard fact:** AI vision works for 1-stage image grids (~70% of trusted-click escalations). For 3+-stage chained challenges, the round-trip time of `screenshot → crop → read → identify → click → verify` is the bottleneck, not vision accuracy. For those, hand off to the user immediately.++**Reputation-building tips** for boosting silent-pass rate over time:+- Have the user sign into Google in the pup profile once.+- Visit a few high-reputation domains (google.com search, gmail.com) before the gated form.+- Use the same `chip-fetcher` profile across all sessions so cookies+history accumulate.++In practice, expect ~30–50% silent-pass rate on first attempts and increasing over time.++## Watcher race-safety (as of 2026-04-30)++`scripts/cf-autowatch.sh` enforces:+- **PID lock** at `/tmp/cf-autowatch.lock` — only one watcher at a time.+- **URL match** — verifies the active pup tab URL contains the expected MPN base before clicking Submit.+- **Filename match** — the Downloads-folder poll filters for filenames containing the MPN base, AND filename matches `^(ul_|LIB_).*\.zip$`.++Past incident: two watchers running simultaneously both saw the same CAPTCHA solve token and both raced to download/import the same UL zip — one ended up imported into the wrong MPN folder.++## UL gotchas++1. **UL download tokens are SINGLE-USE.** After Chrome consumes the URL once, server returns `Token Expired`. Re-fetching via `browser_eval` + `fetch()` will not work. **The only reliable path is `pull_file` from the user's Downloads folder.**+2. **UL ships PACKAGE-named files inside the zip**, not MPN-named. Inside `ul_TPS62840YBGR.zip` the STEP is `DSBGA6_1P4XP9_TEX.step`. Always pass `--mpn <MPN>` to import so files get renamed to `<MPN>.step`.+3. **UL ships multiple footprint variants** (default, `-L`, `-M`). Current import overwrites them all to one `<MPN>.kicad_mod`. TODO: disambiguate suffixes.+4. **TI does NOT self-host STEPs on ti.com.** Every TI part's "CAD/CAE Symbols" link is a UL deep-link with a session-tied URL.
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()