← Commit history
DEVELOPING.mdadded+108
@@ -0,0 +1,108 @@+# Developing chip-fetcher-lite — architecture & hard-won lessons++Read this before extending the app. Everything here is a real lesson from the build; it will save+a fresh session hours.++## Architecture in one screen++- **`server.js`** — pure-Node `http` server (no native deps). Serves the SPA + a REST API.+  In-memory `DB` persisted to `data/data.json` (debounced). Uploaded files in `data/staging/<id>/`.+- **`public/index.html`** — the entire frontend: one vanilla-JS SPA, dark Adom theme. Polls+  `GET /api/state` every 2.5s and re-renders.+- **`cli.js`** — thin wrapper (`serve`, `add`, `list`, `app`).+- No build step. Start with `node server.js` (port 7821, `CFL_PORT` to override).++### Data model (per component)+`{ id, name(=MPN once resolved), mpn, slug, type, passive, manufacturer, package, subcategory,+   pinCount, description, jlc, jlcPart, datasheetUrl, selected, footprintLocked,+   wiki:{status, slug, owner, url, variants}, _wiki:{files[], has3d},+   eda:{kicad|altium|fusion: {symbol|footprint|model3d: {present, source}}},+   uploads:[{eda,kind,name,path,...}], dest:{symbol|footprint|model3d:{name,path,exists,source,fpName}},+   names:{symbol,footprint} }`++- `eda` is recomputed (`recomputeEda`) from `_wiki.files` + `uploads`. 3D files mark `model3d` for all EDAs.+- `dest` is computed by `computeDest`/`applyDest` from the probed libraries.++## The Adom wiki (what this app reads)++- **Search:** `adompkg search <q>` (FTS). Hyphens break FTS (`chip-fetcher` → "no such column").+- **Page meta:** `GET https://wiki.adom.inc/api/v1/pages/<slug>` → `.page` (brief, owner) + `.metadata`+  (mpn, manufacturer, package, subcategory, pin_count, jlcpcb_part, datasheet_path, has_symbol/footprint/3d_model).+- **File list:** `GET /api/v1/pages/<slug>/files`.+- **Download a file:** `GET /blob/component/<slug>/<filename>` (the `/raw/` and `/api/.../raw/` paths 404).+- Components are wiki **pages** (type `component`), NOT adompkg packages — `adompkg view` won't find them.+- Most "library"/passive component pages are **stubs** (README + page.json only, no CAD). Handle gracefully.++### Description → MPN resolution+Users type descriptions ("100nF 0402 capacitor"). `classify()` resolves them to the real MPN page by+matching value+package against the FTS top component result (`titleMpn()` parses the MPN from a title+like "0402B104J160CT — 100nF MLCC Capacitor"). The component column then shows the MPN.++## The desktop (THE most important section)++**Barrett's "laptop" is Linux**, `HOME=/home/barrett-land`. Don't assume Windows.+**Always reach his file system through the `adom-desktop` bridge — never the container FS.**++His KiCad user libraries:+- `~/Documents/KiCAD/Symbols/*.kicad_sym` (JLC_*_BLT, etc.)+- `~/Documents/KiCAD/Footprints/*.pretty` (manufacturer-named: `TI_BLT.pretty`, …)+- `~/Documents/KiCAD/3D/<Manufacturer>/` (manufacturer subfolders)+- KiCad stock libs at `/usr/share/kicad/footprints/<lib>.pretty` (Capacitor_SMD, Resistor_SMD, …).++### adom-desktop gotchas (these WILL bite you)+- `desktop_list_files` is **non-recursive** and does **NOT** expand `%VARS%`/`~`. Takes a JSON arg.+- `read_file` **does** expand `~`/`%VARS%`. No approval needed.+- `shell_execute` runs `sh -c` (Linux). Needs user approval (auto-approve was on for Barrett).+- **`send_files` IGNORES its `dest` arg on Linux** — it dumps everything to `~/Downloads`. So:+  land the file, then `mv` it into place with a `shell_execute`. (`sendFiles()` does this.)+- `write_file` works but base64 on the CLI arg has a size ceiling — prefer `send_files`+`mv` for real files.++## Destination logic (computeDest)++- **Symbol** → JLC category lib by type: `JLC_<Cat>_BLT.kicad_sym` (Capacitors/Resistors/Inductors/+  LEDs/Diodes/Oscillators/FETs/Connectors/Flash/Power/MCUs/Misc). MCUs matches MPN hints (stm32/nrf/…).+- **Footprint** → prefer the wiki footprint filed into the manufacturer `.pretty`; for passives NOT on+  the wiki, use the KiCad **stock** lib (Capacitor_SMD → `C_0402_1005Metric`, name LOCKED, "KiCad default" badge).+- **3D** → manufacturer subfolder (deepest), matched to `metadata.manufacturer`.+- **`matchByMfr`** matches a manufacturer to a lib/folder by **exact name-segment** (`TI_BLT`→`ti`); fuzzy+  substring only when BOTH sides ≥4 chars; aliases (`MFR_ALIASES`) expand ONLY on a full-name match; generic+  words (`MFR_STOP`: technology/corporation/…) are filtered. Without these, "corpora**ti**on"→TI and+  "in**st**ruments"→ST false-matched. Don't loosen it.++## Filing into KiCad (send-to-library)++- Footprint/3D: download → `sendFiles()` (land + mv).+- Symbol: `read_file` the dest lib → `extractSymbolBlock` (balanced-paren) → `renameSymbol` →+  `stripExtraProps` (keep ONLY Reference/Value/Footprint/Datasheet/Description) → set Description+  (comma-separated specs), Footprint (`<nick>:<fpName>`), Datasheet, LCSC(=jlcPart) → `mergeSymbol`+  REPLACES an existing symbol of that name (re-send updates) → `.bak` backup → `sendFiles` (overwrites).+- Stubs (no CAD) are skipped, not errored.+- Symbol descriptions: passives = value/size/power(or voltage/dielectric); ICs = manufacturer, package,+  pin count, subcategory — all comma-separated into the single Description field (NOT separate fields).++## Hard rules++- **Re-checking the wiki must NEVER delete user-uploaded files or user-edited fields.** `runCheck`+  preserves `uploads` and a manually-set `jlcPart` (`meta.jlcpcb_part || c.jlcPart || null`); edited names+  survive (only reset on a footprint lock-state change).+- **Dedup:** adding a duplicate name, or a description that resolves to an MPN already listed, is rejected.+- **Bounded wiki checks:** mass-add uses a 4-concurrent queue (`enqueueCheck`/`pumpChecks`) — never fire+  N `adompkg` processes at once (138-part stress test confirmed this is necessary).++## Local dev gotchas++- **`pkill -f "node server.js"` matches its OWN shell** (the start command contains that string) and+  kills itself before node starts. Kill by PID (`ps -eo pid,args | grep "[s]erver.js"`) or+  `fuser -k 7821/tcp`, in a SEPARATE command from the `node server.js` start.+- Start the server as a background task with `exec node server.js` (cwd = repo). Don't combine kill+start.+- API calls in the SPA are **relative to the page** (`new URL('api'+path, document.baseURI)`) so they work+  behind the Hydrogen proxy (`/proxy/<port>/`). Absolute `/api/...` hits the proxy origin and 404s.+- Fonts: Adom brand `@font-face` from `adom.inc/fonts/...` (verified 200). Never system fonts as primary.+- **Sticky top bar:** the header + controls + table column-header must stay pinned while rows scroll.+  Two traps: (1) `body{height:100%}` constrains sticky children to one viewport so they scroll away —+  use `body{min-height:100%}` instead; (2) the thead's sticky `top` must equal header+controls height,+  which CHANGES when the controls bar wraps — compute it in JS (`updateStickyOffsets` sets `--header-h`+  and `--stick-top`, called on render + resize), never hardcode pixels.+- The webview tab is created with `adom-cli hydrogen webview open-or-refresh --name "Chip Fetcher Lite"+  --url <proxyURL> --panel-id <pane>`; `--panel-id` is required the first time (find via+  `adom-cli hydrogen workspace tabs`). Reload picks up server-side file changes (server serves from disk).
SKILL.mdadded+59
@@ -0,0 +1,59 @@+---+name: chip-fetcher-lite+description: >+  Lightweight wiki-first component staging area. Add a component (MPN or description), check the+  Adom wiki for KiCad/Altium/Fusion files, upload what's missing, then file the symbol + footprint+  + 3D model into the user's EDA libraries with per-manufacturer destinations and a clean 5-field+  symbol. A fast, low-context alternative to the full chip-fetcher. Trigger words — chip fetcher+  lite, stage a component, check the wiki for a part, do I have CAD for, add basic parts, file into+  kicad, send to library, upload component to kicad.+---++# chip-fetcher-lite++A low-context alternative to the full chip-fetcher. It does NOT scrape vendors, generate symbols,+or run concur. It reads the Adom wiki, stages files, and files them into the user's libraries.++## Running++```+node ~/.claude/skills/chip-fetcher-lite/server.js     # or: chip-fetcher-lite serve+chip-fetcher-lite app                                  # open as a Hydrogen webview+```++Server listens on `:7821` (`CFL_PORT` to override). Runtime data in `data/` (not shipped).++## What's automatic (no agent needed)++- **Wiki check** on add: `adompkg search` → classify exact/variant/none (MPN→slug normalize,+  passive detection, **description→MPN resolution** so "100nF 0402 capacitor" → `0402B104J160CT`).+  Bounded to 4 concurrent lookups so mass-adds don't spawn hundreds of processes.+- **File-availability boxes** per row (KiCad/Altium/Fusion + JLC) from the wiki page's files/metadata.+- **Library probe** (clicking *Probe libraries*): the SERVER shells `adom-desktop shell_execute`+  to scan the user's machine for their KiCad libraries (`~/Documents/KiCAD/{Symbols,Footprints,3D}`,+  etc.) and computes per-component destinations.+- **Send to library** (clicking *Send to library*): the SERVER downloads each component's CAD from+  `wiki.adom.inc/blob/component/<slug>/<file>` (or uses uploaded files) and files them onto the+  desktop via adom-desktop — footprint→`.pretty`, 3D→manufacturer folder, symbol→merged into the+  JLC category lib (trimmed to Reference/Value/Footprint/Datasheet/Description + LCSC). Creates and+  registers any missing libraries. Live progress in the top bar.++## The agent's role++Mostly: drive the UI for the user and answer "what's on the wiki." Useful things you can do:++- **Add parts in bulk** — POST `/api/components {name}` per part (dedup is automatic). Honour a+  concurrency-friendly pace; the server queues the checks.+- **Look up JLC/LCSC part numbers** the wiki doesn't have: `adom-parts-search search <mpn>` →+  the jlcpcb hit's `vendor_pn` (a `C…` number) → POST `/api/components/:id/jlc {jlcPart}`.+- **Open the webview** in the right pane (see DEVELOPING.md for the panel-id flow).++## Hard rules++- **Re-checking the wiki must NEVER delete user-uploaded files or user-edited fields** (JLC PN,+  edited names). The server preserves them; keep it that way.+- **Always reach the user's file system through adom-desktop**, never the container FS.+- Prefer what's on the wiki over the KiCad default library; use the KiCad **stock** footprint only+  for passives that aren't on the wiki.++See **DEVELOPING.md** for the full architecture + the gotchas that will bite a fresh session.
cli.jsadded+65
@@ -0,0 +1,65 @@+#!/usr/bin/env node+'use strict';+/*+ * chip-fetcher-lite CLI — thin wrapper over the local server API.+ *+ *   chip-fetcher-lite serve            start the server (foreground)+ *   chip-fetcher-lite add <name...>    add a component (starts a wiki check)+ *   chip-fetcher-lite list             list components + status+ *   chip-fetcher-lite jobs             show pending agent jobs (probe / send)+ *   chip-fetcher-lite app              open the viewer as a Hydrogen webview+ */+const { spawn, execFileSync } = require('child_process');+const path = require('path');++const PORT = parseInt(process.env.CFL_PORT || process.env.PORT || '7821', 10);+const BASE = `http://localhost:${PORT}`;++async function api(p, opts) {+  const r = await fetch(BASE + p, opts);+  if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);+  return r.json();+}++async function main() {+  const [cmd, ...rest] = process.argv.slice(2);+  switch (cmd) {+    case 'serve': {+      const s = spawn(process.execPath, [path.join(__dirname, 'server.js')], { stdio: 'inherit', env: process.env });+      s.on('exit', c => process.exit(c || 0));+      break;+    }+    case 'add': {+      const name = rest.join(' ').trim();+      if (!name) { console.error('usage: add <name>'); process.exit(1); }+      const c = await api('/api/components', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) });+      console.log(`added ${c.name} (${c.id}) — checking wiki…`);+      break;+    }+    case 'list': {+      const s = await api('/api/state');+      for (const c of s.components) {+        const n = ['kicad', 'altium', 'fusion'].filter(e => c.eda[e].present);+        console.log(`${c.name.padEnd(22)} ${c.wiki.status.padEnd(9)} [${n.join(',') || '—'}]  ${c.type}`);+      }+      break;+    }+    case 'jobs': {+      const s = await api('/api/jobs');+      console.log(JSON.stringify(s.jobs, null, 2));+      break;+    }+    case 'app': {+      const proxy = process.env.VSCODE_PROXY_URI;+      if (!proxy) { console.log(`open ${BASE} in a browser`); break; }+      const url = proxy.replace('{{port}}', String(PORT));+      try {+        execFileSync('adom-cli', ['hydrogen', 'webview', 'open-or-refresh', '--name', 'Chip Fetcher Lite', '--url', url], { stdio: 'inherit' });+      } catch (e) { console.log(`open this URL as a webview:\n  ${url}`); }+      break;+    }+    default:+      console.log('chip-fetcher-lite <serve|add|list|jobs|app>');+  }+}+main().catch(e => { console.error(e.message); process.exit(1); });
install.shadded+16
@@ -0,0 +1,16 @@+#!/bin/bash+set -e+PKG_DIR="$(cd "$(dirname "$0")" && pwd)"+SKILLS_DIR="$HOME/.claude/skills"+DEST="$SKILLS_DIR/chip-fetcher-lite"+mkdir -p "$DEST"+cp -r "$PKG_DIR/server.js" "$PKG_DIR/cli.js" "$PKG_DIR/public" "$PKG_DIR/SKILL.md" "$DEST/" 2>/dev/null || true+[ -f "$PKG_DIR/README.md" ] && cp "$PKG_DIR/README.md" "$DEST/README.md"+# expose the CLI+mkdir -p "$HOME/.local/bin"+cat > "$HOME/.local/bin/chip-fetcher-lite" <<EOF+#!/bin/bash+exec node "$DEST/cli.js" "\$@"+EOF+chmod +x "$HOME/.local/bin/chip-fetcher-lite"+echo "Installed chip-fetcher-lite"
package.jsonadded+27
@@ -0,0 +1,27 @@+{+  "slug": "chip-fetcher-lite",+  "title": "Chip Fetcher Lite",+  "version": "1.0.0",+  "type": "app",+  "description": "Lightweight wiki-first component staging area. Add a component, check the Adom wiki for KiCad/Altium/Fusion files, upload what's missing, then file the symbol + footprint + 3D model straight into your EDA libraries — with library destinations matched per manufacturer and a clean 5-field symbol.",+  "tags": ["eda", "kicad", "altium", "fusion", "components", "cad", "library", "passives", "jlcpcb", "footprint", "symbol", "staging", "chip-fetcher"],+  "hero": { "path": "screenshots/hero.png" },+  "dependencies": {},+  "files": [+    "screenshots/hero.png",+    "server.js",+    "cli.js",+    "public/index.html",+    "SKILL.md",+    "README.md",+    "DEVELOPING.md",+    "install.sh",+    "uninstall.sh",+    "package.json"+  ],+  "scripts": {+    "start": "node server.js",+    "install": "./install.sh",+    "uninstall": "./uninstall.sh"+  }+}
public/index.htmladded+487
@@ -0,0 +1,487 @@+<!DOCTYPE html>+<html lang="en">+<head>+<meta charset="utf-8" />+<meta name="viewport" content="width=device-width, initial-scale=1" />+<title>chip-fetcher-lite</title>+<style>+  /* Adom brand fonts — Familjen Grotesk (headlines), Satoshi (body), JetBrains Mono (code).+     Per the Adom brand style guide (wiki: brand). Never fall back to system fonts as the primary face. */+  @font-face{font-family:'Familjen Grotesk';src:url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-700-normal.woff2') format('woff2');font-weight:700;font-display:swap}+  @font-face{font-family:'Familjen Grotesk';src:url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-500-normal.woff2') format('woff2');font-weight:500;font-display:swap}+  @font-face{font-family:'Satoshi';src:url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2');font-weight:400;font-display:swap}+  @font-face{font-family:'Satoshi';src:url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2');font-weight:500;font-display:swap}+  @font-face{font-family:'JetBrains Mono';src:url('https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx3cwhsk.woff2') format('woff2');font-weight:400;font-display:swap}+  @font-face{font-family:'JetBrains Mono';src:url('https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxTcwhsk.woff2') format('woff2');font-weight:500;font-display:swap}+  :root{+    --bg:#0d1117; --surface:#161b22; --elevated:#1c2128; --overlay:#21262d;+    --border:#30363d; --border-muted:#21262d;+    --text:#e6edf3; --text-muted:#8b949e; --text-dim:#484f58;+    --accent:#00b8b0; --accent-bright:#00e6dc; --accent-dim:rgba(0,184,176,.12); --accent-border:rgba(0,184,176,.25);+    --green:#3fb950; --green-dim:rgba(63,185,80,.14);+    --yellow:#d29922; --yellow-dim:rgba(210,153,34,.14);+    --red:#f85149; --red-dim:rgba(248,81,73,.14);+    --purple:#8C6BF7; --row-hover:#1c2128;+  }+  *{box-sizing:border-box}+  html{height:100%}+  body{margin:0;min-height:100%}+  body{background:var(--bg);color:var(--text);font-family:'Satoshi',-apple-system,Segoe UI,Roboto,sans-serif;font-size:13px}+  .mono{font-family:'JetBrains Mono',ui-monospace,Menlo,Consolas,monospace}+  h1,h2,h3{font-family:'Familjen Grotesk',sans-serif;margin:0}+  button{font-family:inherit;cursor:pointer}++  /* header */+  .app-header{display:flex;align-items:center;gap:12px;height:44px;padding:0 14px;background:var(--surface);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:30}+  .brand{display:flex;align-items:center;gap:9px}+  .logo-chip{display:block;color:var(--accent-bright)}+  .app-name{font-family:'Familjen Grotesk',sans-serif;font-weight:600;font-size:15px;letter-spacing:.2px;color:var(--accent-bright)}+  .sep{color:var(--text-dim)}+  .app-subtitle{color:var(--text-muted);font-size:12px}+  .subject{margin-left:6px;color:var(--text-muted);font-size:12px}+  .subject b{color:var(--text);font-weight:600}+  .spacer{flex:1}+  .lib-status{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--text-muted);margin-right:6px}+  .lib-status .dot{width:8px;height:8px;border-radius:50%;background:var(--text-dim);flex:none}+  .lib-status.ok .dot{background:var(--green);box-shadow:0 0 6px var(--green)}+  .lib-status.warn .dot{background:var(--yellow)}+  .lib-status.none .dot{background:var(--red)}+  .send-status{display:inline-flex;align-items:center;gap:7px;font-size:12px;padding:4px 11px;border-radius:7px;margin-left:12px;font-weight:600;max-width:520px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}+  .send-status.running{background:var(--accent-dim);border:1px solid var(--accent-border);color:var(--text)}+  .send-status.done{background:var(--green-dim);border:1px solid var(--green);color:var(--text)}+  .send-status.err{background:var(--red-dim);border:1px solid var(--red);color:var(--text)}++  /* controls bar */+  .controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:10px 14px;background:var(--bg);border-bottom:1px solid var(--border-muted);position:sticky;top:var(--header-h,44px);z-index:20}+  .add-box{display:flex;align-items:center;gap:6px;flex:1;min-width:260px;max-width:520px}+  input[type=text]{background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:7px 10px;font-size:13px;width:100%}+  input[type=text]:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-dim)}+  .btn{background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:7px 12px;font-size:12.5px;font-weight:600;transition:.15s;white-space:nowrap}+  .btn:hover{border-color:var(--accent-border);background:var(--elevated)}+  .btn.primary{background:var(--accent);border-color:var(--accent);color:#04231f}+  .btn.primary:hover{background:var(--accent-bright)}+  .btn:disabled{opacity:.45;cursor:not-allowed}+  .pills{display:flex;gap:6px}+  .pill{background:transparent;border:1px solid var(--border);color:var(--text-muted);border-radius:999px;padding:5px 11px;font-size:12px}+  .pill.active{color:var(--text);border-color:var(--accent-border);background:var(--accent-dim)}+  .send-group{display:flex;align-items:center;gap:8px;padding:5px 9px;border:1px solid var(--border);border-radius:8px;background:var(--surface)}+  .send-group .lbl{color:var(--text-muted);font-size:11px;text-transform:uppercase;letter-spacing:.4px}+  .eda-checks{display:flex;gap:8px}+  .eda-checks label{display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-muted);cursor:pointer;user-select:none}+  .eda-checks input{accent-color:var(--accent)}++  /* table */+  .wrap{padding:0 0 60px}+  table{width:100%;border-collapse:collapse}+  thead th{position:sticky;top:var(--stick-top,96px);z-index:10;background:var(--surface);color:var(--text-muted);font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.5px;text-align:left;padding:8px 12px;border-bottom:1px solid var(--border)}+  .cat-row td{background:var(--elevated);border-top:1px solid var(--border);border-bottom:1px solid var(--border-muted);padding:6px 12px}+  .cat-name{font-family:'Satoshi',sans-serif;font-weight:600;font-size:12px;letter-spacing:.6px;text-transform:uppercase;color:var(--accent-bright)}+  .cat-count{color:var(--text-dim);font-size:11px;margin-left:8px}+  tbody tr.item{border-bottom:1px solid var(--border-muted);cursor:pointer}+  tbody tr.item:hover{background:var(--row-hover)}+  tbody tr.item.sel{background:var(--accent-dim);box-shadow:inset 3px 0 0 var(--accent)}+  td{padding:8px 12px;vertical-align:middle}+  td.sel-col{width:34px;text-align:center}+  input[type=checkbox]{accent-color:var(--accent);width:15px;height:15px;cursor:pointer}+  .name{font-weight:600}+  .name .mpn{color:var(--text-muted);font-size:11px;font-weight:400}+  .cdesc{color:var(--text-muted);font-size:11px;font-weight:400;margin-top:2px;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}+  .dest{color:var(--text);font-size:12px}+  .dest.empty{color:var(--text-dim)}+  .dest .basename{cursor:default;border-bottom:1px dotted var(--text-dim)}+  .namecell{min-width:180px}+  .nameinput{background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:5px;padding:4px 7px;font-size:12px;width:100%;font-family:'JetBrains Mono',ui-monospace,monospace}+  .nameinput:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-dim)}+  .destsub{font-size:10.5px;color:var(--text-dim);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}+  .destsub .basename{border-bottom:1px dotted var(--text-dim);cursor:default;color:var(--text-muted)}+  .destsub .nodest{font-style:italic}++  /* EDA file-availability boxes — one per EDA, each colours on its own */+  .eda-boxes{display:flex;gap:6px}+  .checking-box{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);border-radius:8px;padding:5px 10px;font-size:11px;color:var(--text-dim)}+  .eda-box{position:relative;display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border:1px solid var(--border);border-radius:8px;background:var(--surface);font-size:11.5px;font-weight:600;cursor:default}+  .eda-box .tally{font-family:'JetBrains Mono',monospace;font-size:9.5px;opacity:.85}+  .eda-box.jlc{cursor:default}+  .newtag{font-size:9px;font-weight:700;letter-spacing:.3px;text-transform:uppercase;color:var(--accent-bright);border:1px solid var(--accent-border);background:var(--accent-dim);border-radius:4px;padding:0 4px;vertical-align:1px}+  .deftag{font-size:9px;font-weight:700;letter-spacing:.3px;text-transform:uppercase;color:var(--purple-light);border:1px solid rgba(140,107,247,.4);background:rgba(140,107,247,.14);border-radius:4px;padding:0 4px;vertical-align:1px}+  .nameinput.locked{opacity:.7;cursor:not-allowed;border-style:dashed}+  .eda-box.green{border-color:var(--green);background:var(--green-dim);color:var(--text)}+  .eda-box.yellow{border-color:var(--yellow);background:var(--yellow-dim);color:var(--text)}+  .eda-box.red{border-color:var(--red);background:var(--red-dim);color:var(--text-muted)}+  .eda-pop{position:absolute;top:calc(100% + 6px);left:0;z-index:40;display:none;flex-direction:column;gap:5px;background:var(--overlay);border:1px solid var(--border);border-radius:8px;padding:9px 11px;min-width:140px;box-shadow:0 12px 34px rgba(0,0,0,.55)}+  .eda-box:hover .eda-pop{display:flex}+  .eda-pop .pophdr{font-size:10px;letter-spacing:.5px;text-transform:uppercase;color:var(--text-dim);margin-bottom:1px}+  .eda-pop .pk{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-dim)}+  .eda-pop .pk.on{color:var(--text)}+  .eda-pop .cb{width:15px;height:15px;border:1px solid var(--text-dim);border-radius:4px;display:inline-flex;align-items:center;justify-content:center;font-size:11px;color:#04231f;flex:none}+  .eda-pop .pk.on .cb{background:var(--green);border-color:var(--green)}+  .eda-pop .pk .tag{margin-left:auto;font-size:9px;color:var(--text-dim);font-style:italic}+  .spin{width:12px;height:12px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;display:inline-block;animation:sp .7s linear infinite;vertical-align:-2px}+  @keyframes sp{to{transform:rotate(360deg)}}++  .row-actions{display:flex;gap:6px;justify-content:flex-end}+  .icon-btn{background:var(--surface);border:1px solid var(--border);color:var(--text-muted);border-radius:6px;width:30px;height:28px;display:inline-flex;align-items:center;justify-content:center;font-size:13px}+  .icon-btn:hover{border-color:var(--accent-border);color:var(--text)}+  .icon-btn.del:hover{border-color:var(--red);color:var(--red)}+  .icon-btn:disabled{opacity:.3;cursor:not-allowed}+  .icon-btn:disabled:hover{border-color:var(--border);color:var(--text-muted)}+  .badge3d{font-size:10px;color:var(--text-dim);border:1px solid var(--border);border-radius:5px;padding:2px 5px}+  .badge3d.on{color:var(--green);border-color:var(--green)}++  .empty-state{text-align:center;color:var(--text-muted);padding:80px 20px}+  .empty-state h2{font-size:18px;margin-bottom:8px;color:var(--text)}++  /* modal */+  .modal-bg{position:fixed;inset:0;background:rgba(1,4,9,.7);backdrop-filter:blur(6px);display:none;align-items:center;justify-content:center;z-index:50}+  .modal-bg.show{display:flex}+  .modal{background:var(--overlay);border:1px solid var(--border);border-radius:12px;padding:22px;width:440px;max-width:92vw;box-shadow:0 20px 60px rgba(0,0,0,.5)}+  .modal h2{font-size:17px;margin-bottom:6px}+  .modal p{color:var(--text-muted);margin:0 0 16px;line-height:1.5}+  .modal .eda-pick{display:flex;flex-direction:column;gap:10px;margin-bottom:18px}+  .modal .eda-pick label{display:flex;align-items:center;gap:9px;padding:10px 12px;border:1px solid var(--border);border-radius:8px;cursor:pointer;font-size:13px}+  .modal .eda-pick label:hover{border-color:var(--accent-border);background:var(--accent-dim)}+  .modal-actions{display:flex;gap:8px;justify-content:flex-end}++  /* toast */+  #toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:var(--elevated);border:1px solid var(--accent-border);color:var(--text);padding:10px 16px;border-radius:9px;font-size:12.5px;z-index:60;opacity:0;transition:.2s;pointer-events:none}+  #toast.show{opacity:1}+</style>+</head>+<body>+  <header class="app-header">+    <div class="brand">+      <svg class="logo-chip" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">+        <rect x="7" y="7" width="10" height="10" rx="1.5"/>+        <rect x="10.5" y="10.5" width="3" height="3" rx=".5"/>+        <line x1="10" y1="7" x2="10" y2="4"/><line x1="14" y1="7" x2="14" y2="4"/>+        <line x1="10" y1="17" x2="10" y2="20"/><line x1="14" y1="17" x2="14" y2="20"/>+        <line x1="7" y1="10" x2="4" y2="10"/><line x1="7" y1="14" x2="4" y2="14"/>+        <line x1="17" y1="10" x2="20" y2="10"/><line x1="17" y1="14" x2="20" y2="14"/>+      </svg>+      <span class="app-name">chip-fetcher-lite</span>+    </div>+    <span class="subject">· <b id="count">0</b> components</span>+    <span class="send-status" id="sendStatus" style="display:none"></span>+    <span class="spacer"></span>+    <span class="lib-status" id="libStatus"><span class="dot"></span><span class="txt">libraries not probed</span></span>+    <button class="btn" id="probeBtn" title="Probe your EDA libraries">◎ Probe libraries</button>+  </header>++  <div class="controls">+    <div class="add-box">+      <input type="text" id="addInput" placeholder="Add a component — MPN or name, then Enter" autocomplete="off" />+      <button class="btn primary" id="addBtn">+ Add</button>+    </div>+    <div class="pills" id="pills">+      <button class="pill active" data-f="all">All</button>+      <button class="pill" data-f="green">Complete</button>+      <button class="pill" data-f="yellow">Partial</button>+      <button class="pill" data-f="red">Missing</button>+    </div>+    <span class="spacer"></span>+    <button class="btn" id="selectAll">✓ Select all</button>+    <div class="send-group">+      <span class="lbl">send to</span>+      <div class="eda-checks" id="sendEdas">+        <label><input type="checkbox" value="kicad" checked> KiCad</label>+        <label><input type="checkbox" value="altium"> Altium</label>+        <label><input type="checkbox" value="fusion"> Fusion</label>+      </div>+      <button class="btn primary" id="sendBtn">↑ Send to library</button>+    </div>+  </div>++  <div class="wrap">+    <table>+      <thead><tr>+        <th class="sel-col"></th>+        <th>Component</th>+        <th>Symbol name → dest</th>+        <th>Footprint name → dest</th>+        <th>3D dest</th>+        <th id="edaHead">EDA Files Available</th>+        <th style="text-align:right">Actions</th>+      </tr></thead>+      <tbody id="tbody"></tbody>+    </table>+    <div class="empty-state" id="empty" style="display:none">+      <h2>No components yet</h2>+      <div>Add a component above. It'll be checked against the Adom wiki for KiCad, Altium &amp; Fusion files.</div>+    </div>+  </div>++  <!-- probe modal -->+  <div class="modal-bg" id="probeModal">+    <div class="modal">+      <h2>Probe your EDA libraries?</h2>+      <p>To show where each component will be filed, I can scan your local libraries. Pick which EDA software you use:</p>+      <div class="eda-pick">+        <label><input type="checkbox" value="kicad" checked> <span>KiCad</span></label>+        <label><input type="checkbox" value="altium"> <span>Altium</span></label>+        <label><input type="checkbox" value="fusion"> <span>Fusion 360</span></label>+      </div>+      <div class="modal-actions">+        <button class="btn" id="probeSkip">Skip for now</button>+        <button class="btn primary" id="probeGo">⌖ Probe my libraries</button>+      </div>+    </div>+  </div>++  <input type="file" id="fileInput" style="display:none" />+  <div id="toast"></div>++<script>+const $ = s => document.querySelector(s);+let STATE = { components:[], probe:{edas:[]}, jobs:[] };+let FILTER = 'all';+let uploadTarget = null;++function toast(msg){ const t=$('#toast'); t.textContent=msg; t.classList.add('show'); clearTimeout(t._t); t._t=setTimeout(()=>t.classList.remove('show'),2600); }+// Resolve API paths relative to the page (works behind the Hydrogen proxy at /proxy/<port>/).+function apiUrl(path){ return new URL('api'+path, document.baseURI).href; }+async function api(path, opts){ const r = await fetch(apiUrl(path), opts); if(!r.ok && r.status!==409){ const e=await r.json().catch(()=>({})); throw Object.assign(new Error(e.error||r.statusText),{status:r.status,body:e}); } return r.status===409 ? Object.assign({_conflict:true}, await r.json()) : r.json(); }++// EDAs the user actually probed for; before any probe, show all three.+function activeEdas(){+  const e=(STATE.probe&&STATE.probe.edas)||[];+  const v=e.filter(x=>['kicad','altium','fusion'].includes(x));+  return v.length?v:['kicad','altium','fusion'];+}+const KINDS=[['symbol','Symbol'],['footprint','Footprint'],['model3d','3D']];+// each EDA box colours independently by how many of symbol/footprint/3d it has+function edaColor(c,eda){+  const e=c.eda[eda]||{}; const n=['symbol','footprint','model3d'].filter(k=>e[k]&&e[k].present).length;+  return n===3?'green':n>=1?'yellow':'red';+}+// component-level status (for the filter pills): green=all active boxes green, red=all red, else yellow+function compStatus(c){+  if(c.wiki.status==='checking') return 'checking';+  const cols=['kicad','altium','fusion'].map(e=>edaColor(c,e));+  if(cols.every(x=>x==='green')) return 'green';+  if(cols.every(x=>x==='red')) return 'red';+  return 'yellow';+}+// editable library name (defaults to MPN) + the destination it'll be filed into+function destBasename(d){+  if(!d||!d.name) return '<span class="nodest">no destination yet</span>';+  let tag='';+  if(d.source==='kicad-default') tag=' <span class="deftag">KiCad default</span>';+  else if(d.exists===false) tag=' <span class="newtag">new</span>';+  return `→ <span class="basename" title="${esc(d.path||d.name)}">${esc(d.name)}</span>${tag}`;+}+function nameDestCell(c, kind){+  const d=c.dest&&c.dest[kind];+  const nm=(c.names&&c.names[kind])!=null?c.names[kind]:c.mpn;+  const locked = kind==='footprint' && c.footprintLocked;+  const attrs = locked ? 'readonly class="nameinput locked" title="Standard KiCad footprint — name is fixed"' : `class="nameinput" title="Name this ${kind} will get in the library"`;+  return `<td class="namecell"><input ${attrs} data-name="${kind}" data-id="${c.id}" value="${esc(nm)}" spellcheck="false"><div class="destsub">${destBasename(d)}</div></td>`;+}+function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m])); }++const EDA_NAME={kicad:'KiCad',altium:'Altium',fusion:'Fusion'};+function edaBox(c,eda){+  const e=c.eda[eda]||{};+  const n=['symbol','footprint','model3d'].filter(k=>e[k]&&e[k].present).length;+  const rows=KINDS.map(([k,label])=>{+    const v=e[k]||{}; const on=v.present;+    const tag=on?`<span class="tag">${v.source==='upload'?'uploaded':'wiki'}</span>`:'';+    return `<div class="pk ${on?'on':''}"><span class="cb">${on?'✓':''}</span>${label}${tag}</div>`;+  }).join('');+  return `<div class="eda-box ${edaColor(c,eda)}" title="Hover for symbol / footprint / 3D status">${EDA_NAME[eda]} <span class="tally">${n}/3</span>`++         `<div class="eda-pop"><div class="pophdr">${EDA_NAME[eda]} files on wiki</div>${rows}</div></div>`;+}+function jlcBox(c){+  const on=!!c.jlc;+  const row = on+    ? `<div class="pk on"><span class="cb">✓</span>${esc(c.jlcPart||'available')}</div>`+    : `<div class="pk"><span class="cb"></span>Part number not on wiki</div>`;+  return `<div class="eda-box jlc ${on?'green':'red'}" data-jlcedit="${c.id}" style="cursor:pointer"><span style="pointer-events:none">JLC</span><div class="eda-pop"><div class="pophdr">JLCPCB part number — click to edit</div>${row}</div></div>`;+}+function statusCell(c){+  if(c.wiki.status==='checking') return `<div class="checking-box"><span class="spin"></span>&nbsp;checking wiki…</div>`;+  // always show all three EDA boxes + a JLC presence box; probe selection only affects the status text+  return `<div class="eda-boxes">${['kicad','altium','fusion'].map(e=>edaBox(c,e)).join('')}${jlcBox(c)}</div>`;+}++const EDA_LABEL={kicad:'KiCad',altium:'Altium',fusion:'Fusion'};+function updateLibStatus(){+  const el=$('#libStatus'); if(!el) return;+  const p=STATE.probe||{};+  const found=p.libraries?Object.keys(p.libraries):[];+  let cls,txt;+  if(p.probedAt){+    if(found.length){ cls='ok'; txt='libraries found: '+found.map(e=>EDA_LABEL[e]||e).join(', '); }+    else { cls='none'; txt='no libraries found'; }+  } else if(p.status==='requested'){ cls='warn'; txt='probing libraries…'; }+  else { cls='none'; txt='libraries not probed'; }+  el.className='lib-status '+cls;+  el.querySelector('.txt').textContent=txt;+}+function updateSendStatus(){+  const el=$('#sendStatus'); if(!el) return;+  const s=STATE.send||{};+  if(s.status==='running'){ el.style.display='inline-flex'; el.className='send-status running'; el.innerHTML=`<span class="spin"></span> Sending ${s.done||0}/${s.total||0} — ${esc(s.current||'')}`; }+  else if(s.status==='done'){ el.style.display='inline-flex'; el.className='send-status done'; el.textContent='✓ '+(s.current||'Sent to library'); }+  else if(s.status==='error'){ el.style.display='inline-flex'; el.className='send-status err'; el.textContent='Send error: '+(s.current||''); }+  else { el.style.display='none'; }+}+function updateStickyOffsets(){+  const h=$('.app-header'), c=$('.controls'); if(!h||!c) return;+  const hh=h.offsetHeight, ch=c.offsetHeight;+  document.documentElement.style.setProperty('--header-h', hh+'px');+  document.documentElement.style.setProperty('--stick-top', (hh+ch)+'px');+}+window.addEventListener('resize', updateStickyOffsets);+function render(){+  $('#count').textContent = STATE.components.length;+  updateLibStatus();+  updateSendStatus();+  updateStickyOffsets();+  const list = STATE.components.filter(c=>{+    if(FILTER==='all') return true;+    if(c.wiki.status==='checking') return false;+    return compStatus(c)===FILTER;+  });+  $('#empty').style.display = STATE.components.length? 'none':'block';+  // group by type+  const groups={};+  for(const c of list){ const t=c.type||'IC / Other'; (groups[t]=groups[t]||[]).push(c); }+  const tbody=$('#tbody'); tbody.innerHTML='';+  const types=Object.keys(groups).sort();+  for(const t of types){+    const cat=document.createElement('tr'); cat.className='cat-row';+    cat.innerHTML=`<td colspan="7"><span class="cat-name">${esc(t)}</span><span class="cat-count">${groups[t].length}</span></td>`;+    tbody.appendChild(cat);+    for(const c of groups[t]){+      const tr=document.createElement('tr'); tr.className='item'+(c.selected?' sel':''); tr.dataset.id=c.id;+      const d3 = c.dest&&c.dest.model3d;+      const has3d = c.wikiFiles && c.wikiFiles['3d'];+      tr.innerHTML =+        `<td class="sel-col"><input type="checkbox" ${c.selected?'checked':''} data-sel="${c.id}"></td>`++        `<td class="name">${esc(c.name)}${c.description?`<div class="cdesc" title="Symbol description">${esc(c.description)}</div>`:''}</td>`++        nameDestCell(c,'symbol')++        nameDestCell(c,'footprint')++        `<td class="dest ${d3&&d3.name?'':'empty'}">${d3&&d3.name?`<span class="basename" title="${esc(d3.path||d3.name)}">${esc(d3.name)}</span>${d3.exists===false?' <span class="newtag">new</span>':''}`:(has3d?'<span class="badge3d on">3D on wiki</span>':'—')}</td>`++        `<td>${statusCell(c)}</td>`++        `<td><div class="row-actions">`++          (c.wiki&&c.wiki.url+            ? `<button class="icon-btn" data-wiki="${esc(c.wiki.url)}" title="Open wiki page: ${esc(c.wiki.url)}">↗</button>`+            : `<button class="icon-btn" disabled title="No wiki page found">↗</button>`)++          `<button class="icon-btn" data-up="${c.id}" title="Upload a file">⬆</button>`++          `<button class="icon-btn" data-check="${c.id}" title="Re-check wiki">↻</button>`++          `<button class="icon-btn del" data-del="${c.id}" title="Remove">✕</button>`++        `</div></td>`;+      tbody.appendChild(tr);+    }+  }+}++// ---- events+$('#addBtn').onclick = addComponent;+$('#addInput').addEventListener('keydown', e=>{ if(e.key==='Enter') addComponent(); });+async function addComponent(){+  const inp=$('#addInput'); const name=inp.value.trim(); if(!name) return;+  inp.value='';+  try{ const c=await api('/components',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})});+    if(c._duplicate || STATE.components.some(x=>x.id===c.id)){ toast(`${c.name} is already in the list`); render(); return; }+    STATE.components.unshift(c); render(); toast(`Added ${name} — checking wiki…`);+  }catch(e){ toast('Add failed: '+e.message); }+}++$('#tbody').addEventListener('click', async e=>{+  const t=e.target;+  if(t.dataset.del){ if(!confirm('Remove this component and its staged files?'))return; await api(`/components/${t.dataset.del}`,{method:'DELETE'}); STATE.components=STATE.components.filter(x=>x.id!==t.dataset.del); render(); return; }+  if(t.dataset.check){ const id=t.dataset.check; const c=STATE.components.find(x=>x.id===id); if(c){c.wiki.status='checking';render();} const nc=await api(`/components/${id}/check`,{method:'POST'}); replaceComp(nc); render(); return; }+  if(t.dataset.up){ uploadTarget=t.dataset.up; $('#fileInput').value=''; $('#fileInput').click(); return; }+  if(t.dataset.wiki){ window.open(t.dataset.wiki,'_blank','noopener'); return; }+  const jb=t.closest('[data-jlcedit]');+  if(jb){ const id=jb.dataset.jlcedit; const c=STATE.components.find(x=>x.id===id); if(!c)return;+    const pn=prompt('JLCPCB / LCSC part number for '+c.name+':', c.jlcPart||'');+    if(pn===null) return;+    const nc=await api(`/components/${id}/jlc`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({jlcPart:pn.trim()})});+    replaceComp(nc); render(); toast(pn.trim()?('JLC PN set: '+pn.trim()):'JLC PN cleared'); return; }+  if(t.dataset.sel!==undefined){ return setSelected(t.dataset.sel, t.checked); } // checkbox+  // click anywhere else on the row toggles selection (but not on inputs/buttons/eda boxes)+  if(t.closest('input,button,.eda-box,.nameinput,.icon-btn,a')) return;+  const tr=t.closest('tr.item'); if(!tr) return;+  const c=STATE.components.find(x=>x.id===tr.dataset.id); if(!c) return;+  setSelected(tr.dataset.id, !c.selected);+});+async function setSelected(id, on){+  const c=STATE.components.find(x=>x.id===id); if(c) c.selected=!!on; render();+  try{ await api(`/components/${id}/select`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({on:!!on})}); }catch(e){}+}++// editable symbol/footprint library names+$('#tbody').addEventListener('change', async e=>{+  const t=e.target; if(t.dataset.name===undefined) return;+  const id=t.dataset.id, kind=t.dataset.name, val=t.value.trim();+  const c=STATE.components.find(x=>x.id===id); if(c){ c.names=c.names||{}; c.names[kind]=val; }+  try{ await api(`/components/${id}/names`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({[kind]:val})}); toast(`${kind[0].toUpperCase()+kind.slice(1)} name → ${val}`); }+  catch(err){ toast('Rename failed: '+err.message); }+});+$('#tbody').addEventListener('keydown', e=>{ if(e.target.classList.contains('nameinput') && e.key==='Enter') e.target.blur(); });++$('#fileInput').addEventListener('change', async e=>{+  const f=e.target.files[0]; if(!f||!uploadTarget) return;+  const id=uploadTarget; const buf=await f.arrayBuffer();+  const q=`?filename=${encodeURIComponent(f.name)}`;+  try{+    let res=await api(`/components/${id}/upload${q}`,{method:'POST',body:buf});+    if(res._conflict){+      if(!confirm(`A ${res.existing.eda} ${res.existing.kind} file (${res.existing.name}) is already staged. Replace it with ${f.name}?`)) return;+      res=await api(`/components/${id}/upload${q}&replace=1`,{method:'POST',body:buf});+    }+    replaceComp(res); render(); toast(`Uploaded ${f.name}`);+  }catch(err){ toast('Upload failed: '+err.message); }+});++$('#pills').addEventListener('click', e=>{ if(!e.target.dataset.f)return; FILTER=e.target.dataset.f; [...$('#pills').children].forEach(p=>p.classList.toggle('active',p.dataset.f===FILTER)); render(); });++let allOn=false;+$('#selectAll').onclick=async()=>{ allOn=!allOn; await api('/select-all',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({on:allOn})}); STATE.components.forEach(c=>c.selected=allOn); $('#selectAll').textContent=(allOn?'✕ Deselect all':'✓ Select all'); render(); };++$('#sendBtn').onclick=async()=>{+  const ids=STATE.components.filter(c=>c.selected).map(c=>c.id);+  if(!ids.length){ toast('Select components first'); return; }+  const edas=[...$('#sendEdas').querySelectorAll('input:checked')].map(i=>i.value);+  if(!edas.length){ toast('Pick at least one EDA target'); return; }+  try{+    await api('/send-to-library',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids,edas})});+    toast(`Sending ${ids.length} component(s) to ${edas.join(', ')}…`); poll();+  }catch(e){ toast('Send failed: '+e.message); }+};++// ---- probe+function showProbe(){ $('#probeModal').classList.add('show'); }+$('#probeBtn').onclick=showProbe;+$('#probeSkip').onclick=()=>$('#probeModal').classList.remove('show');+$('#probeGo').onclick=async()=>{+  const edas=[...$('#probeModal').querySelectorAll('.eda-pick input:checked')].map(i=>i.value);+  if(!edas.length){ toast('Pick at least one EDA'); return; }+  await api('/probe-request',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({edas})});+  // sync the send-to targets to what they use+  $('#sendEdas').querySelectorAll('input').forEach(i=>i.checked=edas.includes(i.value));+  $('#probeModal').classList.remove('show');+  toast('Probe requested — the agent will scan your '+edas.join(', ')+' libraries.');+};++function replaceComp(nc){ const i=STATE.components.findIndex(x=>x.id===nc.id); if(i>=0)STATE.components[i]=nc; }++// ---- poll+async function poll(){+  try{ const s=await api('/state'); STATE=s;+    // don't rebuild the table out from under an in-progress edit+    if(!document.activeElement || !document.activeElement.classList.contains('nameinput')) render();+  }catch(e){}+}+async function init(){+  await poll();+  // offer the probe on first launch if not done yet+  if(!STATE.probe || (!STATE.probe.probedAt && STATE.probe.status!=='requested')) showProbe();+  setInterval(poll, 2500);+}+init();+</script>+</body>+</html>
screenshots/hero.pngadded
⋯ 1 unchanged line ⋯
server.jsadded+864
@@ -0,0 +1,864 @@+#!/usr/bin/env node+'use strict';+/*+ * chip-fetcher-lite — backend+ *+ * Pure-Node http server (no native deps). Serves the SPA and a small REST API.+ * Storage is a single JSON file + a staging dir for uploaded files.+ *+ *   GET  /api/state                       -> { components, probe, jobs }+ *   POST /api/components        {name}     -> add a row (status "checking")+ *   POST /api/components/:id/check         -> run wiki lookup + file detection+ *   POST /api/components/:id/select {on}   -> toggle row highlight+ *   POST /api/select-all        {on}       -> toggle every row+ *   POST /api/components/:id/upload?kind=&filename=  (raw body) -> stage a file+ *   DELETE /api/components/:id+ *   POST /api/probe-request     {edas}     -> queue a library-probe job for the agent+ *   POST /api/probe             {edas, libraries}  -> agent posts probe results+ *   POST /api/send-to-library   {ids, edas}-> queue a send job for the agent+ *   GET  /api/jobs                         -> pending agent jobs+ *   POST /api/jobs/:id/complete {result}   -> agent marks a job done+ */++const http = require('http');+const fs = require('fs');+const os = require('os');+const path = require('path');+const crypto = require('crypto');+const { execFile } = require('child_process');++const ROOT = __dirname;+const DATA_DIR = process.env.CFL_DATA_DIR || path.join(ROOT, 'data');+const DATA_FILE = path.join(DATA_DIR, 'data.json');+const STAGING_DIR = path.join(DATA_DIR, 'staging');+const PUBLIC_DIR = path.join(ROOT, 'public');+const PORT = parseInt(process.env.PORT || process.env.CFL_PORT || '7821', 10);+const WIKI = process.env.CFL_WIKI || 'https://wiki.adom.inc';++fs.mkdirSync(STAGING_DIR, { recursive: true });++// ---------------------------------------------------------------- data store+function load() {+  try { return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); }+  catch { return { components: [], probe: { edas: [], libraries: {}, probedAt: null }, jobs: [] }; }+}+let DB = load();+if (!DB.send) DB.send = { status: 'idle', total: 0, done: 0, current: '', errors: [] };+let saveTimer = null;+function save() {+  clearTimeout(saveTimer);+  saveTimer = setTimeout(() => {+    fs.mkdirSync(DATA_DIR, { recursive: true });+    fs.writeFileSync(DATA_FILE, JSON.stringify(DB, null, 2));+  }, 50);+}+function uid() { return crypto.randomBytes(8).toString('hex'); }+function find(id) { return DB.components.find(c => c.id === id); }++// bounded wiki-check queue so mass-adds don't spawn hundreds of `adompkg` processes at once+const CHECK_CONCURRENCY = 4;+let _checkQueue = [], _checkActive = 0;+function enqueueCheck(id) { _checkQueue.push(id); pumpChecks(); }+function pumpChecks() {+  while (_checkActive < CHECK_CONCURRENCY && _checkQueue.length) {+    const c = find(_checkQueue.shift()); if (!c) continue;+    _checkActive++;+    runCheck(c).catch(e => { c.wiki.status = 'error'; c.type = 'IC / Other'; c.error = String(e && e.message || e); })+      .finally(() => { _checkActive--; save(); pumpChecks(); });+  }+}++// ---------------------------------------------------------------- wiki logic+//  (ported from chip-fetcher-wiki-flow / cf-wiki-lookup.py)+function norm(mpn) {+  return mpn.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');+}+const SUFFIX = /(tr|t6|t7|tb|tg|ar|ag|-?reel|-?cut|-?tape)$/i;+function stem(slug) { return (slug.replace(SUFFIX, '').replace(/^-+|-+$/g, '')) || slug; }+const PASSIVE_HINT = /\b(0201|0402|0603|0805|1206|1210|resistor|capacitor|mlcc|inductor|ferrite|led)\b/i;+function sharedPrefix(a, b) { let n = 0; const L = Math.min(a.length, b.length); while (n < L && a[n] === b[n]) n++; return n; }++// `adompkg search` -> [{slug,type,title,desc}]+function adompkgSearch(q) {+  return new Promise((resolve) => {+    execFile('adompkg', ['search', q], { timeout: 45000, maxBuffer: 4 << 20, env: process.env },+      (err, stdout) => {+        if (err && !stdout) return resolve([]);+        const rows = []; let cur = null;+        const ENTRY = /^(\S+)\s{2,}([A-Za-z][\w-]*)\s{2,}(.*)$/;+        for (const line of (stdout || '').split('\n')) {+          const m = ENTRY.exec(line);+          if (m) { cur = { slug: m[1], type: m[2], title: m[3].trim(), desc: '' }; rows.push(cur); }+          else if (line.trim() && cur && /^\s/.test(line)) cur.desc = (cur.desc + ' ' + line.trim()).trim();+        }+        resolve(rows);+      });+  });+}++// the MPN is the head of a result title, e.g. "0402B104J160CT — 100nF MLCC Capacitor" -> "0402B104J160CT"+function titleMpn(r) {+  const t = (r.title || '').trim();+  return (t.split(/\s+[—–-]\s+/)[0].trim()) || t.split(/\s+/)[0] || r.slug;+}+// pull a value (100nF, 10k, 4.7uF) + package (0402…) out of a descriptive passive string+function passiveTokens(s) {+  const pkg = (s.match(/\b(0201|0402|0603|0805|1206|1210|1812|2010|2512)\b/) || [])[0] || null;+  const vm = s.match(/(\d+(?:\.\d+)?)\s*([pnuµμmkKMG]?)\s*(f|h|r|ω|ohm|farad|henry)?\b/i);+  let val = null;+  if (vm && /[a-zµμω]/i.test((vm[2] || '') + (vm[3] || ''))) // require a unit/prefix so we don't grab package digits+    val = (vm[1] + (vm[2] || '') + (vm[3] || '')).toLowerCase().replace(/[µμ]/g, 'u');+  return { pkg, val };+}+async function classify(mpn) {+  const slug = norm(mpn);+  let rows = await adompkgSearch(mpn);+  const st = stem(slug);+  if (st !== slug) {+    const seen = new Set(rows.map(r => r.slug));+    const more = await adompkgSearch(st);+    rows = rows.concat(more.filter(r => !seen.has(r.slug)));+  }+  const comps = rows.filter(r => r.type === 'component');+  const passive = !!(PASSIVE_HINT.test(mpn) || comps.some(r => PASSIVE_HINT.test(r.desc)));++  // 1. exact: slug, the MPN parsed from a result title, or a title equal to the input+  let exact = comps.find(r => r.slug === slug || norm(titleMpn(r)) === slug || r.title.toLowerCase() === mpn.toLowerCase()) || null;++  // 2. description -> MPN: the user may type "100nF 0402 capacitor"; resolve to the real MPN+  //    page (the FTS already ranks it #1) by matching value + package against a passive result.+  if (!exact) {+    const tok = passiveTokens(mpn);+    const descriptive = (tok.pkg && tok.val) || /capacitor|resistor|inductor|\bled\b|ferrite|diode|mlcc/i.test(mpn);+    if (descriptive) {+      const hay = r => (r.title + ' ' + r.desc).toLowerCase().replace(/\s+/g, '');+      const isPassive = r => /capacitor|resistor|inductor|\bled\b|ferrite|diode|mlcc|oscillator|crystal/i.test(r.title + ' ' + r.desc);+      exact = comps.find(r => isPassive(r) && (!tok.val || hay(r).includes(tok.val)) && (!tok.pkg || hay(r).includes(tok.pkg))) || null;+    }+  }++  let variants = [];+  if (!exact) {+    const thresh = Math.max(6, Math.floor(slug.length * 0.6));+    for (const r of comps) {+      const sh = sharedPrefix(r.slug, slug);+      if (r.slug !== slug && (sh >= thresh || stem(r.slug) === st)) variants.push({ ...r, shared: sh });+    }+    variants.sort((a, b) => b.shared - a.shared);+    variants = variants.slice(0, 6);+  }+  const status = exact ? 'exact' : (variants.length ? 'variant' : 'none');+  return { slug, status, passive, exact, variants, resolvedMpn: exact ? titleMpn(exact) : null };+}++async function wikiJSON(p) {+  try {+    const res = await fetch(WIKI + p, { signal: AbortSignal.timeout(20000) });+    if (!res.ok) return null;+    return await res.json();+  } catch { return null; }+}++// Map a filename to an EDA bucket + kind+const EXT = [+  [/\.kicad_sym$/i,                 'kicad',  'symbol'],+  [/\.kicad_mod$/i,                 'kicad',  'footprint'],+  [/\.kicad_pcb$/i,                 'kicad',  'footprint'],+  [/\.pretty$/i,                    'kicad',  'footprint'],+  [/\.schlib$/i,                    'altium', 'symbol'],+  [/\.pcblib$/i,                    'altium', 'footprint'],+  [/\.intlib$/i,                    'altium', 'library'],+  [/\.lbr$/i,                       'fusion', 'library'],+  [/\.f3[dz]$/i,                    'fusion', 'library'],+  [/\.(step|stp|glb|stl|wrl)$/i,    '3d',     'model3d'],+];+function classifyFile(name) {+  for (const [re, eda, kind] of EXT) if (re.test(name)) return { eda, kind };+  return null;+}++// derive a grouping "type" for a component+function deriveType(meta, passive, mpn) {+  if (meta && meta.category && meta.category !== 'library' && meta.category !== 'other')+    return meta.category;+  const s = (mpn + ' ' + (meta && meta.package || '')).toLowerCase();+  if (/resistor/.test(s)) return 'Resistor';+  if (/capacitor|mlcc/.test(s)) return 'Capacitor';+  if (/inductor|ferrite/.test(s)) return 'Inductor';+  if (/\bled\b/.test(s)) return 'LED';+  if (/crystal|oscillator|mhz|khz/.test(s)) return 'Crystal / Oscillator';+  if (passive) return 'Passive';+  if (meta && meta.category) return meta.category === 'library' ? 'IC / Other' : meta.category;+  return 'IC / Other';+}++// ---- build the symbol's Description field (key specs per component type)+const POWER_BY_SIZE = { '0201': '1/20W', '0402': '1/16W', '0603': '1/10W', '0805': '1/8W', '1206': '1/4W', '1210': '1/2W', '2010': '3/4W', '2512': '1W' };+function sizeOf(c) { return c.package || (`${c.name || ''} ${c.mpn || ''}`.match(/\b(0201|0402|0603|0805|1206|1210|1812|2010|2512)\b/) || [])[1] || null; }+function pick(re, txt) { const m = (txt || '').match(re); return m ? (m[1] || m[0]).replace(/\s+/g, '') : null; }+function buildSymbolDescription(c, brief) {+  const t = `${c.type || ''} ${c.subcategory || ''}`;+  const txt = [brief, c.name, c.mpn].filter(Boolean).join(' ');+  const size = sizeOf(c);+  const tol = pick(/[±+]\s*\d+(?:\.\d+)?\s*%/, txt);+  if (/capacitor|mlcc/i.test(t)) {+    const val = pick(/\d+(?:\.\d+)?\s*[pnuµμm]?F\b/i, txt);+    const volt = pick(/\d+(?:\.\d+)?\s*V\b/i, txt);+    const diel = pick(/\b(?:C0G|NP0|X7R|X5R|X7S|X6S|Y5V|Z5U)\b/i, txt);+    return [val, size, volt, diel, tol].filter(Boolean).join(', ');+  }+  if (/resistor/i.test(t)) {+    const val = pick(/\d+(?:\.\d+)?\s*[kKMRGΩ]\d*(?:\s*(?:ohm|Ω))?\b/i, txt) || pick(/\b\d+(?:\.\d+)?[kKMR]\b/, `${c.name} ${c.mpn}`);+    const pwr = pick(/\d+\/\d+\s*W\b|\d+(?:\.\d+)?\s*W\b/i, txt) || (size && POWER_BY_SIZE[size]) || null;+    return [val, size, pwr, tol].filter(Boolean).join(', ');+  }+  if (/inductor|ferrite|choke/i.test(t)) {+    const val = pick(/\d+(?:\.\d+)?\s*[pnuµμm]?H\b/i, txt);+    const cur = pick(/\d+(?:\.\d+)?\s*m?A\b/i, txt);+    return [val, size, cur].filter(Boolean).join(', ') || ['Inductor', size].filter(Boolean).join(', ');+  }+  if (/\bled\b/i.test(t)) {+    const color = pick(/\b(?:red|green|blue|white|yellow|amber|orange|RGB)\b/i, txt);+    const volt = pick(/\d+(?:\.\d+)?\s*V\b/i, txt);+    return [color, size, volt].filter(Boolean).join(', ') || ['LED', size].filter(Boolean).join(', ');+  }+  if (/diode|rectifier|zener|schottky/i.test(t)) {+    const volt = pick(/\d+(?:\.\d+)?\s*V\b/i, txt);+    const cur = pick(/\d+(?:\.\d+)?\s*m?A\b/i, txt);+    return ['Diode', size, volt, cur].filter(Boolean).join(', ');+  }+  // non-passives: comma-separated key specs+  const pkgShort = (c.package || '').split(/[ ,(]/)[0] || c.package;+  const parts = [c.manufacturer, pkgShort, c.pinCount ? `${c.pinCount}-pin` : null, c.subcategory].filter(Boolean);+  if (parts.length >= 2) return parts.join(', ');+  if (brief) return brief.split(/\s+[—–-]\s+/)[0].trim().slice(0, 120);+  return parts.join(', ') || c.type;+}++// ---- per-EDA, per-file-kind presence model -----------------------------------+// Each component tracks, for KiCad/Altium/Fusion independently, whether a+// symbol / footprint / 3D model is available (and whether it came from the wiki+// or a user upload). Each EDA box colours on its own: red=0, yellow=1-2, green=3.+const EDAS = ['kicad', 'altium', 'fusion'];+const KINDS = ['symbol', 'footprint', 'model3d'];+function blankKind() { return { present: false, source: null }; }+function blankEda() { return { symbol: blankKind(), footprint: blankKind(), model3d: blankKind() }; }+function freshEda() { return { kicad: blankEda(), altium: blankEda(), fusion: blankEda() }; }++// apply one classified file to the eda model (uploads override wiki)+function applyFile(c, eda, kind, source) {+  const set = (e, k) => { const cur = c.eda[e][k]; if (cur.source !== 'upload' || source === 'upload') c.eda[e][k] = { present: true, source }; };+  if (kind === 'library') { set(eda, 'symbol'); set(eda, 'footprint'); }      // .IntLib / .lbr = symbol + footprint+  else if (kind === 'model3d') { for (const e of EDAS) set(e, 'model3d'); }   // a STEP/GLB serves any EDA+  else if (EDAS.includes(eda)) set(eda, kind);+}++// rebuild c.eda from the last wiki check (c._wiki) + current uploads+function recomputeEda(c) {+  c.eda = freshEda();+  const w = c._wiki || { files: [], has3d: false };+  for (const name of w.files) { const hit = classifyFile(name); if (hit) applyFile(c, hit.eda, hit.kind, 'wiki'); }+  if (w.has3d) for (const e of EDAS) if (!c.eda[e].model3d.present) c.eda[e].model3d = { present: true, source: 'wiki' };+  for (const u of (c.uploads || [])) { const hit = classifyFile(u.name); if (hit) applyFile(c, hit.eda, hit.kind, 'upload'); }+  c.wikiFiles = { '3d': EDAS.some(e => c.eda[e].model3d.present && c.eda[e].model3d.source === 'wiki') };+}++// Run the full wiki check for one component and merge results into its record.+async function runCheck(c) {+  const cls = await classify(c.mpn);+  // a descriptive request ("100nF 0402 capacitor") resolves to a real MPN page — adopt the MPN+  if (cls.resolvedMpn && cls.resolvedMpn !== c.mpn) {+    // if that MPN is already in the list, this entry is a duplicate — drop it+    const other = DB.components.find(x => x.id !== c.id && (x.slug === norm(cls.resolvedMpn) || norm(x.mpn) === norm(cls.resolvedMpn)));+    if (other) { DB.components = DB.components.filter(x => x.id !== c.id); return c; }+    const old = c.mpn;+    c.mpn = cls.resolvedMpn; c.name = cls.resolvedMpn;+    if (c.names) {+      if (c.names.symbol === old) c.names.symbol = cls.resolvedMpn;+      if (c.names.footprint === old && !c.footprintLocked) c.names.footprint = cls.resolvedMpn;+    }+  }+  c.slug = cls.exact ? cls.exact.slug : norm(c.mpn);+  c.passive = cls.passive;+  c.wiki = { status: cls.status, slug: null, variants: cls.variants || [], checkedAt: new Date().toISOString() };++  let meta = null, brief = '';+  c._wiki = { files: [], has3d: false };+  const resolvedSlug = cls.exact ? cls.exact.slug : null;+  if (resolvedSlug) {+    c.wiki.slug = resolvedSlug;+    const pageMeta = await wikiJSON(`/api/v1/pages/${resolvedSlug}`);+    meta = pageMeta && pageMeta.metadata;+    brief = (pageMeta && pageMeta.page && pageMeta.page.brief) || '';+    c.wiki.owner = (pageMeta && pageMeta.page && pageMeta.page.owner) || null;+    c.wiki.url = c.wiki.owner ? `${WIKI}/${c.wiki.owner}/${resolvedSlug}` : `${WIKI}/${resolvedSlug}`;+    const filesResp = await wikiJSON(`/api/v1/pages/${resolvedSlug}/files`);+    const files = (filesResp && filesResp.files) || [];+    c._wiki.files = files.map(f => f.name);+    if (meta && meta.has_3d_model) c._wiki.has3d = true;+  }+  c.type = deriveType(meta, c.passive, c.mpn);+  c.manufacturer = (meta && meta.manufacturer) || c.manufacturer || null;+  c.package = (meta && meta.package) || c.package || null;+  c.subcategory = (meta && meta.subcategory) || null;+  c.pinCount = (meta && meta.pin_count) || null;+  c.jlcPart = (meta && meta.jlcpcb_part) || c.jlcPart || null;   // keep a manually-set PN through re-checks+  c.jlc = !!c.jlcPart;+  const dsPath = meta && meta.datasheet_path;+  c.datasheetUrl = dsPath ? `${WIKI}/blob/component/${resolvedSlug}/${dsPath}` : (c.datasheetUrl || null);+  c.description = buildSymbolDescription(c, brief);  // goes in the symbol's Description field+  recomputeEda(c);+  // once we know the manufacturer, refine the destination (esp. the 3D manufacturer folder)+  if (DB.probe && DB.probe.libraries && Object.keys(DB.probe.libraries).length) applyDest(c);+  return c;+}++// ---------------------------------------------------------------- library probe+// The user's libraries live on THEIR machine, reached via the adom-desktop bridge.+// The server shells to `adom-desktop` to read the user's KiCad/Altium/Fusion+// library locations, then fills each component's symbol/footprint/3D destination.+const HOME = os.homedir();+const EDA_EXTS = { kicad: /\.(kicad_sym|kicad_mod|pretty)$/i, altium: /\.(schlib|pcblib|intlib)$/i, fusion: /\.lbr$/i };++function nameDest(p) { if (!p) return null; const name = p.split(/[\\/]/).filter(Boolean).pop() || p; return { name, path: p }; }+const dirOf = p => (p || '').replace(/[\\/][^\\/]*$/, '');++// Run a discovery shell command on the user's machine via adom-desktop.+function runDesktopShell(command, timeoutSeconds = 90) {+  return new Promise((resolve) => {+    execFile('adom-desktop', ['shell_execute', JSON.stringify({ command, timeoutSeconds })],+      { timeout: (timeoutSeconds + 15) * 1000, maxBuffer: 32 << 20, env: process.env }, (err, stdout) => {+        let out = '';+        try { out = JSON.parse(stdout || '{}').stdout || ''; } catch { out = stdout || ''; }+        resolve(out);+      });+  });+}++// Probe the USER's machine (via adom-desktop) for their own EDA libraries.+// KiCad: enumerate the actual .kicad_sym FILES, .pretty FOLDERS, and the 3D+// manufacturer SUBFOLDERS — NOT the KiCad stock/default libraries.+async function probeUserLibraries(edas) {+  const sel = (edas && edas.length) ? edas : ['kicad', 'altium', 'fusion'];+  const cmd = [+    'D="$HOME/Documents"',+    'find "$D" -iname "*.kicad_sym" 2>/dev/null | grep -vi backup | head -200 | sed "s|^|KSYM=|"',+    'find "$D" -type d -iname "*.pretty" 2>/dev/null | grep -vi backup | head -200 | sed "s|^|KFP=|"',+    'for d in "$D/KiCAD/3D" "$D/KiCad/3D" "$D/KiCAD/3d" "$D/KiCad/3d"; do [ -d "$d" ] && echo "K3D=$d"; done',+    'for d in "$D/KiCAD/3D" "$D/KiCad/3D"; do [ -d "$d" ] && find "$d" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | head -300 | sed "s|^|K3DSUB=|"; done',+    'find "$D" -iname "*.lbr" 2>/dev/null | grep -vi backup | head -80 | sed "s|^|FUS=|"',+    'find "$D" \\( -iname "*.SchLib" -o -iname "*.PcbLib" -o -iname "*.IntLib" \\) 2>/dev/null | head -80 | sed "s|^|ALT=|"',+    // KiCad STOCK/default footprint libraries (passives & basics) — OS-specific share paths+    'for s in /usr/share/kicad /usr/local/share/kicad /snap/kicad/current/usr/share/kicad "/var/lib/flatpak/app/org.kicad.KiCad/current/active/files/share/kicad" "$HOME/.local/share/flatpak/app/org.kicad.KiCad/current/active/files/share/kicad" "/Applications/KiCad/KiCad.app/Contents/SharedSupport"; do for lib in Capacitor_SMD Resistor_SMD Inductor_SMD LED_SMD Capacitor_THT Resistor_THT; do dd="$s/footprints/$lib.pretty"; if [ -d "$dd" ]; then echo "KDEFLIB=$lib|$dd"; ls "$dd" 2>/dev/null | grep -i "\\.kicad_mod$" | head -500 | sed "s|^|KDEFFP=$lib/|"; fi; done; done',+  ].join('; ');+  const out = await runDesktopShell(cmd);+  const lines = out.split('\n').map(l => l.trim());+  const grab = pfx => lines.filter(l => l.startsWith(pfx)).map(l => l.slice(pfx.length)).filter(Boolean);+  const base = p => (p || '').split('/').pop();+  const ksym = grab('KSYM='), kfp = grab('KFP='), k3d = grab('K3D='), k3dsub = grab('K3DSUB='), fus = grab('FUS='), alt = grab('ALT=');+  const kdeflib = grab('KDEFLIB='), kdeffp = grab('KDEFFP=');+  const defaultFootprints = {};+  for (const e of kdeflib) { const i = e.indexOf('|'); if (i < 0) continue; const lib = e.slice(0, i), pth = e.slice(i + 1); if (lib && pth && !defaultFootprints[lib]) defaultFootprints[lib] = { path: pth, footprints: [] }; }+  for (const e of kdeffp) { const i = e.indexOf('/'); if (i < 0) continue; const lib = e.slice(0, i), fp = e.slice(i + 1).replace(/\.kicad_mod$/i, ''); if (defaultFootprints[lib]) defaultFootprints[lib].footprints.push(fp); }++  const libraries = {};+  if (sel.includes('kicad') && (ksym.length || kfp.length || Object.keys(defaultFootprints).length)) {+    libraries.kicad = {+      symbolsDir: dirOf(ksym[0] || ''),+      footprintsDir: kfp.length ? dirOf(kfp[0]) : '',+      models3dDir: k3d[0] || '',+      symbolFiles: ksym,                 // full paths to each .kicad_sym+      footprintDirs: kfp,                // full paths to each .pretty+      mfrFolders: k3dsub.map(p => ({ name: base(p), path: p })), // 3D manufacturer subfolders+      defaultFootprints,                 // KiCad stock footprint libs (Capacitor_SMD, etc.)+    };+  }+  if (sel.includes('fusion') && fus.length) libraries.fusion = { root: dirOf(fus[0]), files: fus.map(base) };+  if (sel.includes('altium') && alt.length) libraries.altium = { root: dirOf(alt[0]), files: alt.map(base) };+  return { libraries };+}++// ---- destination picking: JLC-category symbol lib, manufacturer footprint lib, manufacturer 3D folder+const baseNoExt = p => (p || '').split('/').pop().replace(/\.(kicad_sym|pretty)$/i, '');+const nrm = s => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');+// manufacturer name -> tokens/abbreviations that may appear in folder/library names+const MFR_ALIASES = {+  'texas instruments': ['ti'], 'stmicroelectronics': ['st', 'stm'], 'nordic semiconductor': ['nordic', 'nrf'],+  'analog devices': ['adi', 'analog', 'maxim'], 'on semiconductor': ['onsemi', 'on'], 'onsemi': ['on'],+  'nxp semiconductors': ['nxp'], 'microchip technology': ['microchip', 'atmel'], 'würth elektronik': ['wurth', 'würth'],+  'wurth elektronik': ['wurth'], 'vishay': ['vishay'], 'murata': ['murata'], 'tdk': ['tdk'], 'bosch': ['bosch'], 'espressif': ['espressif', 'esp'],+};+const MFR_STOP = new Set(['technology', 'technologies', 'corporation', 'corp', 'semiconductor', 'semiconductors', 'electronics', 'electronic', 'inc', 'ltd', 'co', 'company', 'gmbh', 'components', 'component', 'international', 'industries', 'microelectronics', 'group', 'limited', 'systems']);+function mfrTokens(mfr) {+  if (!mfr) return [];+  const lower = mfr.toLowerCase();+  const toks = [nrm(mfr)];+  // expand aliases only when the FULL manufacturer name matches the alias key (no substring contamination)+  for (const [k, v] of Object.entries(MFR_ALIASES)) if (lower.includes(k)) toks.push(...v.map(nrm));+  toks.push(...lower.split(/[^a-z0-9]+/).filter(w => w.length >= 3 && !MFR_STOP.has(w)).map(nrm));+  return [...new Set(toks.filter(Boolean))];+}+// map a component to its JLC category-library name (their convention: JLC_<Cat>_BLT)+const JLC_CATEGORY = [+  [/capacitor|\bmlcc\b/i, 'Capacitors'], [/resistor/i, 'Resistors'], [/inductor|ferrite|\bchoke\b/i, 'Inductors'],+  [/\bled\b/i, 'LEDs'], [/diode|rectifier|zener|\btvs\b|schottky/i, 'Diodes'], [/crystal|oscillator|resonator/i, 'Oscillators'],+  [/\bfet\b|mosfet|transistor|\bbjt\b/i, 'FETs'], [/connector|header|socket|receptacle/i, 'Connectors'],+  [/\bflash\b|eeprom|\bsram\b|\bdram\b|\bram\b|memory/i, 'Flash'], [/regulator|\bldo\b|dc-dc|buck|boost|\bpmic\b|power management/i, 'Power'],+  [/microcontroller|\bmcu\b|\bsoc\b|processor|stm32|nrf\d|esp32|esp8266|atmega|attiny|rp2040|rp2350|\bpic\d|samd\d|msp430/i, 'MCUs'],+];+function jlcCategory(c) {+  const s = `${c.type || ''} ${c.subcategory || ''} ${c.mpn || ''}`;+  for (const [re, cat] of JLC_CATEGORY) if (re.test(s)) return cat;+  return 'Misc';+}+function matchByMfr(items, getName, mfr) { // items -> first whose name matches a manufacturer token+  const toks = mfrTokens(mfr);+  if (!toks.length) return null;+  for (const it of items) {+    const raw = getName(it) || '';+    const segs = raw.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).map(nrm); // e.g. "TI_BLT" -> ["ti","blt"]+    const full = nrm(raw);+    for (const t of toks) {+      if (segs.includes(t)) return it;                                  // exact segment (handles TI_BLT -> "ti")+      if (t.length >= 4 && full.length >= 4 && (full.includes(t) || t.includes(full))) return it; // fuzzy: both sides long+    }+  }+  return null;+}+// imperial -> metric size code; passive type -> (symbol prefix, KiCad stock lib)+const IMP_TO_METRIC = { '0201': '0603', '0402': '1005', '0603': '1608', '0805': '2012', '1206': '3216', '1210': '3225', '1812': '4532', '2010': '5025', '2512': '6332' };+const PASSIVE_FP = [[/capacitor|\bmlcc\b/i, 'C', 'Capacitor_SMD'], [/resistor/i, 'R', 'Resistor_SMD'], [/inductor|ferrite/i, 'L', 'Inductor_SMD'], [/\bled\b/i, 'LED', 'LED_SMD']];+// If this is a passive with a known size, return its KiCad STOCK footprint (non-editable).+function defaultFootprint(c, defLibs) {+  if (!defLibs) return null;+  const s = `${c.type || ''} ${c.subcategory || ''} ${c.mpn || ''} ${c.name || ''}`;+  const hit = PASSIVE_FP.find(([re]) => re.test(s)); if (!hit) return null;+  const [, prefix, libName] = hit;+  const sizem = `${c.name || ''} ${c.mpn || ''} ${c.package || ''}`.match(/\b(0201|0402|0603|0805|1206|1210|1812|2010|2512)\b/);+  if (!sizem) return null;+  const imp = sizem[1], met = IMP_TO_METRIC[imp]; if (!met) return null;+  const lib = defLibs[libName]; if (!lib) return null;+  const want = `${prefix}_${imp}_${met}Metric`;+  const fpName = (lib.footprints || []).includes(want) ? want : (lib.footprints || []).find(n => n.startsWith(want));+  if (!fpName) return null;+  return { name: `${libName}.pretty`, path: lib.path, source: 'kicad-default', exists: true, fpName };+}+function computeDest(c) {+  const lib = DB.probe && DB.probe.libraries;+  const k = lib && lib.kicad;+  if (k) {+    // SYMBOL -> JLC category library (.kicad_sym); create if missing+    const cat = jlcCategory(c);+    const jlcBase = `JLC_${cat}_BLT`;+    const symHit = (k.symbolFiles || []).find(p => baseNoExt(p).toLowerCase() === jlcBase.toLowerCase())+      || (k.symbolFiles || []).find(p => /jlc/i.test(p) && nrm(p).includes(nrm(cat).replace(/s$/, '')));+    const symbol = symHit ? { ...nameDest(symHit), exists: true }+      : { name: `${jlcBase}.kicad_sym`, path: `${k.symbolsDir}/${jlcBase}.kicad_sym`, exists: false };++    // FOOTPRINT -> prefer a footprint that's ON THE WIKI (file into the manufacturer library);+    // only fall back to the KiCad STOCK library for passives that are NOT on the wiki.+    const mfr = c.manufacturer || 'Misc';+    const hasWikiFp = c.eda && c.eda.kicad && c.eda.kicad.footprint && c.eda.kicad.footprint.present && c.eda.kicad.footprint.source === 'wiki';+    let footprint = hasWikiFp ? null : defaultFootprint(c, k.defaultFootprints);+    if (!footprint) {+      const fpHit = matchByMfr(k.footprintDirs || [], baseNoExt, c.manufacturer);+      footprint = fpHit ? { ...nameDest(fpHit), exists: true, source: 'manufacturer' }+        : { name: `${mfr}.pretty`, path: `${k.footprintsDir}/${mfr}.pretty`, exists: false, source: 'manufacturer' };+    }++    // 3D -> manufacturer subfolder (deepest); create if missing+    let model3d = null;+    if (k.models3dDir) {+      const mfHit = matchByMfr(k.mfrFolders || [], it => it.name, c.manufacturer);+      model3d = mfHit ? { ...nameDest(mfHit.path), exists: true }+        : { name: mfr, path: `${k.models3dDir}/${mfr}`, exists: false };+    }+    return { symbol, footprint, model3d };+  }+  const r = (lib && lib.fusion && lib.fusion.root) || (lib && lib.altium && lib.altium.root);+  return r ? { symbol: { ...nameDest(r), exists: true }, footprint: { ...nameDest(r), exists: true }, model3d: { ...nameDest(r), exists: true } } : { symbol: null, footprint: null, model3d: null };+}+// compute + apply the destination, locking the footprint name for KiCad stock footprints+function applyDest(c) {+  const wasLocked = c.footprintLocked;+  const d = computeDest(c);+  c.dest = d;+  c.names = c.names || {};+  if (d.footprint && d.footprint.source === 'kicad-default' && d.footprint.fpName) {+    c.names.footprint = d.footprint.fpName; c.footprintLocked = true;       // stock footprint -> fixed name+  } else {+    c.footprintLocked = false;+    if (wasLocked) c.names.footprint = c.mpn;                               // came off a stock lock -> default to MPN+  }+  return c;+}++// ---- create missing KiCad libraries on the user's machine (via adom-desktop)+const shq = s => "'" + String(s).replace(/'/g, "'\\''") + "'";+function regSnippet(table, name, uri) { // append a lib entry to a KiCad lib-table (idempotent, safe)+  const entry = `  (lib (name "${name}")(type "KiCad")(uri "${uri}")(options "")(descr ""))`;+  return `T="${table}"; [ -f "$T" ] && ! grep -qF '(name "${name}")' "$T" && { tmp=$(mktemp); head -n -1 "$T" > "$tmp"; printf '%s\\n)\\n' '${entry}' >> "$tmp"; mv "$tmp" "$T"; echo 'REG=${name}'; }`;+}+async function createMissingLibraries(components) {+  const symLibs = new Map(), fpLibs = new Map(), dirs = new Set();+  for (const c of components) {+    const d = c.dest || {};+    if (d.symbol && d.symbol.exists === false) symLibs.set(d.symbol.path, baseNoExt(d.symbol.path));+    if (d.footprint && d.footprint.exists === false) { fpLibs.set(d.footprint.path, baseNoExt(d.footprint.path)); dirs.add(d.footprint.path); }+    if (d.model3d && d.model3d.exists === false) dirs.add(d.model3d.path);+  }+  if (!symLibs.size && !fpLibs.size && !dirs.size) return { created: [] };+  const sh = ['CFG=$(ls -d "$HOME/.config/kicad"/*/ 2>/dev/null | head -1)'];+  for (const dir of dirs) sh.push(`mkdir -p ${shq(dir)} && echo "DIR=${dir}"`);+  for (const [pth, name] of symLibs) {+    sh.push(`[ -f ${shq(pth)} ] || { printf '(kicad_symbol_lib (version 20211014) (generator chip-fetcher-lite)\\n)\\n' > ${shq(pth)}; echo "SYM=${pth}"; }`);+    sh.push(regSnippet('${CFG}sym-lib-table', name, pth));+  }+  for (const [pth, name] of fpLibs) sh.push(regSnippet('${CFG}fp-lib-table', name, pth));+  const out = await runDesktopShell(sh.join('\n'), 90);+  return { created: out.split('\n').map(l => l.trim()).filter(l => /^(DIR|SYM|REG)=/.test(l)), raw: out };+}++// ---------------------------------------------------------------- file the actual CAD into KiCad+function adomDesktopJSON(verb, argsObj, timeout = 180000) {+  return new Promise((resolve) => {+    execFile('adom-desktop', [verb, JSON.stringify(argsObj)], { timeout, maxBuffer: 64 << 20, env: process.env }, (err, stdout) => {+      let j = null; try { j = JSON.parse(stdout || '{}'); } catch {}+      resolve({ err, j, stdout: stdout || '' });+    });+  });+}+async function wikiBlob(slug, filename) {+  try {+    const r = await fetch(`${WIKI}/blob/component/${slug}/${encodeURIComponent(filename)}`, { signal: AbortSignal.timeout(45000) });+    if (!r.ok) return null;+    return Buffer.from(await r.arrayBuffer());+  } catch { return null; }+}+const escapeRe = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');+// get a component's symbol/footprint/3D source bytes (uploaded file wins, else wiki download)+async function getFileContent(c, kind) {+  const up = (c.uploads || []).find(u => u.kind === kind || (kind !== 'model3d' && u.kind === 'library'));+  if (up) { try { return { buf: fs.readFileSync(up.path), srcName: up.name }; } catch {} }+  const slug = c.wiki && c.wiki.slug; if (!slug) return null;+  const exts = kind === 'symbol' ? ['.kicad_sym'] : kind === 'footprint' ? ['.kicad_mod'] : ['.step', '.stp', '.wrl', '.glb'];+  for (const e of exts) { // honour ext priority (e.g. STEP before GLB for KiCad)+    const fname = (c._wiki && c._wiki.files || []).find(f => f.toLowerCase().endsWith(e));+    if (fname) { const buf = await wikiBlob(slug, fname); if (buf) return { buf, srcName: fname }; }+  }+  return null;+}+// does this component have ANY CAD to file (uploads or wiki files)? stubs (README only) don't.+function hasAnyCad(c) {+  if ((c.uploads || []).length) return true;+  const files = (c._wiki && c._wiki.files) || [];+  return files.some(f => /\.(kicad_sym|kicad_mod|step|stp|wrl|glb|lbr)$/i.test(f));+}+// send_files ignores `dest` on Linux (lands in ~/Downloads), so land then move into place+async function sendFiles(filePaths, destDir) {+  const r = await adomDesktopJSON('send_files', { filePaths });+  const landed = (r.j && r.j.destinationPaths) || [];+  if (!landed.length) return { j: { success: false, error: (r.j && r.j.error) || 'send_files returned no paths' } };+  const mvs = landed.map(p => `mv -f ${shq(p)} ${shq(destDir)}/`).join('; ');+  const out = await runDesktopShell(`mkdir -p ${shq(destDir)}; ${mvs}; echo MOVED`, 60);+  return { j: { success: /MOVED/.test(out), landed, destDir } };+}++// --- KiCad .kicad_sym s-expression helpers (extract one symbol, rename, set props, merge)+function extractSymbolBlock(libText) {+  const i = libText.indexOf('(symbol "'); if (i < 0) return null;+  let depth = 0, j = i;+  for (; j < libText.length; j++) { const ch = libText[j]; if (ch === '(') depth++; else if (ch === ')') { depth--; if (depth === 0) { j++; break; } } }+  return libText.slice(i, j);+}+function symbolNameOf(block) { const m = block.match(/\(symbol\s+"([^"]+)"/); return m ? m[1] : null; }+function renameSymbol(block, oldN, newN) {+  return block+    .replace(new RegExp('\\(symbol\\s+"' + escapeRe(oldN) + '"'), `(symbol "${newN}"`)+    .replace(new RegExp('\\(symbol\\s+"' + escapeRe(oldN) + '_', 'g'), `(symbol "${newN}_`);+}+function setSymProp(block, name, value) {+  const v = String(value).replace(/"/g, '\\"');+  const re = new RegExp('\\(property\\s+"' + name + '"\\s+"(?:[^"\\\\]|\\\\.)*"');+  if (re.test(block)) return block.replace(re, `(property "${name}" "${v}"`);+  return block.replace(/(\(symbol\s+"[^"]+"\s*)/, `$1\n    (property "${name}" "${v}" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))`);+}+// keep only standard symbol properties; the specs go in Description, not separate fields+function stripExtraProps(block) {+  const keep = new Set(['Reference', 'Value', 'Footprint', 'Datasheet', 'Description']);+  let out = '', i = 0;+  while (i < block.length) {+    if (block.startsWith('(property "', i)) {+      const nm = (block.slice(i).match(/^\(property\s+"([^"]+)"/) || [])[1] || '';+      let depth = 0, j = i;+      for (; j < block.length; j++) { const ch = block[j]; if (ch === '(') depth++; else if (ch === ')') { depth--; if (depth === 0) { j++; break; } } }+      if (keep.has(nm)) out += block.slice(i, j);+      else { while (j < block.length && /[ \t\r\n]/.test(block[j])) j++; } // drop block + trailing ws+      i = j;+    } else { out += block[i]; i++; }+  }+  return out;+}+// insert the symbol; if one with this name exists, REPLACE it (so re-sending updates it)+function mergeSymbol(destText, block, newN) {+  const startRe = new RegExp('\\(symbol\\s+"' + escapeRe(newN) + '"');+  const m = startRe.exec(destText);+  if (m) {+    let depth = 0, j = m.index;+    for (; j < destText.length; j++) { const ch = destText[j]; if (ch === '(') depth++; else if (ch === ')') { depth--; if (depth === 0) { j++; break; } } }+    destText = destText.slice(0, m.index) + destText.slice(j);+  }+  const last = destText.lastIndexOf(')');+  if (last < 0) return { text: destText, skipped: false, error: 'bad lib' };+  const body = '  ' + block.replace(/\n/g, '\n  ') + '\n';+  return { text: destText.slice(0, last) + body + destText.slice(last), skipped: false };+}++async function fileFootprint(c, tmpdir) {+  const d = c.dest && c.dest.footprint; if (!d || !d.path) return 'no dest';+  if (d.source === 'kicad-default') return 'stock (skipped)';+  const got = await getFileContent(c, 'footprint'); if (!got) return 'no source file';+  const name = ((c.names && c.names.footprint) || c.mpn) + '.kicad_mod';+  const tmp = path.join(tmpdir, name); fs.writeFileSync(tmp, got.buf);+  const r = await sendFiles([tmp], d.path);+  return (r.j && r.j.success === false) ? ('send failed: ' + (r.j.error || '')) : 'ok';+}+async function file3D(c, tmpdir) {+  const d = c.dest && c.dest.model3d; if (!d || !d.path) return 'no dest';+  const got = await getFileContent(c, 'model3d'); if (!got) return 'no source file';+  const ext = (got.srcName.match(/\.[a-z0-9]+$/i) || ['.step'])[0];+  const name = ((c.names && c.names.symbol) || c.mpn) + ext;+  const tmp = path.join(tmpdir, name); fs.writeFileSync(tmp, got.buf);+  const r = await sendFiles([tmp], d.path);+  return (r.j && r.j.success === false) ? ('send failed: ' + (r.j.error || '')) : 'ok';+}+async function fileSymbol(c, tmpdir) {+  const d = c.dest && c.dest.symbol; if (!d || !d.path) return 'no dest';+  const got = await getFileContent(c, 'symbol'); if (!got) return 'no source file';+  const block0 = extractSymbolBlock(got.buf.toString('utf8')); if (!block0) return 'no symbol block';+  const oldN = symbolNameOf(block0) || c.mpn;+  const newN = (c.names && c.names.symbol) || c.mpn;+  let block = renameSymbol(block0, oldN, newN);+  block = stripExtraProps(block);                       // drop the wiki symbol's many custom spec fields+  if (c.description) block = setSymProp(block, 'Description', c.description);+  const fpd = c.dest.footprint;+  if (fpd) {+    const nick = baseNoExt(fpd.name);+    const fpName = (fpd.source === 'kicad-default' && fpd.fpName) ? fpd.fpName : ((c.names && c.names.footprint) || c.mpn);+    block = setSymProp(block, 'Footprint', `${nick}:${fpName}`);+  }+  if (c.datasheetUrl) block = setSymProp(block, 'Datasheet', c.datasheetUrl);+  if (c.jlcPart) block = setSymProp(block, 'LCSC', c.jlcPart);   // LCSC/JLCPCB part number field+  const r = await adomDesktopJSON('read_file', { path: d.path, encoding: 'utf8' });+  const destText = (r.j && r.j.success && r.j.content) || '(kicad_symbol_lib (version 20211014) (generator chip-fetcher-lite)\n)\n';+  const { text, skipped } = mergeSymbol(destText, block, newN);+  if (skipped) return 'already present';+  await runDesktopShell(`cp ${shq(d.path)} ${shq(d.path + '.bak')} 2>/dev/null || true`, 20);+  const tmp = path.join(tmpdir, baseNoExt(d.name) + '.kicad_sym'); fs.writeFileSync(tmp, text);+  const sr = await sendFiles([tmp], dirOf(d.path));+  return (sr.j && sr.j.success === false) ? ('send failed: ' + (sr.j.error || '')) : 'ok';+}+async function fileToLibrary(components) {+  DB.send = { status: 'running', total: components.length, done: 0, current: 'creating libraries…', errors: [], startedAt: new Date().toISOString() };+  save();+  try { await createMissingLibraries(components); } catch (e) { DB.send.errors.push('create libs: ' + (e.message || e)); }+  const tmpdir = path.join(DATA_DIR, 'send'); fs.mkdirSync(tmpdir, { recursive: true });+  for (const c of components) {+    if (!hasAnyCad(c)) { DB.send.errors.push(`${c.mpn}: skipped — no CAD on the wiki`); DB.send.done++; save(); continue; }+    for (const [kind, fn] of [['footprint', fileFootprint], ['3D model', file3D], ['symbol', fileSymbol]]) {+      DB.send.current = `${c.mpn}: ${kind}`; save();+      try { const r = await fn(c, tmpdir); if (r && !/^(ok|stock|already)/.test(r)) DB.send.errors.push(`${c.mpn} ${kind}: ${r}`); }+      catch (e) { DB.send.errors.push(`${c.mpn} ${kind}: ${e.message || e}`); }+    }+    // mark destinations as now existing+    for (const kd of ['symbol', 'footprint', 'model3d']) if (c.dest && c.dest[kd]) c.dest[kd].exists = true;+    DB.send.done++; save();+  }+  DB.send.status = 'done'; DB.send.current = `Filed ${DB.send.done} component(s)` + (DB.send.errors.length ? ` (${DB.send.errors.length} issue(s))` : '');+  DB.send.finishedAt = new Date().toISOString(); save();+}++// ---------------------------------------------------------------- http helpers+function sendJSON(res, code, obj) {+  const b = Buffer.from(JSON.stringify(obj));+  res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Length': b.length });+  res.end(b);+}+function readBody(req, limit = 64 << 20) {+  return new Promise((resolve, reject) => {+    const chunks = []; let n = 0;+    req.on('data', d => { n += d.length; if (n > limit) { reject(new Error('too large')); req.destroy(); } else chunks.push(d); });+    req.on('end', () => resolve(Buffer.concat(chunks)));+    req.on('error', reject);+  });+}+async function readJSON(req) { const b = await readBody(req); return b.length ? JSON.parse(b.toString('utf8')) : {}; }++const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png' };+function serveStatic(res, urlPath) {+  let rel = urlPath === '/' ? '/index.html' : urlPath;+  const fp = path.join(PUBLIC_DIR, path.normalize(rel).replace(/^(\.\.[/\\])+/, ''));+  if (!fp.startsWith(PUBLIC_DIR) || !fs.existsSync(fp) || !fs.statSync(fp).isFile()) { res.writeHead(404); return res.end('not found'); }+  const body = fs.readFileSync(fp);+  res.writeHead(200, { 'Content-Type': MIME[path.extname(fp)] || 'application/octet-stream' });+  res.end(body);+}++// ---------------------------------------------------------------- routes+const server = http.createServer(async (req, res) => {+  const u = new URL(req.url, 'http://localhost');+  const p = u.pathname;+  try {+    if (!p.startsWith('/api/')) return serveStatic(res, p);++    // ---- state+    if (p === '/api/state' && req.method === 'GET') return sendJSON(res, 200, DB);++    // ---- add component+    if (p === '/api/components' && req.method === 'POST') {+      const { name } = await readJSON(req);+      if (!name || !name.trim()) return sendJSON(res, 400, { error: 'name required' });+      // don't add duplicates — match an existing component by normalized name or MPN+      const want = norm(name);+      const dup = DB.components.find(x => norm(x.name) === want || norm(x.mpn) === want);+      if (dup) return sendJSON(res, 200, { ...dup, _duplicate: true });+      const c = {+        id: uid(), name: name.trim(), mpn: name.trim(), slug: norm(name),+        type: 'Checking…', passive: false, selected: false,+        wiki: { status: 'checking', slug: null, variants: [], checkedAt: null },+        eda: freshEda(),+        _wiki: { files: [], has3d: false },+        wikiFiles: { '3d': false },+        uploads: [],+        dest: { symbol: null, footprint: null, model3d: null },+        names: { symbol: name.trim(), footprint: name.trim() }, // editable library names; default to the MPN+        manufacturer: null, subcategory: null, jlc: false, jlcPart: null, description: '',+        addedAt: new Date().toISOString(),+      };+      // if libraries are already probed, file new components into them by default (refined after wiki check)+      if (DB.probe && DB.probe.libraries && Object.keys(DB.probe.libraries).length) applyDest(c);+      DB.components.unshift(c); save();+      // kick off check in the background+      enqueueCheck(c.id);+      return sendJSON(res, 200, c);+    }++    // ---- per-component routes+    let m;+    if ((m = p.match(/^\/api\/components\/([0-9a-f]+)(\/[a-z-]+)?$/))) {+      const c = find(m[1]);+      if (!c) return sendJSON(res, 404, { error: 'not found' });+      const action = m[2];++      if (!action && req.method === 'DELETE') {+        DB.components = DB.components.filter(x => x.id !== c.id);+        fs.rmSync(path.join(STAGING_DIR, c.id), { recursive: true, force: true });+        save(); return sendJSON(res, 200, { ok: true });+      }+      if (action === '/check' && req.method === 'POST') {+        c.wiki.status = 'checking'; save();+        await runCheck(c); save(); return sendJSON(res, 200, c);+      }+      if (action === '/select' && req.method === 'POST') {+        const { on } = await readJSON(req); c.selected = !!on; save(); return sendJSON(res, 200, c);+      }+      if (action === '/jlc' && req.method === 'POST') {+        const { jlcPart } = await readJSON(req);+        c.jlcPart = (jlcPart || '').trim() || null;+        c.jlc = !!c.jlcPart;+        save(); return sendJSON(res, 200, c);+      }+      if (action === '/names' && req.method === 'POST') {+        const { symbol, footprint } = await readJSON(req);+        if (!c.names) c.names = {};+        if (symbol !== undefined) c.names.symbol = String(symbol).trim();+        if (footprint !== undefined && !c.footprintLocked) c.names.footprint = String(footprint).trim();+        save(); return sendJSON(res, 200, c);+      }+      if (action === '/upload' && req.method === 'POST') {+        const filename = (u.searchParams.get('filename') || 'file.bin').replace(/[/\\]/g, '_');+        const replace = u.searchParams.get('replace') === '1';+        const hit = classifyFile(filename);+        if (!hit) return sendJSON(res, 400, { error: `unrecognized file type: ${filename}` });+        const existing = c.uploads.find(x => x.eda === hit.eda && x.kind === hit.kind);+        if (existing && !replace) return sendJSON(res, 409, { error: 'exists', existing });+        const body = await readBody(req);+        const dir = path.join(STAGING_DIR, c.id); fs.mkdirSync(dir, { recursive: true });+        const fp = path.join(dir, filename); fs.writeFileSync(fp, body);+        if (existing) Object.assign(existing, { name: filename, path: fp, size: body.length, uploadedAt: new Date().toISOString() });+        else c.uploads.push({ eda: hit.eda, kind: hit.kind, name: filename, path: fp, size: body.length, uploadedAt: new Date().toISOString() });+        recomputeEda(c);+        save(); return sendJSON(res, 200, c);+      }+    }++    // ---- selection: all+    if (p === '/api/select-all' && req.method === 'POST') {+      const { on } = await readJSON(req);+      for (const c of DB.components) c.selected = !!on;+      save(); return sendJSON(res, 200, { ok: true });+    }++    // ---- probe: UI requests a probe -> scan the user's machine via adom-desktop+    if (p === '/api/probe-request' && req.method === 'POST') {+      const { edas } = await readJSON(req);+      const sel = (edas && edas.length) ? edas : ['kicad', 'altium', 'fusion'];+      DB.probe = { ...(DB.probe || {}), edas: sel, status: 'requested' }; save();+      // run asynchronously; the UI polls /api/state and shows "probing…" until done+      probeUserLibraries(sel).then(({ libraries }) => {+        DB.probe = { edas: sel, libraries, probedAt: new Date().toISOString(), status: 'done' };+        for (const c of DB.components) applyDest(c);+        save();+      }).catch(e => { DB.probe = { ...DB.probe, status: 'error', error: String(e && e.message || e) }; save(); });+      return sendJSON(res, 200, { status: 'requested', edas: sel });+    }+    // ---- probe: agent posts results+    if (p === '/api/probe' && req.method === 'POST') {+      const { edas, libraries, defaults, destinations } = await readJSON(req);+      DB.probe = {+        edas: edas || DB.probe.edas, libraries: libraries || {},+        defaults: defaults || (DB.probe && DB.probe.defaults) || null, // default symbol/footprint/3d destination+        probedAt: new Date().toISOString(), status: 'done',+      };+      // apply the default destination to every component that doesn't have a specific one+      if (defaults) for (const c of DB.components) {+        if (!c.dest || (!c.dest.symbol && !c.dest.footprint && !c.dest.model3d)) c.dest = JSON.parse(JSON.stringify(defaults));+      }+      // per-component overrides: { <componentId>: { symbol:{name,path}, footprint:{name,path}, model3d:{name,path} } }+      if (destinations) for (const [id, d] of Object.entries(destinations)) { const c = find(id); if (c) c.dest = d; }+      save(); return sendJSON(res, 200, DB.probe);+    }++    // ---- send to library: create any missing libraries on the user's machine, then file in+    if (p === '/api/send-to-library' && req.method === 'POST') {+      const { ids, edas } = await readJSON(req);+      const sel = (ids && ids.length) ? ids : DB.components.filter(c => c.selected).map(c => c.id);+      if (!sel.length) return sendJSON(res, 400, { error: 'no components selected' });+      const comps = sel.map(find).filter(Boolean);+      if (DB.send && DB.send.status === 'running') return sendJSON(res, 409, { error: 'a send is already in progress' });+      // create the libs, download the wiki/uploaded CAD, and file symbol+footprint+3D onto the desktop+      fileToLibrary(comps).catch(e => { DB.send.status = 'error'; DB.send.current = String(e && e.message || e); save(); });+      return sendJSON(res, 200, { status: 'running', total: comps.length });+    }++    // ---- agent job queue+    if (p === '/api/jobs' && req.method === 'GET')+      return sendJSON(res, 200, { jobs: DB.jobs.filter(j => j.status === 'pending') });+    if ((m = p.match(/^\/api\/jobs\/([0-9a-f]+)\/complete$/)) && req.method === 'POST') {+      const job = DB.jobs.find(j => j.id === m[1]);+      if (!job) return sendJSON(res, 404, { error: 'not found' });+      const { result } = await readJSON(req);+      job.status = 'done'; job.result = result || null; job.completedAt = new Date().toISOString();+      save(); return sendJSON(res, 200, job);+    }++    return sendJSON(res, 404, { error: 'no route' });+  } catch (e) {+    return sendJSON(res, 500, { error: String(e && e.message || e) });+  }+});++server.listen(PORT, () => {+  console.log(`chip-fetcher-lite listening on http://localhost:${PORT}`);+  console.log(`  data: ${DATA_FILE}`);+});
uninstall.shadded+5
@@ -0,0 +1,5 @@+#!/bin/bash+set -e+rm -rf "$HOME/.claude/skills/chip-fetcher-lite"+rm -f "$HOME/.local/bin/chip-fetcher-lite"+echo "Uninstalled chip-fetcher-lite"